Whole-screen flashing/coherent motion is now a gated comfort signal rather than a hunt-and-tweak. Three-arm agitation (flash/3, tile-motion/0.32, structure/0.12) over a 1s floating window, worst window wins — engine/agitation.js. Policy centralised in look/comfort.js: director comfort appetite, per-scene agitationOf proxy, agitationWeight lean and stackAgitation. Canvas beat/flux capped in Layer.resolveParams (0.15/0.25, spike→smooth) so one contract change tames every full-frame scene; lint warns above the cap. Selection now filters before casting (look/scene-filter.js, 12+ ranged metrics from checks/scene-metrics.js, SCHEMA 7) and validates after stacking (look/composition-validator.js) with a deterministic retune: drop overlay → swap quiet composable → reselect filtered shot. Fix validator attempt-3 ground-layer bug that crashed ?song=dusk (LookGenerator now passes module, not layer). Eight canvas shaders window their beat/flux (focusField + per-seed hash mask) and report the scaled amount to Layer so the cap stays authoritative: scan-tear, gate-corridor, analog-wow, block-mosh, metaballs, pitch-shatter, scale-mosaic, tide-rings. Checks: scene-gate comfort gate (agitation ≤ 0.70), filmstrip/gallery meters + per-scene drawer, metadata SCHEMA 7 with ranged metrics and fingerprint over agitation/comfort sources. main.js surfaces COMPOSITION_REFUSED as a user-facing error. Lint: 114 files clean, 71 scenes ok. All 17 songs generate looks. Build passes. metadata.json refresh deferred to gallery → Refresh metadata (SCHEMA 7 writer in vite.config). Co-Authored-By: Claude <noreply@anthropic.com>
79 lines
3.1 KiB
JavaScript
79 lines
3.1 KiB
JavaScript
// Organic family: merging metaballs on an analytic orbit.
|
|
//
|
|
// Positions come from closed-form orbits rather than any simulation, which keeps
|
|
// the scene seek-exact — the same rule the 3D particle layer follows.
|
|
|
|
export const metaballs = {
|
|
name: 'Metaballs',
|
|
family: 'organic',
|
|
kind: 'fragment',
|
|
// Personality: see look/Personality.js.
|
|
consumes: ['cast', 'ink', 'staging'],
|
|
traits: ['shape', 'camera', 'style'],
|
|
|
|
params: {
|
|
count: { type: 'int', range: [2, 10], default: 5, uniform: 'u_count', bias: 'density' },
|
|
radius: { type: 'float', range: [0.1, 0.6], default: 0.3, uniform: 'u_radius', bias: 'energy' },
|
|
threshold: { type: 'float', range: [0.4, 2.2], default: 1.0, uniform: 'u_threshold' },
|
|
speed: { type: 'float', range: [0.03, 0.5], default: 0.15, uniform: 'u_speed', bias: 'motion', rate: true },
|
|
spread: { type: 'float', range: [0.2, 1.1], default: 0.6, uniform: 'u_spread' },
|
|
rim: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_rim' },
|
|
palette: { type: 'palette', count: 5 },
|
|
},
|
|
|
|
reactive: {
|
|
radius: { feature: 'bandLow', amount: 0.25 },
|
|
rim: { feature: 'beat', amount: 0.12, response: 'smooth' },
|
|
threshold: { feature: 'flux', amount: 0.2, response: 'inverse' },
|
|
},
|
|
|
|
shader: `
|
|
vec4 scene(vec2 uv, vec2 p) {
|
|
float t = u_time * u_speed + u_seed;
|
|
p = sigCamera(p);
|
|
|
|
float field = 0.0;
|
|
vec3 tint = vec3(0.0);
|
|
|
|
for (int i = 0; i < 10; i++) {
|
|
if (i >= u_count) break;
|
|
float fi = float(i);
|
|
float s = u_seed + fi * 71.3;
|
|
|
|
// Homes on the song's lattice, orbited by this scene's own motion.
|
|
vec3 node = stageNode(fi, float(u_count));
|
|
vec2 centre = node.xy * u_spread * vec2(1.0, 0.62)
|
|
+ vec2(sin(t * (0.7 + fract(s * 0.13)) + s),
|
|
cos(t * (0.5 + fract(s * 0.29)) + s * 1.7)) * 0.25;
|
|
|
|
// Distance measured in the song's FORM rather than as a circle: for a
|
|
// round cast this is exactly length(p - centre), and for a hexagonal
|
|
// one the blobs merge as hexagons.
|
|
float radius = u_radius * node.z;
|
|
float d = castMain((p - centre) / max(radius, 1e-3)) * radius + radius;
|
|
float contribution = (radius * radius) / max(d * d, 1e-4);
|
|
field += contribution;
|
|
tint += pal(i) * contribution;
|
|
}
|
|
|
|
tint /= max(field, 1e-4);
|
|
|
|
float surface = smoothstep(u_threshold - 0.25, u_threshold + 0.25, field);
|
|
float rim = smoothstep(u_threshold + 0.35, u_threshold, field)
|
|
* smoothstep(u_threshold - 0.3, u_threshold, field);
|
|
|
|
vec3 col = pal(0) * 0.06;
|
|
col = mix(col, tint, surface);
|
|
// Beat window — rim brightens only where a blob is near a focal point,
|
|
// so the highlight is a spot travelling over the surface, not a global wash.
|
|
float metabBeatMask = clamp(focusField(p) * 1.3, 0.0, 1.0) * 0.7 + 0.18;
|
|
float maskedRim = u_rim + metabBeatMask * u_beat * 0.45;
|
|
col += tint * rim * maskedRim;
|
|
|
|
return vec4(inkValue(col), 1.0);
|
|
}
|
|
`,
|
|
};
|
|
|
|
export default metaballs;
|