WASM API
PNGine ships two WASM binaries with two unrelated interfaces. One is the executor, the small interpreter that travels inside every self-contained payload and turns PNGB (PNGine’s compact bytecode) into GPU command buffers. The other compiles SJON, the S-expression source language, in the browser:
| Binary | Role | Size |
|---|---|---|
| Embedded executor | Travels inside every self-contained PNG; turns PNGB bytecode into GPU command buffers | 9 to 14 KB, by variant (2026-08-18) |
pngine-compiler.wasm |
Compiles SJON source in the browser (editors, build tools) | 1.7 MB (2026-08-18) |
The executor interface is frozen (ABI v1). A PNG minted today must render on every future runtime, so the payload travels with its own interpreter and the contract between that interpreter and the host JS is append-only: exports may be added, never changed or removed; opcodes may be added, never renumbered or reused. The compiler has no such constraint: it ships next to the JS that calls it.
Embedded executor (ABI v1)
Section titled “Embedded executor (ABI v1)”The executor does not call WebGPU. It parses the PNGB payload and writes a
command buffer into its own linear memory; the host reads that buffer and
performs the GPU calls. The pngine worker is one host, the native CLI’s
--frame renderer is another, and the protocol below is enough to write a
third.
Exports
Section titled “Exports”| Export | Signature | Semantics |
|---|---|---|
memory |
WebAssembly.Memory |
Every pointer below is an offset into it |
getBytecodePtr |
() → u32 |
Where the host writes the payload head (header + opcodes + tables) |
setBytecodeLen |
(len: u32) |
Call after writing. Silently clamps to the binary’s bytecode cap (default 256 KB) |
getDataPtr |
() → u32 |
Where the host writes the data section (split mode) |
setDataLen |
(len: u32) |
Call after writing. Clamps to the data cap (default 512 KB). Never calling it selects single-buffer mode: the executor reads the data section out of the bytecode buffer |
getCommandPtr |
() → u32 |
Pointer to the command buffer |
getCommandLen |
() → u32 |
Length of the command buffer; valid only after init() / frame() |
init |
() → u32 |
Parse the header, emit resource-creation commands. 0 ok, 1 bytecode too short, 2 bad magic, 3 unsupported PNGB version, 4 malformed section layout (an executor span that overflows, section offsets out of order or past the buffer), 5 out-of-range id, 6 command buffer overflowed |
frame |
(time: f32, width: u32, height: u32) → u32 |
Emit per-frame commands. 0 ok, 1 not initialized, 2 out-of-range id, 3 command buffer overflowed. Width and height must be nonzero |
getFrameCounter |
() → u32 |
Frames rendered since init(); drives ping-pong pool selection |
setFrameCounter |
(n: u32) |
Restore a saved counter around ephemeral renders, so a thumbnail render does not shift pool phase. Absent on older binaries; feature-detect |
getAbiVersion |
() → u32 |
Read as exports.getAbiVersion?.() ?? 1; a binary without the export is v1 |
Buffer caps are per-binary build constants, not protocol: over-long input is clamped silently, never rejected.
Imports
Section titled “Imports”Release executors declare zero imports: WebAssembly.instantiate(bytes, {})
succeeds. Payloads that use nested WASM
((wasm-call …)) rely on host callbacks
(env.wasmInstantiate, env.wasmCall, env.wasmGetResult);
getExecutorImports() provides the full set regardless, and providing imports
a module never declared is harmless.
Call protocol
Section titled “Call protocol”- Split the payload with
parsePayload(pngb); section offsets come from the PNGB header. - Write
payload[0 .. offsets.data)atgetBytecodePtr(); callsetBytecodeLen. - If the data section is non-empty, write
payload[offsets.data .. offsets.wgsl)atgetDataPtr(); callsetDataLen. - Call
init(), which must return 0, then execute the command buffer atgetCommandPtr(). - Per frame: call
frame(time, width, height), which must return 0, then execute the buffer again.
Any nonzero status means “do not execute this buffer”: skip it and surface the code. The set is append-only, so a host must treat an unknown nonzero the same way.
The command buffer stays valid until the next init() or frame() call.
Command buffer
Section titled “Command buffer”Little-endian throughout. Pointers are u32 offsets into the executor’s memory.
Header (8 bytes): total_len: u32 total bytes including header cmd_count: u16 number of commands flags: u16 0 (reserved)
Then cmd_count commands: opcode: u8, followed by a fixed argument layout per opcodeMagic resource IDs: 0xFFFF = none/absent, 0xFFFE = the canvas swap-chain
texture.
A host that meets an opcode it does not know must stop: the operand width is
exactly what is unknown, so advancing would read operand bytes as opcodes. The
pngine dispatcher aborts the buffer and reports
unknown GPU command 0x… through onError. This is why the opcode table is
append-only, and why a new opcode ships in the JS dispatcher before any
executor emits it.
Append-only is a rule with a worked example. A pass’s clear value used to
travel as four bytes the host divided by 255, which saturated anything outside
0 to 1 on a float target. It travels as four f32 now, and rather than change
what an existing opcode means, two new ones carry it: 0x53
(begin_render_pass_f32) and 0x54 (begin_render_pass_mrt_f32). The two
byte-valued originals are retired from the emitter and still decoded by the
host, because payloads minted before the change still contain them.
The full opcode table with argument layouts lives in the engine repo:
src/executor/command_buffer.zig,
and docs/abi.md
is its frozen specification.
Driving it from JavaScript
Section titled “Driving it from JavaScript”Two npm subpaths cover the host side. pngine/executor (1.9 KB) loads the
binary; pngine/core (26.6 KB) executes the command buffers it produces:
| Export | From | Purpose |
|---|---|---|
parsePayload(pngb) |
pngine/executor |
Split a PNGB payload: {executor, bytecode, payload, plugins, offsets, hasEmbeddedExecutor, …} |
createExecutor(wasmBytes, imports) |
pngine/executor |
Instantiate; returns {memory, exports, …} plus wrappers for every ABI export |
getExecutorImports(callbacks?) |
pngine/executor |
The host import object (env.log, env.wasm*) |
getExecutorVariantName(plugins) |
pngine/executor |
Variant name for fetching a non-embedded executor, e.g. "core-render-compute" |
createCoreDispatcher(device, ctx) |
pngine/core |
The GPU dispatcher: setMemory, execute(ptr), setUniform(s), setTime, setCanvasSize, setDebug, destroy |
getDevice(adapter?) |
pngine/core |
Request a device with every optional feature the adapter supports |
configureCanvas(canvas, device, alphaMode?) |
pngine/core |
Configure a webgpu context; pass canvasAlphaMode(bytecode) to honor an authored (canvas :alpha-mode …) |
import { extractBytecode } from "pngine";import { createExecutor, getExecutorImports, parsePayload } from "pngine/executor";import { canvasAlphaMode, configureCanvas, createCoreDispatcher, getDevice } from "pngine/core";
const png = await (await fetch("art.png")).arrayBuffer();const pngb = await extractBytecode(png);
const payload = parsePayload(pngb);const exec = await createExecutor(payload.executor, getExecutorImports());
// Feed the payload through the ABIconst head = payload.payload.subarray(0, payload.offsets.data);new Uint8Array(exec.memory.buffer, exec.getBytecodePtr(), head.length).set(head);exec.setBytecodeLen(head.length);
const data = payload.payload.subarray(payload.offsets.data, payload.offsets.wgsl);if (data.length > 0) { new Uint8Array(exec.memory.buffer, exec.getDataPtr(), data.length).set(data); exec.setDataLen(data.length);}
// Wire the dispatcher and runconst device = await getDevice();const ctx = configureCanvas(canvas, device, canvasAlphaMode(pngb));const gpu = createCoreDispatcher(device, ctx);gpu.setMemory(exec.memory);
exec.init();gpu.execute(exec.getCommandPtr());
function tick(ms) { gpu.setTime(ms / 1000); gpu.setCanvasSize(canvas.width, canvas.height); exec.frame(ms / 1000, canvas.width, canvas.height); gpu.execute(exec.getCommandPtr()); requestAnimationFrame(tick);}requestAnimationFrame(tick);For anything less low-level than this, use the
JavaScript API: pngine() runs the same
protocol in a worker.
In-browser compiler
Section titled “In-browser compiler”pngine-compiler.wasm is the full SJON compiler (parse, validate, WGSL
checking, bytecode emission) compiled for the browser. The npm package ships
it at the pngine/compiler-wasm subpath and its wrapper at pngine/compiler.
Wrapper
Section titled “Wrapper”import { createCompiler } from "pngine/compiler";
const compiler = await createCompiler(wasmUrl); // URL to pngine-compiler.wasm
const { pngb, errors, diagnostics } = compiler.compile(source);const { png } = compiler.compileToPng(source); // self-contained PNGconst { minified } = compiler.minifyWgsl(wgslText); // wgslender minifycompile returns PNGB bytecode; compileToPng runs the whole pipeline
(plugin detection, executor variant selection, 1×1 PNG encoding, embedding into
the pNGb chunk, the ancillary PNG chunk that carries the bytecode) and
returns a finished PNG. Both return
{…, errors: string|null, diagnostics: Array}; diagnostics is populated even
on success (warnings, advisory findings).
Raw exports
Section titled “Raw exports”For hosts that skip the wrapper:
| Export | Signature | Semantics |
|---|---|---|
getSourcePtr |
() → ptr |
Fixed source buffer; write UTF-8 SJON here |
setSourceLen |
(len: u32) |
Silently clamps to the 256 KiB source cap |
compile |
() → i32 |
0 ok, 1 parse error, 2 validation error, 3 emit error, -1 OOM |
compileToPng |
() → i32 |
Same codes plus 4 PNG error |
minifyWgsl |
() → i32 |
0 ok, 1 minify failure, 2 output too large, -1 OOM |
getOutputPtr / getOutputLen |
Result bytes (256 KiB cap) | |
getErrorPtr / getErrorLen |
Error text (4 KiB cap) | |
getDiagPtr / getDiagLen |
Diagnostics JSON array (32 KiB cap) |
Instantiation requires one stub import beyond env.log:
env.sjon_host_invoke_plugin must exist and may return 0: the SJON expression
evaluator declares it unconditionally, and PNGine registers no executable
plugins that would call it. The wrapper provides both stubs.
The source cap is real: setSourceLen clamps rather than erroring, so a
document past 256 KiB truncates and fails validation with an unrelated-looking
diagnostic. The native CLI has no such cap short of its 16 MiB input guard, so
when the browser compiler rejects a document the CLI accepts, suspect the cap.
Related
Section titled “Related”- JavaScript API - The high-level runtime over this ABI
- Bytecode Format - The PNGB payload the executor parses
- CLI Reference - The native compiler and renderer