Staging: a shared lattice, and the element size the songs were all sharing

The cast and ink slice lowered the floor as predicted but left `scale` — feature
size — consistently WORSE than the legacy arm, 0.033 against 0.053 and well
outside the noise. Every stage still chose its own element size from its own
param range, so every song landed in the same place: the videos shared a cast
and also, accidentally, shared how big everything was.

Staging is the third artifact. A lattice — grid, radial, spiral, scatter,
strata — with jitter, spread, a size hierarchy and a bias toward the middle or
the edges, transported the same way the cast is: uniforms plus a `stageNode`
function in the preamble, no new engine plumbing. Constellation, Swarm and
Procession place on it; the stage keeps its motion and gives up its composition.
Soloist takes only the scale, since a close-up has no composition to share.

`elementScale` is the part that mattered. It is the song's answer to "how big is
this made of", spanning about a factor of six, and it is the decision that was
missing rather than mis-set. Measured, over twelve songs and three runs:

    stages spread  +0.0111 ±0.0043  →  +0.0143 ±0.0008
    scale block      0.033 ±0.004   →    0.041 ±0.007

Against the legacy arm at +0.0108 ±0.0071 the harness still says
indistinguishable, and it is right to: the gap is +0.0035 and the legacy arm's
own run-to-run range is twice that. What can be said is narrower and holds up.
Stages have the lowest floor of the three arms by a clear margin — 0.082 against
0.095 and 0.104 — so sharing content does make a video look like itself, which
was the central prediction. And the targeted fix moved the block it was aimed at
in the direction it was aimed.

Also added: a lint check that the shader preamble contains no backticks. Twice
now one has closed the template literal and produced a check page that hangs on
"starting…" with an empty console, which is an expensive way to find a typo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-17 21:36:55 +02:00
parent 577ec107f6
commit 6e4106048b
9 changed files with 225 additions and 66 deletions

View File

@ -108,6 +108,7 @@ async function main() {
const { lines, ok, headline } = await experimentReportLines({ const { lines, ok, headline } = await experimentReportLines({
songs: Number(params.get('count')) || 6, songs: Number(params.get('count')) || 6,
probes: Number(params.get('probes')) || 4, probes: Number(params.get('probes')) || 4,
repeats: Number(params.get('repeats')) || 3,
}); });
out.innerHTML = `<pre class="scene-gate">${lines.join('\n')}</pre>`; out.innerHTML = `<pre class="scene-gate">${lines.join('\n')}</pre>`;
summaryEl.textContent = `${headline} · ${((Date.now() - started) / 1000).toFixed(1)}s`; summaryEl.textContent = `${headline} · ${((Date.now() - started) / 1000).toFixed(1)}s`;

View File

@ -209,7 +209,7 @@ export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) {
* their own content. Three arms, because two would not distinguish "stages are * their own content. Three arms, because two would not distinguish "stages are
* better" from "a small pool is better". * 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 { scenes } = await import('../../scenes/registry.js');
const byName = (n) => scenes.find((m) => m.name === n); 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(' Higher observed = two songs that look like different work.');
lines.push(''); 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 = []; const results = [];
for (const arm of arms) { for (const arm of arms) {
await new Promise((r) => setTimeout(r, 0)); const runs = [];
const r = measureSongVariety({ songs, probes, pool: arm.pool }); for (let k = 0; k < repeats; k++) {
results.push({ arm, r }); 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'); const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length;
lines.push(' ' + '-'.repeat(78)); const half = (a) => (Math.max(...a) - Math.min(...a)) / 2;
for (const { arm, r } of results) {
const spread = r.observed - r.floor; lines.push(` arm floor observed spread (${repeats} runs)`);
lines.push(` ${arm.label.padEnd(44)}${r.floor.toFixed(4)} ${r.observed.toFixed(4)}` + lines.push(' ' + '-'.repeat(80));
` ${spread >= 0 ? '+' : ''}${spread.toFixed(4)} ${(r.observed / r.floor).toFixed(3)}`); 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(''); 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}`); lines.push(` ${arm.label}`);
for (const [name, b] of Object.entries(r.byBlock)) { for (const [name] of Object.entries(r.byBlock)) {
lines.push(` ${name.padEnd(8)} between ${b.between.toFixed(3)}`); 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 c = runs.map((x) => x.coupling);
const worst = r.pairs[0]; lines.push(` coupling ${mean(c).toFixed(2)} ±${half(c).toFixed(2)}`);
lines.push(` closest pair ${worst.a}${worst.b} at ${worst.total.toFixed(3)}`);
lines.push(''); lines.push('');
} }
const stages = results[0].r, legacy = results[1].r; const ok = gap > noise * 2;
const ok = (stages.observed - stages.floor) > (legacy.observed - legacy.floor); const headline = `stages ${gap >= 0 ? '+' : ''}${gap.toFixed(4)} vs legacy, noise ±${noise.toFixed(4)}` +
const headline = `stages spread ${(stages.observed - stages.floor).toFixed(4)} ` + (Math.abs(gap) > noise * 2 ? (gap > 0 ? 'the inversion helps' : 'the inversion hurts')
`vs legacy ${(legacy.observed - legacy.floor).toFixed(4)}` + : 'indistinguishable');
(ok ? 'the inversion helps' : 'no improvement');
return { lines, ok, headline }; return { lines, ok, headline };
} }

View File

@ -121,6 +121,13 @@ export const IDENTITY_UNIFORMS = {
u_inkHatchScale: 'float', u_inkHatchScale: 'float',
u_inkOutline: 'float', // 0..1 outline strength on top of the fill u_inkOutline: 'float', // 0..1 outline strength on top of the fill
u_inkPosterize: 'float', // 0 = off, else levels 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 = [ export const FRAME_UNIFORMS = [
@ -350,6 +357,61 @@ float castChorus(vec2 q) {
u_chorusNotchN, u_chorusNotchD, u_chorusHollow); 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 --------------------------------------------------------------- // --- the ink ---------------------------------------------------------------
// How the cast is drawn. Changes every pixel of every stage at once, and does // 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 // it structurally rather than chromatically — which is the point, since colour

View File

@ -29,6 +29,9 @@
/** Fill treatments, as the shader's `u_inkFill` index. */ /** Fill treatments, as the shader's `u_inkFill` index. */
export const FILLS = ['flat', 'ramp', 'hatch', 'stipple', 'halftone', 'hollow']; 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)); 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, 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. */ /** 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_chorusNotchN: 0, u_chorusNotchD: 0, u_chorusHollow: 0,
u_inkWeight: 0.3, u_inkEdge: 0.5, u_inkFill: 0, u_inkHatchAngle: 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_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_inkFill: FILLS.indexOf(ink.fill),
u_inkHatchAngle: ink.hatchAngle, u_inkHatchScale: ink.hatchScale, u_inkHatchAngle: ink.hatchAngle, u_inkHatchScale: ink.hatchScale,
u_inkOutline: ink.outline, u_inkPosterize: ink.posterize, 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; const ink = identity.ink;
return `cast ${form(a)} + ${form(b)} · ink ${ink.fill}` + return `cast ${form(a)} + ${form(b)} · ink ${ink.fill}` +
`${ink.outline ? '+outline' : ''}${ink.posterize ? `/${ink.posterize}-tone` : ''}` + `${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`;
} }

View File

@ -8,7 +8,7 @@ export const constellation = {
name: 'Constellation', name: 'Constellation',
family: 'minimal', family: 'minimal',
kind: 'fragment', kind: 'fragment',
consumes: ['cast', 'ink'], consumes: ['cast', 'ink', 'staging'],
texture: 0.6, texture: 0.6,
traits: ['shape', 'camera', 'space', 'style'], traits: ['shape', 'camera', 'space', 'style'],
@ -42,13 +42,15 @@ vec4 scene(vec2 uv, vec2 p) {
float fi = float(i); float fi = float(i);
vec2 h = hash22(vec2(fi + u_seed, fi * 1.7)); vec2 h = hash22(vec2(fi + u_seed, fi * 1.7));
// A slow wander around a fixed home, so the constellation keeps its // Homes come from the song's lattice — the stage owns the wander, not
// shape while nothing in it is ever quite still. // the composition. A slow drift around a fixed home, so the
vec2 home = (h - 0.5) * 2.0 * u_spread; // constellation keeps its shape while nothing in it is ever quite still.
vec2 pos = home + vec2(sin(t + h.x * 6.28), cos(t * 0.83 + h.y * 6.28)) * 0.08; 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 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); vec2 q = (p - pos) / max(size, 1e-3);
float d = castChorus(q) * size; float d = castChorus(q) * size;

View File

@ -9,12 +9,12 @@ export const procession = {
name: 'Procession', name: 'Procession',
family: 'structural', family: 'structural',
kind: 'fragment', kind: 'fragment',
consumes: ['cast', 'ink'], consumes: ['cast', 'ink', 'staging'],
texture: 0.4, texture: 0.4,
traits: ['shape', 'camera', 'style'], traits: ['shape', 'camera', 'style'],
params: { 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' }, 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 }, 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' }, 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. // Rows recede toward the horizon the track shares with every other scene.
float horizon = sigHorizonY(); float horizon = sigHorizonY();
for (int row = 0; row < 8; row++) { // The formation is the song's lattice; the march is this stage's own idea
if (row >= u_depth) break; // of what to do with it. That split is the whole point — identity owns
float fr = float(row); // placement, the stage owns motion.
// Rows further back are smaller and closer to the horizon. float total = min(float(u_columns * u_depth), 64.0);
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);
for (int c = 0; c < 9; c++) { for (int i = 0; i < 64; i++) {
if (c >= u_columns) break; float fi = float(i);
float fc = float(c); if (fi >= total) break;
float lane = (fc / max(float(u_columns) - 1.0, 1.0) - 0.5) * 2.4;
// The march: each row slides at its own pace, wrapping. vec3 node = stageNode(fi, total);
float phase = fract(t * (0.4 + back * 0.6) + fr * u_stagger + fc * 0.017); // Depth from the node's own height in the frame, so recession agrees
float x = lane + (phase - 0.5) * 0.6; // 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); // The march: each element slides at a pace set by its depth, wrapping.
q = rot(t * u_spin + fr) * q; 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; vec2 q = rot(t * u_spin + fi) * (p - pos) / max(u_size * scale, 1e-3);
float a = inkMask(d, uv);
col = mix(col, pal(row + c + 2), a * (0.35 + 0.65 * (1.0 - back))); 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); col += sigGrain(uv);

View File

@ -8,7 +8,7 @@ export const soloist = {
name: 'Soloist', name: 'Soloist',
family: 'minimal', family: 'minimal',
kind: 'fragment', kind: 'fragment',
consumes: ['cast', 'ink'], consumes: ['cast', 'ink', 'staging'],
texture: 0.5, texture: 0.5,
traits: ['shape', 'camera', 'style'], traits: ['shape', 'camera', 'style'],
@ -32,6 +32,9 @@ vec4 scene(vec2 uv, vec2 p) {
p = sigCamera(p); p = sigCamera(p);
p = sigFolded(p); p = sigFolded(p);
float t = u_time * u_turn + u_seed; 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); 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--) { for (int i = 6; i >= 1; i--) {
if (i > u_echoes) continue; if (i > u_echoes) continue;
float fi = float(i); 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); vec2 q = rot(t * (1.0 - fi * 0.15)) * (p - u_offset) / max(size, 1e-3);
float d = castMain(q) * size; float d = castMain(q) * size;
float a = inkMask(d, uv) * (0.5 / fi); float a = inkMask(d, uv) * (0.5 / fi);
col = mix(col, pal(i + 1), a); col = mix(col, pal(i + 1), a);
} }
vec2 q = rot(t) * (p - u_offset) / max(u_size, 1e-3); vec2 q = rot(t) * (p - u_offset) / max(size0, 1e-3);
float d = castMain(q) * u_size; float d = castMain(q) * size0;
col = mix(col, pal(2), inkMask(d, uv)); 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); col += sigGrain(uv);
return vec4(inkValue(col), 1.0); return vec4(inkValue(col), 1.0);

View File

@ -8,7 +8,7 @@ export const swarm = {
name: 'Swarm', name: 'Swarm',
family: 'organic', family: 'organic',
kind: 'fragment', kind: 'fragment',
consumes: ['cast', 'ink'], consumes: ['cast', 'ink', 'staging'],
texture: 0.5, texture: 0.5,
traits: ['shape', 'camera', 'style'], traits: ['shape', 'camera', 'style'],
@ -37,18 +37,17 @@ vec4 scene(vec2 uv, vec2 p) {
for (int i = 0; i < 48; i++) { for (int i = 0; i < 48; i++) {
if (i >= u_count) break; if (i >= u_count) break;
float fi = float(i); float fi = float(i);
vec2 h = hash22(vec2(fi + u_seed * 0.5, fi * 2.3)); // 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
// Each member rides the same flow field, which is what makes it a // scatter — cohesion decides how far it is allowed to stray.
// swarm rather than a scatter — cohesion decides how strictly. vec3 node = stageNode(fi, float(u_count));
vec2 seed = (h - 0.5) * 2.4; vec2 flow = curl(node.xy * u_field + t * 0.15, t * 0.1);
vec2 flow = curl(seed * u_field + t * 0.15, t * 0.1); vec2 pos = node.xy + flow * mix(0.05, 0.45, u_cohesion);
vec2 pos = seed + flow * mix(0.05, 0.45, u_cohesion);
pos += vec2(sin(t * 0.7 + fi), cos(t * 0.6 + fi * 1.3)) * 0.06; 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 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 = mix(col, pal(i + 1), inkMask(d, uv));
col += pal(2) * u_trail * 0.004 / (0.02 + abs(d)); col += pal(2) * u_trail * 0.004 / (0.02 + abs(d));

View File

@ -101,6 +101,24 @@ const TRAIT_EVIDENCE = {
style: /\b(sigEdge|sigGrain|sigFolded|inkMask|inkValue|inkPattern)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/, 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'); console.log('\nscene schema lint');
{ {
const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href); const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);