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. All GPU work runs in a WebWorker over an OffscreenCanvas.

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.

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

Node resolution gets stubs that throw: the runtime is browser-only. 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("shader.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)

The default profile is strict, and the rejections are part of its contract; each one throws a plain Error whose message points at pngine/dev:

  • CSS-selector and HTMLImageElement sources
  • the wasmUrl option
  • payloads without an embedded executor

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; 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 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) / 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.

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 }, … }

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.

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

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.

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();

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),
});

Setup failures reject the promise: no WebGPU adapter, no pNGf chunk, or a chunk whose version byte is not 2. Version 2 is the current one; a --flat PNG exported before clear values became one f32 per channel encodes its pass commands differently, and the player refuses it rather than walking a stream it would misread. Re-export it with the current CLI.

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("shader.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.

// pngine: the production viewer
export { 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, plus
export { parsePayload, createExecutor, getExecutorImports, getExecutorVariantName } from "./loader.js";
// pngine/mini
export { miniPngine };