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
+2
View File
@@ -5,6 +5,7 @@ import { validateModule } from '../params/schema.js';
import { procession } from './stage/procession.js';
import { constellation } from './stage/constellation.js';
import { soloist } from './stage/soloist.js';
import { effigy } from './stage/effigy.js';
import { swarm } from './stage/swarm.js';
import { nebula } from './shader/nebula.js';
@@ -164,6 +165,7 @@ const MODULES = [
constellation,
soloist,
swarm,
effigy,
mountainFlight,
];
@@ -1,6 +1,14 @@
// Ported from party-stage's "Floating Geometry". Shape count, size and motion
// were fixed constants in the original; they are the whole point of the scene,
// so they are now params the look generator can move.
//
// The bodies are SOLID. This scene is the one in the library whose whole
// premise is objects adrift with nothing under them, and adrift is a thing that
// only reads in three dimensions: a flat silhouette rotating is a sticker
// turning, and no amount of drift makes it a body. Each element is now an
// instance of the song's assembly, marched in its own frame — so a shape hides
// its own far side, and two of them at different angles are visibly the same
// object seen twice rather than two copies of one outline. See castSolid.
export const floatingGeometry = {
name: 'Floating Geometry',
@@ -10,7 +18,7 @@ export const floatingGeometry = {
// of shapes, so `shape` is the trait it exists to express.
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
texture: 0.4,
consumes: ['cast', 'ink', 'staging'],
consumes: ['form', 'ink', 'staging'],
traits: ['shape', 'camera', 'style'],
params: {
@@ -35,6 +43,15 @@ vec4 scene(vec2 uv, vec2 p) {
// Background wash from the two darkest palette entries.
vec3 col = mix(pal(0) * 0.18, pal(1) * 0.24, sin(t) * 0.5 + 0.5);
// Whether a body already owns this pixel. The elements all float at the same
// depth, so which one wins where they overlap was always arbitrary — it used
// to be whichever came last. Making it whichever comes FIRST costs nothing
// visually and bounds the work at roughly one march per pixel: at the top of
// the size range fourteen bodies cover the frame several deep, and marching
// every one of them at every pixel was slow enough that the scene gate ran
// for minutes without finishing.
float painted = 0.0;
for (int i = 0; i < 14; i++) {
if (i >= u_count) break;
float fi = float(i);
@@ -45,8 +62,6 @@ vec4 scene(vec2 uv, vec2 p) {
vec2 pos = node.xy * u_spread * vec2(1.0, 0.55)
+ vec2(sin(t * 0.5 + s), cos(t * 0.3 + s * 1.1)) * 0.18;
vec2 sp = rot(t * (0.2 + fract(s) * u_spin)) * (p - pos);
float size = u_size * node.z * (0.6 + fract(s * 0.7) * 0.8);
// Every element is the song's protagonist. This scene used to pick
@@ -54,11 +69,40 @@ vec4 scene(vec2 uv, vec2 p) {
// the production design should be making — one video, one cast.
// The variety param only scales them apart; it never changes what they are.
float scale = size * (1.0 + (fract(s * 0.37) - 0.5) * u_variety);
float d = castMain(sp / max(scale, 1e-3)) * scale;
// Each body turns on its own two axes, at its own rate. The spread of
// angles is what makes the field read as one object seen from several
// sides rather than as a row of identical stamps — the thing a flat
// silhouette could not do however fast it span.
mat3 turn = castTurn(t * (0.2 + fract(s) * u_spin) + s,
sin(t * 0.4 + s * 2.1) * 0.9);
vec2 local = (p - pos) / max(scale, 1e-3);
// Nothing this element can contribute to this pixel. Cheap to ask, and
// it is what keeps the cost proportional to the elements a pixel
// actually touches rather than to the elements in the frame — without
// it the slice distance below is evaluated fourteen times per pixel.
if (dot(local, local) > 1.6 || painted > 0.5) continue;
vec3 n;
float hit = castSolid(local, turn, n);
vec3 shapeColor = pal(i + int(floor(t * 0.3)));
col = mix(col, shapeColor, inkMask(d, uv) * 0.85);
if (hit > 0.0) {
painted = 1.0;
// Lit in the track's key light, then pulled toward this element's
// own palette entry so the field keeps the colour rhythm it had.
col = mix(castLit(n, vec3(0.0, 0.0, 1.0)), shapeColor, 0.35);
}
// The halo and the rim survive the migration: the halo is what stops a
// dark body on a dark wash from disappearing, and the rim is the body's
// outline drawn in the track's line weight. Both read off the object's
// own slice at this angle rather than off a separate flat profile, so
// there is still exactly one shape on screen.
float d = castSDF3(turn * vec3(local, 0.0)) * scale;
col += shapeColor * (1.0 - smoothstep(0.0, size * 2.2, abs(d))) * 0.25;
col = mix(col, shapeColor, inkStroke(d) * 0.7);
}
return vec4(inkValue(col), 1.0);
+103
View File
@@ -0,0 +1,103 @@
// STAGE: the effigy. The protagonist as a SOLID, held large and turning.
//
// The soloist shows you the song's form; this shows you the song's OBJECT. The
// difference is what happens over eight bars: a silhouette rotated is the same
// picture at every angle, so a shot of one has nowhere to go, while an assembly
// turning reveals a limb, closes a hole, and reads as something you are walking
// around. That is the whole reason the identity grew a third dimension — see
// look/Identity.js generateForm.
//
// The object turns and the camera does not, deliberately. A moving camera would
// make the changing outline the SHOT's doing; here it is the song's.
export const effigy = {
name: 'Effigy',
family: 'geometric',
// One object against a dark wash — most of the frame is legitimately empty,
// and it reads as something standing in a space rather than as the space.
surface: 'composable',
kind: 'fragment',
consumes: ['form', 'ink', 'staging'],
texture: 0.6,
traits: ['shape', 'camera', 'style'],
params: {
size: { type: 'float', range: [0.45, 1.1], default: 0.7, uniform: 'u_size' },
dist: { type: 'float', range: [1.8, 3.4], default: 2.4, uniform: 'u_dist', slowAxis: true },
lift: { type: 'float', range: [-1.0, 1.6], default: 0.5, uniform: 'u_lift' },
lens: { type: 'float', range: [1.0, 2.6], default: 1.6, uniform: 'u_lens' },
turn: { type: 'float', range: [0.03, 0.7], default: 0.18, uniform: 'u_turn', bias: 'motion', rate: true },
tumble: { type: 'float', range: [0.0, 0.8], default: 0.25, uniform: 'u_tumble' },
glow: { type: 'float', range: [0.0, 1.0], default: 0.35, uniform: 'u_glow', bias: 'energy' },
palette:{ type: 'palette', count: 5 },
},
reactive: {
glow: { feature: 'beat', amount: 0.4, response: 'spike' },
tumble: { feature: 'bandLow', amount: 0.3 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
p = sigCamera(p);
float t = u_time * u_turn + u_seed;
vec3 col = mix(pal(0) * 0.13, pal(1) * 0.17, uv.y) + pal(1) * 0.06 * (1.0 - length(p) * 0.4);
// The song's own element size decides how big its object is, exactly as it
// decides how big every other stage's elements are — pulled toward it
// rather than replaced by it, the same bargain the soloist strikes. A
// stage that took stageScale() outright rendered a 0.7%-of-frame speck on
// tiny-scale tracks, which passes "renders something" and is not a shot.
float s = max(u_size * mix(1.0, stageScale(), 0.5), 0.15);
// A locked-off lens looking at the origin, standing back in units of the
// object's own radius, so dist means "how much room around it" whatever
// size the track decided on. Lens is multiplied back into the distance for
// the same reason: it should change PERSPECTIVE — how much the near side
// flares — and not how big the subject is. Left coupled, a long lens and a
// small song scale multiplied into a subject a few percent of frame across.
vec3 ro = vec3(0.0, u_lift, -u_dist * u_lens / s);
vec3 fw = normalize(-ro);
vec3 rt = normalize(cross(vec3(0.0, 1.0, 0.0), fw));
vec3 up = cross(fw, rt);
vec3 rd = normalize(fw * u_lens + rt * p.x + up * p.y);
// The object's own turn, plus a bounded nod on a second axis so the shape
// is read from more than one band of angles. Both are applied to the RAY,
// which is the same thing as turning the object and costs no transform.
float ca = cos(t), sa = sin(t);
mat3 spin = mat3(ca, 0.0, -sa, 0.0, 1.0, 0.0, sa, 0.0, ca);
float nod = sin(u_time * 0.11 + u_seed) * u_tumble;
float cn = cos(nod), sn = sin(nod);
mat3 nodM = mat3(1.0, 0.0, 0.0, 0.0, cn, sn, 0.0, -sn, cn);
mat3 turn = nodM * spin;
float eye = length(ro);
vec3 n;
float hit = castMarch(turn * ro, turn * rd, eye + 3.0, n);
if (hit > 0.0) {
// The normal comes back in object space; the transpose puts it back in
// the world, which is where the lighting and the view direction live.
vec3 nw = n * turn;
col = castLit(nw, rd);
// Sits in the track's air: the far side falls toward the background
// rather than staying lit all the way round.
col = mix(col, pal(0) * 0.2, clamp((hit - eye) * 0.3 + 0.35, 0.0, 0.7));
} else {
// Just outside the silhouette: a halo so the form reads against the
// wash even when the palette is flat, and a contour drawn at the
// track's line weight — the object's outline in the song's own hand.
// Measured at the closest-approach plane and scaled back into scene
// units, so the line is the track's weight rather than the lens's.
float near = castSDF3(turn * (ro + rd * eye)) * s / max(u_dist, 1e-3);
col += pal(3) * u_glow * 0.5 * exp(-max(near, 0.0) * 6.0);
col = mix(col, pal(4), inkStroke(near) * (0.4 + u_glow * 0.6));
}
return vec4(inkValue(col), 1.0);
}
`,
};
export default effigy;
+42 -7
View File
@@ -3,6 +3,14 @@
// 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.
//
// The members are SOLID — the chorus assembly, which is the protagonist's body
// plan with fewer parts and its own proportions (see Identity.js). A swarm is
// the case that most wants it: forty flat stamps all face the viewer, so the
// flock has one attitude, while forty bodies at forty angles have the tumbling
// look a flock actually has. Cost stays low because each member is marched only
// where its own bounding disc covers the pixel, and at this size that is a few
// percent of the frame each.
export const swarm = {
name: 'Swarm',
@@ -10,12 +18,12 @@ export const swarm = {
kind: 'fragment',
// Paints 2% of the frame — see checks/phase12 coverage.
surface: 'composable',
consumes: ['cast', 'ink', 'staging'],
consumes: ['form', 'ink', 'staging'],
texture: 0.5,
traits: ['shape', 'camera', 'style'],
params: {
count: { type: 'int', range: [8, 48], default: 24, uniform: 'u_count', bias: 'density' },
count: { type: 'int', range: [8, 36], 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' },
@@ -34,7 +42,13 @@ vec4 scene(vec2 uv, vec2 p) {
vec3 col = pal(0) * 0.13;
for (int i = 0; i < 48; i++) {
// Whether a member already owns this pixel — see Floating Geometry for why
// first-wins rather than last-wins. Members are drawn small, but the song's
// element scale can inflate them until they overlap several deep, and that
// is the case that decides the frame budget.
float painted = 0.0;
for (int i = 0; i < 36; i++) {
if (i >= u_count) break;
float fi = float(i);
// Each member has a home on the song's lattice and rides the same flow
@@ -45,12 +59,33 @@ vec4 scene(vec2 uv, vec2 p) {
vec2 pos = node.xy + 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 * node.z, 1e-3);
q = rot(atan(flow.y, flow.x)) * q; // they face where they go
float d = castChorus(q) * u_size * node.z;
float size = max(u_size * node.z, 1e-3);
vec2 local = (p - pos) / size;
// A swarm is dozens of members over a mostly empty frame, so this
// rejection is doing most of the work: without it every pixel evaluates
// every member's distance field, and the scene measured 59ms at 4K —
// near enough to the 60ms budget ceiling to fail on a slower GPU.
if (dot(local, local) > 1.6 || painted > 0.5) continue;
col = mix(col, pal(i + 1), inkMask(d, uv));
// They face where they go — now as a body turning into the flow rather
// than as an outline rotating in the plane, with a roll from the same
// field so the flock is not all level with the horizon.
float heading = atan(flow.y, flow.x);
mat3 turn = castTurn(heading, flow.y * 1.2 + sin(t + fi) * 0.4);
vec3 n;
float hit = castChorusSolid(local, turn, n);
if (hit > 0.0) {
painted = 1.0;
col = mix(col, mix(castLit(n, vec3(0.0, 0.0, 1.0)), pal(i + 1), 0.4), 0.95);
}
// The glow and the rim keep the swarm legible at the sizes it is drawn:
// a member is often only a few pixels across, and at that size the lit
// faces are one pixel and the outline is the whole read.
float d = castChorus3(turn * vec3(local, 0.0)) * size;
col += pal(2) * 0.3 * 0.004 / (0.02 + abs(d));
col = mix(col, pal(i + 1), inkStroke(d) * 0.8);
}
return vec4(inkValue(col), 1.0);