Compute shader (asset type)
New to how content lives across the CPU and GPU? Read
core/resource-modelfirst: it covers the asset / CPU / GPU forms every resource takes, the upload path, and how compute fits the performance ladder (core/performance).
Folder shape
A .computeShader is a folder asset:
<name>.computeShader/
shader.wgsl # the WGSL body — only `@compute fn main` + helpers (required)
bindings.yaml # the binding + params schema (required)
README.md # a one-paragraph description of THIS shader (required)
.metadata # asset metadata
Each file carries a committed .meta sidecar pinning its stable guid. The
identity is <name> (the .computeShader suffix strips).
Resolve the shader once, then dispatch through the reference:
local carve = asset.resolve("carve", "computeShader")
carve:dispatch({ buffers = { heights }, workgroups = { 64 } })
asset.resolve is what records your script's dependency on the shader, so the
shader travels with the world your script lives in — packed, pulled, or
published. It compiles on first dispatch; there is no setup step.
bindings.yaml
One ordered bindings: list — the binding index is the list position
(0, 1, 2, …) — plus an optional params: scalar-uniform block. kind defaults
to buffer when omitted.
bindings:
- { name: clouds, kind: storage3d, format: rgba16f } # texture_storage_3d<rgba16float, read_write>
- { name: occupancy, kind: texture3d } # texture_3d<f32>
- { name: smp, kind: sampler } # sampler
- { name: out_tex, kind: storage2d, format: rgba16f } # texture_storage_2d<rgba16float, write>
- { name: U, kind: buffer, access: read, element: f32 } # var<storage, read> U: array<f32>
- { name: Uniforms, kind: buffer, access: read, element: VolumeUniforms, array: false } # var<storage, read> Uniforms: VolumeUniforms
params:
- { name: scale, type: float, default: 2.0 }
- { name: count, type: int, default: 0 }
kind | extra keys | generated WGSL declaration |
|---|---|---|
buffer (default) | access: read | read_write (default read_write), element: <wgsl type> (default f32), array: true | false (default true) | var<storage, ACCESS> NAME: array<ELEMENT>; (or … NAME: ELEMENT; when array: false) |
texture3d | — | var NAME: texture_3d<f32>; |
texture2d | — | var NAME: texture_2d<f32>; |
storage3d | format | var NAME: texture_storage_3d<FMT, read_write>; |
storage2d | format | var NAME: texture_storage_2d<FMT, write>; |
sampler | — | var NAME: sampler; |
vertices | access: read | read_write (default read_write) | var<storage, ACCESS> NAME: array<u32>; plus the mesh's layout uniform and the zeroVertex* accessors |
acceleration_structure | — | var NAME: acceleration_structure; (hardware ray tracing) |
- Eight storage buffers per shader — the WebGPU guarantee, and what the
engine asks every device for.
bufferandverticesbindings spend that budget; textures, samplers, storage textures andparams:do not. Anacceleration_structurebrings engine-owned buffers with it — three where the device has hardware ray query (the tableszeroHitSurface/zeroMaterialread) and five on the software backend (those, plus the scene triangles and the hierarchy over them) — so a traced pass has three slots of its own on the backend web users get. Over budget, the engine refuses the shader and logs which buffers it declared. - A
bufferdefaults toarray<ELEMENT>. Setarray: falsefor a single NON-array storage buffer — a struct binding (e.g.element: VolumeUniforms,array: false→var<storage, read> NAME: VolumeUniforms;). The struct definition itself lives inshader.wgsl, notbindings.yaml. - Storage-texture access is fixed by kind (
storage3d→read_write,storage2d→write) to match the layout the engine builds — it is not configurable. formattags (storage textures):r8,r16f,r32f,rgba8,rgba16f,rgba32f. Storage textures only allow storage-compatible formats (r32f/rgba8/rgba16f/rgba32f).- An
acceleration_structurebinding resolves to the scene acceleration structure when the shader runs as a render-pass with ray tracing enabled (renderer.setRaytrace(true)). Declaring one makes the engine emit the helper functions the body can call:zeroTraceClosest(origin, dir, t_min, t_max) -> ZeroHit(struct:hit, t, instance, primitive, bary, attr, whereinstanceis the hit object's render slot),zeroTraceAny(origin, dir, t_min, t_max) -> bool(terminate-on-first-hit, for shadow/occlusion rays), andzeroHitSurface(hit) -> ZeroSurface(normal, uv, material) +zeroMaterial(index) -> ZeroMaterial(base_color, emissive, metallic, roughness) to shade what a hit landed on. The same surface works on both backends — hardware ray query and the software/compute path — so the shader doesn't branch onrenderer.raytraceCapability(). Bind it from a render-pass, which is where the scene acceleration structure exists.
params: declares scalar uniforms (type: float | int). When non-empty the
engine appends a ZeroComputeParams uniform as the trailing binding (read it in
WGSL as params.<name>) and tunes its values at runtime with
shaderRef:setParam(prop, value).
Writing a mesh's vertices — kind: vertices
A mesh's vertices are raw bytes on the GPU, and the engine stores a mesh in one
of two layouts: 92 bytes a vertex with every attribute a f32, or 32 bytes a
vertex with normals and tangents quantized to bytes and UVs to shorts. Which one
a mesh got depends on the mesh.
Declare kind: vertices and the shader is handed both the buffer and the layout
that mesh is in, plus accessors that address it:
bindings:
- { name: verts, kind: vertices } # the mesh, writable
- { name: heights, kind: buffer, access: read, element: f32 }
params:
- { name: lift, type: float, default: 1.0 }
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x >= zeroVertexCount()) { return; }
var p = zeroVertexPosition(id.x);
p.y = p.y + heights[id.x] * params.lift;
zeroSetVertexPosition(id.x, p);
zeroSetVertexNormal(id.x, vec3<f32>(0.0, 1.0, 0.0));
}
carve:dispatchOnVertices({ model = meshRef:handle().guid, buffers = { heights }, workgroups = { 64 } })
The accessors, over a binding declared read_write:
| read | write |
|---|---|
zeroVertexCount() -> u32 | — |
zeroVertexPosition(i) -> vec3<f32> | zeroSetVertexPosition(i, vec3<f32>) |
zeroVertexNormal(i) -> vec3<f32> | zeroSetVertexNormal(i, vec3<f32>) |
zeroVertexTangent(i) -> vec4<f32> | zeroSetVertexTangent(i, vec4<f32>) |
zeroVertexUv(i) -> vec2<f32> | zeroSetVertexUv(i, vec2<f32>) |
zeroVertexColor(i) -> vec4<f32> | zeroSetVertexColor(i, vec4<f32>) |
zeroVertexNodeIndex(i) -> u32 | — |
zeroVertexWord(i, offset) -> u32 | — |
access: read gives you the readers only — a var<storage, read> binding
cannot be assigned to, so the writers are left out rather than handed to you
broken.
Once a pass has written a mesh's vertices, the engine bounds that mesh by what
the pass wrote: it reduces the vertices to an AABB every frame and culls against
that, so geometry a pass moves across the scene needs no oversized placeholder
to survive culling. renderer.mesh.boundsSource(guid) reports "compute" for
such a mesh and "geometry" for one still bounded by what it was created with.
A shader declares at most one vertices binding; the accessors are generated
for that mesh. It can be declared anywhere in the list — the mesh is bound by
the dispatch, and buffers fills the other storage slots in declaration order.
The tangent's w carries handedness, and quantized normals/tangents come back
in -1..1 while quantized UVs and colours come back in 0..1, so the values are
the same ones a surface shader sees whichever layout the mesh is in.
shader.wgsl
Write only @compute fn main (and any helpers / #includes). The names declared
in bindings.yaml are in scope — data, out_tex, params.scale, … — with no
@group/@binding lines of your own.
// @domain: compute
// Bindings are declared in bindings.yaml — do NOT write @group/@binding here.
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x < arrayLength(&data)) {
data[id.x] = data[id.x] * 2.0;
}
}
Dispatch & runtime
Everything a compute shader does, it does through its reference:
| method | what it does |
|---|---|
ref:dispatch(opts) | { buffers, workgroups } — bind one buffer per declared storage binding, in order |
ref:dispatchOnVertices(opts) | { model, buffers?, workgroups } — bind the mesh model names to the vertices binding, buffers filling the rest |
ref:dispatchEx(opts) | { resources, workgroups } — for shaders mixing textures, samplers and storage textures: one { kind, name } per declared binding, in order |
ref:setParam(prop, value) | write one params: scalar uniform |
ref:createBuffer(name, opts) | { type, len, usage? } — a GPU buffer owned by this shader |
ref:buffer(name) | the buffer this shader last made under name |
ref:getBindings() | the parsed bindings.yaml schema |
ref:getSource() / ref:setSource(wgsl) | read / overwrite shader.wgsl |
ref:compile() | compile now instead of on the first dispatch |
ref:status() | what the engine did with this shader's dispatches, one record per target |
The params: uniform is engine-owned and takes no entry in buffers or
resources, and neither does a vertices binding's layout.
workgroups is { x, y, z }, { x }, or a bare x, and every dimension it
leaves out is 1. A zero in any dimension is refused: the dispatch is dropped and
recorded as a failure, so it surfaces in ref:status() and compute.failing()
with the reason. A count derived from how much data there is passes through
math.max first, so an empty set dispatches one workgroup rather than none:
carve:dispatch({ buffers = { heights }, workgroups = { math.max(1, math.ceil(#points / 64)) } })
Buffers and readback (the CPU side)
A buffer is created through the shader that owns it and comes back as a handle. Pass the handle to a dispatch — including another shader's — to bind it there:
local double = asset.resolve("@builtin::shaders.compute_double", "computeShader")
-- `type` is the element the shader declares, `len` how many of them.
local data = double:createBuffer("data", { type = "f32", len = 4, usage = { "readback" } })
data:write({ 1.0, 2.0, 3.0, 4.0 }) -- :writeU32 for integer words, :writeBytes for packed bytes
double:dispatch({ buffers = { data }, workgroups = { 1, 1, 1 } })
local pending = data:read() -- starts an async GPU->CPU readback
-- a frame or two later, drain it (nil until it has arrived):
local out = pending:result() -- :resultU32 / :resultBytes read the same bytes another way
data:destroy() -- free it when done
type is one of "f32", "vec3", "vec4", "quat", "mat4", and usage
adds "readback", "vertex", "index" or "indirect" on top of the storage a
buffer always has. An "indirect" buffer holds a draw's arguments where the GPU
reads them, so a shader that compacts geometry writes the drawn count into the
same dispatch that produced it and a render feature's kind = "draw" pass
submits exactly that — the renderFeature asset type's README documents that
pass and the storage.indirect name it reads.
The handle's key is the shader's guid plus the name you gave it and
a serial, so two shaders that both want a buffer called data get one each —
and so do two callers asking this same shader for one. ref:buffer(name)
returns the last one made under that name, which is how two callers share one
on purpose.
A buffer that no shader computes — a vertex or index run that is only ever
drawn — is allocated straight from the primitive instead:
substrate.createBuffer({ name = ..., type = ..., len = ..., kind = "gpu", usage = { "vertex" } }).
The first dispatch of a shader compiles it, so the very first readback can still
reflect the un-run shader; dispatch again once it is compiled, or ref:compile()
to pre-warm. This create/write/dispatch/readback loop is the CPU side of the
resource model in core/resource-model.
Is it compiled?
ref:compile() queues the compile; the pipeline is built on the render side
frames later, so that call cannot say whether it produced a program.
compute.programState(ref) is where that lands:
if compute.programState(carve) == "ready" then carve:dispatch(opts) end
local state, reason = compute.programState(carve)
if state == "failed" then print(reason) end
"absent"— the engine holds nothing under this key and nothing is in flight: never asked for, or released."pending"— a compile was asked for and has not reached the device. A recompile of a resident program reads pending too, because what it produces is a different program from the one bound now — which is what makes this the precondition to wait on after an edit."ready"— compiled and resident, so a dispatch naming it binds it."failed"— the most recent compile produced no program, and the second return says why.
A dispatch issued while the program is "pending" is held behind the compile
and dropped if it never lands, so a readback taken before "ready" reports the
buffer as it was. Wait for "ready", not for a count of frames.
Did it run?
A dispatch is recorded into a command encoder frames after the call that asked
for it returned, so that call's true means "queued", not "ran". ref:status()
is where the outcome lands — one record per target, cumulative:
for _, d in ipairs(carve:status()) do
print(d.target, d.ok, d.dispatches, d.failures, d.lastError)
end
target is the mesh guid for a dispatchOnVertices and the buffers it bound
for a dispatch that writes only those, so two systems driving one shader over
their own buffers read as the two passes they are. ok is the most recent outcome; lastError says
why the last failure failed and is kept after a recovery. An empty result means
nothing has dispatched this shader at all. compute.failing() answers the same
question across every shader at once.
A failing dispatch also posts a notice, so a pass that stops running reports itself rather than waiting to be found.
Running one every frame
A kind = "compute" substrate job queues one dispatch per frame:
jobs.register({
phase = "main",
executor = {
kind = "compute",
shader = asset.resolve("carve", "computeShader"),
buffers = { "heights" },
workgroups = { 64 },
},
})
Registration & hot reload
Writing shader.wgsl or bindings.yaml fires the .computeShader assetType's
onChange, which calls __compute.compile — that generates the binding
declarations, expands #includes, naga-validates, and registers. This happens on
create and on every edit, so saving recompiles with no world reload. A shader
with invalid WGSL (a type mismatch, an undefined name) fails to compile with
the reason logged ([compute] Compile of '<name>' failed: …) and is not
registered. You don't need to register a shader before dispatching it — the first
dispatch auto-compiles it on use. ref:compile() remains as an optional explicit
pre-warm, only to avoid the one-frame first-dispatch warm-up in a
latency-critical spot.
What compute is holding, and why a key names nothing
Every buffer, volume, storage target, history pair and sampler a dispatch can
bind is filed under a key, and compute.observe() lists all of them with what
each costs:
local r = compute.observe()
print(r.totals.count, r.totals.bytes)
for _, res in ipairs(r.resources) do
print(res.key, res.kind, res.bytes, res.owner.name)
end
A row carries the key verbatim — the same string a dispatch names it by and a
dispatch-failure message prints — plus its kind, its bytes, its extent and
format where it has them, the usage bits it was created with, and the frame
it was created on. owner is read back out of the key
(<shader-guid>::<name>#<serial>), so a resource nothing has ever dispatched
over still names the shader that made it. compute.resources(ref) narrows to
one shader's — including the <shader-guid>::__params uniform the compile
created for a params: block, which the shader owns like any other resource.
r.totals.bytes is the compute figure of renderer.gpuMemory() — the two are
read off the same registries, so a listing accounts for the category exactly.
The same reading is served at /zero/runtime/compute.
When a key names nothing, compute.diagnose(key) says which of the states the
inventory distinguishes it is in, from the closed set compute.absentReasons()
enumerates:
| reason | what the inventory read |
|---|---|
resident | a resource is filed under exactly this key; the row is on resource |
replaced | the owner holds the same name at a different serial — current names the live key |
nameUnused | the owner holds resources, none under this name |
ownerAbsent | no live resource carries this key's owner |
unowned | the key carries no owner segment, and nothing is filed under it |
notPublished | no renderer has published an inventory, so no key can be answered for |
So a key out of [compute] dispatch '…' failed resolves against what exists,
and a typo reports which part of it the engine could not match rather than
coming back empty.
A reason is the inventory at the moment the call read it. The reading is republished on a frame where a registry gained or lost an entry, so a resource created and diagnosed inside one script sees the reading from before it was made; wait a frame and ask again.
Discovery
asset.list("computeShader")— every registered compute shader.asset.inspect("<name>")— status, source, parse errors, this README.computeRef:getBindings()— the parsedbindings.yamlschema ({ bindings, params }).compute.programState(ref)— whether the program is compiled, still compiling, or failed, and why.compute.observe()— every GPU resource compute holds, and what it costs.compute.diagnose(key)— whether a key names a live resource, and why not.profiler.gpuFrame()— what each dispatch of this shader spends on the GPU. A dispatch is timed undercompute.<identity>— the same identityasset.resolvetakes, so a timing row names the shader that produced it.
Common pitfalls
- Compute shaders don't render — verify output via a buffer readback or a debug pass, not a screenshot.
ref:compile()returns before the program exists — the pipeline is built on the render side;compute.programState(ref)says when it is"ready", and a dispatch issued before that is dropped.- A dispatch call returning true means queued — ask
ref:status()whether it ran. - A destroyed buffer is refused where it is bound — a handle outlives its
storage, and
buf:alive()reports which. - Binding order is the contract —
buffersandresourcesbind inbindings:list order; reordering the list reorders the bindings. - Don't write
@group/@bindinginshader.wgsl— the engine owns them; re-declaring is the fastest way to a layout mismatch. - Don't infer a mesh's vertex stride — declare
kind: verticesand use the accessors. A wrong stride writes positions correctly and normals into the wrong word, which renders as flat lighting rather than as broken geometry. - Storage-texture format must be storage-compatible —
r32f/rgba8/rgba16f/rgba32f; others are rejected at compile. - A key that comes back empty is not the same as one that failed —
compute.diagnose(key)separates a name never used from a generation that was replaced, and names the live key in the second case.
Related types
.shader— render-domain shaders (surface, sky, post-process, screen) and the legacy@domain: computefallback.