Log inGet started

Compute shader (asset type)

Updated 4 September 2026

New to how content lives across the CPU and GPU? Read core/resource-model first: 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 }
kindextra keysgenerated 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)
texture3dvar NAME: texture_3d<f32>;
texture2dvar NAME: texture_2d<f32>;
storage3dformatvar NAME: texture_storage_3d<FMT, read_write>;
storage2dformatvar NAME: texture_storage_2d<FMT, write>;
samplervar NAME: sampler;
verticesaccess: read | read_write (default read_write)var<storage, ACCESS> NAME: array<u32>; plus the mesh's layout uniform and the zeroVertex* accessors
acceleration_structurevar NAME: acceleration_structure; (hardware ray tracing)
  • Eight storage buffers per shader — the WebGPU guarantee, and what the engine asks every device for. buffer and vertices bindings spend that budget; textures, samplers, storage textures and params: do not. An acceleration_structure brings engine-owned buffers with it — three where the device has hardware ray query (the tables zeroHitSurface / zeroMaterial read) 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 buffer defaults to array<ELEMENT>. Set array: false for a single NON-array storage buffer — a struct binding (e.g. element: VolumeUniforms, array: falsevar<storage, read> NAME: VolumeUniforms;). The struct definition itself lives in shader.wgsl, not bindings.yaml.
  • Storage-texture access is fixed by kind (storage3dread_write, storage2dwrite) to match the layout the engine builds — it is not configurable.
  • format tags (storage textures): r8, r16f, r32f, rgba8, rgba16f, rgba32f. Storage textures only allow storage-compatible formats (r32f / rgba8 / rgba16f / rgba32f).
  • An acceleration_structure binding 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, where instance is the hit object's render slot), zeroTraceAny(origin, dir, t_min, t_max) -> bool (terminate-on-first-hit, for shadow/occlusion rays), and zeroHitSurface(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 on renderer.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:

readwrite
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:

methodwhat 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:

reasonwhat the inventory read
residenta resource is filed under exactly this key; the row is on resource
replacedthe owner holds the same name at a different serial — current names the live key
nameUnusedthe owner holds resources, none under this name
ownerAbsentno live resource carries this key's owner
unownedthe key carries no owner segment, and nothing is filed under it
notPublishedno 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 parsed bindings.yaml schema ({ 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 under compute.<identity> — the same identity asset.resolve takes, 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 contractbuffers and resources bind in bindings: list order; reordering the list reorders the bindings.
  • Don't write @group/@binding in shader.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: vertices and 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-compatibler32f / rgba8 / rgba16f / rgba32f; others are rejected at compile.
  • A key that comes back empty is not the same as one that failedcompute.diagnose(key) separates a name never used from a generation that was replaced, and names the live key in the second case.
  • .shader — render-domain shaders (surface, sky, post-process, screen) and the legacy @domain: compute fallback.
  • asset-type
  • reference