daveisbest.org

Director’s commentary · We Made Our Own

Felt tip on cereal box

How Disk 2 gets its homemade look: a shared toon material with a warm rim, felt-tip outlines found from depth and normals, and procedural paper grain, all in TSL.

We couldn't afford Cluedo or Monopoly, so my brother, my sister and I made our own, out of cereal boxes, felt tips and scissors at the kitchen table. Disk 2 has to look like that afternoon: thick ink lines that aren't quite straight, colours a bit too loud, and everything sitting on card. Nothing arrives looking like that. The props come out of Meshy looking like soft painted toys, and the people are procedural shapes. So the homemade look is made in two places: a material every lit object is given, and two steps in the post-processing pipeline.

One material for everything

Every prop in the game comes from a different Meshy generation, and left alone they'd each bring their own idea of shininess and shading. The memory material flattens that out. When a prop loads, every lit material on it is swapped for a toon material with the same colour and texture, a shared three-step light ramp (shadow, mid, lit) and a warm fresnel rim, the soft glow round an edge that faces away from you. It's the style bible's rule in code: everything sits in one world.

/** The one shared three-step toon ramp: soft shadow, mid, lit. */
function gradientRamp() {
  if (!ramp) {
    ramp = new THREE.DataTexture(new Uint8Array([120, 190, 255]), 3, 1, THREE.RedFormat);
    ramp.minFilter = ramp.magFilter = THREE.NearestFilter;
    ramp.generateMipmaps = false;
    ramp.needsUpdate = true;
  }
  return ramp;
}
// …
// Warm fresnel rim on top of whatever the source emitted
const facing = dot(normalView, positionViewDirection).clamp(0, 1);
const fresnel = pow(float(1).sub(facing), 3);
m.emissiveNode = materialEmissive.add(vec3(1, 0.72, 0.42).mul(fresnel.mul(rim)));
engine/looks/memory.ts

Replacements are cached per source material, so every clone of a prop in a scene shares one material, and anything unlit, like the CRT screens, is left alone. Anything see-through also stops casting a solid shadow.

Ink from depth and normals

The outlines are drawn after the scene, from two things the scene pass records. The first is depth. For every pixel the outline step looks at its four neighbours and asks whether the depth bends. The trick is to use one over the view depth rather than depth itself: 1/z changes linearly across any flat surface on screen, so its second difference is exactly zero on a flat face and spikes where two surfaces meet or where an object stands in front of another. That's a crease or a silhouette, and it gets ink.

The second is the surface normal. Depth alone misses a fold where two faces meet at the same distance, so the scene pass also writes each pixel's view-space normal into an extra render target, and a sharp change in direction between neighbours gets ink too. The line thickness is in pixels, scaled by the device pixel ratio, so it looks the same on a phone and a monitor.

/** Dark felt-tip outlines from depth creases and normal breaks in the scene pass. */
export function toonOutline(color: Node<"vec4">, depth: TextureNode, aux: TextureNode, coord: Node<"vec2">, u: LookUniforms): Node<"vec4"> {
  const step = vec2(1).div(screenSize).mul(max(u.outline.mul(u.pixelRatio).mul(0.5), 0.5));
  const at = [vec2(0, 0), vec2(-1, 0), vec2(1, 0), vec2(0, -1), vec2(0, 1)].map((o) => coord.add(o.mul(step)));
  // 1/z is linear across a plane in screen space, so its Laplacian is zero on flat faces
  const w = at.map((p) => float(-1).div(perspectiveDepthToViewZ(depth.sample(p).r, u.near, u.far)));
  const lap = abs(w[1].add(w[2]).sub(w[0].mul(2))).add(abs(w[3].add(w[4]).sub(w[0].mul(2)))).div(w[0]);
  const n = at.map((p) => unpackRGBToNormal(aux.sample(p).rgb));
  const bend = float(4).sub(dot(n[0], n[1]).add(dot(n[0], n[2])).add(dot(n[0], n[3])).add(dot(n[0], n[4])));
  const edge = max(smoothstep(0.06, 0.2, lap), smoothstep(0.35, 0.9, bend)).mul(smoothstep(0, 0.5, u.outline));
  const ink = vec3(0.03, 0.02, 0.018);
  return vec4(mix(color.rgb, ink, edge.mul(0.92)), color.a);
}
engine/looks/effects.ts

The ink isn't black. It's a very dark warm brown, which is what a black felt tip looks like on cardboard once it's dried.

if (has.has("grade") || has.has("toon-outline")) {
  const targets = mrt({ output, [AUX]: vec4(packNormalToRGB(normalView), 1) });
  targets.setClearColor(AUX, new THREE.Color(0.5, 0.5, 1), 1);
  scenePass.setMRT(targets);
  scenePass.getTexture(AUX).type = THREE.UnsignedByteType;
  aux = scenePass.getTextureNode(AUX);
}
engine/looks/pipeline.ts: the scene pass writes normals alongside colour when outlines or the grade need them.

Paper, without a texture

The card underneath everything is procedural, so there's no texture to download and no seam to hide. Soft blotches come from two scales of value noise. Fibres are the same noise stretched long and thin at two angles and sharpened. The tooth, the fine grain you'd feel under a fingertip, is a fine scatter of random values. Those combine into a sheet of warm off-white that's multiplied into the frame, by an amount the look controls.

/** Procedural paper: soft blotches, long fibres and fine tooth, multiplied in. */
export function paper(c: Node<"vec3">, amount: F, coord: Node<"vec2">): Node<"vec3"> {
  const p = coord.mul(vec2(screenSize.x.div(screenSize.y), 1)).mul(640);
  const blotch = valueNoise(p.mul(0.011)).mul(0.6).add(valueNoise(p.mul(0.043)).mul(0.4));
  const r1 = vec2(dot(p, vec2(0.8, 0.6)), dot(p, vec2(-0.6, 0.8)));
  const r2 = vec2(dot(p, vec2(0.28, -0.96)), dot(p, vec2(0.96, 0.28)));
  const fibres = pow(max(valueNoise(r1.mul(vec2(0.05, 0.8))), valueNoise(r2.mul(vec2(0.05, 0.8)))), 4);
  const tooth = rand(floor(p.mul(0.7)));
  const tone = blotch.mul(0.55).add(tooth.mul(0.25)).sub(fibres.mul(0.45)).add(0.2);
  const sheet = mix(vec3(0.86, 0.8, 0.7), vec3(1, 0.99, 0.96), clamp(tone, 0, 1));
  return c.mul(mix(vec3(1), sheet, amount));
}
engine/looks/effects.ts

Put together, the chapter's look is four steps and a handful of numbers: a grade that pushes saturation up by a fifth, outlines at 2.2 pixels, paper at 65 percent and a light vignette.

"we-made-our-own": look("we-made-our-own", {
  // Over-saturated felt tip on cereal-box cardboard
  post: ["grade", "toon-outline", "paper", "vignette"],
  params: { exposure: 1.08, contrast: 1.05, saturation: 1.2, temperature: 0.08, outline: 2.2, paper: 0.65, vignette: 0.15 },
  uiSkin: "felt-tip",
  background: "#e2cfa9",
  music: null,
  ambience: "kitchen",
}),
engine/looks/presets.ts

The same outline step turns up again in Disk 6, thinner and paired with posterising to six levels per channel, the old web-safe palette, and there it reads as Flash-era vector art instead of felt tip. One effect, two decades, depending on what it's paired with.

The 2D minigames in this chapter, the homemade deduction game and the lap of the homemade board, don't use the shader at all. They draw their own wobbly outlines from a seeded random source with one seed per element, so every card has a slightly different hand-drawn edge that stays the same each time you see it.

What I'd do differently

The outlines come from the whole frame, so they can't tell a prop from the table it's standing on. Every edge gets the same pen. Real felt-tip drawings have heavier silhouettes and lighter inside lines, and the next version would write an object ID into the extra render target alongside the normal, so a silhouette between two objects can be drawn thicker than a crease within one.

The other honest note is about phones. On the low quality tier the pipeline keeps only the grade, the vignette and the fade, so the ink and the paper both go. The chapter still works, but it looks like a toy shop rather than a kitchen table. And none of this will sell the chapter as well as scans of real drawings would, which is the one thing the spec asks for that no shader can supply.