The song brings a body, not only an outline

A silhouette is the same picture from every angle, so a scene that turns one is
showing you the same shape rotated. That is the ceiling the cast has been under:
it can be notched and hollowed and it still cannot be walked around.

So the identity generates an ASSEMBLY — two to six parts, each a primitive with
an offset, a scale, a rotation and a boolean op, all under a symmetry. The
symmetry is the load-bearing half: parts unioned at random positions read as
debris, the same parts folded read as designed, and only a designed object is
worth calling a protagonist. Prism parts are the existing 2D profile extruded,
so the solid and the silhouette stay one character rather than two generators
running side by side.

It travels as data like every other artifact: three vec4 rows per part in
u_formPart, plus the scalars. Scenes declare `consumes: ['form']` and get
castSDF3, castMarch, castSolid, castChorusSolid and castLit; with no identity
they fall back to the flat profile extruded, so the helpers are safe to call
unconditionally. The chorus is the same rows with fewer parts and its own
proportions — a relative, not a second generator, and no extra uniforms.

Three scenes carry it. Effigy is new and holds the object still while it turns.
Floating Geometry and Swarm were already loops of stamps and are now loops of
bodies; Swarm is what the chorus solid exists for. That is 34.8% of videos
containing a 3D cast, against 11.9% when only Effigy had it.

Measured, the outline does change rather than merely spin: over one turn the lit
area of Effigy's subject varies 14-113% against Soloist's 3-51% for the same
rotation. Whether that reaches the variety blocks is not yet measured, and
HOWTO-variety says so rather than claiming the win.

Four costs, each found by measuring rather than by reading:

  * every pixel evaluated every instance's field — Swarm at 59ms/frame against a
    60ms ceiling. Bounding-sphere reject first, now 12.3ms.
  * instances overlap several deep at the top of the size range, and marching
    all of them made Floating Geometry's own gate run for minutes. First-wins
    instead of last-wins, which was arbitrary either way.
  * a normal inside the march loop multiplies four copies of the SDF by the step
    count, because GLSL unrolls a fixed bound. Hoisted out.
  * the helpers in the shared preamble made all 68 scenes compile what 3 of them
    call. FORM_PREAMBLE is appended per scene instead.

The lint's backtick check was green through two of my own breakages: quoting a
name in a doc comment adds backticks in PAIRS, so parity survives and the
pair-scanner just re-partitions the file. It now finds where a shader literal
opens and requires the next backtick to be a real terminator — which
immediately found a second stray pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-19 17:15:49 +02:00
co-authored by Claude Opus 5
parent 518e9ea731
commit 144c8c918e
12 changed files with 910 additions and 39 deletions
+198 -2
View File
@@ -26,6 +26,34 @@
// it as a modifier and will drift back into ignoring it, exactly the way most of
// the library ignores u_sigSides today.
/**
* Solids a 3D cast member is assembled from, as the part's kind index.
*
* `prism` is the 2D cast profile extruded — the protagonist given a body — and
* it is deliberately first, because a form built only from library primitives
* would be a shape the song did not choose. The other four are what a profile
* cannot be: something that bulges, something that tapers, something with a
* hole you can see through from an angle.
*/
export const SOLIDS = ['prism', 'box', 'capsule', 'torus', 'sphere'];
/**
* How the parts repeat, as the shader's `u_formSym` index.
*
* This is the load-bearing half of the assembly. Parts unioned at random
* positions read as debris; the same parts under a symmetry read as DESIGNED,
* and a designed object is the only kind worth calling a protagonist. It is
* also what keeps the form recognisable from any angle, which is the whole
* reason for giving it a third dimension.
*/
export const SYMMETRIES = ['none', 'mirror', 'radial', 'stack'];
/** Booleans a part can join with, as the part's op index. */
export const FORM_OPS = ['union', 'blend', 'carve'];
/** How many parts an assembly can have. The shader loops to exactly this. */
export const MAX_FORM_PARTS = 6;
/** Fill treatments, as the shader's `u_inkFill` index. */
export const FILLS = ['flat', 'ramp', 'hatch', 'stipple', 'halftone', 'hollow'];
@@ -77,6 +105,104 @@ function castMember(rng, { angular, intricate, solid }) {
};
}
/**
* The protagonist as a SOLID: a small assembly of parts, joined by booleans
* under a symmetry.
*
* The 2D cast is a silhouette, and a silhouette is the same picture from every
* angle — which means a scene that turns one is not showing you anything new,
* it is showing you the same outline rotated. That is the ceiling this exists
* to lift: an assembly's outline CHANGES as it turns, so a shot of it has
* somewhere to go over eight bars without the scene inventing motion.
*
* It is content rather than a modifier by the EPIC-3 §4 test: a stage handed a
* default assembly draws a plain extruded profile, and no stage could have
* invented "a five-fold radial of carved prisms with a torus through it" for
* itself. The parts stay tied to the 2D cast — `prism` parts ARE the song's
* profile — so the solid and the silhouette are the same character rather than
* two unrelated generators running side by side.
*/
function generateForm(rng, { angular, intricate, solid }) {
const count = rng.pickWeighted([2, 3, 4, 5, 6],
[3, 3 + intricate, 1.5 + intricate * 3, 0.5 + intricate * 3, 0.2 + intricate * 2]);
const symmetry = rng.pickWeighted(SYMMETRIES, [
1, // none — rare, and it shows
2 + angular, // mirror
2 + (1 - angular) * 2, // radial
1 + angular * 1.5, // stack
]);
// A radial fold of 2 is a mirror by another name, and a stack of 6 is a
// column rather than an object, so the two symmetries want different counts.
const symmetryN = symmetry === 'radial' ? rng.int(3, 8) : rng.int(2, 5);
const parts = [];
for (let i = 0; i < count; i++) {
// The first part is the body and always positive: an assembly whose
// opening move is a subtraction has nothing to subtract from.
const op = i === 0 ? 'union' : rng.pickWeighted(FORM_OPS, [
2, // union
1 + (1 - angular) * 2.5, // blend — smooth, and the soft look
0.6 + intricate * 2, // carve — holes, and the made look
]);
const kind = rng.pickWeighted(SOLIDS, [
// The profile leads, so the solid keeps the song's own outline.
4,
1 + angular * 2, // box
1 + (1 - angular) * 1.5, // capsule
0.6 + intricate * 1.6, // torus
1 + (1 - angular), // sphere
]);
// Parts near the origin build a body; parts far out build limbs. The
// first one is centred so there is always something at the middle.
const reach = i === 0 ? 0 : rng.range(0.15, 0.85) * (0.6 + intricate * 0.7);
const dir = rng.range(0, Math.PI * 2);
const size = (i === 0 ? rng.range(0.55, 0.95) : rng.range(0.2, 0.6))
* (1.15 - intricate * 0.35);
parts.push({
kind, op,
offset: [Math.cos(dir) * reach, rng.range(-0.7, 0.7) * reach, Math.sin(dir) * reach],
scale: [size, size * rng.range(0.6, 1.5), size * rng.range(0.35, 1.2)],
yaw: rng.range(0, Math.PI * 2),
pitch: rng.range(-0.9, 0.9),
// Rounding the part's own surface, on top of the ink's edge. A
// solid track wants blunt parts; a dynamic one wants sharp ones.
round: rng.range(0, 0.35) * (0.4 + solid * 1.2),
});
}
return {
parts, symmetry, symmetryN,
// THE CHORUS SOLID: the protagonist's body plan, simplified.
//
// A relative rather than a stranger, for the same reason the 2D chorus
// is: a frame full of both has to read as one production. So it is the
// first few parts of the same assembly under its own symmetry and its
// own proportions — which is what a supporting character IS, structurally.
//
// It costs no extra part rows. A second full assembly would have doubled
// the uniform array for something that must not look like a different
// object anyway, and "fewer parts, squashed differently" is both cheaper
// and a better description of the thing.
chorus: {
count: Math.min(parts.length, rng.int(1, 3)),
symmetry: rng.bool(0.55) ? symmetry : rng.pick(['none', 'mirror']),
symmetryN: rng.int(2, 5),
// Squashed and thinned against the protagonist. A chorus that is
// merely a smaller protagonist adds numbers and no information.
flat: rng.range(0.5, 1.7),
thin: rng.range(0.45, 1.5),
},
// How far a `blend` op melts two parts into one. Low reads as welded
// hard edges, high as a single lump — both are legible, and the middle
// is where an assembly stops looking like parts at all.
blend: rng.range(0.04, 0.3) * (1.4 - angular * 0.8),
// How deep the solid is relative to how wide. A track can be built on
// slabs or on columns, and that decision is visible before anything else.
depth: rng.range(0.45, 1.6),
};
}
/**
* @param {object} summary FeatureTrack summary
* @param {Rng} rng
@@ -179,8 +305,13 @@ export function generateIdentity(summary, rng, sections = 4) {
impact: rng.pick(IMPACTS),
};
// The solid the protagonist is, as opposed to the outline it casts. Forked
// rather than drawn inline so adding it does not shift every decision made
// after it — an identity generated today has to stay the identity it was.
const form = generateForm(rng.fork('form'), { angular, intricate, solid });
return {
cast: { protagonist, chorus }, ink, lattice,
cast: { protagonist, chorus }, ink, lattice, form,
character: { angular, intricate, solid },
};
}
@@ -196,8 +327,56 @@ export const NEUTRAL_IDENTITY_UNIFORMS = {
u_latKind: 3, u_latJitter: 0.5, u_latSpread: 0.9,
u_latScaleSpread: 0.3, u_latScaleBias: 0, u_latScale: 0.35,
u_focusN: 0, u_focusR: 0.6, u_focusPull: 0, u_impact: 0,
// A count of zero is the shader's instruction to fall back to the 2D cast
// extruded, so a scene that marches the solid still draws the right
// character when it is handed no identity at all.
u_formCount: 0, u_formSym: 0, u_formSymN: 3, u_formBlend: 0.12, u_formDepth: 0.8,
u_formChorusN: 0, u_formChorusSym: 0, u_formChorusSymN: 3,
u_formChorusFlat: 1, u_formChorusThin: 1,
u_formPart: Array.from({ length: MAX_FORM_PARTS * 3 }, () => [0, 0, 0, 0]),
};
/**
* The assembly, packed for the shader: three vec4 per part.
*
* A part is eleven numbers, and eleven scalar uniforms times six parts is
* sixty-six declarations nobody would keep in step with the generator. Packed
* rows are indexed by the loop counter instead, which is the one array access
* GLSL ES 1.0 allows and is why the layout is fixed-width rather than tight.
*
* row 0 offset.xyz | solid index
* row 1 scale.xyz | boolean op index
* row 2 yaw, pitch, round | unused
*/
function chorusUniforms(chorus) {
if (!chorus) {
return {
u_formChorusN: 0, u_formChorusSym: 0, u_formChorusSymN: 3,
u_formChorusFlat: 1, u_formChorusThin: 1,
};
}
return {
u_formChorusN: chorus.count,
u_formChorusSym: SYMMETRIES.indexOf(chorus.symmetry),
u_formChorusSymN: chorus.symmetryN,
u_formChorusFlat: chorus.flat,
u_formChorusThin: chorus.thin,
};
}
function formPartRows(form) {
const rows = [];
for (let i = 0; i < MAX_FORM_PARTS; i++) {
const p = form && form.parts[i];
if (!p) { rows.push([0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]); continue; }
rows.push([p.offset[0], p.offset[1], p.offset[2], SOLIDS.indexOf(p.kind)]);
rows.push([p.scale[0], p.scale[1], p.scale[2], FORM_OPS.indexOf(p.op)]);
rows.push([p.yaw, p.pitch, p.round, 0]);
}
return rows;
}
/**
* @param {object} identity
* @param {object} shape the personality's signature form
@@ -248,6 +427,19 @@ export function identityUniforms(identity, shape = null) {
u_focusR: identity.lattice.focusRadius,
u_focusPull: identity.lattice.focusPull,
u_impact: IMPACTS.indexOf(identity.lattice.impact),
u_formCount: identity.form ? identity.form.parts.length : 0,
u_formSym: identity.form ? SYMMETRIES.indexOf(identity.form.symmetry) : 0,
u_formSymN: identity.form ? identity.form.symmetryN : 3,
u_formBlend: identity.form ? identity.form.blend : 0.12,
u_formDepth: identity.form ? identity.form.depth : 0.8,
u_formPart: formPartRows(identity.form),
// Defaulted rather than assumed. A check harness builds identities by
// hand to probe a scene, and reading through a missing sub-object here
// throws inside a uniform getter — where the only symptom is a page that
// never finishes and never says why. Cost of the guard: nothing.
...chorusUniforms(identity.form && identity.form.chorus),
};
}
@@ -259,7 +451,11 @@ export function describeIdentity(identity) {
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}` +
const solid = identity.form
? ` · solid ${identity.form.parts.length}-part/${identity.form.symmetry}` +
`${identity.form.symmetry === 'none' ? '' : identity.form.symmetryN}`
: '';
return `cast ${form(a)} + ${form(b)}${solid} · ink ${ink.fill}` +
`${ink.outline ? '+outline' : ''}${ink.posterize ? `/${ink.posterize}-tone` : ''}` +
` w${ink.weight.toFixed(2)} · on ${identity.lattice.kind}` +
` · ${identity.lattice.focusCount} focus/${identity.lattice.impact}` +