diff --git a/flow-state/HOWTO-variety.md b/flow-state/HOWTO-variety.md index 1ba96d3..26605e8 100644 --- a/flow-state/HOWTO-variety.md +++ b/flow-state/HOWTO-variety.md @@ -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 diff --git a/flow-state/MIGRATION.md b/flow-state/MIGRATION.md index 550dcd1..a2f8881 100644 --- a/flow-state/MIGRATION.md +++ b/flow-state/MIGRATION.md @@ -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. diff --git a/flow-state/src/checks/scene-gate.js b/flow-state/src/checks/scene-gate.js index d58cd49..09fd333 100644 --- a/flow-state/src/checks/scene-gate.js +++ b/flow-state/src/checks/scene-gate.js @@ -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, diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js index 42f609a..e48ecd0 100644 --- a/flow-state/src/engine/Layer.js +++ b/flow-state/src/engine/Layer.js @@ -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 diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js index 78c8a1a..6c8dd8b 100644 --- a/flow-state/src/engine/shader-contract.js +++ b/flow-state/src/engine/shader-contract.js @@ -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, diff --git a/flow-state/src/look/Identity.js b/flow-state/src/look/Identity.js index 6d1e5aa..bc5d5d7 100644 --- a/flow-state/src/look/Identity.js +++ b/flow-state/src/look/Identity.js @@ -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}` + diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js index cab04fe..22713bf 100644 --- a/flow-state/src/params/schema.js +++ b/flow-state/src/params/schema.js @@ -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. diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index 23a11b8..99fc08b 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -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, ]; diff --git a/flow-state/src/scenes/shader/floating-geometry.js b/flow-state/src/scenes/shader/floating-geometry.js index ec0d8dc..869c9e5 100644 --- a/flow-state/src/scenes/shader/floating-geometry.js +++ b/flow-state/src/scenes/shader/floating-geometry.js @@ -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); diff --git a/flow-state/src/scenes/stage/effigy.js b/flow-state/src/scenes/stage/effigy.js new file mode 100644 index 0000000..da389b4 --- /dev/null +++ b/flow-state/src/scenes/stage/effigy.js @@ -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; diff --git a/flow-state/src/scenes/stage/swarm.js b/flow-state/src/scenes/stage/swarm.js index ba62b60..ffbf9eb 100644 --- a/flow-state/src/scenes/stage/swarm.js +++ b/flow-state/src/scenes/stage/swarm.js @@ -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); diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index b7cdb45..84ccca8 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -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');