Phase 7: grow the scene library to 16

Ten new scenes, with 'minimal' first: the family was empty, so intros and
breakdowns fell through to flow/organic and every track opened at full
density. Quiet sections now land on a restful family 48/48 times across 24
seeds, 28 of them minimal.

New: Horizon Lines, Spectrum Sculpture, Slow Orb (minimal); Curl Flow
(flow); Plasma Bloom, Metaballs (organic); Kaleido Tunnel, Moiré Grid
(geometric); Ridge Terrain (structural); Scan Tear (glitch).

Four real bugs, three of which the existing gates could not have caught:

1. SHADER PROGRAMS LINK ASYNCHRONOUSLY. three.js uses
   KHR_parallel_shader_compile, so draws against an unlinked program render
   wrong. The heaviest scene had its first TEN frames differ from every
   later render of the same frames. Preview hides this entirely; export
   renders each frame once, so those frames would ship broken. Added
   Engine.prime() — WebGLRenderer.compile() plus a discarded warm frame —
   and the exporter now primes before encoding. Rendering a throwaway frame
   and reading it back is NOT sufficient; measured, it left 3-5 frames wrong.

2. Moiré Grid declared a param on u_width, which the shader contract already
   uses for stereo width. GLSL redefinition, and the only symptom was a
   black frame. Lint now rejects any param uniform colliding with the
   contract.

3. Spectrum Sculpture strobed at 4 flashes/s. Two causes: rotation measured
   in turns meant bar-crossing frequency was bars x rate (82 bars put a
   slow-looking 0.12 turns/s at 10 Hz), and hard band-tier boundaries made
   every bar switch band simultaneously. Rotation is now in segment units so
   the rate IS the crossing frequency, bands interpolate, and the range is
   capped where the flash meter measures zero.

4. Particle Field was being chosen as a primary background despite being
   mostly empty by design. Scenes now declare role: 'accent'; those are
   never primary and are judged on variance rather than luminance.

Three checks were themselves wrong and were rebuilt: mean-distance metrics
unfairly fail sparse scenes for being tasteful rather than static, so
"animates" and "no duplicates" now use max channel delta.

PLAN.md §1 gains two refinements: programs must be primed before the first
frame, and even same-machine the heaviest shaders vary by one LSB under
differing GPU load — so the per-scene criterion is max delta <= 1 rather
than an identical hash. A real bug scores in the tens there.

Full suite 67/67 across all seven phases. Worst 4K frame 3.8ms,
worst flash rate 0/s, worst determinism delta 1/255. Adds README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-05 18:40:56 +02:00
co-authored by Claude Opus 5
parent 8a94a3f3a5
commit 9d15c3cf49
24 changed files with 1234 additions and 10 deletions
+59
View File
@@ -45,6 +45,7 @@ export class Compositor {
this.fade = 1;
this.soloIndex = -1; // debug: render one layer alone
this.postEnabled = true;
this._primed = new WeakSet();
this._buildTargets();
this._buildMaterials();
@@ -124,10 +125,68 @@ export class Compositor {
* about to be reused (and recompile them on the way back).
*/
setLayers(layers) {
for (const layer of layers) {
if (!this._primed.has(layer)) {
this._primeLayer(layer);
this._primed.add(layer);
}
}
this.layers = layers;
return this;
}
/**
* Force a layer's shader program to finish linking before it is used for real.
*
* three.js links programs through KHR_parallel_shader_compile, so the first
* draws after a material is created can run against a program that is not
* ready and produce wrong output. Measured on the heaviest scene in the
* library, the first TEN frames rendered differently from every later render
* of the same frames. Preview hides this — the frames go by and the next pass
* is correct — but an export renders each frame exactly once, so those frames
* would ship broken.
*
* Rendering a throwaway frame and reading it back is NOT sufficient: measured,
* it left 3-5 frames still wrong. WebGLRenderer.compile() is the API that
* actually waits for the link, and it clears the problem completely.
*/
_primeLayer(layer) {
try {
if (layer.material) this.renderer.compileMaterial(layer.material);
else if (layer.scene && layer.camera) this.renderer.compileScene(layer.scene, layer.camera);
} catch (err) {
console.warn('[compositor] priming failed for', layer.module && layer.module.name, err);
}
}
/**
* Bring the whole chain to a state where the next rendered frame is correct.
*
* Shader programs link asynchronously (KHR_parallel_shader_compile), and until
* they are ready a draw produces wrong output. compile() covers the programs;
* the discarded frame covers everything else that is lazily created on first
* use. Cheap, and it converts "the first few frames may be wrong" into "the
* first few frames were thrown away".
*
* The exporter calls this before encoding anything, because an export renders
* each frame exactly once and has no second chance to get frame 0 right.
*/
prime(ctx = null) {
const materials = [
this.blendMaterial, this.feedbackMaterial, this.brightMaterial,
this.blurMaterial, this.compositeMaterial, this.copyMaterial,
];
for (const material of materials) {
try { this.renderer.compileMaterial(material); } catch { /* non-fatal */ }
}
for (const layer of this.layers) this._primeLayer(layer);
if (ctx) {
try { this.render(ctx); } catch { /* non-fatal */ }
}
this.reset();
}
setPost(post) {
this.post = { ...this.post, ...post };
return this;
+12 -1
View File
@@ -68,6 +68,16 @@ export class Engine {
this.compositor.layers.forEach((l) => l.setPalette(colors));
}
/**
* Compile every shader and discard a warm frame, so the next frame rendered
* is correct. Required before any frame-exact use (export, hashing).
*/
prime(frame = 0) {
this.timeline.seek(frame);
this.compositor.prime({ timeline: this.timeline, features: this.featuresAt(frame) });
return this;
}
/** Render exactly one frame at the timeline's current position. */
renderCurrent() {
const features = this.featuresAt(this.timeline.frame);
@@ -99,7 +109,8 @@ export class Engine {
* return a hash per frame. Sequential and reset-first, so the result depends
* only on the inputs — this is the primitive every determinism check uses.
*/
hashRun(start, count, { reset = true } = {}) {
hashRun(start, count, { reset = true, prime = true } = {}) {
if (prime) this.prime(start);
if (reset) this.compositor.reset();
const hashes = [];
for (let i = 0; i < count; i++) {
+20
View File
@@ -89,6 +89,26 @@ export class Renderer {
this.gl.setRenderTarget(null);
}
/**
* Force a material's shader program to compile and link NOW.
*
* three.js links through KHR_parallel_shader_compile, so a freshly created
* material can be drawn with a program that is not ready yet, producing wrong
* frames until it is. Rendering a throwaway frame and reading it back does not
* reliably wait for the link; WebGLRenderer.compile() does.
*/
compileMaterial(material) {
const previous = this.quadMesh.material;
this.quadMesh.material = material;
this.gl.compile(this.quadScene, this.quadCamera);
this.quadMesh.material = previous;
}
/** Same, for a 3D layer's own scene. */
compileScene(scene, camera) {
this.gl.compile(scene, camera);
}
readPixels(target) {
const w = target ? target.width : this.width;
const h = target ? target.height : this.height;