A Real-Time 2D Ray Tracer That Runs on Phones
A TypeScript library that ray-traces 2D scenes built from signed distance fields. Tile-based rendering and generated shaders keep it real-time on phones.
My BSc thesis needed two things: a renderer, and something to render. The renderer became SDF-2D, a 2D ray tracer for the browser built on signed distance fields; the something became decla.red, a multiplayer space shooter that proved the renderer could survive a real game loop. This article is about the renderer.
The motivation was mild dissatisfaction: modern 2D games mostly ship flat sprites and no lighting while sitting on ever-faster GPUs. Ray tracing is how you get lighting worth staring at, and in 2D it’s almost cheap enough to run on a phone. Most of my thesis was about the “almost”.
Circle tracing
Sphere tracing renders shapes defined by a signed distance field: it marches along each ray in steps equal to the field’s value at the ray’s current end. Circle tracing is its 2D simplification. The pleasant surprise of 2D is that the geometry needs no rays at all: evaluate the field at every pixel, and a negative value means the pixel is inside an object (values near zero hand you antialiasing for free). Rays are only needed for lighting, so performance is all about how often the field gets evaluated.
Scenes are described in plain TypeScript; a minimal animation of a circle orbiting a light looks like this:
import { CircleFactory, CircleLight, hsl, runAnimation } from 'sdf-2d';
const canvas = document.querySelector('canvas');
const Circle = CircleFactory(hsl(180, 100, 40));
const draw = (renderer, time) => {
renderer.addDrawable(
new Circle([150 + 50 * Math.cos(time / 1000), 75 + 50 * Math.sin(time / 1000)], 25)
);
renderer.addDrawable(new CircleLight([150, 75], hsl(270, 100, 40), 0.1));
return true; // flag that more frames are coming
};
runAnimation(canvas, [Circle.descriptor, CircleLight.descriptor], draw);
The descriptor list at the end looks redundant (surely the library can see what I’m drawing?), but it’s load-bearing; I’ll come back to why.
Two passes and a grid
The naive renderer evaluates the field pixels × lights × steps times per frame. Deferred shading fixes most of that: a first pass renders the distance field itself into a texture, the lighting pass reads the memoised values back, and the field gets evaluated once per pixel. The distance pass can even render at half resolution with barely any visible difference.
The second fix borrows from tiled renderers: the screen is split into an 8 by 8 grid, and each tile receives the short list of objects near it before the fragment shader runs, so its pixels never consider the whole scene. In my test scene of 200 objects, the average tile needed to know about 23 of them.
Measured on that scene (200 objects, two lights, 2560 × 1080, a desktop RX 590), with identical lighting code throughout:
| Optimisations enabled | FPS | GPU draw time |
|---|---|---|
| None | 7.5 | 130 ms |
| Memoised distance field | 60 (capped) | 5 ms |
| Memoised field and tiles | 60 (capped) | 1.85 ms |
That’s a 70-fold speed-up, and the difference between a tech demo and something a mid-range phone can run.
Shadows in 16 steps
The textbook route to soft SDF shadows is Quilez’s ray-marched penumbra technique1: lovely, but it wants 64 to 128 steps per light and an exact field rather than a lower bound. I ended up with a blunter instrument: march towards the light for 16 steps, divide how far the ray got by how far the light is, and raise the ratio to an arbitrary power to smooth it.
float shadowTransparency(float lightDistance, vec2 lightDirection) {
float rayLength = 0.0;
for (int j = 0; j < 16; j++) {
rayLength += max(0.0, getDistance(uvCoordinates + lightDirection * rayLength));
}
return min(1.0, pow(rayLength / lightDistance, 0.3));
}
The closer the ray gets to the light, the less shadow lands on its starting point; that’s the whole theory. There is no physical justification for it. It just runs several times faster than the principled version, tolerates lower-bound fields, and looks right. It also has one known bug: an object standing in another object’s shadow casts a second shadow from the same light. I know it’s there; nobody ever noticed it during testing.
Shaders written by a program
WebGL1 requires loop bounds to be known at shader compile time, but a library can’t know ahead of time how many circles you’ll draw. So SDF-2D writes its own shaders at runtime: it generates a program from GLSL templates for every combination of the object counts each drawable type declares. Each frame then runs the smallest program that fits the scene. Keeping the combinations to a couple hundred programs wasn’t hard. Compiling them all before the first frame is why the library leans on parallel shader compilation (JavaScript is single-threaded; the driver doing the compiling is not). It’s also why runAnimation demands those descriptors up front: the generator has to know every type you’ll ever draw before it can write the shaders.
Surviving the browser
The library also supports WebGL1 alongside WebGL2 (in 2020, iPhones left no choice), degrades gracefully when extensions are missing, and even survives the browser yanking the GPU away mid-frame. I debugged that last failure mode with a simulator that killed and restored the rendering context at random intervals until nothing broke any more.
Field testing in an electronics store
“Runs on phones” is an empirical claim, so the demo page2 anonymously logs frame rates. Gathering the data was the fun part: beyond organic visitors and pestered acquaintances, I walked through an electronics store running the demo on every device on display, because the companies that rent out real hardware for testing charge more than a student can afford. Across 37 unique devices, 33 held 30 FPS or better, most sat pinned at the browser’s 60 FPS cap, and the strangest row in the data was a 4K TV running a four-year-old Chrome. The numbers fed straight back into the autoscaler that trades render resolution for frame rate: I lowered its target to 30 FPS and added motion blur so weaker devices still feel smooth. Fleeting Garden3 got the same treatment years later, shedding agents instead of pixels.
Limitations
- Objects reach the GPU as uniforms, and uniform counts are capped by the GPU, the driver, and the browser, so a sufficiently complex scene hits a wall. The planned fix was a non-uniform tile grid, finer where the scene is busy.
- There are exactly two light types and no way to plug in a custom lighting model.
- The banding around lights bothers me to this day. Dithering would fix it; it looked too expensive at the time.
The library is still on npm and gets a hundred-odd downloads a week, which may not be much, but I like to imagine that somebody, somewhere, is actually using it. The demo scenes are still up too. The game half of the thesis deserves its own article, and it will get one.
- Quilez’s ray-marched penumbra technique: https://iquilezles.org/articles/rmshadows/↩
- demo page: https://sdf2d.schmelczer.dev/↩
- Fleeting Garden: https://schmelczer.dev/articles/fleeting-garden-webgpu-drawing/↩