Replace Array2D with vector<vector<>>

The thought behind Array2D was that it would be more efficient from a
memory allocation perspective to have one fixed buffer that grows than
it would be to have a vector of vectors. In reality, the runtime of
inserting into the middle of one large buffer, and shifting all the
resulting elements, ends up far outweighing any memory allocation
overhead.

In the mesh optimizer, things are added to the end of each sub-vector
one by one. It isn't known up front how many elements each sub-vector
will have. With vertex welding enabled, it is far more likely that a
given vertex will be influenced by a large number of joints. When using
the Array2D to store the influences, and a vertex with a low index has
more than 4 influences, those influences have to be inserted into close
to the front of the big vector, and all the other elements shifted.
Array2D was doing this with a linear shift, shifting the elements by 1
at a time, and not providing any exponential growth on the amount of
elements reserved by each sub-vector. The result is a linear insertion
time.

By contrast, using vector<vector<Influence>> instead gives us back the
amortized constant insertion time. Since the skin influences are just
added to the end of each sub-vector, no shifting of the elements is
necessary. And since the amount of original vertex indices is known up
front, the number of sub-vectors can still be pre-created, so the
sub-vectors themselves never need to shift.

Signed-off-by: Chris Burel <burelc@amazon.com>
monroegm-disable-blank-issue-2
Chris Burel 4 years ago
parent 9ea46bad83
commit 2355581d79

@ -1,264 +0,0 @@
/*
* Copyright (c) Contributors to the Open 3D Engine Project.
* For complete copyright and license terms please see the LICENSE at the root of this distribution.
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*
*/
#pragma once
#include <AzCore/std/containers/vector.h>
namespace MCore
{
/**
* A dynamic 2D array template.
* This would be a better solution than "Array< Array< T > >", because the Array inside Array will perform many allocations,
* while this specialized 2D array will only perform two similar allocations.
* What it does is keep one big array of data elements, and maintain a table that indices inside this big array.
* We advise you to call the Shrink function after you performed a number of operations on the array, to maximize its memory usage efficiency.
*
* The layout of the array is as following:
*
* <pre>
*
* [ROW0]: [E0][E1][E2]
* [ROW1]: [E0][E1]
* [ROW2]: [E0][E1][E2][E3]
* [ROW3]: [E0]
*
* </pre>
*
* Where E0, E1, E2, etc are elements of the specified type T.
* Each row can have a different amount of elements that can be added or removed dynamically. Also rows can be deleted
* or added when desired.
*/
template <class T>
class Array2D
{
public:
/**
* An index table entry.
* Each row in the 2D array will get a table entry, which tells us where in the data array
* the element data starts for the given row, and how many elements will follow for the given row.
*/
struct TableEntry
{
size_t mStartIndex; /**< The index offset where the data for this row starts. */
size_t mNumElements; /**< The number of elements to follow. */
};
/**
* The default constructor.
* The number of pre-cached/allocated elements per row is set to a value of 2 on default.
* You can use the SetNumPreCachedElements(...) method to adjust this value. Make sure you adjust this value
* before you call the Resize method though, otherwise it will have no immediate effect.
*/
Array2D() = default;
/**
* Extended constructor which will automatically initialize the array dimensions.
* Basically this will initialize the array dimensions at (numRows x numPreAllocatedElemsPerRow) elements.
* Please note though, that this will NOT add actual elements. So you can't get values from the elements yet.
* This would just pre-allocate data. You have to use the Add method to actually fill the items.
* @param numRows The number of rows the array should have.
* @param numPreAllocatedElemsPerRow The number of pre-cached/allocated elements per row.
*
*/
Array2D(size_t numRows, size_t numPreAllocatedElemsPerRow = 2)
: mNumPreCachedElements(numPreAllocatedElemsPerRow) { Resize(numRows); }
/**
* Resize the array in one dimension (the number of rows).
* Rows that will be added willl automatically get [n] number of elements pre-allocated.
* The number of [n] can be set with the SetNumPreCachedElements(...) method.
* Please note that the pre-allocated/cached elements are not valid to be used yet. You have to use the Add method first.
* @param numRows The number of rows you wish to init for.
* @param autoShrink When set to true, after execution of this method the Shrink method will automatically be called in order
* to optimize the memory usage. This only happens when resizing to a lower amount of rows, so when making the array smaller.
*/
void Resize(size_t numRows, bool autoShrink = false);
/**
* Add an element to the list of elements in a given row.
* @param rowIndex The row number to add the element to.
* @param element The value of the element to add.
*/
void Add(size_t rowIndex, const T& element);
/**
* Remove an element from the array.
* @param rowIndex The row number where the element is stored.
* @param elementIndex The element number inside this row to remove.
*/
void Remove(size_t rowIndex, size_t elementIndex);
/**
* Remove a given row, including all its elements.
* This will decrease the number of rows.
* @param rowIndex The row number to remove.
* @param autoShrink When set to true, the array's memory usage will be optimized and minimized as much as possible.
*/
void RemoveRow(size_t rowIndex, bool autoShrink = false);
/**
* Remove a given range of rows and all their elements.
* All rows from the specified start row until the end row will be removed, with the start and end rows included.
* @param startRow The start row number to start removing from (so this one will also be removed).
* @param endRow The end row number (which will also be removed).
* @param autoShrink When set to true, the array's memory usage will be optimized and minimized as much as possible.
*/
void RemoveRows(size_t startRow, size_t endRow, bool autoShrink = false);
/**
* Optimize (minimize) the memory usage of the array.
* This will move all elements around, removing all gaps and unused pre-cached/allocated items.
* It is advised to call this method after you applied some heavy modifications to the array, such as
* removing rows or many elements. When your array data is fairly static, and you won't be adding or removing
* data from it very frequently, you should definitely call this method after you have filled the array with data.
*/
void Shrink();
/**
* Set the number of elements per row that should be pre-allocated/cached when creating / adding new rows.
* This doesn't actually increase the number of elements for a given row, but just reserves memory for the elements, which can
* speedup adding of new elements and prevent memory reallocs. The default value is set to 2 when creating an array, unless specified differently.
* @param numElemsPerRow The number of elements per row that should be pre-allocated.
*/
void SetNumPreCachedElements(size_t numElemsPerRow) { mNumPreCachedElements = numElemsPerRow; }
/**
* Get the number of pre-cached/allocated elements per row, when creating new rows.
* See the SetNumPreCachedElements for more information.
* @result The number of elements per row that will be pre-allocated/cached when adding a new row.
* @see SetNumPreCachedElements.
*/
size_t GetNumPreCachedElements() const { return mNumPreCachedElements; }
/**
* Get the number of stored elements inside a given row.
* @param rowIndex The row number.
* @result The number of elements stored inside this row.
*/
size_t GetNumElements(size_t rowIndex) const { return mIndexTable[rowIndex].mNumElements; }
/**
* Get a pointer to the element data stored in a given row.
* Use this method with care as it can easily overwrite data from other elements.
* All element data for a given row is stored sequential, so right after eachother in one continuous piece of memory.
* The next row's element data however might not be connected to the memory of row before that!
* Also only use this method when the GetNumElements(...) method for this row returns a value greater than zero.
* @param rowIndex the row number.
* @result A pointer to the element data for the given row.
*/
T* GetElements(size_t rowIndex) { return &mData[ mIndexTable[rowIndex].mStartIndex ]; }
/**
* Get the data of a given element.
* @param rowIndex The row number where the element is stored.
* @param elementNr The element number inside this row to retrieve.
* @result A reference to the element data.
*/
T& GetElement(size_t rowIndex, size_t elementNr) { return mData[ mIndexTable[rowIndex].mStartIndex + elementNr ]; }
/**
* Get the data of a given element.
* @param rowIndex The row number where the element is stored.
* @param elementNr The element number inside this row to retrieve.
* @result A const reference to the element data.
*/
const T& GetElement(size_t rowIndex, size_t elementNr) const { return mData[ mIndexTable[rowIndex].mStartIndex + elementNr ]; }
/**
* Set the value for a given element in the array.
* @param rowIndex The row where the element is stored in.
* @param elementNr The element number to set the value for.
* @param value The value to set the element to.
*/
void SetElement(size_t rowIndex, size_t elementNr, const T& value) { MCORE_ASSERT(rowIndex < mIndexTable.GetLength()); MCORE_ASSERT(elementNr < mIndexTable[rowIndex].mNumElements); mData[ mIndexTable[rowIndex].mStartIndex + elementNr ] = value; }
/**
* Get the number of rows in the 2D array.
* @result The number of rows.
*/
size_t GetNumRows() const { return mIndexTable.size(); }
/**
* Calculate the percentage of memory that is filled with element data.
* When this is 100%, then all allocated element data is filled and used.
* When it would be 25% then only 25% of all allocated element data is used. This is an indication that
* you should most likely use the Shrink method, which will ensure that the memory usage will become 100% again, which
* would be most optimal.
* @result The percentage (in range of 0..100) of used element memory.
*/
float CalcUsedElementMemoryPercentage() const { return (mData.GetLength() ? (CalcTotalNumElements() / (float)mData.GetLength()) * 100.0f : 0); }
/**
* Swap the element data of two rows.
* Beware, this is pretty slow!
* @param rowA The first row.
* @param rowB The second row.
*/
void Swap(size_t rowA, size_t rowB);
/**
* Calculate the total number of used elements.
* A used element is an element that has been added and that has a valid value stored.
* This excludes pre-allocated/cached elements.
* @result The total number of elements stored in the array.
*/
size_t CalcTotalNumElements() const;
/**
* Clear all contents.
* This deletes all rows and clears all their their elements as well.
* Please keep in mind though, that when you have an array of pointers to objects you allocated, that
* you still have to delete those objects by hand! The Clear function will not delete those.
* @param freeMem When set to true, all memory used by the array internally will be deleted. If set to false, the memory
* will not be deleted and can be reused later on again without doing any memory realloc when possible.
*/
void Clear(bool freeMem = true)
{
mIndexTable.clear();
mData.clear();
if (freeMem)
{
mIndexTable.shrink_to_fit();
mData.shrink_to_fit();
}
}
/**
* Log all array contents.
* This will log the number of rows, number of elements, used element memory percentage, as well
* as some details about each row.
*/
void LogContents();
/**
* Get the index table.
* This table describes for each row the start index and number of elements for the row.
* The length of the array equals the value returned by GetNumRows().
* @result The array of index table entries, which specify the start indices and number of entries per row.
*/
AZStd::vector<TableEntry>& GetIndexTable() { return mIndexTable; }
/**
* Get the data array.
* This contains the data array in which the index table points.
* Normally you shouldn't be using this method. However it is useful in some specific cases.
* @result The data array that contains all elements.
*/
AZStd::vector<T>& GetData() { return mData; }
private:
AZStd::vector<T> mData; /**< The element data. */
AZStd::vector<TableEntry> mIndexTable; /**< The index table that let's us know where what data is inside the element data array. */
size_t mNumPreCachedElements = 2; /**< The number of elements per row to pre-allocate when resizing this array. This prevents some re-allocs. */
};
// include inline code
#include "Array2D.inl"
} // namespace MCore

@ -1,289 +0,0 @@
/*
* Copyright (c) Contributors to the Open 3D Engine Project.
* For complete copyright and license terms please see the LICENSE at the root of this distribution.
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*
*/
// resize the array's number of rows
template <class T>
void Array2D<T>::Resize(size_t numRows, bool autoShrink)
{
// get the current (old) number of rows
const size_t oldNumRows = mIndexTable.size();
// don't do anything when we don't need to
if (numRows == oldNumRows)
{
return;
}
// resize the index table
mIndexTable.resize(numRows);
// check if we decreased the number of rows or not
if (numRows < oldNumRows)
{
// pack memory as tight as possible
if (autoShrink)
{
Shrink();
}
}
else // we added new entries
{
// init the new table entries
for (size_t i = oldNumRows; i < numRows; ++i)
{
mIndexTable[i].mStartIndex = mData.size() + (i * mNumPreCachedElements);
mIndexTable[i].mNumElements = 0;
}
// grow the data array
const size_t numNewRows = numRows - oldNumRows;
mData.resize(mData.size() + numNewRows * mNumPreCachedElements);
}
}
// add an element
template <class T>
void Array2D<T>::Add(size_t rowIndex, const T& element)
{
AZ_Assert(rowIndex < mIndexTable.size(), "Array index out of bounds");
// find the insert location inside the data array
size_t insertPos = mIndexTable[rowIndex].mStartIndex + mIndexTable[rowIndex].mNumElements;
if (insertPos >= mData.size())
{
mData.resize(insertPos + 1);
}
// check if we need to insert for real
bool needRealInsert = true;
if (rowIndex < mIndexTable.size() - 1) // if there are still entries coming after the one we have to add to
{
if (insertPos < mIndexTable[rowIndex + 1].mStartIndex) // if basically there are empty unused element we can use
{
needRealInsert = false; // then we don't need to do any reallocs
}
}
else
{
// if we're dealing with the last row
if (rowIndex == mIndexTable.size() - 1)
{
if (insertPos < mData.size()) // if basically there are empty unused element we can use
{
needRealInsert = false;
}
}
}
// perform the insertion
if (needRealInsert)
{
// insert the element inside the data array
mData.insert(AZStd::next(begin(mData), insertPos), element);
// adjust the index table entries
const size_t numRows = mIndexTable.size();
for (size_t i = rowIndex + 1; i < numRows; ++i)
{
mIndexTable[i].mStartIndex++;
}
}
else
{
mData[insertPos] = element;
}
// increase the number of elements in the index table
mIndexTable[rowIndex].mNumElements++;
}
// remove a given element
template <class T>
void Array2D<T>::Remove(size_t rowIndex, size_t elementIndex)
{
AZ_Assert(rowIndex < mIndexTable.size(), "Array2D<>::Remove: array index out of bounds");
AZ_Assert(elementIndex < mIndexTable[rowIndex].mNumElements, "Array2D<>::Remove: element index out of bounds");
AZ_Assert(mIndexTable[rowIndex].mNumElements > 0, "Array2D<>::Remove: array index out of bounds");
const size_t startIndex = mIndexTable[rowIndex].mStartIndex;
const size_t maxElementIndex = mIndexTable[rowIndex].mNumElements - 1;
// swap the last element with the one to be removed
if (elementIndex != maxElementIndex)
{
mData[startIndex + elementIndex] = mData[startIndex + maxElementIndex];
}
// decrease the number of elements
mIndexTable[rowIndex].mNumElements--;
}
// remove a given row
template <class T>
void Array2D<T>::RemoveRow(size_t rowIndex, bool autoShrink)
{
AZ_Assert(rowIndex < mIndexTable.GetLength(), "Array2D<>::RemoveRow: rowIndex out of bounds");
mIndexTable.Remove(rowIndex);
// optimize memory usage when desired
if (autoShrink)
{
Shrink();
}
}
// remove a set of rows
template <class T>
void Array2D<T>::RemoveRows(size_t startRow, size_t endRow, bool autoShrink)
{
AZ_Assert(startRow < mIndexTable.size(), "Array2D<>::RemoveRows: startRow out of bounds");
AZ_Assert(endRow < mIndexTable.size(), "Array2D<>::RemoveRows: endRow out of bounds");
// check if the start row is smaller than the end row
if (startRow < endRow)
{
const size_t numToRemove = (endRow - startRow) + 1;
mIndexTable.erase(AZStd::next(begin(mIndexTable), startRow), AZStd::next(AZStd::next(begin(mIndexTable), startRow), numToRemove));
}
else // if the end row is smaller than the start row
{
const size_t numToRemove = (startRow - endRow) + 1;
mIndexTable.erase(AZStd::next(begin(mIndexTable), endRow), AZStd::next(AZStd::next(begin(mIndexTable), endRow), numToRemove));
}
// optimize memory usage when desired
if (autoShrink)
{
Shrink();
}
}
// optimize memory usage
template <class T>
void Array2D<T>::Shrink()
{
// for all attributes, except for the last one
const size_t numRows = mIndexTable.size();
if (numRows == 0)
{
return;
}
// remove all unused items between the rows (unused element data per row)
const size_t numRowsMinusOne = numRows - 1;
for (size_t a = 0; a < numRowsMinusOne; ++a)
{
const size_t firstUnusedIndex = mIndexTable[a ].mStartIndex + mIndexTable[a].mNumElements;
const size_t numUnusedElements = mIndexTable[a + 1].mStartIndex - firstUnusedIndex;
// if we have pre-cached/unused elements, remove those by moving memory to remove the "holes"
if (numUnusedElements > 0)
{
// remove the unused elements from the array
mData.erase(AZStd::next(begin(mData), firstUnusedIndex), AZStd::next(AZStd::next(begin(mData), firstUnusedIndex), numUnusedElements));
// change the start indices for all the rows coming after the current one
const size_t numTotalRows = mIndexTable.size();
for (size_t i = a + 1; i < numTotalRows; ++i)
{
mIndexTable[i].mStartIndex -= numUnusedElements;
}
}
}
// now move all start index values and all data to the front of the data array as much as possible
// like data on row 0 starting at data element 7, would be moved to data element 0
size_t dataPos = 0;
for (size_t row = 0; row < numRows; ++row)
{
// if the data starts after the place where it could start, move it to the place where it could start
if (mIndexTable[row].mStartIndex > dataPos)
{
AZStd::move(AZStd::next(begin(mData), this->mIndexTable[row].mStartIndex), AZStd::next(AZStd::next(begin(mData), this->mIndexTable[row].mStartIndex), this->mIndexTable[row].mNumElements), AZStd::next(begin(mData), dataPos));
mIndexTable[row].mStartIndex = dataPos;
}
// increase the data pos
dataPos += mIndexTable[row].mNumElements;
}
// remove all unused data items
if (dataPos < mData.size())
{
mData.erase(AZStd::next(begin(mData), dataPos), end(mData));
}
// shrink the arrays
mData.shrink_to_fit();
mIndexTable.shrink_to_fit();
}
// calculate the number of used elements
template <class T>
size_t Array2D<T>::CalcTotalNumElements() const
{
size_t totalElements = 0;
// add all number of row elements together
const size_t numRows = mIndexTable.size();
for (size_t i = 0; i < numRows; ++i)
{
totalElements += mIndexTable[i].mNumElements;
}
return totalElements;
}
// swap the contents of two rows
template <class T>
void Array2D<T>::Swap(size_t rowA, size_t rowB)
{
// get the original number of elements from both rows
const size_t numElementsA = mIndexTable[rowA].mNumElements;
const size_t numElementsB = mIndexTable[rowB].mNumElements;
// move the element data of rowA into a temp buffer
AZStd::vector<T> tempData(numElementsA);
AZStd::move(
AZStd::next(mData.begin(), mIndexTable[rowA].mStartIndex),
AZStd::next(mData.begin(), mIndexTable[rowA].mStartIndex + numElementsA),
tempData.begin()
);
// remove the elements from rowA
while (GetNumElements(rowA))
{
Remove(rowA, 0);
}
// add all elements of row B
size_t i;
for (i = 0; i < numElementsB; ++i)
{
Add(rowA, GetElement(rowB, i));
}
// remove all elements from B
while (GetNumElements(rowB))
{
Remove(rowB, 0);
}
// add all elements from the original A
for (i = 0; i < numElementsA; ++i)
{
Add(rowB, tempData[i]);
}
}

@ -25,8 +25,11 @@ namespace AZ::MeshBuilder
// constructor
MeshBuilderSkinningInfo::MeshBuilderSkinningInfo(size_t numOrgVertices)
{
mInfluences.SetNumPreCachedElements(4); // TODO: verify if this is the fastest
mInfluences.Resize(numOrgVertices);
mInfluences.resize(numOrgVertices);
for (auto& subArray : mInfluences)
{
subArray.reserve(4);
}
}
@ -100,13 +103,13 @@ namespace AZ::MeshBuilder
// remove all influences
for (size_t i = 0; i < numInfluences; ++i)
{
mInfluences.Remove(v, 0);
RemoveInfluence(v, 0);
}
// re-add them
for (const Influence& influence : influences)
{
mInfluences.Add(v, influence);
AddInfluence(v, influence);
}
}
}

@ -10,8 +10,8 @@
#include <AzCore/Memory/Memory.h>
#include <AzCore/base.h>
#include <AzCore/std/containers/vector.h>
#include "MeshBuilderInvalidIndex.h"
#include "Array2D.h"
namespace AZ::MeshBuilder
{
@ -35,14 +35,19 @@ namespace AZ::MeshBuilder
MeshBuilderSkinningInfo(size_t numOrgVertices);
void AddInfluence(size_t orgVtxNr, size_t nodeNr, float weight) { AddInfluence(orgVtxNr, {nodeNr, weight}); }
void AddInfluence(size_t orgVtxNr, const Influence& influence) { mInfluences.Add(orgVtxNr, influence); }
void RemoveInfluence(size_t orgVtxNr, size_t influenceNr) { mInfluences.Remove(orgVtxNr, influenceNr); }
const Influence& GetInfluence(size_t orgVtxNr, size_t influenceNr) const { return mInfluences.GetElement(orgVtxNr, influenceNr); }
size_t GetNumInfluences(size_t orgVtxNr) const { return mInfluences.GetNumElements(orgVtxNr); }
size_t GetNumOrgVertices() const { return mInfluences.GetNumRows(); }
void OptimizeMemoryUsage() { mInfluences.Shrink(); }
size_t CalcTotalNumInfluences() const { return mInfluences.CalcTotalNumElements(); }
void AddInfluence(size_t orgVtxNr, const Influence& influence) { mInfluences.resize(AZStd::max(mInfluences.size(), orgVtxNr)); mInfluences.at(orgVtxNr).emplace_back(influence); }
void RemoveInfluence(size_t orgVtxNr, size_t influenceNr) { mInfluences.at(orgVtxNr).erase(mInfluences.at(orgVtxNr).begin() + influenceNr); }
const Influence& GetInfluence(size_t orgVtxNr, size_t influenceNr) const { return mInfluences.at(orgVtxNr).at(influenceNr); }
size_t GetNumInfluences(size_t orgVtxNr) const { return mInfluences.at(orgVtxNr).size(); }
size_t GetNumOrgVertices() const { return mInfluences.size(); }
void OptimizeMemoryUsage()
{
for (auto& subArray : mInfluences)
{
subArray.shrink_to_fit();
}
mInfluences.shrink_to_fit();
}
// optimize the weight data
void Optimize(AZ::u32 maxNumWeightsPerVertex = 4, float weightThreshold = 0.0001f);
@ -53,7 +58,7 @@ namespace AZ::MeshBuilder
// sort the influences, starting with the biggest weight
static void SortInfluences(AZStd::vector<Influence>& influences);
public:
MCore::Array2D<Influence> mInfluences;
private:
AZStd::vector<AZStd::vector<Influence>> mInfluences;
};
} // namespace AZ::MeshBuilder

@ -117,6 +117,7 @@ namespace AZ::SceneGenerationComponents
}
using AZStd::unordered_map<AZ::Vector3, AZ::u32>::reserve;
using AZStd::unordered_map<AZ::Vector3, AZ::u32>::size;
AZ::u32 operator[](const AZ::u32 vertexIndex)
{
@ -203,7 +204,7 @@ namespace AZ::SceneGenerationComponents
return {};
}
const size_t usedControlPointCount = meshData->GetUsedControlPointCount();
const size_t usedControlPointCount = positionMap.size();
auto skinningInfo = AZStd::make_unique<AZ::MeshBuilder::MeshBuilderSkinningInfo>(aznumeric_cast<AZ::u32>(usedControlPointCount));

@ -66,7 +66,7 @@ namespace AZ::MeshBuilder
for (size_t i = 0; i < numSkinInfluences; ++i)
{
const float influenceWeight = (i != numSkinInfluences - 1 ? fmod(random.GetRandomFloat(), totalWeight) : totalWeight);
skinningInfo->AddInfluence(v, i, influenceWeight);
skinningInfo->AddInfluence(v, {i, influenceWeight});
totalWeight -= influenceWeight;
}
const float totalSkinInfluenceWeight = CalcSkinInfluencesTotalWeight(skinningInfo.get(), v);

@ -20,8 +20,6 @@ set(FILES
Source/Generation/Components/TangentGenerator/TangentGenerators/MikkTGenerator.cpp
Source/Generation/Components/TangentGenerator/TangentGenerators/BlendShapeMikkTGenerator.h
Source/Generation/Components/TangentGenerator/TangentGenerators/BlendShapeMikkTGenerator.cpp
Source/Generation/Components/MeshOptimizer/Array2D.h
Source/Generation/Components/MeshOptimizer/Array2D.inl
Source/Generation/Components/MeshOptimizer/MeshBuilder.cpp
Source/Generation/Components/MeshOptimizer/MeshBuilder.h
Source/Generation/Components/MeshOptimizer/MeshBuilderInvalidIndex.h

Loading…
Cancel
Save