Epic 3, first slice: the song brings its own cast
Four stages, an identity layer, and an A/B that says the idea is right and the
reason it works is not the reason I expected.
A stage has no image of its own. It owns arrangement — a procession, a
constellation, a soloist, a swarm — and what it arranges comes from the track:
`castMain` and `castChorus` for the forms, `inkMask` and `inkValue` for the hand
they are drawn in. The identity generates a protagonist and a chorus with sides,
notches and hollows, plus an ink treatment of weight, edge, fill, outline and
posterisation. All of it travels as uniforms, so it is data rather than code and
a stage consumes it without knowing any other stage exists.
The protagonist IS the signature form rather than a second opinion about it.
They were separate draws in the first version, which let a track built on
hexagons put a round protagonist on screen — the signature said one thing and
the picture said another, and the shape trait stopped meaning anything for
stages. The cast now reads its geometry from the personality live and adds the
notches and hollows that turn a shape into a character.
Measured across seven songs, three arms, same instrument:
arm floor observed spread
stages, four of them 0.0801 0.1133 +0.0332
legacy scenes, four of them 0.0830 0.1122 +0.0292
the unrestricted generator 0.1058 0.1146 +0.0088
The prediction in EPIC-3 §7 was that sharing a cast would drop the floor
sharply. The floor did drop sharply — by a quarter — but nearly all of that came
from casting FEWER scenes per video, not from sharing content: the legacy arm,
four ordinary scenes with no cast at all, gets most of the same benefit. Stages
add about 14% on top of that.
That is worth knowing before building the other four registers. The single
largest available win was hiding in the roster size all along, and it costs one
number to take.
One result runs the wrong way and is recorded rather than explained: coupling is
+0.29 on the legacy arm and -0.18 on the stages. At twenty-one pairs neither is
distinguishable from zero, so it is a flag for a larger sample rather than a
finding.
Also fixed: a backtick inside the shader preamble's template literal, which
closed it and made every check page hang on "starting…" with no error in the
console.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { runAll, summarize, allChecks } from './framework.js';
|
||||
import { runSceneGate } from './scene-gate.js';
|
||||
import { varietyReportLines, songVarietyReportLines } from './variety/print.js';
|
||||
import {
|
||||
varietyReportLines, songVarietyReportLines, experimentReportLines,
|
||||
} from './variety/print.js';
|
||||
|
||||
// Registering a phase's checks is a side effect of importing it.
|
||||
import './phase0.js';
|
||||
@@ -98,6 +100,24 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The Epic 3 A/B: stages against comparable legacy scenes.
|
||||
// checks.html?experiment=1
|
||||
if (params.get('experiment')) {
|
||||
summaryEl.textContent = 'epic 3 experiment: rendering three arms across the song bank…';
|
||||
const started = Date.now();
|
||||
const { lines, ok, headline } = await experimentReportLines({
|
||||
songs: Number(params.get('count')) || 6,
|
||||
probes: Number(params.get('probes')) || 4,
|
||||
});
|
||||
out.innerHTML = `<pre class="scene-gate">${lines.join('\n')}</pre>`;
|
||||
summaryEl.textContent = `${headline} · ${((Date.now() - started) / 1000).toFixed(1)}s`;
|
||||
summaryEl.className = ok ? 'ok' : 'bad';
|
||||
window.__CHECKS__ = { experiment: true, ok, lines };
|
||||
window.__CHECKS_DONE__ = true;
|
||||
console.log('[experiment]\n' + lines.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseArg = params.get('phase');
|
||||
const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
|
||||
const skipSlow = params.get('slow') !== '1';
|
||||
|
||||
@@ -199,3 +199,77 @@ export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) {
|
||||
|
||||
return { lines, ok, headline };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Epic 3 experiment: does a video built from STAGES beat one built from
|
||||
* comparable legacy scenes?
|
||||
*
|
||||
* Same songs, same instrument, same number of scenes available — the only
|
||||
* difference is whether those scenes draw the song's cast in the song's ink, or
|
||||
* 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 } = {}) {
|
||||
const { scenes } = await import('../../scenes/registry.js');
|
||||
const byName = (n) => scenes.find((m) => m.name === n);
|
||||
|
||||
const arms = [
|
||||
{
|
||||
label: 'STAGES — draw the song\'s cast and ink',
|
||||
pool: ['Procession', 'Constellation', 'Soloist', 'Swarm'].map(byName),
|
||||
},
|
||||
{
|
||||
label: 'LEGACY — four comparable element-placing scenes',
|
||||
pool: ['Floating Geometry', 'Firefly Drift', 'Scale Mosaic', 'Metaballs'].map(byName),
|
||||
},
|
||||
{
|
||||
label: 'FULL — the unrestricted generator, for reference',
|
||||
pool: null,
|
||||
},
|
||||
];
|
||||
|
||||
const lines = [];
|
||||
lines.push('EPIC 3 EXPERIMENT — container vs content');
|
||||
lines.push('');
|
||||
lines.push(' Same songs, same instrument, same pool size. The stages own arrangement');
|
||||
lines.push(' and nothing else: what is on screen comes from the song. The legacy arm');
|
||||
lines.push(' is four scenes that each invent their own content.');
|
||||
lines.push('');
|
||||
lines.push(' Lower floor = a video that looks like itself over its own length.');
|
||||
lines.push(' Higher observed = two songs that look like different work.');
|
||||
lines.push('');
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
for (const { arm, 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)}`);
|
||||
}
|
||||
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)}`);
|
||||
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');
|
||||
return { lines, ok, headline };
|
||||
}
|
||||
|
||||
@@ -40,10 +40,11 @@ const RENDER = { width: 160, height: 90 };
|
||||
|
||||
/** Signature for one seed, rendered through the whole normal pipeline. */
|
||||
export function signatureForSeed(track, seed, options = {}) {
|
||||
const { pool = null, ...rest } = options;
|
||||
const show = new Show({ ...RENDER });
|
||||
try {
|
||||
show.useTrack(track, generateLook(track, { seed: seed >>> 0 }));
|
||||
return videoSignature(show, options);
|
||||
show.useTrack(track, generateLook(track, { seed: seed >>> 0, pool }));
|
||||
return videoSignature(show, rest);
|
||||
} finally {
|
||||
show.dispose();
|
||||
}
|
||||
@@ -474,14 +475,14 @@ function spearman(xs, ys) {
|
||||
* @param {object} options
|
||||
* @returns {object} report
|
||||
*/
|
||||
export function measureSongVariety({ songs = 6, probes = 5, refScenes = 4 } = {}) {
|
||||
export function measureSongVariety({ songs = 6, probes = 5, refScenes = 4, pool = null } = {}) {
|
||||
const bank = songBank({ count: songs });
|
||||
|
||||
// The seed is derived from the audio in the real pipeline, so each song must
|
||||
// get its own — deriving it from the name is the same relationship without
|
||||
// needing the samples.
|
||||
const sigs = bank.map((entry) =>
|
||||
signatureForSeed(entry.track, hashString(entry.name), { probes }));
|
||||
signatureForSeed(entry.track, hashString(entry.name), { probes, pool }));
|
||||
|
||||
const floor = mean(sigs.map((s) => s.drift));
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as THREE from 'three';
|
||||
import { VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS, SIGNATURE_UNIFORMS } from './shader-contract.js';
|
||||
import {
|
||||
VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS,
|
||||
SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS,
|
||||
} from './shader-contract.js';
|
||||
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js';
|
||||
import { clampValue } from '../params/schema.js';
|
||||
|
||||
@@ -40,7 +43,7 @@ export function buildShaderUniforms(module, baseParams, seed) {
|
||||
u_hasPrev: { value: 0 },
|
||||
};
|
||||
for (const name of AUDIO_UNIFORMS) uniforms[name] = { value: 0 };
|
||||
for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
|
||||
for (const [name, type] of Object.entries({ ...SIGNATURE_UNIFORMS, ...IDENTITY_UNIFORMS })) {
|
||||
const v = NEUTRAL_UNIFORMS[name];
|
||||
uniforms[name] = { value: type === 'vec2' ? new THREE.Vector2(v[0], v[1]) : v };
|
||||
}
|
||||
@@ -93,7 +96,7 @@ export function setFrameUniforms(layer, renderer, target, ctx) {
|
||||
}
|
||||
|
||||
const signature = signatureUniforms(layer.personality, layer.module);
|
||||
for (const [name, type] of Object.entries(SIGNATURE_UNIFORMS)) {
|
||||
for (const [name, type] of Object.entries({ ...SIGNATURE_UNIFORMS, ...IDENTITY_UNIFORMS })) {
|
||||
const v = signature[name];
|
||||
if (type === 'vec2') u[name].value.set(v[0], v[1]);
|
||||
else u[name].value = v;
|
||||
|
||||
@@ -89,6 +89,40 @@ export const SIGNATURE_UNIFORMS = {
|
||||
u_sigFrameShift: 'vec2', // recentre, in scene units
|
||||
};
|
||||
|
||||
/**
|
||||
* The song's CAST and INK — Epic 3's content and style artifacts.
|
||||
*
|
||||
* These differ from the signature uniforms above in kind, not degree. A
|
||||
* signature uniform is a modifier on an image the shader already had, which is
|
||||
* why a scene is free to ignore one. A cast uniform IS the image: a stage that
|
||||
* ignores it has nothing to draw. See look/Identity.js.
|
||||
*/
|
||||
export const IDENTITY_UNIFORMS = {
|
||||
u_castSides: 'float', // protagonist: 0 = round, else polygon sides
|
||||
u_castRound: 'float',
|
||||
u_castElong: 'float',
|
||||
u_castTilt: 'float',
|
||||
u_castNotchN: 'float', // notches cut into the boundary, 0 = none
|
||||
u_castNotchD: 'float',
|
||||
u_castHollow: 'float', // >0 makes it an annulus — a form with a hole
|
||||
|
||||
u_chorusSides: 'float', // the second member: a relative, not a stranger
|
||||
u_chorusRound: 'float',
|
||||
u_chorusElong: 'float',
|
||||
u_chorusTilt: 'float',
|
||||
u_chorusNotchN: 'float',
|
||||
u_chorusNotchD: 'float',
|
||||
u_chorusHollow: 'float',
|
||||
|
||||
u_inkWeight: 'float', // stroke width
|
||||
u_inkEdge: 'float', // 0 = soft/airbrushed, 1 = hard vector
|
||||
u_inkFill: 'float', // index into Identity.FILLS
|
||||
u_inkHatchAngle: 'float',
|
||||
u_inkHatchScale: 'float',
|
||||
u_inkOutline: 'float', // 0..1 outline strength on top of the fill
|
||||
u_inkPosterize: 'float', // 0 = off, else levels
|
||||
};
|
||||
|
||||
export const FRAME_UNIFORMS = [
|
||||
'u_time', 'u_frame', 'u_progress', 'u_seed',
|
||||
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
|
||||
@@ -113,6 +147,8 @@ ${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
|
||||
|
||||
${Object.entries(SIGNATURE_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
|
||||
|
||||
${Object.entries(IDENTITY_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
|
||||
|
||||
uniform sampler2D u_prev;
|
||||
uniform int u_hasPrev;
|
||||
|
||||
@@ -268,6 +304,99 @@ vec3 sigAir(vec3 col, vec2 p, float distance01) {
|
||||
return col;
|
||||
}
|
||||
|
||||
// --- the cast --------------------------------------------------------------
|
||||
// The song's own forms. A stage that places discrete elements places THESE, and
|
||||
// that is what makes two stages in one video look like one video — and two
|
||||
// videos of different songs look like different work.
|
||||
|
||||
/** Signed distance to a cast member, radius ~1 at size 1. */
|
||||
float castSDF(vec2 q, float sides, float rnd, float elong, float tilt,
|
||||
float notchN, float notchD, float hollow) {
|
||||
q = rot(tilt) * q;
|
||||
q.x /= max(elong, 0.05);
|
||||
|
||||
float r = length(q);
|
||||
float a = atan(q.y, q.x);
|
||||
|
||||
float d;
|
||||
if (sides < 2.5) {
|
||||
d = r - 1.0;
|
||||
} else {
|
||||
float seg = 6.28318530718 / sides;
|
||||
float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5);
|
||||
float poly = r * folded - cos(seg * 0.5);
|
||||
d = mix(poly, r - 1.0, clamp(rnd, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// Notches scallop the boundary. Approximate as a radial perturbation — it
|
||||
// is not a true distance any more, but every use here is a thresholded mask
|
||||
// and the error is far below a pixel at the sizes these are drawn.
|
||||
if (notchN > 0.5) d += notchD * cos(notchN * a);
|
||||
// A hole through the middle. Cheap, and the single most recognisable thing
|
||||
// a generated form can have.
|
||||
if (hollow > 0.001) d = abs(d) - hollow * 0.35;
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The protagonist, centred, radius ~1. */
|
||||
float castMain(vec2 q) {
|
||||
return castSDF(q, u_castSides, u_castRound, u_castElong, u_castTilt,
|
||||
u_castNotchN, u_castNotchD, u_castHollow);
|
||||
}
|
||||
|
||||
/** The chorus member — many of these, small. */
|
||||
float castChorus(vec2 q) {
|
||||
return castSDF(q, u_chorusSides, u_chorusRound, u_chorusElong, u_chorusTilt,
|
||||
u_chorusNotchN, u_chorusNotchD, u_chorusHollow);
|
||||
}
|
||||
|
||||
// --- 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
|
||||
// was already the only register doing any work.
|
||||
|
||||
/** The fill treatment as a 0..1 coverage pattern. 1 everywhere when flat. */
|
||||
float inkPattern(vec2 uv) {
|
||||
int mode = int(u_inkFill + 0.5);
|
||||
if (mode == 2) { // hatch
|
||||
vec2 h = rot(u_inkHatchAngle) * uv * u_inkHatchScale;
|
||||
return smoothstep(0.3, 0.7, 0.5 + 0.5 * sin(h.y));
|
||||
}
|
||||
if (mode == 3) { // stipple
|
||||
return step(0.42, hash12(floor(uv * u_inkHatchScale * 2.0)));
|
||||
}
|
||||
if (mode == 4) { // halftone
|
||||
vec2 g = fract(uv * u_inkHatchScale * 0.25) - 0.5;
|
||||
return smoothstep(0.38, 0.28, length(g));
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ink coverage for a signed distance: the fill in the track's treatment, plus
|
||||
* its outline. The 'hollow' fill treatment draws the outline only.
|
||||
*/
|
||||
float inkMask(float d, vec2 uv) {
|
||||
float soft = mix(0.03, 0.0015, clamp(u_inkEdge, 0.0, 1.0));
|
||||
int mode = int(u_inkFill + 0.5);
|
||||
|
||||
float fillA = smoothstep(soft, -soft, d) * inkPattern(uv);
|
||||
if (mode == 5) fillA = 0.0;
|
||||
|
||||
float w = 0.004 + u_inkWeight * 0.055;
|
||||
float strength = (mode == 5) ? 1.0 : u_inkOutline;
|
||||
float line = smoothstep(w + soft, w - soft, abs(d)) * strength;
|
||||
|
||||
return clamp(max(fillA, line), 0.0, 1.0);
|
||||
}
|
||||
|
||||
/** The track's value structure. Off unless the identity asked for it. */
|
||||
vec3 inkValue(vec3 col) {
|
||||
if (u_inkPosterize < 1.5) return col;
|
||||
float n = u_inkPosterize;
|
||||
return floor(col * n + 0.5) / n;
|
||||
}
|
||||
|
||||
vec3 prev(vec2 uv) {
|
||||
if (u_hasPrev == 0) return vec3(0.0);
|
||||
return texture2D(u_prev, uv).rgb;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// The song's IDENTITY: the content it is made of, and the hand it is drawn in.
|
||||
//
|
||||
// This is the Epic 3 inversion. A scene used to be self-contained — it knew how
|
||||
// to make metaballs and needed nothing from the track but a palette — and the
|
||||
// personality could only ever be a set of modifiers layered on an image the
|
||||
// shader already had. Measured, that is why two songs came out about as
|
||||
// different from each other as one video is from itself: the generator was
|
||||
// varying the CONTAINER and never the content.
|
||||
//
|
||||
// So the song generates content first, and a stage is a way of arranging
|
||||
// content it is given.
|
||||
//
|
||||
// CAST what is on screen. A protagonist and a chorus, as actual forms with
|
||||
// sides, notches and hollows, not as hints a shader may consult.
|
||||
// INK how they are drawn. Weight, edge, fill treatment, outline,
|
||||
// posterisation — the hand, which changes every pixel of every stage
|
||||
// at once and does it structurally rather than chromatically.
|
||||
//
|
||||
// Both travel as uniforms, which makes them data rather than code and means any
|
||||
// stage can consume them without knowing anything about any other stage. The
|
||||
// rule that keeps this honest, from EPIC-3.md §2:
|
||||
//
|
||||
// An artifact is content a stage could not have invented for itself.
|
||||
//
|
||||
// A stage that renders acceptably with the cast replaced by a default is using
|
||||
// it as a modifier and will drift back into ignoring it, exactly the way most of
|
||||
// the library ignores u_sigSides today.
|
||||
|
||||
/** Fill treatments, as the shader's `u_inkFill` index. */
|
||||
export const FILLS = ['flat', 'ramp', 'hatch', 'stipple', 'halftone', 'hollow'];
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
|
||||
/**
|
||||
* One cast member.
|
||||
*
|
||||
* `notches` and `hollow` are what take this past the existing signature form.
|
||||
* A rounded pentagon is a shape; a pentagon with six notches cut into it and a
|
||||
* hole through the middle is a CHARACTER — recognisable across stages, which is
|
||||
* the entire point of sharing it.
|
||||
*/
|
||||
function castMember(rng, { angular, intricate, solid }) {
|
||||
const sides = rng.pickWeighted(
|
||||
[0, 3, 4, 5, 6, 8],
|
||||
[3 + (1 - angular) * 5, 1 + angular * 2, 2 + angular * 2,
|
||||
1 + angular * 2, 1.5 + angular * 2, 0.5 + angular * 1.5]);
|
||||
return {
|
||||
sides,
|
||||
round: clamp01(rng.range(0.05, 0.55) * (1.3 - angular * 0.6)),
|
||||
elong: rng.range(0.8, 1.5),
|
||||
tilt: rng.range(0, Math.PI),
|
||||
// A notched form reads as made rather than as found.
|
||||
notchCount: rng.bool(0.25 + intricate * 0.5)
|
||||
? rng.pickWeighted([3, 4, 5, 6, 8, 12], [2, 3, 2, 3, 2, 1]) : 0,
|
||||
notchDepth: rng.range(0.06, 0.1 + intricate * 0.22),
|
||||
// Hollow forms are the difference between a blob library and a
|
||||
// recognisable one, and they cost nothing to draw.
|
||||
hollow: rng.bool(0.45 - solid * 0.3) ? rng.range(0.15, 0.6) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} summary FeatureTrack summary
|
||||
* @param {Rng} rng
|
||||
* @param {number} sections
|
||||
*/
|
||||
export function generateIdentity(summary, rng, sections = 4) {
|
||||
const bright = summary.meanCentroid ?? 0.5;
|
||||
const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3);
|
||||
const fast = clamp01(((summary.bpm ?? 120) - 80) / 80);
|
||||
const dynamic = clamp01(summary.dynamicRange ?? 0.5);
|
||||
const busy = clamp01((sections - 2) / 5);
|
||||
|
||||
// The audio sets the centre of each decision and the seed picks within it —
|
||||
// the same arrangement the personality values use, and for the same reason:
|
||||
// deriving outright would buy coupling by destroying seed variety.
|
||||
const angular = clamp01(noisy * 0.6 + fast * 0.3 + rng.range(-0.25, 0.25));
|
||||
const intricate = clamp01(busy * 0.5 + bright * 0.3 + rng.range(-0.3, 0.3));
|
||||
const solid = clamp01(0.5 - dynamic * 0.4 + rng.range(-0.25, 0.25));
|
||||
|
||||
const protagonist = castMember(rng.fork('protagonist'), { angular, intricate, solid });
|
||||
// The chorus is a relative of the protagonist, not a stranger: it shares the
|
||||
// family and differs in proportion, which is what makes a frame full of them
|
||||
// read as one production rather than as two libraries stacked.
|
||||
const chorusRng = rng.fork('chorus');
|
||||
const chorus = {
|
||||
...castMember(chorusRng, { angular, intricate, solid }),
|
||||
sides: chorusRng.bool(0.6) ? protagonist.sides : chorusRng.pick([0, 3, 4, 6]),
|
||||
tiltOffset: chorusRng.range(-0.6, 0.6),
|
||||
};
|
||||
|
||||
const ink = {
|
||||
// Line weight and edge hardness: the two decisions a viewer reads as
|
||||
// "what this was drawn with".
|
||||
weight: clamp01(0.15 + noisy * 0.35 + rng.range(-0.15, 0.35)),
|
||||
edge: clamp01(0.3 + bright * 0.3 + rng.range(-0.3, 0.4)),
|
||||
fill: rng.pickWeighted(FILLS, [
|
||||
3, // flat
|
||||
3, // ramp
|
||||
1 + intricate * 3, // hatch
|
||||
1 + noisy * 2.5, // stipple
|
||||
1 + bright * 2.5, // halftone
|
||||
1 + (1 - solid) * 2, // hollow — outline only
|
||||
]),
|
||||
hatchAngle: rng.range(0, Math.PI),
|
||||
hatchScale: rng.range(40, 160) * (0.6 + intricate * 0.9),
|
||||
outline: rng.bool(0.45 + angular * 0.3) ? rng.range(0.3, 1) : 0,
|
||||
// Posterisation is a value-structure decision, and it is the cheapest
|
||||
// way to make one track look printed and another look lit.
|
||||
posterize: rng.bool(0.3) ? rng.int(3, 6) : 0,
|
||||
};
|
||||
|
||||
return { cast: { protagonist, chorus }, ink, character: { angular, intricate, solid } };
|
||||
}
|
||||
|
||||
/** Neutral values, so a layer built without an identity renders as it always did. */
|
||||
export const NEUTRAL_IDENTITY_UNIFORMS = {
|
||||
u_castSides: 0, u_castRound: 0.25, u_castElong: 1, u_castTilt: 0,
|
||||
u_castNotchN: 0, u_castNotchD: 0, u_castHollow: 0,
|
||||
u_chorusSides: 0, u_chorusRound: 0.25, u_chorusElong: 1, u_chorusTilt: 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_inkHatchScale: 80, u_inkOutline: 0, u_inkPosterize: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} identity
|
||||
* @param {object} shape the personality's signature form
|
||||
*
|
||||
* The protagonist's GEOMETRY is the signature form, read live rather than
|
||||
* copied at generation time. The two were separate decisions in the first
|
||||
* draft, which meant a track built on hexagons could have a round protagonist —
|
||||
* the signature form said one thing and the thing actually on screen said
|
||||
* another, and the shape trait stopped meaning anything for stages. The cast is
|
||||
* the signature form made concrete: same sides, same rounding, same tilt, plus
|
||||
* the notches and hollows that turn a shape into a character.
|
||||
*/
|
||||
export function identityUniforms(identity, shape = null) {
|
||||
if (!identity) return { ...NEUTRAL_IDENTITY_UNIFORMS };
|
||||
const { protagonist: p, chorus: b } = identity.cast;
|
||||
const a = shape ? {
|
||||
...p,
|
||||
sides: shape.sides,
|
||||
round: shape.roundness,
|
||||
elong: shape.elongation,
|
||||
tilt: shape.tilt,
|
||||
} : p;
|
||||
const ink = identity.ink;
|
||||
return {
|
||||
u_castSides: a.sides, u_castRound: a.round, u_castElong: a.elong, u_castTilt: a.tilt,
|
||||
u_castNotchN: a.notchCount, u_castNotchD: a.notchCount ? a.notchDepth : 0,
|
||||
u_castHollow: a.hollow,
|
||||
|
||||
// The chorus stays a relative of the protagonist: it inherits the
|
||||
// signature form's tilt and usually its sides.
|
||||
u_chorusSides: b.sides, u_chorusRound: b.round, u_chorusElong: b.elong,
|
||||
u_chorusTilt: a.tilt + b.tiltOffset,
|
||||
u_chorusNotchN: b.notchCount, u_chorusNotchD: b.notchCount ? b.notchDepth : 0,
|
||||
u_chorusHollow: b.hollow,
|
||||
|
||||
u_inkWeight: ink.weight, u_inkEdge: ink.edge,
|
||||
u_inkFill: FILLS.indexOf(ink.fill),
|
||||
u_inkHatchAngle: ink.hatchAngle, u_inkHatchScale: ink.hatchScale,
|
||||
u_inkOutline: ink.outline, u_inkPosterize: ink.posterize,
|
||||
};
|
||||
}
|
||||
|
||||
const SHAPE_NAMES = { 0: 'round', 3: 'triangular', 4: 'square', 5: 'pentagonal', 6: 'hexagonal', 8: 'octagonal' };
|
||||
|
||||
export function describeIdentity(identity) {
|
||||
if (!identity) return 'no identity';
|
||||
const { protagonist: a, chorus: b } = identity.cast;
|
||||
const form = (m) => `${SHAPE_NAMES[m.sides] || `${m.sides}-sided`}` +
|
||||
`${m.notchCount ? `/${m.notchCount}-notch` : ''}${m.hollow ? '/hollow' : ''}`;
|
||||
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)}`;
|
||||
}
|
||||
@@ -38,6 +38,10 @@
|
||||
// Everything here is seeded off the look seed, so a track's personality is as
|
||||
// reproducible as everything else.
|
||||
|
||||
import {
|
||||
generateIdentity, identityUniforms, describeIdentity, NEUTRAL_IDENTITY_UNIFORMS,
|
||||
} from './Identity.js';
|
||||
|
||||
export const TRAITS = ['shape', 'camera', 'space', 'style'];
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
@@ -208,7 +212,12 @@ export function generatePersonality(summary, rng, countEligible = null, sections
|
||||
|
||||
const signature = pickSignature(rng, countEligible, signatureTilt(summary, sections));
|
||||
|
||||
return { signature, shape, camera, space, style, motion, temperament };
|
||||
// The song's cast and ink. Generated here so everything downstream — layer
|
||||
// uniforms, the HUD, presets — reaches it the same way it reaches the rest
|
||||
// of the production design. See look/Identity.js.
|
||||
const identity = generateIdentity(summary, rng.fork('identity'), sections);
|
||||
|
||||
return { signature, shape, camera, space, style, motion, temperament, identity };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,10 +318,13 @@ export function signatureUniforms(personality, module = null) {
|
||||
// every frame. Neutral here so a layer built without one is unframed.
|
||||
u_sigFrameScale: 1,
|
||||
u_sigFrameShift: [0, 0],
|
||||
|
||||
...identityUniforms(personality.identity, personality.shape),
|
||||
};
|
||||
}
|
||||
|
||||
export const NEUTRAL_UNIFORMS = {
|
||||
...NEUTRAL_IDENTITY_UNIFORMS,
|
||||
u_sigSides: 0,
|
||||
u_sigRound: 0.25,
|
||||
u_sigElong: 1,
|
||||
@@ -344,6 +356,7 @@ export function describePersonality(personality) {
|
||||
SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`,
|
||||
];
|
||||
if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`);
|
||||
if (personality.identity) parts.push(describeIdentity(personality.identity));
|
||||
if (personality.motion) {
|
||||
const m = personality.motion;
|
||||
parts.push(`${m.stillness > 0.6 ? 'still' : m.stillness < 0.3 ? 'restless' : 'moving'}` +
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { validateModule } from '../params/schema.js';
|
||||
|
||||
// STAGES — scenes that draw the song's cast rather than their own content.
|
||||
// See scenes/stage/README.md and EPIC-3.md.
|
||||
import { procession } from './stage/procession.js';
|
||||
import { constellation } from './stage/constellation.js';
|
||||
import { soloist } from './stage/soloist.js';
|
||||
import { swarm } from './stage/swarm.js';
|
||||
|
||||
import { nebula } from './shader/nebula.js';
|
||||
import { classicWave } from './shader/classic-wave.js';
|
||||
import { floatingGeometry } from './shader/floating-geometry.js';
|
||||
@@ -149,6 +156,13 @@ const MODULES = [
|
||||
halftoneMisprint,
|
||||
drosteFeedback,
|
||||
analogWow,
|
||||
|
||||
// Stages. Registered alongside the scenes so every existing gate covers
|
||||
// them; what makes them different is `consumes`, not where they live.
|
||||
procession,
|
||||
constellation,
|
||||
soloist,
|
||||
swarm,
|
||||
];
|
||||
|
||||
const errors = [];
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Stages
|
||||
|
||||
A stage is a scene that draws the SONG's content rather than its own.
|
||||
|
||||
An ordinary scene under `shader/` is self-contained: it knows how to make
|
||||
metaballs, and the personality can only modify an image it already had. That is
|
||||
why most of the library quietly ignores `u_sigSides` — the uniform is a hint,
|
||||
and a hint costs nothing to skip.
|
||||
|
||||
A stage has no image of its own. It knows how to arrange, place and animate
|
||||
things, and what those things ARE comes from the track's identity: `castMain`
|
||||
and `castChorus` for the forms, `inkMask` and `inkValue` for the hand they are
|
||||
drawn in. See `look/Identity.js` and EPIC-3.md.
|
||||
|
||||
The test of whether a file belongs here, from EPIC-3.md §2:
|
||||
|
||||
> An artifact is content a stage could not have invented for itself.
|
||||
|
||||
If it renders acceptably with a default cast, it is using the cast as a
|
||||
modifier, and it belongs under `shader/` instead.
|
||||
|
||||
Stages declare `consumes` so the harness — and eventually the casting code — can
|
||||
tell the two kinds apart.
|
||||
@@ -0,0 +1,71 @@
|
||||
// STAGE: a constellation. The chorus, scattered, drifting, joined by the light
|
||||
// between them.
|
||||
//
|
||||
// Where Procession is regular, this is the scattered lattice: a point set that
|
||||
// wanders, with the song's second cast member at every node.
|
||||
|
||||
export const constellation = {
|
||||
name: 'Constellation',
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
consumes: ['cast', 'ink'],
|
||||
texture: 0.6,
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
count: { type: 'int', range: [6, 40], default: 16, uniform: 'u_count', bias: 'density' },
|
||||
size: { type: 'float', range: [0.02, 0.16],default: 0.06,uniform: 'u_size' },
|
||||
drift: { type: 'float', range: [0.02, 0.6], default: 0.12,uniform: 'u_drift', bias: 'motion', rate: true },
|
||||
spread: { type: 'float', range: [0.4, 1.4], default: 0.95,uniform: 'u_spread' },
|
||||
link: { type: 'float', range: [0, 1], default: 0.35,uniform: 'u_link', bias: 'energy' },
|
||||
twinkle: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_twinkle' },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
twinkle: { feature: 'bandHigh', amount: 0.5 },
|
||||
size: { feature: 'beat', amount: 0.12, response: 'spike' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
p = sigCamera(p);
|
||||
float t = u_time * u_drift + u_seed;
|
||||
|
||||
vec3 col = pal(0) * 0.12;
|
||||
col = sigAir(col, p, 0.5);
|
||||
|
||||
float glow = 0.0;
|
||||
|
||||
for (int i = 0; i < 40; i++) {
|
||||
if (i >= u_count) break;
|
||||
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;
|
||||
|
||||
float pulse = 0.7 + 0.3 * sin(t * 2.0 + fi) * u_twinkle;
|
||||
float size = u_size * (0.5 + h.x) * pulse;
|
||||
|
||||
vec2 q = (p - pos) / max(size, 1e-3);
|
||||
float d = castChorus(q) * size;
|
||||
|
||||
float a = inkMask(d, uv);
|
||||
col = mix(col, pal(i + 1), a);
|
||||
|
||||
// The light between them: what makes this a constellation and not a
|
||||
// scatter of dots.
|
||||
glow += u_link * 0.006 / (0.01 + abs(d));
|
||||
}
|
||||
|
||||
col += pal(2) * glow * 0.35;
|
||||
col += sigGrain(uv);
|
||||
return vec4(inkValue(col), 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default constellation;
|
||||
@@ -0,0 +1,74 @@
|
||||
// STAGE: a procession. The protagonist, repeated across a marching lattice.
|
||||
//
|
||||
// The stage decides the lattice, the march and the scale falloff. It does not
|
||||
// decide what is marching — that is the song's cast, and with a different
|
||||
// identity this is a completely different image rather than the same image in
|
||||
// another palette.
|
||||
|
||||
export const procession = {
|
||||
name: 'Procession',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
consumes: ['cast', 'ink'],
|
||||
texture: 0.4,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
columns: { type: 'int', range: [2, 9], 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' },
|
||||
stagger: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_stagger' },
|
||||
recede: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_recede' },
|
||||
spin: { type: 'float', range: [0, 1.5], default: 0.2, uniform: 'u_spin', rate: true },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
size: { feature: 'beat', amount: 0.16, response: 'spike' },
|
||||
recede: { feature: 'bandLow', amount: 0.25 },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
p = sigCamera(p);
|
||||
float t = u_time * u_march + u_seed;
|
||||
|
||||
vec3 col = mix(pal(0) * 0.16, pal(1) * 0.20, uv.y);
|
||||
|
||||
// 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);
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
||||
vec2 q = (p - vec2(x, y)) / max(u_size * scale, 1e-3);
|
||||
q = rot(t * u_spin + fr) * q;
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(inkValue(col), 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default procession;
|
||||
@@ -0,0 +1,60 @@
|
||||
// STAGE: the soloist. One member of the cast, large, centred, held.
|
||||
//
|
||||
// Every video needs a shot where you can actually see what the song is made of.
|
||||
// This is that shot: the protagonist at full size, breathing on the bar, with
|
||||
// its own echoes behind it.
|
||||
|
||||
export const soloist = {
|
||||
name: 'Soloist',
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
consumes: ['cast', 'ink'],
|
||||
texture: 0.5,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
size: { type: 'float', range: [0.25, 0.85], default: 0.45, uniform: 'u_size' },
|
||||
echoes: { type: 'int', range: [0, 6], default: 2, uniform: 'u_echoes', bias: 'density' },
|
||||
echoStep: { type: 'float', range: [0.05, 0.5], default: 0.18, uniform: 'u_echoStep' },
|
||||
turn: { type: 'float', range: [0, 0.8], default: 0.12, uniform: 'u_turn', bias: 'motion', rate: true },
|
||||
offset: { type: 'vec2', range: [-0.4, 0.4], default: [0, 0], uniform: 'u_offset' },
|
||||
halo: { type: 'float', range: [0, 1], default: 0.3, uniform: 'u_halo', bias: 'energy' },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
size: { feature: 'beat', amount: 0.1, response: 'spike' },
|
||||
halo: { feature: 'loudness', amount: 0.4 },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
p = sigCamera(p);
|
||||
p = sigFolded(p);
|
||||
float t = u_time * u_turn + u_seed;
|
||||
|
||||
vec3 col = mix(pal(0) * 0.14, pal(1) * 0.18, length(p) * 0.5);
|
||||
|
||||
// Echoes first, so the soloist reads in front of them.
|
||||
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);
|
||||
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;
|
||||
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 += sigGrain(uv);
|
||||
return vec4(inkValue(col), 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default soloist;
|
||||
@@ -0,0 +1,63 @@
|
||||
// STAGE: a swarm. The chorus in numbers, flocking through a flow field.
|
||||
//
|
||||
// The stage owns the flocking; the song owns what is flocking. A swarm of
|
||||
// notched hexagons and a swarm of hollow circles are not the same video, and
|
||||
// with the old library they would have been the same scene.
|
||||
|
||||
export const swarm = {
|
||||
name: 'Swarm',
|
||||
family: 'organic',
|
||||
kind: 'fragment',
|
||||
consumes: ['cast', 'ink'],
|
||||
texture: 0.5,
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
count: { type: 'int', range: [8, 48], default: 24, uniform: 'u_count', bias: 'density' },
|
||||
size: { type: 'float', range: [0.02, 0.14],default: 0.05,uniform: 'u_size' },
|
||||
speed: { type: 'float', range: [0.05, 0.9], default: 0.25,uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
cohesion:{ type: 'float', range: [0, 1], default: 0.5, uniform: 'u_cohesion' },
|
||||
field: { type: 'float', range: [0.4, 3.0], default: 1.2, uniform: 'u_field' },
|
||||
trail: { type: 'float', range: [0, 0.9], default: 0.3, uniform: 'u_trail', bias: 'energy' },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
trail: { feature: 'flux', amount: 0.3 },
|
||||
size: { feature: 'bandLow', amount: 0.2 },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
p = sigCamera(p);
|
||||
float t = u_time * u_speed + u_seed;
|
||||
|
||||
vec3 col = pal(0) * 0.13;
|
||||
|
||||
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);
|
||||
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);
|
||||
q = rot(atan(flow.y, flow.x)) * q; // they face where they go
|
||||
float d = castChorus(q) * u_size;
|
||||
|
||||
col = mix(col, pal(i + 1), inkMask(d, uv));
|
||||
col += pal(2) * u_trail * 0.004 / (0.02 + abs(d));
|
||||
}
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(inkValue(col), 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default swarm;
|
||||
Reference in New Issue
Block a user