A progressive wavefront GPU path tracer in WebGPU compute (no hardware ray
tracing). It traces its own two-level BVH: a per-mesh BLAS over triangles plus a
TLAS over instances, where an instance is either a triangle mesh or an analytic
shape. Everything lives under src/renderer/pathtracer/ with the WGSL kernels in
src/shaders/pathtracer/.
Building a scene#
import { assemblePtScene, makeMat, loadGltfMeshes } from '../src/renderer/pathtracer/scene.js';
import { sphere, box, torus, rect, csg, CsgOp, trs, rotXYZ } from '../src/renderer/pathtracer/shapes.js';
const floor = makeMat({ baseColor: [0.8, 0.8, 0.8], checker: [0.3, 0.3, 0.3], checkScale: 0.5 });
const gold = makeMat({ baseColor: [1, 0.78, 0.35], metalness: 1, roughness: 0.2 });
const lamp = makeMat({ baseColor: [0, 0, 0], specularWeight: 0, emission: [40, 38, 35] });
const shapes = [
rect([0, 0, 0], 10, 10, floor), // XZ rectangle, +Y normal, double-sided
sphere([0, 1, 0], 1, gold),
torus([3, 0.4, 0], 1, 0.35, gold, rotXYZ(Math.PI / 2, 0, 0)),
sphere([2, 5, 2], 0.4, lamp), // emissive sphere → sampled as a light
];
// CSG: operands are indices into the same array; the parent gets its own entry.
shapes.push(box([-3, 1, 0], [0.8, 0.8, 0.8], gold));
shapes.push(sphere([-3, 1, 0], 1.05, gold));
shapes.push(csg(CsgOp.Intersection, shapes.length - 2, shapes.length - 1, gold));
const gltf = await loadGltfMeshes(device, duckUrl, { transform: trs([0, 0, 3], 0.01) });
const scene = assemblePtScene(device, gltf.meshes, gltf.insts, shapes);
gltf.destroy(); // after assembling — the atlas bake reads the glTF texturesassemblePtScene(device, meshes, insts, shapes) returns a PtScene: GPU
buffers, counts, sceneCenter / sceneRadius, and setInstanceWorld(i, world)
commit()for animating instance transforms (the TLAS and light list are rebuilt on the CPU and re-uploaded). Shapeiis instancescene.shapeInstanceBase + i.
Shapes (shapes.ts)#
Every shape is a unit solid placed by an object→world matrix (trs, rotXYZ,
rotateYTo help build them): sphere (ellipsoid with a radius triple), box,
cylinder (capped or open tube), cone (frustum via topRadius), paraboloid,
hyperboloid (one sheet, waist ratio), hyperbolicParaboloid (saddle sheet),
capsule, torus (exact, sphere-traced SDF), disk, rect, quadric (4×4
coefficient matrix, quadricCoeffs(A..J) builds it), convex (up to four
half-spaces, optionally mirrored), csg (union / difference / intersection of
two non-CSG shapes), terrain (ray-marched fbm / ridged heightfield inside its
box) and ocean (animated wave heightfield driven by settings.time).
Materials (makeMat)#
OpenPBR-style parameters: baseColor, metalness, roughness,
specularWeight, ior, transmission (+ attenuationColor / attenuationDistance
Beer absorption), coatWeight / coatRoughness / coatColor, fuzz* sheen,
subsurfaceWeight / subsurfaceColor / sssRadius (random-walk subsurface, mean
free path in world units), emission, a world-space checker second color with
checkScale, and slopeBlend + slopeColor (terrain: steep → flat blend).
Lights#
Any emissive surface emits. Emissive triangles, spheres, disks and rects are also added to the next-event-estimation light list, chosen in proportion to their power and combined with BSDF sampling through MIS. Point lights are tiny emissive spheres; a spot is an emissive disk inside an open cylinder housing.
Environment#
PathTracer.create(ctx, scene, envTexture, rgbe) builds the importance-sampling
distribution from an HDR's RGBE bytes. For an analytic sky use sky.ts:
const baked = bakeSky({ sunElevation: 35, sunAzimuth: 120, turbidity: 3 });
const tex = createFloatEnvTexture(device, baked.width, baked.height, baked.data);
const pt = PathTracer.createWithDistribution(ctx, scene, tex, envDistributionFromFloat(baked.data, baked.width, baked.height));
pt.settings.sunDir = baked.sunDir; // used by the cloud layer
pt.settings.sunRadiance = baked.sunRadiance;The sky is normalized so sun + sky deliver an irradiance of π (a white ground
reads as 1.0); sunStrength sets the sun / sky irradiance ratio (default 5).
Settings (pt.settings)#
Integrator: maxBounces, sortEnable, fireflyClamp. Image: exposure,
envIntensity, envRotation, denoise (à-trous SVGF) + temporal. Camera:
aperture / focusDistance, ortho + orthoScale. Media: fogDensity
(extinction per unit, 0 = off), fogColor (single-scattering albedo), fogAniso
(Henyey–Greenstein g). Sky: clouds, cloudCover, cloudBase, cloudThickness,
cloudScale, sunDir, sunRadiance, time.
Call trace() per sample and present() per frame; reset() after anything
that changes the integrand (camera, scene, most settings).
Samples#
samples/lib/pt_sample_base.ts drives all the demo pages (camera, control panel,
environment loading, animation loop, ?scene=<key> URL parameter):
pt_geometry_showcase, pt_classic_scenes, pt_csg, pt_materials,
pt_volumetric, pt_terrain, pt_ocean_sky, pt_shapes_bvh, pt_bvh_lights,
plus the original glTF / MaterialX path_tracer page.
Limits#
No bidirectional path tracing (hidden-light scenes converge slowly), no GPU BVH
refit for animated triangle meshes (shape instances animate through
commit()), one homogeneous global fog, and the texture atlas is 512² per
textured material.