Adhiraj Singh ← All writing

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 —

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:

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

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 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

RendererWhere it runsTechniqueJob
RasterizerBrowser (WebGL2)Forward PBR, shadow maps, bloomReal-time, interactive
Path tracerNode (CPU)Monte-Carlo GI, BVH, DoFOffline, photorealistic
GPU raytracerBrowser (GLSL)BVH-in-textures fragment tracerInteractive 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.