A typed WebGPU engine in which a single ArkType scope is the source of truth for both the CPU-side data and the generated WGSL. You declare types once; buffer layouts, struct declarations, pack/unpack helpers, bind group layouts and shader entry points are derived from them.
Scope<$>
→ engine.type / engine.define (ArkType vocabulary)
→ engine.buffer(...) (resource definition)
→ engine.compute / engine.render (program definition)
→ link() (pure, device-free, unit-testable)
→ serialize() / deserialize() (optional AOT JSON boundary)
→ compile({ device? }) (WebGPU materialization)
→ computePass() / submit() (execution)
- A WebGPU runtime (
navigator.gpu, or any object satisfyingGPUDevice). arktype2.2.3.@schema-pop/schema,@schema-pop/core,@schema-pop/exporter— these carry the WGSL vocabulary, the schema analyzer and the WGSL exporter.- Bun for the test and build scripts.
import { scope } from "arktype";
import { wgsl } from "@schema-pop/schema";
import { Sandblaster } from "@sandblaster/core";
const $ = scope({
...wgsl.import(), // vec3f, u32, au32 (atomic), builtins, ...
Result: { sumFixed: "au32 = 0" },
});
const engine = Sandblaster.create($);
const result = engine.buffer(engine.type("Result"), {
label: "result",
value: { sumFixed: 0 },
readback: true,
});
const program = engine.compute({
label: "accumulate",
resources: { result },
compute: {
params: engine.type({ id: "global_invocation_id" }),
workgroupSize: 64,
code: /* wgsl */ `
if (id.x >= 1024u) { return; }
atomicAdd(&result.sumFixed, 1u);
`,
},
});
await engine.compile(); // or compile({ device })
engine.computePass((pass) => {
pass.run(program, { items: 1024 });
});
console.log(await result.readback());Sandblaster.blank() is a shorthand for Sandblaster.create(wgsl) — the bare
schema-pop WGSL scope with no application types.
engine.buffer(type, descriptor) takes an already parsed ArkType Type and a
descriptor that keeps the native WebGPU fields:
const particles = engine.buffer(engine.type("Particle"), {
label: "particles",
count: 4096, // number of physical records; default 1
value: [...], // initial value (array when count > 1)
readback: true, // adds COPY_SRC and enables readback()
usage: extraFlags, // merged with the inferred usages
size: minimumBytes, // optional floor; the physical size is inferred
buffer: existingGPUBuffer, // adopt an external buffer
representation: "native", // or "packed"
});sizeandusageare inferred. Every buffer getsCOPY_DST;readback: trueaddsCOPY_SRC; each shader use addsSTORAGEorUNIFORM.representationselects the physical memory format:native(a real WGSL struct, the default) orpacked(schema-pop bitpacking, declared in WGSL asu32/array<u32, N>words with generated pack/unpack helpers). The legacyautoPack: trueengine option flips the default for every buffer.- Types may be named scope types or anonymous inline ones
(
engine.type("u32[] == 1024"),engine.type({ x: "f32" })). Anonymous types get an internal plan key and never need scope registration.
Runtime methods, all available after compile():
| Method | Notes |
|---|---|
write(value, { index }) |
Encodes and uploads one record. |
encodeInto(value, view, offset) |
Encodes into caller-owned memory without submitting. |
readback({ index, count, dropIfBusy }) |
Requires readback: true. Resolves to one record or an array. Returns null when a readback is already in flight, unless dropIfBusy: false. |
gpu |
The underlying GPUBuffer. |
compiledInfo |
Resolved TypePlan, stride, byte size and usage flags. |
Resource-use semantics live in the program descriptor, not on the type or the buffer. Shorthand means writable storage in group 0:
resources: { particles }The explicit form carries the native WebGPU fields:
resources: {
particles: {
resource: particles,
group: 2,
buffer: { type: "read-only-storage", hasDynamicOffset: false },
offset: 0,
size: 1024,
},
}There is no public numeric binding field. link() assigns bindings from
declaration order within each group and reports them in the program manifest.
buffer.type accepts "storage" (default), "read-only-storage" and
"uniform". Uniform bindings are generated as var<uniform>, but the schema is
still analyzed with a std430 layout — a struct that does not also satisfy std140
alignment is rejected by the driver at pipeline creation rather than being
blocked at link time.
const program = engine.compute({
label: "step",
resources: { input, output },
types: [SomeType], // extra schema types referenced by raw WGSL
codecs: [SomeType], // extra types needing pack/unpack helpers
includes: [sharedWgslText], // declarations placed before the entry point
compute: {
params: engine.type({ id: "global_invocation_id" }),
workgroupSize: [64, 1, 1],
code: `...`, // body only; the entry point is generated
},
});params fields must carry WGSL builtin metadata; the legal set per stage is
enforced during linking. parseComputeShader(source, entryPoint?) is a
transitional adapter that splits a conventional .wgsl file into
{ entryPoint, workgroupSize, include, code } for this API.
Handles expose ready, source (generated WGSL), manifest and pipeline.
engine.computePass((pass) => {
pass.run(program, { items: 100_000 }); // linear, split by workgroupSize
pass.run(program, { workgroups: [64, 4] }); // direct
pass.run(program, { indirectBuffer, indirectOffset: 0 });// indirect
});run() also accepts per-dispatch options:
pass.run(program, { items: n }, {
resources: { input: otherBuffer }, // GPUBuffer | BufferResource | GPUBufferBinding
dynamicOffsets: { input: 4096 }, // only for bindings with hasDynamicOffset
});Bind groups for overrides are cached by the concrete buffer identities and
binding ranges, so repeatedly cycling through a set of pages does not allocate a
new GPUBindGroup per dispatch.
engine.submit(callback) encodes one command buffer containing several passes:
engine.submit((encoder) => {
encoder.compute({ label: "physics" }, (pass) => pass.run(program, { items: n }));
encoder.render(renderPassDescriptor, (pass) => { ... });
encoder.gpu.copyBufferToBuffer(...); // the raw encoder stays available
});For mixed pipelines — Sandblaster programs sharing one command buffer with
hand-written or legacy WebGPU code — encodeComputePass() records a pass into an
encoder the caller owns. The engine never calls finish() or queue.submit() on
it, and creates no encoder of its own; recording order and submission stay with
the owner. Bind groups, overrides and dispatch math are the same code path as
computePass().
const encoder = device.createCommandEncoder(); // owned by the legacy side
legacy.recordPrepass(encoder);
engine.encodeComputePass(encoder, (pass) => {
pass.run(program, { items: n });
}, { label: "sandblaster-step" });
legacy.recordPostpass(encoder);
device.queue.submit([encoder.finish()]);An attached probe still decorates these passes, but its measurement window is not
managed for you — see beginFrame() / resolve() below.
Fullscreen rendering works today without textures, samplers or vertex buffers:
const fullscreen = engine.render({
label: "fullscreen",
vertex: {
params: engine.type({ vertexIndex: "vertex_index" }),
returns: engine.type({ position: "builtin_position", uv: "location0_vec2f" }),
code: `
let x = f32((vertexIndex << 1u) & 2u);
let y = f32(vertexIndex & 2u);
out.uv = vec2f(x, y);
out.position = vec4f(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);`,
},
fragment: {
params: engine.type({ uv: "location0_vec2f" }),
returns: engine.type({ color: "location0_vec4f" }),
code: `out.color = vec4f(uv, 0.25, 1.0);`,
targets: [{ format: canvasFormat }],
},
primitive: { topology: "triangle-list" },
});
await engine.compile({ device });
engine.submit((encoder) => {
encoder.render({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
}],
}, (pass) => {
pass.setPipeline(fullscreen);
pass.draw(3);
});
});When a stage declares returns, Sandblaster generates its output struct,
predeclares out and appends return out. The body only assigns fields. The
vertex/fragment interface is validated during linking: vertex output must contain
@builtin(position), and every fragment input location must match a vertex
output of the same WGSL type.
Render programs use the same buffer resource-use descriptors as compute, and the
same includes. Their generated bind-group visibility is currently
VERTEX | FRAGMENT.
Builtin keywords (global_invocation_id, vertex_index, builtin_position, …)
come from schema-pop's wgsl scope. A WGSL @location(n) input or output is not
a type but a decoration of one, so it cannot live in that scope — and
Sandblaster ships no scope of its own, because an engine binds exactly one
application scope.
locations() generates those aliases for spreading into your scope, where they
stay fully inferred:
import { location, locations } from "@sandblaster/core";
const $ = scope({
...wgsl.import(),
...locations(["vec2f", "vec4f"], [0, 1]), // location0_vec2f, location1_vec4f, ...
uv: location(0, "vec2f"), // or name one yourself
});
const params = engine.type({ uv: "location0_vec2f" });Linking never analyzes the whole application scope. For each program and buffer,
Sandblaster exports a minimal module containing only the transitive closure of
the types actually used, resolved by identity through the owning scope. Rich app
types (maps, dates, DTOs, anything the WGSL exporter cannot lower) can live in
the same scope without breaking plan generation. Linked programs expose the
module they were built from as program.module, and their pruned LayoutPlan as
program.plan.
compile() returns an aggregated report instead of stopping at the first broken
shader:
const result = await engine.compile({ device });
// { status: "ok" | "partial" | "failed", total, ok, failed, elapsedMs, programs }
for (const program of result.programs) {
if (program.status === "failed") {
console.error(program.phase, program.errors, program.source);
}
}Per-program failures are classified as wgsl, validate or pipeline and carry
the offending source plus every error diagnostic. Successful programs carry all
diagnostics reported by getCompilationInfo(), including warnings.
If any program fails, the whole compile rolls back: GPU objects are destroyed,
state returns to defining, and the engine can be fixed and compiled again. The
returned report is then purely informational. Fatal errors (device acquisition,
linking, buffer materialization) still throw.
Other options: compile({ device, codec, schema }), where codec is "jit"
(default, faster) or "interpreted" (works under a strict CSP).
link() can be moved entirely to build time. serialize() emits a versioned
JSON artifact containing generated WGSL, manifests, pipeline state and the
physical LayoutPlan for every buffer. deserialize() binds that artifact to an
otherwise identical runtime definition, after which compile() skips linking and
schema analysis.
// build
const linked = engine.link();
const json = engine.serialize(linked);
// runtime (string or imported JSON object)
engine.deserialize(json);
await engine.compile({ device });The artifact starts with { format: "sandblaster", version: 1 }. Buffer ids,
schema root names, representations and counts are checked against the runtime
definition before compilation, so stale artifacts fail explicitly. Runtime-only
fields such as external GPUBuffer objects and initial values stay on the
runtime resource descriptor and are not serialized.
deserialize() still requires the resources and programs to be declared again at
runtime, which means parsing the whole schema through ArkType at startup only to
overwrite the result with the artifact. fromArtifact() creates them from the
artifact instead, so no scope is constructed at all:
import artifact from "./pipeline.artifact.generated.json";
import { deserializeParticle, serializeParticle } from "./codec.generated";
import type { Particle } from "./schema.generated";
const engine = Sandblaster.fromArtifact(artifact, {
codecs: {
Particle: { serialize: serializeParticle, deserialize: deserializeParticle },
},
buffers: {
particles: { readback: true }, // keyed by artifact label or resource id
},
});
await engine.compile({ device });
const particles = engine.resource<Particle>("particles");
const step = engine.computeProgram("step");Everything physical — layout plans, binding manifests, generated WGSL, pipeline state — comes from the artifact. What an artifact cannot carry is supplied through options:
| Missing at runtime | Supplied by |
|---|---|
Labels, initial values, readback, external GPUBuffer, size floor, extra usage |
options.buffers, keyed by the buffer's artifact label or its resourceId. An unmatched key is an error, not a silent no-op. |
| Binary codecs | options.codecs, keyed by schema type name. Without them the engine derives a codec from the layout plan — the JIT one uses new Function. |
| TypeScript record types | engine.resource<T>(...), with T generated alongside the codec. |
Generate the two halves at build time from the same plan:
exportPlan(plan, "ts") // interfaces — the T in resource<T>()
exportPlan(plan, "ts:codec") // serializeX / deserializeX, exactly TypeCodec's shapeAn artifact-backed engine owns no scope, so type(), define(), buffer(),
compute(), render(), link() and deserialize() all refuse rather than
half-work. serialize() still returns the artifact it was built from.
Measured on this repo, bundling an entry that only calls fromArtifact():
| Entry | Bundle | Module init | new Function at init |
|---|---|---|---|
| Runtime modules only (resource, programs, probe, gpu) | 12 KB | 3 ms | 0 |
The package root, @sandblaster/core |
555 KB | ~450 ms | 321 |
fromArtifact() plus generated codecs removes every dynamic site from the
execution path. It does not remove them from the import path: engine.ts
statically imports SchemaAnalyzer and the codec factories from
@schema-pop/core, the linker (and through it @schema-pop/exporter), and the
wgsl scope for Sandblaster.blank(). Importing @schema-pop/schema alone
costs ~500 ms and 304 new Function calls, because its wgsl scope is built at
module load.
Closing the remaining gap needs a runtime-only entry point that never imports the
linker or the analyzer — the 12 KB row above is what that entry would cost. The
codec modules in @schema-pop/core already import @schema-pop/schema type-only,
so nothing in the runtime path depends on it at runtime today; only the module
graph does.
An artifact is the whole truth about a compiled engine, and also a wall of JSON.
artifactReport() renders it as a standalone HTML page — no external assets, no
dependencies:
bun run report pipeline.artifact.json # → pipeline.artifact.report.htmlimport { artifactReport } from "@sandblaster/core/report";
await Bun.write("report.html", artifactReport(engine.serialize(engine.link())));The page carries everything the artifact holds, arranged for debugging:
- A bindings matrix — every buffer against every program, showing
group.bindingand the access mode, so a missing or duplicated binding is visible at a glance. - Per program: entry points, workgroup size, bind groups, type roots, pipeline constants, render targets, and the generated WGSL with line numbers.
- Per buffer: the record stride and total allocation the engine will actually make, which programs bind it and how, and the full layout plan — every type, every field's offset and size, a byte map that shows padding holes, and bit offsets for bit-packed fields.
- Warnings for the mistakes that are otherwise found late: a buffer no program binds, non-contiguous bind groups, and a uniform whose record is not a multiple of 16 bytes (the std430/std140 hazard above).
It lives at the @sandblaster/core/report subpath rather than the package root,
so a runtime bundle never pulls it in — the entry is 25 KB and imports nothing.
An optional GPU pass-timing probe. With no probe attached the engine emits
exactly the command stream it emitted before; an attached probe only decorates
pass descriptors with timestampWrites and never reorders, splits or inserts
work.
const probe = engine.attachSamdoneter({ capacity: 64 });
if (probe) {
engine.submit((encoder) => { ... });
const report = await probe.read();
// { passes: [{ index, kind, label, durationNs }], gpuNs, wallMs, skipped, resolutionNs }
}
engine.detachSamdoneter();timestamp-queryis an optional WebGPU feature and must be requested at device creation. When it is missing,attachSamdoneter()returnsnullinstead of reporting zeros.- Compute and render passes are both instrumented and numbered in submission
order; each reported pass carries its
kind. - Granularity is one pass, because WebGPU writes timestamps only at pass boundaries. Per-dispatch and per-draw numbers would require splitting passes, which changes the schedule being measured.
- Browsers quantize timestamps (Chrome to ~100 µs).
resolutionNsreports the smallest non-zero duration observed so a quantization floor is distinguishable from a measurement. - Passes beyond
capacity, or recorded while a readback is still pending, run untouched and are counted inskipped.
submit() opens and closes the measurement window for you. On an encoder you own
— encodeComputePass(), or raw WebGPU work — drive it yourself:
probe.beginFrame(); // discard the previous window
engine.encodeComputePass(encoder, ...);
probe.resolve(encoder); // after the last pass, before finish()
device.queue.submit([encoder.finish()]);Skipping beginFrame() makes the probe accumulate passes across frames until it
runs out of capacity; resolving too early reads back timestamps the GPU has not
written yet.
engine.state is defining → compiling → ready → destroyed. Definitions
cannot be added after compilation; engine.device and every GPU-backed accessor
throw before compile(). engine.destroy() releases pipelines, bind groups,
owned buffers and any attached probe. External buffers passed in through
descriptor.buffer are never destroyed by the engine.
- Textures and samplers.
- Vertex and index buffer layouts driven by schema types (the raw
setVertexBuffer/setIndexBuffercalls exist, but attributes are not generated from types). encodeRenderPass()— only compute passes can be encoded into a foreign encoder so far.- A runtime-only entry point.
fromArtifact()needs no linker or analyzer, but the package root still imports both, so they stay in the bundle. See Static builds. Sandblaster.create(definitions)scope-inference shorthand — a scope must be constructed explicitly.- Multi-scope engines: one engine binds one
Scope<$>.
bun test
bun run typecheck| File | Covers |
|---|---|
test/link.test.ts |
Pure linking: scope resolution, binding assignment, includes, uniform uses, legacy shader adaptation. No GPU. |
test/surgical.test.ts |
Surgical scope isolation, packed vs native declarations, anonymous types, mixed engines. |
test/render.test.ts |
Vertex/fragment interface generation, render pipeline creation, bind visibility, render includes, location vocabulary. |
test/compile.test.ts |
Lifecycle, group gaps, dispatch math, dynamic offsets, aggregated compile failures and rollback, foreign-encoder encoding. |
test/artifact.test.ts |
AOT JSON roundtrip, stale-artifact rejection, and fromArtifact(): scope-free compile and dispatch, buffer option resolution, injected codecs. |
test/samdoneter.test.ts |
Probe instrumentation across compute and render passes, capacity, detach, timestamp math. |
test/report.test.ts |
HTML inspector: structure, cross-references, stride math, warnings, and escaping of WGSL and hostile labels. |
test/fakeDevice.ts provides a structural GPUDevice stand-in with programmable
shader/pipeline failures, optional device features and seeded timestamps, so
everything above runs without a GPU.
bun run example # builds the artifact reports, then starts vite
bun run report:examples # just the reports, into public/reports/- Gradient (
example/gradient.ts) — the smallest complete path: one scope, one render program, no buffers. Displays the WGSL the engine generated. - Particles (
example/particles.ts) — 20k particles in a storage buffer, integrated by a compute pass and drawn by a render pass in the same submission, with a uniform buffer written per frame,includesshared into the compute shader, and Samdoneter reporting both pass timings live.
Each example splits into <name>.engine.ts, holding only the scope, buffers and
programs, and <name>.ts, which owns the canvas, the device and the frame loop.
Because linking needs neither, example/reports.ts links the very same
definitions headlessly and writes an artifact report
per example, linked from the landing page.
- Include objects should carry their own schema-type and codec-helper
dependencies so the linker can collect them transitively. The explicit
typesandcodecsfields remain the escape hatch for raw WGSL and migration adapters. - Uniform buffers are analyzed with a std430 layout, so a std140 violation is only caught by the driver. The analyzer should be able to lay out a uniform binding as std140 directly.