From b9f7bf6086e955ba13b2500f9fcfffdb1fedf5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Tue, 14 Jun 2022 22:21:09 +0200 Subject: [PATCH] TextureTools: new, optimal, atlasArrayPowerOfTwo() algorithm. --- doc/changelog.dox | 5 + src/Magnum/TextureTools/Atlas.cpp | 82 ++++++++ src/Magnum/TextureTools/Atlas.h | 30 ++- src/Magnum/TextureTools/CMakeLists.txt | 21 +- src/Magnum/TextureTools/Test/AtlasTest.cpp | 210 +++++++++++++++++++- src/Magnum/TextureTools/Test/CMakeLists.txt | 2 +- 6 files changed, 344 insertions(+), 6 deletions(-) diff --git a/doc/changelog.dox b/doc/changelog.dox index 07468817e..f4a13d425 100644 --- a/doc/changelog.dox +++ b/doc/changelog.dox @@ -255,6 +255,11 @@ See also: @ref ShaderTools::AnyConverter "AnyShaderConverter" plugin and a @ref magnum-shaderconverter "magnum-shaderconverter" utility +@subsubsection changelog-latest-new-texturetools TextureTools library + +- New @ref TextureTools::atlasArrayPowerOfTwo() utility for optimal packing + of power-of-two textures into a texture atlas array + @subsubsection changelog-latest-new-trade Trade library - A new, redesigned @ref Trade::MaterialData class allowing to store custom diff --git a/src/Magnum/TextureTools/Atlas.cpp b/src/Magnum/TextureTools/Atlas.cpp index ab1ef3831..d38e4b9b4 100644 --- a/src/Magnum/TextureTools/Atlas.cpp +++ b/src/Magnum/TextureTools/Atlas.cpp @@ -25,7 +25,10 @@ #include "Atlas.h" +#include #include +#include +#include #include "Magnum/Math/Functions.h" #include "Magnum/Math/Range.h" @@ -61,4 +64,83 @@ std::vector atlas(const Vector2i& atlasSize, const std::vector> atlasArrayPowerOfTwo(const Vector2i& layerSize, const Containers::ArrayView sizes) { + CORRADE_ASSERT(layerSize.product() && layerSize.x() == layerSize.y() && (layerSize & (layerSize - Vector2i{1})).isZero(), + "TextureTools::atlasArrayPowerOfTwo(): expected layer size to be a non-zero power-of-two square, got" << Debug::packed << layerSize, {}); + + if(sizes.isEmpty()) + return {}; + + Containers::Array output{NoInit, sizes.size()}; + + /* Copy the input to a sorted array, together with a mapping to the + original order stored in Z. Can't really reuse the output allocation + as it would be overwritten in random order. */ + Containers::Array sortedSizes{NoInit, sizes.size()}; + for(std::size_t i = 0; i != sizes.size(); ++i) { + const Vector2i size = sizes[i]; + CORRADE_ASSERT(size.product() && size.x() == size.y() && (size & (size - Vector2i{1})).isZero(), + "TextureTools::atlasArrayPowerOfTwo(): expected size" << i << "to be a non-zero power-of-two square, got" << Debug::packed << size, {}); + + sortedSizes[i].xy() = sizes[i]; + sortedSizes[i].z() = i; + } + + /* Sort to have the biggest size first. Assuming the items are square, + which is checked below in the loop. It's highly likely there are many + textures of the same size, thus use a stable sort to have output + consistent across platforms */ + /** @todo stable_sort allocates, would be great if i could make it reuse + the memory allocated for output */ + std::stable_sort(sortedSizes.begin(), sortedSizes.end(), [](const Vector3i& a, const Vector3i& b) { + return a.x() > b.x(); + }); + + /* Start with the whole first layer free */ + Int layer = 0; + UnsignedInt free = 1; + Vector2i previousSize = layerSize; + for(const Vector3i& size: sortedSizes) { + /* No free slots left, go to the next layer. Then, what's free, is one + whole layer. */ + if(!free) { + ++layer; + free = 1; + previousSize = layerSize; + } + + /* Multiply number of free slots based on area difference from previous + size. If the size is the same, nothing changes. */ + /** @todo there's definitely some bit trick for dividing power-of-two + numbers, use it */ + free *= (previousSize/size.xy()).product(); + + /* Slot index as if the whole layer was consisting just of slots of + this size. */ + const UnsignedInt sideSlotCount = layerSize.x()/size.x(); + const UnsignedInt layerDepth = Math::log2(sideSlotCount); + const UnsignedInt slotIndex = sideSlotCount*sideSlotCount - free; + + /* Calculate coordinates out of the slot index */ + Vector2i coordinates; + for(UnsignedInt i = 0; i < layerDepth; ++i) { + if(slotIndex & (1 << 2*(layerDepth - i - 1))) + coordinates.x() += layerSize.x() >> (i + 1); + if(slotIndex & (1 << (2*(layerDepth - i - 1) + 1))) + coordinates.y() += layerSize.y() >> (i + 1); + } + + /* Save to the output in the original order */ + output[size.z()] = {coordinates, layer}; + previousSize = size.xy(); + --free; + } + + return {layer + 1, std::move(output)}; +} + +Containers::Pair> atlasArrayPowerOfTwo(const Vector2i& layerSize, const std::initializer_list sizes) { + return atlasArrayPowerOfTwo(layerSize, Containers::arrayView(sizes)); +} + }} diff --git a/src/Magnum/TextureTools/Atlas.h b/src/Magnum/TextureTools/Atlas.h index 7f9f8d140..f3063465c 100644 --- a/src/Magnum/TextureTools/Atlas.h +++ b/src/Magnum/TextureTools/Atlas.h @@ -26,7 +26,7 @@ */ /** @file - * @brief Function @ref Magnum::TextureTools::atlas() + * @brief Function @ref Magnum::TextureTools::atlas(), @ref Magnum::TextureTools::atlasArrayPowerOfTwo() */ #include @@ -52,6 +52,34 @@ padding. */ std::vector MAGNUM_TEXTURETOOLS_EXPORT atlas(const Vector2i& atlasSize, const std::vector& sizes, const Vector2i& padding = Vector2i()); +/** +@brief Pack square power-of-two textures into a texture atlas array +@param layerSize Size of the texture layer +@param sizes Sizes of all textures in the atlas +@return Total layer count and offsets of all textures in the atlas, with the Z + coordinate being the layer index +@m_since_latest + +Both @p layerSize and all items in @p sizes are expected to be non-zero, square +and power-of-two. With such constraints the packing is optimal with no wasted +space in all but the last layer. Setting @p layerSize to the size of the +largest texture in the set will lead to the least wasted space in the last +layer. + +The algorithm first sorts the textures by size using @ref std::stable_sort(), +which is usually @f$ \mathcal{O}(n \log{} n) @f$, and then performs the actual +atlasing in a single @f$ \mathcal{O}(n) @f$ operation. Due to the sort +involved, a temporary allocation holds the sorted array and additionally +@ref std::stable_sort() performs its own allocation. +*/ +Containers::Pair> MAGNUM_TEXTURETOOLS_EXPORT atlasArrayPowerOfTwo(const Vector2i& layerSize, Containers::ArrayView sizes); + +/** + * @overload + * @m_since_latest + */ +Containers::Pair> MAGNUM_TEXTURETOOLS_EXPORT atlasArrayPowerOfTwo(const Vector2i& layerSize, std::initializer_list sizes); + }} #endif diff --git a/src/Magnum/TextureTools/CMakeLists.txt b/src/Magnum/TextureTools/CMakeLists.txt index 5f002f297..114f0f564 100644 --- a/src/Magnum/TextureTools/CMakeLists.txt +++ b/src/Magnum/TextureTools/CMakeLists.txt @@ -27,7 +27,7 @@ # property that would have to be set on each target separately. set(CMAKE_FOLDER "Magnum/TextureTools") -set(MagnumTextureTools_SRCS +set(MagnumTextureTools_GracefulAssert_SRCS Atlas.cpp) set(MagnumTextureTools_HEADERS @@ -46,7 +46,7 @@ if(MAGNUM_TARGET_GL) "CORRADE_AUTOMATIC_FINALIZER=CORRADE_NOOP") endif() - list(APPEND MagnumTextureTools_SRCS + list(APPEND MagnumTextureTools_GracefulAssert_SRCS DistanceField.cpp ${MagnumTextureTools_RESOURCES}) @@ -55,7 +55,7 @@ endif() # TextureTools library add_library(MagnumTextureTools ${SHARED_OR_STATIC} - ${MagnumTextureTools_SRCS} + ${MagnumTextureTools_GracefulAssert_SRCS} ${MagnumTextureTools_HEADERS}) set_target_properties(MagnumTextureTools PROPERTIES DEBUG_POSTFIX "-d") if(NOT MAGNUM_BUILD_STATIC) @@ -114,6 +114,21 @@ if(MAGNUM_WITH_DISTANCEFIELDCONVERTER) endif() if(MAGNUM_BUILD_TESTS) + # Library with graceful assert for testing + add_library(MagnumTextureToolsTestLib ${SHARED_OR_STATIC} + ${MagnumTextureTools_GracefulAssert_SRCS}) + set_target_properties(MagnumTextureToolsTestLib PROPERTIES DEBUG_POSTFIX "-d") + target_compile_definitions(MagnumTextureToolsTestLib PRIVATE + "CORRADE_GRACEFUL_ASSERT" "MagnumTextureTools_EXPORTS") + if(MAGNUM_BUILD_STATIC_PIC) + set_target_properties(MagnumTextureToolsTestLib PROPERTIES POSITION_INDEPENDENT_CODE ON) + endif() + target_link_libraries(MagnumTextureToolsTestLib PUBLIC + Magnum) + if(MAGNUM_TARGET_GL) + target_link_libraries(MagnumTextureToolsTestLib PUBLIC MagnumGL) + endif() + add_subdirectory(Test) endif() diff --git a/src/Magnum/TextureTools/Test/AtlasTest.cpp b/src/Magnum/TextureTools/Test/AtlasTest.cpp index f23179254..88e2e1fe4 100644 --- a/src/Magnum/TextureTools/Test/AtlasTest.cpp +++ b/src/Magnum/TextureTools/Test/AtlasTest.cpp @@ -25,8 +25,12 @@ #include #include +#include +#include #include +#include #include +#include #include "Magnum/Math/Range.h" #include "Magnum/TextureTools/Atlas.h" @@ -40,13 +44,56 @@ struct AtlasTest: TestSuite::Tester { void padding(); void empty(); void tooSmall(); + + void arrayPowerOfTwoEmpty(); + void arrayPowerOfTwoSingleElement(); + void arrayPowerOfTwoAllSameElements(); + void arrayPowerOfTwoOneLayer(); + void arrayPowerOfTwoMoreLayers(); + void arrayPowerOfTwoWrongLayerSize(); + void arrayPowerOfTwoWrongSize(); +}; + +const struct { + const char* name; + std::size_t order[15]; +} ArrayPowerOfTwoOneLayerData[]{ + {"sorted", + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}}, + {"", + /* Because there are duplicate sizes, the shuffling needs to preserve + the original order of duplicates to match the output */ + {0, 2, 7, 13, 11, 3, 4, 5, 8, 14, 1, 9, 6, 12, 10}}, +}; + +const struct { + const char* name; + Vector2i size; + const char* message; +} ArrayPowerOfTwoWrongSizeData[]{ + {"non-power-of-two", {128, 127}, "{128, 127}"}, + {"non-square", {128, 256}, "{128, 256}"}, + {"zero", {1024, 0}, "{1024, 0}"} }; AtlasTest::AtlasTest() { addTests({&AtlasTest::basic, &AtlasTest::padding, &AtlasTest::empty, - &AtlasTest::tooSmall}); + &AtlasTest::tooSmall, + + &AtlasTest::arrayPowerOfTwoEmpty, + &AtlasTest::arrayPowerOfTwoSingleElement, + &AtlasTest::arrayPowerOfTwoAllSameElements}); + + addInstancedTests({&AtlasTest::arrayPowerOfTwoOneLayer}, + Containers::arraySize(ArrayPowerOfTwoOneLayerData)); + + addTests({&AtlasTest::arrayPowerOfTwoMoreLayers}); + + addInstancedTests({&AtlasTest::arrayPowerOfTwoWrongLayerSize, + &AtlasTest::arrayPowerOfTwoWrongSize}, + Containers::arraySize(ArrayPowerOfTwoWrongSizeData)); } void AtlasTest::basic() { @@ -95,6 +142,167 @@ void AtlasTest::tooSmall() { CORRADE_COMPARE(o.str(), "TextureTools::atlas(): requested atlas size Vector(64, 32) is too small to fit 3 Vector(25, 31) textures. Generated atlas will be empty.\n"); } +void AtlasTest::arrayPowerOfTwoEmpty() { + Containers::Pair> out = atlasArrayPowerOfTwo({128, 128}, {}); + CORRADE_COMPARE(out.first(), 0); + CORRADE_COMPARE_AS(out.second(), Containers::arrayView({ + }), TestSuite::Compare::Container); +} + +void AtlasTest::arrayPowerOfTwoSingleElement() { + Containers::Pair> out = atlasArrayPowerOfTwo({128, 128}, {{128, 128}}); + CORRADE_COMPARE(out.first(), 1); + CORRADE_COMPARE_AS(out.second(), Containers::arrayView({ + {0, 0, 0} + }), TestSuite::Compare::Container); +} + +void AtlasTest::arrayPowerOfTwoAllSameElements() { + Containers::Pair> out = atlasArrayPowerOfTwo({128, 128}, { + {64, 64}, + {64, 64}, + {64, 64}, + {64, 64}, + }); + CORRADE_COMPARE(out.first(), 1); + CORRADE_COMPARE_AS(out.second(), Containers::arrayView({ + {0, 0, 0}, + {64, 0, 0}, + {0, 64, 0}, + {64, 64, 0} + }), TestSuite::Compare::Container); +} + +void AtlasTest::arrayPowerOfTwoOneLayer() { + auto&& data = ArrayPowerOfTwoOneLayerData[testCaseInstanceId()]; + setTestCaseDescription(data.name); + + constexpr std::size_t count = Containers::arraySize(ArrayPowerOfTwoOneLayerData->order); + + const Vector2i inputSorted[count]{ + {1024, 1024}, /* 0 */ + {1024, 1024}, /* 1 */ + + {512, 512}, /* 2 */ + {512, 512}, /* 3 */ + {512, 512}, /* 4 */ + {512, 512}, /* 5 */ + {512, 512}, /* 6 */ + + {256, 256}, /* 7 */ + {256, 256}, /* 8 */ + {256, 256}, /* 9 */ + {256, 256}, /* 10 */ + + {128, 128}, /* 11 */ + {128, 128}, /* 12 */ + + {32, 32}, /* 13 */ + {32, 32} /* 14 */ + }; + + const Vector3i expectedSorted[count]{ + {0, 0, 0}, + {1024, 0, 0}, + + {0, 1024, 0}, + {512, 1024, 0}, + {0, 1536, 0}, + {512, 1536, 0}, + {1024, 1024, 0}, + + {1536, 1024, 0}, + {1792, 1024, 0}, + {1536, 1280, 0}, + {1792, 1280, 0}, + + {1024, 1536, 0}, + {1152, 1536, 0}, + + {1024, 1664, 0}, + {1056, 1664, 0} + }; + + Vector2i input[count]; + Vector3i expected[count]; + for(std::size_t i = 0; i != count; ++i) { + input[i] = inputSorted[data.order[i]]; + expected[i] = expectedSorted[data.order[i]]; + } + + Containers::Pair> out = atlasArrayPowerOfTwo({2048, 2048}, input); + CORRADE_COMPARE(out.first(), 1); + CORRADE_COMPARE_AS(out.second(), + Containers::ArrayView{expected}, + TestSuite::Compare::Container); +} + +void AtlasTest::arrayPowerOfTwoMoreLayers() { + Containers::Pair> out = atlasArrayPowerOfTwo({2048, 2048}, { + {2048, 2048}, + + {1024, 1024}, + {1024, 1024}, + {1024, 1024}, + {512, 512}, + {512, 512}, + {512, 512}, + {512, 512}, + + {512, 512}, + {256, 256}, + {256, 256} + }); + CORRADE_COMPARE(out.first(), 3); + CORRADE_COMPARE_AS(out.second(), Containers::arrayView({ + {0, 0, 0}, + + {0, 0, 1}, + {1024, 0, 1}, + {0, 1024, 1}, + {1024, 1024, 1}, + {1536, 1024, 1}, + {1024, 1536, 1}, + {1536, 1536, 1}, + + {0, 0, 2}, + {512, 0, 2}, + {768, 0, 2} + }), TestSuite::Compare::Container); +} + +void AtlasTest::arrayPowerOfTwoWrongLayerSize() { + auto&& data = ArrayPowerOfTwoWrongSizeData[testCaseInstanceId()]; + setTestCaseDescription(data.name); + + #ifdef CORRADE_NO_ASSERT + CORRADE_SKIP("CORRADE_NO_ASSERT defined, can't test assertions"); + #endif + + std::ostringstream out; + Error redirectError{&out}; + atlasArrayPowerOfTwo(data.size, {}); + CORRADE_COMPARE(out.str(), Utility::formatString("TextureTools::atlasArrayPowerOfTwo(): expected layer size to be a non-zero power-of-two square, got {}\n", data.message)); +} + +void AtlasTest::arrayPowerOfTwoWrongSize() { + auto&& data = ArrayPowerOfTwoWrongSizeData[testCaseInstanceId()]; + setTestCaseDescription(data.name); + + #ifdef CORRADE_NO_ASSERT + CORRADE_SKIP("CORRADE_NO_ASSERT defined, can't test assertions"); + #endif + + std::ostringstream out; + Error redirectError{&out}; + atlasArrayPowerOfTwo({256, 256}, { + {64, 64}, + {128, 128}, + data.size + }); + CORRADE_COMPARE(out.str(), Utility::formatString("TextureTools::atlasArrayPowerOfTwo(): expected size 2 to be a non-zero power-of-two square, got {}\n", data.message)); +} + }}}} CORRADE_TEST_MAIN(Magnum::TextureTools::Test::AtlasTest) diff --git a/src/Magnum/TextureTools/Test/CMakeLists.txt b/src/Magnum/TextureTools/Test/CMakeLists.txt index 9ee66aeaf..8e20ebaa3 100644 --- a/src/Magnum/TextureTools/Test/CMakeLists.txt +++ b/src/Magnum/TextureTools/Test/CMakeLists.txt @@ -27,7 +27,7 @@ # property that would have to be set on each target separately. set(CMAKE_FOLDER "Magnum/TextureTools/Test") -corrade_add_test(TextureToolsAtlasTest AtlasTest.cpp LIBRARIES MagnumTextureTools) +corrade_add_test(TextureToolsAtlasTest AtlasTest.cpp LIBRARIES MagnumTextureToolsTestLib) if(CORRADE_TARGET_EMSCRIPTEN OR CORRADE_TARGET_ANDROID) set(DISTANCEFIELDGLTEST_FILES_DIR "DistanceFieldGLTestFiles")