---
title: "Compute shader (asset type)"
description: "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…"
section: "Types"
slug: "types-computeshader"
canonical: "https://origozero.ai/docs/types-computeshader"
updated: "2026-09-04T19:42:52.390443503+00:00"
tags: ["asset-type", "reference"]
---

# Compute shader (asset type)

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

```lua
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.

```yaml
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. `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`.
- Storage-texture access is fixed by kind (`storage3d` → `read_write`,
  `storage2d` → `write`) 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:

```yaml
bindings:
  - { name: verts,   kind: vertices }                              # the mesh, writable
  - { name: heights, kind: buffer, access: read, element: f32 }
params:
  - { name: lift, type: float, default: 1.0 }
```

```wgsl
@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));
}
```

```lua
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 / `#include`s). The names declared
in `bindings.yaml` are in scope — `data`, `out_tex`, `params.scale`, … — with no
`@group`/`@binding` lines of your own.

```wgsl
// @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:

```lua
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:

```lua
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:

```lua
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:

```lua
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:

```lua
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 `#include`s, 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:

```lua
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 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.

## Related types

- `.shader` — render-domain shaders (surface, sky, post-process, screen) and the
  legacy `@domain: compute` fallback.
