Anatomie van een Texture
Introductie
Vorig jaar moest ik code schrijven die texture-data tussen verschillende gaming-platformen converteerde. Bij aanvang had ik de complexiteit van texture-geheugenlayouts ernstig onderschat.
Ik werk momenteel in een team bij 505 Games aan het porteren van een custom PC-game-engine naar consoles. Een van de vele uitdagingen hierbij was de texture-conversie. Ik wist dat er veel complexiteiten en nuances aan kleefden en ik herkende de meeste daarvan in isolatie. Pas toen ik werkende code moest schrijven die al deze details gelijktijdig afhandelde, drong de volledige complexiteit echt tot me door. Daarom dacht ik dat dit een interessant blogbericht zou worden.
Dit artikel legt de complexiteit van texture-geheugenlayout uit en beargumenteert waarom dit noodzakelijk is. Dit wordt beschreven vanuit het perspectief van iemand die probeert de berekening van geheugenadressen voor elk onderdeel van een console-texture te debuggen. Vanwege het onderwerp zal dit bericht technisch van aard zijn.
Ik ga ervan uit dat de lezer bekend is met de basisprincipes van programmeren, geheugenlayouts, basistechnologie voor videogames en digitale beeldvorming.
In dit artikel gebruiken we een 1024x1024 RGBA-texture met BC7 als voorbeeld. Ter illustratie gebruiken we een eenvoudige houttextuur.
Figuur 1. Voorbeeldtextuur van 1024x1024
Het converteren van deze texture tussen platformen vereist dat we veel details bijhouden. In dit artikel leg ik de volgende zaken uit: blokcompressie, texel-volgorde, mips en texture-tiles. Er zijn andere details, zoals pitch, diepte en de texture-array-index. Deze vereisen voornamelijk eenvoudige offsets die weliswaal hinderlijk zijn, maar geen interessante theorie toevoegen, dus zal ik deze negeren.
Hieronder zie je hoe dat beeld eruit zou zien als we al deze complicaties negeren en het resultaat interpreteren als een eenvoudige stroom van kleurwaarden:
Figuur 2. 1024x1024 chaotische regenboogruis
Platformspecifieke kenmerken
Als dit bericht over texture-formaten gaat, waarom praat ik dan over geheugenlocaties? Alle details die we zullen bespreken vertonen belangrijke overeenkomsten en verschillen tussen platformen. In het bijzonder worden textures op alle platformen ontbonden in dezelfde hiërarchie van bouwstenen. Echter, op elk niveau van die hiërarchie kan de volgorde van deze elementen in het geheugen per platform verschillen.
Dit betekent dat, gegeven een geheugengebied voor de texture, het converteren van een texture tussen platformen kan worden gezien als het probleem van het berekenen van het verwachte geheugenadres van elk element na conversie.
We zullen de meeste van deze componenten op een platformonafhankelijke manier bespreken. Waar een onderscheid noodzakelijk is, baseren we ons op de visie van DirectX 12.
Geïdealiseerd beeld
Hoe zou men, op basis van een algemeen begrip van digitale afbeeldingen, verwachten dat een 2D-texture in het geheugen wordt gerepresenteerd? Afbeeldingen hebben een aantal kleurkanalen, elk met een specifieke bitdiepte. Een gebruikelijke bitdiepte is 8, wat betekent: één byte per kanaal. Dit geeft ons waarden in het bereik [0, 255] per kleur. Een afbeelding bestaat uit een raster van kleurmonsters, doorgaans over twee dimensies: breedte en hoogte. Bij gewone afbeeldingen worden deze monsters pixels genoemd; bij textures noemen we ze texels.
Op basis hiervan zou de naïve aanname zijn dat een RGBA-afbeelding met een 32-bit kleurdiepte gewoon een stroom van texels is die van links naar rechts, rij voor rij, gaat, waarbij elke texel wordt weergegeven als vier opeenvolgende bytes: één voor elk kanaal. Hoewel sommige eenvoudige textures inderdaad zo werken, is dit helaas erg ver verwijderd van hoe de meeste textures in moderne videogames worden gerepresenteerd.
De reden hiervoor is prestatie. Er worden verschillende orthogonale technieken toegepast die de representatie van textures compliceren, maar de rendering-prestaties verbeteren. De meeste (zoals texel-volgorde en texture-tiles) bestaan om de cache-lokaliteit te verbeteren. Dat wil zeggen: we doen ons best om data zo dicht mogelijk bij alle andere data op te slaan waarvan we verwachten dat we deze op hetzelfde moment nodig hebben. Goede lokaliteit vermindert drastisch de tijd die verloren gaat aan het wachten op geheugentransfers, wat een van de duurste operaties is in moderne computerhardware.
Hoewel blokcompressie ook helpt bij de lokaliteit, verbetert het primair de snelheid van geheugentransfers door de grootte van de texture in het geheugen te verkleinen. Ten slotte hebben we mips, die de renderingkosten drastisch verlagen door de volledige texture vooraf in meerdere stappen te downsamplen. Hiermee voorkomen we de vele dure texture-samples die anders nodig zouden zijn telkens wanneer we alle texels in een omliggend gebied willen middelen.
Vanuit het perspectief van iemand die texture-loading tussen platformen debugt, is elke een van deze technieken een extra complicatie om in de gaten te houden.
Een enkele texel
Laten we kijken naar hoe een werkelijke texel wordt gerepresenteerd in een typische moderne videogame-texture. Voor de eenvoud nemen we aan dat de oorspronkelijke afbeelding RGBA is met 8 bits per kanaal. Dit geeft een totaal van 32 bits (4 bytes) per texel als deze niet gecomprimeerd is. De meeste moderne textures maken echter gebruik van blokcompressie. Blokcompressie verkleint de geheugenomvang van textures om de datatransfertijden te verkorten.
Het meest voorkomende blokcompressieformaat van dit moment is BC7. Dit is een zeer complex formaat met meer eigenaardigheden dan ik in dit artikel kan uitleggen. In de kern is BC7 een lossy compressieformaat dat leunt op slimme aannames over de gelijkenis van aangrenzende texels. Het slaat texels op in blokken van 4x4. Elk blok specificeert paren van referentiekleuren, genaamd end points. Elke texel krijgt vervolgens zijn kleur door een index te specificeren die een geïnterpoleerde waarde tussen deze end points identificeert.
Een enkel blok is 16 bytes groot en beschrijft 16 texels, wat neerkomt op één byte per texel: een compressiefactor van 4x voor een RGBA-texture met 8-bit kanalen. Ondanks deze grote compressiefactor is het voor het menselijk oog erg moeilijk om het verschil te zien tussen BC7-gecomprimeerde textures en ongecomprimeerde textures, zelfs niet in screenshots naast elkaar. Dit is goed nieuws voor game-ontwikkelaars die het streamen van grote aantallen grote textures moeten optimaliseren. Een ander voordeel van het comprimeren van texels in blokken is dat veel effecten vereisen dat er meerdere aangrenzende texels worden gesampled. Door nabijgelegen texels dichter bij elkaar in het geheugen te plaatsen, wordt de cache-lokaliteit verhoogd en de geheugentoegang versneld.
Als we onze texture correct behandelen als een reeks van 16-byte chunks, waarbij elk chunk een 4x4 BC7-blok representeert, zien we blokken met de juiste kleuren, maar in de verkeerde volgorde.
Figuur 3. 1024x1024 kriskras door elkaar lopende texels in de verkeerde volgorde
Helaas voegt blokcompressie een aanzienlijke hoeveelheid complexiteit toe aan de conversiecode. Dit komt omdat het zowel de geheugenomvang van de texture als de texture-indexering verandert. De grootste valkuil is dat niet alle textures gecomprimeerd zijn, waardoor de code consistent de juiste actie moet ondernemen in beide gevallen.
Code die anders een enkele texel zou lezen of schrijven, moet nu controleren of er met een texel of een blok wordt gewerkt. Voor niet-gecomprimeerde textures zijn breedte en hoogte simpelweg indexen over elke dimensie. Gecomprimeerde textures moeten itereren over blokken van 4x4 texels tegelijk. Compressie beïnvloedt ook hoe de grootte van een texture in het geheugen wordt berekend. De geheugenomvang hangt af van de resolutie, het aantal kanalen, de bitdiepte van het kanaal en de potentiële compressiemodus.
Een block debuggen
De hoeveelheid slimme trucjes die door BC7 worden gebruikt, is slecht nieuws voor debugging. Het maakt een memory dump praktisch onleesbaar zonder tooling. Graphics-debuggers bevatten ingebouwde tools om textures te visualiseren en te analyseren, maar dat helpt vaak niet wanneer het resultaat eruitziet als de bovenstaande figuren. Het is daarnaast niet triviaal om visueel interpreteerbare debug-informatie naar een gecomprimeerde texture te schrijven. Als je ruwe waarden schrijft en de compressie negeert, krijg je een chaos van betekenisloze kleuren.
Wat visuele debugging echt compliceert, is dat elke accidentele offset die je byte-alignment verandert, alle data visueel incoherent maakt. Dit komt omdat het beïnvloedt welke delen van de blokken worden gelezen als end points en welke worden geïnterpreteerd als indices. Als je debug-informatie schrijft omdat je een probleem hebt met je berekeningen van texture-adressen, kan het zelfs een uitdaging zijn om te vinden waar deze informatie is beland.
Gelukkig heb ik een paar nuttige trucs gevonden voor debugging:
- In de meeste gevallen kun je elk blok van 16 bytes (128 bits) simpelweg vervangen door 4 afzonderlijke 32-bit integers naar keuze. Bijvoorbeeld: dit is precies genoeg om x, y, z-coördinaten en het mip-niveau op te slaan. Op die manier kun je gemakkelijk de exacte geheugenoffset identificeren door de memory dump van een gegeven texel-blok te lezen en de geschreven waarden te vergelijken met de werkelijke coördinaten.
- In sommige gevallen moest ik grotere chunks data schrijven naar specifieke texture-locaties die voldeden aan bepaalde debugcriteria. In deze gevallen kun je 16 bytes van uitsluitend nullen schrijven om een blok van 4x4 texels te krijgen dat technisch gezien ongeldig is, maar gegarandeerd als zwart wordt gerenderd. Ik heb een reeks zwarte blokken gebruikt als visuele markeringen; net genoeg om een paar zichtbare blokken te krijgen, zelfs bij alignment-problemen. Tussen deze markeringen schrijf ik mijn ruwe debug-datawaarden. Zo kan ik visueel debug-secties in een texture-view identificeren, de geheugenlocatie van de bijbehorende texel vinden en vervolgens memory dumps gebruiken om de werkelijke waarden tussen de markeringen te lezen.
Texel-volgorde
Het is makkelijk om texture-geheugen voor te stellen als een bytestroom die texel voor texel, rij voor rij, verloopt. Helaas is zo'n layout niet ideaal. We willen alles doen om de lokaliteit van naburige texels te vergroten. Dat wil zeggen: texels die visueel dicht bij elkaar liggen, moeten ook dicht bij elkaar in het geheugen liggen. Dit is belangrijk omdat naburige texels vaak samen worden gesampled.
Om dit te bereiken gebruiken textures vaak verschillende patronen voor de geheugenlayout. Zo'n patroon wordt een swizzle genoemd. Bij het itereren over alle texels in een texture stelt de swizzle je in staat om een texel-index om te zetten naar de coördinaten van de bijbehorende texel. De bekendste swizzle is waarschijnlijk de Morton order. Deze swizzle betekent dat de volgorde van texels in het geheugen niet van links naar rechts per rij gaat, maar in een fractaal Z-patroon over de texture beweegt.
Figuur 4. Een vergelijking tussen lineaire (links) versus Morton (rechts) texture-volgorde.
Er zijn andere mogelijke volgordes, en welke nodig is, hangt af van de details van je specifieke texture. Deze keuze kan voor dezelfde texture per platform verschillen. Dit betekent dat je de texels volledig opnieuw moet ordenen bij het converteren van een texture van het ene platform naar het andere. In het algemene geval moet je conversiecode rekening houden met zowel de volgorde die wordt gebruikt voor het bronplatform als die voor het doelplatform.
Net als bij blokcompressie maakt deze herordening het debuggen complexer. Correct uitgelijnd texture-geheugen waarbij de verkeerde swizzle wordt gebruikt, zorgt ervoor dat je afbeelding eruitziet als 4x4 puzzelstukjes die door elkaar zijn gehusseld (zie Figuur 3).
Mips
Textures zijn eigenlijk geen enkele afbeelding. Elke texture bevat een reeks steeds verder verkleinde versies van dezelfde afbeelding, genaamd mips. De grootste versie (mip 0) heeft de grootte van de oorspronkelijke afbeelding. Elke volgende mip is de helft van de breedte en hoogte van de vorige.
De reden hiervoor is dat we bij het renderen willen dat de resolutie van de gesamplede texels overeenkomt met de resolutie van het render-target. Voor een getextureerd oppervlak dat verder van de camera af staat, zal elke pixel meerdere texels overlappen. Dit betekent dat we een groter gebied van de texture moeten samplen, wat relatief duur is. Als we dit niet doen, ontstaan er aliasing-artefacten zoals shimmering en Moiré-patronen. De oplossing is om mips vooraf te maken. Bij het renderen van een getextureerd oppervlak kan de shader dan de mip gebruiken waarbij de texel-grootte het dichtst bij de pixel-grootte van het render-target ligt. Real-world renderers gebruiken geavanceerdere texture-filteringstechnieken, maar ze leunen allemaal op mips om area-sampling vooraf te berekenen.
Mips zijn grotendeels in een goed gedefinieerde volgorde achter elkaar in het geheugen geplaatst. Echter, of ze in oplopende of aflopende volgorde worden opgeslagen, varieert per platform. Dit betekent dat je niet simpelweg over ze heen kunt itereren en zowel de bron- als doelpointer synchroon kunt verhogen. Voor elke mip moet je uitzoeken waar deze in het geheugen begint voor zowel het bron- als het doelplatform.
Figuur 5. Illustratie van de 256x256 mip naast alle hogere mips.
Tiles
De volgende complicatie zijn tiles. Opnieuw: we willen de cache-lokaliteit optimaliseren wanneer we werken aan een texel en diens omgeving. Een andere manier waarop dit wordt gedaan, is door het texture-geheugen op te splitsen in tiles. Deze tiles bevatten elk een vierkant stuk van de onderliggende mip. Deze layout stelt games ook in staat om alleen de zichtbare delen van grote textures te streamen. Binnen elke tile zijn aangrenzende texels geblokcomprimeerd en geswizzled zoals eerder beschreven. Tiles zijn lineair in het geheugen geplaatst van linksboven naar rechtsonder.
Een tile is doorgaans 64 KiB. Dit betekent dat een 1024x1024 texture-mip met BC7 is opgebouwd uit 16 tiles in een 4x4 raster, waarbij elke tile een vierkant van 256x256 texels bevat. Met BC7 bevat elke tile 4096 blokken van 4x4 texels, geordend volgens het gebruikte swizzle-patroon.
Figuur 6. 1024x1024 texture waarbij elke tile is gemarkeerd met een andere tint
Als we nadenken over hoe we dit in het geheugen opslaan, is het eenvoudig om een hiërarchisch beeld te hanteren: textures bestaan uit mips, mips bestaan uit tiles, en tiles bestaan uit blokken of texels. Natuurlijk zullen de kleinste mips veel kleiner zijn dan een volledige tile, dus zou het erg verspillend zijn om ze elk een volledige tile te geven. In ons voorbeeld zouden de laatste 8 mips in één enkele tile van 64 KiB passen! Wanneer je dit voor elke texture doet, tellen de geheugenbesparingen snel op. Daarom pakken de meeste platformen alle mips die kleiner zijn dan een volledige tile in zo weinig mogelijk tiles. Dit wordt de mip tail genoemd. Figuur 5 laat precies de 256x256 mip zien die één tile vult, naast alle hogere mips, wat aantoont dat ze samen in één gepakte tile passen.
Dit betekent dat we rekening moeten houden met zowel mips die over meerdere tiles verspreid zijn als tiles die meerdere gepakte mips bevatten.
Niet-contigue tiles
Hoewel alles hierboven bijna voldoende is om een contigue lineaire texture om te zetten naar een console-specifieke geheugenlayout, is er nog één laatste complicatie. In een moderne game-engine worden textures vaak tile voor tile gestreamd naar een gedeeld gebied van texture-geheugen (in DirectX 12 een heap genoemd). Elke tile van een texture is gemapt naar zijn eigen geheugengebied binnen deze heap. Omdat verschillende mips op aanvraag worden in- en uitgestreamd, kan er fragmentatie van het texture-geheugen optreden.
Dit betekent dat verschillende tiles van een texture mogelijk niet in een aaneengesloten (contigu) gebied van het texture-geheugen terechtkomen. Dit houdt in dat als we data naar het texture-geheugen schrijven, we een vertaling moeten maken van de tile-index van een texture naar het specifieke adres waar deze tile is gemapt. Daarna voegen we een offset binnen de gegeven tile toe om het werkelijke geheugenadres te verkrijgen. Een simpelere aanpak zou zijn om berekeningen te baseren op het basisadres van de eerste texel van onze texture plus een globale offset. Maar bij niet-contigue textures zou dit tiles van andere textures overschrijven en sommige van onze eigen tiles ongeïnitialiseerd laten. Dit is vergelijkbaar met een werkelijke bug die ik tijdens dit project veroorzaakte, en het kostte me een hele tijd om de foutieve aanname te ontdekken!
Samenvatting
Wanneer je al deze elementen combineert, krijg je een ruw beeld van hoe een texture in het geheugen is georganiseerd en hoe dit per platform kan verschillen. Het implementeren van alles in mijn conversiecode kostte verrassend veel werk. Onderweg stuitte ik voortdurend op speciale gevallen en specifieke textures die impliciete aannames die ik had gedaan, doorbraken. Hopelijk deel je nu mijn waardering voor dit aspect van de complexiteit die ervoor zorgt dat je games net iets sneller draaien.
***
Over de auteur Mijn naam is Daniel "Agentlien" Kvick en ik ben een Graphics Programmer met een passie voor games. Ik werk momenteel als Senior Software Engineer bij 505 Games.
Anatomie van een Texture
Introductie
Vorig jaar moest ik code schrijven die texture-data tussen verschillende gaming-platformen converteerde. Bij aanvang had ik de complexiteit van texture-geheugenlayouts ernstig onderschat.
Ik werk momenteel in een team bij 505 Games aan het porteren van een custom PC-game-engine naar consoles. Een van de vele uitdagingen hierbij was de texture-conversie. Ik wist dat er veel complexiteiten en nuances aan kleefden en ik herkende de meeste daarvan in isolatie. Pas toen ik werkende code moest schrijven die al deze details gelijktijdig afhandelde, drong de volledige complexiteit echt tot me door. Daarom dacht ik dat dit een interessant blogbericht zou worden.
Dit artikel legt de complexiteit van texture-geheugenlayout uit en beargumenteert waarom dit noodzakelijk is. Dit wordt beschreven vanuit het perspectief van iemand die probeert de berekening van geheugenadressen voor elk onderdeel van een console-texture te debuggen. Vanwege het onderwerp zal dit bericht technisch van aard zijn.
Ik ga ervan uit dat de lezer bekend is met de basisprincipes van programmeren, geheugenlayouts, basistechnologie voor videogames en digitale beeldvorming.
In dit artikel gebruiken we een 1024x1024 RGBA-texture met BC7 als voorbeeld. Ter illustratie gebruiken we een eenvoudige houttextuur.
Figuur 1. Voorbeeldtextuur van 1024x1024
Het converteren van deze texture tussen platformen vereist dat we veel details bijhouden. In dit artikel leg ik de volgende zaken uit: blokcompressie, texel-volgorde, mips en texture-tiles. Er zijn andere details, zoals pitch, diepte en de texture-array-index. Deze vereisen voornamelijk eenvoudige offsets die weliswaal hinderlijk zijn, maar geen interessante theorie toevoegen, dus zal ik deze negeren.
Hieronder zie je hoe dat beeld eruit zou zien als we al deze complicaties negeren en het resultaat interpreteren als een eenvoudige stroom van kleurwaarden:
Figuur 2. 1024x1024 chaotische regenboogruis
Platformspecifieke kenmerken
Als dit bericht over texture-formaten gaat, waarom praat ik dan over geheugenlocaties? Alle details die we zullen bespreken vertonen belangrijke overeenkomsten en verschillen tussen platformen. In het bijzonder worden textures op alle platformen ontbonden in dezelfde hiërarchie van bouwstenen. Echter, op elk niveau van die hiërarchie kan de volgorde van deze elementen in het geheugen per platform verschillen.
Dit betekent dat, gegeven een geheugengebied voor de texture, het converteren van een texture tussen platformen kan worden gezien als het probleem van het berekenen van het verwachte geheugenadres van elk element na conversie.
We zullen de meeste van deze componenten op een platformonafhankelijke manier bespreken. Waar een onderscheid noodzakelijk is, baseren we ons op de visie van DirectX 12.
Geïdealiseerd beeld
Hoe zou men, op basis van een algemeen begrip van digitale afbeeldingen, verwachten dat een 2D-texture in het geheugen wordt gerepresenteerd? Afbeeldingen hebben een aantal kleurkanalen, elk met een specifieke bitdiepte. Een gebruikelijke bitdiepte is 8, wat betekent: één byte per kanaal. Dit geeft ons waarden in het bereik [0, 255] per kleur. Een afbeelding bestaat uit een raster van kleurmonsters, doorgaans over twee dimensies: breedte en hoogte. Bij gewone afbeeldingen worden deze monsters pixels genoemd; bij textures noemen we ze texels.
Op basis hiervan zou de naïve aanname zijn dat een RGBA-afbeelding met een 32-bit kleurdiepte gewoon een stroom van texels is die van links naar rechts, rij voor rij, gaat, waarbij elke texel wordt weergegeven als vier opeenvolgende bytes: één voor elk kanaal. Hoewel sommige eenvoudige textures inderdaad zo werken, is dit helaas erg ver verwijderd van hoe de meeste textures in moderne videogames worden gerepresenteerd.
De reden hiervoor is prestatie. Er worden verschillende orthogonale technieken toegepast die de representatie van textures compliceren, maar de rendering-prestaties verbeteren. De meeste (zoals texel-volgorde en texture-tiles) bestaan om de cache-lokaliteit te verbeteren. Dat wil zeggen: we doen ons best om data zo dicht mogelijk bij alle andere data op te slaan waarvan we verwachten dat we deze op hetzelfde moment nodig hebben. Goede lokaliteit vermindert drastisch de tijd die verloren gaat aan het wachten op geheugentransfers, wat een van de duurste operaties is in moderne computerhardware.
Hoewel blokcompressie ook helpt bij de lokaliteit, verbetert het primair de snelheid van geheugentransfers door de grootte van de texture in het geheugen te verkleinen. Ten slotte hebben we mips, die de renderingkosten drastisch verlagen door de volledige texture vooraf in meerdere stappen te downsamplen. Hiermee voorkomen we de vele dure texture-samples die anders nodig zouden zijn telkens wanneer we alle texels in een omliggend gebied willen middelen.
Vanuit het perspectief van iemand die texture-loading tussen platformen debugt, is elke een van deze technieken een extra complicatie om in de gaten te houden.
Een enkele texel
Laten we kijken naar hoe een werkelijke texel wordt gerepresenteerd in een typische moderne videogame-texture. Voor de eenvoud nemen we aan dat de oorspronkelijke afbeelding RGBA is met 8 bits per kanaal. Dit geeft een totaal van 32 bits (4 bytes) per texel als deze niet gecomprimeerd is. De meeste moderne textures maken echter gebruik van blokcompressie. Blokcompressie verkleint de geheugenomvang van textures om de datatransfertijden te verkorten.
Het meest voorkomende blokcompressieformaat van dit moment is BC7. Dit is een zeer complex formaat met meer eigenaardigheden dan ik in dit artikel kan uitleggen. In de kern is BC7 een lossy compressieformaat dat leunt op slimme aannames over de gelijkenis van aangrenzende texels. Het slaat texels op in blokken van 4x4. Elk blok specificeert paren van referentiekleuren, genaamd end points. Elke texel krijgt vervolgens zijn kleur door een index te specificeren die een geïnterpoleerde waarde tussen deze end points identificeert.
Een enkel blok is 16 bytes groot en beschrijft 16 texels, wat neerkomt op één byte per texel: een compressiefactor van 4x voor een RGBA-texture met 8-bit kanalen. Ondanks deze grote compressiefactor is het voor het menselijk oog erg moeilijk om het verschil te zien tussen BC7-gecomprimeerde textures en ongecomprimeerde textures, zelfs niet in screenshots naast elkaar. Dit is goed nieuws voor game-ontwikkelaars die het streamen van grote aantallen grote textures moeten optimaliseren. Een ander voordeel van het comprimeren van texels in blokken is dat veel effecten vereisen dat er meerdere aangrenzende texels worden gesampled. Door nabijgelegen texels dichter bij elkaar in het geheugen te plaatsen, wordt de cache-lokaliteit verhoogd en de geheugentoegang versneld.
Als we onze texture correct behandelen als een reeks van 16-byte chunks, waarbij elk chunk een 4x4 BC7-blok representeert, zien we blokken met de juiste kleuren, maar in de verkeerde volgorde.
Figuur 3. 1024x1024 kriskras door elkaar lopende texels in de verkeerde volgorde
Helaas voegt blokcompressie een aanzienlijke hoeveelheid complexiteit toe aan de conversiecode. Dit komt omdat het zowel de geheugenomvang van de texture als de texture-indexering verandert. De grootste valkuil is dat niet alle textures gecomprimeerd zijn, waardoor de code consistent de juiste actie moet ondernemen in beide gevallen.
Code die anders een enkele texel zou lezen of schrijven, moet nu controleren of er met een texel of een blok wordt gewerkt. Voor niet-gecomprimeerde textures zijn breedte en hoogte simpelweg indexen over elke dimensie. Gecomprimeerde textures moeten itereren over blokken van 4x4 texels tegelijk. Compressie beïnvloedt ook hoe de grootte van een texture in het geheugen wordt berekend. De geheugenomvang hangt af van de resolutie, het aantal kanalen, de bitdiepte van het kanaal en de potentiële compressiemodus.
Een block debuggen
De hoeveelheid slimme trucjes die door BC7 worden gebruikt, is slecht nieuws voor debugging. Het maakt een memory dump praktisch onleesbaar zonder tooling. Graphics-debuggers bevatten ingebouwde tools om textures te visualiseren en te analyseren, maar dat helpt vaak niet wanneer het resultaat eruitziet als de bovenstaande figuren. Het is daarnaast niet triviaal om visueel interpreteerbare debug-informatie naar een gecomprimeerde texture te schrijven. Als je ruwe waarden schrijft en de compressie negeert, krijg je een chaos van betekenisloze kleuren.
Wat visuele debugging echt compliceert, is dat elke accidentele offset die je byte-alignment verandert, alle data visueel incoherent maakt. Dit komt omdat het beïnvloedt welke delen van de blokken worden gelezen als end points en welke worden geïnterpreteerd als indices. Als je debug-informatie schrijft omdat je een probleem hebt met je berekeningen van texture-adressen, kan het zelfs een uitdaging zijn om te vinden waar deze informatie is beland.
Gelukkig heb ik een paar nuttige trucs gevonden voor debugging:
- In de meeste gevallen kun je elk blok van 16 bytes (128 bits) simpelweg vervangen door 4 afzonderlijke 32-bit integers naar keuze. Bijvoorbeeld: dit is precies genoeg om x, y, z-coördinaten en het mip-niveau op te slaan. Op die manier kun je gemakkelijk de exacte geheugenoffset identificeren door de memory dump van een gegeven texel-blok te lezen en de geschreven waarden te vergelijken met de werkelijke coördinaten.
- In sommige gevallen moest ik grotere chunks data schrijven naar specifieke texture-locaties die voldeden aan bepaalde debugcriteria. In deze gevallen kun je 16 bytes van uitsluitend nullen schrijven om een blok van 4x4 texels te krijgen dat technisch gezien ongeldig is, maar gegarandeerd als zwart wordt gerenderd. Ik heb een reeks zwarte blokken gebruikt als visuele markeringen; net genoeg om een paar zichtbare blokken te krijgen, zelfs bij alignment-problemen. Tussen deze markeringen schrijf ik mijn ruwe debug-datawaarden. Zo kan ik visueel debug-secties in een texture-view identificeren, de geheugenlocatie van de bijbehorende texel vinden en vervolgens memory dumps gebruiken om de werkelijke waarden tussen de markeringen te lezen.
Texel-volgorde
Het is makkelijk om texture-geheugen voor te stellen als een bytestroom die texel voor texel, rij voor rij, verloopt. Helaas is zo'n layout niet ideaal. We willen alles doen om de lokaliteit van naburige texels te vergroten. Dat wil zeggen: texels die visueel dicht bij elkaar liggen, moeten ook dicht bij elkaar in het geheugen liggen. Dit is belangrijk omdat naburige texels vaak samen worden gesampled.
Om dit te bereiken gebruiken textures vaak verschillende patronen voor de geheugenlayout. Zo'n patroon wordt een swizzle genoemd. Bij het itereren over alle texels in een texture stelt de swizzle je in staat om een texel-index om te zetten naar de coördinaten van de bijbehorende texel. De bekendste swizzle is waarschijnlijk de Morton order. Deze swizzle betekent dat de volgorde van texels in het geheugen niet van links naar rechts per rij gaat, maar in een fractaal Z-patroon over de texture beweegt.
Figuur 4. Een vergelijking tussen lineaire (links) versus Morton (rechts) texture-volgorde.
Er zijn andere mogelijke volgordes, en welke nodig is, hangt af van de details van je specifieke texture. Deze keuze kan voor dezelfde texture per platform verschillen. Dit betekent dat je de texels volledig opnieuw moet ordenen bij het converteren van een texture van het ene platform naar het andere. In het algemene geval moet je conversiecode rekening houden met zowel de volgorde die wordt gebruikt voor het bronplatform als die voor het doelplatform.
Net als bij blokcompressie maakt deze herordening het debuggen complexer. Correct uitgelijnd texture-geheugen waarbij de verkeerde swizzle wordt gebruikt, zorgt ervoor dat je afbeelding eruitziet als 4x4 puzzelstukjes die door elkaar zijn gehusseld (zie Figuur 3).
Mips
Textures zijn eigenlijk geen enkele afbeelding. Elke texture bevat een reeks steeds verder verkleinde versies van dezelfde afbeelding, genaamd mips. De grootste versie (mip 0) heeft de grootte van de oorspronkelijke afbeelding. Elke volgende mip is de helft van de breedte en hoogte van de vorige.
De reden hiervoor is dat we bij het renderen willen dat de resolutie van de gesamplede texels overeenkomt met de resolutie van het render-target. Voor een getextureerd oppervlak dat verder van de camera af staat, zal elke pixel meerdere texels overlappen. Dit betekent dat we een groter gebied van de texture moeten samplen, wat relatief duur is. Als we dit niet doen, ontstaan er aliasing-artefacten zoals shimmering en Moiré-patronen. De oplossing is om mips vooraf te maken. Bij het renderen van een getextureerd oppervlak kan de shader dan de mip gebruiken waarbij de texel-grootte het dichtst bij de pixel-grootte van het render-target ligt. Real-world renderers gebruiken geavanceerdere texture-filteringstechnieken, maar ze leunen allemaal op mips om area-sampling vooraf te berekenen.
Mips zijn grotendeels in een goed gedefinieerde volgorde achter elkaar in het geheugen geplaatst. Echter, of ze in oplopende of aflopende volgorde worden opgeslagen, varieert per platform. Dit betekent dat je niet simpelweg over ze heen kunt itereren en zowel de bron- als doelpointer synchroon kunt verhogen. Voor elke mip moet je uitzoeken waar deze in het geheugen begint voor zowel het bron- als het doelplatform.
Figuur 5. Illustratie van de 256x256 mip naast alle hogere mips.
Tiles
De volgende complicatie zijn tiles. Opnieuw: we willen de cache-lokaliteit optimaliseren wanneer we werken aan een texel en diens omgeving. Een andere manier waarop dit wordt gedaan, is door het texture-geheugen op te splitsen in tiles. Deze tiles bevatten elk een vierkant stuk van de onderliggende mip. Deze layout stelt games ook in staat om alleen de zichtbare delen van grote textures te streamen. Binnen elke tile zijn aangrenzende texels geblokcomprimeerd en geswizzled zoals eerder beschreven. Tiles zijn lineair in het geheugen geplaatst van linksboven naar rechtsonder.
Een tile is doorgaans 64 KiB. Dit betekent dat een 1024x1024 texture-mip met BC7 is opgebouwd uit 16 tiles in een 4x4 raster, waarbij elke tile een vierkant van 256x256 texels bevat. Met BC7 bevat elke tile 4096 blokken van 4x4 texels, geordend volgens het gebruikte swizzle-patroon.
Figuur 6. 1024x1024 texture waarbij elke tile is gemarkeerd met een andere tint
Als we nadenken over hoe we dit in het geheugen opslaan, is het eenvoudig om een hiërarchisch beeld te hanteren: textures bestaan uit mips, mips bestaan uit tiles, en tiles bestaan uit blokken of texels. Natuurlijk zullen de kleinste mips veel kleiner zijn dan een volledige tile, dus zou het erg verspillend zijn om ze elk een volledige tile te geven. In ons voorbeeld zouden de laatste 8 mips in één enkele tile van 64 KiB passen! Wanneer je dit voor elke texture doet, tellen de geheugenbesparingen snel op. Daarom pakken de meeste platformen alle mips die kleiner zijn dan een volledige tile in zo weinig mogelijk tiles. Dit wordt de mip tail genoemd. Figuur 5 laat precies de 256x256 mip zien die één tile vult, naast alle hogere mips, wat aantoont dat ze samen in één gepakte tile passen.
Dit betekent dat we rekening moeten houden met zowel mips die over meerdere tiles verspreid zijn als tiles die meerdere gepakte mips bevatten.
Niet-contigue tiles
Hoewel alles hierboven bijna voldoende is om een contigue lineaire texture om te zetten naar een console-specifieke geheugenlayout, is er nog één laatste complicatie. In een moderne game-engine worden textures vaak tile voor tile gestreamd naar een gedeeld gebied van texture-geheugen (in DirectX 12 een heap genoemd). Elke tile van een texture is gemapt naar zijn eigen geheugengebied binnen deze heap. Omdat verschillende mips op aanvraag worden in- en uitgestreamd, kan er fragmentatie van het texture-geheugen optreden.
Dit betekent dat verschillende tiles van een texture mogelijk niet in een aaneengesloten (contigu) gebied van het texture-geheugen terechtkomen. Dit houdt in dat als we data naar het texture-geheugen schrijven, we een vertaling moeten maken van de tile-index van een texture naar het specifieke adres waar deze tile is gemapt. Daarna voegen we een offset binnen de gegeven tile toe om het werkelijke geheugenadres te verkrijgen. Een simpelere aanpak zou zijn om berekeningen te baseren op het basisadres van de eerste texel van onze texture plus een globale offset. Maar bij niet-contigue textures zou dit tiles van andere textures overschrijven en sommige van onze eigen tiles ongeïnitialiseerd laten. Dit is vergelijkbaar met een werkelijke bug die ik tijdens dit project veroorzaakte, en het kostte me een hele tijd om de foutieve aanname te ontdekken!
Samenvatting
Wanneer je al deze elementen combineert, krijg je een ruw beeld van hoe een texture in het geheugen is georganiseerd en hoe dit per platform kan verschillen. Het implementeren van alles in mijn conversiecode kostte verrassend veel werk. Onderweg stuitte ik voortdurend op speciale gevallen en specifieke textures die impliciete aannames die ik had gedaan, doorbraken. Hopelijk deel je nu mijn waardering voor dit aspect van de complexiteit die ervoor zorgt dat je games net iets sneller draaien.
***
Over de auteur Mijn naam is Daniel "Agentlien" Kvick en ik ben een Graphics Programmer met een passie voor games. Ik werk momenteel als Senior Software Engineer bij 505 Games.