---
title: "Ray tracing"
description: "Cast rays against the live scene from a compute shader and shade with the hits — shadows, ambient occlusion, reflections, GI. The engine builds the scene acceleration structure and exposes a small…"
section: "Topics"
slug: "topics-raytracing"
canonical: "https://origozero.ai/docs/topics-raytracing"
updated: "2026-08-21T16:51:54.162598843+00:00"
tags: ["documentation", "guide"]
---

# Ray tracing

## Two backends, one surface

Ray tracing always works — only the backend differs:

- **`hardware`** — the device granted hardware ray query (native Vulkan/DX12/Metal).
  Rays traverse a GPU BLAS/TLAS.
- **`compute`** — no hardware ray query (e.g. the web/WebGPU path). The engine
  gathers the scene into a triangle buffer, builds a bounding-volume hierarchy
  over it, and your `zeroTrace*` calls walk that hierarchy in software.

You write the SAME shader either way — the `zeroTraceAny` / `zeroTraceClosest`
WGSL surface is identical, so a ray-tracing pass is backend-agnostic. Check the
backend if you want to scale ray budgets:

```lua
local backend = renderer.raytraceCapability()   -- "hardware" | "compute"
renderer.setRaytrace(true)                       -- build the scene structure each frame
```

`setRaytrace(true)` is required — it gates the per-frame acceleration-structure
build, so it costs nothing until a ray-tracing effect is active.

## The trace surface: an `acceleration_structure` binding

Ray tracing rides the `.computeShader` abstraction (`guides { path: "types/computeShader" }`). Declare
one `acceleration_structure` binding in `bindings.yaml` and the engine wires it to
the scene structure and generates the trace helpers — you never write
`@group`/`@binding`, `enable wgpu_ray_query`, or any backend-specific code:

```yaml
bindings:
  - { name: out_tex,    kind: storage2d, format: rgba8 }
  - { name: scene_tlas, kind: acceleration_structure }
```

That makes the trace surface available in `shader.wgsl`, identical on both
backends:

```wgsl
// Closest hit. `instance` is the hit object's render slot, `bary` the
// barycentrics, `primitive` the triangle's index within the structure that was
// traversed, and `attr` its row in the shared attribute table.
struct ZeroHit { hit: bool, t: f32, instance: u32, primitive: u32, bary: vec2<f32>, attr: u32 };
fn zeroTraceClosest(origin: vec3<f32>, dir: vec3<f32>, t_min: f32, t_max: f32) -> ZeroHit;

// Terminate-on-first-hit — for shadow / occlusion rays.
fn zeroTraceAny(origin: vec3<f32>, dir: vec3<f32>, t_min: f32, t_max: f32) -> bool;
```

`primitive` numbers the triangle within the backend's own structure, and each
backend numbers its own way — the software backend orders the scene triangles by
the hierarchy it built over them, which it rebuilds whenever the scene changes.
To name a surface across frames or across backends, shade from `zeroHitSurface(hit)`
/ `zeroMaterial(...)` or from `instance`, both of which are stable.

A ray-tracing pass is a `kind = "compute"` render-feature pass; the engine binds
the scene structure to your declared slot:

```lua
ctx.enqueue {
    kind = "compute", program = "my_rt_shader",
    storage = { out_tex = rt.guid },
    dispatch = { x = gx, y = gy, z = 1 },
    phase = "afterLighting",
}
```

## The screen-space recipe (shadows, AO, reflections)

Most effects shade what the camera already sees: reconstruct each pixel's world
position from the depth buffer, trace from there, and darken/tint the scene colour.
The pieces:

- **`@scene.depth`** via a `texture_depth` binding — the depth buffer
  (`textureLoad` returns the `[0,1]` clip depth). The range is **reversed**: 1.0
  is the near plane, 0.0 is the far plane and what empty sky clears to, and a
  nearer surface reads GREATER. So `d > 0.0` is the test for "a surface was drawn
  here", and `zero_linear_depth(d, near, far)` is the one conversion to metres.
- **`@scene.color`** / **`@scene.normal`** via `texture2d` bindings — the lit scene
  colour and (deferred path) the G-buffer normal.
- **`@frame.camera`** via a read-only `buffer` binding — the reserved per-render-
  target camera buffer: 5×`vec4<f32>` = inverse view-projection (columns `[0..4)`,
  column-major) + camera world position (`[4].xyz`). Bind it through `inputs`
  (`inputs = { cam = "@frame.camera" }`) and reconstruct world position as
  `inv_vp * vec4(ndc, 1)` (with `ndc.y = 1 - 2*uv.y` for the wgpu top-left
  convention). The matrix inverts the reversed range, so `ndc.z` takes a depth
  sample directly, `ndc.z = 1` is the near plane and `ndc.z = 0` the far one.
  The engine resolves it against the camera CURRENTLY
  being drawn, so the effect is correct in the live viewport AND in offscreen
  captures / RTT from a different camera. (`renderer.mainCameraView()` returns the
  same `{16 inverse VP, 3 position}` for the **main** camera if a script needs it
  on the Luau side; inside a pass, prefer `@frame.camera` so offscreen renders
  reconstruct against their own camera.)

```wgsl
let depth = textureLoad(scene_depth, coord, 0);
let ndc = vec3<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth);
let inv_vp = mat4x4<f32>(cam[0], cam[1], cam[2], cam[3]);  // cam = @frame.camera
let wh = inv_vp * vec4<f32>(ndc, 1.0);
let world_pos = wh.xyz / wh.w;
let occluded = zeroTraceAny(world_pos + n * 0.05, to_light, 0.0, 1000.0);  // hard shadow
```

Soft shadows / AO cast a small cone or hemisphere of jittered rays and average the
result. The fragment passes that follow are single-input, so a screen-space effect
typically does its compositing IN the compute pass (read `@scene.color`, write the
shaded result to an RT) and then a plain blit copies the RT to the screen.

## Samples

Two built-in render features are the reference implementations:

- **`rt_shadows`** (`@builtin::renderFeatures.rt_shadows`) — hard + soft ray-traced
  shadows: reconstruct world position, cast shadow rays toward the sun via
  `zeroTraceAny`, darken by the occluded fraction.
- **`rt_ao`** (`@builtin::renderFeatures.rt_ao`) — ray-traced ambient occlusion: a
  cosine-weighted hemisphere of short rays, darkening contact / crevice regions.
  Its settings live in `@builtin::modules.rt_ao` — see the cost section below.

Read them with `bash { command: "cat /zero/source/libs/@builtin/renderFeatures/rt_shadows.renderFeature/init.luau" }`
and adapt. Create one with `renderer.feature.create("rt_shadows")`.

## What tracing a screen-space effect costs

A screen-space RT effect casts a number of rays per pixel it shades, and that
product — rays × pixels — is what the pass costs. It is linear in both, and it
does not move with the scene's entity count: a ray walks the depth of the
structure rather than its contents, so halving the geometry in the scene leaves
the pass where it was.

That is why the resolution the effect shades at is worth as much as its ray
count, and why both belong under one knob. `rt_ao` is the worked example:

```lua
local rtAo = require("@builtin::modules.rt_ao")

renderer.setRaytrace(true)
rtAo.set({ quality = "medium" })   -- low | medium | high | ultra
rtAo.qualityLevels()               -- the rays and grid resolution of each
rtAo.stats()                       -- the grid the running pass actually built
```

| quality | rays per pixel | grid resolution | rays per 1920×1080 frame |
|---|---|---|---|
| `low` | 3 | half | 1.6 M |
| `medium` | 6 | half | 3.1 M |
| `high` | 6 | full | 12.4 M |
| `ultra` | 12 | full | 24.9 M |

Measured on a discrete GPU with hardware ray query, at 1920×1080, in an
optimized build of a scene of ~170 renderables and four point lights: the trace
pass costs about **2 ms per million rays**. `medium` spends 5-6 ms of GPU on it
and `high` 26 ms; the frame that scene draws in 16 ms without occlusion draws in
17-20 ms at `medium` and 38 ms at `high`.

Halving the grid is worth four rays, and at this scale the occlusion is the
same: occlusion is low-frequency almost everywhere, and a depth-aware upsample
carries the coarse grid back to the frame without crossing a silhouette. Budget
the same way for an effect of your own — count the rays a frame casts before
writing the shader, because that number is the answer.

The cost the trace does not carry is the filter behind it. A few rays per pixel
leave visible noise, and `@builtin::systems.denoising.denoiser` is what makes
that ray count usable — at its defaults, six more full-resolution dispatches
that together cost a few milliseconds regardless of the grid the signal was
traced on. `rtAo.stats().dispatches` counts the whole chain.

## Testing the software backend on a hardware machine

Launch with `--force-rt-compute` (or `ZERO_FORCE_RT_COMPUTE=1`) to select the
software backend even where hardware ray query exists — so the same effect can be
verified on the path web users get. `renderer.raytraceCapability()` then reports
`"compute"`.

## What keeping the structure current costs

A ray walks a structure built over the scene's geometry, and keeping that
structure describing the scene is work a frame pays before it traces anything.
`renderer.raytraceStats()` is what that work reads as:

```lua
local s = renderer.raytraceStats()
print(s.backend, s.triangles, s.staticTriangles, s.dynamicTriangles)
print(s.fullRebuilds, s.partialRebuilds, s.reusedFrames, s.trianglesRebuilt)
```

On the software backend a renderable that has stood still for a while is filed
under the STATIC partition — the front of the triangle buffer, with its own
subtree — and everything else under the DYNAMIC one. A frame in which only
dynamic geometry changed re-emits and re-sorts that partition alone and leaves
every static triangle where the device already holds it, so a scene where a few
things move stops paying for all of it. A frame in which a settled renderable
moves re-partitions the whole structure once, and the frames after it are
dynamic-only again.

The counters are cumulative over the session: sample, run the scene, sample
again. `trianglesRebuilt` divided by `triangles` is how many whole-structure
rebuilds those frames amounted to — a scene of 7,310 triangles with one moving
cube reads 12 triangles per moving frame rather than 7,310.

On the hardware backend the same call reports what the driver holds: `blas` (one
per unique static mesh, plus one per skinned instance), `blasBuilt` for the last
frame's submission, and `tlasInstances`.

## Notes

- The software (compute) backend traverses a bounding-volume hierarchy the engine
  builds over the gathered scene triangles, so a ray costs the depth of the tree
  rather than the triangle count. The build is CPU-side, and what a frame rebuilds
  of it is what moved — see the section above.
- Both backends fill the whole of `ZeroHit` and resolve `zeroHitSurface` /
  `zeroMaterial` through the same tables, so an effect reads the same on either.
- **Eight storage buffers per shader stage** is what a device guarantees, and
  declaring an `acceleration_structure` spends some of them on engine-owned
  tables: three on the hardware backend (what `zeroHitSurface` / `zeroMaterial`
  read), five on the software backend (those, plus the scene triangles and the
  hierarchy). A ray-tracing `.computeShader` therefore has **three** `buffer`
  bindings of its own on the backend web users get — pack several inputs into one
  buffer rather than declaring a slot each. Over budget, the engine refuses the
  shader and logs which buffers it declared.
- The scene structure includes off-screen geometry (rays aren't limited to the
  camera frustum), so shadows and reflections from objects outside the view are
  correct.
