Log inGet started

Ray tracing

Updated 21 August 2026

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:

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:

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:

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

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

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
qualityrays per pixelgrid resolutionrays per 1920×1080 frame
low3half1.6 M
medium6half3.1 M
high6full12.4 M
ultra12full24.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:

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.
  • documentation
  • guide