Getting Started
This page builds one program and then builds it again. First a red triangle (the hello world of GPUs) written as four S-expression forms, compiled to a PNG, inspected, and played in a browser. Then the same triangle made to spin, which is where you meet uniforms, bind groups and the queue, the machinery every animated program uses. By the end you will have run the whole toolchain once: validate, compile, inspect, play, debug.
Installation
Section titled “Installation”npm install pngine@^3One package installs both: the native CLI (a platform binary, like esbuild) and the browser runtime. Everything on these pages is written for pngine 3.0.0 or later; an older install refuses the examples.
Requirements: Zig 0.16.0 or later.
git clone https://github.com/HugoDaniel/pngine.gitcd pngine
zig build # CLI → zig-out/bin/pnginezig build web # WASM runtime + local playgroundzig build test # test suiteFirst Program
Section titled “First Program”A PNGine document is a .sjon file: SJON, an S-expression format in which each
WebGPU resource (a shader module, a pipeline, a pass, a frame) is one form,
checked against a WebGPU schema. The shaders stay plain WGSL, WebGPU’s shading
language. Create triangle.sjon:
(shader-module :name code :code """ @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { var pos = array<vec2f, 3>( vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5) ); return vec4f(pos[i], 0.0, 1.0); }
@fragment fn fs() -> @location(0) vec4f { return vec4f(1.0, 0.0, 0.0, 1.0); }""")
(render-pipeline :name pipeline :layout auto (vertex :module code :entry vs) (fragment :module code :entry fs (target :format preferred-canvas-format)) (primitive :topology triangle-list))
(render-pass :name pass (color-attachment :view context-current-texture :clear-value [0 0 0 1] :load-op clear :store-op store) :pipeline pipeline (draw :vertex-count 3))
(frame :name main :perform [pass])Every key is one WebGPU descriptor member, spelled as the specification spells
it: :layout auto asks WebGPU to derive the pipeline layout from the shader,
and :module plus :entry name the function each stage runs. :entry is
optional when the module holds exactly one entry point of that stage kind, so
the pipeline above would resolve vs and fs on its own. A module with two
@vertex functions and no :entry is a located error, never a guess.
Before compiling, a guess: how many GPU calls do these four forms become?
Keep your number; inspect reports it in step 3.
-
Check the source’s syntax, references and WGSL:
Terminal window npx pngine validate triangle.sjon -
Compile to a PNG with embedded bytecode (a 1×1 transparent pixel):
Terminal window npx pngine triangle.sjon -o triangle.png -
Confirm what the payload does:
Terminal window npx pngine inspect triangle.pngPNGB: triangle.pngBytecode: 57 bytesStrings: 1 entriesData section: 3 entriesExecution OK: 7 GPU callsShaders: 1Pipelines: 1Draw calls: 1Entry points (verify these match shader functions):Pipeline 0 vertex: vsPipeline 0 fragment: fsWarning: draw call without set_bind_groupWarning: 1 draw call(s) may have missing bind groupsEnsure bindGroups=[...] is set in render passes
inspect replays the bytecode against a mock GPU (a recorder that logs the
GPU calls instead of drawing), so it reports what the payload does, not just
that it parsed. PNGB is the compiled bytecode; 57 bytes of it here, because the
shader text lives in the data section rather than in the opcode stream.
The answer to the guess is seven: the shader module and the pipeline cost one call each, the pass unrolls into begin, set-pipeline, draw and end, and the frame’s submit closes it. The full trace, call by call, is in Debug Mode below.
The PNG is about 4.7 KB (4,706 bytes measured 2026-08-18); most of that is the embedded executor, the small WASM interpreter that plays the bytecode and makes the file self-contained.
Render a Preview
Section titled “Render a Preview”--frame draws the document on a real GPU and writes the result as the image,
instead of the 1×1 pixel:
# Render an actual 512x512 framepngine triangle.sjon --frame -o triangle.png
# Render at a specific sizepngine triangle.sjon --frame -s 1920x1080 -o triangle.pngRun in Browser
Section titled “Run in Browser”<!DOCTYPE html><html><head> <title>PNGine Triangle</title></head><body> <canvas id="canvas" width="512" height="512"></canvas>
<script type="module"> import { pngine, play } from 'pngine';
const p = await pngine('triangle.png', { canvas: document.getElementById('canvas') });
play(p); </script></body></html>From an Image Element
Section titled “From an Image Element”pngine/dev initializes directly from an <img>; the canvas is created and
positioned over the image:
<img id="shader" src="triangle.png" />
<script type="module"> import { pngine, play } from 'pngine/dev';
const p = await pngine('#shader'); play(p);</script>Animation Control
Section titled “Animation Control”import { pngine, play, pause, stop, draw, seek, destroy } from 'pngine';
const p = await pngine('shader.png', { canvas });
play(p); // Start animation looppause(p); // Pause (keeps current time)stop(p); // Stop and reset to t=0seek(p, 2.5); // Jump to a timedraw(p, { time: 1.0 }); // Render one framedestroy(p); // Release worker, listeners, GPU deviceProperties
Section titled “Properties”p.width // Canvas widthp.height // Canvas heightp.time // Current time in secondsp.isPlaying // Animation statep.frameCount // Number of (frame …) definitionsAdding Animation
Section titled “Adding Animation”Now make the triangle spin. A spinning triangle needs one thing the first
program never had: the time. A shader cannot ask for it (WGSL has no clock),
so the runtime has to write it somewhere the shader can read, and that
somewhere is a uniform buffer. Three new forms carry it there: pngine-inputs
is a built-in data source, 16 bytes of frame state the runtime refreshes
before each draw; a (queue …) form writes those bytes into a uniform buffer;
and a (bind-group …) binds that buffer to the pipeline’s
@group(0) @binding(0):
(shader-module :name code :code """ struct Uniforms { time: f32, width: f32, height: f32, aspect: f32, } @group(0) @binding(0) var<uniform> u: Uniforms;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { var pos = array<vec2f, 3>( vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5) );
let angle = u.time; let c = cos(angle); let s = sin(angle); let p = pos[i]; let rotated = vec2f(p.x * c - p.y * s, p.x * s + p.y * c);
return vec4f(rotated, 0.0, 1.0); }
@fragment fn fs() -> @location(0) vec4f { return vec4f(1.0, 0.0, 0.0, 1.0); }""")
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeTime (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(render-pipeline :name pipeline :layout auto (vertex :module code :entry vs) (fragment :module code :entry fs (target :format preferred-canvas-format)))
(bind-group :name uniformsGroup :layout pipeline :group 0 (entry :binding 0 :buffer uniforms))
(render-pass :name pass (color-attachment :view context-current-texture :clear-value [0 0 0 1] :load-op clear :store-op store) :pipeline pipeline :bind-groups [uniformsGroup] (draw :vertex-count 3))
(frame :name main :perform [writeTime pass])Run inspect on this version and the bind-group warning from the first
program is gone: the draw now travels with uniformsGroup bound. The warning
was never wrong; the first triangle really did bind nothing.
An exercise before moving on: make the triangle pulse instead of spin. Scale
p by 0.75 + 0.25 * sin(u.time) in place of the rotation, then run the same
validate, compile, inspect loop on it. The shader changes; the buffer, queue
and bind group do not, and that split (machinery that stands still while the
shader moves) is the shape of most PNGine programs.
Built-in Uniforms
Section titled “Built-in Uniforms”The 16 bytes are four f32 fields, in this order:
| Field | Type | Description |
|---|---|---|
time |
f32 | Elapsed seconds since start |
width |
f32 | Canvas width in pixels |
height |
f32 | Canvas height in pixels |
aspect |
f32 | width / height |
The WGSL struct must match this layout exactly.
Debug Mode
Section titled “Debug Mode”const p = await pngine('shader.png', { canvas, debug: true});[Worker] lines come from the worker thread, [GPU] lines from the command
dispatcher, one per GPU call it performs. A first frame reads like this
(abridged; the ids and byte counts follow the document):
[Worker] Using embedded executor from payload[GPU] Execute: 5 cmds, 42b[GPU] createShader(0, 245b)[GPU] createRenderPipeline(0) desc= {"vertex":{"entryPoint":"vs"},…}[GPU] beginRenderPass colorId=CANVAS loadOp=0 storeOp=0 clear=[0,0,0,1][GPU] setPipeline(0)[GPU] draw(3, 1) pass=valid[GPU] endPass[GPU] submit enc=trueNext Steps
Section titled “Next Steps”(buffer …)Vertex and uniform buffers(frame …)Frame execution and init passes.sjon is written in