Compute shader (asset type)
A .computeShader is a GPU compute (GPGPU) program with a zero-scaffolding authoring contract: you write only @compute fn main(...) plus a declarative bindings.yaml, and the engine GENERATES every…
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). Dispatch by that
identity (or a resolved handle) — it resolves to the stable guid and
auto-compiles on first use (collision-safe, no setup).
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; |
acceleration_structure | — | var NAME: acceleration_structure; (hardware ray tracing) |
- 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 two helper functions the body can call:zeroTraceClosest(origin, dir, t_min, t_max) -> ZeroHit(struct:hit, t, instance, primitive, bary, whereinstanceis the hit object's render slot) andzeroTraceAny(origin, dir, t_min, t_max) -> bool(terminate-on-first-hit, for shadow/occlusion rays). 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 — the legacycompute.dispatchpath does not provide it.
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
compute.setParam(name, prop, value).
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
compute.dispatch(name, { buffers = { "bufA", "bufB" }, workgroups = { x, y, z } })— run the shader binding the named storage buffers inbindings:order.compute.dispatchEx(name, { resources = { … } })— for shaders that mix textures, samplers, and storage textures: bind each declared binding by the resource of the matching kind, in order.compute.setParam(name, prop, value)— write oneparams:scalar uniform.
Buffers and readback (the CPU side)
For a buffer-only shader, create and fill the named storage buffers on the CPU, dispatch, then read results back:
local N = 4
compute.createBuffer("data", { size = N * 4, readback = true }) -- size in BYTES; readback to read it back
compute.writeBuffer("data", { 1.0, 2.0, 3.0, 4.0 }) -- also writeBufferU32 / writeBufferBytes
compute.dispatch("@builtin::shaders.compute_double", { buffers = { "data" }, workgroups = { 1, 1, 1 } })
local key = compute.readBuffer("data") -- starts an async GPU->CPU readback, returns a poll key
-- a frame or two later, poll for the result (nil until ready, then the f32 array):
local out = compute.getReadbackResult(key) -- getReadbackResultU32 for a u32 buffer
compute.destroyBuffer("data") -- free it when done
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
compute.compileByName(ref) to pre-warm. This create/write/dispatch/readback
loop is the CPU side of the resource model in core/resource-model.
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
compute.dispatch/dispatchEx auto-compiles it on use. compute.compileByName(ref)
remains as an optional explicit pre-warm, only to avoid the one-frame first-dispatch
warm-up in a latency-critical spot.
Discovery
asset.list("computeShader")— every registered compute shader.asset.inspect("<name>")— status, source, parse errors, this README.computeRef:getBindings()— the parsedbindings.yamlschema ({ bindings, params }).
Common pitfalls
- Compute shaders don't render — verify output via
compute.readBufferor a debug pass, not a screenshot. - Binding order is the contract —
compute.dispatch/dispatchExbind resources 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. - Storage-texture format must be storage-compatible —
r32f/rgba8/rgba16f/rgba32f; others are rejected at compile.
Related types
.shader— render-domain shaders (surface, sky, post-process, screen) and the legacy@domain: computefallback.