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
parent 518e9ea731
commit 144c8c918e
12 changed files with 910 additions and 39 deletions

View File

@ -113,6 +113,25 @@ scene also carries the identity better than a stage written from scratch to
carry it, which was a surprise and is why the whole library was migrated rather
than replaced.
### 1b. Give the subject a third dimension — payoff still unmeasured
`castSolid` / `castChorusSolid` / `castMarch`, and `consumes: ['form']`. The
song's protagonist as an assembly of solids instead of an outline, so its
silhouette CHANGES as the shot moves rather than merely rotating. Recipe in
`MIGRATION.md`, including the two guards that keep many instances affordable.
Three scenes carry it — Effigy, Floating Geometry, Swarm — which is 34.8% of
videos containing at least one, against 11.9% when only Effigy had it. That is
reach, not payoff.
The payoff is still unmeasured: the variety report has not been re-run against
a library with these in it, so nothing here says the videos are more varied.
What is measured is narrower — across four seeds, the lit area of Effigy's
subject varies 14-113% over one turn against Soloist's 3-51% for the same
rotation, which says the outline genuinely changes rather than merely spinning.
Whether that reaches the variety blocks is the open question, and it is the next
thing to run. Do not migrate a field scene onto it hoping for a win.
### 2. Let the song decide element SIZE
`stageScale()`. Not the size of your features relative to each other — the size

View File

@ -38,6 +38,7 @@ will be misfiled.
| **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.
@ -113,6 +114,67 @@ 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.

View File

@ -157,6 +157,21 @@ export function runSceneGate(name) {
// The protagonist's geometry is read from the signature form.
other.shape = { sides: 8, roundness: 0.02, elongation: 1.4, tilt: 0.9 };
}
if (artifact === 'form') {
// Pin an assembly that is unmistakably not the fallback profile:
// a five-fold radial with a limb carved out of the body. A scene
// that renders this the same as a plain extrusion is treating
// the solid as a modifier, which is what the gate is for.
alt.form = {
symmetry: 'radial', symmetryN: 5, blend: 0.22, depth: 1.5,
chorus: { count: 2, symmetry: 'mirror', symmetryN: 3, flat: 1.5, thin: 0.6 },
parts: [
{ kind: 'prism', op: 'union', offset: [0, 0, 0], scale: [0.8, 0.7, 0.5], yaw: 0.4, pitch: 0.2, round: 0.1 },
{ kind: 'capsule', op: 'blend', offset: [0.6, 0.25, 0.1], scale: [0.3, 0.5, 0.3], yaw: 1.1, pitch: -0.4, round: 0.2 },
{ kind: 'torus', op: 'carve', offset: [0, 0.1, 0], scale: [0.55, 0.3, 0.4], yaw: 0.2, pitch: 0.8, round: 0 },
],
};
}
engine.setLayerSpecs([{
module, params: defaultValues(module), seed: 4242,

View File

@ -1,7 +1,7 @@
import * as THREE from 'three';
import {
VERTEX_SHADER, buildFragmentShader, AUDIO_UNIFORMS,
SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS,
SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS, IDENTITY_ARRAY_UNIFORMS,
} from './shader-contract.js';
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js';
import { clampValue } from '../params/schema.js';
@ -47,6 +47,13 @@ export function buildShaderUniforms(module, baseParams, seed) {
const v = NEUTRAL_UNIFORMS[name];
uniforms[name] = { value: type === 'vec2' ? new THREE.Vector2(v[0], v[1]) : v };
}
// The assembly rides as an array of rows — see IDENTITY_ARRAY_UNIFORMS. The
// vectors are allocated once and written in place per frame, like u_colors.
for (const [name, def] of Object.entries(IDENTITY_ARRAY_UNIFORMS)) {
uniforms[name] = {
value: Array.from({ length: def.length }, () => new THREE.Vector4()),
};
}
for (const [name, def] of Object.entries(module.params || {})) {
if (!def.uniform || def.type === 'palette') continue;
@ -101,6 +108,13 @@ export function setFrameUniforms(layer, renderer, target, ctx) {
if (type === 'vec2') u[name].value.set(v[0], v[1]);
else u[name].value = v;
}
for (const [name, def] of Object.entries(IDENTITY_ARRAY_UNIFORMS)) {
const rows = signature[name] || [];
for (let i = 0; i < def.length; i++) {
const r = rows[i];
u[name].value[i].set(r ? r[0] : 0, r ? r[1] : 0, r ? r[2] : 0, r ? r[3] : 0);
}
}
// Framing is per shot and wins over the personality's neutral defaults —
// it is the operator's hand on a shot that is already set up, not a trait

View File

@ -133,6 +133,31 @@ export const IDENTITY_UNIFORMS = {
u_focusR: 'float', // their reach, in scene units
u_focusPull: 'float', // + draws the field in, - opens a void
u_impact: 'float', // index into Identity.IMPACTS
u_formCount: 'float', // parts in the assembly, 0 = fall back to the profile
u_formSym: 'float', // index into Identity.SYMMETRIES
u_formSymN: 'float', // its fold/repeat count
u_formBlend: 'float', // how far a `blend` op melts two parts together
u_formDepth: 'float', // how deep the solid is relative to how wide
// The chorus solid: the same parts, fewer of them, squashed differently.
u_formChorusN: 'float',
u_formChorusSym: 'float',
u_formChorusSymN: 'float',
u_formChorusFlat: 'float', // its height against the protagonist's
u_formChorusThin: 'float', // its depth against the protagonist's
};
/**
* The assembly, as fixed-width rows. See look/Identity.js formPartRows.
*
* Separate from the scalars above because it is an ARRAY, and three.js needs an
* array of Vector4 rather than a number the one place the uniform plumbing
* has to know the difference. Sized for MAX_FORM_PARTS × 3 rows and read only
* through castSDF3, so a scene never touches it directly.
*/
export const IDENTITY_ARRAY_UNIFORMS = {
u_formPart: { type: 'vec4', length: 18 },
};
export const FRAME_UNIFORMS = [
@ -161,6 +186,8 @@ ${Object.entries(SIGNATURE_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join(
${Object.entries(IDENTITY_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
${Object.entries(IDENTITY_ARRAY_UNIFORMS).map(([u, d]) => `uniform ${d.type} ${u}[${d.length}];`).join('\n')}
uniform sampler2D u_prev;
uniform int u_hasPrev;
@ -627,7 +654,6 @@ vec2 subjectWarp(vec2 p, float size, float amount) {
/** Which impact the song chose. 0 shift, 1 warp, 2 punch, 3 morph, 4 overlay. */
bool impactIs(float which) { return abs(u_impact - which) < 0.5; }
vec3 prev(vec2 uv) {
if (u_hasPrev == 0) return vec3(0.0);
return texture2D(u_prev, uv).rgb;
@ -648,6 +674,291 @@ vec3 sat3(vec3 x) { return clamp(x, 0.0, 1.0); }
vec2 framedUv(vec2 p) { return vec2(p.x / u_aspect, p.y) * 0.5 + 0.5; }
`;
/**
* The 3D cast helpers, compiled ONLY into scenes that declare
* `consumes: ['form']`.
*
* They belong to the contract like everything else, but not to the preamble.
* A raymarch is a fixed-bound loop, GLSL compilers unroll those, and an
* unrolled march carries a copy of the assembly SDF per step so leaving
* these in the shared preamble made all 68 scenes pay a large compile for
* something 3 of them call. Measured, that was enough to make the check page
* look hung: every scene gate compiles the whole library for its distinctness
* comparison.
*/
export const FORM_PREAMBLE = `
// --- the cast in three dimensions -------------------------------------------
// The protagonist as a SOLID rather than as a silhouette. See Identity.js
// generateForm for what an assembly is and why it is worth having.
//
// The short version: an outline is the same picture from every angle, so a
// scene that turns one is showing you the same shape rotated. A solid's
// outline changes as it turns, and that is a variety axis the library did not
// previously have — but only for a scene that actually moves the camera or the
// object, which is why the helpers below take a ray rather than a point.
//
// Everything here is a distance function of position alone: no state, no
// integration, so a seek lands exactly where playback would. Same rule as the
// rest of the contract.
mat3 formRot(float yaw, float pitch) {
float cy = cos(yaw), sy = sin(yaw);
float cp = cos(pitch), sp = sin(pitch);
return mat3(cy, 0.0, -sy, sy * sp, cp, cy * sp, sy * cp, -sp, cy * cp);
}
float sminForm(float a, float b, float k) {
if (k <= 0.0001) return min(a, b);
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
/** The song's own profile, extruded. The part kind that keeps the cast's face. */
float formPrism(vec3 q, vec3 r) {
vec2 rr = max(r.xy, vec2(1e-3));
// Scaled back by the SMALLER axis: a non-uniform scale is not a distance,
// and taking the larger one overestimates, which the march then overshoots
// into pinholes along the profile's edge.
float d2 = castMain(q.xy / rr) * min(rr.x, rr.y);
float dz = abs(q.z) - max(r.z, 1e-3) * u_formDepth;
return min(max(d2, dz), 0.0) + length(max(vec2(d2, dz), 0.0));
}
float formBox(vec3 q, vec3 r) {
vec3 d = abs(q) - max(r, vec3(1e-3));
return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0));
}
float formCapsule(vec3 q, vec3 r) {
float rad = max(min(r.x, r.z), 1e-3);
float h = max(r.y, 1e-3);
q.y -= clamp(q.y, -h, h);
return length(q) - rad;
}
float formTorus(vec3 q, vec3 r) {
vec2 c = vec2(length(q.xz) - max(r.x, 1e-3), q.y);
return length(c) - max(r.z * 0.45, 1e-3);
}
/**
* Fold a point so the parts repeat. This is what turns an assembly from debris
* into an object see Identity.js SYMMETRIES.
*/
vec3 formFold(vec3 q, float k, float folds) {
if (k < 0.5) return q;
if (k < 1.5) { q.x = abs(q.x); return q; }
if (k < 2.5) { // radial about the up axis
float n = max(folds, 2.0);
float seg = 6.28318530718 / n;
float a = atan(q.z, q.x);
float r = length(q.xz);
a = abs(mod(a + seg * 0.5, seg) - seg * 0.5);
return vec3(cos(a) * r, q.y, sin(a) * r);
}
// stack: a bounded repeat up the form's own axis, so it is a column of a
// known height rather than an infinite one the ray never escapes.
float n = max(folds, 2.0);
float h = 1.6 / n;
float lim = (n - 1.0) * 0.5;
q.y -= h * clamp(floor(q.y / h + 0.5), -lim, lim);
return q;
}
/**
* Signed distance to an assembly, radius ~1.
*
* Shared by the protagonist and the chorus, which differ only in how many parts
* they take, how those parts repeat, and their proportions see Identity.js.
* The prop argument scales each part's height and depth, which is what makes the
* chorus a relative of the protagonist rather than a smaller copy of it.
*
* Falls back to the flat profile extruded when the song brought no assembly, so
* a scene may call this unconditionally and still draw the right character.
*/
float formSDF(vec3 q, float count, float sym, float symN, vec2 prop) {
q = formFold(q, sym, symN);
if (count < 0.5) return formPrism(q, vec3(1.0, prop.x, 0.6 * prop.y));
float d = 1e3;
for (int i = 0; i < 6; i++) {
if (float(i) >= count) break;
vec4 row0 = u_formPart[i * 3];
vec4 row1 = u_formPart[i * 3 + 1];
vec4 row2 = u_formPart[i * 3 + 2];
vec3 p = formRot(row2.x, row2.y) * (q - row0.xyz);
vec3 r = row1.xyz * vec3(1.0, prop.x, prop.y);
float pd;
if (row0.w < 0.5) pd = formPrism(p, r);
else if (row0.w < 1.5) pd = formBox(p, r);
else if (row0.w < 2.5) pd = formCapsule(p, r);
else if (row0.w < 3.5) pd = formTorus(p, r);
else pd = length(p / max(r, vec3(1e-3))) * min(r.x, min(r.y, r.z))
- min(r.x, min(r.y, r.z));
pd -= row2.z * 0.15;
if (i == 0) d = pd;
else if (row1.w < 0.5) d = min(d, pd);
else if (row1.w < 1.5) d = sminForm(d, pd, u_formBlend);
else d = max(d, -pd);
}
return d;
}
/** The protagonist as a solid, radius ~1. */
float castSDF3(vec3 q) {
return formSDF(q, u_formCount, u_formSym, u_formSymN, vec2(1.0));
}
/**
* The chorus member as a solid many of these, small. The 3D counterpart of
* castChorus, and what a scene fills a space with.
*/
float castChorus3(vec3 q) {
return formSDF(q, u_formChorusN, u_formChorusSym, u_formChorusSymN,
vec2(u_formChorusFlat, u_formChorusThin));
}
// Surface normals, by the tetrahedron trick: four samples rather than the six
// central differences take. The assembly SDF is the expensive call in this
// contract — up to six primitives per evaluation — so two saved samples per
// shaded pixel is worth more here than the marginal accuracy, and at the sizes
// these are drawn the difference is not visible.
vec3 castNormal3(vec3 q) {
vec2 k = vec2(1.0, -1.0);
float e = 0.0025;
return normalize(k.xyy * castSDF3(q + k.xyy * e) + k.yyx * castSDF3(q + k.yyx * e)
+ k.yxy * castSDF3(q + k.yxy * e) + k.xxx * castSDF3(q + k.xxx * e));
}
vec3 castChorusNormal3(vec3 q) {
vec2 k = vec2(1.0, -1.0);
float e = 0.004;
return normalize(k.xyy * castChorus3(q + k.xyy * e) + k.yyx * castChorus3(q + k.yyx * e)
+ k.yxy * castChorus3(q + k.yxy * e) + k.xxx * castChorus3(q + k.xxx * e));
}
/** A rotation a scene can hand the instance helpers. */
mat3 castTurn(float yaw, float pitch) { return formRot(yaw, pitch); }
/**
* March a ray at the solid. Returns the hit distance, or -1 for a miss, and
* writes the surface normal.
*
* The canned version exists so a scene that wants the song's object in its world
* costs five lines rather than a raymarcher the same bargain castForm makes in
* two dimensions, and the reason the library has sixty scenes.
*/
float castMarch(vec3 ro, vec3 rd, float far, out vec3 n) {
n = vec3(0.0, 0.0, 1.0);
float t = 0.0;
float hit = -1.0;
for (int i = 0; i < 64; i++) {
float d = castSDF3(ro + rd * t);
if (d < 0.0012) { hit = t; break; }
// Slightly under-relaxed: the boolean ops are not true distances at the
// seams, and a full step overshoots them into visible pinholes.
t += d * 0.82;
if (t > far) break;
}
// The normal is taken AFTER the loop, never inside it. GLSL compilers
// unroll a fixed-bound march, so a normal in the loop body multiplies four
// more copies of the assembly SDF by the step count — which compiled fine
// for one call site and made a fourteen-instance scene take minutes to
// build, with the check page sitting there looking hung.
if (hit < 0.0) return -1.0;
n = castNormal3(ro + rd * hit);
return hit;
}
/**
* The solid shaded in the track's palette and value structure.
*
* Lighting is a look decision, so it belongs here rather than in each scene:
* two stages that both march the protagonist should agree about which way the
* key light points, exactly as they agree about the lattice.
*/
/**
* One INSTANCE of the solid, drawn where a 2D scene was stamping the profile.
*
* The local argument is the pixel in the instance's own frame (p - centre) /
* size, so an existing scene passes exactly what it already computes for
* castMain. Returns depth into the object, or -1 for a miss, and writes the
* world-space normal.
*
* Orthographic, and that is the point: a scene drawing thirty small objects
* wants each one to look solid, not to share one perspective camera it does not
* have. The bounding-sphere reject before the march is what makes thirty of them
* affordable cost follows the instances a pixel actually touches rather than
* the instances in the frame, so a sparse field costs almost nothing.
*/
// The span of the ray that can possibly be inside the object: the chord of the
// bounding sphere, from a camera two radii back. Starting the march at the
// SPHERE rather than at the camera is what makes many instances affordable —
// the first draft marched the empty two radii in front of every object, spent
// most of its step budget there, and made a fourteen-element field slow enough
// that the scene gate stopped finishing.
#define CAST_SPHERE_R2 1.3
float castSolid(vec2 local, mat3 turn, out vec3 n) {
n = vec3(0.0, 0.0, -1.0);
float h = CAST_SPHERE_R2 - dot(local, local);
if (h <= 0.0) return -1.0;
float half_ = sqrt(h);
vec3 ro = turn * vec3(local, -2.0);
vec3 rd = turn * vec3(0.0, 0.0, 1.0);
float t = 2.0 - half_;
float far = 2.0 + half_;
float hit = -1.0;
for (int i = 0; i < 24; i++) {
float d = castSDF3(ro + rd * t);
if (d < 0.004) { hit = t; break; }
t += d * 0.82;
if (t > far) break;
}
if (hit < 0.0) return -1.0;
n = castNormal3(ro + rd * hit) * turn;
return hit;
}
/** The same, for a chorus member. Fewer steps: these are drawn small. */
float castChorusSolid(vec2 local, mat3 turn, out vec3 n) {
n = vec3(0.0, 0.0, -1.0);
float h = CAST_SPHERE_R2 - dot(local, local);
if (h <= 0.0) return -1.0;
float half_ = sqrt(h);
vec3 ro = turn * vec3(local, -2.0);
vec3 rd = turn * vec3(0.0, 0.0, 1.0);
float t = 2.0 - half_;
float far = 2.0 + half_;
float hit = -1.0;
for (int i = 0; i < 12; i++) {
float d = castChorus3(ro + rd * t);
if (d < 0.008) { hit = t; break; }
t += d * 0.82;
if (t > far) break;
}
if (hit < 0.0) return -1.0;
n = castChorusNormal3(ro + rd * hit) * turn;
return hit;
}
vec3 castLit(vec3 n, vec3 rd) {
vec3 key = normalize(vec3(0.45, 0.75, 0.5));
float diff = max(dot(n, key), 0.0);
float fill = max(dot(n, -key), 0.0) * 0.35;
float rim = pow(1.0 - max(dot(n, -rd), 0.0), 2.5);
vec3 col = mix(pal(1) * 0.22, pal(2), diff);
col += pal(0) * fill;
col += pal(3) * rim * 0.55;
return inkValue(col);
}
`;
const EPILOGUE = `
void main() {
vec2 uv = vUv;
@ -689,8 +1000,13 @@ export function buildFragmentShader(sceneModule) {
paramUniforms.push(`uniform ${glslType} ${def.uniform}; // param: ${name}`);
}
// The solid helpers are opt-in: see FORM_PREAMBLE for why they are not in
// the shared preamble.
const wantsForm = (sceneModule.consumes || []).includes('form');
return [
PREAMBLE,
wantsForm ? FORM_PREAMBLE : '',
paramUniforms.join('\n'),
'\n// ---- scene ----\n',
sceneModule.shader,

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}` +

View File

@ -59,8 +59,13 @@ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
* A trait is a modifier a scene may honour; an artifact is CONTENT the scene
* draws. Declaring one is a commitment the gate enforces: swap the song's
* identity and a scene that claims `cast` must produce a different picture.
*
* `form` is `cast` in three dimensions the protagonist as an assembly of
* solids rather than as an outline. A scene declaring it marches the object,
* which is what makes its silhouette change as the shot moves; a scene that
* only stamps the flat profile declares `cast` and not this.
*/
export const ARTIFACT_NAMES = ['cast', 'ink', 'staging'];
export const ARTIFACT_NAMES = ['cast', 'ink', 'staging', 'form'];
/**
* What a scene is for, compositionally.

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,
];

View File

@ -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);

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;

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);

View File

@ -94,6 +94,9 @@ const CONTRACT_UNIFORMS = new Set([
'u_latKind', 'u_latJitter', 'u_latSpread',
'u_latScaleSpread', 'u_latScaleBias', 'u_latScale',
'u_focusN', 'u_focusR', 'u_focusPull', 'u_impact',
'u_formCount', 'u_formSym', 'u_formSymN', 'u_formBlend', 'u_formDepth',
'u_formPart', 'u_formChorusN', 'u_formChorusSym', 'u_formChorusSymN',
'u_formChorusFlat', 'u_formChorusThin',
]);
/**
@ -123,55 +126,112 @@ const CONTRACT_UNIFORMS = new Set([
// `consumes` is a comment, and the migration becomes unverifiable the moment it
// is more than a handful of files.
const ARTIFACT_EVIDENCE = {
// The solid. Distinct from `cast` rather than a superset of it: these names
// deliberately do not match the cast pattern below, so a scene that marches
// the object is not also made to declare the silhouette it never stamps.
form: /\b(castSDF3|castChorus3|castNormal3|castChorusNormal3|castMarch|castSolid|castChorusSolid|castLit)\s*\(/,
cast: /\bcast(Main|Chorus|SDF|Form)\s*\(/,
ink: /\bink(Mask|Value|Pattern|Stroke)\s*\(/,
staging: /\b(stageNode|stageScale)\s*\(/,
};
const TRAIT_EVIDENCE = {
shape: /\b(sig(Shape|Form)|cast(Main|Chorus|SDF|Form))\s*\(/,
// Marching the solid is the fullest expression of `shape` there is: the
// prism parts ARE the signature profile, given a body.
shape: /\b(sig(Shape|Form)|cast(Main|Chorus|SDF|Form|SDF3|Chorus3|March|Solid|ChorusSolid))\s*\(/,
camera: /\bsigCamera\s*\(/,
space: /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/,
style: /\b(sigEdge|sigGrain|sigFolded|ink(Mask|Stroke|Value|Pattern))\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/,
};
// Every shader in this project lives inside a JS template literal, so a
// backtick anywhere in one silently closes it. Three times now that has cost a
// debugging round: twice in the preamble, where it produced a check page that
// hung on "starting…" with an empty console, and once in a scene, where at
// least the module failed to parse loudly. A GLSL comment is the natural place
// to reach for backticks when quoting a param name, which is exactly why this
// keeps happening.
// backtick anywhere in one silently closes it. Five times now that has cost a
// debugging round: in the preamble, where it produces a check page that hangs
// on "starting…" with an empty console, and in scenes, where at least the
// module fails to parse loudly. A GLSL comment is the natural place to reach
// for backticks when quoting a param name, which is exactly why this keeps
// happening.
//
// HOW THIS IS CHECKED, and why the obvious way does not work. The first version
// counted backticks for parity and scanned each `...` pair for stray ones. Both
// tests pass when the mistake comes in a PAIR — quoting `prop` in a doc comment
// adds two, parity survives, and the pair-scanner simply re-partitions the file
// into different "literals" and finds nothing inside them. That version was in
// place, green, while the contract was broken.
//
// So the check is anchored instead: find where a shader literal OPENS, then
// require that the next backtick is a real terminator — one followed by the
// comma, semicolon or brace that closes the declaration. A backtick anywhere in
// between is the bug, whatever the file's parity says.
//
// Checked across the contract AND every scene, since the scene case is the one
// a mechanical pass over sixty files will keep reintroducing.
/**
* Index of the backtick that closes the template literal starting at `from`, or
* -1. Interpolations are skipped wholesale, nested templates and all the
* preamble builds its uniform block with `${LIST.map((u) => \`\`)}`, and those
* inner backticks are legal.
*/
function endOfLiteral(src, from) {
let i = from;
let depth = 0;
while (i < src.length) {
const c = src[i];
if (c === '\\') { i += 2; continue; }
if (depth === 0 && c === '`') return i;
if (c === '$' && src[i + 1] === '{') { depth++; i += 2; continue; }
if (depth > 0) {
if (c === '{') depth++;
else if (c === '}') depth--;
else if (c === '`') {
const inner = endOfLiteral(src, i + 1);
if (inner < 0) return -1;
i = inner;
}
}
i++;
}
return -1;
}
console.log('\nshader literals');
{
const targets = [join(SRC, 'engine/shader-contract.js'), ...walk(join(SRC, 'scenes'))];
let clean = 0;
let literals = 0;
for (const file of targets) {
const src = readFileSync(file, 'utf8');
const rel = relative(SRC, file).replace(/\\/g, '/');
// Every template literal in the file, then the lines inside them that
// carry a backtick without being an interpolation.
const stray = [];
const rx = /`([\s\S]*?)`/g;
const problems = [];
// Where a shader string is declared: `shader: \`` or `const X = \``.
const opens = /(?:shader\s*:|[A-Za-z_$][\w$]*\s*=)\s*`/g;
let m;
while ((m = rx.exec(src)) !== null) {
if (!/\bvec4 scene|precision highp|void main/.test(m[1])) continue;
for (const line of m[1].split('\n')) {
if (line.includes('`') && !line.includes('${')) stray.push(line);
while ((m = opens.exec(src)) !== null) {
const from = m.index + m[0].length;
const end = endOfLiteral(src, from);
if (end < 0) { problems.push('unterminated template literal'); break; }
const body = src.slice(from, end);
// Only shader strings are governed; an ordinary template literal is
// free to contain whatever it likes.
if (!/\bvec4 scene|precision highp|void main|gl_Position/.test(body)) continue;
literals++;
const after = src.slice(end + 1, end + 4);
if (!/^\s*[,;)\]}]/.test(after)) {
const line = src.slice(0, end).split('\n').pop();
problems.push(`closed early at: ${line.trim()}`);
}
opens.lastIndex = end + 1;
}
// A shader body that swallowed a closing backtick shows up as an
// unbalanced count across the file.
const ticks = (src.match(/`/g) || []).length;
if (stray.length || ticks % 2 !== 0) {
fail(`${rel}: backtick inside a shader literal — it closes the string` +
(stray.length ? `:\n${stray.map((l) => ` ${l.trim()}`).join('\n')}` : ''));
if (problems.length) {
fail(`${rel}: backtick inside a shader literal — it closes the string:\n` +
problems.map((p) => ` ${p}`).join('\n'));
} else clean++;
}
if (clean === targets.length) ok(`${clean} shader literals balanced, no stray backticks`);
if (clean === targets.length) ok(`${literals} shader literals close where they should`);
}
console.log('\nscene schema lint');