Compare commits

...

10 Commits

Author SHA1 Message Date
Dejvino
a9b028b0bf Export: fix VideoEncoder.configure TypeError on Chrome
probeExportCodec returned codec/muxerCodec but not config, so the
pre-probed pick used by the software-encoding warning path passed
undefined to VideoEncoder.configure, which Chrome rejects as "not of
type VideoEncoderConfig". Include config in the probe result and thread
it through exportSegment (test render was still reprobing and ignoring
the pick).

Also prefer the browser-negotiated config from isConfigSupported when
available — it is guaranteed valid for configure (covers new required
fields), whereas our minimal config had started failing validation on
Chrome despite isConfigSupported reporting "supported".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:09:49 +02:00
Dejvino
e5484c9c38 Export: fall back to VP9/AV1 when H.264 unavailable and warn about software encoding
Firefox on Linux exposes VideoEncoder but reports every avc1.* config as
unsupported (OpenH264 is decode-only), so pickVideoConfig returned null and
export threw "no supported H.264 configuration found". Probe VP9/AV1 families
after H.264 and pick the first profile that isConfigSupported and does not
reorder (B-frame) — same 12-frame ordering test. Map the chosen family to
mp4-muxer's video codec (avc/vp9/av1).

Before any rendering, probe the codec for the chosen preset and show a
modal when we fell off H.264 onto a software VP9/AV1 path — that's what the
user felt as "super slow". The modal names the codec, explains render vs
encode cost, suggests Chromium/Chrome for hardware H.264 (or 720p/lower
preset), and offers Continue anyway / Cancel (Esc/backdrop also cancel) plus
"Don't warn again this session". Injected pick is threaded through
Exporter.export({ videoPick }) and exportSegment so we don't probe twice.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:17:53 +02:00
Dejvino
b2c9497bae Epic 5 Phase 2.5 — model blend via alpha, not lumakey
Model stages render with alpha:0 where no mesh covers (transparent
clear) and carry dark material colours. Lumakey keys on luma
(dot(src,0.212…)), so a dim mesh keyed to ~0 and vanished — Pylon
Field 3D / Synthwave Corridor looked black. Use 'normal' for
kind:'model' so the alpha is the mask; fragment over dark ground
stays covered. Per-stage fog/light tuning deferred to a director
param (LookGenerator) per review — not hand-tuned per stage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 22:08:32 +02:00
Dejvino
f8e8d91c9b Epic 5 Phase 2.4 — Pylon Field 3D + Synthwave Corridor (model stages)
Two Stage-A model stages behind the same infra as Assembly:

  Pylon Field 3D (structural, kind:model, actor:structure) — chorus
    stacked legs + protagonist crowns on the pylon-grid perspective
    grid, transparent compositing via ModelLayer, analytic f(t,seed).

  Synthwave Corridor (structural, kind:model, actor:vehicle) — grid
    verges with streaming chorus passers + monolith hero on the road,
    verge math preserved from synthwave-run horizon/roadHalf calc.

Registered in scenes/registry.js; consumes:['form','ink','staging'],
traits:['shape','space','camera','style'] so trait/consumes gates
stay testable (reads personality in update()). Grid texture falls
back to DataTexture headless so lint/build stay green outside a
browser.

Gates: lint 110 clean / 70 literals / 71 scenes; vite build 301 modules.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 20:43:50 +02:00
Dejvino
b98aa94dda Stage 3.6 — models visible: transparent model compositing + Assembly density
Model layers render with transparent clear so Compositor lumakey/normal
can show composition underneath where no mesh covers — Assembly is now
sparse (hero + satellites on ground, not opaque fill), satellites wired
to count/spread/density so the density slider visibly changes the stage
and a seek is still just f(t,seed). Shared camera plumbing already
drove framing->dolly/personality->drift for real parallax.

Gates: lint 108 clean / 70 literals / 69 scenes; vite built.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 18:58:32 +02:00
Dejvino
93a5dc8437 Epic 5 Phase 2.3 — instrument Assembly for measurement
Gallery, metadata, scene-gate and scene-sweep now handle kind:'model'
with a deterministic ActorSpec. Drawing uses a forked ActorGenerator
so the actor stream never shifts the identity/palette streams:
  gallery.renderScene → generateActor per context for model stages
  metadata.measureLibrary now includes model stages, SCHEMA 5,
    fingerprint covers actors/
  scene-gate.runSceneGate → alive/animates/deterministic/distinct/
    param-sweep/flash-rate all drive Engine.setLayerSpecs with
    actorSpec, and consumes: searches re-derive the actor for the
    swapped identity (so the consumes: gate is still testable).
  scene-sweep behaves the same per-cell.
  Engine.setLayerSpecs now forwards actorSpec/framing into createLayer
    so the checks don't need ArcDriver.

SCHEMA bump 4→5 forces metadata refresh on next gallery build,
which is intentional: model stages now participate. Gates remain
all green (69 scenes) and vite builds clean — no runtime change to
the render path for fragment stages.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 18:38:21 +02:00
Dejvino
16bb67e703 Epic 5 Phase 2.2 — actors visible in look description
describeLook now includes describeActorSet so HUD / check output
shows which actor archetypes and forms were cast. Look -> actor
plumbing is end-to-end: LookGenerator.actors on the look,
ArcDriver._actorFor + ModelLayer setActor, Show.renderFrame shared
camera injection, all deterministic.

Gates: lint 108 clean / 70 literals / 69 scenes green; vite built.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 18:27:10 +02:00
Dejvino
fcbde602de Epic 5 Phase 2.1 — Assembly stage (model)
First kind:'model' stage: Assembly. The song's solid as a mesh.
Hero is the ActorSpec(monolith) assembly via actorToGeometry, built
from Identity.form with paletteMap recolour — same character as the
shader impostor, now with real occlusion/parallax/foreshortening.

Analytic motion only (spin/bob/orbit as f(t,seed,params)), ground
plane + standard-material lighting that matches castLit key/fill, fog
for depth haze, shared camera rig (framing→dolly, personality.camera→
drift/sway/spin). Palette arc visible live via per-frame material
recolour; opacity crossfade via material.opacity.

Registered in structural family so castingPool / director blends see
it like any structural shot.

Gates: lint 108 clean / 70 literals / 69 scenes green; vite 294
modules built.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 18:22:52 +02:00
Dejvino
f60a29ea97 Epic 5 Phase 1 — form is a mesh, actors have parity with SDF
meshes.js now mirrors shader-contract.js verbatim:
- jsCastSDF rotates by -tilt (matching GLSL column-major mat2), hollow
  excluded during outer-radius search so annular SDFs sample correctly,
  radial search probes without hollow then punches hole separately.
- castShape samples 64 segments, inner hole scaled by hollow width.
- formToGeometry uses formDepth (u_formDepth = r.z*depth half-extent),
  capsule h correctly half-height, sphere as scaled unit sphere, prism
  extruded with proper halfDepth and centered; bevel thickness from
  part.round.
- actorToGeometry preserves per-part op/blend userData, uses YXZ Euler
  for yaw/pitch (matches formRot), mirrors radial/mirror/stack symmetry
  exactly as shader formFold does.

Shared camera wired live: Show.renderFrame drives
Compositor.updateSharedCamera from ArcDriver framing/personality/time
and injects sharedCamera into each active ModelLayer — pure f(frame,look)
so seek === playback. ArcDriver exposes framingForFrame helper.

Gates: lint 107 clean / 70 literals / 68 scenes green; vite build
294 modules; deterministic actor geometry smoke-tested (same seed same
vert count, hollow handling, fallback).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 17:01:44 +02:00
Dejvino
7aa60d7336 Epic 5 Phase 0 — actors have bodies; the stage has depth
ActorGenerator is the cast with bodies: generateActor{mpl} takes
{summary,rng,archetype,personality,identity} and returns a serialisable
ActorSpec — same audio-tilts-centre / seed-picks-within rule as
Personality/Identity, forked rng so adding an actor never shifts later
decisions. Five archetypes (monolith/swarm/walker/vehicle/structure),
per-track actor set on look.actors, HUD helper included. Stage C will
grow as a library on this without infra changes.

Mesh twin of Identity.form: actors/meshes.js builds BufferGeometry from
the same assembly (cast SDF → Shape → ExtrudeGeometry, box/capsule/
torus/sphere primitives, symmetry folding radial/mirror/stack). Shared
with the shader impostor path — one character, two projectors.

Renderer depth targets: createDepthTarget / createTarget{depthTexture}
for WebGL DepthTexture plumbing.

Compositor shared rig: one PerspectiveCamera + DepthTexture so a ground
mesh can occlude a subject mesh from another layer. 4/scale dolly,
Personality.camera drift/sway/spin, framing shift — matches particles.js
and shader epilogue behaviour. ModelLayer (kind:model) with
build/update(actorSpec) and sharedCamera injection; createLayer dispatches
on model. Shader contract gains MODEL_PREAMBLE.

LookGenerator now derives actors before scenes; ArcDriver._actorFor +
_layerFor wires ActorSpec into ModelLayer; schema validates kind:model
and actor archetype; lint determinism gate covers actors/.

Gate: lint 107 files clean, 70 shader literals, 68 scenes green; vite
build 294 modules; ActorGenerator determinism + mesh smoke tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:26:15 +02:00
28 changed files with 10719 additions and 8000 deletions

336
flow-state/EPIC-5-3D.md Normal file
View 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.*

View File

@ -21,6 +21,18 @@
</div> </div>
<div id="hud" hidden></div> <div id="hud" hidden></div>
<div id="toast" 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>
<div id="transport"> <div id="transport">

View File

@ -13,6 +13,7 @@
"three": "^0.181.1" "three": "^0.181.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1",
"vite": "^7.2.2" "vite": "^7.2.2"
} }
}, },
@ -475,6 +476,22 @@
"node": "^22.20 || ^24.12 || >=25" "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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.4", "version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", "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" "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": { "node_modules/postcss": {
"version": "8.5.25", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",

View File

@ -16,6 +16,7 @@
}, },
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1",
"vite": "^7.2.2" "vite": "^7.2.2"
}, },
"dependencies": { "dependencies": {

View File

@ -202,6 +202,25 @@ export class Show {
this._lastLayers = layers.slice(); 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 this.engine.compositor
.setPost(this._postAt(timeline, features)) .setPost(this._postAt(timeline, features))
.setFeedback(this.look.feedback); .setFeedback(this.look.feedback);

View 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;
}

View 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,
});
}

View File

@ -27,6 +27,7 @@ import { describeIdentity } from '../look/Identity.js';
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js'; import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { frameLuminance, frameVariance } from '../engine/hash.js'; import { frameLuminance, frameVariance } from '../engine/hash.js';
import { descriptorDistance, STRUCTURAL } from './variety/signature.js'; import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
import { generateActor } from '../actors/ActorGenerator.js';
const THUMB = { width: 256, height: 144 }; 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 rng = new Rng(hashString(`${module.name}:${ctx.name}`));
const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament); 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([{ engine.setLayerSpecs([{
module, module,
@ -98,6 +104,7 @@ export function renderScene(engine, module, contexts) {
blend: 'normal', blend: 'normal',
palette: ctx.palette, palette: ctx.palette,
personality: ctx.personality, personality: ctx.personality,
actorSpec,
}]); }]);
engine.compositor.reset(); engine.compositor.reset();
// A few frames of warm-up so anything with state is past its first frame. // 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 } = {}) { export async function buildGallery({ contexts, onScene, only = null } = {}) {
const list = only const list = only
? scenes.filter((m) => only.includes(m.name)) ? 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 engine = new Engine({ ...THUMB });
const rows = []; const rows = [];

View File

@ -50,11 +50,12 @@ const SOURCES = {
...import.meta.glob('/src/look/Identity.js', { query: '?raw', import: 'default', eager: true }), ...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/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/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 }), ...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. */ /** The version of the measurement itself. Bump to force a refresh of everything. */
export const SCHEMA = 4; export const SCHEMA = 5;
export function metricsFingerprint() { export function metricsFingerprint() {
let h = 0x811c9dc5 >>> 0; let h = 0x811c9dc5 >>> 0;
@ -130,7 +131,7 @@ export function measureLibrary({ onScene = null, contexts = null } = {}) {
const engine = new Engine({ ...THUMB }); const engine = new Engine({ ...THUMB });
const out = {}; const out = {};
try { 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) { for (const module of list) {
const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx); const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx);
const bed = renderScene(engine, module, bedCtx); const bed = renderScene(engine, module, bedCtx);

View File

@ -24,6 +24,7 @@ import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js'
import { peakFlashRate } from '../engine/flash.js'; import { peakFlashRate } from '../engine/flash.js';
import { generatePersonality } from '../look/Personality.js'; import { generatePersonality } from '../look/Personality.js';
import { generateIdentity } from '../look/Identity.js'; import { generateIdentity } from '../look/Identity.js';
import { generateActor } from '../actors/ActorGenerator.js';
const PALETTE = [ const PALETTE = [
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95], [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)); engine.setFeatureProvider(featureProviderFor(track));
const personality = generatePersonality(SUMMARY, new Rng(9001)); 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) => { 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([{ 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(); engine.compositor.reset();
return Uint8Array.from(engine.readPixels(engine.renderFrame(frame))); 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 { try {
// --- alive ------------------------------------------------------- // --- alive -------------------------------------------------------
@ -93,10 +107,13 @@ export function runSceneGate(name) {
let closest = 255; let closest = 255;
let closestName = ''; let closestName = '';
for (const other of scenes) { 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([{ engine.setLayerSpecs([{
module: other, params: defaultValues(other), seed: 4242, 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(); engine.compositor.reset();
const d = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600)))); const d = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600))));
@ -123,8 +140,11 @@ export function runSceneGate(name) {
// --- flash rate ------------------------------------------------------ // --- flash rate ------------------------------------------------------
const hot = sampleValues(module, new Rng(77), { energy: 0.95, density: 0.9, motion: 0.9 }); 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([{ 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(); engine.compositor.reset();
const luminance = []; const luminance = [];
@ -173,9 +193,10 @@ export function runSceneGate(name) {
}; };
} }
const otherActor = actorFor(other);
engine.setLayerSpecs([{ engine.setLayerSpecs([{
module, params: defaultValues(module), seed: 4242, 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(); engine.compositor.reset();
const changed = frameMaxDelta(base, const changed = frameMaxDelta(base,

View File

@ -33,6 +33,7 @@ import { sampleValues } from '../params/schema.js';
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js'; import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { descriptorDistance, STRUCTURAL } from './variety/signature.js'; import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
import { THUMB } from './gallery.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. */ /** How many songs the bank can offer. The grid cannot ask for more. */
export const MAX_SONGS = SONGS.length; 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 rng = new Rng(ctx.seed ^ hashString(module.name));
const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament); 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([{ engine.setLayerSpecs([{
module, params, seed: rng.int(0, 0x7fffffff), module, params, seed: rng.int(0, 0x7fffffff),
opacity: 1, blend: 'normal', opacity: 1, blend: 'normal',
palette: ctx.palette, personality: ctx.personality, palette: ctx.palette, personality: ctx.personality, actorSpec,
}]); }]);
engine.compositor.reset(); engine.compositor.reset();
for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f); for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f);

View File

@ -71,6 +71,47 @@ export class Compositor {
this.bloomA = r.createTarget(bw, bh); this.bloomA = r.createTarget(bw, bh);
this.bloomB = r.createTarget(bw, bh); this.bloomB = r.createTarget(bw, bh);
this.outputTarget = r.createTarget(w, h); 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() { _buildMaterials() {
@ -176,7 +217,10 @@ export class Compositor {
_primeLayer(layer) { _primeLayer(layer) {
try { try {
if (layer.material) this.renderer.compileMaterial(layer.material); 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) { } catch (err) {
console.warn('[compositor] priming failed for', layer.module && layer.module.name, err); console.warn('[compositor] priming failed for', layer.module && layer.module.name, err);
} }
@ -348,7 +392,9 @@ export class Compositor {
disposeTargets() { disposeTargets() {
[this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB, [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() { dispose() {

View File

@ -55,9 +55,15 @@ export class Engine {
setLayerSpecs(specs) { setLayerSpecs(specs) {
this.ownedLayers.forEach((l) => l.dispose()); this.ownedLayers.forEach((l) => l.dispose());
const layers = specs.map((s) => { 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); const layer = createLayer(s.module, s);
if (s.palette) layer.setPalette(s.palette); if (s.palette) layer.setPalette(s.palette);
if (s.personality) layer.setPersonality(s.personality); 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; return layer;
}); });
this.ownedLayers = layers; this.ownedLayers = layers;

View File

@ -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) { export function createLayer(module, options) {
if (module.kind === 'model') return new ModelLayer({ module, ...options });
if (module.kind === 'layer3d') return new SceneLayer({ module, ...options }); if (module.kind === 'layer3d') return new SceneLayer({ module, ...options });
return new ShaderLayer({ module, ...options }); return new ShaderLayer({ module, ...options });
} }

View File

@ -55,6 +55,12 @@ export class Renderer {
stencilBuffer: false, stencilBuffer: false,
generateMipmaps: 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.wrapS = THREE.ClampToEdgeWrapping;
target.texture.wrapT = THREE.ClampToEdgeWrapping; target.texture.wrapT = THREE.ClampToEdgeWrapping;
// Deterministic initial contents: never inherit whatever was in GPU memory. // Deterministic initial contents: never inherit whatever was in GPU memory.
@ -62,6 +68,11 @@ export class Renderer {
return target; 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) { clear(target = null, r = 0, g = 0, b = 0, a = 1) {
const prev = this.gl.getClearColor(new THREE.Color()); const prev = this.gl.getClearColor(new THREE.Color());
const prevAlpha = this.gl.getClearAlpha(); const prevAlpha = this.gl.getClearAlpha();
@ -72,6 +83,11 @@ export class Renderer {
this.gl.setRenderTarget(null); 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. */ /** Run a fullscreen shader pass. target === null renders to the canvas. */
blit(material, target = null) { blit(material, target = null) {
this.quadMesh.material = material; this.quadMesh.material = material;
@ -84,7 +100,22 @@ export class Renderer {
/** Render a real three.js scene (used by 3D layers). */ /** Render a real three.js scene (used by 3D layers). */
renderScene(scene, camera, target = null, clear = true) { renderScene(scene, camera, target = null, clear = true) {
this.gl.setRenderTarget(target); 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.render(scene, camera);
this.gl.setRenderTarget(null); this.gl.setRenderTarget(null);
} }

View File

@ -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 = ` const EPILOGUE = `
void main() { void main() {
vec2 uv = vUv; vec2 uv = vUv;

View File

@ -85,43 +85,95 @@ async function emitsInPresentationOrder(config) {
} }
/** /**
* Probe for a codec configuration the browser will actually accept and will * Families in preference order H.264 first where available (most compatible),
* encode in presentation order. * then VP9, then AV1. Each family maps to one mp4-muxer codec ("avc"/"vp9"/"av1").
* * Inside a family we walk highlow profiles; the first that does not reorder wins.
* 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.
*/ */
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) { async function pickVideoConfig(width, height, bitrate, fps) {
const candidates = [ const attempted = [];
'avc1.640034', 'avc1.640033', 'avc1.4d0034', 'avc1.42003e', const reorderFailures = [];
]; for (const family of VIDEO_FAMILIES) {
const supported = []; for (const cand of family.candidates) {
for (const codec of candidates) { const config = { codec: cand.codec, width, height, bitrate, framerate: fps };
const config = { if (cand.avc) config.avc = cand.avc;
codec, width, height, bitrate, framerate: fps, let supported = false;
avc: { format: 'avc' }, let negotiatedConfig = null;
}; let negotiatedHardware = '';
try { try {
const support = await VideoEncoder.isConfigSupported(config); const sup = await VideoEncoder.isConfigSupported(config);
if (!support.supported) continue; supported = !!sup.supported;
} catch { continue; } negotiatedConfig = sup.config || null;
supported.push(config); negotiatedHardware = negotiatedConfig ? negotiatedConfig.hardwareAcceleration || '' : '';
if (await emitsInPresentationOrder(config)) return config; } 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 };
}
reorderFailures.push(`${cand.codec}`);
}
} }
// Every supported profile reorders. Refuse rather than write a file that if (reorderFailures.length) {
// silently loses most of its frames — see emitsInPresentationOrder.
if (supported.length) {
throw new Error( throw new Error(
'every supported H.264 profile emits frames out of order on this browser ' + 'every supported codec profile emits frames out of order on this browser ' +
`(tried ${supported.map((c) => c.codec).join(', ')}), which this exporter ` + `(tried ${reorderFailures.join(', ')} — all reordered), which this exporter ` +
'cannot mux correctly', '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; 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. * 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] * @param {(progress: {frame, total, fraction, stage}) => void} [options.onProgress]
* @returns {Promise<Blob>} * @returns {Promise<Blob>}
*/ */
async export({ preset = '1080p', frameRange = null, onProgress = null } = {}) { async export({ preset = '1080p', frameRange = null, onProgress = null, videoPick = null } = {}) {
if (!isSupported()) { if (!isSupported()) {
throw new Error('WebCodecs VideoEncoder is unavailable in this browser'); 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 [startFrame, endFrame] = frameRange || [0, show.frameCount];
const total = Math.max(1, endFrame - startFrame); const total = Math.max(1, endFrame - startFrame);
const videoConfig = await pickVideoConfig(width, height, bitrate, fps); const picked = videoPick || await pickVideoConfig(width, height, bitrate, fps);
if (!videoConfig) throw new Error('no supported H.264 configuration found'); 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 channels = show.audioBuffer ? Math.min(2, show.audioBuffer.numberOfChannels) : 0;
const audioConfig = show.audioBuffer && typeof AudioEncoder !== 'undefined' const audioConfig = show.audioBuffer && typeof AudioEncoder !== 'undefined'
@ -258,7 +318,7 @@ export class Exporter {
const muxer = new Muxer({ const muxer = new Muxer({
target: new ArrayBufferTarget(), target: new ArrayBufferTarget(),
video: { codec: 'avc', width, height, frameRate: fps }, video: { codec: muxerVideoCodec, width, height, frameRate: fps },
...(hasAudio ? { ...(hasAudio ? {
audio: { audio: {
codec: audioConfig.muxerCodec, 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. */ /** Render a short range around a frame — the "test render" bridge before a full export. */
export async function exportSegment(show, centreFrame, 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 half = Math.round((seconds * show.fps) / 2);
const start = Math.max(0, centreFrame - half); const start = Math.max(0, centreFrame - half);
const end = Math.min(show.frameCount, centreFrame + half); const end = Math.min(show.frameCount, centreFrame + half);
// Accept a caller-supplied Exporter so the caller can read `warnings` and cancel. // 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) { export function downloadBlob(blob, filename) {

View File

@ -221,20 +221,32 @@ export class ArcDriver {
return stack[slot] || null; 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. */ /** One Layer per (section, variant, layer slot), built lazily and kept. */
_layerFor(sectionIndex, variant, slot = 0) { _layerFor(sectionIndex, variant, slot = 0) {
const key = `${sectionIndex}:${variant}:${slot}`; const key = `${sectionIndex}:${variant}:${slot}`;
let layer = this.layerCache.get(key); let layer = this.layerCache.get(key);
if (!layer) { if (!layer) {
const spec = this._specFor(sectionIndex, variant, slot); const spec = this._specFor(sectionIndex, variant, slot);
const actorSpec = this._actorFor(spec.module);
layer = createLayer(spec.module, { layer = createLayer(spec.module, {
params: spec.params, params: spec.params,
seed: spec.seed, seed: spec.seed,
opacity: spec.opacity, opacity: spec.opacity,
blend: spec.blend, blend: spec.blend,
actorSpec,
}); });
layer.setPalette(this.look.palette); layer.setPalette(this.look.palette);
layer.setPersonality(this.look.personality); 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); this.layerCache.set(key, layer);
} }
return layer; return layer;
@ -804,6 +816,16 @@ export class ArcDriver {
return null; 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. */ /** Push a palette change through without rebuilding layers. */
setPalette(palette) { setPalette(palette) {
this.look.palette = palette; this.look.palette = palette;

View File

@ -25,6 +25,7 @@ import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
import { deriveFramingStyle, describeFraming } from './framing.js'; import { deriveFramingStyle, describeFraming } from './framing.js';
import { deriveCamera, describeCamera } from './Camera.js'; import { deriveCamera, describeCamera } from './Camera.js';
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.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 // 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 // (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 // 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 // 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. // 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 // Carried so the HUD, the checks and a later pass over the look can all
// tell a deliberate bloom-out from a broken one. // tell a deliberate bloom-out from a broken one.
blaze, blaze,
@ -816,6 +823,10 @@ export function generateLook(track, {
// toward one camera the way it leans toward one family per kind, and the // toward one camera the way it leans toward one family per kind, and the
// seed decides — see look/Camera.js. // seed decides — see look/Camera.js.
const camera = deriveCamera(director, summary, rng.fork('camera')); 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); const { post, feedback } = derivePost(summary, rng.fork('post'), grain);
// Scenes eligible to be composited OVER a background. Same casting rule as // Scenes eligible to be composited OVER a background. Same casting rule as
@ -887,6 +898,7 @@ export function generateLook(track, {
paletteArc, paletteArc,
framing, framing,
camera, camera,
actors,
grain, grain,
post, post,
feedback, feedback,
@ -992,10 +1004,11 @@ export function describeLook(look) {
const planTag = look.palettePlan const planTag = look.palettePlan
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}` ? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
: ''; : '';
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` + return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` + `${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` + `${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
`${describeCamera(look.camera)}${planTag} · ` + `${describeCamera(look.camera)}${planTag}${actorTag} · ` +
`${[...new Set(kinds)].join(', ')}`; `${[...new Set(kinds)].join(', ')}`;
} }

View File

@ -6,7 +6,7 @@ import { describeLook } from './look/LookGenerator.js';
import { toHex } from './look/palette.js'; import { toHex } from './look/palette.js';
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js'; import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.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 { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
import { coverageOf as sceneCoverage } from './scenes/surface.js'; import { coverageOf as sceneCoverage } from './scenes/surface.js';
@ -37,6 +37,11 @@ const dom = {
thStep: document.getElementById('th-step'), thStep: document.getElementById('th-step'),
thFill: document.getElementById('th-fill'), thFill: document.getElementById('th-fill'),
changeTrack: document.getElementById('btn-change-track'), 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 = { const state = {
@ -549,6 +554,56 @@ function grainRows(grain) {
// ---------------------------------------------------------------- export // ---------------------------------------------------------------- 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() { function currentPreset() {
const el = document.getElementById('sel-preset'); const el = document.getElementById('sel-preset');
return el ? el.value : '1080p'; return el ? el.value : '1080p';
@ -590,11 +645,21 @@ async function runExport(segment) {
if (dom.toast) dom.toast.hidden = true; if (dom.toast) dom.toast.hidden = true;
try { try {
const preset = currentPreset(); 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 exporter = new Exporter(state.show);
const blob = segment const blob = segment
? await exportSegment(state.show, state.show.timeline.frame, ? await exportSegment(state.show, state.show.timeline.frame,
{ seconds: 20, preset, onProgress: exportProgress, exporter }) { seconds: 20, preset, onProgress: exportProgress, exporter, videoPick: pick })
: await exporter.export({ preset, onProgress: exportProgress }); : await exporter.export({ preset, onProgress: exportProgress, videoPick: pick });
const suffix = segment ? `-segment-${state.show.timeline.frame}` : ''; const suffix = segment ? `-segment-${state.show.timeline.frame}` : '';
downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`); downloadBlob(blob, `${state.show.fileName || 'flow-state'}${suffix}-${preset}.mp4`);

View File

@ -66,6 +66,7 @@ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
* only stamps the flat profile declares `cast` and not this. * only stamps the flat profile declares `cast` and not this.
*/ */
export const ARTIFACT_NAMES = ['cast', 'ink', 'staging', 'form']; 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. * 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 ` + 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)`); `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) errors.push(`${id}: kind 'fragment' but no \`shader\``);
if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.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)\``); 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 params = module.params || {};
const uniformNames = new Set(); const uniformNames = new Set();

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,9 @@ import { halftoneMisprint } from './shader/halftone-misprint.js';
import { drosteFeedback } from './shader/droste-feedback.js'; import { drosteFeedback } from './shader/droste-feedback.js';
import { analogWow } from './shader/analog-wow.js'; import { analogWow } from './shader/analog-wow.js';
import { mountainFlight } from './shader/mountain-flight.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 * The scene library. Families exist so the arc driver can choose by section
@ -167,6 +170,9 @@ const MODULES = [
swarm, swarm,
effigy, effigy,
mountainFlight, mountainFlight,
assembly,
pylonField3D,
synthwaveCorridor,
]; ];
const errors = []; const errors = [];

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

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

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

View File

@ -108,6 +108,17 @@ body {
color: var(--dim); font-size: 14px; 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); } #transport { grid-area: transport; background: var(--panel); }
#timeline { height: 46px; } #timeline { height: 46px; }
#timeline-canvas { width: 100%; height: 46px; display: block; cursor: pointer; } #timeline-canvas { width: 100%; height: 46px; display: block; cursor: pointer; }

View File

@ -39,7 +39,7 @@ const FORBIDDEN = [
]; ];
// Directories whose output must be a pure function of (seed, params, frame). // 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. // Files legitimately allowed a wall clock: perf measurement, not image content.
const ALLOWED = new Set(['engine/perf.js']); const ALLOWED = new Set(['engine/perf.js']);