Skip to content

Commit

Permalink
Improve texture atlas packing
Browse files Browse the repository at this point in the history
- Atlas texture utilization: 70% --> 90-94%
- Computational time to find suitable free region in atlas: speedup by 40x-164x depending on atlas dimension and utilization.
- Additional memory necessary for texture atlas handling: max. ~128 KB --> max. ~3 MB
  • Loading branch information
Robyt3 committed Aug 5, 2023
1 parent 8c49c2e commit 6894321
Showing 1 changed file with 205 additions and 78 deletions.
283 changes: 205 additions & 78 deletions src/engine/client/text.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,203 @@ struct SGlyphKeyEquals
}
};

struct STextureSkyline
class CAtlas
{
// the height of each column
std::vector<size_t> m_vCurHeightOfPixelColumn;
struct SSectionKeyHash
{
size_t operator()(const std::tuple<size_t, size_t> &Key) const
{
// Width and height should never be above 2^16 so this hash should cause no collisions
return (std::get<0>(Key) << 16) ^ std::get<1>(Key);
}
};

struct SSectionKeyEquals
{
bool operator()(const std::tuple<size_t, size_t> &Lhs, const std::tuple<size_t, size_t> &Rhs) const
{
return std::get<0>(Lhs) == std::get<0>(Rhs) && std::get<1>(Lhs) == std::get<1>(Rhs);
}
};

struct SSection
{
size_t m_X;
size_t m_Y;
size_t m_W;
size_t m_H;

SSection() = default;

SSection(size_t X, size_t Y, size_t W, size_t H) :
m_X(X), m_Y(Y), m_W(W), m_H(H)
{
}
};

/**
* Sections with a smaller width or height will not be created
* when cutting larger sections, to prevent collecting many
* small, mostly unuseable sections.
*/
static constexpr size_t MIN_SECTION_DIMENSION = 6;

/**
* Sections with a larger width or height will be stored in m_vSections.
* Sections with width and height equal or smaller will be stored in m_SectionsMap.
* This achieves a good balance between the size of the vector storing all large
* sections and the map storing vectors of all sections with specific small sizes.
* Lowering this value will result in the size of m_vSections becoming the bottleneck.
* Increasing this value will result in the map becoming the bottleneck.
*/
static constexpr size_t MAX_SECTION_DIMENSION_MAPPED = 8 * MIN_SECTION_DIMENSION;

size_t m_TextureDimension;
std::vector<SSection> m_vSections;
std::unordered_map<std::tuple<size_t, size_t>, std::vector<SSection>, SSectionKeyHash, SSectionKeyEquals> m_SectionsMap;

void AddSection(size_t X, size_t Y, size_t W, size_t H)
{
std::vector<SSection> &vSections = W <= MAX_SECTION_DIMENSION_MAPPED && H <= MAX_SECTION_DIMENSION_MAPPED ? m_SectionsMap[std::make_tuple(W, H)] : m_vSections;
vSections.emplace_back(X, Y, W, H);
}

void UseSection(const SSection &Section, size_t Width, size_t Height, int &PosX, int &PosY)
{
PosX = Section.m_X;
PosY = Section.m_Y;

// Create cut sections
const size_t FreeW = Section.m_W - Width;
const size_t FreeH = Section.m_H - Height;
if(FreeW == 0 && FreeH == 0)
{
// Section is a perfect fit, no cut sections are created
}
else if(FreeW == 0)
{
if(FreeH >= MIN_SECTION_DIMENSION)
AddSection(Section.m_X, Section.m_Y + Height, Section.m_W, FreeH);
}
else if(FreeH == 0)
{
if(FreeW >= MIN_SECTION_DIMENSION)
AddSection(Section.m_X + Width, Section.m_Y, FreeW, Section.m_H);
}
else if(FreeW > FreeH)
{
if(FreeW >= MIN_SECTION_DIMENSION)
AddSection(Section.m_X + Width, Section.m_Y, FreeW, Section.m_H);
if(FreeH >= MIN_SECTION_DIMENSION)
AddSection(Section.m_X, Section.m_Y + Height, Width, FreeH);
}
else
{
if(FreeH >= MIN_SECTION_DIMENSION)
AddSection(Section.m_X, Section.m_Y + Height, Section.m_W, FreeH);
if(FreeW >= MIN_SECTION_DIMENSION)
AddSection(Section.m_X + Width, Section.m_Y, FreeW, Height);
}
}

public:
void Clear(size_t TextureDimension)
{
m_TextureDimension = TextureDimension;
m_vSections.clear();
m_vSections.emplace_back(0, 0, m_TextureDimension, m_TextureDimension);
m_SectionsMap.clear();
}

void IncreaseDimension(size_t NewTextureDimension)
{
dbg_assert(NewTextureDimension == m_TextureDimension * 2, "New atlas dimension must be twice the old one");
// Create 3 square sections to cover the new area, add the sections
// to the beginning of the vector so they are considered last.
m_vSections.emplace_back(m_TextureDimension, m_TextureDimension, m_TextureDimension, m_TextureDimension);
m_vSections.emplace_back(m_TextureDimension, 0, m_TextureDimension, m_TextureDimension);
m_vSections.emplace_back(0, m_TextureDimension, m_TextureDimension, m_TextureDimension);
std::rotate(m_vSections.rbegin(), m_vSections.rbegin() + 3, m_vSections.rend());
m_TextureDimension = NewTextureDimension;
}

bool Add(size_t Width, size_t Height, int &PosX, int &PosY)
{
if(m_vSections.empty() || m_TextureDimension < Width || m_TextureDimension < Height)
return false;

// Find small section more efficiently by using maps
if(Width <= MAX_SECTION_DIMENSION_MAPPED && Height <= MAX_SECTION_DIMENSION_MAPPED)
{
const auto UseSectionFromVector = [&](std::vector<SSection> &vSections) {
if(!vSections.empty())
{
const SSection Section = vSections.back();
vSections.pop_back();
UseSection(Section, Width, Height, PosX, PosY);
return true;
}
return false;
};

if(UseSectionFromVector(m_SectionsMap[std::make_tuple(Width, Height)]))
return true;

for(size_t CheckWidth = Width + 1; CheckWidth <= MAX_SECTION_DIMENSION_MAPPED; ++CheckWidth)
{
if(UseSectionFromVector(m_SectionsMap[std::make_tuple(CheckWidth, Height)]))
return true;
}

for(size_t CheckHeight = Height + 1; CheckHeight <= MAX_SECTION_DIMENSION_MAPPED; ++CheckHeight)
{
if(UseSectionFromVector(m_SectionsMap[std::make_tuple(Width, CheckHeight)]))
return true;
}

// We don't iterate sections in the map with increasing width and height at the same time,
// because it's slower and doesn't noticable increase the atlas utilization.
}

// Check vector of larger sections
size_t SmallestLossValue = std::numeric_limits<size_t>::max();
size_t SmallestLossIndex = m_vSections.size();
size_t SectionIndex = m_vSections.size();
do
{
--SectionIndex;
const SSection &Section = m_vSections[SectionIndex];
if(Section.m_W < Width || Section.m_H < Height)
continue;

const size_t FreeW = Section.m_W - Width;
const size_t FreeH = Section.m_H - Height;

size_t Loss;
if(FreeW == 0)
Loss = FreeH;
else if(FreeH == 0)
Loss = FreeW;
else
Loss = FreeW * FreeH;

if(Loss < SmallestLossValue)
{
SmallestLossValue = Loss;
SmallestLossIndex = SectionIndex;
if(SmallestLossValue == 0)
break;
}
} while(SectionIndex > 0);
if(SmallestLossIndex == m_vSections.size())
return false; // No useable section found in vector

// Use the section with the smallest loss
const SSection Section = m_vSections[SmallestLossIndex];
m_vSections.erase(m_vSections.begin() + SmallestLossIndex);
UseSection(Section, Width, Height, PosX, PosY);
return true;
}
};

class CGlyphMap
Expand Down Expand Up @@ -129,7 +322,7 @@ class CGlyphMap
size_t m_TextureDimension = INITIAL_ATLAS_DIMENSION;
// Keep the full texture data, because OpenGL doesn't provide texture copying
uint8_t *m_apTextureData[NUM_FONT_TEXTURES];
STextureSkyline m_TextureSkyline;
CAtlas m_TextureAtlas;
std::unordered_map<std::tuple<FT_Face, int, int>, SGlyph, SGlyphKeyHash, SGlyphKeyEquals> m_Glyphs;

// Data used for rendering glyphs
Expand Down Expand Up @@ -190,7 +383,9 @@ class CGlyphMap
delete[] pTextureData;
pTextureData = pTmpTexBuffer;
}
m_TextureSkyline.m_vCurHeightOfPixelColumn.resize(NewTextureDimension, 0);

m_TextureAtlas.IncreaseDimension(NewTextureDimension);

m_TextureDimension = NewTextureDimension;

UploadTextures();
Expand Down Expand Up @@ -303,77 +498,9 @@ class CGlyphMap
Graphics()->UpdateTextTexture(m_aTextures[TextureIndex], PosX, PosY, Width, Height, pData);
}

bool GetCharacterSpace(size_t Width, size_t Height, int &PosX, int &PosY)
bool FitGlyph(size_t Width, size_t Height, int &PosX, int &PosY)
{
if(m_TextureDimension < Width || m_TextureDimension < Height)
return false;

// skyline bottom left algorithm
std::vector<size_t> &vSkylineHeights = m_TextureSkyline.m_vCurHeightOfPixelColumn;

// search a fitting area with the least pixel loss
size_t SmallestPixelLossAreaX = 0;
size_t SmallestPixelLossAreaY = m_TextureDimension + 1;
size_t SmallestPixelLossCurPixelLoss = m_TextureDimension * m_TextureDimension;

bool FoundAnyArea = false;
for(size_t i = 0; i < vSkylineHeights.size(); i++)
{
size_t CurHeight = vSkylineHeights[i];
size_t CurPixelLoss = 0;
// find width pixels, and we are happy
size_t AreaWidth = 1;
for(size_t n = i + 1; n < i + Width && n < vSkylineHeights.size(); ++n)
{
++AreaWidth;
if(vSkylineHeights[n] <= CurHeight)
{
CurPixelLoss += CurHeight - vSkylineHeights[n];
}
// if the height changed, we will use that new height and adjust the pixel loss
else
{
CurPixelLoss = 0;
CurHeight = vSkylineHeights[n];
for(size_t l = i; l <= n; ++l)
{
CurPixelLoss += CurHeight - vSkylineHeights[l];
}
}
}

// if the area is too high, continue
if(CurHeight + Height > m_TextureDimension)
continue;
// if the found area fits our needs, check if we can use it
if(AreaWidth == Width)
{
if(SmallestPixelLossCurPixelLoss >= CurPixelLoss)
{
if(CurHeight < SmallestPixelLossAreaY)
{
SmallestPixelLossCurPixelLoss = CurPixelLoss;
SmallestPixelLossAreaX = (int)i;
SmallestPixelLossAreaY = CurHeight;
FoundAnyArea = true;
if(CurPixelLoss == 0)
break;
}
}
}
}

if(FoundAnyArea)
{
PosX = SmallestPixelLossAreaX;
PosY = SmallestPixelLossAreaY;
for(size_t i = PosX; i < PosX + Width; ++i)
{
vSkylineHeights[i] = PosY + Height;
}
return true;
}
return false;
return m_TextureAtlas.Add(Width, Height, PosX, PosY);
}

bool RenderGlyph(SGlyph &Glyph)
Expand Down Expand Up @@ -411,7 +538,7 @@ class CGlyphMap
if(Width > 0 && Height > 0)
{
// find space in atlas, or increase size if necessary
while(!GetCharacterSpace(Width, Height, X, Y))
while(!FitGlyph(Width, Height, X, Y))
{
if(!IncreaseGlyphMapSize())
{
Expand Down Expand Up @@ -466,7 +593,7 @@ class CGlyphMap
mem_zero(pTextureData, m_TextureDimension * m_TextureDimension * sizeof(uint8_t));
}

m_TextureSkyline.m_vCurHeightOfPixelColumn.resize(m_TextureDimension, 0);
m_TextureAtlas.Clear(m_TextureDimension);
UploadTextures();
}

Expand Down Expand Up @@ -546,7 +673,7 @@ class CGlyphMap
Graphics()->UpdateTextTexture(m_aTextures[TextureIndex], 0, 0, m_TextureDimension, m_TextureDimension, m_apTextureData[TextureIndex]);
}

std::fill(m_TextureSkyline.m_vCurHeightOfPixelColumn.begin(), m_TextureSkyline.m_vCurHeightOfPixelColumn.end(), 0);
m_TextureAtlas.Clear(m_TextureDimension);
m_Glyphs.clear();
}

Expand Down

0 comments on commit 6894321

Please sign in to comment.