Declaratieve WebGPU met S-expressies

(shader-module :name code :code """
@vertex
fn vertexMain(
@builtin(vertex_index) VertexIndex : u32
) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2(0.0, 0.5),
vec2(-0.5, -0.5),
vec2(0.5, -0.5)
);
return vec4f(pos[VertexIndex], 0.0, 1.0);
}
@fragment
fn fragMain() -> @location(0) vec4f {
return vec4(1.0, 0.0, 0.0, 1.0);
}
""")
(render-pipeline :name pipeline
:layout auto
(vertex :module code :entry vertexMain)
(fragment :module code :entry fragMain
(target :format preferred-canvas-format))
)
(render-pass :name trianglePass
(color-attachment :view context-current-texture
:clear-value [0 0 0 0] :load-op clear :store-op store)
:pipeline pipeline
(draw :vertex-count 3))
(frame :name main :perform [trianglePass])

De bovenstaande code is een eenvoudige rode driehoek gemaakt in WebGPU, waarbij S-expressies worden gebruikt voor de "plumbing" (de infrastructuur), die 1:1 overeenkomt met de WebGPU-specificatie.

De drie kernmogelijkheden van pngine

Pngine heeft drie hoofdfuncties:

  1. Declaratieve WebGPU-infrastructuur: Het stelt je in staat om WebGPU-plumbing (inclusief shaders en CPU/WASM-initialisatie) te declareren en te distribueren op een platformonafhankelijke manier. Het is niet beperkt tot browsers, maar werkt ook met Rust wgpu, en er zijn players beschikbaar voor Android en iOS.
  2. Validatie: Het valideert de gedeclareerde WebGPU-configuratie met behulp van WGSL-reflectie en specificatiecontroles. Hierdoor worden fouten en waarschuwingen gegenereerd zonder dat er een actieve WebGPU-context nodig is. Daarnaast is er een LSP (Language Server Protocol), waardoor validatie tijdens het typen gebeurt.
  3. Export: Alles kan worden geëxporteerd naar één .html-bestand, een .zip-bestand of een .png-bestand. Dit laatste stelt je in staat om iemand een PNG-afbeelding te sturen die zichzelf kan uitvoeren (de .png voert niet automatisch uit, maar bevat de bytecodes; er is nog steeds een kleine player nodig om deze te lezen en op verzoek af te spelen).

Dit laatste onderdeel is waar de "png" in pngine vandaan komt: de S-expressies worden gecompileerd naar een binaire representatie die wordt geïnterpreteerd door een kleine runtime. Deze runtime en payload kunnen in een extra PNG-chunk worden geplaatst, terwijl de PNG zelf het voorbeeldbeeld blijft van wat je verzendt.

De code is CC0 en beschikbaar op GitHub voor issues en discussies; de releases worden gepubliceerd vanuit mijn eigen self-hosted repository.

Waarom pngine?

Het distribueren en delen van aangepaste WebGPU-wiring is voor mij een open probleem geweest. Ik wil één enkel bestand dat al mijn WebGPU-pipelines, buffers, passes, enzovoort beschrijft, evenals de shader-modules en de WGSL-code.

Hoewel dit momenteel kan met elke algemene programmeertaal die WebGPU ondersteunt, zoek ik een hogere abstractie zonder afleidingen. Iets dat kan dienen als substraat voor andere tools en diepere inzichten biedt, zowel voor mensen als voor machines.

SJON gaf me een manier om DSL's (Domain Specific Languages) te bouwen die automatisch gevalideerd kunnen worden en op een consistente, componeerbare manier via S-expressies kunnen worden geleverd. Dit geldt alleen voor de WebGPU-wiring en onderdelen; niet voor de shader-code. WGSL zie ik als een eigen DSL en deze blijft ongerept en onuitgebreid.

Het idee dat de programmarepresentatie zelf leesbare data is, staat centraal in pngine. Deze eigenschap is zeer nuttig omdat gestructureerde data het automatisch mogelijk maakt om vooraf te valideren, te herhalen, te inspecteren, te minificeren, te serialiseren en machinaal te genereren, waarbij elke functionaliteit op dezelfde representatie opereert.

Verpak je "wires"

Als ik vandaag een pipeline met je zou willen delen, moeten we eerst akkoord gaan over een reeks conventies. In welke taal is het geschreven: TypeScript, JavaScript, Rust? Hoe worden handles gerepresenteerd en doorgegeven? Maakt deze pipeline deel uit van een kleine animatie, een game, of iets heel anders? Deze keuzes beïnvloeden hoe dezelfde WebGPU-wiring wordt verpakt en gedeeld.

Pngine verwijdert deze kwesties niet, maar probeert het oppervlak te verkleinen, zodat je meerdere WebGPU-wirings op dezelfde manier kunt bundelen voor zeer uiteenlopende doeleinden en targets.

Het meeste WebGPU-plumbing is statisch

De WebGPU-specificatie is een voorbeeld van consistentie en elegantie, toegepast op het anders zo modderige en complexe onderwerp van de grafische wereld. WebGPU doet veel moeite om impliciete status te verwijderen en zaken te verplaatsen naar zorgvuldig ontworpen API-aanroepen en configuratie-objecten.

Om dit te illustreren, volgt hier de equivalente JavaScript-code voor de eenvoudige driehoek uit het begin:

// (shader-module :name code ...)
const code = device.createShaderModule({
code: `
@vertex
fn vertexMain(
@builtin(vertex_index) VertexIndex : u32
) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2(0.0, 0.5),
vec2(-0.5, -0.5),
vec2(0.5, -0.5)
);
return vec4f(pos[VertexIndex], 0.0, 1.0);
}
@fragment
fn fragMain() -> @location(0) vec4f {
return vec4(1.0, 0.0, 0.0, 1.0);
}
`,
});

// (render-pipeline :name pipeline ...)
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex:   { module: code, entryPoint: "vertexMain" },
fragment: { module: code, entryPoint: "fragMain", targets: [{ format }] },
primitive: { topology: "triangle-list" }, // default
});

// (frame :name main :perform [trianglePass])
const encoder = device.createCommandEncoder();

// (render-pass :name trianglePass ...)
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: [0, 0, 0, 0],
loadOp: "clear",
storeOp: "store",
}],
});
pass.setPipeline(pipeline);
pass.draw(3);          // (draw :vertex-count 3)
pass.end();
device.queue.submit([encoder.finish()]);

Deze code creëert objecten (createShaderModule, createRenderPipeline, createCommandEncoder), verbindt ze vervolgens en sequenceert ze aan het einde in een pass. (Ter info: deze JavaScript-code is gegenereerd met pngine; het is een van de mogelijke outputs van de S-expressies).

Het creëren, verbinden en sequenceren is de kern van WebGPU-werk, en dit gebeurt doorgaans in een procedurele omgeving waar je abstracties kunt toevoegen naar eigen smaak. Door WebGPU-wirings naar een declaratieve ruimte te brengen, kunnen we spelen met concepten en blokken op een manier waarbij ze beschreven kunnen worden ongeacht de volgorde; ze bestaan zoals ze zijn gedeclareerd en kunnen worden verplaatst zoals we willen.

Het sequenceren in WebGPU is ook niet zo moeilijk: we houden een beschrijving bij van wat er in welke volgorde moet gebeuren en voeren dit uit op het juiste moment. In pngine is frame een van de weinige plaatsen waar de volgorde ertoe doet, en elke naam in :perform verwijst naar een pass die elders in het bestand is gedeclareerd.

(frame :name main :perform [
update-uniforms
update-textures
sdf-pass
post-processing
])

In lijn met de pipeline

Het delen van shader-code is fantastisch; tools zoals Shadertoy zijn geweldig vanwege hun community en de hoeveelheid voorbeelden. Ook compute.toys is indrukwekkend, omdat het meerdere compute-shaders toestaat die elkaar voeden in een finale buffer die als pixels naar het scherm wordt gestuurd. Het breidt WGSL uit met #macros voor pipeline-configuraties.

Er zijn tegenwoordig talloze van dit soort tools, vaak ontwikkeld door artiesten. Maar dat is niet wat pngine is. Pngine is geen playground (hoewel er een LSP is), maar een substraat. Het is een veelzijdige basis die je niet beperkt, maar je helpt om daarop je eigen complexe systemen te bouwen die hogere abstracties niet zo gemakkelijk bieden (zoals scene graphs, material nodes, etc.).

Van driehoeken naar complexere systemen

Een eenvoudige driehoek zegt niet genoeg over de mogelijkheden van pngine. Denk bijvoorbeeld aan een particlesysteem dat nooit de CPU raakt: 2048 GPU-particles met buffer pooling, die uit een nozzle spuiten, worden beïnvloed door zwaartekracht en opnieuw spawnen met een willekeurige snelheid wanneer hun levensduur afloopt. In zo'n voorbeeld is de attribute buffer tevens het object waar de simulatie naar schrijft.

Een ander voorbeeld is een bos van 400 low-poly bomen die in de wind wiegen, met depth testing tegen een lucht. Eén enkele draw call volstaat hier. De boom zelf bestaat uit 12 vertices die direct in het document staan als een (data ...) form. De 400 instance-records (positie, schaal, rotatie en kleur) worden eenmaal gevuld door een compute pass. Beide zijn vertex buffers op dezelfde pipeline; de één stapt per vertex en de ander per instance. Het hele bos wordt dus aangeroepen via (draw :vertex-count 12 :instance-count NUM_TREES).

In beide gevallen kan de PNG-afbeelding dienen als programma, poster en container tegelijk.

De bundler en de voyeur

De bovengenoemde afbeeldingen zijn .png-bestanden die een extra binaire chunk bevatten met de eigenlijke WebGPU-player en de payload. Hierdoor is de JS die nodig is om het te laden zeer klein. Dit is één optie om .sjon-bestanden te exporteren, wat handig is voor kleine statische projecten. Andere opties zijn export naar .html, een .zip-bundel of ruwe binaire bytecode zonder PNG.

De PNG in pngine is een hommage aan het oorspronkelijke idee om de afbeelding simultaan als preview/poster en als container voor de bytecode/runtime payload te gebruiken.

Conclusie

Om af te sluiten wil ik dieper ingaan op de term "substraat". In de natuur dienen groeisubstraten drie hoofdfuncties: structurele verankering voor wortels bieden, water vasthouden terwijl zuurstofuitwisseling mogelijk blijft, en voedingsstoffen opslaan of leveren.

Voor mij is pngine zo'n groeisubstraat voor WebGPU en graphics. Het is een plek voor low-level experimenten en exploratie die inherent beperkt en gefocust is op WebGPU alleen, terwijl het een eenvoudige manier biedt om generieke interacties en CPU (WASM) code te koppelen aan buffers die in shaders gebruikt kunnen worden.

Dit wordt geleverd in een pakket dat validatie uitvoert en fouten controleert in de terminal of via een LSP in de editor terwijl je typt. Het kan de plek zijn waar experimenten op een veel hoger niveau hun wortels kunnen schieten en groeien, en waar shaders gedeeld kunnen worden met automatische .png-previews die zichzelf uitvoeren.