One Math Core, Three Renderers
My engine renders 3D three completely different ways: a real-time rasterizer, an offline path tracer, and a GPU raytracer. They look different, run on different hardware, and solve different problems. But they all sit on one math core — the same Vec3, the same Mat4, the same Möller–Trumbore ray/triangle test.
That shared bottom layer is the whole point. Here's what rides on it.
The one thing underneath
At the bottom is @engine/math plus @engine/geometry: immutable, right-handed, column-major (the WebGL / gl-matrix convention). Nothing exotic —
Vec3/Vec4,Mat3/Mat4,Quaternionwith proper slerp,Transform(TRS).Ray,Aabb,Sphere,Triangle, and the intersection tests between them.- A
rayTrianglebuilt on Möller–Trumbore with barycentrics and optional backface cull.
Every renderer below reaches for these instead of rolling its own vectors. When I fixed a normalize edge case once, all three renderers got the fix. That's the payoff you're buying with a shared core: correctness compounds instead of forking.
Renderer 1 — real-time rasterizer (WebGL2)
The interactive one. A forward renderer with a full modern PBR stack:
- Cook-Torrance shading — GGX distribution, Smith geometry, Schlick Fresnel.
- PCF shadow mapping off a 2048² depth map.
- HDR pipeline: bright-pass → separable Gaussian blur → bloom → ACES tonemap → gamma → vignette.
This is the "60 FPS in a browser tab" path. It never traces a ray — it rasterizes triangles and fakes global illumination with a hemispheric ambient term. The math core shows up in the camera (Mat4.perspective, lookAt), the transforms, and the shadow matrix.
Renderer 2 — offline path tracer (Node)
The "leave it running and get one beautiful frame" path. Pure Monte-Carlo, on the CPU, in Node — the same TypeScript, no GPU:
pnpm --filter server render 640 360 64 6 out.png
# W H spp depth
- Cosine-weighted hemisphere GI (real bounced light, not a fake ambient term).
- Emissive area lights + optional sun direct lighting.
- A metal/rough BSDF, firefly clamp, and a thin-lens depth-of-field camera.
- A flat, SAH-inspired BVH (longest-axis split, ≤4 triangles per leaf) so rays don't test every triangle.
This is where "shared math" earns its keep. The path tracer's intersect() calls the same rayTriangle and rayAabb the rasterizer's geometry utilities use. The BVH is built from the same Triangle and Aabb types. I wrote a physically-based renderer without writing a single new vector class.
Renderer 3 — GPU raytracer (interactive Earth)
The show-off one: a fragment-shader path tracer that runs the raytrace on the GPU, interactively. It renders a raytraced Earth from a real NASA GLB model.
- The BVH from renderer 2 gets packed into float textures and traversed with a stack-based loop right in GLSL.
- Möller–Trumbore again — this time reimplemented in the shader, matching the TypeScript version test-for-test.
- Tangent-space normal mapping, a day/night terminator, ocean specular, an atmospheric scattering shell, ACES to finish.
- A from-scratch GLB 2.0 parser loads the model, normalizes it to a unit radius, and builds the BVH the shader consumes.
The GPU can't import my TypeScript, obviously. But it consumes data structures the shared core produced (the BVH, the triangle buffers) and reimplements the same intersection math the CPU tracer already proved correct. The TypeScript version becomes the reference implementation the shader is checked against.
The scoreboard
| Renderer | Where it runs | Technique | Job |
|---|---|---|---|
| Rasterizer | Browser (WebGL2) | Forward PBR, shadow maps, bloom | Real-time, interactive |
| Path tracer | Node (CPU) | Monte-Carlo GI, BVH, DoF | Offline, photorealistic |
| GPU raytracer | Browser (GLSL) | BVH-in-textures fragment tracer | Interactive raytracing |
Three renderers. Three runtimes. One Vec3.
The lesson
It would have been faster, the first time, to give each renderer its own little vector helpers and its own ray test. It's always faster the first time. The bet I made instead was that math is math — a dot product is a dot product whether it's rasterizing a shadow, bouncing GI on the CPU, or traversing a BVH on the GPU.
That bet paid off the moment I had three renderers instead of one. New rendering approaches don't start from a blank file; they start from a proven math layer and only write the part that's actually new. The core got more valuable each time I reused it — which is the only real test of whether an abstraction was worth building.