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 browser-based 2D ray tracer built on signed distance fields. The other half became decla.red1, a multiplayer space shooter built on it. This article is about the renderer.
2D games mostly composite unlit sprites while running on ever-faster GPUs. Ray tracing would give them shadows and area lights, and in 2D it is nearly cheap enough for a phone. Closing that gap is most of the thesis.
Circle tracing
Sphere tracing (Hart, 1996) renders surfaces defined by a signed distance field (SDF) by marching along each ray in steps equal to the field’s value at the current point. Circle tracing is the 2D version. The geometry itself needs no rays: a negative field value means the pixel is inside an object, and values near zero give antialiasing. Rays are only needed for lighting, so performance depends on how often the field is evaluated.
A minimal scene, a circle orbiting a light, in TypeScript:
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, but the shader generator needs it; more on that below.
Two passes and a grid
The naive renderer evaluates the field pixels × lights × steps times per frame. Deferred shading cuts this to once per pixel: one pass renders the distance field and base colour into a texture, the lighting pass reads it back. The distance texture defaults to half the canvas’s resolution, and nothing visibly changes.
The second fix borrows from tiled renderers. The screen is split into an 8 × 8 grid, and each tile receives only the objects whose CPU-side lower-bound distance puts them within reach; its field is capped at the distance of the nearest object left out. In my 200-object test scene, the average tile needed 23.
Measured on that scene (200 objects, two lights, 16 shadow steps, 2560 × 1080, a desktop RX 590), GPU time from EXT_disjoint_timer_query:
| 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 |
A 70-fold speed-up, enough headroom for a mid-range phone.
Shadows in 16 steps
The textbook route to soft SDF shadows is Quilez’s ray-marched penumbra technique2. Mine needed 64 to 128 steps per light, an exact field rather than a lower bound, and still showed artefacts at sharp edges. I replaced it with a heuristic: march towards the light for 16 steps, divide the distance travelled by the distance to the light, and raise the ratio to the power of 0.3.
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 its starting point receives. It has no physical basis but runs several times faster, accepts lower-bound fields, and anything from 8 to 32 steps looks acceptable. Light attenuates as 1 / (d / intensity + 1)² and penetrates objects at reduced intensity. One known bug: an object already inside another’s shadow darkens that shadow again, though rarely noticeably.
Shaders written by a program
WebGL1 requires loop bounds to be compile-time constants, and WebGL2 unrolls fixed-bound loops. A library can’t know how many circles you’ll draw, so SDF-2D generates its shaders at runtime. Each drawable’s descriptor declares its GLSL distance function, the uniform arrays carrying its instances, and the object counts to compile for, such as [0, 1, 2, 4, 8, 16]. The generator substitutes these into GLSL templates and compiles a program for every combination across drawable types; the count grows exponentially, but keeping it under a few hundred wasn’t hard. Each tile is drawn with the smallest program covering its object counts. Compiling them all before the first frame is why the library relies on KHR_parallel_shader_compile where available: JavaScript is single-threaded, but the driver compiling shaders is not. It’s also why runAnimation needs the descriptors up front: every drawable type must be known before the shaders are written.
Surviving the browser
The library supports WebGL1 alongside WebGL2 because, in 2020, iPhones left no choice. Both are wrapped in one context type carrying an isWebGL2 flag, so the shared path type-checks and every WebGL2-only feature has a fallback: float render targets degrade to 8-bit, parallel compilation to synchronous, the timer query to nothing. Context loss is handled too. A JavaScript Proxy around the context throws on every call once the context-lost event fires, unwinding the frame; a proxy around the renderer catches that, caches settings changed meanwhile, and rebuilds the renderer on restore. A simulator that lost and restored the context at random intervals shook out the bugs.
Field testing in an electronics store
The demo page3 anonymously logs each scene’s frame rate and the autoscaler’s resolution scales. Organic visitors and pestered acquaintances provided some data; I gathered the rest by running the demo on every device on display in an electronics store, since device farms charged more than a student could afford. Of 37 unique devices, 33 held 30 FPS or better, and over three quarters stayed at the browser’s 60 FPS cap in every scene. The bars above 60 are a 120 Hz Samsung tablet and a 90 Hz Pixel 5; the slowest include the SwiftShader software renderer and a 4K TV running a four-year-old Chrome.
The data fed back into the autoscaler, which trades resolution for frame rate: I lowered its target from 50 to 30 FPS and added motion blur so weaker devices still felt smooth. Years later, Fleeting Garden4 took the same approach, shedding agents instead of pixels.
Limitations
- Objects reach the GPU as uniform arrays, whose capacity is capped by the GPU, driver, and browser, so a complex enough scene hits a wall. The planned fix was a non-uniform tile grid, finer where the scene is busy.
- There are two light types, a circular area light and a torch-like directional one, and no custom lighting models.
- The banding around lights bothers me to this day. Dithering would hide it but seemed too expensive at the time.
The library is still on npm and gets a few dozen downloads a week; I like to imagine somebody, somewhere, is using it. The demo scenes are still live.
- decla.red: https://schmelczer.dev/articles/declared-shared-simulation-code/↩
- 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/↩




