Waarom ik Polygon vanaf nul heb herschreven

Ik ben acht jaar geleden begonnen met Polygonjs. Het was gebaseerd op three.js, maakte gebruik van TypeScript en Vue.js, en bood procedurele geometrie in een browser. Het werkte erg goed en hielp bij het opleveren van een aantal commerciële projecten. Het staat nog steeds op GitHub.

Maar er waren aspecten die me bleven storen. Het kwam uiteindelijk allemaal neer op JavaScript.

Geheugengebruik inspecteren en beheersen

Een van de belangrijkste functies van Polygon is dat je procedurele geometrieën kunt genereren. Dit betekent dat een typische applicatie er veel kan genereren, wat veel geheugen in beslag kan nemen. In een browser is het geheugen vrij beperkt; als je te veel gebruikt, crasht het tabblad.

Achter deze generatie zat een systeem dat bijhield welke geometrieën in gebruik waren en hoe. Werden ze door de renderer gebruikt? Dienden ze als cache tussen nodes, zodat downstream nodes sneller konden herberekenen? Als het antwoord op beide vragen "nee" was, verwijderde het systeem de geometrie. En het verwijderen van een geometrie maakt het gebruikte geheugen vrij.

Of doet het dat wel?

Dat is mijn belangrijkste grief met JavaScript. Er is geen eenvoudige manier om te zien wat het geheugen verbruikt. Er zijn natuurlijk browser-devtools en browser-flags, maar de antwoorden voelden altijd ofwel onvolledig, of onbruikbaar in een testsuite.

Bovendien: als een ander object nog steeds naar die geometrie verwees, zou de garbage collector deze als "in gebruik" beschouwen. Hierdoor zou het geheugen nooit worden vrijgegeven. Of mijn code dat geheugen werkelijk had vrijgemaakt, was een vraag die ik moest beantwoorden, maar dat kon ik simpelweg niet.

De garbage collector

De garbage collector is het onderdeel waar ik het moeilijkst mee kan werken. Dit is het systeem dat het geheugen opschoont wanneer het objecten vindt waar niets meer naar verwijst.

Wanneer dit systeem opschoont, kan dit een aanzienlijk deel van je frame budget opsnoepen, wat kan leiden tot het overslaan van een frame (frame drop). Dat is altijd storend, vooral als je een ervaring ontwerpt die veel geanimeerde objecten moet bevatten, of als de camera geanimeerd is of door de gebruiker wordt bestuurd.

Het beste wat je hiertegen kunt doen, is voorkomen dat de garbage collector überhaupt in actie komt. Dat betekent dat je het geheugen zeer nauwkeurig moet beheren, wat, zoals ik eerder noemde, erg moeilijk is.

Natuurlijk zijn er talloze technieken om geheugen te hergebruiken, zoals object pooling, en die heb ik toegepast. Maar hoe groter je systeem is, hoe makkelijker het is om een plek te missen waar pooling nodig is, omdat het systeem je niet vertelt of je iets bent vergeten. Als een testsuite je niet kan vertellen waar je een geheugenlek hebt, of zelfs of je er een hebt, dan werk je blind.

Implementatie

JavaScript is geweldig voor het implementeren van een app in browsers. En als je apps wilt implementeren op native platforms, zoals games op Steam, kun je Electron of Capacitor gebruiken. Ik vond deze prima, omdat ze me in staat stelden builds te maken voor Windows, Mac en Linux.

Maar toen ik begon met het integreren van de Steam API om multiplayer aan te bieden, moest ik vanuit JavaScript een native bibliotheek aanroepen. Dit betekende dat ik door complexe omwegen in en uit de Electron-sandbox moest manoeuvreren, wat snel erg verwarrend werd. Ik had er geen vertrouwen in dat de code vrij was van race conditions of geheugenlekken. Dit was geen code die ik durfde te verschepen, dus heb ik de game niet uitgebracht.

Het begin van een omweg

Twee jaar geleden begon ik naar mijn opties te kijken. Een volledige herschrijving van Polygon was toen nog niet in mijn hoofd, maar het herschrijven van enkele kernfuncties was noodzakelijk.

Toen hoorde ik over de programmeertaal Jai, gecreëerd door Jonathan Blow. Zijn redenering over waarom een nieuwe taal nuttig kon zijn, sprak me aan. Ik vroeg me aan voor de bèta (de taal is terwijl ik dit schrijf nog niet publiekelijk beschikbaar) en kreeg toegang tot de Jai-compiler om deze te testen.

Ik voelde me er erg prettig bij en het was duidelijk dat het veel van de problemen waar ik tegenaan liep zou oplossen. Ik kon het geheugen inspecteren, zien waar elk stukje informatie was toegewezen, beslissen hoe ik het moest alloceren en hoe en wanneer ik het moest vrijgeven. Dat klinkt als typisch C/C++ of elke andere low-level taal, maar dan met meer elegantie en practicaliteit. En wanneer je alloqueert in een pool, of via de temp allocator, is het vrijgeven van het geheel erg snel. Dat was twee vliegen in één klap: het geheugenprobleem en de frame-drops door de garbage collector.

Het kon bovendien worden geïmplementeerd op alle besturingssystemen (Windows, Mac en Linux), evenals WebAssembly, Android en iOS. Ik stopte onmiddellijk met denken aan Electron en ik heb het geen seconde gemist.

Op dat moment vroeg ik me niet meer af hoe ik Jai in Polygon kon verweven, maar of het herschrijven van het geheel een optie was. Ik maakte een lijst met voor- en nadelen, wat intimiderend was. De belangrijkste nadelen waren:

  • Ik zou de gehele kern van de procedurele engine moeten herschrijven, en de meeste nodes (ongeveer 300, min of meer).
  • Ik zou niet meer kunnen vertrouwen op three.js. Ik zou zelf een scene graph en een renderer moeten schrijven, terwijl die bibliotheek een pijler van Polygon v1 was geweest.

De kernengine had ik al eens gedaan, dus dat kon ik een tweede keer. Maar de renderer, samen met alle materialen die three.js biedt, was een ander verhaal. Ik had geen idee of ik dat zou kunnen redden.

Toen ik naar de voordelen keek, sprongen er twee zaken uit:

  1. Controle over het geheugen.
  2. Controle over de prestaties.

Ik kon daar geen nee tegen zeggen. Dat plantte het zaadje, en kort daarna begon ik aan de herschrijving. De eerste stap was het blootleggen van de "onbekende onbekenden".

Jaar 1

Jaar 1 was het moeilijkste deel van de leercurve. Ik moest me opnieuw bekendmaken met een low-level taal. Ik deed vroeger aan C/C++, maar ik was erg uit de praktijk. Het herbouwen van een node-systeem dat was gemaakt in een objectgeoriënteerde taal, naar een taal die alleen structs en functies ondersteunt, was een nederstellende ervaring. Maar het nieuwe systeem werd schoner, veel puurder, en ik weet nu precies hoe de data is gestructureerd. Zozeer zelfs dat ik mijn interesse in objectgeoriënteerd ontwerp volledig ben verloren.

Three.js bleef voorlopig nog de rendering-laag, waarbij ik geometrie genereerde in WebAssembly en een pointer en een grootte doorgaf aan JavaScript. JavaScript maakte daar vervolgens een typed array van en bouwde daar de geometrie-attributen mee op. Het werkte en hielp bij het opleveren van commerciële projecten, wat de ontwikkeling financierde.

Maar dat zou geen oplossing zijn voor native platformen. Dus langzaam maar zeker raakte ik meer gewend aan raw WebGPU/wgpu. Ik leerde hoe ik bindings, layouts en pipelines moest maken, hoe ik de matrix van een object of camera moest voeden, en hoe ik data naar de GPU moest uploaden: allemaal de basiscomponenten die een renderer nodig heeft. Daarnaast voegde ik een dunne vertaallaag van Jai naar JavaScript toe om wgpu-aanroepen om te zetten in WebGPU-aanroepen, met enkele struct-conversies om de WebAssembly-geheugenbuffer op het juiste adres te lezen.

Dat was het jaar van het bouwen van het fundament.

Jaar 2

Ik experimenteerde met verschillende manieren om geometrieën te genereren en kwam uit op een recyclebaar systeem, waarbij geheugen nooit wordt vrijgegeven maar in plaats daarvan wordt hergebruikt. Wanneer een node geometrieën genereert, vraagt deze geheugen aan bij een centrale plek, die dit vervolgens toewijst aan een nieuw gealloqueerd gebied of aan een gebied dat eerder was vrijgegeven.

Veel low-level programmeurs zullen dit waarschijnlijk lezen en dit als de meest basale functie beschouwen. De beslissing om het toe te passen was het makkelijke deel; het werkend krijgen voor elk stukje data in Polygon was het lastige deel. Nu wordt alles wat wordt aangemaakt opgeslagen in een recyclebare array of in de temp allocator, die aan het einde van het frame wordt geleegd. Elke node, elke expressie, elk geometrie-attribuut, elk object-attribuut, elke verbinding en vele anderen worden op deze manier opgeslagen. Dit maakt het hele systeem zeer, zeer efficiënt.

Al met al werd de ontwikkeling eenvoudiger, kwamen bugs minder vaak voor, en wanneer er een optrad, was de diagnose veel simpeler.

Dit stelde me in staat om secties te refactoren die ik al jaren wilde aanpakken, maar waar ik nooit tijd voor had gevonden. Zo heb ik gl- en js-nodes samengevoegd tot één code-context, waarbij er geen onderscheid meer is tussen nodes die code genereren voor de GPU (GLSL of WGSL) en voor de CPU (JavaScript of Jai). Een code-node is nu in staat om voor beide code te genereren met een eenvoudige schakelaar.

En de renderer werd behoorlijk degelijk. Aan het begin van dat jaar was hij net functioneel, waarbij hij meerdere procedureel gegenereerde objecten kon weergeven met een zeer basis Lambert-shader. Stap voor stap voegde ik nieuwe mogelijkheden of nieuwe controles in de testsuite toe. Inmiddels kan hij custom materials renderen, plus veel meer waar ik een andere keer op in zal gaan.

Waar we nu staan

Polygon v2 is nu uitgebracht (versie 2.0.54). Het beschikt over procedurele geometrieën, custom materials, compute shaders en een render graph. Er zijn meer functies in ontwikkeling. Het is beschikbaar als een eenvoudig uitvoerbaar bestand voor artiesten die alleen een scene-bestand willen openen en opslaan, of als een SDK voor ontwikkelaars die custom nodes willen schrijven.