---
title: A 2D Ray Tracer for the Browser, Tuned for the Phone in Your Pocket
description: 'My BSc thesis library. The mobile GPU shaped the architecture: tile-based passes, deferred shading, shaders generated per scene and device.'
date: 2026-05-08
period: 'Autumn-Winter 2020'
thumbnail:
  src: ./_assets/sdf2d.jpg
  alt: SDF-2D browser demo with soft lighting effects.
links:
  - label: NPM package
    url: https://www.npmjs.com/package/sdf-2d
  - label: Video
    url: https://www.youtube.com/watch?v=K3cEtnZUNR0
  - label: BSc thesis
    url: /media/downloads/sdf2d-andras-schmelczer.pdf
    download: true
article:
  featuredOrder: 3
  tags: ['graphics', 'web', 'systems']
  stack:
    [
      'TypeScript',
      'WebGL',
      'WebGL2',
      'Signed distance fields',
      'Dynamic shader generation',
    ]
  scale: Browser library, mobile-targeted, real-time on consumer GPUs, both WebGL1 and WebGL2 paths
  media:
    - type: image
      src: ./_assets/sdf2d.jpg
      caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
project:
  title: SDF-2D
  selected: true
  technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
---

Winter 2020, the BSc thesis deadline closing in, and one fact concentrating my mind wonderfully: the renderer had to run acceptably on my advisor's laptop on the day he graded it. Nothing exposes a lazy architectural assumption like knowing exactly which unremarkable GPU your grade depends on. The thesis was a 2D ray tracer for the browser built on signed distance fields (soft shadows, smooth reflections, no triangle mesh anywhere), and it shipped as an NPM library rather than a demo page. The other half of the thesis was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game that proved the renderer could survive a real game loop.

## What "mobile GPU" actually meant

A 2D SDF ray tracer is conceptually simple: for every pixel, march along rays, sample the distance field, accumulate light. The implementation that hums on a desktop NVIDIA card spends so much per pixel that a phone melts. So the design problem was never "can SDFs do soft shadows" (they can, easily) but "how much per-pixel work can be avoided without giving up the look".

Three constraints did most of the architecture for me:

- **WebGL1 and WebGL2, both.** No "modern browsers only" escape hatch, which ruled out anything needing compute shaders or storage buffers.
- **No hand-tuned shader per scene.** It's a library. Users bring scenes I've never seen, so the renderer has to compile something appropriate at runtime.
- **Acceptable on a phone.** Not "fine if the user owns the right hardware", but acceptable on whatever mid-range thing is actually in the room.

## How it runs

- **Tile-based passes.** Group pixels and reason about them together: most regions of a frame share the same nearby geometry, so a tile that knows its bounds can skip enormous amounts of per-pixel work. The single biggest performance win in the project.
- **Deferred shading.** The first pass writes the distance field into a texture: a 16-bit float channel where WebGL2 offers one, packed into RGBA where WebGL1 doesn't. The lighting pass reads it back. Shadows and reflections want the same geometry queries, so answering them once per pixel and reusing the result was worth the texture bandwidth.
- **Shaders generated per scene and device.** The generator is honest string substitution: GLSL templates with placeholder slots, filled in at runtime from the scene's actual contents. A scene with no reflective surfaces produces a shader with no reflection path; a WebGL1 device produces one that never mentions WebGL2 features. Where the browser supports parallel shader compilation, the variants build without blocking the first frame.
- **Quality that follows the frame rate.** An autoscaler watches the 90th-percentile frame time and trades render resolution against it, with the distance and lighting passes scaled independently and a hysteresis band so quality doesn't oscillate. It also adapts more cautiously the longer the session runs, which keeps a momentary stutter from permanently degrading the picture.
- **TypeScript scene descriptions, no DSL.** I prototyped a small language for authoring SDFs and then made myself throw it away, which hurt more than it should have. Users describe scenes in plain TypeScript and the library compiles them down; a DSL would have been one more language to teach and one more compiler to debug, in exchange for elegance only I would have noticed.

## What held up, what didn't

- **Held up:** letting the weakest target device pick the architecture. When something only has to run on a desktop GPU, headroom quietly impersonates good design, and a user eventually delivers the correction.
- **Held up:** keeping the library boundary clean. A demo can hide a messy implementation; a published package can't.
- **Didn't:** I never built instrumentation around the shader variants. I still think a `?debug=1` overlay that prints which shader a session compiled, and why, is the first thing I'd add today.
- **Didn't:** the documentation explains ray marching in prose. The ideas are visual and the explanation should have been too. Diagrams next time.
