daveisbest.org

Director’s commentary · Type-In

A CRT in TSL

The monitor on the Disk 1 desk: curved glass, scanlines, an aperture grille and phosphor glow, written once in TSL for WebGPU and WebGL 2, with a live minigame canvas on the tube.

The real star of Disk 1 isn't me, it's the monitor. BRICKS.BAS runs on a CRT on the desk, and the camera pushes in until the tube fills the phone. So that screen had to look like curved glass and glowing phosphor from across the room and from a few centimetres away, on WebGPU and on WebGL 2, from one shader. This is how it's built.

One shader, two backends

The game renders with three.js's WebGPURenderer, which falls back to WebGL 2 on its own when WebGPU isn't there. Hand-written GLSL would only work on one of those. TSL, the three.js shading language, is plain JavaScript that builds a graph of shader nodes, and the renderer compiles that graph to WGSL for WebGPU or to GLSL for WebGL 2 when a material is first used. Every effect in the game is written that way, so none of them care which backend they're on.

Testing the fallback is one query parameter. Add ?webgl to any game URL and the renderer is forced onto WebGL 2.

gl={async (props) => {
  const renderer = new THREE.WebGPURenderer({
    ...(props as ConstructorParameters<typeof THREE.WebGPURenderer>[0]),
    antialias: tier !== "low",
    powerPreference: "high-performance",
    // ?webgl forces the WebGL 2 backend (fallback testing, spec §16).
    forceWebGL: new URLSearchParams(window.location.search).has("webgl"),
  });
  await renderer.init();
  return renderer;
}}
game/Stage.tsx: R3F's async renderer factory.

The tube

crtScreenMaterial takes any texture and shows it through glass. It's unlit and slightly over-bright, the way a screen in a dim room is, and every step reads like a line from a description of a real CRT: bend the coordinates, add a halo, darken the gaps between scanlines, split the columns into red, green and blue stripes, shade towards the rim, and put dark glass round the edge.

/** Fades a periodic pattern out as it approaches the pixel rate, so distant screens don't moiré. */
const antiAlias = (cycles: Node<"float">) => float(1).sub(smoothstep(0.35, 0.75, fwidth(cycles)));
// …
export function crtScreenMaterial(map: THREE.Texture, opts: { curvature?: number; scanlines?: number; glow?: number } = {}): THREE.Material {
  const curvature = float(opts.curvature ?? 0.12);
  const lines = opts.scanlines ?? 0.45;
  const glow = opts.glow ?? 0.5;

  const coord = crtCurve(uv(), curvature);
  const screen = texture(map, coord);
  const halo = GLOW_TAPS.map(([x, y]) => texture(map, coord.add(vec2(x, y).mul(0.012))).rgb)
    .reduce((a, b) => a.add(b))
    .div(GLOW_TAPS.length);
  let c = screen.rgb.add(halo.mul(glow * 0.4));

  const size = map.image as { width?: number; height?: number } | undefined;
  const rows = coord.y.mul(Math.min(size?.height ?? 240, 288));
  const g = lineGap(rows);
  const gap = g.mul(g).mul(antiAlias(rows));
  c = c.mul(float(1).sub(gap.mul(lines * 0.7)));
  const cols = coord.x.mul(Math.min(size?.width ?? 320, 360));
  c = rgbMask(c, antiAlias(cols).mul(lines * 0.3), cols);

  c = c.mul(tubeShade(coord, 0.55)).mul(1 + glow * 0.2);

  // Bezel: dark glass that catches a little of the screen's light near the tube
  const bezel = vec3(0.02, 0.022, 0.024).add(halo.mul(0.08 * glow));
  const m = new THREE.MeshBasicNodeMaterial({ name: "crt-screen" });
  m.colorNode = vec4(mix(bezel, c, tubeCoverage(coord, curvature)), 1);
  m.mrtNode = mrt({ output, [AUX]: vec4(packNormalToRGB(normalView), 0) });
  return m;
}
engine/looks/screen.ts

The halo is eight extra samples in a ring round each pixel, averaged and added back, which is enough for bright bricks to bleed into the black around them. The scanline count comes from the canvas itself, capped at 288, so a 320 by 256 game gets 256 lines, one per row of its pixels.

The curve is the part that makes it a tube rather than a flat panel. Each axis bows outwards by the square of the other, so the corners of the picture fall off the edge of the glass instead of staying pinned. A rounded-rectangle mask then decides where the picture ends and the bezel begins, with a softer edge the more the glass bends.

/**
 * Curved-glass coordinates: each axis bows out with the square of the other, so the
 * picture's corners fall off the tube (three's barrelUV keeps them pinned instead).
 */
export function crtCurve(coord: Node<"vec2">, curvature: Node<"float">): Node<"vec2"> {
  const c = coord.mul(2).sub(1);
  return c.mul(c.yx.mul(c.yx).mul(curvature).add(1)).mul(0.5).add(0.5);
}

/** Rounded-rectangle coverage of a screen in 0…1 coords; corner radius and edge softness grow with curvature. */
export function tubeCoverage(coord: Node<"vec2">, curvature: Node<"float">): Node<"float"> {
  const radius = curvature.mul(0.45);
  const soft = curvature.mul(0.06).add(0.002);
  const k = abs(coord.sub(0.5)).mul(2).sub(float(1).sub(radius));
  const d = length(max(k, 0)).add(min(max(k.x, k.y), 0)).sub(radius);
  return float(1).sub(smoothstep(soft.negate(), 0, d));
}
engine/looks/effects.ts

Moiré, and keeping the phosphor cold

A 256-line pattern on a monitor that's a hundred pixels tall on your phone can't be drawn. Try, and the scanlines beat against the screen's own pixels and you get moiré: shimmering bands that crawl as the camera moves. The antiAlias helper uses fwidth, how fast a value changes from one pixel to the next, to fade the scanlines and the RGB mask out as they approach the pixel rate. From across the room the screen is a soft glow. As the camera pushes in, the lines resolve.

The other problem was colour. The Disk 1 look warms the whole frame to desk-lamp amber, and that grade would have turned the screen's cold phosphor orange too. So the scene pass writes a second render target alongside the colour, and the screen material writes 0 into its alpha where every other material writes 1. The grade reads that as a mask and leaves the screens alone.

const mask = aux ? aux.sample(coord).a : float(1);
if (has.has("grade")) hdr = vec4(hdr.rgb.mul(mix(float(1), u.exposure, mask)), 1);

// Display stage. Looks are tuned for Khronos PBR Neutral, which keeps authored colours
// (backgrounds, props, the "now" palette) true where ACES would shift their hues.
let c = renderOutput(hdr, THREE.NeutralToneMapping).rgb;
if (has.has("grade")) c = grade(c, u, mask);
engine/looks/pipeline.ts: the grade, masked off in-world screens.

Getting a game onto the glass

Minigames don't know about 3D. An in-world game just gets a canvas to draw on, and the minigame host decides where that canvas ends up. It makes one at the size the game asks for (BRICKS.BAS asks for 320 by 256, the Amiga's low-resolution PAL screen), binds it to a Screen in the scene, and marks it dirty whenever the game draws a frame. If a scene has no screen at all, the canvas is shown full-screen instead.

if (game.display === "in-world") {
  const [w, h] = game.screenSize ?? [320, 256];
  const canvas = document.createElement("canvas");
  canvas.width = w;
  canvas.height = h;
  const binding = (opts.screen && this.world.screens.get(opts.screen)) || this.world.screens.values().next().value;
  binding?.setSource(canvas);
  screen = { canvas, width: w, height: h, markDirty: () => binding?.markDirty() };
  unbind = () => binding?.setSource(null);
  if (!binding) {
    // No screen in this scene: fall back to showing the canvas full-screen.
    canvas.style.cssText = "position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(96vw,128vh);image-rendering:pixelated;";
    root.appendChild(canvas);
  }
}
engine/minigames/host.ts

The Screen component is a plane with the CRT material on it. Until a game binds, it shows an idle picture drawn once. When one does, it wraps the canvas in a CanvasTexture with nearest-neighbour filtering, so the pixels stay square and chunky, and swaps the material. Each markDirty flags the texture for upload on the next frame.

const binding: ScreenBinding = {
  id,
  mesh,
  setSource(canvas) {
    live?.dispose();
    live = null;
    if (canvas) {
      live = new THREE.CanvasTexture(canvas);
      live.colorSpace = THREE.SRGBColorSpace;
      live.minFilter = THREE.NearestFilter;
      live.magFilter = THREE.NearestFilter;
      mesh.material = crtScreenMaterial(live, { curvature, scanlines });
    } else {
      mesh.material = material;
    }
  },
  markDirty() {
    if (live) live.needsUpdate = true;
  },
};
game/scene/components.tsx: the Screen's binding.

The same functions also power a full-screen version. The title card, Insert Disk 1 on a curved tube, runs the whole frame through a CRT step in the post-processing pipeline, with a faint bright band that rolls down the screen every seven seconds. Reduced motion stops the roll.

What I'd do differently

Binding a game builds a brand-new material, and a new node material means a new shader compile the first time it's drawn, which can show up as a small hitch just as the game starts. The better version keeps one material per screen and swaps the texture inside it, so the shader is compiled once, while the idle picture is showing. It's a small change and it's next on the list.

I'd also like the glass to reflect the room. A faint, blurred reflection of the desk lamp on the tube would sell it more than any amount of extra scanline detail.