Skip to content

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.

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.

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.

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.

  1. Split the payload with parsePayload(pngb); section offsets come from the PNGB header.
  2. Write payload[0 .. offsets.data) at getBytecodePtr(); call setBytecodeLen.
  3. If the data section is non-empty, write payload[offsets.data .. offsets.wgsl) at getDataPtr(); call setDataLen.
  4. Call init(), which must return 0, then execute the command buffer at getCommandPtr().
  5. 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.

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 opcode

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

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 ABI
const 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 run
const 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.

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.

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 PNG
const { minified } = compiler.minifyWgsl(wgslText); // wgslender minify

compile 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).

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.