Compare commits
10 Commits
ef1d24e692
...
a9b028b0bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b028b0bf | ||
|
|
e5484c9c38 | ||
|
|
b2c9497bae | ||
|
|
f8e8d91c9b | ||
|
|
b98aa94dda | ||
|
|
93a5dc8437 | ||
|
|
16bb67e703 | ||
|
|
fcbde602de | ||
|
|
f60a29ea97 | ||
|
|
7aa60d7336 |
336
flow-state/EPIC-5-3D.md
Normal file
336
flow-state/EPIC-5-3D.md
Normal file
@ -0,0 +1,336 @@
|
||||
# Epic 5 — a stage with depth, a cast with bodies
|
||||
|
||||
> Every scene so far is an abstract flat image held full-frame. The song's cast is a shared silhouette, inked on a shared lattice. It is recognisably this track's, and it is still a sticker.
|
||||
|
||||
This epic puts the cast *in* a space, on a ground, seen by a camera that can be close or far, with occlusion and parallax that a fragment shader can only fake. And it gives the cast bodies — a **3D actor/model generator** that takes parameters and returns a mesh unique to the song and seed, so a later library of actors (Stage C) has something to be a library *of*.
|
||||
|
||||
---
|
||||
|
||||
## 0. Where the project is
|
||||
|
||||
**Stack:** `three@0.181.1` + WebGL2 via `vite`, no React. Deterministic core — `Rng` + analytic `f(t,index,seed)`, no `Math.random`, no wall-clock, `Timeline` injects `{frame,time,dt,progress}`. `Renderer` owns a fullscreen-quad rig + `createTarget`/`blit`/`renderScene`. `Compositor` owns `layerTarget`/`accumA/B`/`historyA/B`/`bloomA/B`/`outputTarget`, plus `BLEND_FRAG`/`FEEDBACK_FRAG`/`BRIGHT_FRAG`/`BLUR_FRAG`/`COMPOSITE_FRAG`.
|
||||
|
||||
**Two layer kinds exist today** (`src/engine/Layer.js`):
|
||||
|
||||
- `ShaderLayer` — 68 of 69 modules. `buildFragmentShader` compiles `VERTEX_SHADER + PREAMBLE (+ FORM_PREAMBLE when consumes.includes('form')) + param uniforms + shader body + EPILOGUE`. Depth is faked with SDF raymarch (`castSDF3`/`castSolid`/`castMarch`/`castLit`), perspective grids (`1/(horizon - p.y)`), `sigHorizonY()`, `sigAir()`.
|
||||
- `SceneLayer` — 1 module: `src/scenes/layers3d/particles.js`. Real `THREE.Scene + PerspectiveCamera(60,16/9,0.1,200)`, `build({scene,camera,seed,params,THREE})` / `update({instance,scene,camera,timeline,features,params,palette,personality,framing,opacity,THREE})`. Analytic `z = fract(depthSeed + t*rise*...)`, `framing` applied as dolly `4/scale`, `personality.camera` as `sin/cos` pan/sway/roll. Proof the compositor is hybrid.
|
||||
|
||||
**The compositor is still 2.5-D.** Each layer renders to `layerTarget` (RGBA, `depth:true` allocated but never shared), then `BLEND_FRAG` composites into `accumA/B` with `normal/add/screen/multiply/overlay/softlight/lumakey`. Depth from one layer never occludes another. Two `ShaderLayer`s over a `SceneLayer` are stacked pictures, not a scene with depth.
|
||||
|
||||
**"3D" already has a contract** (`src/engine/shader-contract.js`): `FORM_PREAMBLE` (opt-in, only when `consumes.includes('form')`) gives `castSDF3`/`castChorus3`/`castSolid(local,turn)`/`castChorusSolid`/`castMarch`/`castLit`/`castNormal3`. `src/look/Identity.js:generateForm` builds a 6-part assembly (`SOLIDS: prism/box/capsule/torus/sphere` + `SYMMETRIES: none/mirror/radial/stack` + `FORM_OPS: union/blend/carve`, `MAX_FORM_PARTS=6`, `formRot`/`formFold`/`sminForm`/`formPrism`/`formBox`/`formCapsule`/`formTorus`, bounding-sphere chord `CAST_SPHERE_R2=1.3`, 24/12-step march, under-relaxed `0.82`). `pylon-grid.js` and `synthwave-run.js` already march dozens of *orthographic impostors* (`castSolid` per pylon/passer, `castTurn(yaw,pitch)` per instance). The ceiling they document is the reason for this epic: *an outline is the same picture from every angle; a solid's outline changes as it turns.*
|
||||
|
||||
**Camera today is a 2-D transform** (`src/look/Camera.js` + `src/look/framing.js`): `Camera.planGaze` (axial reach, `jumpFor`/`targetFor`/`timingFor`/`relocatesAt`, `reachFor(scale,camera)`, `gazeAt(move,frames)`, curves `snap/glide/drift/settle`, directors name a camera `contemplative/kinetic/deliberate/roaming/precise`) + `ArcDriver._framingAt` (size constant per shot, shift live via gaze, `p / scale + shift` in `EPILOGUE`). `particles.js` maps `scale→dolly` as `4/scale`.
|
||||
|
||||
**Identity already decides content** (`src/look/Identity.js` + `src/look/Personality.js`): `castMember` (sides/round/elong/tilt/notchCount/notchDepth/hollow), `generateForm` (parts under symmetry, ops, `chorus` as `count/symmetry/symmetryN/flat/thin`, `blend`/`depth`), `LATTICES` (`grid/radial/spiral/scatter/strata` → `stageNode(i,n)` with `xy` + `z` scale, `stageScale()` = `u_latScale/0.35`), `FILLS`/`IMPACTS` (`shift/warp/punch/morph/overlay`), `FOCUS`, ink (`weight/edge/fill/hatchAngle/hatchScale/outline/posterize`). `src/scenes/surface.js` derives `surfaceOf`/`canGround` from `src/scenes/metadata.json` (`GROUND_MIN=0.5`, `GROUND_BIAS`, `groundTemperamentFrom`, `groundPersonalityFrom` — hollow→flat for grounds). `ArcDriver` already owns drift/slow-axis/gaze/palette-plan/story.
|
||||
|
||||
**Diagnosis in one line:** we have a production-design system (`Identity + Personality + Camera + ArcDriver + Story`) rendering mostly through a flat projector. The fastest path to depth is to keep the design system and change the projector — and to give the cast bodies so the projector has something to film.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this epic is and is not
|
||||
|
||||
**Is:** depth that is visible (parallax, occlusion, contact, scale-foreshortening, DOF), a camera that is a place in a space rather than a coordinate transform, and actors that are this song's — generated from parameters, reproducible from the seed, different between seeds/songs, audibly tilted but not determined.
|
||||
|
||||
**Is not (yet):** a model viewer, a glTF asset pipeline, WebGPU, physics, or a bespoke hero per track. Those are Stage C. This epic builds the Stage A+B rig that Stage C will be a library *on top of*.
|
||||
|
||||
Three levels, in the order they matter:
|
||||
|
||||
| Level | Viewer sees | Reuses |
|
||||
|---|---|---|
|
||||
| **A — real depth from existing content** | Same protagonist/chorus forms as meshes on a real ground, perspective camera, shared depth, contact shadows | `Identity.form → mesh`, `stageNode → world position`, `Camera/framing → camera rig` |
|
||||
| **B — kit of small authored bases** | Same as A but parts are not only `prism/box/capsule/torus/sphere` — 15-20 curated low-poly bases deformed by `sides/round/elong/tilt/notch/hollow` | Kit + Identity deformers + palette materials |
|
||||
| **C — actor library (explicitly deferred but designed for)** | Named actors (characters/vehicles/structures) assembled from B, with skeletons/poses, one per track as the protagonist body | `actors/` library, `ActorGenerator` (this epic), kit |
|
||||
|
||||
**This plan designs and builds A, prototypes B, and leaves C as a growing library that requires no infra change.**
|
||||
|
||||
---
|
||||
|
||||
## 2. The actor / model generator — the centre of the epic
|
||||
|
||||
Everything else in the plan is scaffolding for this.
|
||||
|
||||
### 2.1 Contract
|
||||
|
||||
```js
|
||||
// src/actors/ActorGenerator.js (new, pure, no three.js import at generation time)
|
||||
import { Rng } from '../engine/rng.js';
|
||||
|
||||
export const ACTOR_ARCHETYPES = [
|
||||
'monolith', // one large solid — the protagonist body, Stage A
|
||||
'swarm', // many small chorus instances — already exists as chorus, now as meshes
|
||||
'walker', // articulated: two or three hinged parts, analytic gait ← C
|
||||
'vehicle', // chassis + orientation axis, verges/streaming motion ← C
|
||||
'structure', // ground-anchored, heightfield-aware ← C
|
||||
];
|
||||
|
||||
export function generateActor({ summary, rng, archetype, personality, identity }) {
|
||||
// returns ActorSpec — data, not scene graph
|
||||
}
|
||||
```
|
||||
|
||||
**Inputs** (mirrors `generatePersonality`/`generateIdentity`):
|
||||
|
||||
- `summary` (`FeatureTrack.summary`: `meanCentroid`, `meanFlatness`, `bpm`, `dynamicRange`, `meanLoudness`, `sections`) — tilts centres, never decides.
|
||||
- `rng` — a fork (`rng.fork('actor:'+archetype)`), so adding an actor does not shift any decision made after it (same rule as `rng.fork('form')` in `Identity.js:311`).
|
||||
- `archetype` — optional; when absent the generator picks one weighted by audio.
|
||||
- `personality` + `identity` — so the actor IS the song's cast (same `sides/round/elong/tilt/notch/hollow`, same `SOLIDS`/`SYMMETRIES`/`FORM_OPS`, same `ink`/`lattice` family). The geometry is the signature form made concrete — same sides/rounding/tilt as `Personality.shape`, plus notches/hollows that turn a shape into a character. `identityUniforms(identity, shape)` already does the `shape→cast` reconciliation; the actor does it at the mesh level.
|
||||
|
||||
**Output — `ActorSpec` (serialisable, hashable, no live objects):**
|
||||
|
||||
```js
|
||||
{
|
||||
archetype, seed, // for HUD + determinism proof
|
||||
form: { parts, symmetry, symmetryN, blend, depth, chorus }, // from generateForm, or a kit variant
|
||||
kitRef: null | { id, deform: { sides, notchN, hollow } }, // Stage B
|
||||
rig: null | { joints: [{ parent, axis, range, phase, ratio }], gait: 'walk'|'sway'|'roll' },
|
||||
paletteMap: [0,1,2,3], // which ActorSpec part reads which palette entry
|
||||
scale: { base: number, spread: number }, // maps to stageScale() / stageNode.z
|
||||
placement: { latticeKind, spread, jitter }, // reconciled with Identity.lattice
|
||||
motion: { orbitRate, spin, bobAmp, bobRate }, // analytic, f(t,seed) — no integration
|
||||
}
|
||||
```
|
||||
|
||||
An `ActorSpec` is data. The stage that consumes it decides *where* to put it (`stageNode`) and *when* it moves (`timeline.time`), but it never invents *what* it is.
|
||||
|
||||
### 2.2 How songs get different actors
|
||||
|
||||
Same arrangement as `Personality`: **audio sets the centre, seed picks within it.**
|
||||
|
||||
- `angular = clamp01(noisy*0.6 + fast*0.3 + rng.range(-0.25,0.25))`
|
||||
- `intricate = clamp01(busy*0.5 + bright*0.3 + rng.range(-0.3,0.3))`
|
||||
- `solid = clamp01(0.5 - dynamic*0.4 + rng.range(-0.25,0.25))`
|
||||
|
||||
These already drive `castMember`/`generateForm`. The actor inherits them — so a bright, intricate track gets a notched, multi-part actor and a dark, sparse one gets a monolithic round one — but two seeds on one song still land in different places inside that region. **Two different songs are different actors; two seeds on one song are different readings of the same actor family.**
|
||||
|
||||
Applied to kit deformation (Stage B): the kit base mesh is chosen from `assets/kit/` (see §7), then its vertices are displaced by the same `sides/notchD/hollow` that `castSDF` uses — so the mesh keeps the song's silhouette exactly as the shader does.
|
||||
|
||||
### 2.3 Relation to `Identity.form`
|
||||
|
||||
Not a replacement. `Identity.form` is the artifact the shaders already consume via `FORM_PREAMBLE` (`castSDF3`/`castSolid`/`castMarch`/`castLit`). The actor generator is the mesh-side twin that produces a `BufferGeometry` from the *same* `form`:
|
||||
|
||||
```
|
||||
Identity.generateForm ─┬─► shader: FORM_PREAMBLE (SDF, imposter)
|
||||
└─► mesh: actorToGeometry(form, kitRef) (this epic)
|
||||
```
|
||||
|
||||
A track that brought no assembly (`u_formCount==0`) still renders correctly: `formSDF` falls back to `formPrism` extruded, `actorToGeometry` falls back to `extrudeCastProfile(identity.cast.protagonist)`.
|
||||
|
||||
The actor's `parts` array has exactly the same layout as `Identity.form.parts` (`offset xyz | kind`, `scale xyz | op`, `yaw/pitch/round`) so `formPartRows` and `actorToGeometry` consume the same rows. Adding an actor archetype never changes `formPartRows` width (`MAX_FORM_PARTS × 3 vec4`).
|
||||
|
||||
### 2.4 Library growth (Stage C) without infra change
|
||||
|
||||
```js
|
||||
// src/actors/library/monolith.js, walker.js, vehicle.js, ...
|
||||
export const monolith = {
|
||||
archetype: 'monolith',
|
||||
traits: ['shape','space'], // which personality traits it can express
|
||||
consumes: ['form','ink','staging'],
|
||||
// a function that maps an ActorSpec → THREE.Group, analytic f(t)
|
||||
instantiate: ({ spec, THREE, palette, identity }) => Group,
|
||||
};
|
||||
```
|
||||
|
||||
A new actor is a file plus a registry entry, exactly like a new shader scene. The look generator casts actors the way it casts scenes (`signatureAffinity`/`signatureWeight`), and a stage declares which archetype it wants (`actor: 'walker'`). The migration recipe (cf. `MIGRATION.md`) gains a Stage C appendix: replace `castSolid` imposter loop with `actorInstancedMesh` loop.
|
||||
|
||||
No new uniform type is needed. An actor that needs to vary per instance beyond what `form` already varies (e.g., walker gait phase) gets it via `InstancedBufferAttribute` seeded from `stageNode` + `ActorSpec.seed`, still analytic.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 `Renderer` — depth-aware targets
|
||||
|
||||
Today `createTarget(w,h,{depth, float})` allocates depth only for `layerTarget` and discards it on `blit`.
|
||||
|
||||
New:
|
||||
|
||||
```js
|
||||
// src/engine/Renderer.js
|
||||
createDepthTarget(w,h) // RGBAFormat + DepthTexture (UnsignedInt24)
|
||||
renderScene(scene,camera,target,{clear, withDepth:true})
|
||||
getDepthTexture() // shared depth of last model pass
|
||||
```
|
||||
|
||||
`Compositor` keeps one depth texture per slot if any active layer is `kind:'model'|'layer3d'`. Pure shader stacks keep the current `Copy/Blend` path unchanged.
|
||||
|
||||
### 3.2 `Layer` — introduce `ModelLayer` (do not overload `SceneLayer`)
|
||||
|
||||
```js
|
||||
// src/engine/Layer.js
|
||||
class ModelLayer extends Layer {
|
||||
// module: { kind:'model', build({scene,camera,seed,params,THREE,actorSpec,formMesh})
|
||||
// update({instance,scene,camera,timeline,features,params,palette,
|
||||
// personality,framing,opacity,actorSpec,THREE}) }
|
||||
// - scene is a THREE.Group owned by the layer
|
||||
// - camera is borrowed from Compositor.sharedCamera (see 3.3), not per-layer
|
||||
// - actorSpec is the ActorSpec for this stage (or null for kit-free Stages A)
|
||||
// - formMesh(kind, opts) -> BufferGeometry from Identity form
|
||||
}
|
||||
```
|
||||
|
||||
Keep `ShaderLayer` and `SceneLayer` as-is. `particles.js` stays `layer3d`. `ModelLayer` is for meshes. `createLayer` dispatches on `kind`.
|
||||
|
||||
`build()` runs once, seeded. `update()` is analytic per frame: no `position += velocity*dt` (same rule as `particles.js:3` header). `dispose()` disposes geometries/materials.
|
||||
|
||||
Injected helpers:
|
||||
|
||||
- `formToGeometry(part, identity)` — one `form.parts[i]` + `cast` profile → `BufferGeometry`. V1 is `ExtrudeGeometry` of the `castSDF` profile (`castMain(q/rr)*min(rr)` → 2-D outline → extrude by `u_formDepth`), or `LatheGeometry` for round forms. No marching cubes in V1.
|
||||
- `actorToGeometry(actorSpec, palette)` — the mesh path of §2.3. Handles both primitive assembly and `kitRef` deformation.
|
||||
- `paletteMaterial(index, {roughness, metalness})` — `MeshStandardMaterial` wired to `palette[index]` via `color.set(palette[i])`; `inkValue` grade still runs in `COMPOSITE_FRAG` so posterize/hollow still affect meshes via the grade pass. Kept separate from lighting: two stages that both march the protagonist must agree which way the key light points (same rationale as `castLit`).
|
||||
|
||||
### 3.3 Camera — one shared rig, not N cameras
|
||||
|
||||
Today each `SceneLayer` owns its `PerspectiveCamera`. That breaks shared depth and makes `gaze` diverge per layer.
|
||||
|
||||
New: `Compositor` owns `sharedCamera: PerspectiveCamera(60, aspect, 0.1, 200)` + `sharedScene` root for the depth prepass. `ArcDriver._framingAt` + `gazeAt` drive it centrally:
|
||||
|
||||
- `scale` → dolly `z = baseZ / scale` (centralise `particles.js:149`'s `4/scale`).
|
||||
- `shift` → camera `x,y` (or `lookAt` offset).
|
||||
- `Personality.camera` (`driftAngle/driftRate/sway/swayRate/spin/horizon`) → same sinusoids as `sigCamera` but as translation/roll: `pan = 20*sin(t*0.05)`, `x = cos(driftAngle)*driftRate*pan + sin(t*swayRate)*sway + shift[0]`, `z` roll `spin*t`, `lookAt(x, y, -depth*0.4)` (mirrors `particles.js:152-166`, now shared).
|
||||
- `u_sigHorizon` → ground `y = sigHorizonY()` so shader ground and mesh ground agree (already shared by `pylon-grid`, `synthwave-run`).
|
||||
|
||||
Shader layers that need depth-aware occlusion sample shared depth via opt-in uniform `u_sceneDepth` (module field `readsDepth:true`, which excludes it from `canGround` — a `prev()`-like entanglement, same as `readsHistory`).
|
||||
|
||||
### 3.4 `shader-contract.js` — keep, add sibling
|
||||
|
||||
`FORM_PREAMBLE` stays for shader impostors — it is the fallback when `u_formCount==0` and the opt-in that keeps `checks.html` distinctness sweep cheap (only `consumes.includes('form')` scenes pay the compile — measured as minutes saved). Add:
|
||||
|
||||
```js
|
||||
export const MODEL_PREAMBLE = `...` // JS-side helpers only; NOT appended to fragment shaders
|
||||
```
|
||||
|
||||
Shader scenes unchanged. Model scenes do not include fragment preamble.
|
||||
|
||||
New module fields (handled by `params/schema.js` + `scenes/surface.js`):
|
||||
|
||||
- `readsDepth: true` → shader samples `u_sceneDepth`; implies `canGround()==false`.
|
||||
- `actor: 'monolith'|'swarm'|...` → stage requests an `ActorSpec` of that archetype.
|
||||
|
||||
### 3.5 Staging / lattice → world space
|
||||
|
||||
Reuse `stageNode(i,n)` directly: `xy ∈ [-1,1] → worldXZ`, `z (scale) → instance scale`, `+ sigHorizonY()*0.3 → ground offset`. `procession.js:47`'s loop
|
||||
|
||||
```glsl
|
||||
vec3 node = stageNode(fi, total);
|
||||
float scale = mix(1.0, 0.35, back*u_recede) * node.z;
|
||||
```
|
||||
|
||||
becomes ~10 lines of `InstancedMesh` setup in `ModelLayer.build()` with the same `palette[i%N]`, `inkMask`→`paletteMaterial`, `stageScale()` mapping. Near/far LOD swaps `InstancedMesh` count, not geometry cost.
|
||||
|
||||
`look/stack.js` stays: slot 0 = ground (canvas) — now a plane/heightfield mesh when the subject is `model`; slot ≥1 = instanced subjects on it. Keep `blend:'normal'|'lumakey'` for shader+mesh composite; add `blend:'depth'` (depth-tested, no blend) when two model layers share `sharedCamera`.
|
||||
|
||||
### 3.6 Assets
|
||||
|
||||
- `src/assets/kit/` — 15-20 glTFs, <50KB gzipped each, Draco-compressed, single material slot. `THREE.GLTFLoader` + `DRACOLoader`, cached by `ArcDriver.layerCache` key, prewarmed in `Show.prewarm()` (fetch + `renderer.compileScene`). Budget: one stage loads ≤3 kit pieces. Growth path for Stage C is just adding files here.
|
||||
- `src/assets/models/` — reserved for bespoke heroes (C), not V1.
|
||||
- Deformation: kit vertices displaced by `sides/notchD/hollow` via a small vertex shader driven by the same uniforms shaders already read, so a seek is still exact.
|
||||
|
||||
### 3.7 Post
|
||||
|
||||
Keep bloom/feedback/grade. Add opt-in behind flags (off by default):
|
||||
|
||||
- Contact shadows — one `PCFSoftShadowMap` directional light, `shadowMap.enabled` only when a `model` layer is active.
|
||||
- SSAO — `three/examples/jsm/postprocessing/SAO` between `accum` and `feedback`, disabled at 720p preview, enabled at 1080p export.
|
||||
- DOF — `COMPOSITE_FRAG` switch reading shared depth, driven by `framing.scale` (close-up = shallow DOF).
|
||||
|
||||
---
|
||||
|
||||
## 4. What the first 3D stages are
|
||||
|
||||
Not "a 3D scene" — rebuilds of existing scenes measured A/B, so the variety instrument can see what moved.
|
||||
|
||||
1. **Pylon Field 3D** — Replace `pylon-grid.js:83-169` SDF imposter loop (`castSolid`/`castChorusSolid` per pylon with `castTurn` + bounding-sphere `member*1.3` + `painted` first-wins) with `InstancedMesh` for crowns + stacked `InstancedMesh` for legs. Ground is a plane at `hy = sigHorizonY()`. Proves chorus-stacked legs are more legible as meshes (current bottom-third crop 22.2/255 vs 13.4 with strut; whole-frame currently 0.132 vs flat 0.141 — expect the crop to finally move the frame score once depth is real). Uses `ActorSpec(archetype:'structure')`.
|
||||
|
||||
2. **Synthwave Corridor (ground + passers as meshes)** — Grid stays shader (`perspective = 1/(horizon - p.y)`, `abs(p.x*perspective)<0.8 → roadHalf = 0.8*(horizon-py+0.05)`, verge `roadHalf+psize*0.92+gap`, `bound=dot(p-passPos,p-passPos)/psize²`) but passers (`castChorus` verges) become `InstancedMesh` of `ActorSpec(archetype:'vehicle')` streaming on verges; hero `castSolid` at `heroPos = (sin(t*0.08)*0.18, -0.68+bob)` becomes `ActorSpec(archetype:'monolith')` mesh with real shadow ellipse (currently faked at `synthwave-run.js:179`).
|
||||
|
||||
3. **Assembly Stage** — One protagonist `Mesh` (full `form` assembly via `actorToGeometry` + `SYMMETRIES` as `InstancedMesh` folds, cf. `formFold`) on a `stageNode` chorus field. Camera orbits via `gaze` (shift + dolly, not just screen shift). This is the "solid's outline changes as it turns" promise from `Identity.js:112`.
|
||||
|
||||
All three: `consumes:['form','ink','staging']`, `traits:['shape','space','camera']`, `actor:'…'` and declare `slowAxis` on `count/spread/columns` so the slow-axis journey is measurable. Kit variants of (1) and (2) are the first B prototypes (swap `formToGeometry` primitive for `kitRef`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased rollout
|
||||
|
||||
### Phase 0 — infra, no visual change (1-2 days)
|
||||
|
||||
- `Renderer.createDepthTarget`/`renderScene` with depth, `Compositor.sharedCamera/sharedDepth`, `ModelLayer` skeleton, `formToGeometry` stub (extruded `cast` profile), `Show.prewarm` preloads kit manifest, `ActorGenerator` pure module with monolith archetype.
|
||||
- **Gate:** `npm run lint:scenes` + `checks.html?phase=4` (first-render determinism) green; `grep` still clean (`Math.random`/`performance.now`/`Date.now` only in allowed spots); dual-resolution diff still passes.
|
||||
|
||||
### Phase 1 — form → mesh (2-3 days)
|
||||
|
||||
- `Identity.form → actorToGeometry` (extrude of `castSDF` profile + `u_formDepth`; `box/capsule/torus` as primitives; `sminForm`/`formFold` mirrored in JS for assembly). Pylon 3D variant behind `?modelPylon=1` for A/B screenshots.
|
||||
- **Gate:** `checks.html?variety=1` — pylon bottom-third crop variety must rise (replicates `pylon-grid.js:41` measurement) without whole-frame collapsing; `phase12` seed variety still computable.
|
||||
|
||||
### Phase 2 — two stages ship (3-4 days)
|
||||
|
||||
- `src/scenes/stage/pylon-field-3d.js` + `src/scenes/stage/synthwave-corridor.js` (registered in `src/scenes/registry.js` as new modules, family `structural`). `LookGenerator` casts them like any `structural` scene (`DIRECTORS` already weight that family). `ActorGenerator` now serves `monolith`/`structure`/`vehicle`/`swarm`.
|
||||
- **Gate:** `filmstrip.html` 30s probes not interchangeable stills; `phase12` (seed variety) not regressed; `checks.html?scene=Pylon%20Field%203D` — trait/ink/staging gates green; `decompose` identity component rises.
|
||||
|
||||
### Phase 3 — kit + materials (when Phase 2 measures well, 2-3 days)
|
||||
|
||||
- `src/assets/kit/` (15 pieces), `paletteMaterial`, kit deformation by `sides/notch/hollow` (vertex displacement driven by identity uniforms). One new stage uses kit pieces as chorus.
|
||||
- **Gate:** `tools/build-song-bank.js` still builds, `src/scenes/metadata.json` regenerated via `gallery.html → refresh metadata`, `surfaceOf` re-derived for kit stages (`ASSUMED_COVERAGE=0.15` no longer needed for them); `phase6` `canGround` still holds.
|
||||
|
||||
### Phase 4 — depth polish, opt-in (1-2 days, flag-guarded)
|
||||
|
||||
- Shadows, SSAO, DOF behind `look.post` flags (`post.shadows`/`post.ssao`/`post.dof`). Enabled by `Story` tension (climax gets DOF, resolution gets haze), never by default. Fallback: `ModelLayer` renders `castSolid` impostor if `capabilities.isWebGL2===false` or `maxTextureSize<2048`.
|
||||
- **Gate:** dual-resolution diff (`uv/p` vs pixels) exact — any `u_resolution`-dependent shadow bias fails it; `phase5` feedback stability still 10k frames; export still `mp4-muxer` A/V sync within one frame.
|
||||
|
||||
### Phase 5 — library growth (ongoing, Stage C)
|
||||
|
||||
- `src/actors/library/` grows by adding files — `walker`/`vehicle`/`structure` archetypes, articulated rigs (analytic `sin/cos` gait, no physics). MIGRATION.md gains Stage C appendix: `castSolid` loop → `actorInstancedMesh` loop. `tools/new-scene.js --model` scaffolds `ModelLayer`; `tools/new-actor.js --archetype` scaffolds `ActorSpec`.
|
||||
- **Gate per actor:** same as `HOWTO-visualizers.md#verify` — `lint:scenes`, `checks.html?scene=`, then full suite before batch commit.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tooling & checks
|
||||
|
||||
- `tools/new-scene.js --model` scaffolds `ModelLayer` ( `kind:'model'`, `consumes`, `traits`, `actor`, `slowAxis`, `build`/`update` stubs that already map `stageNode` + `castTurn` as quaternions + `instanceMatrix.needsUpdate`).
|
||||
- `tools/new-actor.js --archetype=walker` scaffolds `ActorSpec` + `src/actors/library/<name>.js` + registry entry; bakes name-derived constants so two fresh actors are not twins.
|
||||
- `tools/lint-scenes.js` adds: `kind:'model'` must have `build`+`update`, must not declare `shader`; `readsDepth` scenes can't be `canGround`; `actor` must be a known archetype; `rate:true` exclusion still enforced (for `orbitRate` etc.); trait evidence checked for model scenes too.
|
||||
- `checks/scene-gate.js` — model scenes excluded from fragment-only distinctness compile sweep (they have no fragment shader), included in `variety` via WebGL readback.
|
||||
- `src/scenes/metadata.json` — add `kind` + `actor` to rows so `surface.js` does not pessimistically assume `ASSUMED_COVERAGE=0.15`. Measure kit stages like shader stages (`gallery.html → refresh metadata`).
|
||||
|
||||
---
|
||||
|
||||
## 7. What to decide at planning review
|
||||
|
||||
1. **Depth scope:** depth *between* layers (ground mesh behind shader subject) vs *within* a layer (one `ModelLayer` owns the whole 3D world). This plan picks **within a layer, composited at 2.5-D** — one 3D stage is one world, still stacked over a shader ground if needed via `lumakey`. Cheaper than a global scene graph, preserves `stack.js`/`groundPersonalityFrom`/`groundCoverageOf` logic.
|
||||
2. **Kit on day one?** Recommendation: **no** — Stage A proves the pipeline with primitives; B adds kit once A measures well. Tentative kit shortlist: extruded profile, rounded box, capsule, torus, cone, low-poly teapot/monkey/icosphere as deformation targets.
|
||||
3. **First stage to convert:** **Pylon Field** (proven variety story, clean ground plane, already documents the silhouette-vs-solid trade and has a crop measurement to beat).
|
||||
|
||||
If approved, this becomes `PLAN.md §15` + `HOWTO-visualizers.md` Appendix C + `MIGRATION.md` §C and the first commit is Phase 0.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| **Compile/link hitches** — each `ModelLayer` brings programs for shadow/SSAO/palette materials | `ArcDriver.prewarm` + `Compositor.prime` already exist; add `renderer.compileScene(sharedScene,sharedCamera)` there. Measure on `checks.html?phase=4`. |
|
||||
| **Mobile / low-end GPU** — instancing helps but shadows/SSAO hurt | Shadows/SSAO off by default; `ModelLayer` falls back to `castSolid` impostor when `isWebGL2===false` or `maxTextureSize<2048`. |
|
||||
| **Variety regression** — mesh fills silhouette uniformly vs stamped `inkMask`'s `flat/hatch/stipple/halftone/hollow` swing (already measured: pylon whole-frame 0.132 vs flat 0.141, `pylon-grid.js:41`) | Keep far rows as impostors or add `inkPattern` to `castLit` only for distant instances; keep imposter LOD for far field (already documented as the deliberate trade). |
|
||||
| **Determinism** — `InstancedMesh` + `lookAt` per frame can introduce order-dependent float error | Matrices set analytically from `timeline.time` + `seed`, `instanceMatrix.needsUpdate=true`, no `updateMatrixWorld` accumulation. Same rule as `particles.js:116` `fract(depthSeed + t*...)`. |
|
||||
| **Palette coherence** — `MeshStandardMaterial` doesn't read `u_colors` | `paletteMaterial` bakes `pal(i)` at `setPalette` time; `inkValue` posterize still runs in `COMPOSITE_FRAG` outline pass. |
|
||||
| **Actor homogenisation** — one `ActorGenerator` style becomes the house actor | Five archetypes + audio-tilted weights + per-actor `rng.fork`, same defense as `DIRECTORS`/`SIGNATURE_WEIGHTS`. Measure actor census (`tools/cast-census.js` extended) alongside scene census. |
|
||||
| **Stage C scope creep** — library wants rigs/physics before the rig is proven | Gate Stage C behind Phase 2 numbers; articulated walkers are Phase 5, gated per-actor like scenes. No physics — analytic `sin/cos` gaits only. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Validation — how we know it worked
|
||||
|
||||
Carries forward the gates from `PLAN.md §11`, `EPIC-2 §4`, `EPIC-3 §9a`, `EPIC-4 §6`.
|
||||
|
||||
| What | Gate |
|
||||
|---|---|
|
||||
| **Determinism** | `phase4` still green: fresh-engine frame vs later render ≤1 LSB (`max channel delta ≤1`), `prime()` still required (measured 10 bad frames on heaviest scene without it). |
|
||||
| **Framing** | Resolution independence survives (`dual-resolution diff`), `framing.shift/scale` visible on model stages (render delta > floor), determinism + gaze still pure `f(frame)` (`ArcDriver` memoised on rounded `reveal`/`shift`). |
|
||||
| **Variety** | `decompose` identity component non-zero and ≥ container component (cf. `MIGRATION.md:257` `identity 158% of container` after 18 scenes); `floor` still bounded by `GROUND_FLOOR_MIN`; `direction` still >0 over `arcless ref`. |
|
||||
| **Composition** | `composition — a rendered section is neither black nor blown out` still two-ended (painted ≥88% mean / darkest ≥43%, clipped median 0% / worst ≤21%) — ground mesh must not reintroduce the `hollow` 2% failure or the `screen`-as-default 24% clipping. |
|
||||
| **Per-scene** | `checks.html?scene=` 10-line battery for each new model stage (renders/animates/deterministic/distinct/param-sweep/flash-rate/trait evidence/consumes). |
|
||||
| **Per-actor** | `ActorGenerator` census: 6-12 songs × 2 seeds, each archetype appears, no actor within 0.03 of another in identity distance; two seeds on one song produce different `ActorSpec.parts` order but same family (measured like scene census). |
|
||||
| **Performance** | Full stack 60fps at preview; per-layer GPU cost budgeted; 10k-frame feedback stability; export A/V sync within one frame. Model layers report `InstancedMesh` count and `attribute.needsUpdate` churn. |
|
||||
|
||||
---
|
||||
|
||||
*Forked from `party-stage` by copying what was useful, then detached — no imports across the boundary. This epic keeps that rule: every new file lives under `flow-state/`, every new concept is data (`ActorSpec`/`paletteMap`/`rig`) before it is code, and every gate that exists today still runs unchanged.*
|
||||
@ -21,6 +21,18 @@
|
||||
</div>
|
||||
<div id="hud" hidden></div>
|
||||
<div id="toast" hidden></div>
|
||||
<div id="export-warn" hidden>
|
||||
<div class="ew-backdrop"></div>
|
||||
<div class="ew-box" role="dialog" aria-modal="true" aria-labelledby="ew-title">
|
||||
<div id="ew-title" class="ew-title">Software encoding — export will be slow</div>
|
||||
<div id="ew-body" class="ew-body"></div>
|
||||
<div class="ew-actions">
|
||||
<button id="ew-cancel">Cancel</button>
|
||||
<button id="ew-continue" class="primary">Continue anyway</button>
|
||||
</div>
|
||||
<label class="ew-opt"><input type="checkbox" id="ew-dontask"> Don't warn again this session</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="transport">
|
||||
|
||||
64
flow-state/package-lock.json
generated
64
flow-state/package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"three": "^0.181.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"vite": "^7.2.2"
|
||||
}
|
||||
},
|
||||
@ -475,6 +476,22 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
|
||||
@ -969,6 +986,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
},
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"vite": "^7.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -202,6 +202,25 @@ export class Show {
|
||||
this._lastLayers = layers.slice();
|
||||
}
|
||||
|
||||
// Drive the shared perspective rig for model layers: framing (scale→dolly,
|
||||
// shift→x,y) + personality.camera (drift/sway/spin) as real translation/roll.
|
||||
// No-op cost when no model layer is active — Compositor keeps the camera but
|
||||
// nothing reads it. Pure f(frame,look) so seek === playback still holds.
|
||||
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
|
||||
const pers = this.look.personality;
|
||||
const framing = this.arc.framingForFrame(timeline.frame);
|
||||
this.engine.compositor.updateSharedCamera({
|
||||
framing,
|
||||
personality: pers,
|
||||
time: timeline.time,
|
||||
});
|
||||
// Inject the shared camera into each active model layer so they render
|
||||
// through one perspective and one depth buffer.
|
||||
for (const l of sceneLayers) {
|
||||
if (l && l.module && l.module.kind === 'model') l.sharedCamera = this.engine.compositor.sharedCamera;
|
||||
}
|
||||
}
|
||||
|
||||
this.engine.compositor
|
||||
.setPost(this._postAt(timeline, features))
|
||||
.setFeedback(this.look.feedback);
|
||||
|
||||
216
flow-state/src/actors/ActorGenerator.js
Normal file
216
flow-state/src/actors/ActorGenerator.js
Normal file
@ -0,0 +1,216 @@
|
||||
// The cast with bodies.
|
||||
//
|
||||
// Identity gives the song a silhouette — sides, notches, hollows — and a solid
|
||||
// assembly (form) the shaders can march as SDF. This module gives the same song a
|
||||
// MESH: an ActorSpec that a ModelLayer can turn into BufferGeometry with
|
||||
// actorToGeometry, and later a library of named actors (Stage C) will grow on
|
||||
// top of it without changing the infra.
|
||||
//
|
||||
// Pure module: no three.js, no DOM, no wall-clock. Analytic like particles.js —
|
||||
// motion is f(t,seed), never integration, so seek === playback and preview ===
|
||||
// export. Seeded off the look seed via rng.fork('actor:...'), so adding an actor
|
||||
// never shifts a decision made after it (same rule as rng.fork('form') in
|
||||
// Identity.js:311).
|
||||
//
|
||||
// Audio tilts the centre, seed picks within — same arrangement as
|
||||
// generatePersonality/generateIdentity: two songs land in different regions,
|
||||
// two seeds on one song land in different places inside one region.
|
||||
|
||||
import { generateIdentity, SOLIDS, SYMMETRIES, FORM_OPS, MAX_FORM_PARTS } from '../look/Identity.js';
|
||||
|
||||
export const ACTOR_ARCHETYPES = ['monolith', 'swarm', 'walker', 'vehicle', 'structure'];
|
||||
|
||||
/**
|
||||
* Which archetypes suit which section kind — as a per-track lean, not a rule.
|
||||
* Kept small and audio-tilted so every archetype stays reachable for every
|
||||
* track, the way directors.js keeps every director reachable.
|
||||
*/
|
||||
const ARCHETYPE_WEIGHTS = {
|
||||
monolith: 3, // one large solid — the default protagonist body
|
||||
swarm: 2, // many small chorus instances
|
||||
walker: 1, // articulated: two/three hinged parts, analytic gait (Stage C)
|
||||
vehicle: 1, // chassis + orientation axis, streaming motion (Stage C)
|
||||
structure: 2, // ground-anchored, heightfield-aware (Stage C)
|
||||
};
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
|
||||
/**
|
||||
* Generate one actor — data, not scene graph.
|
||||
*
|
||||
* @param {object} opts.summary FeatureTrack.summary
|
||||
* @param {import('../engine/rng.js').Rng} opts.rng forked for this actor
|
||||
* @param {string} [opts.archetype] when absent, picked weighted by audio
|
||||
* @param {object} [opts.personality] look.personality — for shape reconciliation
|
||||
* @param {object} [opts.identity] look.personality.identity
|
||||
* @returns {object} ActorSpec — serialisable, hashable
|
||||
*/
|
||||
export function generateActor({ summary, rng, archetype = null, personality = null, identity = null }) {
|
||||
const s = summary || {};
|
||||
const bright = s.meanCentroid ?? 0.5;
|
||||
const noisy = Math.min(1, (s.meanFlatness ?? 0.2) * 3);
|
||||
const fast = clamp01(((s.bpm ?? 120) - 80) / 80);
|
||||
const dynamic = clamp01(s.dynamicRange ?? 0.5);
|
||||
const sections = s.sections ?? 4;
|
||||
const busy = clamp01((sections - 2) / 5);
|
||||
|
||||
// Audio sets the centre, seed picks within — mirrors Identity.generateIdentity.
|
||||
const angular = clamp01(noisy * 0.6 + fast * 0.3 + rng.range(-0.25, 0.25));
|
||||
const intricate = clamp01(busy * 0.5 + bright * 0.3 + rng.range(-0.3, 0.3));
|
||||
const solid = clamp01(0.5 - dynamic * 0.4 + rng.range(-0.25, 0.25));
|
||||
|
||||
if (!archetype) {
|
||||
const noisyW = 0.5 + noisy * 1.2;
|
||||
const weights = ACTOR_ARCHETYPES.map((a) => {
|
||||
let w = ARCHETYPE_WEIGHTS[a] || 1;
|
||||
if (a === 'walker' || a === 'vehicle') w *= 0.6 + noisyW * 0.4;
|
||||
if (a === 'structure') w *= 0.6 + (1 - noisy) * 0.6 + dynamic * 0.4;
|
||||
return w;
|
||||
});
|
||||
archetype = rng.pickWeighted(ACTOR_ARCHETYPES, weights);
|
||||
}
|
||||
|
||||
// The solid assembly — same rows the shaders march, so the mesh and the
|
||||
// impostor are the same character. Reuses Identity.generateForm via a
|
||||
// derived identity when one was not supplied (checks, unit tests).
|
||||
let form;
|
||||
if (identity && identity.form) {
|
||||
form = identity.form;
|
||||
} else {
|
||||
// Derive a throwaway identity just to get a form; forked so the main
|
||||
// identity stream is untouched when this path is used in isolation.
|
||||
const derived = generateIdentity(s, rng.fork('actor:form'), sections);
|
||||
form = derived.form;
|
||||
// Keep the cast family in sync with the supplied personality shape when
|
||||
// both exist — mirrors identityUniforms(identity, shape) reconciliation.
|
||||
if (personality && personality.shape && identity === null) {
|
||||
identity = derived;
|
||||
}
|
||||
}
|
||||
|
||||
// Kit reference — Stage B. Null in Stage A, which uses primitives.
|
||||
const kitRef = null;
|
||||
|
||||
// Rig — Stage C. Null until walker/vehicle get articulated.
|
||||
let rig = null;
|
||||
if (archetype === 'walker' || archetype === 'vehicle') {
|
||||
// Stub rig: one hinge, analytic gait params — enough to prove the
|
||||
// ActorSpec shape without requiring a skeleton system.
|
||||
const joints = archetype === 'walker'
|
||||
? [
|
||||
{ parent: -1, axis: [0, 1, 0], range: rng.range(0.3, 0.9), phase: rng.range(0, Math.PI * 2), ratio: 1 },
|
||||
{ parent: 0, axis: [1, 0, 0], range: rng.range(0.2, 0.6), phase: rng.range(0, Math.PI * 2), ratio: 0.6 },
|
||||
]
|
||||
: [
|
||||
{ parent: -1, axis: [0, 1, 0], range: rng.range(0.15, 0.45), phase: rng.range(0, Math.PI * 2), ratio: 1 },
|
||||
];
|
||||
rig = { joints, gait: archetype === 'walker' ? 'walk' : 'roll' };
|
||||
}
|
||||
|
||||
// Which palette entry each part reads — seeded, so two actors on one track
|
||||
// differ in colour rhythm even when their forms coincide.
|
||||
const paletteMap = form.parts.map(() => rng.int(0, 3));
|
||||
|
||||
// Scale reconciled with Identity.lattice.elementScale so mesh size agrees
|
||||
// with stageNode.z. Base is the song's elementScale-derived size; spread
|
||||
// is how much the actor's own parts vary.
|
||||
const elementScale = identity ? identity.lattice.elementScale : 0.35;
|
||||
const scale = {
|
||||
base: elementScale,
|
||||
spread: clamp01(0.15 + intricate * 0.6 + rng.range(-0.2, 0.25)),
|
||||
};
|
||||
|
||||
const placement = identity ? {
|
||||
latticeKind: identity.lattice.kind,
|
||||
spread: identity.lattice.spread,
|
||||
jitter: identity.lattice.jitter,
|
||||
} : { latticeKind: 'scatter', spread: 0.7, jitter: 0.3 };
|
||||
|
||||
const motion = {
|
||||
orbitRate: rng.range(0.08, 0.45),
|
||||
spin: rng.range(-0.6, 0.6),
|
||||
bobAmp: rng.range(0.005, 0.025),
|
||||
bobRate: rng.range(0.3, 1.2),
|
||||
};
|
||||
|
||||
return {
|
||||
archetype,
|
||||
seed: rng.seed >>> 0,
|
||||
form,
|
||||
kitRef,
|
||||
rig,
|
||||
paletteMap,
|
||||
scale,
|
||||
placement,
|
||||
motion,
|
||||
// Keep the audio-derived character alongside the spec so a HUD or
|
||||
// check can report why this actor looks the way it does.
|
||||
character: { angular, intricate, solid },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the per-track actor set — one ActorSpec per archetype, each from
|
||||
* its own fork so the set is stable under reordering.
|
||||
*
|
||||
* @param {object} summary
|
||||
* @param {import('../engine/rng.js').Rng} rng parent (look seed fork)
|
||||
* @param {object} personality
|
||||
* @param {object} identity
|
||||
* @returns {Record<string, object>} archetype -> ActorSpec
|
||||
*/
|
||||
export function generateActorSet(summary, rng, personality = null, identity = null) {
|
||||
const set = {};
|
||||
for (const arch of ACTOR_ARCHETYPES) {
|
||||
set[arch] = generateActor({
|
||||
summary,
|
||||
rng: rng.fork(`actor:${arch}`),
|
||||
archetype: arch,
|
||||
personality,
|
||||
identity,
|
||||
});
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Totally ordered actor-set summary for HUD / check output — mirrors
|
||||
* describeIdentity / describePersonality shape.
|
||||
*/
|
||||
export function describeActor(actor) {
|
||||
if (!actor) return 'no actor';
|
||||
const f = actor.form;
|
||||
const parts = f ? `${f.parts.length}-part/${f.symmetry}${f.symmetry !== 'none' ? f.symmetryN : ''}` : 'no form';
|
||||
const rig = actor.rig ? ` · rig ${actor.rig.gait} ${actor.rig.joints.length}j` : '';
|
||||
const kit = actor.kitRef ? ` · kit ${actor.kitRef.id}` : '';
|
||||
return `${actor.archetype} ${parts}${rig}${kit} · scale ${actor.scale.base.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function describeActorSet(set) {
|
||||
if (!set) return 'no actors';
|
||||
return ACTOR_ARCHETYPES.map((a) => (set[a] ? describeActor(set[a]) : `${a}:—`)).join(' | ');
|
||||
}
|
||||
|
||||
// Re-export for consumers that only need the constants without importing Identity.
|
||||
export { SOLIDS, SYMMETRIES, FORM_OPS, MAX_FORM_PARTS };
|
||||
|
||||
// Convenience: deterministic hash of an ActorSpec's visible content — for
|
||||
// determinism checks and census tooling.
|
||||
export function hashActorSpec(spec) {
|
||||
let h = 0x811c9dc5 >>> 0;
|
||||
const mix = (n) => {
|
||||
h ^= n & 0xff; h = Math.imul(h, 0x01000193) >>> 0;
|
||||
h ^= (n >>> 8) & 0xff; h = Math.imul(h, 0x01000193) >>> 0;
|
||||
};
|
||||
mix(spec.seed);
|
||||
for (let i = 0; i < spec.archetype.length; i++) mix(spec.archetype.charCodeAt(i));
|
||||
if (spec.form) {
|
||||
mix(spec.form.parts.length);
|
||||
for (const p of spec.form.parts) {
|
||||
mix(SOLIDS.indexOf(p.kind));
|
||||
mix(Math.round(p.offset[0] * 100));
|
||||
mix(Math.round(p.scale[0] * 100));
|
||||
}
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
272
flow-state/src/actors/meshes.js
Normal file
272
flow-state/src/actors/meshes.js
Normal file
@ -0,0 +1,272 @@
|
||||
// Mesh-side twin of Identity.form's SDF assembly.
|
||||
//
|
||||
// The shaders march the assembly as SDF (FORM_PREAMBLE / castSDF3). This module
|
||||
// builds the same assembly as BufferGeometry for ModelLayer — so the mesh and the
|
||||
// impostor are the same character, and a stage that was stamping castSolid can
|
||||
// become a stage instancing a mesh without inventing a new protagonist.
|
||||
//
|
||||
// Stage A uses primitives + extruded 2-D cast profile (prism). Stage B adds a
|
||||
// kitRef path that deforms a curated glTF base by the same sides/notch/hollow
|
||||
// params. Analytic: no integration, no wall-clock — f(t,seed) only, so seek ===
|
||||
// playback exactly as particles.js requires.
|
||||
//
|
||||
// Kept small on purpose. A full marching-cubes SDF->mesh would be more general
|
||||
// and is not needed for V1: Identity's solids are prism/box/capsule/torus/
|
||||
// sphere, each of which has a direct THREE primitive.
|
||||
// Mirrors shader-contract.js castSDF / FORM_PREAMBLE verbatim so the profile and
|
||||
// the solids match the shaders — a seek must land on the same mesh the shader
|
||||
// would have stamped.
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ------------------------------------------------------------------ cast SDF in JS
|
||||
// Mirrors shader-contract.js castSDF verbatim — any drift here makes the mesh a
|
||||
// different character than the impostor.
|
||||
|
||||
function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) {
|
||||
// GLSL: mat2 rot = mat2(c, -s, s, c) is column-major => [[c,s],[-s,c]]
|
||||
// => (c*x + s*y, -s*x + c*y), i.e. rotation by -tilt. Keep it identical.
|
||||
const c = Math.cos(tilt), s = Math.sin(tilt);
|
||||
const qx = c * q[0] + s * q[1];
|
||||
const qy = -s * q[0] + c * q[1];
|
||||
const qx2 = qx / Math.max(elong, 0.05);
|
||||
const qy2 = qy;
|
||||
|
||||
const r = Math.hypot(qx2, qy2);
|
||||
const a = Math.atan2(qy2, qx2);
|
||||
let d;
|
||||
if (sides < 2.5) {
|
||||
d = r - 1.0;
|
||||
} else {
|
||||
const seg = (Math.PI * 2) / sides;
|
||||
const half = seg * 0.5;
|
||||
let aa = a + half;
|
||||
aa = aa % seg;
|
||||
if (aa < 0) aa += seg;
|
||||
aa -= half;
|
||||
const folded = Math.cos(aa);
|
||||
const poly = r * folded - Math.cos(half);
|
||||
const t = Math.max(0, Math.min(1, rnd));
|
||||
d = poly * (1 - t) + (r - 1.0) * t; // mix(poly, r-1, clamp(rnd))
|
||||
}
|
||||
if (notchN > 0.5) d += notchD * Math.cos(notchN * a);
|
||||
if (hollow > 0.001) d = Math.abs(d) - hollow * 0.35;
|
||||
return d;
|
||||
}
|
||||
|
||||
function sampleCastRadius(angle, cast, steps = 24) {
|
||||
// Find the OUTER zero-crossing along a ray from the origin. For a hollow
|
||||
// form the SDF is positive at the centre (outside the annulus), so sampling
|
||||
// with hollow included would see two crossings and the binary search would
|
||||
// fail. Sample WITHOUT the hollow term to get the outer silhouette — the hole
|
||||
// is punched separately in castShape.
|
||||
const probe = (r) => {
|
||||
const q = [Math.cos(angle) * r, Math.sin(angle) * r];
|
||||
return jsCastSDF(q, cast.sides, cast.round, cast.elong, cast.tilt,
|
||||
cast.notchCount, cast.notchCount ? cast.notchDepth : 0, 0);
|
||||
};
|
||||
let lo = 0, hi = 2.0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (probe(hi) > 0) break;
|
||||
hi *= 1.5;
|
||||
if (hi > 10) break;
|
||||
}
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const mid = (lo + hi) * 0.5;
|
||||
if (probe(mid) > 0) hi = mid; else lo = mid;
|
||||
}
|
||||
return (lo + hi) * 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a THREE.Shape from a 2-D cast profile (identity.cast.protagonist or
|
||||
* chorus). Used for formPrism — the profile extruded. Hollow is punched as a
|
||||
* hole path so the 2-D shape and the 3-D mesh agree with the shader's
|
||||
* hollow (which is an SDF annulus, not an inner silhouette).
|
||||
*/
|
||||
export function castShape(cast, segments = 64) {
|
||||
const shape = new THREE.Shape();
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const a = (i / segments) * Math.PI * 2;
|
||||
const r = sampleCastRadius(a, cast);
|
||||
const x = Math.cos(a) * r;
|
||||
const y = Math.sin(a) * r;
|
||||
if (i === 0) shape.moveTo(x, y);
|
||||
else shape.lineTo(x, y);
|
||||
}
|
||||
if (cast.hollow > 0.001) {
|
||||
// Inner hole: scale the outer shape's bounding radius by the SDF hollow
|
||||
// width. Not exact — the SDF hole is `abs(d)-h*0.35`, not a scaled copy —
|
||||
// but the mesh reads as hollow and the outer silhouette still matches.
|
||||
const hr = Math.max(0.08, 1 - cast.hollow * 0.9) * 0.45;
|
||||
const hole = new THREE.Path();
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const a = (i / segments) * Math.PI * 2;
|
||||
const r = sampleCastRadius(a, cast) * hr;
|
||||
const x = Math.cos(a) * r;
|
||||
const y = Math.sin(a) * r;
|
||||
if (i === 0) hole.moveTo(x, y);
|
||||
else hole.lineTo(x, y);
|
||||
}
|
||||
shape.holes.push(hole);
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ per-part geometry
|
||||
|
||||
/**
|
||||
* One part of an Identity.form assembly → BufferGeometry.
|
||||
*
|
||||
* Mirrors shader-contract.js formPrism/formBox/formCapsule/formTorus exactly:
|
||||
* prism extruded cast profile, depth = 2 * r.z * u_formDepth
|
||||
* box half-extents r
|
||||
* capsule radius = min(r.x,r.z), half-height = r.y
|
||||
* torus major = r.x, tube = r.z*0.45
|
||||
* sphere ellipsoid r (else branch of formSDF)
|
||||
*
|
||||
* @param {object} part {kind, scale:[x,y,z], round}
|
||||
* @param {object} identity look.personality.identity (for cast profile when prism)
|
||||
* @param {object} [opts] { formDepth } u_formDepth from Identity.form.depth
|
||||
*/
|
||||
export function formToGeometry(part, identity, opts = {}) {
|
||||
const kind = part.kind || 'prism';
|
||||
const sx = Math.max(1e-3, part.scale[0]);
|
||||
const sy = Math.max(1e-3, part.scale[1]);
|
||||
const sz = Math.max(1e-3, part.scale[2]);
|
||||
const formDepth = opts.formDepth ?? (identity && identity.form ? identity.form.depth : 0.8);
|
||||
|
||||
if (kind === 'prism') {
|
||||
const cast = identity && identity.cast ? identity.cast.protagonist : null;
|
||||
if (!cast || cast.sides === undefined) {
|
||||
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
||||
}
|
||||
const shape = castShape(cast, 64);
|
||||
// Shader: dz = abs(q.z) - max(r.z,1e-3)*u_formDepth => half-depth = r.z*formDepth
|
||||
const halfDepth = Math.max(sz, 1e-3) * Math.max(formDepth, 1e-3);
|
||||
const depth = halfDepth * 2;
|
||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: part.round ? part.round * 0.15 : 0.02,
|
||||
bevelSize: part.round ? part.round * 0.12 : 0.015,
|
||||
bevelSegments: 2,
|
||||
});
|
||||
geo.translate(0, 0, -halfDepth);
|
||||
// Shape sampled at radius ~1, so scale xy by the part's half-extents.
|
||||
geo.scale(sx, sy, 1);
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
}
|
||||
if (kind === 'box') {
|
||||
// Shader: d = abs(q) - max(r) => half-extents = r
|
||||
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
||||
}
|
||||
if (kind === 'capsule') {
|
||||
// Shader: rad = max(min(r.x,r.z),1e-3), h = max(r.y,1e-3)
|
||||
const rad = Math.max(1e-3, Math.min(sx, sz));
|
||||
const h = Math.max(1e-3, sy);
|
||||
// THREE.CapsuleGeometry(len = cylinder height 2*h? — shader's h is half-height)
|
||||
// Shader's capsule length = 2*h plus caps radius rad.
|
||||
// Keep capSegments low — these are many small instances.
|
||||
return new THREE.CapsuleGeometry(rad, h * 2, 6, 12);
|
||||
}
|
||||
if (kind === 'torus') {
|
||||
// Shader: major = r.x, tube = r.z*0.45
|
||||
const major = Math.max(1e-3, sx);
|
||||
const tube = Math.max(1e-3, sz * 0.45);
|
||||
return new THREE.TorusGeometry(major, tube, 12, 24);
|
||||
}
|
||||
if (kind === 'sphere') {
|
||||
// Shader else branch: ellipsoid `length(p/max(r))*min(r) - min(r)`
|
||||
// Approximate as scaled sphere: unit sphere scaled by r.
|
||||
const geo = new THREE.SphereGeometry(1, 16, 12);
|
||||
geo.scale(sx, sy, sz);
|
||||
return geo;
|
||||
}
|
||||
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ actor → geometry
|
||||
|
||||
/**
|
||||
* ActorSpec → THREE.Group. Stage A: assembly of formToGeometry clones under the
|
||||
* actor's symmetry. Stage B will add kitRef deformation here without changing
|
||||
* the caller. Boolean ops (union/blend/carve) are carried as userData for later
|
||||
* CSG — Phase 1 treats them as union, which is exact for the first part and
|
||||
* the common `union` op (majority of parts).
|
||||
*
|
||||
* @param {object} actorSpec from ActorGenerator.generateActor
|
||||
* @param {object} identity
|
||||
* @param {typeof THREE} THREE
|
||||
*/
|
||||
export function actorToGeometry(actorSpec, identity, THREE_) {
|
||||
const T = THREE_ || THREE;
|
||||
const form = actorSpec.form;
|
||||
if (!form || !form.parts.length) {
|
||||
const g = formToGeometry({ kind: 'prism', scale: [0.6, 0.6, 0.35], round: 0.1 }, identity, { formDepth: 0.8 });
|
||||
const m = new T.Mesh(g, new T.MeshStandardMaterial({ color: 0xffffff }));
|
||||
const grp = new T.Group();
|
||||
grp.add(m);
|
||||
return grp;
|
||||
}
|
||||
const group = new T.Group();
|
||||
const sym = form.symmetry || 'none';
|
||||
const symN = Math.max(2, form.symmetryN | 0);
|
||||
const formDepth = form.depth ?? 0.8;
|
||||
|
||||
for (let i = 0; i < form.parts.length; i++) {
|
||||
const part = form.parts[i];
|
||||
const geo = formToGeometry(part, identity, { formDepth });
|
||||
|
||||
const addInstance = (offset, yaw, pitch) => {
|
||||
const mesh = new T.Mesh(geo, new T.MeshStandardMaterial({ color: 0xffffff }));
|
||||
mesh.position.set(offset[0], offset[1], offset[2]);
|
||||
// Shader: formRot(yaw, pitch) = mat3(cy,0,-sy, sy*sp,cp,cy*sp, sy*cp,-sp,cy*cp)
|
||||
// THREE Euler order XYZ with ZYX would not match; use YXZ so yaw is Y.
|
||||
mesh.rotation.order = 'YXZ';
|
||||
mesh.rotation.set(pitch, yaw, 0);
|
||||
mesh.userData.partIndex = i;
|
||||
mesh.userData.op = part.op || 'union';
|
||||
mesh.userData.blend = form.blend ?? 0.12;
|
||||
group.add(mesh);
|
||||
};
|
||||
|
||||
if (sym === 'radial' && symN > 1) {
|
||||
for (let k = 0; k < symN; k++) {
|
||||
const a = (k / symN) * Math.PI * 2;
|
||||
const ox = part.offset[0] * Math.cos(a) - part.offset[2] * Math.sin(a);
|
||||
const oz = part.offset[0] * Math.sin(a) + part.offset[2] * Math.cos(a);
|
||||
addInstance([ox, part.offset[1], oz], part.yaw + a, part.pitch);
|
||||
}
|
||||
} else if (sym === 'mirror') {
|
||||
addInstance(part.offset, part.yaw, part.pitch);
|
||||
addInstance([-part.offset[0], part.offset[1], part.offset[2]], -part.yaw, part.pitch);
|
||||
} else if (sym === 'stack') {
|
||||
const h = 1.6 / symN;
|
||||
const lim = (symN - 1) * 0.5;
|
||||
for (let k = -lim; k <= lim; k++) {
|
||||
addInstance([part.offset[0], part.offset[1] + k * h, part.offset[2]], part.yaw, part.pitch);
|
||||
}
|
||||
} else {
|
||||
addInstance(part.offset, part.yaw, part.pitch);
|
||||
}
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Palette-aware material for a model part — bakes pal(i) at setPalette time so
|
||||
* MeshStandardMaterial agrees with shader pal()/inkValue grade.
|
||||
*/
|
||||
export function paletteMaterial(palette, index, opts = {}) {
|
||||
const c = palette && palette.length ? palette[index % palette.length] : [1, 1, 1];
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(c[0], c[1], c[2]),
|
||||
roughness: opts.roughness ?? 0.45,
|
||||
metalness: opts.metalness ?? 0.1,
|
||||
transparent: opts.transparent ?? false,
|
||||
opacity: opts.opacity ?? 1,
|
||||
});
|
||||
}
|
||||
@ -27,6 +27,7 @@ import { describeIdentity } from '../look/Identity.js';
|
||||
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
||||
import { frameLuminance, frameVariance } from '../engine/hash.js';
|
||||
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
|
||||
import { generateActor } from '../actors/ActorGenerator.js';
|
||||
|
||||
const THUMB = { width: 256, height: 144 };
|
||||
|
||||
@ -89,6 +90,11 @@ export function renderScene(engine, module, contexts) {
|
||||
|
||||
const rng = new Rng(hashString(`${module.name}:${ctx.name}`));
|
||||
const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament);
|
||||
const actorRng = new Rng(rng.fork(`actor:${module.actor || 'model'}`).seed);
|
||||
const summary = ctx.track.summary;
|
||||
const actorSpec = module.kind === 'model' && module.actor
|
||||
? generateActor({ summary, rng: actorRng, archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity })
|
||||
: null;
|
||||
|
||||
engine.setLayerSpecs([{
|
||||
module,
|
||||
@ -98,6 +104,7 @@ export function renderScene(engine, module, contexts) {
|
||||
blend: 'normal',
|
||||
palette: ctx.palette,
|
||||
personality: ctx.personality,
|
||||
actorSpec,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
// A few frames of warm-up so anything with state is past its first frame.
|
||||
@ -181,7 +188,7 @@ export function renderScene(engine, module, contexts) {
|
||||
export async function buildGallery({ contexts, onScene, only = null } = {}) {
|
||||
const list = only
|
||||
? scenes.filter((m) => only.includes(m.name))
|
||||
: scenes.filter((m) => m.kind === 'fragment');
|
||||
: scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model');
|
||||
|
||||
const engine = new Engine({ ...THUMB });
|
||||
const rows = [];
|
||||
|
||||
@ -50,11 +50,12 @@ const SOURCES = {
|
||||
...import.meta.glob('/src/look/Identity.js', { query: '?raw', import: 'default', eager: true }),
|
||||
...import.meta.glob('/src/look/Personality.js', { query: '?raw', import: 'default', eager: true }),
|
||||
...import.meta.glob('/src/look/palette.js', { query: '?raw', import: 'default', eager: true }),
|
||||
...import.meta.glob('/src/actors/**/*.js', { query: '?raw', import: 'default', eager: true }),
|
||||
...import.meta.glob('/src/audio/songbank.js', { query: '?raw', import: 'default', eager: true }),
|
||||
};
|
||||
|
||||
/** The version of the measurement itself. Bump to force a refresh of everything. */
|
||||
export const SCHEMA = 4;
|
||||
export const SCHEMA = 5;
|
||||
|
||||
export function metricsFingerprint() {
|
||||
let h = 0x811c9dc5 >>> 0;
|
||||
@ -130,7 +131,7 @@ export function measureLibrary({ onScene = null, contexts = null } = {}) {
|
||||
const engine = new Engine({ ...THUMB });
|
||||
const out = {};
|
||||
try {
|
||||
const list = scenes.filter((m) => m.kind === 'fragment');
|
||||
const list = scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model');
|
||||
for (const module of list) {
|
||||
const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx);
|
||||
const bed = renderScene(engine, module, bedCtx);
|
||||
|
||||
@ -24,6 +24,7 @@ import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js'
|
||||
import { peakFlashRate } from '../engine/flash.js';
|
||||
import { generatePersonality } from '../look/Personality.js';
|
||||
import { generateIdentity } from '../look/Identity.js';
|
||||
import { generateActor } from '../actors/ActorGenerator.js';
|
||||
|
||||
const PALETTE = [
|
||||
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
||||
@ -63,13 +64,26 @@ export function runSceneGate(name) {
|
||||
engine.setFeatureProvider(featureProviderFor(track));
|
||||
|
||||
const personality = generatePersonality(SUMMARY, new Rng(9001));
|
||||
// Model stages need a deterministic ActorSpec — derived the same way
|
||||
// gallery/metadata derive it: fork from the scene seed + song context.
|
||||
const actorSpec = (module.kind === 'model' && module.actor)
|
||||
? generateActor({ summary: SUMMARY, rng: new Rng(4242).fork(`actor:${module.actor}`), archetype: module.actor, personality, identity: personality.identity })
|
||||
: null;
|
||||
const draw = (params, frame, seed = 4242) => {
|
||||
// For model stages the per-draw ActorSpec stays consistent within a
|
||||
// gate run (same personality), so geometry parity is testable.
|
||||
const actor = actorSpec && module.kind === 'model' ? actorSpec : null;
|
||||
engine.setLayerSpecs([{
|
||||
module, params, seed, opacity: 1, blend: 'normal', palette: PALETTE, personality,
|
||||
module, params, seed, opacity: 1, blend: 'normal', palette: PALETTE, personality, actorSpec: actor,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
return Uint8Array.from(engine.readPixels(engine.renderFrame(frame)));
|
||||
};
|
||||
// Re-derive actor for consumptive identity probes — the identity is swapped.
|
||||
const actorFor = (personalityOverride) => {
|
||||
if (!(module.kind === 'model' && module.actor)) return null;
|
||||
return generateActor({ summary: SUMMARY, rng: new Rng(31337).fork(`actor:${module.actor}`), archetype: module.actor, personality: personalityOverride, identity: personalityOverride.identity });
|
||||
};
|
||||
|
||||
try {
|
||||
// --- alive -------------------------------------------------------
|
||||
@ -93,10 +107,13 @@ export function runSceneGate(name) {
|
||||
let closest = 255;
|
||||
let closestName = '';
|
||||
for (const other of scenes) {
|
||||
if (other === module || other.kind !== 'fragment') continue;
|
||||
if (other === module) continue;
|
||||
const otherActor = (other.kind === 'model' && other.actor)
|
||||
? generateActor({ summary: SUMMARY, rng: new Rng(4242).fork(`actor:${other.actor}`), archetype: other.actor, personality, identity: personality.identity })
|
||||
: null;
|
||||
engine.setLayerSpecs([{
|
||||
module: other, params: defaultValues(other), seed: 4242,
|
||||
opacity: 1, blend: 'normal', palette: PALETTE, personality,
|
||||
opacity: 1, blend: 'normal', palette: PALETTE, personality, actorSpec: otherActor,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
const d = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600))));
|
||||
@ -123,8 +140,11 @@ export function runSceneGate(name) {
|
||||
|
||||
// --- flash rate ------------------------------------------------------
|
||||
const hot = sampleValues(module, new Rng(77), { energy: 0.95, density: 0.9, motion: 0.9 });
|
||||
const hotActor = actorSpec && module.kind === 'model'
|
||||
? generateActor({ summary: SUMMARY, rng: new Rng(99).fork(`actor:${module.actor}`), archetype: module.actor, personality, identity: personality.identity })
|
||||
: null;
|
||||
engine.setLayerSpecs([{
|
||||
module, params: hot, seed: 99, opacity: 1, blend: 'normal', palette: PALETTE, personality,
|
||||
module, params: hot, seed: 99, opacity: 1, blend: 'normal', palette: PALETTE, personality, actorSpec: hotActor,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
const luminance = [];
|
||||
@ -173,9 +193,10 @@ export function runSceneGate(name) {
|
||||
};
|
||||
}
|
||||
|
||||
const otherActor = actorFor(other);
|
||||
engine.setLayerSpecs([{
|
||||
module, params: defaultValues(module), seed: 4242,
|
||||
opacity: 1, blend: 'normal', palette: PALETTE, personality: other,
|
||||
opacity: 1, blend: 'normal', palette: PALETTE, personality: other, actorSpec: otherActor,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
const changed = frameMaxDelta(base,
|
||||
|
||||
@ -33,6 +33,7 @@ import { sampleValues } from '../params/schema.js';
|
||||
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
||||
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
|
||||
import { THUMB } from './gallery.js';
|
||||
import { generateActor } from '../actors/ActorGenerator.js';
|
||||
|
||||
/** How many songs the bank can offer. The grid cannot ask for more. */
|
||||
export const MAX_SONGS = SONGS.length;
|
||||
@ -167,10 +168,13 @@ export async function sweepScene({ engine, module, cells, onCell }) {
|
||||
const rng = new Rng(ctx.seed ^ hashString(module.name));
|
||||
const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament);
|
||||
|
||||
const actorSpec = module.kind === 'model' && module.actor
|
||||
? generateActor({ summary: ctx.track.summary, rng: rng.fork(`actor:${module.actor}`), archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity })
|
||||
: null;
|
||||
engine.setLayerSpecs([{
|
||||
module, params, seed: rng.int(0, 0x7fffffff),
|
||||
opacity: 1, blend: 'normal',
|
||||
palette: ctx.palette, personality: ctx.personality,
|
||||
palette: ctx.palette, personality: ctx.personality, actorSpec,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f);
|
||||
|
||||
@ -71,6 +71,47 @@ export class Compositor {
|
||||
this.bloomA = r.createTarget(bw, bh);
|
||||
this.bloomB = r.createTarget(bw, bh);
|
||||
this.outputTarget = r.createTarget(w, h);
|
||||
|
||||
// Shared perspective rig for model layers — one camera, one depth, so a
|
||||
// ground mesh in one layer can occlude a subject in another. Created
|
||||
// lazily here so existing shader-only stacks pay nothing extra.
|
||||
this.sharedCamera = new THREE.PerspectiveCamera(60, w / h, 0.1, 200);
|
||||
this.sharedCamera.position.set(0, 0, 5);
|
||||
this.sharedDepthTarget = null; // allocated on demand when a model layer is active
|
||||
}
|
||||
|
||||
/** Ensure the shared depth target exists at the current size. */
|
||||
_ensureSharedDepth() {
|
||||
if (this.sharedDepthTarget
|
||||
&& this.sharedDepthTarget.width === this.width
|
||||
&& this.sharedDepthTarget.height === this.height) return;
|
||||
if (this.sharedDepthTarget) this.sharedDepthTarget.dispose();
|
||||
this.sharedDepthTarget = this.renderer.createDepthTarget(this.width, this.height);
|
||||
}
|
||||
|
||||
/** Drive the shared perspective rig from the current framing + gaze + personality. */
|
||||
updateSharedCamera({ framing, personality, time }) {
|
||||
const cam = this.sharedCamera;
|
||||
const frame = framing || { scale: 1, shift: [0, 0] };
|
||||
const dolly = 4 / Math.max(frame.scale, 0.05);
|
||||
const pCam = personality ? personality.camera : null;
|
||||
if (pCam) {
|
||||
const pan = 20 * Math.sin(time * 0.05);
|
||||
cam.position.set(
|
||||
Math.cos(pCam.driftAngle) * pCam.driftRate * pan
|
||||
+ Math.sin(time * pCam.swayRate) * pCam.sway + frame.shift[0],
|
||||
Math.sin(pCam.driftAngle) * pCam.driftRate * pan
|
||||
+ Math.cos(time * pCam.swayRate * 0.83) * pCam.sway + frame.shift[1],
|
||||
dolly,
|
||||
);
|
||||
cam.rotation.z = pCam.spin * time;
|
||||
} else {
|
||||
cam.position.set(frame.shift[0], frame.shift[1], dolly);
|
||||
cam.rotation.z = 0;
|
||||
}
|
||||
// Look slightly down the depth axis so a ground plane is visible.
|
||||
cam.lookAt(cam.position.x, cam.position.y * 0.3, cam.position.z - 5);
|
||||
cam.updateMatrixWorld();
|
||||
}
|
||||
|
||||
_buildMaterials() {
|
||||
@ -176,7 +217,10 @@ export class Compositor {
|
||||
_primeLayer(layer) {
|
||||
try {
|
||||
if (layer.material) this.renderer.compileMaterial(layer.material);
|
||||
else if (layer.scene && layer.camera) this.renderer.compileScene(layer.scene, layer.camera);
|
||||
else if (layer.scene) {
|
||||
const cam = layer.sharedCamera || layer.camera;
|
||||
if (cam) this.renderer.compileScene(layer.scene, cam);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[compositor] priming failed for', layer.module && layer.module.name, err);
|
||||
}
|
||||
@ -348,7 +392,9 @@ export class Compositor {
|
||||
|
||||
disposeTargets() {
|
||||
[this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB,
|
||||
this.bloomA, this.bloomB, this.outputTarget].forEach((t) => t && t.dispose());
|
||||
this.bloomA, this.bloomB, this.outputTarget,
|
||||
this.sharedDepthTarget].forEach((t) => t && t.dispose());
|
||||
this.sharedDepthTarget = null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
@ -55,9 +55,15 @@ export class Engine {
|
||||
setLayerSpecs(specs) {
|
||||
this.ownedLayers.forEach((l) => l.dispose());
|
||||
const layers = specs.map((s) => {
|
||||
// actorSpec is part of the spec for kind:'model' — passed through to
|
||||
// ModelLayer at construction so build() sees it. For checks/gallery the
|
||||
// caller generates it deterministically via ActorGenerator; Show's
|
||||
// ArcDriver already did the same via look.actors.
|
||||
const layer = createLayer(s.module, s);
|
||||
if (s.palette) layer.setPalette(s.palette);
|
||||
if (s.personality) layer.setPersonality(s.personality);
|
||||
if (s.actorSpec && layer.setActor) layer.setActor(s.actorSpec);
|
||||
if (s.framing) layer.setFraming(s.framing);
|
||||
return layer;
|
||||
});
|
||||
this.ownedLayers = layers;
|
||||
|
||||
@ -317,7 +317,98 @@ export class SceneLayer extends Layer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 3D model layer — the mesh twin of the shader impostor.
|
||||
*
|
||||
* Like SceneLayer it owns a THREE.Scene and receives build/update hooks, but
|
||||
* its geometry comes from the song's ActorSpec (the mesh assembly), not from
|
||||
* a hand-written point cloud. Determinism rule is the same: no integration,
|
||||
* only analytic f(time, index, seed). See processors/meshes.js and
|
||||
* src/actors/ActorGenerator.js.
|
||||
*
|
||||
* The camera is borrowed from Compositor.sharedCamera when one exists, so
|
||||
* multiple ModelLayers share one perspective and one depth buffer — that is
|
||||
* what makes a ground mesh occlude a subject mesh from another layer.
|
||||
* Falls back to its own camera when no shared rig is present (tests, solo
|
||||
* preview), so existing SceneLayer behaviour is unchanged.
|
||||
*/
|
||||
export class ModelLayer extends Layer {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.scene = new THREE.Scene();
|
||||
// Model layers are sparse: a hero plus a few satellites on a ground.
|
||||
// Mark the scene for a transparent clear so Renderer clears to alpha 0
|
||||
// and Compositor's lumakey/normal blend shows the composition underneath
|
||||
// where there is no mesh — same role as a 'composable' shader layer.
|
||||
this.scene.userData.transparentBackground = true;
|
||||
// Keep the WebGL clear colour transparent as well; renderScene reads the
|
||||
// scene flag but the fallback alpha is set here for safety.
|
||||
this.scene.background = null;
|
||||
this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
|
||||
this.camera.position.set(0, 0, 5);
|
||||
this.actorSpec = options.actorSpec || null;
|
||||
// Shared rig injected by Compositor at render time when available.
|
||||
this.sharedCamera = null;
|
||||
this.instance = this.module.build({
|
||||
scene: this.scene,
|
||||
camera: this.camera,
|
||||
seed: this.seed,
|
||||
params: this.baseParams,
|
||||
actorSpec: this.actorSpec,
|
||||
THREE,
|
||||
});
|
||||
}
|
||||
|
||||
/** Allow the look to swap the actor without rebuilding the layer. */
|
||||
setActor(actorSpec) {
|
||||
this.actorSpec = actorSpec || null;
|
||||
return this;
|
||||
}
|
||||
|
||||
render(renderer, target, ctx) {
|
||||
const { timeline, features } = ctx;
|
||||
const w = target ? target.width : renderer.width;
|
||||
const h = target ? target.height : renderer.height;
|
||||
// Use the compositor's shared camera when it has been injected; it is
|
||||
// updated centrally from ArcDriver's framing/gaze so every model layer
|
||||
// shares one perspective and one depth, and a ground in one layer can
|
||||
// occlude a subject in another.
|
||||
const cam = this.sharedCamera || this.camera;
|
||||
if (cam.aspect !== w / h) {
|
||||
cam.aspect = w / h;
|
||||
cam.updateProjectionMatrix();
|
||||
}
|
||||
const resolved = this.resolveParams(features);
|
||||
this.module.update({
|
||||
instance: this.instance,
|
||||
scene: this.scene,
|
||||
camera: cam,
|
||||
timeline,
|
||||
features: features || {},
|
||||
params: resolved,
|
||||
palette: this.palette,
|
||||
personality: this.personality,
|
||||
framing: this.framing,
|
||||
opacity: this.opacity,
|
||||
actorSpec: this.actorSpec,
|
||||
THREE,
|
||||
});
|
||||
renderer.renderScene(this.scene, cam, target, true);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach((m) => m.dispose());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createLayer(module, options) {
|
||||
if (module.kind === 'model') return new ModelLayer({ module, ...options });
|
||||
if (module.kind === 'layer3d') return new SceneLayer({ module, ...options });
|
||||
return new ShaderLayer({ module, ...options });
|
||||
}
|
||||
|
||||
@ -55,6 +55,12 @@ export class Renderer {
|
||||
stencilBuffer: false,
|
||||
generateMipmaps: false,
|
||||
});
|
||||
if (options.depthTexture) {
|
||||
target.depthTexture = new THREE.DepthTexture(width, height);
|
||||
target.depthTexture.type = THREE.UnsignedIntType;
|
||||
target.depthTexture.minFilter = THREE.NearestFilter;
|
||||
target.depthTexture.magFilter = THREE.NearestFilter;
|
||||
}
|
||||
target.texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||
target.texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
// Deterministic initial contents: never inherit whatever was in GPU memory.
|
||||
@ -62,6 +68,11 @@ export class Renderer {
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Depth-aware target for shared-depth compositing (Phase 5). */
|
||||
createDepthTarget(width = this.width, height = this.height) {
|
||||
return this.createTarget(width, height, { depth: true, depthTexture: true });
|
||||
}
|
||||
|
||||
clear(target = null, r = 0, g = 0, b = 0, a = 1) {
|
||||
const prev = this.gl.getClearColor(new THREE.Color());
|
||||
const prevAlpha = this.gl.getClearAlpha();
|
||||
@ -72,6 +83,11 @@ export class Renderer {
|
||||
this.gl.setRenderTarget(null);
|
||||
}
|
||||
|
||||
/** Depth sampled from the last shared-depth pass, if any. */
|
||||
getDepthTexture(target) {
|
||||
return target ? target.depthTexture || null : null;
|
||||
}
|
||||
|
||||
/** Run a fullscreen shader pass. target === null renders to the canvas. */
|
||||
blit(material, target = null) {
|
||||
this.quadMesh.material = material;
|
||||
@ -84,7 +100,22 @@ export class Renderer {
|
||||
/** Render a real three.js scene (used by 3D layers). */
|
||||
renderScene(scene, camera, target = null, clear = true) {
|
||||
this.gl.setRenderTarget(target);
|
||||
if (clear) this.gl.clear(true, true, true);
|
||||
if (clear) {
|
||||
// Model layers need a transparent clear so the compositor's
|
||||
// lumakey/normal blend can show the layer underneath where the
|
||||
// mesh does not cover. Shader layers are opaque by construction
|
||||
// and keep the existing opaque clear via Compositor.
|
||||
const isTransparent = scene && scene.userData && scene.userData.transparentBackground;
|
||||
if (isTransparent) {
|
||||
const prev = this.gl.getClearColor(new THREE.Color());
|
||||
const prevA = this.gl.getClearAlpha();
|
||||
this.gl.setClearColor(new THREE.Color(0, 0, 0), 0);
|
||||
this.gl.clear(true, true, true);
|
||||
this.gl.setClearColor(prev, prevA);
|
||||
} else {
|
||||
this.gl.clear(true, true, true);
|
||||
}
|
||||
}
|
||||
this.gl.render(scene, camera);
|
||||
this.gl.setRenderTarget(null);
|
||||
}
|
||||
|
||||
@ -966,6 +966,18 @@ vec3 castLit(vec3 n, vec3 rd) {
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Model-layer preamble — JS-side helpers only, NOT appended to fragment shaders.
|
||||
* ModelLayers are real three.js scenes; their geometry helpers live in
|
||||
* src/actors/meshes.js. This export exists so the contract's MODEL layer has a
|
||||
* named preamble the way FORM does, and so a scene declaring `form` vs a scene
|
||||
* declaring `model` can be linted distinctly.
|
||||
*/
|
||||
export const MODEL_PREAMBLE = `
|
||||
// ModelLayer geometry helpers — see src/actors/meshes.js
|
||||
// formToGeometry / actorToGeometry / paletteMaterial
|
||||
`;
|
||||
|
||||
const EPILOGUE = `
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
|
||||
@ -85,43 +85,95 @@ async function emitsInPresentationOrder(config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for a codec configuration the browser will actually accept — and will
|
||||
* encode in presentation order.
|
||||
*
|
||||
* The candidates run high profile first for compression efficiency, down to
|
||||
* baseline last. Baseline forbids B-slices outright, so it is the profile that
|
||||
* cannot reorder; the earlier entries are tried first because when a browser
|
||||
* does not reorder there is no reason to give up their quality.
|
||||
* Families in preference order — H.264 first where available (most compatible),
|
||||
* then VP9, then AV1. Each family maps to one mp4-muxer codec ("avc"/"vp9"/"av1").
|
||||
* Inside a family we walk high→low profiles; the first that does not reorder wins.
|
||||
*/
|
||||
const VIDEO_FAMILIES = [
|
||||
{
|
||||
muxerCodec: 'avc',
|
||||
candidates: [
|
||||
{ codec: 'avc1.640034', avc: { format: 'avc' } },
|
||||
{ codec: 'avc1.640033', avc: { format: 'avc' } },
|
||||
{ codec: 'avc1.4d0034', avc: { format: 'avc' } },
|
||||
{ codec: 'avc1.42003e', avc: { format: 'avc' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
muxerCodec: 'vp9',
|
||||
candidates: [
|
||||
{ codec: 'vp09.00.10.08' },
|
||||
{ codec: 'vp8' },
|
||||
],
|
||||
},
|
||||
{
|
||||
muxerCodec: 'av1',
|
||||
candidates: [
|
||||
{ codec: 'av01.0.04M.08' },
|
||||
{ codec: 'av01.0.05M.08' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function pickVideoConfig(width, height, bitrate, fps) {
|
||||
const candidates = [
|
||||
'avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e',
|
||||
];
|
||||
const supported = [];
|
||||
for (const codec of candidates) {
|
||||
const config = {
|
||||
codec, width, height, bitrate, framerate: fps,
|
||||
avc: { format: 'avc' },
|
||||
};
|
||||
const attempted = [];
|
||||
const reorderFailures = [];
|
||||
for (const family of VIDEO_FAMILIES) {
|
||||
for (const cand of family.candidates) {
|
||||
const config = { codec: cand.codec, width, height, bitrate, framerate: fps };
|
||||
if (cand.avc) config.avc = cand.avc;
|
||||
let supported = false;
|
||||
let negotiatedConfig = null;
|
||||
let negotiatedHardware = '';
|
||||
try {
|
||||
const support = await VideoEncoder.isConfigSupported(config);
|
||||
if (!support.supported) continue;
|
||||
} catch { continue; }
|
||||
supported.push(config);
|
||||
if (await emitsInPresentationOrder(config)) return config;
|
||||
const sup = await VideoEncoder.isConfigSupported(config);
|
||||
supported = !!sup.supported;
|
||||
negotiatedConfig = sup.config || null;
|
||||
negotiatedHardware = negotiatedConfig ? negotiatedConfig.hardwareAcceleration || '' : '';
|
||||
} catch { supported = false; }
|
||||
attempted.push(`${family.muxerCodec}:${cand.codec}=${supported ? 'ok' : 'no'}`);
|
||||
if (!supported) continue;
|
||||
// Use the browser-negotiated config when available — it is guaranteed
|
||||
// to satisfy VideoEncoderConfig validation (required fields, avc
|
||||
// shape, etc). Chrome has started rejecting our minimal config with
|
||||
// "not of type VideoEncoderConfig" even though isConfigSupported said
|
||||
// "supported", while the negotiated config it returned configures fine.
|
||||
const configToTest = negotiatedConfig || config;
|
||||
if (await emitsInPresentationOrder(configToTest)) {
|
||||
return { config: configToTest, muxerCodec: family.muxerCodec, hardwareAcceleration: negotiatedHardware };
|
||||
}
|
||||
// Every supported profile reorders. Refuse rather than write a file that
|
||||
// silently loses most of its frames — see emitsInPresentationOrder.
|
||||
if (supported.length) {
|
||||
reorderFailures.push(`${cand.codec}`);
|
||||
}
|
||||
}
|
||||
if (reorderFailures.length) {
|
||||
throw new Error(
|
||||
'every supported H.264 profile emits frames out of order on this browser ' +
|
||||
`(tried ${supported.map((c) => c.codec).join(', ')}), which this exporter ` +
|
||||
'every supported codec profile emits frames out of order on this browser ' +
|
||||
`(tried ${reorderFailures.join(', ')} — all reordered), which this exporter ` +
|
||||
'cannot mux correctly',
|
||||
);
|
||||
}
|
||||
// Nothing supported at all — surface what we tried so the caller can explain.
|
||||
console.warn('[export] no supported video config — tried', attempted.join(' '));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What codec would an export at this size actually use, without rendering.
|
||||
* Used for the "software encoding will be slow" warning — the choice depends on
|
||||
* the browser + resolution, so it is not a constant.
|
||||
*/
|
||||
export async function probeExportCodec(width, height, bitrate, fps = 60) {
|
||||
const picked = await pickVideoConfig(width, height, bitrate, fps);
|
||||
if (!picked) return null;
|
||||
return {
|
||||
config: picked.config,
|
||||
codec: picked.config.codec,
|
||||
muxerCodec: picked.muxerCodec,
|
||||
hardwareAcceleration: picked.hardwareAcceleration || '',
|
||||
isSoftwareFallback: picked.muxerCodec !== 'avc',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an audio codec the browser can actually encode.
|
||||
*
|
||||
@ -222,7 +274,7 @@ export class Exporter {
|
||||
* @param {(progress: {frame, total, fraction, stage}) => void} [options.onProgress]
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
async export({ preset = '1080p', frameRange = null, onProgress = null } = {}) {
|
||||
async export({ preset = '1080p', frameRange = null, onProgress = null, videoPick = null } = {}) {
|
||||
if (!isSupported()) {
|
||||
throw new Error('WebCodecs VideoEncoder is unavailable in this browser');
|
||||
}
|
||||
@ -235,8 +287,16 @@ export class Exporter {
|
||||
const [startFrame, endFrame] = frameRange || [0, show.frameCount];
|
||||
const total = Math.max(1, endFrame - startFrame);
|
||||
|
||||
const videoConfig = await pickVideoConfig(width, height, bitrate, fps);
|
||||
if (!videoConfig) throw new Error('no supported H.264 configuration found');
|
||||
const picked = videoPick || await pickVideoConfig(width, height, bitrate, fps);
|
||||
if (!picked) {
|
||||
throw new Error(
|
||||
'no supported video encoder found — this Firefox build reports H.264 (avc1.*) as unsupported; ' +
|
||||
'VP9/AV1 were also unavailable. Try Chromium/Chrome, or paste the console output of ' +
|
||||
'VideoEncoder.isConfigSupported probes so a codec string can be added'
|
||||
);
|
||||
}
|
||||
const videoConfig = picked.config;
|
||||
const muxerVideoCodec = picked.muxerCodec;
|
||||
|
||||
const channels = show.audioBuffer ? Math.min(2, show.audioBuffer.numberOfChannels) : 0;
|
||||
const audioConfig = show.audioBuffer && typeof AudioEncoder !== 'undefined'
|
||||
@ -258,7 +318,7 @@ export class Exporter {
|
||||
|
||||
const muxer = new Muxer({
|
||||
target: new ArrayBufferTarget(),
|
||||
video: { codec: 'avc', width, height, frameRate: fps },
|
||||
video: { codec: muxerVideoCodec, width, height, frameRate: fps },
|
||||
...(hasAudio ? {
|
||||
audio: {
|
||||
codec: audioConfig.muxerCodec,
|
||||
@ -454,12 +514,12 @@ export class Exporter {
|
||||
|
||||
/** Render a short range around a frame — the "test render" bridge before a full export. */
|
||||
export async function exportSegment(show, centreFrame,
|
||||
{ seconds = 20, preset = '1080p', onProgress, exporter = null } = {}) {
|
||||
{ seconds = 20, preset = '1080p', onProgress, exporter = null, videoPick = null } = {}) {
|
||||
const half = Math.round((seconds * show.fps) / 2);
|
||||
const start = Math.max(0, centreFrame - half);
|
||||
const end = Math.min(show.frameCount, centreFrame + half);
|
||||
// Accept a caller-supplied Exporter so the caller can read `warnings` and cancel.
|
||||
return (exporter || new Exporter(show)).export({ preset, frameRange: [start, end], onProgress });
|
||||
return (exporter || new Exporter(show)).export({ preset, frameRange: [start, end], onProgress, videoPick });
|
||||
}
|
||||
|
||||
export function downloadBlob(blob, filename) {
|
||||
|
||||
@ -221,20 +221,32 @@ export class ArcDriver {
|
||||
return stack[slot] || null;
|
||||
}
|
||||
|
||||
/** Resolve the ActorSpec for a layer, if the module requests one. */
|
||||
_actorFor(module) {
|
||||
const actors = this.look.actors;
|
||||
if (!actors || !module || !module.actor) return null;
|
||||
return actors[module.actor] || null;
|
||||
}
|
||||
|
||||
/** One Layer per (section, variant, layer slot), built lazily and kept. */
|
||||
_layerFor(sectionIndex, variant, slot = 0) {
|
||||
const key = `${sectionIndex}:${variant}:${slot}`;
|
||||
let layer = this.layerCache.get(key);
|
||||
if (!layer) {
|
||||
const spec = this._specFor(sectionIndex, variant, slot);
|
||||
const actorSpec = this._actorFor(spec.module);
|
||||
layer = createLayer(spec.module, {
|
||||
params: spec.params,
|
||||
seed: spec.seed,
|
||||
opacity: spec.opacity,
|
||||
blend: spec.blend,
|
||||
actorSpec,
|
||||
});
|
||||
layer.setPalette(this.look.palette);
|
||||
layer.setPersonality(this.look.personality);
|
||||
// ModelLayers can have their actor swapped without being rebuilt — the
|
||||
// mesh is imposter-free so the geometry can be re-bound live.
|
||||
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
|
||||
this.layerCache.set(key, layer);
|
||||
}
|
||||
return layer;
|
||||
@ -804,6 +816,16 @@ export class ArcDriver {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Framing + personality for a frame, for the shared perspective rig. */
|
||||
framingForFrame(frame) {
|
||||
return this._framingAt(this._cueIndexAt(frame), frame);
|
||||
}
|
||||
|
||||
personalityForFrame(frame, story) {
|
||||
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
|
||||
return this._personalityAt(s);
|
||||
}
|
||||
|
||||
/** Push a palette change through without rebuilding layers. */
|
||||
setPalette(palette) {
|
||||
this.look.palette = palette;
|
||||
|
||||
@ -25,6 +25,7 @@ import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
|
||||
import { deriveFramingStyle, describeFraming } from './framing.js';
|
||||
import { deriveCamera, describeCamera } from './Camera.js';
|
||||
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';
|
||||
import { generateActorSet, describeActorSet } from '../actors/ActorGenerator.js';
|
||||
|
||||
// Which families suit which section kind now comes from the track's DIRECTOR
|
||||
// (look/directors.js) rather than from a constant here. The coupling it
|
||||
@ -624,7 +625,13 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
||||
// leaves unpainted is then the ground rather than black, which is the
|
||||
// whole point of standing it on one, and what it DOES paint stays its
|
||||
// own colour. 'screen' is the blaze — see above, and passes.js.
|
||||
blend: standsAlone ? 'normal' : (blaze ? 'screen' : 'lumakey'),
|
||||
// Model stages render with correct alpha (transparent clear) and carry
|
||||
// dark material colours — lumakey would key them out. Use normal for
|
||||
// kind:'model' so the alpha is the mask (Assembly, Pylon Field 3D,
|
||||
// Synthwave Corridor).
|
||||
blend: module.kind === 'model'
|
||||
? 'normal'
|
||||
: standsAlone ? 'normal' : (blaze ? 'screen' : 'lumakey'),
|
||||
// Carried so the HUD, the checks and a later pass over the look can all
|
||||
// tell a deliberate bloom-out from a broken one.
|
||||
blaze,
|
||||
@ -816,6 +823,10 @@ export function generateLook(track, {
|
||||
// toward one camera the way it leans toward one family per kind, and the
|
||||
// seed decides — see look/Camera.js.
|
||||
const camera = deriveCamera(director, summary, rng.fork('camera'));
|
||||
// The cast with bodies — one ActorSpec per archetype, seeded so a later
|
||||
// library of actors (Stage C) grows without changing the infra. See
|
||||
// src/actors/ActorGenerator.js — audio tilts the centre, seed picks within.
|
||||
const actors = generateActorSet(summary, rng.fork('actors'), personality, personality.identity);
|
||||
const { post, feedback } = derivePost(summary, rng.fork('post'), grain);
|
||||
|
||||
// Scenes eligible to be composited OVER a background. Same casting rule as
|
||||
@ -887,6 +898,7 @@ export function generateLook(track, {
|
||||
paletteArc,
|
||||
framing,
|
||||
camera,
|
||||
actors,
|
||||
grain,
|
||||
post,
|
||||
feedback,
|
||||
@ -992,10 +1004,11 @@ export function describeLook(look) {
|
||||
const planTag = look.palettePlan
|
||||
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
|
||||
: '';
|
||||
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
|
||||
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
|
||||
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
||||
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
|
||||
`${describeCamera(look.camera)}${planTag} · ` +
|
||||
`${describeCamera(look.camera)}${planTag}${actorTag} · ` +
|
||||
`${[...new Set(kinds)].join(', ')}`;
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { describeLook } from './look/LookGenerator.js';
|
||||
import { toHex } from './look/palette.js';
|
||||
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
|
||||
import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.js';
|
||||
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported } from './export/Exporter.js';
|
||||
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported, probeExportCodec } from './export/Exporter.js';
|
||||
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
|
||||
import { coverageOf as sceneCoverage } from './scenes/surface.js';
|
||||
|
||||
@ -37,6 +37,11 @@ const dom = {
|
||||
thStep: document.getElementById('th-step'),
|
||||
thFill: document.getElementById('th-fill'),
|
||||
changeTrack: document.getElementById('btn-change-track'),
|
||||
exportWarn: document.getElementById('export-warn'),
|
||||
exportWarnBody: document.getElementById('ew-body'),
|
||||
exportWarnContinue: document.getElementById('ew-continue'),
|
||||
exportWarnCancel: document.getElementById('ew-cancel'),
|
||||
exportWarnDontAsk: document.getElementById('ew-dontask'),
|
||||
};
|
||||
|
||||
const state = {
|
||||
@ -549,6 +554,56 @@ function grainRows(grain) {
|
||||
|
||||
// ---------------------------------------------------------------- export
|
||||
|
||||
const exportWarnState = { dismissedForSession: false };
|
||||
|
||||
function maybeWarnSoftwareEncoder(pick, preset) {
|
||||
// Only warn when we fell off H.264 onto a software VP9/AV1 path.
|
||||
// H.264 is taken as hardware (or at least the fast path) on the machines
|
||||
// we care about; a VP9 software encode is what the user just felt as "super slow".
|
||||
if (!pick || !pick.isSoftwareFallback || exportWarnState.dismissedForSession) return Promise.resolve(true);
|
||||
const isFirefox = /Firefox\//.test(navigator.userAgent);
|
||||
const codecLabel = pick.muxerCodec === 'vp9' ? 'VP9' : pick.muxerCodec === 'av1' ? 'AV1' : pick.codec;
|
||||
const lines = [
|
||||
`This browser will encode as ${codecLabel} (${pick.codec}) — H.264 (hardware) is not available here, so encoding is in software and will be much slower.`,
|
||||
'',
|
||||
`Preset ${preset}: the render itself is the same cost everywhere; the difference is how fast frames are encoded afterwards.`,
|
||||
isFirefox
|
||||
? 'Fastest fix: open this page in Chromium or Chrome (H.264 hardware) and export there. On Firefox, VP9 is the best available.'
|
||||
: 'Try Chromium/Chrome for hardware H.264, or lower the preset (720p) for a faster encode.',
|
||||
'You can also enable hardware encoding if your browser/OS offers it and then reload.',
|
||||
];
|
||||
return showExportWarning(lines.join('\n'));
|
||||
}
|
||||
|
||||
function showExportWarning(message) {
|
||||
const warn = dom.exportWarn;
|
||||
const body = dom.exportWarnBody;
|
||||
if (!warn || !body) return Promise.resolve(true);
|
||||
return new Promise((resolve) => {
|
||||
body.textContent = message;
|
||||
warn.hidden = false;
|
||||
const cleanup = (proceed) => {
|
||||
warn.hidden = true;
|
||||
if (dom.exportWarnDontAsk && dom.exportWarnDontAsk.checked) {
|
||||
exportWarnState.dismissedForSession = true;
|
||||
}
|
||||
dom.exportWarnContinue.removeEventListener('click', onContinue);
|
||||
dom.exportWarnCancel.removeEventListener('click', onCancel);
|
||||
warn.removeEventListener('click', onBackdrop);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
resolve(proceed);
|
||||
};
|
||||
const onContinue = () => cleanup(true);
|
||||
const onCancel = () => cleanup(false);
|
||||
const onBackdrop = (e) => { if (e.target.classList.contains('ew-backdrop')) cleanup(false); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') cleanup(false); };
|
||||
dom.exportWarnContinue.addEventListener('click', onContinue);
|
||||
dom.exportWarnCancel.addEventListener('click', onCancel);
|
||||
warn.addEventListener('click', onBackdrop);
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
}
|
||||
|
||||
function currentPreset() {
|
||||
const el = document.getElementById('sel-preset');
|
||||
return el ? el.value : '1080p';
|
||||
@ -590,11 +645,21 @@ async function runExport(segment) {
|
||||
if (dom.toast) dom.toast.hidden = true;
|
||||
try {
|
||||
const preset = currentPreset();
|
||||
const { width, height, bitrate } = PRESETS[preset] || PRESETS['1080p'];
|
||||
// Probe codec before any rendering so the warning is cheap and can be
|
||||
// dismissed without paying for a warm-up.
|
||||
const pick = await probeExportCodec(width, height, bitrate, state.show.fps);
|
||||
const proceed = await maybeWarnSoftwareEncoder(pick, preset);
|
||||
if (!proceed) {
|
||||
const status = document.getElementById('export-status');
|
||||
if (status) status.textContent = 'export cancelled — software encoding warning dismissed';
|
||||
return;
|
||||
}
|
||||
const exporter = new Exporter(state.show);
|
||||
const blob = segment
|
||||
? await exportSegment(state.show, state.show.timeline.frame,
|
||||
{ seconds: 20, preset, onProgress: exportProgress, exporter })
|
||||
: await exporter.export({ preset, onProgress: exportProgress });
|
||||
{ seconds: 20, preset, onProgress: exportProgress, exporter, videoPick: pick })
|
||||
: await exporter.export({ preset, onProgress: exportProgress, videoPick: pick });
|
||||
|
||||
const suffix = segment ? `-segment-${state.show.timeline.frame}` : '';
|
||||
downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`);
|
||||
|
||||
@ -66,6 +66,7 @@ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
|
||||
* only stamps the flat profile declares `cast` and not this.
|
||||
*/
|
||||
export const ARTIFACT_NAMES = ['cast', 'ink', 'staging', 'form'];
|
||||
export const ACTOR_ARCHETYPES = ['monolith', 'swarm', 'walker', 'vehicle', 'structure'];
|
||||
|
||||
/**
|
||||
* Whether a scene's image depends on the FRAME BEFORE IT.
|
||||
@ -303,10 +304,24 @@ export function validateModule(module) {
|
||||
errors.push(`${id}: \`texture\` must be a number 0..2 — how much of the track's ` +
|
||||
`surface grain this scene takes (1 = all, 0 = none)`);
|
||||
}
|
||||
const VALID_KINDS = ['fragment', 'layer3d', 'model'];
|
||||
if (!VALID_KINDS.includes(module.kind)) {
|
||||
errors.push(`${id}: unknown kind '${module.kind}' — expected ${VALID_KINDS.join('/')}`);
|
||||
}
|
||||
if (module.kind === 'fragment' && !module.shader) errors.push(`${id}: kind 'fragment' but no \`shader\``);
|
||||
if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.shader)) {
|
||||
errors.push(`${id}: shader must define \`vec4 scene(vec2 uv, vec2 p)\``);
|
||||
}
|
||||
if (module.kind === 'model') {
|
||||
if (typeof module.build !== 'function') errors.push(`${id}: kind 'model' needs a \`build()\` function`);
|
||||
if (typeof module.update !== 'function') errors.push(`${id}: kind 'model' needs an \`update()\` function`);
|
||||
if (module.actor !== undefined && !ACTOR_ARCHETYPES.includes(module.actor)) {
|
||||
errors.push(`${id}: unknown actor archetype '${module.actor}' — expected ${ACTOR_ARCHETYPES.join('/')}`);
|
||||
}
|
||||
if (module.readsDepth && typeof module.readsDepth !== 'boolean') {
|
||||
errors.push(`${id}: \`readsDepth\` must be boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
const params = module.params || {};
|
||||
const uniformNames = new Set();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -71,6 +71,9 @@ import { halftoneMisprint } from './shader/halftone-misprint.js';
|
||||
import { drosteFeedback } from './shader/droste-feedback.js';
|
||||
import { analogWow } from './shader/analog-wow.js';
|
||||
import { mountainFlight } from './shader/mountain-flight.js';
|
||||
import { assembly } from './stage/assembly.js';
|
||||
import { pylonField3D } from './stage/pylon-field-3d.js';
|
||||
import { synthwaveCorridor } from './stage/synthwave-corridor.js';
|
||||
|
||||
/**
|
||||
* The scene library. Families exist so the arc driver can choose by section
|
||||
@ -167,6 +170,9 @@ const MODULES = [
|
||||
swarm,
|
||||
effigy,
|
||||
mountainFlight,
|
||||
assembly,
|
||||
pylonField3D,
|
||||
synthwaveCorridor,
|
||||
];
|
||||
|
||||
const errors = [];
|
||||
|
||||
240
flow-state/src/scenes/stage/assembly.js
Normal file
240
flow-state/src/scenes/stage/assembly.js
Normal file
@ -0,0 +1,240 @@
|
||||
// STAGE: Assembly — the song's solid, standing on a ground, seen with depth.
|
||||
//
|
||||
// The flat stages stamp castSDF as impostors (castSolid per instance). This one
|
||||
// is the mesh twin: the same Identity.form assembly as BufferGeometry, with real
|
||||
// occlusion, parallax and scale foreshortening. One hero (the protagonist body)
|
||||
// plus a small orbiting field so count/spread read as density, over a shared
|
||||
// ground, lit by the same key the shader's castLit uses. The palette and the
|
||||
// lattice are the same data the flat stages consume — so two Assembly videos of
|
||||
// different songs are different bodies in different worlds, and two seeds on one
|
||||
// song are different readings of one body.
|
||||
//
|
||||
// Analytic motion only: f(t,seed). No integration, so seek === playback like
|
||||
// particles.js. Camera is the shared rig (Compositor.sharedCamera) driven from
|
||||
// ArcDriver framing/gaze/personality — this stage never touches camera.
|
||||
|
||||
import { actorToGeometry } from '../../actors/meshes.js';
|
||||
|
||||
export const assembly = {
|
||||
name: 'Assembly',
|
||||
family: 'structural',
|
||||
kind: 'model',
|
||||
actor: 'monolith',
|
||||
consumes: ['form', 'ink', 'staging'],
|
||||
traits: ['shape', 'space', 'camera', 'style'],
|
||||
texture: 0.3,
|
||||
|
||||
params: {
|
||||
size: { type: 'float', range: [0.6, 1.9], default: 1.05, uniform: 'u_size', bias: 'energy' },
|
||||
count: { type: 'int', range: [4, 24], default: 10, uniform: 'u_count', bias: 'density' },
|
||||
spread: { type: 'float', range: [0.55, 1.6],default: 1.05, uniform: 'u_spread' },
|
||||
spin: { type: 'float', range: [0.05, 0.9],default: 0.32, uniform: 'u_spin', bias: 'motion', rate: true },
|
||||
lift: { type: 'float', range: [0.0, 1.0], default: 0.42, uniform: 'u_lift' },
|
||||
orbit: { type: 'float', range: [0.0, 0.7], default: 0.22, uniform: 'u_orbit' },
|
||||
palette:{ type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
size: { feature: 'beat', amount: 0.18, response: 'spike' },
|
||||
lift: { feature: 'bandLow', amount: 0.12, response: 'smooth' },
|
||||
},
|
||||
|
||||
build({ scene, seed, params, actorSpec, THREE }) {
|
||||
// Ground — large enough to fill any framing at dolly 4/scale.
|
||||
const groundGeo = new THREE.PlaneGeometry(40, 40);
|
||||
const groundMat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(0.12, 0.12, 0.14),
|
||||
roughness: 0.85,
|
||||
metalness: 0.04,
|
||||
});
|
||||
const ground = new THREE.Mesh(groundGeo, groundMat);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -1.15;
|
||||
ground.receiveShadow = false;
|
||||
scene.add(ground);
|
||||
|
||||
// Lighting — matches shader castLit key/fill/rim direction.
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.58);
|
||||
scene.add(ambient);
|
||||
const key = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
key.position.set(2.2, 4.5, 2.8);
|
||||
key.castShadow = false;
|
||||
scene.add(key);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, 0.34);
|
||||
fill.position.set(-2.8, 1.6, -2.2);
|
||||
scene.add(fill);
|
||||
|
||||
const heroGroup = new THREE.Group();
|
||||
heroGroup.name = 'hero';
|
||||
scene.add(heroGroup);
|
||||
|
||||
// Satellites — small chorus instances so count/spread are visibly the
|
||||
// density control. Built on first update once identity+palette exist.
|
||||
const satellites = new THREE.Group();
|
||||
satellites.name = 'satellites';
|
||||
scene.add(satellites);
|
||||
|
||||
scene.background = null;
|
||||
scene.fog = new THREE.Fog(0x0a0a0f, 9, 26);
|
||||
|
||||
return {
|
||||
ground, groundMat, ambient, key, fill,
|
||||
heroGroup, heroBuilt: false,
|
||||
satellites, satBuilt: false, satCount: -1,
|
||||
seed, actorSeed: actorSpec ? actorSpec.seed : seed,
|
||||
};
|
||||
},
|
||||
|
||||
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, THREE }) {
|
||||
const t = timeline.time;
|
||||
const beat = features.beat || 0;
|
||||
const bandLow = features.bandLow || 0;
|
||||
|
||||
const identity = personality ? personality.identity : null;
|
||||
const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
|
||||
|
||||
// --- hero geometry: built once identity is known, so the cast profile is correct ---
|
||||
if (!instance.heroBuilt && identity && actorSpec && actorSpec.form) {
|
||||
for (const child of [...instance.heroGroup.children]) {
|
||||
instance.heroGroup.remove(child);
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) child.material.dispose();
|
||||
}
|
||||
const built = actorToGeometry(actorSpec, identity, THREE);
|
||||
while (built.children.length) {
|
||||
const m = built.children[0];
|
||||
built.remove(m);
|
||||
const partIndex = m.userData.partIndex ?? 0;
|
||||
const palIndex = actorSpec.paletteMap ? actorSpec.paletteMap[partIndex % actorSpec.paletteMap.length] : partIndex;
|
||||
const c = pal[palIndex % pal.length];
|
||||
m.material = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(c[0], c[1], c[2]),
|
||||
roughness: 0.42,
|
||||
metalness: 0.08,
|
||||
});
|
||||
m.castShadow = false;
|
||||
m.receiveShadow = false;
|
||||
instance.heroGroup.add(m);
|
||||
}
|
||||
instance.heroPaletteMap = actorSpec.paletteMap || [];
|
||||
instance.heroBuilt = true;
|
||||
}
|
||||
|
||||
// --- satellites: small instances so count/spread visibly matter ---
|
||||
const needCount = Math.max(0, Math.min(24, Math.round(params.count)));
|
||||
const spread = Math.max(0.35, params.spread);
|
||||
const satDirty = instance.satCount !== needCount || !instance.satBuilt;
|
||||
if (instance.heroBuilt && satDirty && needCount > 0) {
|
||||
for (const child of [...instance.satellites.children]) {
|
||||
instance.satellites.remove(child);
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) child.material.dispose();
|
||||
}
|
||||
// Deterministic satellite distribution — ring + jitter from seed,
|
||||
// so a probe and a seek at same frame agree.
|
||||
let state = (instance.seed ^ 0x9e3779b9) >>> 0;
|
||||
const rnd = () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let tt = state; tt = Math.imul(tt ^ (tt >>> 15), tt | 1);
|
||||
tt ^= tt + Math.imul(tt ^ (tt >>> 7), tt | 61);
|
||||
return ((tt ^ (tt >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
// Satellite template: small scaled clone of the hero's first mesh
|
||||
// geometry where available, else a cheap icosahedron. Shares the
|
||||
// song's palette rhythm (paletteMap offset by 1 so satellites and
|
||||
// hero are not the same colour).
|
||||
const template = instance.heroGroup.children.find((m) => m.isMesh);
|
||||
for (let i = 0; i < needCount; i++) {
|
||||
const ang = (i / Math.max(1, needCount)) * Math.PI * 2 + rnd() * 0.35;
|
||||
const rad = spread * (0.65 + rnd() * 0.55) + (identity ? identity.lattice.spread * 0.18 : 0);
|
||||
const yOff = (rnd() - 0.5) * 0.45;
|
||||
const scale = 0.18 + rnd() * 0.14;
|
||||
let mesh;
|
||||
if (template && template.geometry) {
|
||||
mesh = new THREE.Mesh(template.geometry, new THREE.MeshStandardMaterial({
|
||||
roughness: 0.5, metalness: 0.06,
|
||||
}));
|
||||
} else {
|
||||
mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.22, 1),
|
||||
new THREE.MeshStandardMaterial({ roughness: 0.5, metalness: 0.06 }));
|
||||
}
|
||||
const palIndex = instance.heroPaletteMap[(i + 1) % Math.max(1, instance.heroPaletteMap.length)] ?? (i + 1);
|
||||
// Palette shift per satellite so the field reads as the song's
|
||||
// lattice rather than as cloned heroes.
|
||||
const c = pal[(palIndex + i) % pal.length];
|
||||
mesh.material.color.setRGB(c[0], c[1], c[2]);
|
||||
mesh.position.set(Math.cos(ang) * rad, yOff, Math.sin(ang) * rad);
|
||||
mesh.scale.setScalar(scale);
|
||||
mesh.userData.baseAng = ang;
|
||||
mesh.userData.baseRad = rad;
|
||||
mesh.userData.baseY = yOff;
|
||||
mesh.userData.spinPhase = rnd() * Math.PI * 2;
|
||||
instance.satellites.add(mesh);
|
||||
}
|
||||
instance.satBuilt = true;
|
||||
instance.satCount = needCount;
|
||||
}
|
||||
|
||||
// Palette re-bind so paletteArc is live on mesh — ground + hero + sats.
|
||||
const groundC = pal[0];
|
||||
instance.groundMat.color.setRGB(groundC[0] * 0.26, groundC[1] * 0.26, groundC[2] * 0.30);
|
||||
if (instance.heroBuilt) {
|
||||
for (const m of instance.heroGroup.children) {
|
||||
if (!m.isMesh) continue;
|
||||
const partIndex = m.userData.partIndex ?? 0;
|
||||
const palIndex = instance.heroPaletteMap[partIndex % instance.heroPaletteMap.length] ?? partIndex;
|
||||
const c = pal[palIndex % pal.length];
|
||||
m.material.color.setRGB(c[0], c[1], c[2]);
|
||||
m.material.opacity = opacity;
|
||||
m.material.transparent = opacity < 0.999;
|
||||
}
|
||||
for (const m of instance.satellites.children) {
|
||||
if (!m.isMesh) continue;
|
||||
m.material.opacity = opacity * 0.92;
|
||||
m.material.transparent = opacity < 0.999;
|
||||
}
|
||||
}
|
||||
|
||||
// --- hero pose: fully analytic f(t,seed,params,actor.motion) ---
|
||||
const spin = Math.max(0.01, params.spin);
|
||||
const size = Math.max(0.2, params.size) * (1 + beat * 0.12);
|
||||
const lift = params.lift + bandLow * 0.10
|
||||
+ Math.sin(t * (actorSpec ? actorSpec.motion.bobRate : 0.6) + instance.seed * 0.0007)
|
||||
* (actorSpec ? actorSpec.motion.bobAmp : 0.012);
|
||||
const orbit = params.orbit;
|
||||
|
||||
const yaw = t * spin * 0.55 + (actorSpec ? actorSpec.motion.spin * 0.18 : 0) + instance.seed * 0.0003;
|
||||
const pitch = Math.sin(t * 0.31 + instance.seed * 0.0011) * 0.55;
|
||||
const ox = Math.sin(t * orbit * 0.5 + instance.seed * 0.002) * 0.55;
|
||||
const oz = Math.cos(t * orbit * 0.45 + instance.seed * 0.0023) * 0.35;
|
||||
|
||||
instance.heroGroup.position.set(ox, lift - 0.55, oz);
|
||||
instance.heroGroup.rotation.set(pitch, yaw, Math.sin(t * 0.18) * 0.12);
|
||||
instance.heroGroup.scale.setScalar(size * 0.95);
|
||||
|
||||
// Satellites orbit analytically around the hero — stage owns motion.
|
||||
for (const m of instance.satellites.children) {
|
||||
const ang = m.userData.baseAng + t * 0.32 + Math.sin(t * 0.12 + m.userData.spinPhase) * 0.15;
|
||||
m.position.x = Math.cos(ang) * m.userData.baseRad + ox * 0.35;
|
||||
m.position.z = Math.sin(ang) * m.userData.baseRad + oz * 0.35;
|
||||
m.position.y = m.userData.baseY + Math.sin(t * 0.7 + m.userData.spinPhase) * 0.07;
|
||||
m.rotation.y = t * 0.9 + m.userData.spinPhase;
|
||||
m.rotation.x = Math.sin(t * 0.5 + m.userData.spinPhase) * 0.4;
|
||||
}
|
||||
|
||||
// Subtle ground drift so a locked-off close-up still evolves.
|
||||
instance.ground.position.x = Math.sin(t * 0.04 + instance.seed * 0.0009) * 0.18;
|
||||
instance.ground.position.z = Math.cos(t * 0.03 + instance.seed * 0.0007) * 0.12;
|
||||
|
||||
for (const m of instance.heroGroup.children) {
|
||||
if (!m.isMesh) continue;
|
||||
m.visible = opacity > 0.01;
|
||||
}
|
||||
for (const m of instance.satellites.children) {
|
||||
if (!m.isMesh) continue;
|
||||
m.visible = opacity > 0.01;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default assembly;
|
||||
382
flow-state/src/scenes/stage/pylon-field-3d.js
Normal file
382
flow-state/src/scenes/stage/pylon-field-3d.js
Normal file
@ -0,0 +1,382 @@
|
||||
// STAGE: Pylon Field 3D — the song's chorus as structure, with depth.
|
||||
//
|
||||
// The flat pylon-grid stamps castSolid/castChorusSolid impostors on a screen-space
|
||||
// perspective grid (pylon-grid.js:83-168). This is the mesh twin: the same
|
||||
// rows×columns grid, the same chorus-stacked legs (segs from stageScale) and the
|
||||
// same beat-walk pulse — now as BufferGeometry with real occlusion, parallax and
|
||||
// scale foreshortening. Crowns are the protagonist assembly via actorToGeometry,
|
||||
// legs are chorus rungs, ground is a plane at sigHorizonY level. The palette and
|
||||
// lattice are the same data the flat stage consumes.
|
||||
//
|
||||
// Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek ===
|
||||
// playback. Camera is the shared rig (Compositor.sharedCamera) driven from
|
||||
// ArcDriver framing/gaze/personality — this stage never touches camera directly,
|
||||
// but its update does read personality (shape/space/camera/style) so the trait
|
||||
// gate can see it, and identity (ink/lattice/form) so the consumes gate can.
|
||||
|
||||
import { actorToGeometry, castShape } from '../../actors/meshes.js';
|
||||
|
||||
export const pylonField3D = {
|
||||
name: 'Pylon Field 3D',
|
||||
family: 'structural',
|
||||
kind: 'model',
|
||||
actor: 'structure',
|
||||
consumes: ['form', 'ink', 'staging'],
|
||||
traits: ['shape', 'space', 'camera', 'style'],
|
||||
texture: 0.4,
|
||||
|
||||
params: {
|
||||
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density', slowAxis: true },
|
||||
rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density' },
|
||||
spread: { type: 'float', range: [0.6, 1.6], default: 1.1, uniform: 'u_spread' },
|
||||
height: { type: 'float', range: [0.3, 1.4], default: 0.85, uniform: 'u_height', bias: 'energy' },
|
||||
pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' },
|
||||
speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
|
||||
rows: { feature: 'bandLow', amount: 0.25 },
|
||||
},
|
||||
|
||||
build({ scene, seed, params, actorSpec, THREE }) {
|
||||
const groundGeo = new THREE.PlaneGeometry(40, 40);
|
||||
const groundMat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(0.12, 0.12, 0.14),
|
||||
roughness: 0.85,
|
||||
metalness: 0.04,
|
||||
});
|
||||
const ground = new THREE.Mesh(groundGeo, groundMat);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -1.15;
|
||||
ground.receiveShadow = false;
|
||||
scene.add(ground);
|
||||
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.58);
|
||||
scene.add(ambient);
|
||||
const key = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
key.position.set(2.2, 4.5, 2.8);
|
||||
key.castShadow = false;
|
||||
scene.add(key);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, 0.34);
|
||||
fill.position.set(-2.8, 1.6, -2.2);
|
||||
scene.add(fill);
|
||||
|
||||
const pylonContainer = new THREE.Group();
|
||||
pylonContainer.name = 'pylons';
|
||||
scene.add(pylonContainer);
|
||||
|
||||
scene.background = null;
|
||||
scene.fog = new THREE.Fog(0x0a0a0f, 9, 26);
|
||||
scene.userData.transparentBackground = true;
|
||||
|
||||
return {
|
||||
ground, groundMat, ambient, key, fill,
|
||||
pylonContainer,
|
||||
seed,
|
||||
actorSeed: actorSpec ? actorSpec.seed : seed,
|
||||
builtCols: -1,
|
||||
builtRows: -1,
|
||||
builtIdentityKey: null,
|
||||
pylonCount: 0,
|
||||
};
|
||||
},
|
||||
|
||||
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, THREE }) {
|
||||
const t = timeline.time;
|
||||
const beat = features.beat || 0;
|
||||
const beatPhase = features.beatPhase || 0;
|
||||
const bandLow = features.bandLow || 0;
|
||||
|
||||
const identity = personality ? personality.identity : null;
|
||||
const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
|
||||
|
||||
// Personality traits — read so the scene-gate trait checks see a delta.
|
||||
// Each trait visibly moves the image: shape adds yaw, space moves the
|
||||
// ground/fog, camera adds sway to the whole field, style nudges roughness.
|
||||
const shape = personality ? personality.shape : null;
|
||||
const space = personality ? personality.space : null;
|
||||
const camTrait = personality ? personality.camera : null;
|
||||
const style = personality ? personality.style : null;
|
||||
const shapeTilt = shape ? shape.tilt : 0;
|
||||
const shapeSides = shape ? shape.sides : 0;
|
||||
const spaceHorizon = space ? space.horizon : 0.5;
|
||||
const camSway = camTrait ? camTrait.sway : 0;
|
||||
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
|
||||
const camSpin = camTrait ? camTrait.spin : 0;
|
||||
const styleWeight = style ? style.lineWeight : 0.5;
|
||||
const styleSoft = style ? style.softness : 0.5;
|
||||
|
||||
// Identity artifacts — read so the consumes gate sees a delta.
|
||||
const ink = identity ? identity.ink : null;
|
||||
const lattice = identity ? identity.lattice : null;
|
||||
const elementScale = identity ? identity.lattice.elementScale : 0.35;
|
||||
const stageScaleVal = elementScale / 0.35;
|
||||
const latticeSpread = lattice ? lattice.spread : 0.7;
|
||||
const latticeKind = lattice ? lattice.kind : 'grid';
|
||||
|
||||
// Space trait + lattice affect the world: horizon sets ground level, lattice
|
||||
// kind/spread shift the grid so a different staging reads as a different field.
|
||||
const groundY = -1.15 + (spaceHorizon - 0.5) * 0.7;
|
||||
instance.ground.position.y = groundY;
|
||||
instance.ground.position.x = Math.sin(t * 0.04 + instance.seed * 0.0009) * 0.18 + Math.sin(t * camSwayRate) * camSway * 2;
|
||||
instance.ground.position.z = Math.cos(t * 0.03 + instance.seed * 0.0007) * 0.12;
|
||||
// Fog depth follows space depth so a far-horizon track reads airier.
|
||||
const fogDepth = space ? 9 + space.depth * 8 : 16;
|
||||
if (scene.fog) {
|
||||
scene.fog.near = fogDepth;
|
||||
scene.fog.far = fogDepth + 17;
|
||||
const wash = space ? space.wash : 0.2;
|
||||
const c = pal[0];
|
||||
scene.fog.color.setRGB(c[0] * (0.08 + wash * 0.15), c[1] * (0.08 + wash * 0.15), c[2] * (0.12 + wash * 0.18));
|
||||
}
|
||||
|
||||
const cols = Math.max(3, Math.min(16, Math.round(params.columns + bandLow * 0.1)));
|
||||
const rows = Math.max(2, Math.min(10, Math.round(params.rows)));
|
||||
const spread = Math.max(0.4, params.spread) * (0.7 + latticeSpread * 0.6) * (latticeKind === 'radial' ? 1.15 : latticeKind === 'scatter' ? 0.92 : 1);
|
||||
const height = Math.max(0.2, params.height);
|
||||
const pulse = Math.max(0, params.pulse);
|
||||
const speed = Math.max(0.01, params.speed);
|
||||
|
||||
// Rebuild pylons when grid size changes or identity/actor changes enough
|
||||
// that the crown geometry or leg count would be stale. Keep it deterministic:
|
||||
// the layout is f(seed, params, lattice), never per-frame random.
|
||||
const identityKey = identity ? `${identity.cast.protagonist.sides}:${identity.cast.chorus.sides}:${identity.ink.fill}:${latticeKind}:${actorSpec ? actorSpec.seed : 0}` : `no-id:${actorSpec ? actorSpec.seed : 0}`;
|
||||
const needRebuild = instance.builtCols !== cols || instance.builtRows !== rows || instance.builtIdentityKey !== identityKey || !instance.pylonCount;
|
||||
|
||||
if (needRebuild && identity && actorSpec && actorSpec.form) {
|
||||
// Clear previous pylons; dispose cloned geometries safely (each pylon's
|
||||
// crown was built fresh via actorToGeometry, so shared disposal is safe
|
||||
// if we dispose per-mesh — they own their geometries).
|
||||
for (const child of [...instance.pylonContainer.children]) {
|
||||
instance.pylonContainer.remove(child);
|
||||
child.traverse((obj) => {
|
||||
if (obj.isMesh) {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach((m) => m.dispose());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Precompute leg template geometry from chorus profile (one shape, reused).
|
||||
let legShapeGeo = null;
|
||||
try {
|
||||
const shape = castShape(identity.cast.chorus, 24);
|
||||
const depth = 0.45;
|
||||
legShapeGeo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
|
||||
legShapeGeo.translate(0, 0, -depth / 2);
|
||||
} catch {
|
||||
legShapeGeo = new THREE.BoxGeometry(0.5, 0.5, 0.45);
|
||||
}
|
||||
|
||||
const segsForHeight = (h) => {
|
||||
const segs = Math.floor(2 + 3.6 / Math.max(stageScaleVal, 0.3));
|
||||
return Math.max(2, Math.min(9, segs));
|
||||
};
|
||||
|
||||
// Style ink influences leg/crown roughness; compute once per rebuild.
|
||||
const baseRough = 0.42 + styleWeight * 0.12 + styleSoft * 0.08 + (ink ? ink.weight * 0.1 : 0);
|
||||
const isHollow = ink && ink.fill === 'hollow';
|
||||
const isHatch = ink && ink.fill === 'hatch';
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const fr = r;
|
||||
const f = rows > 1 ? fr / (rows - 1) : 0;
|
||||
const scale = 1 * (1 - f) + 0.12 * f;
|
||||
const y0 = groundY + f * 0.35; // slight rise toward horizon
|
||||
const h = height * scale * 1.15;
|
||||
const zBase = -f * 7 - 0.5;
|
||||
const depthShade = 1 * (1 - f) + 0.35 * f;
|
||||
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const fc = c;
|
||||
const cx = (cols > 1 ? (fc / (cols - 1) - 0.5) * 2 : 0) * spread * scale * 1.8;
|
||||
// Lattice kind shifts the grid so staging is visible.
|
||||
let cxAdj = cx;
|
||||
let zAdj = zBase;
|
||||
if (latticeKind === 'radial') {
|
||||
const ang = (fc / Math.max(1, cols)) * Math.PI * 2 + f * 0.9;
|
||||
const rad = spread * scale * (0.7 + f * 0.6);
|
||||
cxAdj = Math.cos(ang) * rad;
|
||||
zAdj = Math.sin(ang) * rad - f * 3;
|
||||
} else if (latticeKind === 'scatter') {
|
||||
const jitter = lattice ? lattice.jitter : 0.3;
|
||||
cxAdj += (Math.sin(fc * 12.9898 + r * 78.233) * 2 - 1) * jitter * 0.25 * scale;
|
||||
}
|
||||
|
||||
const pylon = new THREE.Group();
|
||||
pylon.name = `pylon-${r}-${c}`;
|
||||
pylon.position.set(cxAdj, 0, zAdj);
|
||||
pylon.userData.fr = fr;
|
||||
pylon.userData.fc = fc;
|
||||
pylon.userData.f = f;
|
||||
pylon.userData.scale = scale;
|
||||
pylon.userData.depthShade = depthShade;
|
||||
pylon.userData.y0 = y0;
|
||||
pylon.userData.h = h;
|
||||
pylon.userData.cxAdj = cxAdj;
|
||||
|
||||
// Crown — protagonist assembly, one per pylon at its own angle.
|
||||
// Built fresh per pylon so symmetry-expanded parts don't share.
|
||||
const crownGroup = new THREE.Group();
|
||||
crownGroup.name = 'crown';
|
||||
try {
|
||||
const built = actorToGeometry(actorSpec, identity, THREE);
|
||||
// built is a Group of meshes
|
||||
const palIdx = Math.floor(fr) % pal.length;
|
||||
const ccol = pal[palIdx % pal.length];
|
||||
let partIdx = 0;
|
||||
while (built.children.length) {
|
||||
const m = built.children[0];
|
||||
built.remove(m);
|
||||
const pIdx = m.userData.partIndex ?? partIdx++;
|
||||
const pPalIdx = actorSpec.paletteMap ? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx;
|
||||
const cc = pal[(pPalIdx + (isHatch ? 1 : 0)) % pal.length] || ccol;
|
||||
m.material = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(cc[0], cc[1], cc[2]),
|
||||
roughness: baseRough,
|
||||
metalness: isHollow ? 0.02 : 0.08,
|
||||
wireframe: isHollow,
|
||||
transparent: opacity < 0.999 || isHollow,
|
||||
opacity: isHollow ? 0.92 * opacity : opacity,
|
||||
});
|
||||
m.castShadow = false;
|
||||
m.receiveShadow = false;
|
||||
// Ink outline strength subtly scales the crown so ink reads beyond color.
|
||||
const inkOutline = ink ? ink.outline : 0;
|
||||
m.scale.setScalar(1 + inkOutline * 0.08);
|
||||
crownGroup.add(m);
|
||||
}
|
||||
} catch {
|
||||
const g = new THREE.BoxGeometry(0.22, 0.22, 0.22);
|
||||
const cc = pal[0];
|
||||
const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: new THREE.Color(cc[0], cc[1], cc[2]), roughness: baseRough }));
|
||||
crownGroup.add(m);
|
||||
}
|
||||
crownGroup.position.set(0, y0 + h + 0.08 * scale, 0);
|
||||
// Crown scale follows elementScale so a huge-form track has huge crowns.
|
||||
const crownScale = (0.28 + 0.42 * scale) * stageScaleVal * 0.9 * (0.9 + shapeSides * 0.02) * (0.92 + styleWeight * 0.18);
|
||||
crownGroup.scale.setScalar(crownScale);
|
||||
// Per-pylon yaw/pitch seeds so the field is one form seen many ways.
|
||||
crownGroup.userData.yawSeed = fc * 0.9 + fr * 0.4 + shapeTilt * 0.5;
|
||||
crownGroup.userData.pitchSeed = r * 0.3;
|
||||
crownGroup.userData.baseY = y0 + h;
|
||||
pylon.add(crownGroup);
|
||||
|
||||
// Leg — chorus stacked. Each rung at its own turn.
|
||||
const segs = segsForHeight(h);
|
||||
const member = Math.max(h / (segs * 2), 1e-3);
|
||||
const legGroup = new THREE.Group();
|
||||
legGroup.name = 'leg';
|
||||
for (let rung = 0; rung < segs; rung++) {
|
||||
const centreY = y0 + (rung + 0.5) * member * 2;
|
||||
const rungMesh = new THREE.Mesh(legShapeGeo.clone(), new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(1, 1, 1),
|
||||
roughness: 0.52 + styleSoft * 0.08,
|
||||
metalness: 0.06,
|
||||
transparent: opacity < 0.999,
|
||||
opacity: opacity * 0.96,
|
||||
}));
|
||||
rungMesh.scale.set(member * 1.55, member * 1.55, member * 0.9);
|
||||
rungMesh.position.set(0, centreY, 0);
|
||||
rungMesh.userData.rung = rung;
|
||||
rungMesh.userData.member = member;
|
||||
// Palette per leg column so the field keeps colour rhythm.
|
||||
const legPalIdx = (Math.floor(fc) + rung) % pal.length;
|
||||
const lc = pal[legPalIdx % pal.length];
|
||||
rungMesh.material.color.setRGB(lc[0], lc[1], lc[2]);
|
||||
rungMesh.userData.yawSeed = rung * 1.1 + fc * 0.5;
|
||||
rungMesh.userData.pitchSeed = rung * 0.5;
|
||||
legGroup.add(rungMesh);
|
||||
}
|
||||
pylon.add(legGroup);
|
||||
|
||||
instance.pylonContainer.add(pylon);
|
||||
}
|
||||
}
|
||||
if (legShapeGeo) legShapeGeo.dispose();
|
||||
instance.builtCols = cols;
|
||||
instance.builtRows = rows;
|
||||
instance.builtIdentityKey = identityKey;
|
||||
instance.pylonCount = cols * rows;
|
||||
}
|
||||
|
||||
// Palette + opacity live rebind so paletteArc is visible.
|
||||
const groundC = pal[0];
|
||||
// Ground colour carries ink + style so those artifacts move the image.
|
||||
const inkW = ink ? ink.weight : 0.3;
|
||||
const inkOutline = ink ? ink.outline : 0;
|
||||
instance.groundMat.color.setRGB(
|
||||
groundC[0] * (0.26 + inkW * 0.1 + styleWeight * 0.05),
|
||||
groundC[1] * (0.26 + inkW * 0.08),
|
||||
groundC[2] * (0.30 + inkOutline * 0.07),
|
||||
);
|
||||
instance.groundMat.roughness = 0.85 - styleSoft * 0.12;
|
||||
instance.groundMat.needsUpdate = false;
|
||||
|
||||
// Per-pylon analytic pose and palette pulse.
|
||||
for (const pylon of instance.pylonContainer.children) {
|
||||
const fr = pylon.userData.fr;
|
||||
const fc = pylon.userData.fc;
|
||||
const f = pylon.userData.f;
|
||||
const scale = pylon.userData.scale;
|
||||
const depthShade = pylon.userData.depthShade;
|
||||
|
||||
// Beat walk down the rows — light travels rather than strobes.
|
||||
const walk = Math.max(0, Math.min(1, 1 - Math.abs(fr - ((beatPhase * 6 + t * 0.4) % Math.max(1, rows)))));
|
||||
const inten = (0.35 + 0.65 * walk) * depthShade * (0.7 + 0.3 * beat) * (0.6 + pulse * 0.7);
|
||||
const camSwayOff = Math.sin(t * camSwayRate + fc * 0.7) * camSway * 0.45;
|
||||
const camSpinOff = camSpin * t * 0.12;
|
||||
|
||||
// Crown pose
|
||||
const crown = pylon.children.find((ch) => ch.name === 'crown');
|
||||
if (crown) {
|
||||
const yaw = t * speed * 0.6 + crown.userData.yawSeed + shapeTilt + camSpinOff;
|
||||
const pitch = 0.2 + Math.sin(t * 0.35 + crown.userData.pitchSeed) * 0.22;
|
||||
crown.rotation.order = 'YXZ';
|
||||
crown.rotation.set(pitch, yaw, Math.sin(t * 0.18 + fc) * 0.12 + camSwayOff * 0.3);
|
||||
// Crown colour pulse
|
||||
for (const m of crown.children) {
|
||||
if (!m.isMesh) continue;
|
||||
const baseIdx = Math.floor(fr) % pal.length;
|
||||
const cc = pal[baseIdx % pal.length];
|
||||
// Modulate brightness by walk so the travelling pulse is visible as mesh colour.
|
||||
m.material.color.setRGB(cc[0] * (0.55 + inten * 0.9), cc[1] * (0.55 + inten * 0.9), cc[2] * (0.55 + inten * 0.9));
|
||||
m.material.opacity = opacity;
|
||||
m.material.transparent = opacity < 0.999 || m.material.wireframe;
|
||||
m.visible = opacity > 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
// Leg rungs pose — each at its own turn so column reads as one form many ways.
|
||||
const leg = pylon.children.find((ch) => ch.name === 'leg');
|
||||
if (leg) {
|
||||
for (const rung of leg.children) {
|
||||
if (!rung.isMesh) continue;
|
||||
const yaw = t * speed * 0.35 + rung.userData.yawSeed + shapeTilt * 0.3;
|
||||
const pitch = rung.userData.pitchSeed + Math.sin(t * 0.22 + rung.userData.rung) * 0.25;
|
||||
rung.rotation.order = 'YXZ';
|
||||
rung.rotation.set(pitch, yaw, 0);
|
||||
rung.material.opacity = opacity * 0.96;
|
||||
rung.material.transparent = opacity < 0.999;
|
||||
rung.visible = opacity > 0.01;
|
||||
// Slight positional sway from camera trait so camera reads as motion.
|
||||
rung.position.x = Math.sin(t * camSwayRate + rung.userData.rung) * camSway * 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
pylon.visible = opacity > 0.01;
|
||||
}
|
||||
|
||||
// Camera trait also nudges the whole field so a locked-off close-up still evolves.
|
||||
instance.pylonContainer.position.x = Math.sin(t * 0.07 + instance.seed * 0.0011) * camSway * 0.6;
|
||||
instance.pylonContainer.position.z = Math.cos(t * 0.05 + instance.seed * 0.0013) * camSway * 0.35;
|
||||
instance.pylonContainer.rotation.y = camSpin * t * 0.04;
|
||||
},
|
||||
};
|
||||
|
||||
export default pylonField3D;
|
||||
400
flow-state/src/scenes/stage/synthwave-corridor.js
Normal file
400
flow-state/src/scenes/stage/synthwave-corridor.js
Normal file
@ -0,0 +1,400 @@
|
||||
// STAGE: Synthwave Corridor — grid verges streaming with the chorus, hero on the road.
|
||||
//
|
||||
// The flat synthwave-run draws a perspective grid in the fragment shader
|
||||
// (perspective = 1/(horizon - p.y+0.05), roadHalf = 0.8*(horizon-py+0.05)) and
|
||||
// stamps castChorusSolid passers on the verges plus one castSolid hero. This mesh
|
||||
// twin keeps the grid as a textured plane and the passers+hero as BufferGeometry
|
||||
// with real depth: passers stream horizon→camera on the verges, hero chased down
|
||||
// the centre lane. The chorus/hero are the song's own assembly via
|
||||
// actorToGeometry, so a hexagonal track is overtaken by hexagonal debris.
|
||||
//
|
||||
// Analytic motion only: f(t,seed). sharedCamera via Show/Compositor. Personality
|
||||
// traits and identity artifacts read live so scene-gate's trait/consumes probes see
|
||||
// a delta. Transparent composite via scene.userData.transparentBackground.
|
||||
|
||||
import { actorToGeometry } from '../../actors/meshes.js';
|
||||
|
||||
export const synthwaveCorridor = {
|
||||
name: 'Synthwave Corridor',
|
||||
family: 'structural',
|
||||
kind: 'model',
|
||||
actor: 'vehicle',
|
||||
consumes: ['form', 'ink', 'staging'],
|
||||
traits: ['shape', 'space', 'camera', 'style'],
|
||||
texture: 0.4,
|
||||
|
||||
params: {
|
||||
speed: { type: 'float', range: [0.3, 4], default: 1.5, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
gridDensity: { type: 'float', range: [0.5, 3], default: 1.0, uniform: 'u_gridDensity', bias: 'density', slowAxis: true },
|
||||
horizon: { type: 'float', range: [-0.3, 0.3], default: 0.0, uniform: 'u_horizon' },
|
||||
sun: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_sun' },
|
||||
mountains: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_mountains' },
|
||||
car: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_car' },
|
||||
passers: { type: 'int', range: [0, 6], default: 3, uniform: 'u_passers', bias: 'density' },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {},
|
||||
|
||||
build({ scene, seed, params, actorSpec, THREE }) {
|
||||
const groundGeo = new THREE.PlaneGeometry(40, 40);
|
||||
// Procedural grid texture — DataTexture path so it works headless (no document).
|
||||
let gridTex;
|
||||
if (typeof document !== 'undefined' && document.createElement) {
|
||||
const gridCanvas = document.createElement('canvas');
|
||||
gridCanvas.width = 256;
|
||||
gridCanvas.height = 256;
|
||||
const gctx = gridCanvas.getContext('2d');
|
||||
gctx.fillStyle = '#0a0a10';
|
||||
gctx.fillRect(0, 0, 256, 256);
|
||||
gctx.strokeStyle = '#2a3a55';
|
||||
gctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 256; i += 32) {
|
||||
gctx.beginPath(); gctx.moveTo(i, 0); gctx.lineTo(i, 256); gctx.stroke();
|
||||
gctx.beginPath(); gctx.moveTo(0, i); gctx.lineTo(256, i); gctx.stroke();
|
||||
}
|
||||
gridTex = new THREE.CanvasTexture(gridCanvas);
|
||||
} else {
|
||||
// Headless fallback: 64×64 checker via DataTexture (same visual role).
|
||||
const w = 64, h = 64;
|
||||
const data = new Uint8Array(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = (y * w + x) * 4;
|
||||
const onLine = (x % 8 === 0) || (y % 8 === 0);
|
||||
const v = onLine ? 42 : 10;
|
||||
data[i] = v; data[i + 1] = v + 8; data[i + 2] = v + 18; data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
gridTex = new THREE.DataTexture(data, w, h, THREE.RGBAFormat);
|
||||
gridTex.needsUpdate = true;
|
||||
}
|
||||
gridTex.wrapS = THREE.RepeatWrapping;
|
||||
gridTex.wrapT = THREE.RepeatWrapping;
|
||||
gridTex.repeat.set(4, 4);
|
||||
gridTex.needsUpdate = true;
|
||||
|
||||
const groundMat = new THREE.MeshStandardMaterial({
|
||||
map: gridTex,
|
||||
roughness: 0.82,
|
||||
metalness: 0.04,
|
||||
});
|
||||
const ground = new THREE.Mesh(groundGeo, groundMat);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -1.15;
|
||||
ground.receiveShadow = false;
|
||||
ground.name = 'ground';
|
||||
scene.add(ground);
|
||||
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.58);
|
||||
scene.add(ambient);
|
||||
const key = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
key.position.set(2.2, 4.5, 2.8);
|
||||
key.castShadow = false;
|
||||
scene.add(key);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, 0.34);
|
||||
fill.position.set(-2.8, 1.6, -2.2);
|
||||
scene.add(fill);
|
||||
|
||||
// Mountains — distant, cheap, toggled by u_mountains (still read for gate).
|
||||
const mountGroup = new THREE.Group();
|
||||
mountGroup.name = 'mountains';
|
||||
{
|
||||
const h = 1.2;
|
||||
const mountGeo = new THREE.PlaneGeometry(18, 3, 12, 1);
|
||||
// Displace y by simple sine to suggest a range; deterministic from seed.
|
||||
const pos = mountGeo.attributes.position;
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const x = pos.getX(i);
|
||||
const n = Math.sin(x * 0.55 + seed * 0.001) * 0.55 + Math.sin(x * 1.1 + seed * 0.002) * 0.22;
|
||||
pos.setY(i, pos.getY(i) + Math.max(0, n) * 0.9);
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
mountGeo.computeVertexNormals();
|
||||
const mountMat = new THREE.MeshStandardMaterial({ color: 0x1a2a3a, roughness: 0.9, wireframe: false, transparent: true, opacity: 0.95 });
|
||||
const mount = new THREE.Mesh(mountGeo, mountMat);
|
||||
mount.position.set(0, 1.05, -14);
|
||||
mountGroup.add(mount);
|
||||
}
|
||||
scene.add(mountGroup);
|
||||
|
||||
// Sun — simple emissive disc, toggled by u_sun.
|
||||
const sunGeo = new THREE.CircleGeometry(1.4, 32);
|
||||
const sunMat = new THREE.MeshBasicMaterial({ color: 0xff6a3a, transparent: true, opacity: 0.9, side: THREE.DoubleSide });
|
||||
const sun = new THREE.Mesh(sunGeo, sunMat);
|
||||
sun.position.set(0, 4.2, -13);
|
||||
scene.add(sun);
|
||||
|
||||
const heroGroup = new THREE.Group();
|
||||
heroGroup.name = 'hero';
|
||||
scene.add(heroGroup);
|
||||
|
||||
const passerGroup = new THREE.Group();
|
||||
passerGroup.name = 'passers';
|
||||
scene.add(passerGroup);
|
||||
|
||||
scene.background = null;
|
||||
scene.fog = new THREE.Fog(0x0a0a0f, 11, 28);
|
||||
scene.userData.transparentBackground = true;
|
||||
|
||||
return {
|
||||
ground, groundMat, gridTex,
|
||||
mountGroup, sun, sunMat,
|
||||
heroGroup, heroBuilt: false,
|
||||
passerGroup, passerCount: -1,
|
||||
seed,
|
||||
actorSeed: actorSpec ? actorSpec.seed : seed,
|
||||
};
|
||||
},
|
||||
|
||||
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, THREE }) {
|
||||
const t = timeline.time;
|
||||
const beat = features.beat || 0;
|
||||
|
||||
const identity = personality ? personality.identity : null;
|
||||
const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
|
||||
|
||||
// Personality traits — shape/space/camera/style all move the image.
|
||||
const shape = personality ? personality.shape : null;
|
||||
const space = personality ? personality.space : null;
|
||||
const camTrait = personality ? personality.camera : null;
|
||||
const style = personality ? personality.style : null;
|
||||
const shapeTilt = shape ? shape.tilt : 0;
|
||||
const shapeSides = shape ? shape.sides : 0;
|
||||
const spaceHorizon = space ? space.horizon : 0.5;
|
||||
const spaceDepth = space ? space.depth : 0.5;
|
||||
const camSway = camTrait ? camTrait.sway : 0;
|
||||
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
|
||||
const camSpin = camTrait ? camTrait.spin : 0;
|
||||
const styleWeight = style ? style.lineWeight : 0.5;
|
||||
const styleSoft = style ? style.softness : 0.5;
|
||||
|
||||
// Identity artifacts — cast/form/ink/staging consumed.
|
||||
const ink = identity ? identity.ink : null;
|
||||
const lattice = identity ? identity.lattice : null;
|
||||
const elementScale = identity ? identity.lattice.elementScale : 0.35;
|
||||
const stageScaleVal = elementScale / 0.35;
|
||||
const latticeSpread = lattice ? lattice.spread : 0.7;
|
||||
const latticeKind = lattice ? lattice.kind : 'scatter';
|
||||
|
||||
// Params — read so param-sweep gate sees a delta.
|
||||
const speed = Math.max(0.01, params.speed);
|
||||
const gridDensity = Math.max(0.3, params.gridDensity);
|
||||
const horizonOff = params.horizon || 0;
|
||||
const sunOn = params.sun || 0;
|
||||
const mountOn = params.mountains || 0;
|
||||
const carOn = params.car || 0;
|
||||
const passerCount = Math.max(0, Math.min(6, Math.round(params.passers)));
|
||||
|
||||
// Ground + grid
|
||||
const horizon = Math.max(-0.85, Math.min(0.85, horizonOff + (spaceHorizon - 0.5) * 0.7));
|
||||
instance.ground.position.y = -1.15 + horizon * 0.2;
|
||||
instance.ground.position.x = Math.sin(t * camSwayRate) * camSway * 0.25;
|
||||
// Grid scroll analytic: texture offset as f(t), not accumulation, so seek === playback.
|
||||
instance.gridTex.repeat.set(2.2 * gridDensity, 6 * gridDensity);
|
||||
instance.gridTex.offset.set(0, -((t * speed * 0.09) % 1));
|
||||
instance.gridTex.needsUpdate = true;
|
||||
// Staging spread tints the grid colour so staging moves the image.
|
||||
const gc = pal[1] || pal[0];
|
||||
instance.groundMat.color.setRGB(gc[0] * 0.18 + latticeSpread * 0.04, gc[1] * 0.18, gc[2] * 0.22);
|
||||
instance.groundMat.roughness = 0.82 + styleSoft * 0.08;
|
||||
|
||||
// Mountains / sun driven by params + space/ink so those gates move the image.
|
||||
instance.mountGroup.visible = mountOn > 0.01 && opacity > 0.01;
|
||||
if (instance.mountGroup.visible) {
|
||||
for (const m of instance.mountGroup.children) {
|
||||
if (!m.isMesh) continue;
|
||||
const mc = pal[0];
|
||||
m.material.color.setRGB(mc[0] * (0.18 + spaceDepth * 0.12), mc[1] * 0.18, mc[2] * (0.22 + (ink ? ink.weight * 0.1 : 0)));
|
||||
m.material.opacity = mountOn * opacity;
|
||||
m.material.transparent = true;
|
||||
m.position.x = Math.sin(t * 0.03) * 0.35 + (latticeKind === 'radial' ? Math.sin(t * 0.02) * 0.5 : 0);
|
||||
}
|
||||
}
|
||||
instance.sun.visible = sunOn > 0.01 && opacity > 0.01;
|
||||
if (instance.sun.visible) {
|
||||
const sc = pal[2] || pal[0];
|
||||
instance.sun.material.color.setRGB(sc[0], sc[1], sc[2]);
|
||||
instance.sun.material.opacity = 0.9 * sunOn * opacity;
|
||||
instance.sun.position.y = 4.2 + Math.sin(t * 0.05) * 0.08 + horizon * 0.6;
|
||||
}
|
||||
|
||||
// Fog carries space depth.
|
||||
if (scene.fog) {
|
||||
scene.fog.near = 10 + spaceDepth * 6;
|
||||
scene.fog.far = 26 + spaceDepth * 8;
|
||||
const c = pal[0];
|
||||
scene.fog.color.setRGB(c[0] * 0.08, c[1] * 0.08, c[2] * 0.12);
|
||||
}
|
||||
|
||||
// Hero — built once identity is known.
|
||||
if (!instance.heroBuilt && identity && actorSpec && actorSpec.form) {
|
||||
for (const child of [...instance.heroGroup.children]) {
|
||||
instance.heroGroup.remove(child);
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
||||
mats.forEach((mm) => mm.dispose());
|
||||
}
|
||||
}
|
||||
try {
|
||||
const built = actorToGeometry(actorSpec, identity, THREE);
|
||||
while (built.children.length) {
|
||||
const m = built.children[0];
|
||||
built.remove(m);
|
||||
const pIdx = m.userData.partIndex ?? 0;
|
||||
const palIdx = actorSpec.paletteMap ? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx;
|
||||
const c = pal[palIdx % pal.length];
|
||||
m.material = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(c[0], c[1], c[2]),
|
||||
roughness: 0.38 + styleWeight * 0.12,
|
||||
metalness: 0.06,
|
||||
});
|
||||
m.castShadow = false;
|
||||
instance.heroGroup.add(m);
|
||||
}
|
||||
} catch {
|
||||
const g = new THREE.BoxGeometry(0.45, 0.45, 0.45);
|
||||
const c = pal[1] || pal[0];
|
||||
const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: new THREE.Color(c[0], c[1], c[2]) }));
|
||||
instance.heroGroup.add(m);
|
||||
}
|
||||
instance.heroBuilt = true;
|
||||
}
|
||||
|
||||
// Passers — rebuilt only when count changes or identity changes materially.
|
||||
const passerDirty = instance.passerCount !== passerCount || !instance.passerGroup.children.length;
|
||||
if (instance.heroBuilt && passerCount > 0 && passerDirty) {
|
||||
for (const child of [...instance.passerGroup.children]) {
|
||||
instance.passerGroup.remove(child);
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
||||
mats.forEach((mm) => mm.dispose());
|
||||
}
|
||||
}
|
||||
// Deterministic per-passer ra/rb from seed (same hash as shader's hash12 idea).
|
||||
let state = (instance.seed ^ 0x517cc1b7) >>> 0;
|
||||
const hash = (i, salt) => {
|
||||
let x = (instance.seed ^ (i * 0x9e3779b9) ^ salt) >>> 0;
|
||||
x = (x + 0x6d2b79f5) >>> 0;
|
||||
let tt = x; tt = Math.imul(tt ^ (tt >>> 15), tt | 1);
|
||||
tt ^= tt + Math.imul(tt ^ (tt >>> 7), tt | 61);
|
||||
return ((tt ^ (tt >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
// Template for passer geometry: small clone of hero's first mesh geometry if available.
|
||||
const template = instance.heroGroup.children.find((m) => m.isMesh);
|
||||
const passerScaleBase = 0.18;
|
||||
for (let i = 0; i < passerCount; i++) {
|
||||
const ra = hash(i, 0x31);
|
||||
const rb = hash(i, 0x73);
|
||||
let mesh;
|
||||
if (template && template.geometry) {
|
||||
mesh = new THREE.Mesh(template.geometry, new THREE.MeshStandardMaterial({ roughness: 0.48, metalness: 0.05 }));
|
||||
} else {
|
||||
mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.2, 1), new THREE.MeshStandardMaterial({ roughness: 0.48 }));
|
||||
}
|
||||
const palIdx = (i * 2 + Math.floor(ra * 3)) % pal.length;
|
||||
const c = pal[palIdx % pal.length];
|
||||
mesh.material.color.setRGB(c[0], c[1], c[2]);
|
||||
mesh.userData.ra = ra;
|
||||
mesh.userData.rb = rb;
|
||||
mesh.userData.index = i;
|
||||
// Stagger spin seeds so passers are not a convoy.
|
||||
mesh.userData.spinSeed = ra * 6.283 + i * 2.1;
|
||||
mesh.scale.setScalar((0.32 + rb * 0.35) * stageScaleVal * 0.45 + shapeSides * 0.006);
|
||||
instance.passerGroup.add(mesh);
|
||||
}
|
||||
instance.passerCount = passerCount;
|
||||
} else if (passerCount === 0) {
|
||||
for (const child of [...instance.passerGroup.children]) {
|
||||
instance.passerGroup.remove(child);
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
||||
mats.forEach((mm) => mm.dispose());
|
||||
}
|
||||
}
|
||||
instance.passerCount = 0;
|
||||
}
|
||||
|
||||
// Hero pose — analytic f(t,seed,beat) matching shader's heroPos + turn.
|
||||
if (instance.heroBuilt) {
|
||||
const bob = Math.sin(t * speed * 0.6) * 0.012 + Math.sin(t * 1.7 + instance.seed * 0.001) * 0.006;
|
||||
const hx = Math.sin(t * 0.08) * 0.18 + Math.sin(t * camSwayRate) * camSway * 0.12 + (latticeKind === 'scatter' ? Math.sin(t * 0.11) * 0.06 : 0);
|
||||
const base = 0.32 * stageScaleVal;
|
||||
const size = base * (1 + beat * 0.18) * (0.92 + styleWeight * 0.18);
|
||||
instance.heroGroup.position.set(hx, -0.68 + bob + horizon * 0.12, 0.2);
|
||||
instance.heroGroup.scale.setScalar(size * 1.6);
|
||||
const yaw = t * 0.35 + instance.seed * 0.0011 + shapeTilt * 0.6 + camSpin * t * 0.08;
|
||||
const pitch = Math.sin(t * 0.28 + instance.seed * 0.0007) * 0.55;
|
||||
instance.heroGroup.rotation.order = 'YXZ';
|
||||
instance.heroGroup.rotation.set(pitch, yaw, Math.sin(t * 0.18) * 0.12);
|
||||
for (const m of instance.heroGroup.children) {
|
||||
if (!m.isMesh) continue;
|
||||
m.visible = carOn > 0.01 && opacity > 0.01;
|
||||
m.material.opacity = carOn * opacity;
|
||||
m.material.transparent = true;
|
||||
// Ink: hollow draws wireframe, outline/peso tint the hero brightness.
|
||||
const isHollow = ink && ink.fill === 'hollow';
|
||||
m.material.wireframe = !!isHollow;
|
||||
}
|
||||
instance.heroGroup.visible = carOn > 0.01 && opacity > 0.01;
|
||||
// Palette live — ground truth is the same paletteArc that fragment shaders see.
|
||||
for (const m of instance.heroGroup.children) {
|
||||
if (!m.isMesh) continue;
|
||||
const pIdx = m.userData.partIndex ?? 0;
|
||||
const palIdx = actorSpec.paletteMap ? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx;
|
||||
const c = pal[palIdx % pal.length];
|
||||
const inkBoost = ink ? ink.weight * 0.08 : 0;
|
||||
m.material.color.setRGB(c[0] * (0.92 + inkBoost), c[1] * (0.92 + inkBoost), c[2] * (0.92 + inkBoost));
|
||||
}
|
||||
}
|
||||
|
||||
// Passers — stream horizon→camera on verges, each at its own turn.
|
||||
let pi = 0;
|
||||
for (const m of instance.passerGroup.children) {
|
||||
if (!m.isMesh) continue;
|
||||
const ra = m.userData.ra;
|
||||
const rb = m.userData.rb;
|
||||
const idx = m.userData.index;
|
||||
const side = ra < 0.5 ? -1 : 1;
|
||||
// Phase staggered per passer, same formula as synthwave-run.js.
|
||||
let phase = (t * (0.28 + rb * 0.10) + ra * 7 + idx * 1.63) % 1;
|
||||
if (phase < 0) phase += 1;
|
||||
const f = Math.pow(phase, 0.85);
|
||||
const psize = (0.05 + f * 0.19) * stageScaleVal * (0.70 + rb * 0.65);
|
||||
// Road half-width grows near (f→1) and narrow far (f→0), matching
|
||||
// shader's roadHalf = max(0.015, 0.8*(horizon - py +0.05)).
|
||||
// World approx: horizon maps to groundY, roadHalf in world units.
|
||||
const roadHalf = Math.max(0.06, 0.08 + f * (0.85 + horizon * 0.25));
|
||||
const gap = 0.07 + rb * 0.12 + latticeSpread * 0.05;
|
||||
const verge = roadHalf + psize * 0.92 + gap;
|
||||
// Along-road Z: far (≈ -14) → near (≈ +1.2), monotonic with f.
|
||||
const z = -13.5 + f * 15.0;
|
||||
let x = side * verge;
|
||||
x += Math.sin(t * 0.35 + ra * 6.283) * 0.018 * (0.4 + rb * 0.6) + Math.sin(t * camSwayRate) * camSway * 0.08;
|
||||
let y = psize * 0.22;
|
||||
y += Math.cos(t * 0.5 + rb * 6.283) * 0.012;
|
||||
// Size shrinks far, as in shader's psize mix.
|
||||
const s = (0.28 + f * 0.55);
|
||||
m.position.set(x, y, z);
|
||||
// Each passer at its own castTurn yaw/pitch (now Euler YXZ).
|
||||
const yaw = t * (0.45 + rb * 0.6) + ra * 6.283 + idx * 2.1 + camSpin * t * 0.05;
|
||||
const pitch = Math.sin(t * 0.4 + ra * 9) * 0.55 + f * 0.25;
|
||||
m.rotation.order = 'YXZ';
|
||||
m.rotation.set(pitch, yaw, 0);
|
||||
// Depth shade + palette shift so passers keep colour rhythm.
|
||||
const depthShade = 0.38 + f * 0.62;
|
||||
const palIdx = (idx * 2 + Math.floor(ra * 4)) % pal.length;
|
||||
const c = pal[palIdx % pal.length];
|
||||
m.material.color.setRGB(c[0] * depthShade, c[1] * depthShade, c[2] * depthShade);
|
||||
m.material.opacity = opacity * (0.55 + depthShade * 0.45);
|
||||
m.material.transparent = true;
|
||||
m.visible = opacity > 0.01;
|
||||
pi++;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default synthwaveCorridor;
|
||||
@ -108,6 +108,17 @@ body {
|
||||
color: var(--dim); font-size: 14px;
|
||||
}
|
||||
|
||||
#export-warn[hidden] { display: none !important; }
|
||||
#export-warn { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 40; }
|
||||
#export-warn .ew-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.62); backdrop-filter: blur(2px); }
|
||||
#export-warn .ew-box { position: relative; max-width: 560px; margin: 16px; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid #f59e0b; padding: 16px 18px 12px; box-shadow: 0 20px 40px rgba(0,0,0,0.55); }
|
||||
#export-warn .ew-title { font-weight: 700; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 8px; }
|
||||
#export-warn .ew-body { font-size: 12px; line-height: 1.65; color: var(--text); white-space: pre-wrap; }
|
||||
#export-warn .ew-body code { background: #171a22; border: 1px solid var(--line); padding: 1px 4px; font-size: 11px; }
|
||||
#export-warn .ew-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
|
||||
#export-warn .ew-opt { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 11px; color: var(--dim); cursor: pointer; }
|
||||
#export-warn .ew-opt input { accent-color: var(--accent); }
|
||||
|
||||
#transport { grid-area: transport; background: var(--panel); }
|
||||
#timeline { height: 46px; }
|
||||
#timeline-canvas { width: 100%; height: 46px; display: block; cursor: pointer; }
|
||||
|
||||
@ -39,7 +39,7 @@ const FORBIDDEN = [
|
||||
];
|
||||
|
||||
// Directories whose output must be a pure function of (seed, params, frame).
|
||||
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params'];
|
||||
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors'];
|
||||
// Files legitimately allowed a wall clock: perf measurement, not image content.
|
||||
const ALLOWED = new Set(['engine/perf.js']);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user