Skip to content

Getting Started

Terminal window
npm install pngine@^3

One 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.

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.

  1. Check the source’s syntax, references and WGSL:

    Terminal window
    npx pngine validate triangle.sjon
  2. Compile to a PNG with embedded bytecode (a 1×1 transparent pixel):

    Terminal window
    npx pngine triangle.sjon -o triangle.png
  3. Confirm what the payload does:

    Terminal window
    npx pngine inspect triangle.png
    PNGB: triangle.png
    Bytecode: 57 bytes
    Strings: 1 entries
    Data section: 3 entries
    Execution OK: 7 GPU calls
    Shaders: 1
    Pipelines: 1
    Draw calls: 1
    Entry points (verify these match shader functions):
    Pipeline 0 vertex: vs
    Pipeline 0 fragment: fs
    Warning: draw call without set_bind_group
    Warning: 1 draw call(s) may have missing bind groups
    Ensure 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.

--frame draws the document on a real GPU and writes the result as the image, instead of the 1×1 pixel:

Terminal window
# Render an actual 512x512 frame
pngine triangle.sjon --frame -o triangle.png
# Render at a specific size
pngine triangle.sjon --frame -s 1920x1080 -o triangle.png
<!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>

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>
import { pngine, play, pause, stop, draw, seek, destroy } from 'pngine';
const p = await pngine('shader.png', { canvas });
play(p); // Start animation loop
pause(p); // Pause (keeps current time)
stop(p); // Stop and reset to t=0
seek(p, 2.5); // Jump to a time
draw(p, { time: 1.0 }); // Render one frame
destroy(p); // Release worker, listeners, GPU device
p.width // Canvas width
p.height // Canvas height
p.time // Current time in seconds
p.isPlaying // Animation state
p.frameCount // Number of (frame …) definitions

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])

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.

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=true