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:
@@ -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'}` +
|
||||
|
||||
Reference in New Issue
Block a user