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:
-
Object-space dithering for rotating/moving geometry (high confidence — this is the headline technique he describes, and it's exactly what
sphere-lattice.htmlin this repo already does for its occlusion noise: the fbm term is sampled on the rotated sphere normal, not on screen coordinates, so the dark regions turn with the sphere instead of sliding across it). Pope's insight, generalized: if the dither threshold a surface point resolves against is a function of screen position, but the luminance under that point is a function of object rotation, the two clocks disagree — a pixel flips on and off as luminance crawls past a threshold that isn't moving with it. That's shimmer. Anchor the source of high-frequency variation (dither or the value being dithered) to the same space, and the problem mostly disappears. - Pixel-locked / quantized camera movement (medium confidence — reported in interviews and the talk as part of how Obra Dinn avoids subpixel judder generally; I'm less certain of the exact implementation detail than the object-space point above). The idea carries over regardless of the specific claim: sub-pixel motion is the enemy. Anything that nudges geometry by a fraction of a screen pixel per frame forces edge pixels to recompute a luminance-vs-threshold decision that was already stable, for no visible gain.
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:
-
It does not raymarch. It computes each of 7 spheres analytically (circle-in-screen-space test,
d² < r², later-drawn-wins for occlusion) — no SDF, no march loop. -
Its "lighting" is Lambert (
max(0, N·L)) compressed into0.18 + diff*0.62so the sphere never goes full white, plus a rim term0.52 + 0.48*nzn, plus a procedural occlusion mask — fbm noise sampled on the rotated normal, hard-thresholded at two levels (0.600,0.555) taken from the measured p85/p72 of the noise distribution over a sphere — regions, not texture. -
Its "dithering" is per-pixel Bayer-8 (
bayerThreshold(8)) applied to that single luminance value, atCELL = 2CSS px per dithered cell,image-rendering: pixelatedon upscale. - It is already CPU-side Canvas 2D, at 2px cells, and this is not incidental — see §4.
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:
-
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, rzafter 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 fromgl_FragCoordafter the fact. This is the easy mistake: bothmnBayer8andfield.BEFORE.glsltakegl_FragCoord.xyas their only spatial input, which is correct for a flat, non-rotating field and wrong if copy-pasted onto a rotating sphere surface. -
**Keep the Bayer threshold lookup itself in screen space, but keep the underlying rotation slow.** Shimmer amplitude scales with
dL/dtat 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. -
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 assphere-lattice.htmldoes atCELL = 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. - 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:
-
Raymarch cost under software GL. This environment runs
--use-gl=swiftshader— no GPU, ~10x slower than any real device, and the budget is ≤7ms/frame at 1440×900. A 19–37 spheremap()evaluated per step, 40–80 steps, at ~1.3M pixels, is very plausibly 50–100x over budget on swiftshader. This is the single highest-risk item in the whole brief. Recommendation: don't raymarch.sphere-lattice.htmlalready proves the cheaper path works — per output cell, analytic ray-sphere (or here, screen-space circle) test against all N centers, nearest/topmost wins, no SDF, no march. This is correct for flower-of-life because there's no blending requirement — the motif is discrete tangent/overlapping circles, not a fused metaball. Reach for real raymarching only if smooth-union fillets between spheres, or the gyroid variant, are specifically wanted; both raise the cost and the shimmer risk simultaneously (gyroid especially — its isosurface has many separate high-curvature sheets, i.e. many more silhouette pixels than a handful of spheres, which is directly more shimmer-prone). -
GLSL ES 1.00 loop constraints. No dynamic loop bounds on some drivers —
for (int i=0;i<MAX;i++){ if(i>=N) break; }, never a variable in the comparison directly. -
Reduced-motion correctness. Freezing at
t=3.0must reproduce a pixel-identical frame on every load — rotation, wander, and any noise seed must be pure functions oftand a fixed seed, neverMath.random()at init. This is the same "same seed, same output" law the field shader already satisfies; the lattice piece has to satisfy it independently since it's new code. -
The living dot is still required and is cheap to bolt on. None of the above touches essence; composite the existing dot recipe (independent fbm-driven wander,
sin⁴(t·1.55)pulse, drawn last, unaffected by the lattice's dithering) on top exactly asfield.BEFORE.glsldoes — reuse, don't reinvent. -
Paper substrate. Every luminance constant above (
0.18/0.62Lambert range, occlusion thresholds0.600/0.555) was tuned against a#050505ground; DESIGN.md §16 marks "any surface in paper mode" as untested, and this piece inherits that gap — the HI/LO/GROUND swap insphere-lattice.html(242/11/242vs232/5/5) is the existing pattern to extend, not a solved problem to assume.
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.