Skip to content

JavaScript API

The runtime is a set of tree-shakeable ESM functions: an instance handle comes out of pngine(), and every other function takes it as its first argument. I put all GPU work in a WebWorker over an OffscreenCanvas, so a heavy shader costs the page’s main thread nothing but messages.

What it plays is a payload the CLI compiled: PNGB, PNGine’s compact bytecode, normally travelling inside a PNG’s pNGb chunk together with the executor, the small WASM interpreter that turns that bytecode into GPU commands. Where an example below says triangle.png, it means the triangle from Getting Started compiled to a PNG; features the triangle is too small to exercise (readback, flat payloads) borrow documents of their own.

If that page is where you came from, pngine(), play and draw are the whole story for now. The rest is what a real application eventually needs (uniforms, readback, device loss, teardown), one section each; skip freely and come back when a need arrives.

The npm package ships six player profiles (its other subpaths are the compiler, the raw worker and wasm files, and the schema). Sizes are production measurements (2026-08-18, gzip in parentheses), each gated by a hard budget in the bundler:

Import Size What it is
pngine 49.6 KB (17.0) The production viewer. Worker inlined; accepts payloads with an embedded executor only
pngine/dev 54.1 KB (18.5) The viewer plus what it deliberately rejects: selector and <img> sources, a pngine.wasm fetch fallback, prefetch, executor helpers
pngine/mini 7.1 KB (3.3) Main-thread player for flat pNGf payloads (pre-decoded command buffers written by --flat, no executor), audio included
pngine/mini-no-audio 6.4 KB (3.0) mini with the audio path compiled out
pngine/core 26.6 KB (9.0) GPU dispatcher and loader only: no worker, no animation loop
pngine/executor 1.9 KB (0.9) Payload splitting and executor instantiation

This page covers pngine and pngine/dev; pngine/core and pngine/executor are documented in the WASM API, and choosing between the tiers in Shipping a Player.

import { pngine, play } from "pngine";
const p = await pngine("triangle.png", {
canvas: document.getElementById("canvas"),
});
play(p);

Sources: a URL string, ArrayBuffer, Uint8Array, or Blob. The bytes may be a PNG with a pNGb chunk, raw PNGB, or a ZIP bundle; the loader detects the container.

Options:

Option Type Default Description
canvas HTMLCanvasElement required Target canvas; a missing one throws TypeError("viewer pngine() requires options.canvas")
debug boolean false [Worker] / [GPU] console logging
dpr number device Device-pixel-ratio override for the backing store
autoResize boolean false Leave the canvas CSS size to the page and keep the backing store synced to its rendered size
onError function - Receives worker errors, shader errors and GPU errors after init (see Error handling)
onQueryResult function - Receives the contents of every map-read buffer the frame copies into, as one number per 64-bit lane (see Buffer readback)

I made the default profile strict on purpose, and the rejections are part of its contract. A source or option of the wrong kind is a TypeError whose message points at pngine/dev:

  • CSS-selector and HTMLImageElement sources
  • the wasmUrl option

A payload without an embedded executor is refused too, as a plain Error: that is the bytes, not the call.

Same API, looser contract, for development and tooling:

import { pngine, play } from "pngine/dev";
const p = await pngine("#shader-img"); // selector or <img> element:
play(p); // canvas created over the image
const q = await pngine("legacy.png", { // payload without an embedded
canvas, // executor: fetch the shared
wasmUrl: "/pngine.wasm", // runtime instead
});

pngine/dev additionally re-exports the executor helpers (parsePayload, createExecutor, getExecutorImports, getExecutorVariantName).

Function Effect
play(p) Start the animation loop (requestAnimationFrame)
pause(p) Stop the loop, keep the current time
stop(p) Stop the loop, reset time to 0
seek(p, time) Jump to a time in seconds (audio seeks with it) and draw
draw(p, opts?) Render one frame
setFrame(p, frame) Pin which (frame …) renders for every later draw, the play loop’s included; null releases the pin (see Sequencing frames)
restart(p) Rebuild the GPU device and replay the loaded bytecode (see Device loss)
destroy(p) Tear down: listeners, worker, GPU device

Every function has one contract. An argument of the wrong kind (a time that is not a finite number, a frame that is not a string or null, bytes that are not bytes, a trailing argument that is not an options object) is a TypeError from the call itself, before anything is posted. On a destroyed instance the call returns p and posts nothing. And each one returns p, so calls chain. What the runtime cannot honour at draw time (an unknown frame or uniform name, a buffer write it must refuse) is not a TypeError: it reaches onError with a source naming the call (see Error handling).

draw options:

Option Type Description
time number Time in seconds (default: current playback time)
frame string | null The (frame …) this one draw renders, or null for the executor’s own choice; absent, the draw carries the pin setFrame holds
uniforms object Uniform values written for this draw
buffers object Bytes for buffers by their (buffer :name …), applied at the head of this draw (see Host-writable buffers)
draw(p); // current time
draw(p, { time: 1.5 }); // explicit time
draw(p, { frame: "intro" }); // one named frame
draw(p, { uniforms: { brightness: 0.8 } }); // with uniform values

Call destroy(p) when removing an instance: the worker, the GPU device and seven input listeners (five on the canvas, two on the document, for the pointer and keyboard state) outlive garbage collection otherwise.

Uniform names come from the WGSL struct fields of buffers the document binds; the compiler reflects them into the payload’s uniform table.

setUniform(p, name, value, options?) / setUniforms(p, uniforms, options?)

Section titled “setUniform(p, name, value, options?) / setUniforms(p, uniforms, options?)”
setUniform(p, "brightness", 0.5);
setUniforms(p, { brightness: 0.8, color: [1.0, 0.5, 0.0] });
setUniform(p, "brightness", 0.5, { redraw: false }); // rides the next draw, whoever posts it

Values ride a draw: each call renders one frame with the values written into the uniform buffer, where they persist for subsequent frames. With { redraw: false } the values are stored and ride the next draw from anywhere (the play loop’s, a seek’s, a host draw), which clears the store; writeBuffer spells the option the same way. That is how a host batches several writes into one frame, or writes every frame during play without a before-draw hook. A positional boolean in that place is a TypeError naming the spelling: through 3.0.1 it was accepted and did nothing.

A name the payload does not reflect is refused through onError (name PngineGPUError, source "set-uniform"), never dropped silently. One cause looks like a runtime bug but is a compile-time one: if reflection failed for a module (reported as a validate warning), its fields are missing from the uniform table, and every setUniform on them is that refusal.

Resolves to every reflected field by name, {} before init or after 2 s without a worker reply:

const uniforms = await getUniforms(p);
// { time: { type: "f32", size: 4, buffer: "uniforms", offset: 0, elemCount: 0 },
// bands: { type: "vec4f", size: 32, buffer: "uniforms", offset: 16, elemCount: 2 }, … }

type is the field’s WGSL type by name (f32, vec3f, mat4x4f, the integer vectors; unknown for a type the compiler could not reflect, which is written best-effort as f32). buffer is the (buffer :name …) the field lives in, the name writeBuffer and getBuffers speak, or null for a PNG built by a CLI from before the buffer names were written into the payload (3.0.1 and earlier). offset and size are bytes, the whole array for an array field; elemCount is the array length, 0 for a field that is not an array, and for an array type is the element’s.

Every (buffer :name …) is addressable from JavaScript by that name, whatever it holds: a bare array<vec2f> no uniform reflects, a vertex buffer, an indirect-args buffer. The compiler writes the names into the payload and the runtime resolves them.

import { draw, getBuffers, writeBuffer } from "pngine";
writeBuffer(p, "particles", bytes); // one draw, carrying the write
writeBuffer(p, "particles", bytes, { offset: 4096 });
writeBuffer(p, "particles", bytes, { redraw: false }); // rides the next draw, whoever posts it
draw(p, { time, buffers: { particles: bytes } }); // the primitive both are sugar over

bytes is any typed array or DataView (written as the window it covers) or a bare ArrayBuffer. The write lands at the head of the frame that draw renders, before the frame’s own queue operations. Every write is a copy: the draw message crosses to the worker by structured clone, once, so a slice out of an arena is fine and the arena stays yours. With redraw: false the bytes are read when the next draw is posted, not when writeBuffer returns.

Three rules, each refused through onError (name PngineGPUError, source "write-buffer"), never dropped silently:

  • copy-dst makes a buffer writable. WebGPU’s queue.writeBuffer requires it, and :usage [storage], the natural spelling for GPU-computed state, does not carry it. Add it: (buffer :name particles :size … :usage [storage copy-dst]). A buffer whose initial bytes the compiler supplies (:data or :index-of) gets it implied, because those bytes arrive the same way.
  • A buffer the frame itself writes every draw is refused. The frame’s own (write-buffer …) targets, copy destinations and query-resolve destinations run after a host write and would overwrite it every draw; getBuffers reports them as frameWritten. Give the host a dedicated buffer. An :init seed is not frame-written: it runs once per load.
  • A pooled buffer names its member. :pool N declares N copies of a buffer the frame alternates between (a ping-pong buffer). poolIndex: k writes member k, "active" the member the coming frame reads at :ping-pong 0, and no poolIndex writes every member, which is what seeding a ping-pong state means.

Offset and length are multiples of 4, as for an authored (write-buffer …). Bytes that are not bytes, a negative offset or a bad poolIndex are a TypeError from the call itself, before anything is posted.

Resolves to every named buffer, {} before init:

await getBuffers(p);
// { particles: { size: 16384, usage: 0x88, writable: true, pool: 1, frameWritten: false },
// uniforms: { size: 16, usage: 0x48, writable: true, pool: 1, frameWritten: true } }

usage is the GPUBufferUsage bits as authored, writable its copy-dst bit, pool the member count, frameWritten the second rule above. pngine render … --types writes the same names into the .d.ts sidecar as Buffers, beside Uniforms.

A frame that copies into a buffer whose :usage includes map-read is asking for its contents on the CPU. map-read is WebGPU’s mappable usage, host memory the GPU may only copy into, which is why it pairs with copy-dst and nothing else. After each submit the runtime maps every such destination the frame wrote and hands the contents to onQueryResult, on the default profile and on pngine/dev alike:

const p = await pngine("kernel.png", {
canvas,
onQueryResult: (lanes) => {
const [bound, winner, steps, total] = lanes;
console.log(`${winner} takes ${steps} steps`);
},
});

The values arrive as 64-bit lanes, one number per eight bytes. That shape comes from the two features the path was written for: timestamp and occlusion query results are 64-bit. A compute shader that wants its own results back has to write them the same way, as vec2u(value, 0u) with the answer in the low half. Write plain 32-bit values and consecutive pairs arrive fused into one lane, with no error and no warning.

Four things worth knowing before relying on it:

  • Each lane goes through Number(), so a value above 2^53 arrives rounded.
  • A frame whose destination is still mapped is skipped, copy included. The readback is at most one per submit, not exactly one.
  • A callback that throws ends readback for the session. The buffer is left mapped, and every later frame then hits the case above.
  • Readbacks with no onQueryResult attached are dropped, not queued for whenever a handler appears.

(query-set …) is the usual producer. Collatz search is the opposite case, a program with no render pass at all whose entire output is four lanes.

Live resource counts, the instrument for long-running sessions:

const { gpu, wasmBytes, frameCount, moduleLoaded, deviceLost } = await getStats(p);

gpu.live counts the GPU objects held right now, by kind; gpu.executed counts command buffers run; wasmBytes is the executor’s memory size. Over a soak, gpu.live stays flat while gpu.executed climbs, and wasmBytes never changes: the embedded executor has no allocator, so growth there is itself a bug. Resolves to { gpu: null, … } on a destroyed instance.

An exercise for a page you already have open: call getStats twice, a minute of playback apart. gpu.executed climbs; gpu.live and wasmBytes come back identical. That is the invariant to hold a long-running embed to, and drift in either of the flat two is a bug worth reporting.

The GPU device can be lost under a running instance (driver reset, GPU eviction). onError receives a PngineGPUError whose source is "device-lost". The pngine viewer then recovers on its own: the worker acquires a fresh device and replays the loaded bytecode. pngine/dev stays passive (draws become no-ops) until the host calls restart(p).

Read-only, safe on a destroyed instance:

Property Description
p.width / p.height Canvas size in logical pixels
p.time Current playback time in seconds
p.isPlaying Loop state
p.frameCount Number of (frame …) definitions in the payload
p.currentFrame The frame name setFrame pinned, null when none
p.audio The song, when the payload carries a pNGa chunk (an audio WASM module): duration, playing and currentTime (seconds, off the audio clock), read-only; null otherwise. play, pause, stop and seek move the song with the clock, so there is nothing to call here

A document may declare several (frame …) forms. The executor renders the first one until told otherwise: setFrame(p, name) pins another for every later draw, the play loop’s included, and setFrame(p, null) hands the choice back. p.currentFrame reads the pin and p.frameCount counts the frames. A name the payload does not define is refused through onError (name PngineGPUError, source "set-frame", the message listing the names it does define), and the draw still renders whatever was pinned before.

Change the pin when the scene changes, not every frame; the pin holds:

const scenes = [
{ name: "introFrame", until: 10 },
{ name: "mainFrame", until: 25 },
{ name: "outroFrame", until: 30 },
];
play(p);
let current = null;
function tick() {
const t = p.time % 30;
const next = scenes.find((s) => t < s.until).name;
if (next !== current) setFrame(p, (current = next));
requestAnimationFrame(tick);
}
tick();

The payload format also carries an animation table (a scene timeline the executor can select frames from on its own), but no form of SJON, the S-expression source language, emits one: there is no (animation …) or scene-scheduling form in the schema, and the runtime no longer reads the table into JavaScript. An authoring form is deferred work, tracked on Limits & Roadmap.

pngine/mini plays flat pNGf payloads (produced with pngine … --flat) on the main thread, with no worker and no WASM executor:

import { miniPngine } from "pngine/mini";
const player = await miniPngine(canvas, "art.png", {
autoplay: true,
onError: (err) => console.error(err),
});

Runtime failures stop the loop and go to onError. The mini player implements a subset of the command set by design; the flat writer refuses to emit a payload it cannot play, so an unsupported-opcode error means a corrupt or hand-built payload; re-export without --flat and use the full viewer.

const p = await pngine("triangle.png", {
canvas,
onError: (err) => {
if (err.name === "PngineShaderError") {
// WGSL compilation: err.lineNum, err.linePos, err.context
} else if (err.name === "PngineGPUError") {
// WebGPU uncaptured error, device loss (err.source === "device-lost"),
// or a refused host call: err.source is "set-frame" for an unknown
// frame name, "set-uniform" for an unreflected uniform name,
// "write-buffer" for a buffer write the runtime cannot honour
}
},
});

Init failures reject the pngine() promise; everything after init routes to onError.

// pngine: the production viewer
export { pngine, destroy } from "./viewer-init.js";
export {
draw, play, pause, stop, seek, setFrame,
setUniform, setUniforms, getUniforms, writeBuffer, getBuffers, getStats, restart,
} from "./anim.js";
export { extractBytecode, detectFormat, isPng, isZip, isPngb } from "./extract.js";
// pngine/dev: all of the above, plus
export { parsePayload, createExecutor, getExecutorImports, getExecutorVariantName } from "./loader.js";
// pngine/mini
export { miniPngine };