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 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
HTMLImageElementsources - the
wasmUrloption
A payload without an embedded executor is refused too, as a plain Error:
that is the bytes, not the call.
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 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 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, 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 itValues 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.
getUniforms(p)
Section titled “getUniforms(p)”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.
Host-writable buffers
Section titled “Host-writable buffers”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.
writeBuffer(p, name, bytes, options?)
Section titled “writeBuffer(p, name, bytes, options?)”import { draw, getBuffers, writeBuffer } from "pngine";
writeBuffer(p, "particles", bytes); // one draw, carrying the writewriteBuffer(p, "particles", bytes, { offset: 4096 });writeBuffer(p, "particles", bytes, { redraw: false }); // rides the next draw, whoever posts itdraw(p, { time, buffers: { particles: bytes } }); // the primitive both are sugar overbytes 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-dstmakes a buffer writable. WebGPU’squeue.writeBufferrequires 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 (:dataor: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;getBuffersreports them asframeWritten. Give the host a dedicated buffer. An:initseed is not frame-written: it runs once per load. - A pooled buffer names its member.
:pool Ndeclares N copies of a buffer the frame alternates between (a ping-pong buffer).poolIndex: kwrites memberk,"active"the member the coming frame reads at:ping-pong 0, and nopoolIndexwrites 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.
getBuffers(p)
Section titled “getBuffers(p)”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.
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.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 |
Sequencing frames
Section titled “Sequencing frames”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.
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, 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.
Exports
Section titled “Exports”// pngine: the production viewerexport { 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, 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(buffer …)- BufferswriteBufferaddresses by name(queue …)- The runtime data sources behind the built-in uniforms