Gaussian splats deel 3: Er is altijd een grotere scène

Terugblik op OKO...

Het was het jaar 2025 en, zoals ik vorige keer schreef, was de industrie (en Magnopus met hem mee) volledig verkocht aan Gaussian splats. Ik had een flinke tijd besteed aan het weghalen van onze Unreal splat-renderer uit Niagara en het opnieuw opbouwen rondom de instanced rendering van de engine: elke splat werd geregistreerd als een instance, er werd per frame een GPU-side sort uitgevoerd om de transparantie in de juiste volgorde te componeren, en een materiaal shade elk element als een zachte ellips.

Het werkte, en het werkte goed genoeg dat ik er een heel artikel over schreef. Ik was vrij tevreden met mezelf.

Nou, zoals blijkt, vierde ik veel te vroeg feest toen ik claimde dat we een onbeperkt aantal splats konden renderen. In werkelijkheid stonden er verschillende muren in de weg die dit categorisch onwaar maakten, en de eerste muur was veel dichterbij dan ik verwachtte.

De eerste muur

Hier is iets dat me nederig maakte en dat ik pas ontdekte na het publiceren van het vorige artikel: de renderer die ik liet zien had een harde limiet van ongeveer 16 miljoen splats per asset. Ik had geen flauw idee. Elke asset die ik had getest, bleek onder deze limiet te zitten, maar de beperking was er altijd geweest. In mijn overmoed wist ik het simpelweg nog niet.

Die limiet zat in de plek waar de data per splat daadwerkelijk werd opgeslagen. Elke splat registreren als een moderne mesh-instance in Unreal betekende dat deze aan de GPU Scene van de engine werd overhandigd—in feite een centraal register van vrijwel alles in de wereld dat zich op de GPU bevindt.

Hoewel GPU Scene uitmuntend is in wat het doet, is het gebouwd om duizenden instances van een mesh te ondersteunen, niet tientallen of honderden miljoenen. Onder de motorkap zit een limiet van maximaal 16 miljoen instances van een specifieke mesh. Zodra je dit overschrijdt, gaat het mis: slechts een deel van de asset is op een gegeven moment zichtbaar, er ontstaan grafische corrupties en je krijgt een waarschuwing in de viewport die je informeert over je falen.

De naïeve oplossing, en degene die voor mij werkte, was om te stoppen met het gebruik van de GPU Scene voor het bijhouden van splats. In plaats daarvan ging ik volledig 'old-school' en vertrouwde ik op een traditionelere instance-render pipeline, wat uiteindelijk neerkwam op een directe aanroep van wat algemeen bekend staat als DrawIndexedInstancedIndirect.

Dit vereiste een aantal cruciale stappen:

  • Sla het registreren van instances bij de GPU scene over; gebruik in plaats daarvan een klassieke indirect instanced draw.
  • Voed de indirect draw buffer met de draw count van de sort keygen shader, die al over alle splats itereerde tijdens het genereren van sort keys per frame.
  • Sla alle splat-instance data op de GPU op via textures (die we al gratis kregen uit het vorige artikel).
  • Schrijf een kleine vertex factory (Unreal's abstractie over vertex shaders en hun materiaalbindingen) en zorg dat de vertex shader de gesorteerde splat-buffers samplet, de juiste splat-attributen ophaalt en deze vervolgens doorgeeft aan het materiaal.

Het gerenderde beeld was pixel-voor-pixel identiek aan voorheen, maar het plafond van 16 miljoen verdween, omdat het onderdeel dat dit oplegde niet langer in het proces zat. Onze test-asset van ongeveer 30 miljoen splats renderde gelukkig. Sterker nog!

Door deze aanpak met een custom vertex factory ontstonden er ook andere voordelen. Aangezien splat-meshes altijd triviale quads zijn, zijn ze eenvoudig procedureel af te leiden in de shader zelf. Dit betekende dat er geen eigenlijke Unreal-mesh meer gespecificeerd hoefde te worden; het systeem leidt de vertex-attributen simpelweg af in de vertex shader, wat het geheel gebruiksvriendelijker maakt.

Daarnaast zorgde de indirect-instanced-draw ervoor dat we bewogen naar een bindless-rendering benadering. Omdat we elke frame toch al over alle splats itereerden om hun sort keys te genereren, konden we daar ook geavanceerdere per-splat culling uitvoeren en vervolgens het totaal aantal zichtbare splats naar de indirect draw buffer schrijven. We tekenden nu alleen exact het aantal splats dat zichtbaar was binnen het camera-frustum, waarbij de culling-logica volledig op de GPU draaide.

Hoewel dit een mooie prestatieboost geeft, is het ook belangrijk om te vermelden dat het aantal splats dat per frame werd getekend nu echt variabel was en volledig op de GPU werd bepaald. De pipeline had de juiste vorm gekregen voor een level-of-detail (LOD) systeem: het tekenen van een ander subset per frame, beslist op het allerlaatste moment.

Ik was niet begonnen met het bouwen van een aanloop naar LOD's, maar dat is wat het uiteindelijk werd.

Hubris: Redux

Het wegnemen van die instance-limiet voelde als de finishlijn. Echter, in minder dan een week tijd liep ik tegen de volgende muur aan.

Het weghalen van de GPU Scene had wel veranderd hoeveel splats ik kon tekenen, maar niet hoeveel ik kon bewaren: elke splat in de scène bevond zich nog steeds constant in het GPU-geheugen. Dat is prima wanneer de hele scène past. Maar een splat is niet gratis in termen van geheugen. Positie, schaal, oriëntatie, opaciteit, sferische harmonische coëfficiënten—alles bij elkaar ongeveer 32 bytes per splat, nadat ik het zo compact mogelijk had gepackt.

Dit betekent dat je veel splats in het VRAM kunt proppen, totdat je een echte fotogrammetrie-opname van een echte locatie tegenkomt. Op dat moment ontdek je dat de realiteit vervelend genoeg een hoge resolutie heeft en niets omgeeft van je VRAM-budget.

De opnames die we daadwerkelijk aan mensen wilden tonen, waren groter dan de capaciteit van de machines, en er was geen slimme manier meer om de data compacter te maken. De hele scène kon dus niet tegelijkertijd in het geheugen aanwezig zijn. Dat betekent dat slechts een deel aanwezig kan zijn, wat er weer toe leidt dat de renderer per frame moet beslissen welk deel gerenderd moet worden, dit moet ophalen en vervolgens op het scherm moet tekenen.

Wat LOD betekent voor splats

Als je ervaring hebt met real-time graphics, ken je LOD's: dingen die ver weg zijn, krijgen een goedkopere vervanger. Een verre boom-mesh wordt een low-poly boom, dan een billboard, en dan een vlek. Zaken worden goedkoper naarmate de afstand toeneemt.

Splats hebben geen polygonen die we kunnen weggooien, maar ze hebben iets beters: er zijn er simpelweg te veel van, en veel van die dichtheid is verspild aan een regio die veertig meter verderop ligt en slechts zes pixels breed is. Een splat-LOD is dus een gedecimeerde kopie van een regio; dezelfde wolk van blobs, maar met de meeste blobs verwijderd en de overlevenden vergroot om de gaten op te vullen. Bij LOD-niveau 0 tonen we elke splat, en bij hogere LOD-niveaus tonen we progressief minder.

Het formaat waar we voor kozen, de Streamed SOG van PlayCanvas, bakte dit voor ons in. Het verdeelt de scène in een ruimtelijke boom van regio's ("leaves"), waarbij elke leaf verschillende gedecimeerde LOD-niveaus beschrijft. Een klein lod-meta.json bestand op de root beschrijft vervolgens de algemene boomstructuur en waar elk onderdeel zich bevindt.

Bepalen wat er getekend wordt

We hebben onze LOD-data. Nu moeten we per frame, voor elke zichtbare regio, een LOD-niveau kiezen. Maar hoe precies?

Mijn eerste instinct was om iets slims te bedenken. Mijn tweede, betere instinct was om me te herinneren dat het enige dat ertoe doet is hoe "fout" het er op het scherm uitziet, en "hoe fout" wordt gemeten in pixels.

Hiervoor bestaat een decennia-oud concept genaamd screen-space error (SSE). Hierbij bereken je hoeveel visuele fout een bepaald niveau zou introduceren, projecteer je dat naar pixels bij de huidige cameradistance, en kies je het grofste niveau waarvan de fout onder een bepaalde drempelwaarde blijft. We noemen die drempel $\tau$ (tau), want alles in graphics krijgt uiteindelijk een Griekse letter.

Voor splats hebben we geen mesh om de fout tegen af te meten, maar we kunnen een beetje sjoemelen met dichtheid: uit hoeveel splats een regio in zijn volume packt, kun je een geschatte afstand tussen hen afleiden. Projecteer dat naar een pixelgrootte en vergelijk dit met $\tau$. Groot op het scherm en dicht op elkaar? Dan heb je recht op niveau 0. Klein en ver weg? Neem de grove versie; niemand zal het merken.

Uiteindelijk werd $\tau$ een knop die we aan de gebruiker blootstelden, vertaald naar mensvriendelijke termen als Low/Medium/High kwaliteitsopties in de projectinstellingen. 'High' is de standaard en betekent "gebruik details royaal", terwijl 'Low' betekent "wees zuinig in de verte".

De minder glamoureuze mechanieken

De minder sexy kant hiervan is de mechaniek die nodig is om de LOD-data überhaupt op de GPU te krijgen.

Wanneer je voor het eerst naar een van deze scènes kijkt, is er nog niets geladen. De renderer start op, de proxy leeft, maar er is niets in de texture pool. De LOD-selectie draait, bepaalt "ik wil deze chunks graag hebben", maar ze zijn er nog niet. Ze moeten eerst geladen (of zelfs gedownload) worden. Dus hij tekent wat hij al heeft, wat in het begin niets is, daarna de grofste niveaus naarmate ze binnendruppelen, en vervolgens de fijne details waar je daadwerkelijk naar kijkt.

Het idee is dat de scène zich voor je ogen opbouwt—van grof naar fijn—over een bepaalde periode van tijd.

En het andere deel houdt in dat we profiteren van de reden waarom ons gekozen LOD-formaat streamed SOG heet. Het is ontworpen om over het netwerk te worden geleverd, leaf per leaf, LOD-niveau per LOD-niveau. Het idee is dat je niet het hele asset lokaal hoeft te hebben om inhoud te zien—je hoeft alleen de LOD-niveaus van de leaves om je heen te downloaden.

Conceptueel is dit simpel, maar de implementatie was het moeilijkste deel. Er was veel precies werk nodig om dit goed te krijgen. Dit waren de concepten die ik het lastigst vond:

  • Alles asynchroon: Het laden van een chunk kan betekenen dat er een bestand wordt gelezen, maar het kan ook een HTTP-verzoek naar een server op een ander continent zijn. We kunnen de game-thread niet blokkeren, dus het hele proces moet een asynchroon request/delivery-model zijn. Je vraagt om een chunk en krijgt later via een callback bericht dat het werk is voltooid. Dit vereist veel complexe thread-synchronisatiemechanismen.
  • Coarse-LODs-first: We vragen eerst het grofste niveau van een regio aan, zodat je zo snel mogelijk een stabiele versie met lage details op het scherm hebt, die vervolgens verscherpt zodra de fijnere chunks binnenkomen.
  • Fencing chunk uploads: Het uploaden naar het GPU-geheugen en het CPU-side markeren als "klaar om te tekenen" zijn twee verschillende gebeurtenissen op twee verschillende tijdlijnen. Als je de volgorde fout hebt, krijg je één frame lang "garbage triangles" te zien. Een chunk wordt pas tekenbaar in het frame nádat de upload aantoonbaar is geland.
  • Multi-view support: We gebruiken splats vaak in virtual production scenario's, waar onvermijdelijk meerdere camera's gelijktijdig renderen in één instance van Unreal. Aangezien de splat-data hetzelfde is, kan deze slechts één keer bestaan, maar alle LOD-selectiebeslissingen, de registratie daarvan en de transmissie naar de GPU voor de juiste camera moeten individueel worden beheerd.

Het maximale aantal splats verhogen

Muren komen, zoals vastgesteld, in series. Precies op het moment dat de streaming end-to-end werkte, voerde ik een van de werkelijk enorme captures in en het systeem crashte met: Texture2D cannot be created, exceeds this rhi's maximum dimension (16384).

Ah. Dus alle splats leven in een texture, en ik had ze in rijen van 4096 geplaatst. Een texture kan maar een bepaald aantal rijen hoog zijn (in dit geval 16384), wat betekent dat we bij een enkele texture uitkomen op een limiet van ongeveer 67 miljoen splats. Wanneer je dat aantal overschrijdt, weigert de driver/RHI-laag de texture aan te maken en stort alles in.

Uiteindelijk waren er twee dingen die opgelost moesten worden.

Het eerste is een gênante anekdote. In het begin koos ik ervoor om een constante rijbreedte van 4096 splats te gebruiken, wat betekende dat ik potentiële andere (16384 - 4096) texels per rij onbenut liet. Er was in feite veel horizontale ruimte in de texture die ik had kunnen gebruiken, maar niet deed.

Dus... ik heb de texture breder gemaakt. Door de rijen te verbreden van 4096 naar het hardware-maximum, kan dezelfde texture nu potentieel 268 miljoen splats bevatten (wat neerkomt op ongeveer 8,6 GB VRAM). Op dat punt word je eindelijk echt beperkt door de maximale potentiële opslag van een enkele texture.

Een kleine aanpassing in het einde; de rijbreedte werd een getal dat per asset werd berekend in plaats van een constante die op diverse plekken was ingebakken, maar ik zie het graag als een moraal: ik had urenlang iets behandeld als een fundamentele beperking, terwijl het in feite gewoon een willekeurige constante was die ik maanden eerder had getypt.

Het andere punt dat aangepakt moest worden, was wat te doen wanneer splats werkelijk te groot zijn om in een enkele texture te passen. Wat gebeurt er als we voor het eerst een asset van 300 miljoen splats tegenkomen? Het antwoord is, zoals altijd in scenario's met beperkt geheugen, het beheren van een budget: je probeert niet de hele scène resident te houden, maar je houdt een working set aan.

Je bepaalt de grootte van de pool op basis van een geheugenplafond, streamt chunks binnen zodra regio's in beeld komen, en wanneer de ruimte op is, verwijder je de chunks waar niemand meer naar kijkt, gebruikmakend van een "meest-verre-eerst" heuristiek.

Hiermee kan de scène op schijf elke gewenste grootte hebben, omdat er slechts een begrensd deel in het geheugen aanwezig is. Eindelijk, denk ik, heeft de renderer het woord 'onbeperkt' verdiend. Misschien. Mijn zelfvertrouwen is nog steeds een beetje aangetast.

Alles bij elkaar

Dit zijn de grove lijnen van het hele proces, van een bestand op een server tot blobs op je scherm:

  1. Laden & decoderen: Op worker threads worden chunks van schijf of via HTTP opgehaald, gedecomprimeerd en omgezet naar een GPU-ready vorm. De game- en render-threads worden geïnformeerd wanneer dit klaar is.
  2. Residentie & streaming: Chunks worden geüpload naar een vaste texture pool, waarvan de rijen worden toegewezen aan wie ze nodig heeft. Er wordt on-demand gestreamd (grof-eerst) en er wordt geëvicted volgens een geheugenbudget zodat scènes van elke grootte passen.
  3. LOD-selectie: Per view, per frame, wordt er één niveau per regio gekozen op basis van de SSE-metriek. Er wordt het beste getoond wat er op dat moment beschikbaar is, terwijl het ideale niveau wordt gedownload.
  4. Sorteren & Culling: Een compute shader geeft elke zichtbare splat een sleutel op basis van de afstand tot de camera en gooit de off-screen splats weg. De radix sort van Unreal ordent ze van achteren naar voren, zodat de transparantie correct wordt gecomponeerd.
  5. Tekenen: Een vertex factory creëert een camera-facing quad per overlevende splat zonder dat er geometrie in het geheugen staat; het materiaal krijgt de per-splat data en shadet deze dienovereenkomstig.

Het mooiste structurele resultaat is dat een simpele, enkelvoudige splat-scène (geen LOD's, geen streaming) hieruit voortvloeide als het basisgeval: één chunk die altijd resident is, waardoor de LOD-selectie simpelweg wordt overgeslagen. Alles volgt hetzelfde codepad, wat ideaal is vanuit het perspectief van onderhoud en complexiteit.

Tot de volgende keer?

Is het nu echt onbeperkt? Bijna, denk ik.

Op zijn minst is het veerkrachtiger tegen veel grotere splat-aantallen dan ooit tevoren. De scène op schijf kan groter zijn dan de GPU, zelfs groter dan de machine wanneer data via HTTP wordt binnengehaald, en het zal nog steeds renderen binnen een door de gebruiker gespecificeerd geheugenbudget.

Die initiële 30 miljoen splats die een paar maanden geleden mijn middag verpestten, openen nu in een paar seconden en verscherpen zelfs na verloop van tijd.

Er is nog veel te doen. De LOD-selectiepolicy is vrij traag in uitvoering. De eviction-policy is super simpel; het zou mooi zijn om die slimmer te maken. Het concentreren van het geheugenbudget op wat daadwerkelijk in beeld is in plaats van verspreid over de hele scène (misschien via een soort hiërarchische cull over de ruimtelijke boom gedefinieerd in de SOG-metas?). Overdraw is absoluut de grootste limiter wat betreft rendering-prestaties. Ik hoop dat er een geniale ingeving komt over hoe we dat kunnen verminderen.

En ik heb 4DGS nog niet aangeraakt—ik hoop ernstig dat mijn volgende artikel een verslag is van de avonturen daar.

Maar als je bouwt met splats: we hebben nu streamed SOG-ondersteuning geïmplementeerd voor zowel OKO Unreal als OKO Web. Voel je vrij om ze uit te proberen. Probeer ze te breken, vertel ons welke enorme capture het systeem laat crashen, en laat ons weten wat je ervan vindt.

Tot de volgende keer! ✌️