Software occlusion culling in Block Game
Mijn GPU is de geïntegreerde Radeon Vega 8 die bij mijn AMD Ryzen 7 5700G CPU wordt geleverd. Ik vertel dit zodat je weet dat mijn werkstation geen grafisch krachtstation is; het is in feite vrij zwak. Om eerlijk te zijn, scoort mijn geïntegreerde GPU op UserBenchmark 48% hoger dan de GPU in mijn hardware-doelgroep voor het lage segment: een laptop die ik in 2012 heb gekocht.
(Terzijde: Ik ben me bewust van de beschuldigingen over de onnauwkeurigheid van UserBenchmarks, maar dat is niet zo belangrijk. Ik vind het gewoon grappig dat een iGPU die ik onlangs heb aangeschaft, niet veel beter scoort dan een 14 jaar oude laptop-GPU die destijds al niet als bijzonder geweldig werd beschouwd.)
Dit, en het feit dat ik wil dat mijn game zelfs op een 'potato' goed draait, is de reden waarom ik onlangs heb besloten een software-gerenderde occlusion culling-oplossing te schrijven voor de Block Game (werktitel) die ik ontwikkel. Ik ben altijd al geïnteresseerd geweest in dit idee. Blokken en chunks zijn as-uitgelijnde kubussen, wat zaken eenvoudiger maakt, en blok-games hebben vaak een enorme hoeveelheid verborgen geometrie in de vorm van ondergrondse grotten. Er zijn andere manieren om deze te cullen, maar die algoritmen zijn vaak vrij complex. Dit leek een goede manier om die complexiteit te vermijden en vast te houden aan iets dat conceptueel heel eenvoudig is.
In dit artikel leg ik het ontwikkelingsproces uit en de oplossing waar ik uiteindelijk op ben uitgekomen.
Het resultaat is zeer positief en beter dan ik had verwacht. Het proces draait in een halve frame bij 60 FPS of minder (uiteraard via threading) en cullt over het algemeen ten minste 50% van de chunks die de frustum culling hebben overleefd. Boven de grond, kijkend naar de horizon, cullt het tussen de 50% en 60% van de chunks. Binnen en onder de grond in grotten kan dit oplopen tot boven de 95%, wat resulteert in framerates van 400+ zelfs op mijn zwakke systeem. Al met al een groot succes, hoewel er enkele gevallen zijn waarin het systeem tekortschiet, waar ik aan het einde van dit artikel op in zal gaan.
Afbeelding: Vergelijking van depth occlusion culling aan/uit; links uit, rechts aan.
Dieptegebaseerde occlusion culling
Andere mensen hebben het concept van dieptegebaseerde occlusion culling beter uitgelegd dan ik kan, maar ik loop er kort doorheen. Je neemt de dieptebuffer (depth buffer) van de scène en controleert voor elk object dat geculled kan worden of enkele pixels ervan zichtbaar zouden zijn. Zo ja, dan is het zichtbaar. Zo nee, dan wordt het geculled.
Er zijn veel dingen die je hiermee kunt doen. Je kunt renderen op een relatief hoge resolutie en de buffer conservatief downsamplen (altijd de verste afstand gebruiken), wat hierarchical Z buffers wordt genoemd. Met moderne technologie kun je de GPU inschakelen om dit te doen. Je kunt bewegingsvectoren (motion vectors) gebruiken om de buffer van het vorige frame te hergebruiken. Je zou async readback kunnen gebruiken om een daadwerkelijke dieptebuffer die op de GPU is gerenderd terug te halen voor analyse op de CPU zonder de GPU te stallen.
Ik leg eerst mijn uiteindelijke systeem uit en loop daarna door het proces van hoe ik tot dat systeem ben gekomen.
De occlusion culling van Block Game
Wat ik heb gedaan is veel eenvoudiger en bevat geen van de bovengenoemde fancy functies. Ik gebruik FNA om Block Game te maken, waardoor ik gebonden ben aan oudere technologie en de mogelijkheden van de GPU niet volledig kan benutten. Ik zou een readback van de GPU kunnen doen om de werkelijke dieptebuffer te krijgen, maar niet asynchroon, wat onvermijdelijk een GPU-stall zou veroorzaken. Daarom render ik gewoon op de CPU.
Ik render op een resolutie van 256x128 pixels. Omdat in een blok-game alles uit kubussen bestaat, render ik kubussen in mijn dieptebuffer (een eenvoudige array van floats) en controleer ik vervolgens andere kubussen tegen deze buffer.
Wanneer een chunk opnieuw wordt opgebouwd vanwege wijzigingen, bouw ik een soort "mipmap"-keten van occluderende subchunks. Mijn chunks zijn 16x16x16 groot, dus er zijn 5 niveaus:
- Niveau 0: de gehele chunk.
- Niveau 1: 2x2x2 kubussen, elk 8x8x8 blokken groot.
- Niveau 2: 4x4x4 kubussen, elk 4x4x4 blokken groot.
- Niveau 3: 8x8x8 kubussen, elk 2x2x2 blokken groot.
- Niveau 4: 16x16x16 kubussen, waarbij elke kubus overeenkomt met één enkel blok.
Afbeelding: Subchunk-niveaus 1-4, afzonderlijk gerenderd. Subchunks binnen dezelfde chunk hebben een vergelijkbare kleur.
Ik verwerk eerst niveau 4 en markeer elk volledig opaak blok met een zichtbaar vlak als een occluder. Daarna ga ik door de niveaus naar beneden en markeer ik elke kubus van blokken als occluderende subchunk als deze een zichtbaar vlak heeft en volledig bestaat uit opake blokken.
Zodra de camerapositie voor het frame is bijgewerkt, geeft de game de renderer de opdracht om te beginnen met occlusion culling. Dit verzamelt de chunks rond de speler, verwijdert alle chunks zonder zichtbare vlakken of entiteiten, en voert frustum culling uit in de wereldruimte. De overgebleven chunks worden naar de occlusion culler gestuurd, die alle occluders verzamelt. Het hoogste niveau (individuele blokken) bestaat uit occluders binnen een straal van 20 blokken van de camera. De volgende niveaus (1 t/m 3) worden verzameld op basis van de afstand van de chunks waarin ze zich bevinden ten opzichte van de chunk waar de camera is.
Hoewel er veel niveau 1-occluders van 8x8x8 blokken zijn, lijken er nooit 16x16x16-occluders te zijn, maar theoretisch zou ik die kunnen toevoegen. Om foutieve occlusies (false positives) te voorkomen, verklein ik elke occluder-kubus met 1 pixel horizontaal en verticaal. Dit zorgt ervoor dat ze verdwijnen op een voldoende grote afstand, en er is geen zin om kubussen verder weg te renderen dan waar ze redelijk zichtbaar zijn. Het overslaan van occluders op deze manier vermindert de werklast voor de occlusion culler aanzienlijk.
De occlusion culler slaat ook de positie, grootte en chunk-index van elke kandidaat-chunk op in een lijst. Zodra deze gegevens zijn verzameld, hoeft de occlusion culler de eigenlijke gegevens van de chunks niet meer aan te raken, wat betekent dat we thread safety hebben bereikt. Dit is het signaal voor de achtergrondthread om wakker te worden en het eigenlijke werk te doen.
In de achtergrondthread wordt elke occluder eerst onderworpen aan frustum culling, omdat de eerdere frustum culling alleen gold voor volledige chunks en niet voor subchunks. Acht vertex-transformaties zijn namelijk veel duurder dan de dot-products die nodig zijn voor frustum culling.
De hoekpunten van de overgebleven occluders worden vervolgens getransformeerd naar de x/y-coördinaten en diepte van de dieptebuffer. Om de overhead van een volledige 4x4 projectiematrix-vermenigvuldiging te vermijden, roteer ik met behulp van de view matrix, vertaal ik via optelling, en gebruik ik de lineaire weergte-diepte (linear view depth), aangezien mijn buffer gewoon bestaat uit reguliere 32-bit floats. Dit vereist minder bewerkingen en is aanzienlijk sneller bij duizenden vertexen. De dieptewaarde van de occluder is vervolgens het maximum van alle berekende lineaire dieptewaarden.
Als een van de getransformeerde hoekpunten ongeldig is omdat het zich achter het near plane van de camera bevindt, wordt de occluder volledig overgeslagen. Zo niet, dan wordt de "vorm" getraceerd. In plaats van driehoeken of vlakken te renderen, gebruik ik een 1D-array die de minimum- en maximum-x-coördinaten voor elke rij in de dieptebuffer bijhoudt. De vorm traceren betekent dan het vinden van de minimum- en maximum-y-coördinaten van de 8 kubushoeken, en vervolgens door alle 12 kubusranden stappen om de minimum- en maximum-x-waarden voor elke rij vast te leggen.
Het tekenen van de occluder is vervolgens zo eenvoudig als het doorlopen van elke rij van de minimum-y (plus één) naar de maximum-y (min één), het opzoeken van de minimum- en maximum-x uit de 1D-array (eveneer plus en min 1), en het horizontaal vullen van de dieptebuffer met de verste mogelijke dieptewaarde voor de occluder.
De plus en min één is nodig omdat ik de diepte op een zeer lage resolutie render. Door de kubus met een pixel te verkleinen, voorkom ik randgevallen waarbij een chunk op deze lage resolutie helemaal niet zichtbaar zou zijn, terwijl dat op de volledige resolutie wel het geval is.
Afbeelding: Er is een gat zichtbaar in de scène, maar bij de lage resolutie van de occlusion culler zou zonder de 1-pixel inset het gat volledig bedekt zijn, wat leidt tot een foutieve occlusie.
De occlusion culler doet vervolgens iets soortgelijks voor alle kandidaten voor occlusie, behalve dat hij nu de dichtstbijzijnde afstand registreert in plaats van de verste, en er geen 1-pixel inset is. Als de diepte kleiner is dan of gelijk is aan een van de waarden in de buffer, is de chunk zichtbaar en hoeven er geen pixels meer gecontroleerd te worden. Als de diepte nergens doorheen komt, kan de chunk worden geculled. Een opmerkelijke wijziging hier is dat als een van de hoekpunten van een kandidaat-chunk ongeldig is (omdat deze zich achter het near plane bevindt), deze simpelweg als zichtbaar wordt beschouwd, omdat we de check niet kunnen uitvoeren.
Deze aanpak werkt goed in diverse situaties en presteert goed, zelfs op tragere CPU's. Het helpt oudere systemen of systemen met zwakkere graphics, zoals die van mij, om goede framerates te behalen in de meeste normale scenario's, zonder de complexiteit of overhead van cave visibility-algoritmen.
Het proces
Natuurlijk kwam ik niet direct uit op deze volledig gevormde en geoptimaliseerde aanpak. Mijn eerste poging was om elke chunk te verdelen in een vast aantal subchunks. Ik probeerde 2 en 4 op elke zijde (voor respectievelijk 8 en 16 subchunks per chunk) en registreerde simpelweg of een subchunk vol zat met opake blokken, ongeacht of deze zichtbare vlakken had.
Ik stuurde vervolgens alle subchunks van chunks die niet-lucht/leegte-blokken of zichtbare entiteiten bevatten naar de occlusion culler en transformeerde hun vertexen met een volledige 4x4 projectiematrix, vermenigvuldigd met 1/w, waarbij de x/y/z-coördinaten werden vastgelegd. Ik bewaarde de getransformeerde coördinaten in een lijst, renderde de occluders in één pass en las de occlusie-kandidaten in een andere pass. Als enige subchunk van een kandidaat zichtbaar was, werd de hele chunk als zichtbaar gemarkeerd.
Ik voerde op dat moment ook geen correcte frustum culling uit voor deze fase, maar elimineerde alleen chunks waarvan ik wist dat ze volledig achter de camera lagen. De occlusion culler deed in deze fase dus ook de frustum culling.
Dit werkte, voor sommige definities van het woord "werken", in die zin dat het occluded chunks cullde. Helaas was het ook erg traag; het duurde 150-200 milliseconden om te verwerken bij een render-afstand van 12 chunks. Ik geef toe dat dit me een beetje nerveus maakte: dat leek een groot gat om te overbruggen met optimalisaties. Maar ik besloot het toch te proberen.
Optimalisatie 1: goedkopere transformaties
Eerst kwam ik op het idee om de 3x3 view rotation plus additieve translatie te gebruiken en lineaire diepte toe te passen. De normale manier vereist een 4x4 projectiematrix-vermenigvuldiging (16 vermenigvuldigingen), en vervolgens een deling door 1/w voor de x-, y- en z-coördinaten, gevolgd door een vermenigvuldiging met de buffergrootte voor x en y, voor een totaal van 21 vermenigvuldigingen en 1 deling.
In plaats daarvan deed ik dit:
private static Vector3F Transform(Vector3F position, Matrix4 view, Vector2F focalLenPixels, out bool valid) {
// world to view
Vector3F viewPos;
viewPos.X = view.M11 * position.X + view.M21 * position.Y + view.M31 * position.Z + view.M41;
viewPos.Y = view.M12 * position.X + view.M22 * position.Y + view.M32 * position.Z + view.M42;
viewPos.Z = -view.M13 * position.X + view.M23 * position.Y + view.M33 * position.Z + view.M43);
// perspective divide
var invZ = 1.0f / viewPos.Z;
var xScreen = viewPos.X * invZ * focalLenPixels.X + (bufferWidth / 2);
var yScreen = viewPos.Y * invZ * focalLenPixels.Y + (bufferHeight / 2);
// distance in view space substitute for actual depth
var dist = viewPos.Z;
valid = dist > FNARenderer.ZNearPerspective;
return new Vector3F(xScreen, yScreen, dist);
}
Ik voer een 3x3 matrix-vermenigvuldiging uit en gebruik optelling voor de translatie. Daarna voer ik de perspective divide uit en vermenigvuldig ik met de brandpuntsafstand in pixels. Ik hoef de diepte langs de z-as niet te schalen en gebruik in plaats daarvan gewoon de diepte in de lineaire view space, omdat dat voor mijn doeleinden prima werkt. Aangezien ik geen texturen render, is dit voldoende.
Het resultaat is 13 vermenigvuldigingen en 1 deling tegenover 21 vermenigvuldigingen en 1 deling. Alleen al het feit dat er 62% minder vermenigvuldigingen nodig zijn, verlaagde de verwerkingstijd met een factor 0,67, van 150ms naar 99ms. Niet slecht!
Optimalisatie 2: world-space frustum culling
Omdat ik alles al naar de screen space transformeerde, probeerde ik ook in die ruimte frustum culling toe te passen. Ik merkte al snel dat dit een slecht idee was. Hoewel je gemakkelijk de bounding sphere van een kubus kunt berekenen ($\sqrt{3} \times \text{grootte} \times 0,5$), kunnen kubushoeken veel verder uitsteken omdat zaken niet-lineair zijn na projectie naar de screen space. Dat betekende dat ik veel conservatiever moest zijn met mijn culling, wat alles vertraagde.
Gelukkig werd me gewezen op het feit dat een simpel dot-product tussen de frustum planes en de bounding spheres van de kubussen in de wereldruimte veel goedkoper en nauwkeuriger zou zijn. Ik vond een manier om de frustum planes uit mijn projectiematrix te extraheren en merkte dat de oorspronkelijke ongeoptimaliseerde looptijd nu was verlaagd tot een factor 0,43, naar 61ms.
Ter referentie, hier is mijn functie voor het extraheren van de vlakken, gebruikmakend van de Plane struct van FNA:
public static void GetFrustumPlanes(Plane[] planes, Matrix4 viewProjection) {
Debug.Assert(planes.Length >= 6);
// left
planes[0] = new Plane(
viewProjection.M14 + viewProjection.M11,
viewProjection.M24 + viewProjection.M21,
viewProjection.M34 + viewProjection.M31,
viewProjection.M44 + viewProjection.M41
);
// right
planes[1] = new Plane(
viewProjection.M14 - viewProjection.M11,
viewProjection.M24 - viewProjection.M21,
viewProjection.M34 - viewProjection.M31,
viewProjection.M44 - viewProjection.M41
);
// bottom
planes[2] = new Plane(
viewProjection.M14 + viewProjection.M12,
viewProjection.M24 + viewProjection.M22,
viewProjection.M34 + viewProjection.M32,
viewProjection.M44 + viewProjection.M42
);
// top
planes[3] = new Plane(
viewProjection.M14 - viewProjection.M12,
viewProjection.M24 - viewProjection.M22,
viewProjection.M34 - viewProjection.M32,
viewProjection.M44 - viewProjection.M42
);
// near
planes[4] = new Plane(
viewProjection.M13,
viewProjection.M23,
viewProjection.M33,
viewProjection.M43
);
// far
planes[5] = new Plane(
viewProjection.M14 - viewProjection.M13,
viewProjection.M24 - viewProjection.M23,
viewProjection.M34 - viewProjection.M33,
viewProjection.M44 - viewProjection.M43
);
// normalize planes
for (int i = 0; i < 6; i++) {
Plane.Normalize(ref planes[i], out planes[i]);
}
}
Dit was snel genoeg om het uiteindelijk uit de achtergrondthread en de occlusion culler te halen en te verplaatsen naar de normale chunk gathering pass van de renderer.
Optimalisatie 3: zichtbare vlakken
Daarna realiseerde ik me dat mijn criteria voor wat telt als zichtbare geometrie onjuist waren. Ik beschouwde elke chunk (of subchunk op dat moment) als potentieel zichtbaar als deze blokken bevatte die per definitie niet onzichtbaar waren, zoals lucht. Dat is natuurlijk naïef. Een chunk heeft alleen zichtbare geometrie als deze zichtbare vlakken heeft. Dus heb ik subchunks zonder zichtbare vlakken als occlusie-kandidaten geschrapt.
Dit hielp aanzienlijk en bracht de looptijd terug naar een factor 0,21 van het origineel, naar 34ms. Ik voelde me op dit punt veel optimistischer, want dit zat binnen een paar frames en ik had zelfs nog geen SIMD hoeven inzetten!
Optimalisatie 4: nabije occluders, verre culling
Op dit punt kwam ik tot een cruciaal inzicht dat alles op zijn plek zou laten vallen voor de volgende optimalisatie: vanwege het perspectief zijn nabije subchunks groot en verre subchunks klein.
Dit betekende dat ik vooral nabije occluders wilde tekenen, omdat een nabije occluder die een groot deel van de buffer beslaat, veel waarschijnlijker dingen zal occluderen dan een verre occluder die slechts een paar pixels beslaat.
Tegelijkertijd betekende het dat ik vooral verre subchunks wilde testen op occlusie, omdat een verre subchunk piepklein is en dus veel waarschijnlijker geoccluderd wordt, en veel minder pixels heeft om te testen.
Ik testte deze aanname en de looptijd ging van een factor 0,21 naar 0,14, van 37ms naar 29ms. Hoewel dit niet de magic bullet was waar ik op hoopte, leidde het me er wel naartoe.
Optimalisatie 5: subchunk-niveaus
Door het besef dat nabije objecten groot zijn en verre objecten klein, realiseerde ik me dat ik iets zou moeten doen dat lijkt op mipmapping: in plaats van één grootte voor subchunks, zou ik kleinere subchunks dichtbij moeten gebruiken en grotere verder weg.
Dit zou enorm helpen met de werklast. Naarmate de render-afstand toeneemt, stijgt het aantal (sub)chunks geometrisch. Door echter grotere subchunks verder weg te gebruiken, wordt het aantal subchunks geometrisch verlaagd, waardoor deze twee krachten elkaar tegenwerken.
Ik realiseerde me ook dat hoewel ik wilde dat occluders subchunks waren, er niet veel reden was om occlusie-kandidaten zo op te splitsen. Immers, als het me alleen uitmaakte of de gehele chunk geoccluderd was, waarom zou ik die test dan opsplitsen in 8 of 16 aparte tests, elk met hun eigen 8 vertex-transformaties? Dus ik heb occluders en kandidaten gescheiden: de occlusie-kandidaten blijven volledige chunks, en de occluders maken gebruik van de verschillende niveaus van subchunks die ik aan het begin van het artikel heb uitgelegd.
Voor het hoogste niveau (waar elke subchunk een blok is) leg ik twee stukken informatie vast over elke subchunk of elk blok: of het opaak is, en zo ja, of het een occluder is (d.w.z. of het een zichtbaar vlak heeft). Het bijhouden of een subchunk opaak is, ongeacht of het een occluder is, helpt bij het genereren van de lagere subchunk-niveaus.
Om het RAM-gebruik te optimaliseren, sla ik deze informatie op in twee 16-bit integers per z-slice: één voor de opaciteit en één voor de occlusie.
Bij het genereren van hogere niveaus (grotere subchunks) doe ik iets vergelijkbaars: ik kijk naar het vorige niveau en registreer de subchunk als een occluder alleen als alle vorige subchunks in het 2x2x2 gebied volledig opaak zijn, en als ten minste één van hen een occluder is. Met andere woorden: een subchunk is een occluder als alle 8 subchunks die hij vertegenwoordigt in het volgende niveau opaak zijn en ten minste één van hen een zichtbaar vlak heeft.
Toen ik deze optimalisatie eindelijk testte, was ik verbijsterd. Bij een render-afstand van 12 chunks nam het nu gemiddeld 0,04 keer de oorspronkelijke looptijd in beslag. De vorige optimalisatie had het naar 29ms gebracht; deze bracht het naar slechts 4ms. Dat is een versnelling van meer dan zeven keer.
Om eerlijk te zijn tegenover de andere optimalisaties: zij hadden de boel ook vijf keer sneller gemaakt, dus het is niet zo dat ze niet hebben bijgedragen. Op dit punt beschouwde ik mijn werk als voltooid en begon ik met het opschonen van de code.
Beperkingen
Hoewel deze software-gerenderde depth occlusion culling-aanpak in veel gevallen erg goed werkt, zijn er situaties waarin het systeem tekortschiet. De meest opvallende is dat de beste occluders de subchunks zijn die overeenkomen met een enkel blok. Ik kan echter slechts een beperkt aantal occluders verwerken zonder de snelheid te veel te beïnvloeden, waardoor ik dit hoogste niveau van subchunks slechts tot een bepaalde afstand kan renderen.
Zelfs zonder die beperking zorgen de 1-pixel insets ervoor dat deze $1^3$ grootte-occluders relatief snel krimpen tot enkele pixels en vervolgens tot niets. Zelfs als ik er meer zou kunnen verwerken, heeft het boven de 20 blokken afstand weinig zin.
Als de speler zich dus op een plek bevindt waar geen opake blokken binnen een straal van 20 blokken zijn — zoals vliegen in de lucht of staan op een hoog plateau — worden de meest nuttige occluders helemaal niet gerenderd. Hoewel occlusion culling dan nog steeds helpt, daalt de effectiviteit snel van onder de 50% naar ongeveer 25% of minder.
Afbeelding: Worst-case occlusion culling, met occluders van nabije blokken zichtbaar als gekleurde vlekken van enkele pixels groot onderaan in het midden van het scherm.
Naarmate de speler hoger komt, vallen er steeds meer subchunk-niveaus weg omdat ze zelf wegkrimpen tot niets, waardoor de culling slechter presteert. Ook zullen veel door spelers gemaakte structuren slechts 1 of 2 blokken dik zijn, waardoor veel grote en verre structuren helemaal niet als occluders worden beschouwd.
Deze gevallen komen echter niet vaak voor in survival mode-gameplay. In het ergste geval zakt de prestatie terug naar het niveau van wanneer er helemaal geen culling wordt toegepast, dus het schaadt niets. Ondertussen kunnen de FPS in veel situaties met 50%, 100% of zelfs vele malen verbeteren, vooral wanneer de speler binnen of in grotten is. Deze methode is dus nog steeds meer dan de moeite waard ondanks deze beperking.
Een ander probleem is dat de 1-pixel inset op occluders gaten kan veroorzaken tussen subchunks, maar vooral tussen chunks, aangezien occluders beperkt zijn tot een enkele chunk en dus nooit over meerdere chunks kunnen spannen. Dit betekent dat chunks langs dat gat niet worden geculled, maar in de praktijk heeft dit weinig invloed op de prestaties van de occlusion culler.
Afbeelding: Occlusion debug view die twee diagonale gaten toont tussen 3 chunks die uitgaan vanaf de positie van de speler. Langs die lijnen zijn grot-chunks zichtbaar tot helemaal beneden.
Toekomstige ontwikkelingen
Tijdens de implementatie van de meerdere subchunk-niveaus realiseerde ik me dat het beter zou zijn als subchunks platte schijven van 1 blok dik zouden zijn langs elke as. Voor niveau 2, waar subchunks 4 blokken groot zijn, zou je dan 4x4x1, 4x1x4 en 1x4x4 subchunks hebben. Deze zouden veel vaker voorkomen dan hun kubusvormige tegenhangers en dus effectiever zijn als occluders.
Ze zouden ook beter passen bij de geometrie van door spelers gemaakte structuren. En in tegenstelling tot 16x16x16 subchunks, die basisaal nooit voorkomen, denk ik dat 16x16 schijven van één blok dik wel gevonden zouden worden. Deze grotere, frequentere schijven zouden betere bodembedekking bieden wanneer de speler hoog is, wat de culling van ondergrondse grotten in het huidige worst-case scenario zou verbeteren.
Iets dat de moeite waard zou kunnen zijn voor zeer hoge render-afstanden op systemen met een krachtige GPU, is om meerdere chunks te groeperen in superchunks om het volume van de checks te verminderen naarmate de render-afstand toeneemt.
Ik zou kunnen proberen de boel verder te optimaliseren met SIMD, maar eerlijk gezegd klinkt dat als een hele klus en ik weet niet of ik me daarvoor ga storen.
Groetjes,