research brief · fed to the builders

1-bit 3D

SDF math for the lattice, the lighting model, and dithering without shimmer, which is the actual hard problem.

1. Prior art, and what to actually take from it

Obra Dinn (Lucas Pope). Documented in his 2019 GDC talk "The Return of the Obra Dinn: 1-Bit Art, Sound, and Design" and his devblog. Two mechanisms matter here, and I'll flag confidence on each:

Inigo Quilez's raymarching vocabulary (iquilezles.org — "distfunctions", "rmandnorm", the smooth-min articles) supplies the primitives below almost verbatim; this is the standard toolkit, not this-repo-specific.

This repo's sphere-lattice.html is the closest working reference and should be treated as the load-bearing prior art, not merely inspiration:

2. SDF math for the lattice (if you do raymarch)

Sphere: sdSphere(p, r) = length(p) - r

Torus (if a ring motif is wanted instead of/alongside spheres): sdTorus(p, vec2 t) { vec2 q = vec2(length(p.xz) - t.x, p.y); return length(q) - t.y; }

Smooth union (IQ's polynomial smin — needed only if spheres should merge rather than sit tangent/overlapping as flat circles):

float opSmoothUnion(float d1, float d2, float k){
  float h = clamp(0.5 + 0.5*(d2-d1)/k, 0.0, 1.0);
  return mix(d2, d1, h) - k*h*(1.0-h);
}

k on the order of 0.15–0.3 × r gives a visible fillet without turning the lattice into a blob.

Flower-of-life lattice — do not domain-repeat it. A true infinite hex-tile SDF (skew coordinates into a hex basis, round to nearest lattice point, test two candidate cells) is the textbook IQ trick, but it's the wrong tool here: flower-of-life is a bounded, ring-counted pattern (1, 7, 19, 37 circles at rings 0/1/2/3), not an infinite tiling. Enumerate centers explicitly instead — cheaper, exactly matches the motif, and is what sphere-lattice.html already does at ring 1 (7 spheres).

Hex-ring center generation (2D, then optionally lift to 3D):

// basis for a hex lattice, spacing s (s ≈ 1.9r–2r; 1.94r in the existing preview
// so spheres visibly overlap and read as one body rather than touching tangent)
const a1 = [1, 0], a2 = [0.5, Math.sqrt(3)/2];
function hexRing(k, s) {
  if (k === 0) return [[0,0]];
  const pts = [];
  let x = k, y = 0;                      // start at corner of ring k
  const dirs = [[-1,1],[-1,0],[0,-1],[1,-1],[1,0],[0,1]];
  for (const [dx,dy] of dirs) for (let step=0; step<k; step++){
    pts.push([x,y]); x+=dx; y+=dy;
  }
  return pts.map(([i,j]) => [
    s*(i*a1[0]+j*a2[0]), s*(i*a1[1]+j*a2[1])
  ]);
}
// flower-of-life to ring N: centers = rings 0..N flattened, 1+6+12+18N total

Ring 0→1: 7 centers (this repo's existing motif). Ring 0→2: 19. Ring 0→3: 37. Pass centers as a uniform vec3 u_centers[MAX] (WebGL1 guarantees ≥128 fragment vec4 uniforms on the min spec, so 37 vec3 is safe; budget ~40 for headroom) or, per §4, skip the shader entirely and evaluate in JS.

For a literal 3D lattice (not spheres flattened on a plane) extrude a second hex layer along z at z = ±s·sqrt(2/3) with an FCC offset (each atom of the upper layer sits over a "hole" of three lower spheres) — this is exactly hexagonal/face-centered close packing, which is what the HUD label in sphere-lattice.html ("hexagonal close pack") is already naming, just not yet built past one layer.

Union over all N spheres in the march:

float map(vec3 p){
  float d = 1e5;
  for (int i=0;i<MAX;i++){
    if (i>=N) break;                 // GLSL ES 1.00: bound the loop, break dynamically
    d = min(d, sdSphere(p - u_centers[i], u_r));   // or opSmoothUnion for fillets
  }
  return d;
}

Normal (IQ's 4-tap tetrahedron trick — cheaper than 6-tap central differences, matters a lot under software GL):

vec3 calcNormal(vec3 p){
  const vec2 e = vec2(1.0,-1.0)*0.5773*0.0005;
  return normalize(e.xyy*map(p+e.xyy) + e.yyx*map(p+e.yyx) +
                    e.yxy*map(p+e.yxy) + e.xxx*map(p+e.xxx));
}

Gyroid, as the named alternative. Implicit surface f(p) = sin(x)cos(y) + sin(y)cos(z) + sin(z)cos(x); commonly used shader-side approximate distance (not exact — it's a scalar field, not a true SDF, so under-relax the march step):

float sdGyroid(vec3 p, float scale, float thickness, float bias){
  p *= scale;
  float g = sin(p.x)*cos(p.y) + sin(p.y)*cos(p.z) + sin(p.z)*cos(p.x) - bias;
  return abs(g) / (scale * 1.732) - thickness;   // 1.732 ≈ sqrt(3), a gradient-magnitude bound
}

March with t += map(pos) * 0.7 (under-relaxed) because the true Lipschitz bound of this approximation is loose; overshoot pierces the surface.

3. Lighting model

Single key light, Lambert, no specular, no ambient fill (canon forbids grey fills, and a flat ambient term is exactly a grey fill in disguise once dithered):

diff = max(0, dot(N, L))
v = base + diff * range     // e.g. 0.18 + diff*0.62, per sphere-lattice.html —
                             // never let v hit pure 1.0 or the sphere vanishes
                             // into the ground on paper substrate

Rim term for silhouette legibility without a stroke: v *= 0.52 + 0.48 * N.z (view-space z of the normal — cheap, no fresnel exponent needed).

Occlusion between spheres in the lattice is free if you keep the "later-drawn/closer-hit wins" ordering rather than doing real shadow rays — for a small fixed lattice this is both cheaper and, per Obra Dinn's whole ethos, indistinguishable at 1-bit quantization. Real shadow rays (map() march from surface point toward L) are affordable too at N≤37 spheres but are the first thing to cut if the frame budget is tight.

Light direction should be near-fixed, pointer-perturbed at most (law 7: perturb, never drive) — exactly the existing mx, my pointer-nudged light in sphere-lattice.html. A light that free-rotates with the object maximizes shimmer (§4); a light that's essentially locked and lets the object rotate under it keeps luminance changes slow and directional (a crawling terminator line) rather than chaotic.

4. Dithering without shimmer — the actual hard problem

Statement of the problem. Ordered dithering is bit = luminance(pixel) > threshold(screen_x, screen_y). The threshold pattern is fixed in screen space. If luminance is a function of something that moves relative to the screen (object rotation under a fixed light, or a moving light under a fixed object), then every pixel near a threshold boundary flips unpredictably frame to frame — worst on smoothly curved surfaces (spheres, gyroids) because a huge fraction of the visible surface sits in the shallow-gradient region near any given luminance band, so a huge fraction of pixels are "near a boundary" at any moment. This reads as boiling/crawling static over the lit hemisphere. It is the single named risk in the brief and it's exactly the failure mode the sourced techniques all exist to prevent.

What actually fixes it, ranked by leverage:

  1. Move the high-frequency term into object space, not screen space. This is the one fix that generalizes and is already proven in this repo: sphere-lattice.html's occlusion fbm is sampled on the rotated local normal (rx, rz after the rotation matrix), so a given point on the sphere's surface always gets the same occlusion value regardless of where it lands on screen. The Bayer threshold itself stays in screen space (thr(px, py)) — but because the value being thresholded now only changes slowly and smoothly (rim/Lambert falloff, or a rotating occlusion patch crossing a fixed screen pixel), the threshold crossing at any pixel happens roughly once per rotation pass rather than every frame. Do the same for the lattice: any high-frequency shading term (per-sphere occlusion, gyroid isosurface detail) must be sampled in a coordinate frame attached to the geometry, propagated through the march as a hit-local coordinate — not derived from gl_FragCoord after the fact. This is the easy mistake: both mnBayer8 and field.BEFORE.glsl take gl_FragCoord.xy as their only spatial input, which is correct for a flat, non-rotating field and wrong if copy-pasted onto a rotating sphere surface.
  2. **Keep the Bayer threshold lookup itself in screen space, but keep the underlying rotation slow.** Shimmer amplitude scales with dL/dt at the threshold boundary. The canon's own motion law already mandates this register — --beat: 1.6s, --sweep: 10s, "a climate, not a plasma" — so a lattice rotation on the order of 60–120s per revolution is both in-doctrine and the correct mitigation. A one-second spin would shimmer badly under pure screen-space Bayer even with everything else right.
  3. Cell-quantize before dithering, matching the dither matrix to the render cell 1:1, exactly as DESIGN.md §8 already mandates for the image path ("Dither buffers render at an integer cell scale and upscale with image-rendering: pixelated") and as sphere-lattice.html does at CELL = 2. This doesn't remove luminance-driven shimmer but removes a second, independent source: sub-cell antialiasing noise. Every dithered cell should be one deterministic decision, not an average of several screen pixels each separately crossing threshold.
  4. Bayer over blue/white noise, for this specific problem — not for the reason DESIGN.md §8 gives (determinism/tileability of a static photograph), but because Bayer's periodic, spatially-correlated threshold levels produce fewer isolated single-pixel flicker events than an equal-quality blue-noise field would under a slowly drifting luminance. This is a real but secondary effect; §8's determinism argument is the dominant reason to keep using it here regardless, and the two reasons happily agree.

What is hard, concretely, for the builder:

5. What this brief does not settle

Whether the deliverable should raymarch at all is a judgment call the builder has to make against the actual frame budget once measured live (§16's own doctrine: measured, not asserted) — the recommendation above (analytic spheres, no march) is the safe default; true SDF raymarching with smooth unions is possible but is the part of this brief most likely to blow the ≤7ms budget under swiftshader, and that should be verified with performance.now() around the actual draw call before committing to it, not assumed from this text.