Ray tracing
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…
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 and yourzeroTrace*calls traverse it 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 (see the compute guide). 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 two functions available in shader.wgsl:
// Closest hit. `instance` is the hit object's render slot (hardware backend);
// `primitive` is the triangle index; `bary` the barycentrics.
struct ZeroHit { hit: bool, t: f32, instance: u32, primitive: u32, bary: vec2<f32> };
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;
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.depthvia atexture_depthbinding — the depth buffer (textureLoadreturns the[0,1]clip depth).@scene.color/@scene.normalviatexture2dbindings — the lit scene colour and (deferred path) the G-buffer normal.@frame.cameravia a read-onlybufferbinding — 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 throughinputs(inputs = { cam = "@frame.camera" }) and reconstruct world position asinv_vp * vec4(ndc, 1)(withndc.y = 1 - 2*uv.yfor the wgpu top-left /[0,1]-depth convention). 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.cameraso 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 viazeroTraceAny, 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.
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").
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".
Notes
- The software (compute) backend is brute-force over the scene triangles — correct
on any device; a spatial acceleration structure is a performance follow-up. On
the software backend
zeroTraceClosestreturnst+primitive;instance/baryare reconstructed on the hardware backend. - 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.