Log inGet started

Compute shader (asset type)

Updated October 9, 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, 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)
texture3d—var NAME: texture_3d<f32>;
texture2dsample: float | uint | sint (default float)var NAME: texture_2d<f32>;, or <u32> / <i32> for the other classes
texture_depth—var NAME: texture_depth_2d; — the one sampled shape with no sample: of its own; a depth-aspect channel (@scene.depth) binds here, not through texture2d
storage3dformat, access: write | read | read_write (default write)var NAME: texture_storage_3d<FMT, ACCESS>;
storage2dformat, access: write | read | read_write (default write)var NAME: texture_storage_2d<FMT, ACCESS>;
sampler—var NAME: sampler;
textureCubeArray—var NAME: texture_cube_array<f32>; — the environment cube array, bound to @scene.environment by a render feature's pass
verticesaccess: 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. 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: false → var<storage, read> NAME: VolumeUniforms;). The struct definition itself lives in shader.wgsl, not bindings.yaml.
  • A sampled texture binds under the class its FORMAT puts it in, so sample states that class and the generated texture_2d<..> matches it. An integer channel — @scene.material, which is Rgba8Uint — is uint; everything else is the float default. Bind an integer channel to a float slot and the engine refuses the pass and names both classes, because the device would otherwise reject the bind group and invalidate the pipeline.
  • A storage texture's access decides both the generated declaration and the layout the engine registers, so the two always agree. It defaults to write, the access every target grants every storage-compatible format. read_write is granted on r32f alone; declaring it on any other format is refused at the compile, with the reason on compute.programState. A pass that needs to read a wider target reads it through a separate texture3d / texture2d binding, or through a second storage binding declared read.
  • 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).

The kernel ABI: columns, counted and appended outputs, specialize, determinism

One shader asset can serve many shapes of data. bindings.yaml states the shape with these extensions, and the engine's compile path lowers them to the plain bindings and WGSL above (modules/compute_abi, whose README has the whole surface):

workgroup: 64                       # the size the body declares (default 64)
bindings:
  - { name: cols, kind: columns, access: read }                       # a table of columns in one binding
  - { name: src,  kind: buffer, access: read, element: T }
  - { name: dst,  kind: buffer, access: read_write, element: T, count: "len(src)" }
  - { name: hits, kind: buffer, access: read_write, element: vec4<f32>, capacity: "n * 4", append: true }
specialize:
  - { name: T,    options: [f32, i32, vec2<f32>, vec3<f32>, vec4<f32>] }
  - { name: FOLD, options: [add, min, max] }
  - { name: BODY, kind: source, default: "0.0" }                      # text that replaces the identifier
determinism: exact                  # or float_order
  • A buffer of a scalar or vector element gets NAME_get(i), NAME_len() and (unless access: read) NAME_set(i, v). A vec3 element is stored as a flat scalar array, so a column of vec3 keeps its 12-byte stride. zero_n() is the dispatch size.
  • columns binds a table of columns (compute_abi.table) as one storage buffer; cols_f32(c, i, lane), cols_vec3(c, i) and the rest read it.
  • count / capacity give the size, in elements, of an output the run allocates. append: true adds a live counter binding, NAME_count, with NAME_store(slot, v), NAME_set_count(n) and, under determinism: float_order, NAME_append(v).
  • specialize parameters are compile-time: type options become alias NAME, named options become const NAME and NAME_<option>, kind: source text replaces the identifier. Each specialization compiles once.
  • determinism: exact refuses NAME_append: an exact output is written at ranks the kernel computed.

Dispatch such a shader through its program:

local variant = shaderRef:program():variant({ specialize = { T = "vec3<f32>" } })
local run = variant:run({ n = 64, inputs = { src = srcBuffer } })
local values = require("@builtin::modules.compute_abi").read(run.outputs.dst.buffer)

shaderRef:program() answers the same program, with the variants it has lowered and compiled, for as long as shader.wgsl and bindings.yaml hold the bytes they held when it was made. A write to either file, from any origin (vfs.contentVersion moves), makes the next program() a new one, and a dispatch through it runs the new text. The schema getBindings() answers is the one the program was made over.

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, params? } — bind one buffer per declared storage binding, in order
ref:dispatchOnVertices(opts){ model, buffers?, workgroups, params? } — 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, read and parsed again only after the file is written
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.

setParam writes the uniform every later dispatch reads. A dispatch that passes params = { name = value } reads those values instead, in a copy of the uniform only it binds, with every name it leaves out at the value the shader holds — so one shader dispatched twice in a frame over different data reads different values each time:

lift:dispatch({ buffers = { a }, workgroups = { 4 }, params = { height = 1.0 } })
lift:dispatch({ buffers = { b }, workgroups = { 4 }, params = { height = 3.0 } })

A name the shader does not declare fails that dispatch, and the failure names what it does declare.

dispatchEx resources

Each resources entry fills the binding at its position. Its kind says what the resource is, in the words bindings.yaml uses, and the binding decides how it is read. A slot takes the kind it is declared with; a texture2d slot also reads a storage texture, a side of a history pair or the scene depth, and a storage2d slot also writes this frame's side of a history pair:

binding's bindings.yaml kindresources entryname
buffer{ kind = "buffer", name = buf }a buffer handle from ref:createBuffer
texture3d{ kind = "texture3d", name = vol }a 3D texture handle from ref:createTexture3D
storage3d{ kind = "storage3d", name = vol }a 3D texture handle from ref:createTexture3D made with storage = true
storage2d{ kind = "storage2d", name = target }a storage texture handle from ref:createStorageTexture2D
storage2d{ kind = "history_curr", name = history }a history pair from ref:createTextureHistory: this frame's side
texture2d{ kind = "texture2d", name = tex }a handle ref:copyBufferToTexture returns, or a texture asset's guid once texRef:handle() has made it GPU-resident
texture2d{ kind = "storage2d", name = target }a storage texture handle, read as a texture
texture2d{ kind = "history_prev", name = history } / { kind = "history_curr", name = history }a history pair: the previous frame's side or this frame's
texture2d{ kind = "scene_depth" }none: the frame's scene depth
sampler{ kind = "sampler", name = smp }a sampler handle from ref:createSampler

texture3d and storage3d name the same resource, a 3D texture, so either one fills either slot. The engine's own spellings name the same resources and keep working: texture_3d for texture3d and storage3d, texture_2d for texture2d, and storage_2d for storage2d. Any other kind is refused at the call, naming every kind it accepts. A vertices binding is filled by ref:dispatchOnVertices, and texture_depth, textureCubeArray and acceleration_structure bindings by a render feature's pass.

local march = asset.resolve("volume_march", "computeShader")
-- bindings.yaml: density (storage3d), out_tex (storage2d), U (buffer)
march:dispatchEx({
    resources = {
        { kind = "storage3d", name = density },
        { kind = "storage2d", name = target },
        { kind = "buffer", name = uniforms },
    },
    workgroups = { 8, 8, 8 },
})

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 contract — buffers 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-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.
  • .shader — render-domain shaders (surface, sky, post-process, screen) and the legacy @domain: compute fallback.

In the Inspector

Selecting a compute shader opens its Compute section: whether its program is ready (and why not), how many dispatches it has run and failed in this session (and the last error), tables of the bindings and params its bindings.yaml declares, and openers for shader.wgsl and bindings.yaml.

  • asset-type
  • reference