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 yourzeroTrace*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.depthvia atexture_depthbinding — the depth buffer (textureLoadreturns 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. Sod > 0.0is the test for "a surface was drawn here", andzero_linear_depth(d, near, far)is the one conversion to metres.@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 convention). The matrix inverts the reversed range, sondc.ztakes a depth sample directly,ndc.z = 1is the near plane andndc.z = 0the 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.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. 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
| 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:
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
ZeroHitand resolvezeroHitSurface/zeroMaterialthrough 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_structurespends some of them on engine-owned tables: three on the hardware backend (whatzeroHitSurface/zeroMaterialread), five on the software backend (those, plus the scene triangles and the hierarchy). A ray-tracing.computeShadertherefore has threebufferbindings 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.