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.
Profiles
Section titled “Profiles”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.
Initialization
Section titled “Initialization”pngine(source, options)
Section titled “pngine(source, options)”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 Error("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; each one throws a plain Error whose message points at
pngine/dev:
- CSS-selector and
HTMLImageElementsources - the
wasmUrloption - payloads without an embedded executor
pngine/dev
Section titled “pngine/dev”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).
Playback
Section titled “Playback”| 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; null releases the pin |
restart(p) |
Rebuild the GPU device and replay the loaded bytecode (see Device loss) |
destroy(p) |
Tear down: listeners, worker, GPU device |
Every function is a no-op on a destroyed instance except draw, which throws.
draw options:
| Option | Type | Description |
|---|---|---|
time |
number | Time in seconds (default: current playback time) |
frame |
string | null | Frame name; null lets the animation timeline resolve it |
uniforms |
object | Uniform values written for this draw |
draw(p); // current timedraw(p, { time: 1.5 }); // explicit timedraw(p, { frame: "intro" }); // one named framedraw(p, { uniforms: { brightness: 0.8 } }); // with uniform valuesCall 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.
Uniforms
Section titled “Uniforms”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) / setUniforms(p, uniforms)
Section titled “setUniform(p, name, value) / setUniforms(p, uniforms)”setUniform(p, "brightness", 0.5);setUniforms(p, { brightness: 0.8, color: [1.0, 0.5, 0.0] });Values ride a draw: each call renders one frame with the values written into
the uniform buffer, where they persist for subsequent frames. Both take a
trailing redraw boolean that defaults to true; passing false skips the
draw and therefore discards the values; there is no deferred store.
A caveat that 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 setUniform on them does nothing.
getUniforms(p)
Section titled “getUniforms(p)”Resolves to the uniform metadata, {} before init or after 2 s without a
worker reply:
const uniforms = await getUniforms(p);// { time: { type: "f32", size: 4, bufferId: 0, offset: 0 }, … }Buffer readback
Section titled “Buffer readback”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
onQueryResultattached 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.
getStats(p)
Section titled “getStats(p)”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.
Device loss
Section titled “Device loss”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).
Properties
Section titled “Properties”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.duration |
Animation-table duration in seconds, 0 without one |
p.animation |
Animation-table metadata, null without one |
p.currentScene / p.currentFrame |
Timeline position, null without one |
p.audio |
The audio player when the payload carries a pNGa chunk (an audio WASM module) |
Animation timeline
Section titled “Animation timeline”The runtime carries a timeline reader (p.animation, p.currentScene,
p.duration), but no form of SJON, the S-expression source language, emits
a timeline today. There is no
(animation …) or scene-scheduling form in the schema, so on any payload
compiled from .sjon these read empty: p.animation is null and
p.duration is 0. An authoring form is deferred work, tracked on
Limits & Roadmap.
To sequence scenes, declare several
(frame …) forms and switch between them from
JavaScript:
const scenes = [ { name: "introFrame", until: 10 }, { name: "mainFrame", until: 25 }, { name: "outroFrame", until: 30 },];
function tick() { const t = p.time % 30; setFrame(p, scenes.find((s) => t < s.until).name); requestAnimationFrame(tick);}tick();Mini player
Section titled “Mini player”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.
Error handling
Section titled “Error handling”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 or device loss (err.source === "device-lost") } },});Init failures reject the pngine() promise; everything after init routes to
onError.
Exports
Section titled “Exports”// pngine: the production viewerexport { pngine, destroy } from "./viewer-init.js";export { draw, play, pause, stop, seek, setFrame, setUniform, setUniforms, getUniforms, getStats, restart,} from "./anim.js";export { extractBytecode, detectFormat, isPng, isZip, isPngb } from "./extract.js";
// pngine/dev: all of the above, plusexport { parsePayload, createExecutor, getExecutorImports, getExecutorVariantName } from "./loader.js";
// pngine/miniexport { miniPngine };Related
Section titled “Related”- Getting Started - First program, browser setup
- Shipping a Player - Choosing a profile and payload format
- WASM API -
pngine/core,pngine/executor, the compiler (frame …)- Frame definitionssetFrameselects(queue …)- The runtime data sources behind the built-in uniforms