WebRay ray-traced render
github.com/NarinderBrar/WebRay · 6144×2780 FSR export
Project 3 · Open source

WebRay

A ray tracer written as a WebGPU compute shader. It plugs into any Babylon.js scene in three lines and traces it in real time, with soft shadows, reflections, refraction and ambient occlusion.

WebGPU computeWGSLTypeScriptReactBabylon.jsAMD FSR 1
SAHBVH, 12 buckets
1–128accumulated frames
½pixels per frame (checkerboard)
4×FSR export upscale
What I built

Features

01Compute ray tracer

One WGSL compute kernel (8×8 workgroups): ray generation with AA jitter, traversal, shading.

02SAH BVH

Built on the CPU with bucketed SAH, max 8 triangles per leaf, flattened for the GPU.

03Lighting

Soft shadows (1–8 samples), sky and ambient occlusion, emissive lights, day/night sun.

04PBR materials

Colour, roughness, metallic, transparency, IOR; glass, water and diamond presets.

05Reflection & refraction

Secondary rays with Snell's law and configurable quality.

06Temporal accumulation

Progressive refinement while still; resets automatically when the camera moves.

07Checkerboard + TAA

Half the pixels per frame, the rest interpolated and blended.

08FSR export

EASU edge-adaptive upscale plus RCAS sharpening for 1–4× high-resolution renders.

09Tooling

React control panel, material editor, OBJ import, debug views, a WGSL #include build step.

Architecture

CPU prepares the scene once, the GPU traces every frame

CPUGPU MEMGPU Babylon scenemeshes · lights SceneDataBuildertris · material ids BVHBuilderSAH · flatten rebuilt only whengeometry changes triangles materials BVH nodes camera · scene uniforms / frame raytrace.comp8×8 · 1 thread / pixel accumulatemix(hist, cur, 1/n) displaypost-process FSR exportEASU → RCAS read
It integrates into any Babylon.js WebGPU scene with new WebRay({ scene, light }), init(), start(), and you can toggle between the ray-traced and raster views live.
Architecture decisions

What I chose, and what I didn't

DecisionRejectedWhy
Compute shader tracingFragment-shader tracingStorage buffers, workgroup control, write to any pixel; the natural fit for WebGPU
BVHUniform grid / k-d treeAdapts to geometry; each triangle in one leaf; what hardware RT uses
Bucketed SAH (12 buckets)Median split / full sweepNear-optimal trees at a fraction of the build cost
Build on CPU, flatten to an arrayPointer tree / GPU buildSimple and correct first; flat arrays are GPU-friendly; GPU LBVH is the next step
Accumulation + checkerboardMore samples per frameClean image when still, interactive when moving, at half the cost
FSR only on exportTrace at full export resolution6144 px renders at a fraction of the rays
Modular WGSL + #include buildOne giant shader stringWGSL has no includes; modules keep it readable, circular includes detected
Feature · acceleration structure

Choosing a split with the surface area heuristic

node AABB · 12 buckets along x left child · 6 trisright child · 3 tris best split cost at each of the 11 bucket boundaries minimum Surface area heuristic P(ray hits child) ∝ its surface area, so cost = A_L·N_L + A_R·N_R bin triangle centroids into 12 buckets evaluate the 11 boundaries per axis keep the cheapest split leaf when ≤ 8 tris or max depth Why buckets: an exact sweep evaluates everytriangle position; 12 bins get close to the sametree quality at a fraction of the build time.
Smaller, tighter children that rays are less likely to hit mean fewer nodes visited per ray. SAH trades build time for traversal speed, which is the right trade for a scene that's built once and traced every frame.
Feature · GPU traversal

A flat array and an explicit stack (no recursion on GPUs)

tree (nested boxes) 0 1 2 leaf 3 leaf 4 leaf ray flattened · depth-first 0L1 R4 1L2 R3 2tri 0·4 3tri 4·3 4tri 7·5 internal: child indices · leaf: triOffset · triCount traversal trace STEPSTACKACTION 1[0]pop 0 · box hit → push 4, 1 2[4, 1]pop 1 · box hit → push 3, 2 3[4, 3, 2]pop 2 · leaf → 4 triangle tests → hit t 4[4, 3]pop 3 · box missed → skip 3 tris 5[4]pop 4 · leaf → tests, keep nearest slab test with 1/dir precomputed once per ray fixed stack: array<u32, 64> · Möller–Trumbore triangles NEXT · ordered traversal push the far child first, skip nodes beyond the current hit t
GPUs have no call stack for recursion, so traversal is a loop over an explicit per-thread stack. Depth-first order puts a node's left child right after it, which keeps memory access local.
Feature · image quality per ray

Checkerboard + temporal accumulation + FSR

checkerboard frame N frame N + 1 traced = half the raysthe other half interpolated,blended at half weight accumulation history this frame mix output becomes history still: n = min(n + 1, max) · α = 1/n n = max: α = 0 → image frozen, no work view-projection changed: n = 1 · α = 1 export · AMD FSR 1 trace low EASUedge-adaptiveupscale 1–4× RCAScontrast sharpen 6144×2780 rendersat a fraction of the rays
All three trade samples across space and time instead of shooting more rays per pixel: interactive while moving, converged when still, and high resolution only on export.
Memory & buffer packing

GPU layouts: where the bytes go, and the padding I'd remove

BVHNode · 64 B10 px = 1 B · dashed = padding min vec3 12 max vec3 12 LRoffcnt _pad2 vec4 16 24 B padding · 37.5% Triangle · 64 B v0 12 v1 12 v2 12 _pad vec3 16 28 B padding · 43.75% vec3 aligns to 16 in WGSL storage → each 12-byte vec3 costs 16 what I'd do next node · 32 B: depth-first order makes left = i + 1, so the w lanes are free min.xyz + right max.xyz + count 2× smaller · leaf: right lane = triOffset, count > 0 triangles · 36 B → index buffer + shared vertices, or v0 + two edges for Möller–Trumbore traversal is memory-bound: fewer bytes per node = more nodes per cache line
Every ray reads many nodes, so node size is the main lever on traversal cost. The current layout is correct and simple. The packed layout is the obvious next step, with measured before and after results.
Optimizations & what's next

Speed per ray

log n

SAH BVH

Bucketed SAH, max 8 triangles per leaf; roughly O(log n) per ray instead of O(n).

½

Checkerboard

Half the pixels per frame; interpolated pixels blended at half weight.

1/n

Temporal accumulation

Converges when still; stops working entirely once the frame limit is reached.

FSR

Trace low, upscale smart

EASU + RCAS for 1–4× exports instead of tracing every pixel.

1/d

Cheap box tests

Inverse direction computed once per ray; the slab test becomes multiplies.

knobs

Runtime quality

Shadow, AO and reflection samples and feature toggles as uniforms, no recompiles.

Next: ordered traversal with hit-distance culling, 32-byte nodes, a GPU LBVH build (Morton codes) with a TLAS/BLAS split for moving objects, and an SVGF-style denoiser so the image is clean even while moving.