diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js index f254859..fb81a1c 100644 --- a/flow-state/src/checks/main.js +++ b/flow-state/src/checks/main.js @@ -108,6 +108,7 @@ async function main() { const { lines, ok, headline } = await experimentReportLines({ songs: Number(params.get('count')) || 6, probes: Number(params.get('probes')) || 4, + repeats: Number(params.get('repeats')) || 3, }); out.innerHTML = `
${lines.join('\n')}`;
summaryEl.textContent = `${headline} · ${((Date.now() - started) / 1000).toFixed(1)}s`;
diff --git a/flow-state/src/checks/variety/print.js b/flow-state/src/checks/variety/print.js
index 26b1858..a6df653 100644
--- a/flow-state/src/checks/variety/print.js
+++ b/flow-state/src/checks/variety/print.js
@@ -209,7 +209,7 @@ export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) {
* their own content. Three arms, because two would not distinguish "stages are
* better" from "a small pool is better".
*/
-export async function experimentReportLines({ songs = 6, probes = 4 } = {}) {
+export async function experimentReportLines({ songs = 6, probes = 4, repeats = 3 } = {}) {
const { scenes } = await import('../../scenes/registry.js');
const byName = (n) => scenes.find((m) => m.name === n);
@@ -239,38 +239,59 @@ export async function experimentReportLines({ songs = 6, probes = 4 } = {}) {
lines.push(' Higher observed = two songs that look like different work.');
lines.push('');
+ // Repeats, with error bars. The first run of this comparison used seven
+ // songs and put stages 14% ahead; at twelve songs the ordering flipped. A
+ // difference that changes sign with the sample is a difference that has to
+ // be reported with its spread or not at all.
const results = [];
for (const arm of arms) {
- await new Promise((r) => setTimeout(r, 0));
- const r = measureSongVariety({ songs, probes, pool: arm.pool });
- results.push({ arm, r });
+ const runs = [];
+ for (let k = 0; k < repeats; k++) {
+ await new Promise((r) => setTimeout(r, 0));
+ runs.push(measureSongVariety({
+ songs, probes, pool: arm.pool, seedSalt: k * 7919,
+ }));
+ }
+ results.push({ arm, runs, r: runs[0] });
}
- lines.push(' arm floor observed spread ratio');
- lines.push(' ' + '-'.repeat(78));
- for (const { arm, r } of results) {
- const spread = r.observed - r.floor;
- lines.push(` ${arm.label.padEnd(44)}${r.floor.toFixed(4)} ${r.observed.toFixed(4)}` +
- ` ${spread >= 0 ? '+' : ''}${spread.toFixed(4)} ${(r.observed / r.floor).toFixed(3)}`);
+ const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length;
+ const half = (a) => (Math.max(...a) - Math.min(...a)) / 2;
+
+ lines.push(` arm floor observed spread (${repeats} runs)`);
+ lines.push(' ' + '-'.repeat(80));
+ for (const { arm, runs } of results) {
+ const spreads = runs.map((x) => x.observed - x.floor);
+ lines.push(` ${arm.label.padEnd(44)}${mean(runs.map((x) => x.floor)).toFixed(4)}` +
+ ` ${mean(runs.map((x) => x.observed)).toFixed(4)}` +
+ ` ${mean(spreads) >= 0 ? '+' : ''}${mean(spreads).toFixed(4)} ±${half(spreads).toFixed(4)}`);
}
lines.push('');
- for (const { arm, r } of results) {
+ const gap = mean(results[0].runs.map((x) => x.observed - x.floor))
+ - mean(results[1].runs.map((x) => x.observed - x.floor));
+ const noise = Math.max(
+ half(results[0].runs.map((x) => x.observed - x.floor)),
+ half(results[1].runs.map((x) => x.observed - x.floor)));
+ lines.push(` stages minus legacy: ${gap >= 0 ? '+' : ''}${gap.toFixed(4)} against a noise band of ±${noise.toFixed(4)}`);
+ lines.push(` → ${Math.abs(gap) > noise * 2 ? 'a real difference' : 'INDISTINGUISHABLE at this sample size'}`);
+ lines.push('');
+
+ for (const { arm, runs, r } of results) {
lines.push(` ${arm.label}`);
- for (const [name, b] of Object.entries(r.byBlock)) {
- lines.push(` ${name.padEnd(8)} between ${b.between.toFixed(3)}`);
+ for (const [name] of Object.entries(r.byBlock)) {
+ const v = runs.map((x) => x.byBlock[name].between);
+ lines.push(` ${name.padEnd(8)} between ${mean(v).toFixed(3)} ±${half(v).toFixed(3)}`);
}
- lines.push(` coupling ${r.coupling.toFixed(2)}`);
- const worst = r.pairs[0];
- lines.push(` closest pair ${worst.a} ≈ ${worst.b} at ${worst.total.toFixed(3)}`);
+ const c = runs.map((x) => x.coupling);
+ lines.push(` coupling ${mean(c).toFixed(2)} ±${half(c).toFixed(2)}`);
lines.push('');
}
- const stages = results[0].r, legacy = results[1].r;
- const ok = (stages.observed - stages.floor) > (legacy.observed - legacy.floor);
- const headline = `stages spread ${(stages.observed - stages.floor).toFixed(4)} ` +
- `vs legacy ${(legacy.observed - legacy.floor).toFixed(4)} — ` +
- (ok ? 'the inversion helps' : 'no improvement');
+ const ok = gap > noise * 2;
+ const headline = `stages ${gap >= 0 ? '+' : ''}${gap.toFixed(4)} vs legacy, noise ±${noise.toFixed(4)} — ` +
+ (Math.abs(gap) > noise * 2 ? (gap > 0 ? 'the inversion helps' : 'the inversion hurts')
+ : 'indistinguishable');
return { lines, ok, headline };
}
diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js
index 1267d10..e0a701c 100644
--- a/flow-state/src/engine/shader-contract.js
+++ b/flow-state/src/engine/shader-contract.js
@@ -121,6 +121,13 @@ export const IDENTITY_UNIFORMS = {
u_inkHatchScale: 'float',
u_inkOutline: 'float', // 0..1 outline strength on top of the fill
u_inkPosterize: 'float', // 0 = off, else levels
+
+ u_latKind: 'float', // index into Identity.LATTICES
+ u_latJitter: 'float', // how far off the lattice things sit
+ u_latSpread: 'float', // how much of the frame it occupies
+ u_latScaleSpread: 'float', // 0 = all one size, 1 = a few large, many small
+ u_latScaleBias: 'float', // + puts the large ones in the middle
+ u_latScale: 'float', // the song's element size, ~0.1 tiny .. ~0.9 huge
};
export const FRAME_UNIFORMS = [
@@ -350,6 +357,61 @@ float castChorus(vec2 q) {
u_chorusNotchN, u_chorusNotchD, u_chorusHollow);
}
+// --- the staging -----------------------------------------------------------
+// Where things go. Shared, so two stages in one video agree about composition —
+// and so the SIZE HIERARCHY is a decision the song makes once rather than one
+// each stage makes for itself. The first four stages all placed similarly-sized
+// elements, which left feature scale out of the measurement entirely.
+
+/** Node i of n on the song's lattice: xy position, z scale multiplier. */
+vec3 stageNode(float i, float n) {
+ vec2 h = hash22(vec2(i * 1.37 + 3.1, i * 0.71 + 7.7));
+ float total = max(n, 1.0);
+ vec2 pos;
+
+ if (u_latKind < 0.5) { // grid
+ float cols = max(1.0, floor(sqrt(total) + 0.5));
+ float rows = max(1.0, ceil(total / cols));
+ pos = vec2((mod(i, cols) / max(cols - 1.0, 1.0) - 0.5) * 2.0,
+ (floor(i / cols) / max(rows - 1.0, 1.0) - 0.5) * 2.0);
+ } else if (u_latKind < 1.5) { // radial rings
+ float rings = max(1.0, floor(sqrt(total * 0.5) + 0.5));
+ float ring = mod(i, rings) + 1.0;
+ float a = (i / total) * 6.28318530718 * 3.0;
+ pos = vec2(cos(a), sin(a)) * (ring / rings);
+ } else if (u_latKind < 2.5) { // spiral, golden angle
+ float a = i * 2.39996323;
+ pos = vec2(cos(a), sin(a)) * sqrt(i / total);
+ } else if (u_latKind < 3.5) { // scatter
+ pos = (h - 0.5) * 2.0;
+ } else { // strata
+ float rows = max(1.0, floor(total / 4.0 + 0.5));
+ pos = vec2((h.x - 0.5) * 2.0,
+ (mod(i, rows) / max(rows - 1.0, 1.0) - 0.5) * 2.0);
+ }
+
+ pos += (h - 0.5) * u_latJitter;
+ pos *= u_latSpread;
+ // Sits on the same ground every other scene in the track sits on.
+ pos.y += sigHorizonY() * 0.3;
+
+ // A power law when the song wants a hierarchy, near-uniform when it does
+ // not. Biased toward the middle or the edges.
+ float u = max(hash11(i * 7.13 + 1.7), 0.001);
+ float size = mix(1.0, pow(u, 1.0 + u_latScaleSpread * 2.5) * 2.4, u_latScaleSpread);
+ size *= 1.0 + u_latScaleBias * (0.5 - length(pos) * 0.5);
+
+ // The song's own element size, relative to the neutral 0.35. A stage
+ // multiplies its own size param by this rather than choosing outright, so
+ // one song is made of a few huge forms and another of many small ones.
+ size *= u_latScale / 0.35;
+
+ return vec3(pos, max(size, 0.05));
+}
+
+/** The song's element size as a multiplier a stage applies to its own size. */
+float stageScale() { return u_latScale / 0.35; }
+
// --- the ink ---------------------------------------------------------------
// How the cast is drawn. Changes every pixel of every stage at once, and does
// it structurally rather than chromatically — which is the point, since colour
diff --git a/flow-state/src/look/Identity.js b/flow-state/src/look/Identity.js
index d87d899..69b7dbc 100644
--- a/flow-state/src/look/Identity.js
+++ b/flow-state/src/look/Identity.js
@@ -29,6 +29,9 @@
/** Fill treatments, as the shader's `u_inkFill` index. */
export const FILLS = ['flat', 'ramp', 'hatch', 'stipple', 'halftone', 'hollow'];
+/** Lattices, as the shader's `u_latKind` index. */
+export const LATTICES = ['grid', 'radial', 'spiral', 'scatter', 'strata'];
+
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
@@ -110,7 +113,47 @@ export function generateIdentity(summary, rng, sections = 4) {
posterize: rng.bool(0.3) ? rng.int(3, 6) : 0,
};
- return { cast: { protagonist, chorus }, ink, character: { angular, intricate, solid } };
+ // STAGING: where things go, and how their sizes are distributed.
+ //
+ // The first four stages separated songs almost entirely on layout while
+ // their feature SCALE collapsed — every stage placed similarly-sized
+ // elements, so size stopped being a variable at all. A shared lattice fixes
+ // both halves: the stages agree with each other about placement, which is
+ // what makes a video look like itself, and the size hierarchy becomes a
+ // decision the song makes rather than one each stage makes for itself.
+ const lattice = {
+ kind: rng.pickWeighted(LATTICES, [
+ 1 + angular * 3, // grid
+ 1 + (1 - angular) * 2, // radial
+ 1 + (1 - angular) * 2, // spiral
+ 2, // scatter
+ 1 + angular * 2, // strata
+ ]),
+ jitter: clamp01(rng.range(0, 0.5) + (1 - angular) * 0.3),
+ spread: rng.range(0.5, 0.85) + dynamic * 0.35,
+ // A few large and many small, or all one size. Busy material earns the
+ // hierarchy; a sparse track wants its elements to be equals.
+ scaleSpread: clamp01(0.15 + intricate * 0.6 + rng.range(-0.2, 0.25)),
+ // Whether the big ones sit in the middle or around the edges.
+ scaleBias: rng.range(-1, 1),
+ // How big the song's elements are AT ALL — a per-song decision rather
+ // than a per-stage one.
+ //
+ // This is the block the measurements kept pointing at. Sharing a cast
+ // lowered the floor as predicted, but `scale` — feature size — came out
+ // consistently WORSE than the legacy arm (0.033 against 0.053, well
+ // outside the noise), because every stage still chose its own element
+ // size from its own param range and every song therefore landed in the
+ // same place. A song made of six huge forms and a song made of four
+ // hundred tiny ones are different videos before anything else is
+ // decided; that decision belongs here.
+ elementScale: 0.35 * 2 ** rng.range(-1.4, 1.4) * (1.25 - intricate * 0.5),
+ };
+
+ return {
+ cast: { protagonist, chorus }, ink, lattice,
+ character: { angular, intricate, solid },
+ };
}
/** Neutral values, so a layer built without an identity renders as it always did. */
@@ -121,6 +164,8 @@ export const NEUTRAL_IDENTITY_UNIFORMS = {
u_chorusNotchN: 0, u_chorusNotchD: 0, u_chorusHollow: 0,
u_inkWeight: 0.3, u_inkEdge: 0.5, u_inkFill: 0, u_inkHatchAngle: 0,
u_inkHatchScale: 80, u_inkOutline: 0, u_inkPosterize: 0,
+ u_latKind: 3, u_latJitter: 0.5, u_latSpread: 0.9,
+ u_latScaleSpread: 0.3, u_latScaleBias: 0, u_latScale: 0.35,
};
/**
@@ -162,6 +207,13 @@ export function identityUniforms(identity, shape = null) {
u_inkFill: FILLS.indexOf(ink.fill),
u_inkHatchAngle: ink.hatchAngle, u_inkHatchScale: ink.hatchScale,
u_inkOutline: ink.outline, u_inkPosterize: ink.posterize,
+
+ u_latKind: LATTICES.indexOf(identity.lattice.kind),
+ u_latJitter: identity.lattice.jitter,
+ u_latSpread: identity.lattice.spread,
+ u_latScaleSpread: identity.lattice.scaleSpread,
+ u_latScaleBias: identity.lattice.scaleBias,
+ u_latScale: identity.lattice.elementScale,
};
}
@@ -175,5 +227,7 @@ export function describeIdentity(identity) {
const ink = identity.ink;
return `cast ${form(a)} + ${form(b)} · ink ${ink.fill}` +
`${ink.outline ? '+outline' : ''}${ink.posterize ? `/${ink.posterize}-tone` : ''}` +
- ` w${ink.weight.toFixed(2)}`;
+ ` w${ink.weight.toFixed(2)} · on ${identity.lattice.kind}` +
+ ` at ${identity.lattice.elementScale < 0.2 ? 'tiny' :
+ identity.lattice.elementScale > 0.6 ? 'huge' : 'mid'} scale`;
}
diff --git a/flow-state/src/scenes/stage/constellation.js b/flow-state/src/scenes/stage/constellation.js
index dda9673..172db64 100644
--- a/flow-state/src/scenes/stage/constellation.js
+++ b/flow-state/src/scenes/stage/constellation.js
@@ -8,7 +8,7 @@ export const constellation = {
name: 'Constellation',
family: 'minimal',
kind: 'fragment',
- consumes: ['cast', 'ink'],
+ consumes: ['cast', 'ink', 'staging'],
texture: 0.6,
traits: ['shape', 'camera', 'space', 'style'],
@@ -42,13 +42,15 @@ vec4 scene(vec2 uv, vec2 p) {
float fi = float(i);
vec2 h = hash22(vec2(fi + u_seed, fi * 1.7));
- // A slow wander around a fixed home, so the constellation keeps its
- // shape while nothing in it is ever quite still.
- vec2 home = (h - 0.5) * 2.0 * u_spread;
- vec2 pos = home + vec2(sin(t + h.x * 6.28), cos(t * 0.83 + h.y * 6.28)) * 0.08;
+ // Homes come from the song's lattice — the stage owns the wander, not
+ // the composition. A slow drift around a fixed home, so the
+ // constellation keeps its shape while nothing in it is ever quite still.
+ vec3 node = stageNode(fi, float(u_count));
+ vec2 pos = node.xy * u_spread
+ + vec2(sin(t + h.x * 6.28), cos(t * 0.83 + h.y * 6.28)) * 0.08;
float pulse = 0.7 + 0.3 * sin(t * 2.0 + fi) * u_twinkle;
- float size = u_size * (0.5 + h.x) * pulse;
+ float size = u_size * node.z * pulse;
vec2 q = (p - pos) / max(size, 1e-3);
float d = castChorus(q) * size;
diff --git a/flow-state/src/scenes/stage/procession.js b/flow-state/src/scenes/stage/procession.js
index 53ded15..c138652 100644
--- a/flow-state/src/scenes/stage/procession.js
+++ b/flow-state/src/scenes/stage/procession.js
@@ -9,12 +9,12 @@ export const procession = {
name: 'Procession',
family: 'structural',
kind: 'fragment',
- consumes: ['cast', 'ink'],
+ consumes: ['cast', 'ink', 'staging'],
texture: 0.4,
traits: ['shape', 'camera', 'style'],
params: {
- columns: { type: 'int', range: [2, 9], default: 4, uniform: 'u_columns', bias: 'density' },
+ columns: { type: 'int', range: [2, 8], default: 4, uniform: 'u_columns', bias: 'density' },
depth: { type: 'int', range: [2, 8], default: 4, uniform: 'u_depth', bias: 'density' },
march: { type: 'float', range: [0.05, 1.2],default: 0.3, uniform: 'u_march', bias: 'motion', rate: true },
size: { type: 'float', range: [0.1, 0.5], default: 0.28,uniform: 'u_size' },
@@ -39,30 +39,29 @@ vec4 scene(vec2 uv, vec2 p) {
// Rows recede toward the horizon the track shares with every other scene.
float horizon = sigHorizonY();
- for (int row = 0; row < 8; row++) {
- if (row >= u_depth) break;
- float fr = float(row);
- // Rows further back are smaller and closer to the horizon.
- float back = fr / max(float(u_depth), 1.0);
- float scale = mix(1.0, 0.35, back * u_recede);
- float y = mix(horizon - 0.9, horizon + 0.15, back);
+ // The formation is the song's lattice; the march is this stage's own idea
+ // of what to do with it. That split is the whole point — identity owns
+ // placement, the stage owns motion.
+ float total = min(float(u_columns * u_depth), 64.0);
- for (int c = 0; c < 9; c++) {
- if (c >= u_columns) break;
- float fc = float(c);
- float lane = (fc / max(float(u_columns) - 1.0, 1.0) - 0.5) * 2.4;
+ for (int i = 0; i < 64; i++) {
+ float fi = float(i);
+ if (fi >= total) break;
- // The march: each row slides at its own pace, wrapping.
- float phase = fract(t * (0.4 + back * 0.6) + fr * u_stagger + fc * 0.017);
- float x = lane + (phase - 0.5) * 0.6;
+ vec3 node = stageNode(fi, total);
+ // Depth from the node's own height in the frame, so recession agrees
+ // with wherever the lattice put it rather than with a row index.
+ float back = clamp((node.y - (horizon - 0.9)) / 1.05, 0.0, 1.0);
+ float scale = mix(1.0, 0.35, back * u_recede) * node.z;
- vec2 q = (p - vec2(x, y)) / max(u_size * scale, 1e-3);
- q = rot(t * u_spin + fr) * q;
+ // The march: each element slides at a pace set by its depth, wrapping.
+ float phase = fract(t * (0.4 + back * 0.6) + fi * u_stagger * 0.13);
+ vec2 pos = node.xy + vec2((phase - 0.5) * 0.6, 0.0);
- float d = castMain(q) * u_size * scale;
- float a = inkMask(d, uv);
- col = mix(col, pal(row + c + 2), a * (0.35 + 0.65 * (1.0 - back)));
- }
+ vec2 q = rot(t * u_spin + fi) * (p - pos) / max(u_size * scale, 1e-3);
+
+ float d = castMain(q) * u_size * scale;
+ col = mix(col, pal(i + 2), inkMask(d, uv) * (0.35 + 0.65 * (1.0 - back)));
}
col += sigGrain(uv);
diff --git a/flow-state/src/scenes/stage/soloist.js b/flow-state/src/scenes/stage/soloist.js
index 8f39395..bd26749 100644
--- a/flow-state/src/scenes/stage/soloist.js
+++ b/flow-state/src/scenes/stage/soloist.js
@@ -8,7 +8,7 @@ export const soloist = {
name: 'Soloist',
family: 'minimal',
kind: 'fragment',
- consumes: ['cast', 'ink'],
+ consumes: ['cast', 'ink', 'staging'],
texture: 0.5,
traits: ['shape', 'camera', 'style'],
@@ -32,6 +32,9 @@ vec4 scene(vec2 uv, vec2 p) {
p = sigCamera(p);
p = sigFolded(p);
float t = u_time * u_turn + u_seed;
+ // Even a close-up obeys the song's sense of scale — pulled toward it rather
+ // than replaced by it, since a soloist that shrinks to a dot stops being one.
+ float size0 = u_size * mix(1.0, stageScale(), 0.5);
vec3 col = mix(pal(0) * 0.14, pal(1) * 0.18, length(p) * 0.5);
@@ -39,17 +42,17 @@ vec4 scene(vec2 uv, vec2 p) {
for (int i = 6; i >= 1; i--) {
if (i > u_echoes) continue;
float fi = float(i);
- float size = u_size * (1.0 + fi * u_echoStep);
+ float size = size0 * (1.0 + fi * u_echoStep);
vec2 q = rot(t * (1.0 - fi * 0.15)) * (p - u_offset) / max(size, 1e-3);
float d = castMain(q) * size;
float a = inkMask(d, uv) * (0.5 / fi);
col = mix(col, pal(i + 1), a);
}
- vec2 q = rot(t) * (p - u_offset) / max(u_size, 1e-3);
- float d = castMain(q) * u_size;
+ vec2 q = rot(t) * (p - u_offset) / max(size0, 1e-3);
+ float d = castMain(q) * size0;
col = mix(col, pal(2), inkMask(d, uv));
- col += pal(3) * u_halo * (1.0 - smoothstep(0.0, u_size * 1.5, abs(d))) * 0.5;
+ col += pal(3) * u_halo * (1.0 - smoothstep(0.0, size0 * 1.5, abs(d))) * 0.5;
col += sigGrain(uv);
return vec4(inkValue(col), 1.0);
diff --git a/flow-state/src/scenes/stage/swarm.js b/flow-state/src/scenes/stage/swarm.js
index 3cf1066..ab98bd9 100644
--- a/flow-state/src/scenes/stage/swarm.js
+++ b/flow-state/src/scenes/stage/swarm.js
@@ -8,7 +8,7 @@ export const swarm = {
name: 'Swarm',
family: 'organic',
kind: 'fragment',
- consumes: ['cast', 'ink'],
+ consumes: ['cast', 'ink', 'staging'],
texture: 0.5,
traits: ['shape', 'camera', 'style'],
@@ -37,18 +37,17 @@ vec4 scene(vec2 uv, vec2 p) {
for (int i = 0; i < 48; i++) {
if (i >= u_count) break;
float fi = float(i);
- vec2 h = hash22(vec2(fi + u_seed * 0.5, fi * 2.3));
-
- // Each member rides the same flow field, which is what makes it a
- // swarm rather than a scatter — cohesion decides how strictly.
- vec2 seed = (h - 0.5) * 2.4;
- vec2 flow = curl(seed * u_field + t * 0.15, t * 0.1);
- vec2 pos = seed + flow * mix(0.05, 0.45, u_cohesion);
+ // Each member has a home on the song's lattice and rides the same flow
+ // field away from it, which is what makes it a swarm rather than a
+ // scatter — cohesion decides how far it is allowed to stray.
+ vec3 node = stageNode(fi, float(u_count));
+ vec2 flow = curl(node.xy * u_field + t * 0.15, t * 0.1);
+ vec2 pos = node.xy + flow * mix(0.05, 0.45, u_cohesion);
pos += vec2(sin(t * 0.7 + fi), cos(t * 0.6 + fi * 1.3)) * 0.06;
- vec2 q = (p - pos) / max(u_size, 1e-3);
+ vec2 q = (p - pos) / max(u_size * node.z, 1e-3);
q = rot(atan(flow.y, flow.x)) * q; // they face where they go
- float d = castChorus(q) * u_size;
+ float d = castChorus(q) * u_size * node.z;
col = mix(col, pal(i + 1), inkMask(d, uv));
col += pal(2) * u_trail * 0.004 / (0.02 + abs(d));
diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js
index a789cae..a3d899d 100644
--- a/flow-state/tools/lint-scenes.js
+++ b/flow-state/tools/lint-scenes.js
@@ -101,6 +101,24 @@ const TRAIT_EVIDENCE = {
style: /\b(sigEdge|sigGrain|sigFolded|inkMask|inkValue|inkPattern)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/,
};
+// The shader preamble is a JS template literal, so a backtick anywhere inside
+// it silently closes the literal. Twice now that has produced a check page that
+// hangs on "starting…" with an empty console, which is an expensive way to find
+// a typo. One grep is cheaper.
+console.log('\nshader contract');
+{
+ const src = readFileSync(join(SRC, 'engine/shader-contract.js'), 'utf8');
+ const literal = src.slice(src.indexOf('export const PREAMBLE = `') + 25);
+ const body = literal.slice(0, literal.indexOf('\n`;'));
+ const stray = body.split('\n').filter((l) => l.includes('`') && !l.includes('${'));
+ if (stray.length) {
+ fail(`shader preamble contains a backtick, which closes the template literal:\n` +
+ stray.map((l) => ` ${l.trim()}`).join('\n'));
+ } else {
+ ok('preamble has no stray backticks');
+ }
+}
+
console.log('\nscene schema lint');
{
const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);