Log inGet started

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-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). 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 }
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;
acceleration_structurevar NAME: acceleration_structure; (hardware ray tracing)
  • 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 two helper functions the body can call: zeroTraceClosest(origin, dir, t_min, t_max) -> ZeroHit (struct: hit, t, instance, primitive, bary, where instance is the hit object's render slot) and zeroTraceAny(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 on renderer.raytraceCapability(). Bind it from a render-pass — the legacy compute.dispatch path 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 in bindings: 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 one params: 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 parsed bindings.yaml schema ({ bindings, params }).

Common pitfalls

  • Compute shaders don't render — verify output via compute.readBuffer or a debug pass, not a screenshot.
  • Binding order is the contractcompute.dispatch / dispatchEx bind resources 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.
  • Storage-texture format must be storage-compatibler32f / rgba8 / rgba16f / rgba32f; others are rejected at compile.
  • .shader — render-domain shaders (surface, sky, post-process, screen) and the legacy @domain: compute fallback.
  • asset-type
  • reference