Log inGet started

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 your zeroTrace* 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.depth via a texture_depth binding — the depth buffer (textureLoad returns the [0,1] clip depth).
  • @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 / [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.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.

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 zeroTraceClosest returns t + primitive; instance / bary are 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.
  • documentation
  • guide