Getting Started
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.
-
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
bind-group warning is advisory and expected for this program: the triangle
hardcodes its vertices, so it binds nothing.
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”Make the triangle rotate by adding time-based uniforms. 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 the 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])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”- SJON Syntax - Complete language overview
- CLI Reference - All command options
- JavaScript API - Full runtime API
- Shipping a Player - Runtime tiers, payload formats
(buffer …)- Vertex and uniform buffers(frame …)- Frame execution and init passes- SJON language docs - The host language
.sjonis written in