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>
325 lines
12 KiB
Markdown
325 lines
12 KiB
Markdown
# Migrating a scene onto the identity artifacts
|
|
|
|
The recipe `tools/migration-status.js` reports progress against. Sixty-one
|
|
scenes is too many to convert from taste, so this is written to be followed
|
|
mechanically and to produce the same result twice.
|
|
|
|
Background is EPIC-3.md. The one-line version: a scene draws its own content, a
|
|
migrated scene draws the *song's* content — its cast, in its ink, on its lattice
|
|
— and the measured payoff is additive with everything the scene already did.
|
|
|
|
```bash
|
|
node tools/migration-status.js # the queue, and what each scene needs
|
|
node tools/migration-status.js --all # every scene and its tier
|
|
```
|
|
|
|
---
|
|
|
|
## The rule that decides everything
|
|
|
|
> **An artifact is content a scene could not have invented for itself.**
|
|
|
|
If the scene still looks right with the identity switched off, it has used the
|
|
artifact as a modifier and the migration has not happened. That is not a style
|
|
preference — it is the exact failure mode of `u_sigSides`, which thirty scenes
|
|
declare and most quietly ignore, and it is why the gate below exists.
|
|
|
|
---
|
|
|
|
## The four tiers
|
|
|
|
The classifier assigns these from the shader source. Check its answer, don't
|
|
trust it: it reads patterns, and a scene that places elements in an unusual way
|
|
will be misfiled.
|
|
|
|
| tier | what it looks like | consumes |
|
|
|---|---|---|
|
|
| **drawn** | loops over discrete elements at computed positions | `cast, ink, staging` |
|
|
| **figure** | one or a few forms, its own composition | `cast, ink` |
|
|
| **field** | a continuous surface — noise, flow, terrain | `ink` |
|
|
| **treatment** | an effect over an image rather than an image | `ink` |
|
|
| **solid** | a camera in a space, marching a distance field | `form, ink` |
|
|
|
|
Accents are excluded. They are mostly-empty depth passes, not subjects.
|
|
|
|
---
|
|
|
|
## Recipe: `drawn`
|
|
|
|
The highest payoff and the least invention. Four edits.
|
|
|
|
**1. Declare it.** Add to the module, above `params`:
|
|
|
|
```js
|
|
consumes: ['cast', 'ink', 'staging'],
|
|
```
|
|
|
|
**2. Replace the primitive with the cast.** Whatever the loop was drawing —
|
|
a circle, a box, `sigShape`, a bespoke SDF — becomes one of:
|
|
|
|
```glsl
|
|
float d = castMain(q) * size; // the protagonist: large, few
|
|
float d = castChorus(q) * size; // the chorus: small, many
|
|
```
|
|
|
|
`q` is the element-local coordinate, `(p - pos) / size`. Multiplying the result
|
|
back by `size` restores it to scene units, which is what the ink expects.
|
|
|
|
Use `castMain` when the scene draws a handful of things and `castChorus` when it
|
|
draws a crowd. A scene that draws both should use both — that is what the two
|
|
members are for.
|
|
|
|
**3. Replace the placement with the lattice.**
|
|
|
|
```glsl
|
|
vec3 node = stageNode(fi, float(u_count)); // xy position, z scale multiplier
|
|
vec2 pos = node.xy;
|
|
float size = u_size * node.z;
|
|
```
|
|
|
|
Keep whatever the scene did that was *motion* — a drift, an orbit, a wander, a
|
|
march. Give up what was *composition*. The split is the point: identity owns
|
|
where things are, the scene owns what they do.
|
|
|
|
A scene whose composition IS its identity — a spiral that must be a spiral — can
|
|
keep it and take `stageScale()` alone, which is the song's element size. Declare
|
|
`staging` either way.
|
|
|
|
**4. Replace the edge with the ink.**
|
|
|
|
```glsl
|
|
col = mix(col, pal(i + 1), inkMask(d, uv));
|
|
```
|
|
|
|
`inkMask` does fill, fill treatment (hatch, stipple, halftone), outline and edge
|
|
hardness in one call. Delete the scene's own `smoothstep(soft, -soft, d)` and
|
|
its `sigEdge` — the ink supersedes both.
|
|
|
|
Then wrap the return:
|
|
|
|
```glsl
|
|
return vec4(inkValue(col), 1.0);
|
|
```
|
|
|
|
**Do not** delete `sigCamera`, `sigGrain`, `sigHorizonY` or `sigAir`. Traits and
|
|
artifacts are different layers and both still apply.
|
|
|
|
---
|
|
|
|
## Recipe: `figure`
|
|
|
|
Steps 1, 2 and 4, skipping the lattice. Declare `consumes: ['cast', 'ink']`.
|
|
Take `stageScale()` if the figure has a size worth scaling and add `staging` if
|
|
you do.
|
|
|
|
---
|
|
|
|
## Recipe: `solid` — the cast in three dimensions
|
|
|
|
For a scene that has a camera in a space rather than a plane: a raymarcher, a
|
|
corridor, anything where the subject can be walked around. Declare
|
|
`consumes: ['form', ...]`.
|
|
|
|
```glsl
|
|
vec3 ro = vec3(0.0, lift, -dist); // eye, in object radii
|
|
vec3 rd = normalize(fw * lens + rt * p.x + up * p.y);
|
|
vec3 n;
|
|
float hit = castMarch(ro, rd, dist + 3.0, n);
|
|
if (hit > 0.0) col = castLit(n, rd); // lit in the track's palette
|
|
```
|
|
|
|
`castSDF3(vec3)` is the distance field if you want to place, repeat or carve
|
|
with it yourself; `castNormal3` is its normal. All of them fall back to the flat
|
|
profile extruded when a track brought no assembly, so they are safe to call
|
|
unconditionally.
|
|
|
|
### Many instances
|
|
|
|
For a scene that was stamping `castMain` in a loop — a field, a swarm, a belt —
|
|
swap the stamp for `castSolid`, which marches one instance orthographically in
|
|
its own frame:
|
|
|
|
```glsl
|
|
vec2 local = (p - centre) / size; // exactly what castMain was given
|
|
if (dot(local, local) > 1.6 || painted > 0.5) continue;
|
|
vec3 n;
|
|
float hit = castSolid(local, castTurn(yaw, pitch), n);
|
|
if (hit > 0.0) { painted = 1.0; col = castLit(n, vec3(0.0, 0.0, 1.0)); }
|
|
```
|
|
|
|
`castChorusSolid` is the same for a chorus member — the protagonist's body plan
|
|
with fewer parts and its own proportions, which is what a field of many should
|
|
be drawing.
|
|
|
|
**Both guards in that snippet are load-bearing**, and each was found by a
|
|
measurement rather than by review:
|
|
|
|
* the bounding-sphere reject, because without it every pixel evaluates every
|
|
instance's distance field — Swarm measured 59ms/frame at 4K against a 60ms
|
|
ceiling;
|
|
* `painted`, because 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. Which instance wins where they overlap was always arbitrary, so
|
|
first-wins costs nothing.
|
|
|
|
A third rule lives in the contract rather than in your scene: take the surface
|
|
normal AFTER the march loop, never inside it. GLSL unrolls a fixed-bound loop,
|
|
so a normal in the loop body multiplies four more copies of the assembly SDF by
|
|
the step count. For the same reason these helpers are compiled only into scenes
|
|
that declare `form` — see FORM_PREAMBLE.
|
|
|
|
Worth the cost only if the shot MOVES relative to the object. A solid held at
|
|
one angle is a silhouette with shading, and `cast` draws that for a fraction of
|
|
the price — the assembly earns its keep through the outline changing, which
|
|
needs either the object turning or the camera travelling.
|
|
|
|
---
|
|
|
|
## Recipe: `field` and `treatment`
|
|
|
|
There are no elements to replace, so this is one edit plus a judgement.
|
|
|
|
```js
|
|
consumes: ['ink'],
|
|
```
|
|
|
|
```glsl
|
|
return vec4(inkValue(col), 1.0);
|
|
```
|
|
|
|
If the field already dithers, hatches or posterises internally, replace that
|
|
with `inkPattern(uv)` so the treatment is the song's rather than the scene's.
|
|
If it does not, `inkValue` alone is the whole migration — a value structure
|
|
shared across every scene in a video is worth having and costs one line.
|
|
|
|
Be honest about `treatment` scenes. Most of them are effects wearing a scene's
|
|
clothes, and EPIC-3 §8 argues they should move into the identity's EFFECTS
|
|
register rather than compete for screen time as subjects. Migrating one is a
|
|
holding action, not the answer.
|
|
|
|
---
|
|
|
|
## Verifying — the part that makes this reproducible
|
|
|
|
A migration is not done when the code looks right. It is done when the gate
|
|
passes:
|
|
|
|
```
|
|
checks.html?scene=<Scene%20Name>
|
|
```
|
|
|
|
Every artifact in `consumes` must show a passing line:
|
|
|
|
```
|
|
PASS consumes: cast delta 255/255 (floor 24)
|
|
PASS consumes: ink delta 255/255 (floor 24)
|
|
PASS consumes: staging delta 255/255 (floor 24)
|
|
```
|
|
|
|
That check renders the scene twice under two deliberately distant identities and
|
|
requires the picture to change. A scene that declares `cast` and ignores it
|
|
fails here, which is the only reason `consumes` can be trusted at library scale.
|
|
|
|
`npm run lint:scenes` enforces the other half in both directions: declaring an
|
|
artifact without calling it, and calling one without declaring it. The second
|
|
matters more than it looks — an undeclared artifact hides the scene from this
|
|
report and from anything that later selects scenes on capability.
|
|
|
|
Then the usual battery still applies. `renders something`, `animates`,
|
|
`deterministic`, `distinct`, `param sweep`, `flash rate` and every declared
|
|
trait must all still pass. A migration that breaks `distinct` has made the scene
|
|
into one of its neighbours, which is a real risk here: the more scenes share a
|
|
cast, the more two weakly-composed ones converge.
|
|
|
|
---
|
|
|
|
## Measuring the payoff
|
|
|
|
Per scene, the gate. Across the library, two numbers:
|
|
|
|
```
|
|
checks.html?decompose=1 identity against container, measured apart
|
|
checks.html?experiment=1 stages against legacy, with error bars
|
|
```
|
|
|
|
`decompose` is the one to watch. Before the migration, over the four purpose-
|
|
built stages:
|
|
|
|
```
|
|
identity only 0.0299 container only 0.0557 identity = 54% of container
|
|
```
|
|
|
|
After the first eighteen scenes, over the eight that consumed the cast:
|
|
|
|
```
|
|
identity only 0.1287 container only 0.0815 identity = 158% of container
|
|
```
|
|
|
|
Complete, over all thirty-two:
|
|
|
|
```
|
|
identity only 0.1054 container only 0.0901 identity = 117% of container
|
|
```
|
|
|
|
The number that matters in that row is `container only`, which went 0.0557 →
|
|
0.0815 → 0.0901 as the migration progressed. That is the stop condition below
|
|
holding: the library did not homogenise. `identity only` is noisier than it
|
|
looks because the scene it probes is picked from the migrated set and changes
|
|
between runs — Constellation, then Metaballs, then Floating Geometry — so read
|
|
it as "comparable to the container" rather than as a precise ratio.
|
|
|
|
End to end, song variety across twelve songs — and a warning about how to read
|
|
it. A single run after the migration gave separation 0.38. Three runs give:
|
|
|
|
```
|
|
separation 0.314, 0.555, 0.065 mean 0.311, half-range +/-0.245
|
|
floor 0.1073 +/-0.0145
|
|
observed 0.1162 +/-0.0185
|
|
ceiling 0.1434 +/-0.0000
|
|
```
|
|
|
|
**Do not quote the separation figure.** It is a ratio of two small differences —
|
|
`(observed - floor) / (ceiling - floor)` — and the numerator here is 0.0089
|
|
while each of its terms carries an error bar twice that size. The ratio is not
|
|
measuring the generator at this sample size, it is amplifying the noise in both.
|
|
The 0.38 that appeared in a single run was meaningless, as was reporting it.
|
|
|
|
What can be said: the ceiling now sits reliably ABOVE the floor, with zero
|
|
variance across runs, where before the epic it landed underneath and the ratio
|
|
was not computable at all. That is a change in kind and it is solid. The
|
|
magnitude is not.
|
|
|
|
The trustworthy measurement in this harness is the DECOMPOSITION, not the
|
|
top-line ratio. `decompose` has a noise floor of exactly 0.0000 and effects an
|
|
order of magnitude larger than any drift, because it compares renders directly
|
|
rather than dividing differences of aggregates. Steer by that.
|
|
|
|
Coupling did not move: -0.02 +/-0.03. Whether a song LOOKS different in
|
|
proportion to how it SOUNDS different remains unsolved, and nothing in the
|
|
migration addressed it.
|
|
|
|
Read that with its caveat: the stage set changed underneath the measurement, so
|
|
part of the jump is that Metaballs expresses a cast more strongly than
|
|
Constellation did rather than that the migration itself moved anything. What it
|
|
does establish is the thing worth knowing — a migrated LIBRARY scene carries the
|
|
identity better than a stage written from scratch to carry it. The bespoke
|
|
stages were the wrong shape of effort. As scenes migrate, the
|
|
`identity only` number should climb while `container only` holds — because the
|
|
whole point of the correction in EPIC-3 §9b is that these add rather than trade.
|
|
If `container only` falls as scenes migrate, the migration is homogenising the
|
|
library and should stop.
|
|
|
|
---
|
|
|
|
## A caution learned the expensive way
|
|
|
|
This harness's noise floor is large enough to invent findings. Three claims in
|
|
this epic were made from single runs and withdrawn after repeats: that roster
|
|
size was the dominant lever, that stages beat legacy by 14%, and that the
|
|
identity's range was the bottleneck. Anything under about 0.01 of spread needs
|
|
`&repeats=3` before it is believed, and a difference that changes sign with the
|
|
sample size is not a difference.
|
|
|
|
Migrate in batches, measure after each batch, and expect the per-batch effect to
|
|
be inside the noise. The trend across batches is the signal.
|