Skip to content

Limits & Roadmap

What the engine refuses, what it clamps, and what I have not built yet. Recall the triangle from Getting Started: one shader module, one pipeline, one pass, one frame, 631 bytes of bytecode. Nothing on this page will ever bother it. But documents grow, and this page lists every wall a growing document can hit. The values matter less than the enforcement points: a compile-time rejection is a diagnostic you read, a runtime clamp is a failure you reconstruct. I’ll walk you through which caps are which, and one place where I made the executor refuse outright because clamping there would have been worse than either.

Limit Value Enforced
Passes per document 32 Compile time; the emitter rejects the document
Frames per document 64 Compile time
Bundles per :execute-bundles 16 Compile time
Arguments per (wasm-call …) 32 Compile time
Colour attachments per multiple-render-target (MRT) pass 8 Structural; the emitter cannot emit more
CLI input size (file or stdin) 16 MiB Read time
Browser compiler source 256 KiB Clamped silently (see below)
Browser compiler output 256 KiB Errors
Executor bytecode buffer 256 KiB Clamped silently (per-binary default)
Executor data buffer 512 KiB Clamped silently (per-binary default)
Executor command buffer 64 KiB Reported: init returns 6, frame returns 3
Diagnostics in one report 64 Reported: --json carries a dropped count

Why 32? The embedded executor (the small WASM interpreter that plays the bytecode inside each payload) stays between 9 and 14 KB partly by allocating nothing: its tables are fixed arrays, sized when the WASM is built. Passes live in one of those:

the executor's pass table, sized at build time:
slot 0 1 2 31
+-------+-------+-------+ ... +-------+
| range | range | range | | range |
+-------+-------+-------+ ... +-------+
range: where that pass's opcodes begin and end in the bytecode

A 33rd pass has no slot. And here is the trap the compile-time check exists to close: the executor stores a range only for pass_id < 32 and drops the rest at load, while the native reference dispatcher (the one behind --frame and the test suite) keeps an unbounded pass map. Without the check, an over-cap document renders perfectly in a native preview and loses passes in every browser. Works-on-my-machine, made literal. So the emitter refuses the document instead, and its diagnostic tells the whole story:

pass 'pass33' is pass #33, but the browser executor stores at most 32
passes — passes beyond that are silently dropped at load (native
`--frame` has no such cap, so this renders in a preview but not in a
browser)

The 64-frame cap is the same asymmetry with a different fixed table, refused the same way.

The executor’s bytecode and data buffer clamps are ABI behaviour (lengths are clamped, never rejected) and matter only to hosts driving the WASM API directly.

That pays the promise from the top of the page. Why does the command buffer refuse (a status code) when the executor’s other buffers clamp? Because those clamps are harmless: a truncated source or bytecode buffer fails validation a moment later. A clamped command stream would not fail, it would tear:

what clamping would hand the player:
64 KiB cap
v
| begin-pass | set-pipeline | draw | writ▓▓▓▓▓
^ the cut lands mid-command, and
every byte past it decodes as
noise played into a real GPU queue
what the executor does instead:
no partial buffer is emitted; `init` returns 6, `frame` returns 3, and
the host must skip that buffer rather than play it

The triangle’s frame is a handful of commands; a document has to work hard to fill 64 KiB. But where a clamp stays harmless, the ABI clamps, and where it would play garbage, the executor refuses. That is the whole enforcement policy in one sentence.

Decisions I made, not gaps:

  • No resource destruction. I kept the command stream create-only; the triangle makes a module, a pipeline and a pass, and frees none of them. Resource lifetime is scoped to the GPU device, released by whole-instance teardown (destroy(p)). A payload is data: it describes what exists, not an allocation sequence to balance.
  • PNGB, the bytecode format, stays at version 0. Payloads evolve by shipping a newer embedded executor, never by changing the header. The executor↔JS interface is frozen as ABI v1, append-only.
  • WGSL lint is validate-only. compile and render don’t pay for it, and I left the rule engine out of the browser compiler entirely (88 KB).
  • Multi-file documents. No import form; keep each document single-file. Compile-time reuse across files means generating the SJON source (the S-expression language PNGine reads) with your own tooling.
  • Timeline authoring. The payload format has an animation table and the runtime reads it (p.animation, p.duration), but no SJON form emits one. Sequencing is a JavaScript concern today: setFrame over multiple (frame …) definitions.
  • Shader composition. One (shader-module …) is one WGSL compilation unit; the triangle’s vs and fs sharing a module is the only composition there is. A concatenation opcode exists in the format but is reserved: never emitted, rejected by the dispatcher.
Terminal window
pngine validate shader.sjon --json # schema + WGSL + cap diagnostics
pngine inspect shader.sjon # what the compiled payload actually does

An exercise: make a cap fire once, so you know its voice. Duplicate the triangle’s (render-pass …) form until there are 33 (let a shell loop write the boilerplate; each copy needs a distinct :name, all can share the one pipeline), list them in the frame, and run pngine validate. The diagnostic quoted above arrives at pass #33, names the offending pass, and exits non-zero. That one loud refusal is the contrast worth holding on to: it is exactly what the silent clamps never give you.