Declarative WebGPU with S-expressions
-->
Skip to main content
This is pngine, a declarative format and runtime for WebGPU I've been working on for the past 2.5 years:
(shader-module :name code :code """<br>@vertex<br>fn vertexMain(<br>@builtin(vertex_index) VertexIndex : u32<br>) -> @builtin(position) vec4f {<br>var pos = array(<br>vec2(0.0, 0.5),<br>vec2(-0.5, -0.5),<br>vec2(0.5, -0.5)<br>);<br>return vec4f(pos[VertexIndex], 0.0, 1.0);
@fragment<br>fn fragMain() -> @location(0) vec4f {<br>return vec4(1.0, 0.0, 0.0, 1.0);<br>""")
(render-pipeline :name pipeline<br>:layout auto<br>(vertex :module code :entry vertexMain)<br>(fragment :module code :entry fragMain<br>(target :format preferred-canvas-format))
(render-pass :name trianglePass<br>(color-attachment :view context-current-texture<br>:clear-value [0 0 0 0] :load-op clear :store-op store)<br>:pipeline pipeline<br>(draw :vertex-count 3))
(frame :name main :perform [trianglePass])<br>The code above is a simple red triangle done in WebGPU using S-expressions for the plumbing that match 1:1 with the WebGPU spec.
1-2-3 pngine
Pngine has three main capabilities:
It allows you to declare and ship WebGPU plumbing (shaders and cpu/wasm init included), in a cross-platform way (not restricted to browsers, works with rust wgpu too, and players exist for android and ios).
It validates the declared WebGPU configuration using WGSL reflection and spec checks, producing errors and warnings without requiring a live WebGPU context. It also has an LSP so it happens while you type.
It can export everything to a single .html, or a .zip or a .png file, allowing you to hand someone a PNG that runs itself (the .png does not auto-execute, it just contains the bytecodes, you still need a tiny player to read it and play on your demand).
The last part is the cool one, it's where the "png" in pngine comes from because the S-expressions are compiled to a binary representation which is then interpreted by a tiny runtime. That runtime and payload can be put in an extra PNG chunk, while the PNG itself remains the preview image for what you're shipping.
Read more about all of this on its dedicated page here.
The code is CC0 and is available in Github for issues and discussions and the releases are cut from my self-hosted repo.
But why?
Shipping and sharing custom WebGPU wiring has been an open problem for me. I want a single file that describes all my WebGPU pipelines/buffers/passes/etc as well as the shader modules and WGSL code.
You can do this currently with any general purpose language that supports WebGPU, but I want a higher abstraction with no distractions, something that can be used as a substrate for other tools and provide us with deeper insights (either by a human or a machine).
SJON gave me a way to build DSLs that can be automatically validated, and delivered in a consistent composable way through S-expressions. This is just for the WebGPU wiring and parts, not for the shader code, WGSL I see as a DSL of its own and is kept as is, pristine, not extended.
This idea, where the program representation is itself readable data, is at the center of pngine, this property is very useful to me because when the program representation itself is ordinary structured data it automatically allows us to validate, replay, inspect, minify, serialize and machine-generate, all ahead of time and with each capability operating on the same representation.
I won't go much further into this topic because I have written extensively about this in the SJON post and page.
Package your wires
If I want to share a pipeline with you today, we first need to agree on a pile of conventions. What language is it in: TypeScript, JavaScript, Rust? How are handles represented and passed around? Is this pipeline part of a tiny animation, a game, or something else entirely? Those choices affect how the same WebGPU wiring gets packaged and shared.
Pngine does not remove these, but tries very hard to reduce this surface, in a way that you can bundle multiple WebGPU wirings in the same way for very different purposes and targets.
Most WebGPU plumbing is static
The WebGPU spec is an amazing piece of consistency and elegance applied to the otherwise muddy and convoluted topic of what the graphics world has become. WebGPU puts a great deal of effort into taking implicit state away and moving things up into carefully designed API calls and config objects.
I want to drive this point a bit, take a look at the equivalent JS code for the simple triangle that was laid out at the start:
// (shader-module :name code ...)<br>const code = device.createShaderModule({<br>code: `<br>@vertex<br>fn vertexMain(<br>@builtin(vertex_index) VertexIndex : u32<br>) -> @builtin(position) vec4f {<br>var pos = array(<br>vec2(0.0, 0.5),<br>vec2(-0.5, -0.5),<br>vec2(0.5, -0.5)<br>);<br>return vec4f(pos[VertexIndex], 0.0, 1.0);
@fragment<br>fn fragMain() -> @location(0) vec4f {<br>return vec4(1.0, 0.0, 0.0, 1.0);<br>`,<br>});
// (render-pipeline :name pipeline ...)<br>const pipeline = device.createRenderPipeline({<br>layout:...