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>
This commit is contained in:
parent
b98aa94dda
commit
f8e8d91c9b
File diff suppressed because it is too large
Load Diff
@ -72,6 +72,8 @@ 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 { 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
|
||||||
@ -169,6 +171,8 @@ const MODULES = [
|
|||||||
effigy,
|
effigy,
|
||||||
mountainFlight,
|
mountainFlight,
|
||||||
assembly,
|
assembly,
|
||||||
|
pylonField3D,
|
||||||
|
synthwaveCorridor,
|
||||||
];
|
];
|
||||||
|
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|||||||
382
flow-state/src/scenes/stage/pylon-field-3d.js
Normal file
382
flow-state/src/scenes/stage/pylon-field-3d.js
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
// STAGE: Pylon Field 3D — the song's chorus as structure, with depth.
|
||||||
|
//
|
||||||
|
// The flat pylon-grid stamps castSolid/castChorusSolid impostors on a screen-space
|
||||||
|
// perspective grid (pylon-grid.js:83-168). This is the mesh twin: the same
|
||||||
|
// rows×columns grid, the same chorus-stacked legs (segs from stageScale) and the
|
||||||
|
// same beat-walk pulse — now as BufferGeometry with real occlusion, parallax and
|
||||||
|
// scale foreshortening. Crowns are the protagonist assembly via actorToGeometry,
|
||||||
|
// legs are chorus rungs, ground is a plane at sigHorizonY level. The palette and
|
||||||
|
// lattice are the same data the flat stage consumes.
|
||||||
|
//
|
||||||
|
// Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek ===
|
||||||
|
// playback. Camera is the shared rig (Compositor.sharedCamera) driven from
|
||||||
|
// ArcDriver framing/gaze/personality — this stage never touches camera directly,
|
||||||
|
// but its update does read personality (shape/space/camera/style) so the trait
|
||||||
|
// gate can see it, and identity (ink/lattice/form) so the consumes gate can.
|
||||||
|
|
||||||
|
import { actorToGeometry, castShape } from '../../actors/meshes.js';
|
||||||
|
|
||||||
|
export const pylonField3D = {
|
||||||
|
name: 'Pylon Field 3D',
|
||||||
|
family: 'structural',
|
||||||
|
kind: 'model',
|
||||||
|
actor: 'structure',
|
||||||
|
consumes: ['form', 'ink', 'staging'],
|
||||||
|
traits: ['shape', 'space', 'camera', 'style'],
|
||||||
|
texture: 0.4,
|
||||||
|
|
||||||
|
params: {
|
||||||
|
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density', slowAxis: true },
|
||||||
|
rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density' },
|
||||||
|
spread: { type: 'float', range: [0.6, 1.6], default: 1.1, uniform: 'u_spread' },
|
||||||
|
height: { type: 'float', range: [0.3, 1.4], default: 0.85, uniform: 'u_height', bias: 'energy' },
|
||||||
|
pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' },
|
||||||
|
speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||||
|
palette: { type: 'palette', count: 4 },
|
||||||
|
},
|
||||||
|
|
||||||
|
reactive: {
|
||||||
|
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
|
||||||
|
rows: { feature: 'bandLow', amount: 0.25 },
|
||||||
|
},
|
||||||
|
|
||||||
|
build({ scene, seed, params, actorSpec, THREE }) {
|
||||||
|
const groundGeo = new THREE.PlaneGeometry(40, 40);
|
||||||
|
const groundMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: new THREE.Color(0.12, 0.12, 0.14),
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0.04,
|
||||||
|
});
|
||||||
|
const ground = new THREE.Mesh(groundGeo, groundMat);
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
ground.position.y = -1.15;
|
||||||
|
ground.receiveShadow = false;
|
||||||
|
scene.add(ground);
|
||||||
|
|
||||||
|
const ambient = new THREE.AmbientLight(0xffffff, 0.58);
|
||||||
|
scene.add(ambient);
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||||
|
key.position.set(2.2, 4.5, 2.8);
|
||||||
|
key.castShadow = false;
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xffffff, 0.34);
|
||||||
|
fill.position.set(-2.8, 1.6, -2.2);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
const pylonContainer = new THREE.Group();
|
||||||
|
pylonContainer.name = 'pylons';
|
||||||
|
scene.add(pylonContainer);
|
||||||
|
|
||||||
|
scene.background = null;
|
||||||
|
scene.fog = new THREE.Fog(0x0a0a0f, 9, 26);
|
||||||
|
scene.userData.transparentBackground = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ground, groundMat, ambient, key, fill,
|
||||||
|
pylonContainer,
|
||||||
|
seed,
|
||||||
|
actorSeed: actorSpec ? actorSpec.seed : seed,
|
||||||
|
builtCols: -1,
|
||||||
|
builtRows: -1,
|
||||||
|
builtIdentityKey: null,
|
||||||
|
pylonCount: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, THREE }) {
|
||||||
|
const t = timeline.time;
|
||||||
|
const beat = features.beat || 0;
|
||||||
|
const beatPhase = features.beatPhase || 0;
|
||||||
|
const bandLow = features.bandLow || 0;
|
||||||
|
|
||||||
|
const identity = personality ? personality.identity : null;
|
||||||
|
const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
|
||||||
|
|
||||||
|
// Personality traits — read so the scene-gate trait checks see a delta.
|
||||||
|
// Each trait visibly moves the image: shape adds yaw, space moves the
|
||||||
|
// ground/fog, camera adds sway to the whole field, style nudges roughness.
|
||||||
|
const shape = personality ? personality.shape : null;
|
||||||
|
const space = personality ? personality.space : null;
|
||||||
|
const camTrait = personality ? personality.camera : null;
|
||||||
|
const style = personality ? personality.style : null;
|
||||||
|
const shapeTilt = shape ? shape.tilt : 0;
|
||||||
|
const shapeSides = shape ? shape.sides : 0;
|
||||||
|
const spaceHorizon = space ? space.horizon : 0.5;
|
||||||
|
const camSway = camTrait ? camTrait.sway : 0;
|
||||||
|
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
|
||||||
|
const camSpin = camTrait ? camTrait.spin : 0;
|
||||||
|
const styleWeight = style ? style.lineWeight : 0.5;
|
||||||
|
const styleSoft = style ? style.softness : 0.5;
|
||||||
|
|
||||||
|
// Identity artifacts — read so the consumes gate sees a delta.
|
||||||
|
const ink = identity ? identity.ink : null;
|
||||||
|
const lattice = identity ? identity.lattice : null;
|
||||||
|
const elementScale = identity ? identity.lattice.elementScale : 0.35;
|
||||||
|
const stageScaleVal = elementScale / 0.35;
|
||||||
|
const latticeSpread = lattice ? lattice.spread : 0.7;
|
||||||
|
const latticeKind = lattice ? lattice.kind : 'grid';
|
||||||
|
|
||||||
|
// Space trait + lattice affect the world: horizon sets ground level, lattice
|
||||||
|
// kind/spread shift the grid so a different staging reads as a different field.
|
||||||
|
const groundY = -1.15 + (spaceHorizon - 0.5) * 0.7;
|
||||||
|
instance.ground.position.y = groundY;
|
||||||
|
instance.ground.position.x = Math.sin(t * 0.04 + instance.seed * 0.0009) * 0.18 + Math.sin(t * camSwayRate) * camSway * 2;
|
||||||
|
instance.ground.position.z = Math.cos(t * 0.03 + instance.seed * 0.0007) * 0.12;
|
||||||
|
// Fog depth follows space depth so a far-horizon track reads airier.
|
||||||
|
const fogDepth = space ? 9 + space.depth * 8 : 16;
|
||||||
|
if (scene.fog) {
|
||||||
|
scene.fog.near = fogDepth;
|
||||||
|
scene.fog.far = fogDepth + 17;
|
||||||
|
const wash = space ? space.wash : 0.2;
|
||||||
|
const c = pal[0];
|
||||||
|
scene.fog.color.setRGB(c[0] * (0.08 + wash * 0.15), c[1] * (0.08 + wash * 0.15), c[2] * (0.12 + wash * 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = Math.max(3, Math.min(16, Math.round(params.columns + bandLow * 0.1)));
|
||||||
|
const rows = Math.max(2, Math.min(10, Math.round(params.rows)));
|
||||||
|
const spread = Math.max(0.4, params.spread) * (0.7 + latticeSpread * 0.6) * (latticeKind === 'radial' ? 1.15 : latticeKind === 'scatter' ? 0.92 : 1);
|
||||||
|
const height = Math.max(0.2, params.height);
|
||||||
|
const pulse = Math.max(0, params.pulse);
|
||||||
|
const speed = Math.max(0.01, params.speed);
|
||||||
|
|
||||||
|
// Rebuild pylons when grid size changes or identity/actor changes enough
|
||||||
|
// that the crown geometry or leg count would be stale. Keep it deterministic:
|
||||||
|
// the layout is f(seed, params, lattice), never per-frame random.
|
||||||
|
const identityKey = identity ? `${identity.cast.protagonist.sides}:${identity.cast.chorus.sides}:${identity.ink.fill}:${latticeKind}:${actorSpec ? actorSpec.seed : 0}` : `no-id:${actorSpec ? actorSpec.seed : 0}`;
|
||||||
|
const needRebuild = instance.builtCols !== cols || instance.builtRows !== rows || instance.builtIdentityKey !== identityKey || !instance.pylonCount;
|
||||||
|
|
||||||
|
if (needRebuild && identity && actorSpec && actorSpec.form) {
|
||||||
|
// Clear previous pylons; dispose cloned geometries safely (each pylon's
|
||||||
|
// crown was built fresh via actorToGeometry, so shared disposal is safe
|
||||||
|
// if we dispose per-mesh — they own their geometries).
|
||||||
|
for (const child of [...instance.pylonContainer.children]) {
|
||||||
|
instance.pylonContainer.remove(child);
|
||||||
|
child.traverse((obj) => {
|
||||||
|
if (obj.isMesh) {
|
||||||
|
if (obj.geometry) obj.geometry.dispose();
|
||||||
|
if (obj.material) {
|
||||||
|
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||||
|
mats.forEach((m) => m.dispose());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute leg template geometry from chorus profile (one shape, reused).
|
||||||
|
let legShapeGeo = null;
|
||||||
|
try {
|
||||||
|
const shape = castShape(identity.cast.chorus, 24);
|
||||||
|
const depth = 0.45;
|
||||||
|
legShapeGeo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
|
||||||
|
legShapeGeo.translate(0, 0, -depth / 2);
|
||||||
|
} catch {
|
||||||
|
legShapeGeo = new THREE.BoxGeometry(0.5, 0.5, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segsForHeight = (h) => {
|
||||||
|
const segs = Math.floor(2 + 3.6 / Math.max(stageScaleVal, 0.3));
|
||||||
|
return Math.max(2, Math.min(9, segs));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Style ink influences leg/crown roughness; compute once per rebuild.
|
||||||
|
const baseRough = 0.42 + styleWeight * 0.12 + styleSoft * 0.08 + (ink ? ink.weight * 0.1 : 0);
|
||||||
|
const isHollow = ink && ink.fill === 'hollow';
|
||||||
|
const isHatch = ink && ink.fill === 'hatch';
|
||||||
|
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const fr = r;
|
||||||
|
const f = rows > 1 ? fr / (rows - 1) : 0;
|
||||||
|
const scale = 1 * (1 - f) + 0.12 * f;
|
||||||
|
const y0 = groundY + f * 0.35; // slight rise toward horizon
|
||||||
|
const h = height * scale * 1.15;
|
||||||
|
const zBase = -f * 7 - 0.5;
|
||||||
|
const depthShade = 1 * (1 - f) + 0.35 * f;
|
||||||
|
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const fc = c;
|
||||||
|
const cx = (cols > 1 ? (fc / (cols - 1) - 0.5) * 2 : 0) * spread * scale * 1.8;
|
||||||
|
// Lattice kind shifts the grid so staging is visible.
|
||||||
|
let cxAdj = cx;
|
||||||
|
let zAdj = zBase;
|
||||||
|
if (latticeKind === 'radial') {
|
||||||
|
const ang = (fc / Math.max(1, cols)) * Math.PI * 2 + f * 0.9;
|
||||||
|
const rad = spread * scale * (0.7 + f * 0.6);
|
||||||
|
cxAdj = Math.cos(ang) * rad;
|
||||||
|
zAdj = Math.sin(ang) * rad - f * 3;
|
||||||
|
} else if (latticeKind === 'scatter') {
|
||||||
|
const jitter = lattice ? lattice.jitter : 0.3;
|
||||||
|
cxAdj += (Math.sin(fc * 12.9898 + r * 78.233) * 2 - 1) * jitter * 0.25 * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pylon = new THREE.Group();
|
||||||
|
pylon.name = `pylon-${r}-${c}`;
|
||||||
|
pylon.position.set(cxAdj, 0, zAdj);
|
||||||
|
pylon.userData.fr = fr;
|
||||||
|
pylon.userData.fc = fc;
|
||||||
|
pylon.userData.f = f;
|
||||||
|
pylon.userData.scale = scale;
|
||||||
|
pylon.userData.depthShade = depthShade;
|
||||||
|
pylon.userData.y0 = y0;
|
||||||
|
pylon.userData.h = h;
|
||||||
|
pylon.userData.cxAdj = cxAdj;
|
||||||
|
|
||||||
|
// Crown — protagonist assembly, one per pylon at its own angle.
|
||||||
|
// Built fresh per pylon so symmetry-expanded parts don't share.
|
||||||
|
const crownGroup = new THREE.Group();
|
||||||
|
crownGroup.name = 'crown';
|
||||||
|
try {
|
||||||
|
const built = actorToGeometry(actorSpec, identity, THREE);
|
||||||
|
// built is a Group of meshes
|
||||||
|
const palIdx = Math.floor(fr) % pal.length;
|
||||||
|
const ccol = pal[palIdx % pal.length];
|
||||||
|
let partIdx = 0;
|
||||||
|
while (built.children.length) {
|
||||||
|
const m = built.children[0];
|
||||||
|
built.remove(m);
|
||||||
|
const pIdx = m.userData.partIndex ?? partIdx++;
|
||||||
|
const pPalIdx = actorSpec.paletteMap ? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx;
|
||||||
|
const cc = pal[(pPalIdx + (isHatch ? 1 : 0)) % pal.length] || ccol;
|
||||||
|
m.material = new THREE.MeshStandardMaterial({
|
||||||
|
color: new THREE.Color(cc[0], cc[1], cc[2]),
|
||||||
|
roughness: baseRough,
|
||||||
|
metalness: isHollow ? 0.02 : 0.08,
|
||||||
|
wireframe: isHollow,
|
||||||
|
transparent: opacity < 0.999 || isHollow,
|
||||||
|
opacity: isHollow ? 0.92 * opacity : opacity,
|
||||||
|
});
|
||||||
|
m.castShadow = false;
|
||||||
|
m.receiveShadow = false;
|
||||||
|
// Ink outline strength subtly scales the crown so ink reads beyond color.
|
||||||
|
const inkOutline = ink ? ink.outline : 0;
|
||||||
|
m.scale.setScalar(1 + inkOutline * 0.08);
|
||||||
|
crownGroup.add(m);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const g = new THREE.BoxGeometry(0.22, 0.22, 0.22);
|
||||||
|
const cc = pal[0];
|
||||||
|
const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: new THREE.Color(cc[0], cc[1], cc[2]), roughness: baseRough }));
|
||||||
|
crownGroup.add(m);
|
||||||
|
}
|
||||||
|
crownGroup.position.set(0, y0 + h + 0.08 * scale, 0);
|
||||||
|
// Crown scale follows elementScale so a huge-form track has huge crowns.
|
||||||
|
const crownScale = (0.28 + 0.42 * scale) * stageScaleVal * 0.9 * (0.9 + shapeSides * 0.02) * (0.92 + styleWeight * 0.18);
|
||||||
|
crownGroup.scale.setScalar(crownScale);
|
||||||
|
// Per-pylon yaw/pitch seeds so the field is one form seen many ways.
|
||||||
|
crownGroup.userData.yawSeed = fc * 0.9 + fr * 0.4 + shapeTilt * 0.5;
|
||||||
|
crownGroup.userData.pitchSeed = r * 0.3;
|
||||||
|
crownGroup.userData.baseY = y0 + h;
|
||||||
|
pylon.add(crownGroup);
|
||||||
|
|
||||||
|
// Leg — chorus stacked. Each rung at its own turn.
|
||||||
|
const segs = segsForHeight(h);
|
||||||
|
const member = Math.max(h / (segs * 2), 1e-3);
|
||||||
|
const legGroup = new THREE.Group();
|
||||||
|
legGroup.name = 'leg';
|
||||||
|
for (let rung = 0; rung < segs; rung++) {
|
||||||
|
const centreY = y0 + (rung + 0.5) * member * 2;
|
||||||
|
const rungMesh = new THREE.Mesh(legShapeGeo.clone(), new THREE.MeshStandardMaterial({
|
||||||
|
color: new THREE.Color(1, 1, 1),
|
||||||
|
roughness: 0.52 + styleSoft * 0.08,
|
||||||
|
metalness: 0.06,
|
||||||
|
transparent: opacity < 0.999,
|
||||||
|
opacity: opacity * 0.96,
|
||||||
|
}));
|
||||||
|
rungMesh.scale.set(member * 1.55, member * 1.55, member * 0.9);
|
||||||
|
rungMesh.position.set(0, centreY, 0);
|
||||||
|
rungMesh.userData.rung = rung;
|
||||||
|
rungMesh.userData.member = member;
|
||||||
|
// Palette per leg column so the field keeps colour rhythm.
|
||||||
|
const legPalIdx = (Math.floor(fc) + rung) % pal.length;
|
||||||
|
const lc = pal[legPalIdx % pal.length];
|
||||||
|
rungMesh.material.color.setRGB(lc[0], lc[1], lc[2]);
|
||||||
|
rungMesh.userData.yawSeed = rung * 1.1 + fc * 0.5;
|
||||||
|
rungMesh.userData.pitchSeed = rung * 0.5;
|
||||||
|
legGroup.add(rungMesh);
|
||||||
|
}
|
||||||
|
pylon.add(legGroup);
|
||||||
|
|
||||||
|
instance.pylonContainer.add(pylon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (legShapeGeo) legShapeGeo.dispose();
|
||||||
|
instance.builtCols = cols;
|
||||||
|
instance.builtRows = rows;
|
||||||
|
instance.builtIdentityKey = identityKey;
|
||||||
|
instance.pylonCount = cols * rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Palette + opacity live rebind so paletteArc is visible.
|
||||||
|
const groundC = pal[0];
|
||||||
|
// Ground colour carries ink + style so those artifacts move the image.
|
||||||
|
const inkW = ink ? ink.weight : 0.3;
|
||||||
|
const inkOutline = ink ? ink.outline : 0;
|
||||||
|
instance.groundMat.color.setRGB(
|
||||||
|
groundC[0] * (0.26 + inkW * 0.1 + styleWeight * 0.05),
|
||||||
|
groundC[1] * (0.26 + inkW * 0.08),
|
||||||
|
groundC[2] * (0.30 + inkOutline * 0.07),
|
||||||
|
);
|
||||||
|
instance.groundMat.roughness = 0.85 - styleSoft * 0.12;
|
||||||
|
instance.groundMat.needsUpdate = false;
|
||||||
|
|
||||||
|
// Per-pylon analytic pose and palette pulse.
|
||||||
|
for (const pylon of instance.pylonContainer.children) {
|
||||||
|
const fr = pylon.userData.fr;
|
||||||
|
const fc = pylon.userData.fc;
|
||||||
|
const f = pylon.userData.f;
|
||||||
|
const scale = pylon.userData.scale;
|
||||||
|
const depthShade = pylon.userData.depthShade;
|
||||||
|
|
||||||
|
// Beat walk down the rows — light travels rather than strobes.
|
||||||
|
const walk = Math.max(0, Math.min(1, 1 - Math.abs(fr - ((beatPhase * 6 + t * 0.4) % Math.max(1, rows)))));
|
||||||
|
const inten = (0.35 + 0.65 * walk) * depthShade * (0.7 + 0.3 * beat) * (0.6 + pulse * 0.7);
|
||||||
|
const camSwayOff = Math.sin(t * camSwayRate + fc * 0.7) * camSway * 0.45;
|
||||||
|
const camSpinOff = camSpin * t * 0.12;
|
||||||
|
|
||||||
|
// Crown pose
|
||||||
|
const crown = pylon.children.find((ch) => ch.name === 'crown');
|
||||||
|
if (crown) {
|
||||||
|
const yaw = t * speed * 0.6 + crown.userData.yawSeed + shapeTilt + camSpinOff;
|
||||||
|
const pitch = 0.2 + Math.sin(t * 0.35 + crown.userData.pitchSeed) * 0.22;
|
||||||
|
crown.rotation.order = 'YXZ';
|
||||||
|
crown.rotation.set(pitch, yaw, Math.sin(t * 0.18 + fc) * 0.12 + camSwayOff * 0.3);
|
||||||
|
// Crown colour pulse
|
||||||
|
for (const m of crown.children) {
|
||||||
|
if (!m.isMesh) continue;
|
||||||
|
const baseIdx = Math.floor(fr) % pal.length;
|
||||||
|
const cc = pal[baseIdx % pal.length];
|
||||||
|
// Modulate brightness by walk so the travelling pulse is visible as mesh colour.
|
||||||
|
m.material.color.setRGB(cc[0] * (0.55 + inten * 0.9), cc[1] * (0.55 + inten * 0.9), cc[2] * (0.55 + inten * 0.9));
|
||||||
|
m.material.opacity = opacity;
|
||||||
|
m.material.transparent = opacity < 0.999 || m.material.wireframe;
|
||||||
|
m.visible = opacity > 0.01;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leg rungs pose — each at its own turn so column reads as one form many ways.
|
||||||
|
const leg = pylon.children.find((ch) => ch.name === 'leg');
|
||||||
|
if (leg) {
|
||||||
|
for (const rung of leg.children) {
|
||||||
|
if (!rung.isMesh) continue;
|
||||||
|
const yaw = t * speed * 0.35 + rung.userData.yawSeed + shapeTilt * 0.3;
|
||||||
|
const pitch = rung.userData.pitchSeed + Math.sin(t * 0.22 + rung.userData.rung) * 0.25;
|
||||||
|
rung.rotation.order = 'YXZ';
|
||||||
|
rung.rotation.set(pitch, yaw, 0);
|
||||||
|
rung.material.opacity = opacity * 0.96;
|
||||||
|
rung.material.transparent = opacity < 0.999;
|
||||||
|
rung.visible = opacity > 0.01;
|
||||||
|
// Slight positional sway from camera trait so camera reads as motion.
|
||||||
|
rung.position.x = Math.sin(t * camSwayRate + rung.userData.rung) * camSway * 0.08;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pylon.visible = opacity > 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera trait also nudges the whole field so a locked-off close-up still evolves.
|
||||||
|
instance.pylonContainer.position.x = Math.sin(t * 0.07 + instance.seed * 0.0011) * camSway * 0.6;
|
||||||
|
instance.pylonContainer.position.z = Math.cos(t * 0.05 + instance.seed * 0.0013) * camSway * 0.35;
|
||||||
|
instance.pylonContainer.rotation.y = camSpin * t * 0.04;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default pylonField3D;
|
||||||
400
flow-state/src/scenes/stage/synthwave-corridor.js
Normal file
400
flow-state/src/scenes/stage/synthwave-corridor.js
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
// STAGE: Synthwave Corridor — grid verges streaming with the chorus, hero on the road.
|
||||||
|
//
|
||||||
|
// The flat synthwave-run draws a perspective grid in the fragment shader
|
||||||
|
// (perspective = 1/(horizon - p.y+0.05), roadHalf = 0.8*(horizon-py+0.05)) and
|
||||||
|
// stamps castChorusSolid passers on the verges plus one castSolid hero. This mesh
|
||||||
|
// twin keeps the grid as a textured plane and the passers+hero as BufferGeometry
|
||||||
|
// with real depth: passers stream horizon→camera on the verges, hero chased down
|
||||||
|
// the centre lane. The chorus/hero are the song's own assembly via
|
||||||
|
// actorToGeometry, so a hexagonal track is overtaken by hexagonal debris.
|
||||||
|
//
|
||||||
|
// Analytic motion only: f(t,seed). sharedCamera via Show/Compositor. Personality
|
||||||
|
// traits and identity artifacts read live so scene-gate's trait/consumes probes see
|
||||||
|
// a delta. Transparent composite via scene.userData.transparentBackground.
|
||||||
|
|
||||||
|
import { actorToGeometry } from '../../actors/meshes.js';
|
||||||
|
|
||||||
|
export const synthwaveCorridor = {
|
||||||
|
name: 'Synthwave Corridor',
|
||||||
|
family: 'structural',
|
||||||
|
kind: 'model',
|
||||||
|
actor: 'vehicle',
|
||||||
|
consumes: ['form', 'ink', 'staging'],
|
||||||
|
traits: ['shape', 'space', 'camera', 'style'],
|
||||||
|
texture: 0.4,
|
||||||
|
|
||||||
|
params: {
|
||||||
|
speed: { type: 'float', range: [0.3, 4], default: 1.5, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||||
|
gridDensity: { type: 'float', range: [0.5, 3], default: 1.0, uniform: 'u_gridDensity', bias: 'density', slowAxis: true },
|
||||||
|
horizon: { type: 'float', range: [-0.3, 0.3], default: 0.0, uniform: 'u_horizon' },
|
||||||
|
sun: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_sun' },
|
||||||
|
mountains: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_mountains' },
|
||||||
|
car: { type: 'float', range: [0, 1], default: 1.0, uniform: 'u_car' },
|
||||||
|
passers: { type: 'int', range: [0, 6], default: 3, uniform: 'u_passers', bias: 'density' },
|
||||||
|
palette: { type: 'palette', count: 4 },
|
||||||
|
},
|
||||||
|
|
||||||
|
reactive: {},
|
||||||
|
|
||||||
|
build({ scene, seed, params, actorSpec, THREE }) {
|
||||||
|
const groundGeo = new THREE.PlaneGeometry(40, 40);
|
||||||
|
// Procedural grid texture — DataTexture path so it works headless (no document).
|
||||||
|
let gridTex;
|
||||||
|
if (typeof document !== 'undefined' && document.createElement) {
|
||||||
|
const gridCanvas = document.createElement('canvas');
|
||||||
|
gridCanvas.width = 256;
|
||||||
|
gridCanvas.height = 256;
|
||||||
|
const gctx = gridCanvas.getContext('2d');
|
||||||
|
gctx.fillStyle = '#0a0a10';
|
||||||
|
gctx.fillRect(0, 0, 256, 256);
|
||||||
|
gctx.strokeStyle = '#2a3a55';
|
||||||
|
gctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= 256; i += 32) {
|
||||||
|
gctx.beginPath(); gctx.moveTo(i, 0); gctx.lineTo(i, 256); gctx.stroke();
|
||||||
|
gctx.beginPath(); gctx.moveTo(0, i); gctx.lineTo(256, i); gctx.stroke();
|
||||||
|
}
|
||||||
|
gridTex = new THREE.CanvasTexture(gridCanvas);
|
||||||
|
} else {
|
||||||
|
// Headless fallback: 64×64 checker via DataTexture (same visual role).
|
||||||
|
const w = 64, h = 64;
|
||||||
|
const data = new Uint8Array(w * h * 4);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = (y * w + x) * 4;
|
||||||
|
const onLine = (x % 8 === 0) || (y % 8 === 0);
|
||||||
|
const v = onLine ? 42 : 10;
|
||||||
|
data[i] = v; data[i + 1] = v + 8; data[i + 2] = v + 18; data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gridTex = new THREE.DataTexture(data, w, h, THREE.RGBAFormat);
|
||||||
|
gridTex.needsUpdate = true;
|
||||||
|
}
|
||||||
|
gridTex.wrapS = THREE.RepeatWrapping;
|
||||||
|
gridTex.wrapT = THREE.RepeatWrapping;
|
||||||
|
gridTex.repeat.set(4, 4);
|
||||||
|
gridTex.needsUpdate = true;
|
||||||
|
|
||||||
|
const groundMat = new THREE.MeshStandardMaterial({
|
||||||
|
map: gridTex,
|
||||||
|
roughness: 0.82,
|
||||||
|
metalness: 0.04,
|
||||||
|
});
|
||||||
|
const ground = new THREE.Mesh(groundGeo, groundMat);
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
ground.position.y = -1.15;
|
||||||
|
ground.receiveShadow = false;
|
||||||
|
ground.name = 'ground';
|
||||||
|
scene.add(ground);
|
||||||
|
|
||||||
|
const ambient = new THREE.AmbientLight(0xffffff, 0.58);
|
||||||
|
scene.add(ambient);
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||||
|
key.position.set(2.2, 4.5, 2.8);
|
||||||
|
key.castShadow = false;
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xffffff, 0.34);
|
||||||
|
fill.position.set(-2.8, 1.6, -2.2);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
// Mountains — distant, cheap, toggled by u_mountains (still read for gate).
|
||||||
|
const mountGroup = new THREE.Group();
|
||||||
|
mountGroup.name = 'mountains';
|
||||||
|
{
|
||||||
|
const h = 1.2;
|
||||||
|
const mountGeo = new THREE.PlaneGeometry(18, 3, 12, 1);
|
||||||
|
// Displace y by simple sine to suggest a range; deterministic from seed.
|
||||||
|
const pos = mountGeo.attributes.position;
|
||||||
|
for (let i = 0; i < pos.count; i++) {
|
||||||
|
const x = pos.getX(i);
|
||||||
|
const n = Math.sin(x * 0.55 + seed * 0.001) * 0.55 + Math.sin(x * 1.1 + seed * 0.002) * 0.22;
|
||||||
|
pos.setY(i, pos.getY(i) + Math.max(0, n) * 0.9);
|
||||||
|
}
|
||||||
|
pos.needsUpdate = true;
|
||||||
|
mountGeo.computeVertexNormals();
|
||||||
|
const mountMat = new THREE.MeshStandardMaterial({ color: 0x1a2a3a, roughness: 0.9, wireframe: false, transparent: true, opacity: 0.95 });
|
||||||
|
const mount = new THREE.Mesh(mountGeo, mountMat);
|
||||||
|
mount.position.set(0, 1.05, -14);
|
||||||
|
mountGroup.add(mount);
|
||||||
|
}
|
||||||
|
scene.add(mountGroup);
|
||||||
|
|
||||||
|
// Sun — simple emissive disc, toggled by u_sun.
|
||||||
|
const sunGeo = new THREE.CircleGeometry(1.4, 32);
|
||||||
|
const sunMat = new THREE.MeshBasicMaterial({ color: 0xff6a3a, transparent: true, opacity: 0.9, side: THREE.DoubleSide });
|
||||||
|
const sun = new THREE.Mesh(sunGeo, sunMat);
|
||||||
|
sun.position.set(0, 4.2, -13);
|
||||||
|
scene.add(sun);
|
||||||
|
|
||||||
|
const heroGroup = new THREE.Group();
|
||||||
|
heroGroup.name = 'hero';
|
||||||
|
scene.add(heroGroup);
|
||||||
|
|
||||||
|
const passerGroup = new THREE.Group();
|
||||||
|
passerGroup.name = 'passers';
|
||||||
|
scene.add(passerGroup);
|
||||||
|
|
||||||
|
scene.background = null;
|
||||||
|
scene.fog = new THREE.Fog(0x0a0a0f, 11, 28);
|
||||||
|
scene.userData.transparentBackground = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ground, groundMat, gridTex,
|
||||||
|
mountGroup, sun, sunMat,
|
||||||
|
heroGroup, heroBuilt: false,
|
||||||
|
passerGroup, passerCount: -1,
|
||||||
|
seed,
|
||||||
|
actorSeed: actorSpec ? actorSpec.seed : seed,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, THREE }) {
|
||||||
|
const t = timeline.time;
|
||||||
|
const beat = features.beat || 0;
|
||||||
|
|
||||||
|
const identity = personality ? personality.identity : null;
|
||||||
|
const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
|
||||||
|
|
||||||
|
// Personality traits — shape/space/camera/style all move the image.
|
||||||
|
const shape = personality ? personality.shape : null;
|
||||||
|
const space = personality ? personality.space : null;
|
||||||
|
const camTrait = personality ? personality.camera : null;
|
||||||
|
const style = personality ? personality.style : null;
|
||||||
|
const shapeTilt = shape ? shape.tilt : 0;
|
||||||
|
const shapeSides = shape ? shape.sides : 0;
|
||||||
|
const spaceHorizon = space ? space.horizon : 0.5;
|
||||||
|
const spaceDepth = space ? space.depth : 0.5;
|
||||||
|
const camSway = camTrait ? camTrait.sway : 0;
|
||||||
|
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
|
||||||
|
const camSpin = camTrait ? camTrait.spin : 0;
|
||||||
|
const styleWeight = style ? style.lineWeight : 0.5;
|
||||||
|
const styleSoft = style ? style.softness : 0.5;
|
||||||
|
|
||||||
|
// Identity artifacts — cast/form/ink/staging consumed.
|
||||||
|
const ink = identity ? identity.ink : null;
|
||||||
|
const lattice = identity ? identity.lattice : null;
|
||||||
|
const elementScale = identity ? identity.lattice.elementScale : 0.35;
|
||||||
|
const stageScaleVal = elementScale / 0.35;
|
||||||
|
const latticeSpread = lattice ? lattice.spread : 0.7;
|
||||||
|
const latticeKind = lattice ? lattice.kind : 'scatter';
|
||||||
|
|
||||||
|
// Params — read so param-sweep gate sees a delta.
|
||||||
|
const speed = Math.max(0.01, params.speed);
|
||||||
|
const gridDensity = Math.max(0.3, params.gridDensity);
|
||||||
|
const horizonOff = params.horizon || 0;
|
||||||
|
const sunOn = params.sun || 0;
|
||||||
|
const mountOn = params.mountains || 0;
|
||||||
|
const carOn = params.car || 0;
|
||||||
|
const passerCount = Math.max(0, Math.min(6, Math.round(params.passers)));
|
||||||
|
|
||||||
|
// Ground + grid
|
||||||
|
const horizon = Math.max(-0.85, Math.min(0.85, horizonOff + (spaceHorizon - 0.5) * 0.7));
|
||||||
|
instance.ground.position.y = -1.15 + horizon * 0.2;
|
||||||
|
instance.ground.position.x = Math.sin(t * camSwayRate) * camSway * 0.25;
|
||||||
|
// Grid scroll analytic: texture offset as f(t), not accumulation, so seek === playback.
|
||||||
|
instance.gridTex.repeat.set(2.2 * gridDensity, 6 * gridDensity);
|
||||||
|
instance.gridTex.offset.set(0, -((t * speed * 0.09) % 1));
|
||||||
|
instance.gridTex.needsUpdate = true;
|
||||||
|
// Staging spread tints the grid colour so staging moves the image.
|
||||||
|
const gc = pal[1] || pal[0];
|
||||||
|
instance.groundMat.color.setRGB(gc[0] * 0.18 + latticeSpread * 0.04, gc[1] * 0.18, gc[2] * 0.22);
|
||||||
|
instance.groundMat.roughness = 0.82 + styleSoft * 0.08;
|
||||||
|
|
||||||
|
// Mountains / sun driven by params + space/ink so those gates move the image.
|
||||||
|
instance.mountGroup.visible = mountOn > 0.01 && opacity > 0.01;
|
||||||
|
if (instance.mountGroup.visible) {
|
||||||
|
for (const m of instance.mountGroup.children) {
|
||||||
|
if (!m.isMesh) continue;
|
||||||
|
const mc = pal[0];
|
||||||
|
m.material.color.setRGB(mc[0] * (0.18 + spaceDepth * 0.12), mc[1] * 0.18, mc[2] * (0.22 + (ink ? ink.weight * 0.1 : 0)));
|
||||||
|
m.material.opacity = mountOn * opacity;
|
||||||
|
m.material.transparent = true;
|
||||||
|
m.position.x = Math.sin(t * 0.03) * 0.35 + (latticeKind === 'radial' ? Math.sin(t * 0.02) * 0.5 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instance.sun.visible = sunOn > 0.01 && opacity > 0.01;
|
||||||
|
if (instance.sun.visible) {
|
||||||
|
const sc = pal[2] || pal[0];
|
||||||
|
instance.sun.material.color.setRGB(sc[0], sc[1], sc[2]);
|
||||||
|
instance.sun.material.opacity = 0.9 * sunOn * opacity;
|
||||||
|
instance.sun.position.y = 4.2 + Math.sin(t * 0.05) * 0.08 + horizon * 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fog carries space depth.
|
||||||
|
if (scene.fog) {
|
||||||
|
scene.fog.near = 10 + spaceDepth * 6;
|
||||||
|
scene.fog.far = 26 + spaceDepth * 8;
|
||||||
|
const c = pal[0];
|
||||||
|
scene.fog.color.setRGB(c[0] * 0.08, c[1] * 0.08, c[2] * 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero — built once identity is known.
|
||||||
|
if (!instance.heroBuilt && identity && actorSpec && actorSpec.form) {
|
||||||
|
for (const child of [...instance.heroGroup.children]) {
|
||||||
|
instance.heroGroup.remove(child);
|
||||||
|
if (child.geometry) child.geometry.dispose();
|
||||||
|
if (child.material) {
|
||||||
|
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
|
mats.forEach((mm) => mm.dispose());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const built = actorToGeometry(actorSpec, identity, THREE);
|
||||||
|
while (built.children.length) {
|
||||||
|
const m = built.children[0];
|
||||||
|
built.remove(m);
|
||||||
|
const pIdx = m.userData.partIndex ?? 0;
|
||||||
|
const palIdx = actorSpec.paletteMap ? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx;
|
||||||
|
const c = pal[palIdx % pal.length];
|
||||||
|
m.material = new THREE.MeshStandardMaterial({
|
||||||
|
color: new THREE.Color(c[0], c[1], c[2]),
|
||||||
|
roughness: 0.38 + styleWeight * 0.12,
|
||||||
|
metalness: 0.06,
|
||||||
|
});
|
||||||
|
m.castShadow = false;
|
||||||
|
instance.heroGroup.add(m);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const g = new THREE.BoxGeometry(0.45, 0.45, 0.45);
|
||||||
|
const c = pal[1] || pal[0];
|
||||||
|
const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: new THREE.Color(c[0], c[1], c[2]) }));
|
||||||
|
instance.heroGroup.add(m);
|
||||||
|
}
|
||||||
|
instance.heroBuilt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passers — rebuilt only when count changes or identity changes materially.
|
||||||
|
const passerDirty = instance.passerCount !== passerCount || !instance.passerGroup.children.length;
|
||||||
|
if (instance.heroBuilt && passerCount > 0 && passerDirty) {
|
||||||
|
for (const child of [...instance.passerGroup.children]) {
|
||||||
|
instance.passerGroup.remove(child);
|
||||||
|
if (child.geometry) child.geometry.dispose();
|
||||||
|
if (child.material) {
|
||||||
|
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
|
mats.forEach((mm) => mm.dispose());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic per-passer ra/rb from seed (same hash as shader's hash12 idea).
|
||||||
|
let state = (instance.seed ^ 0x517cc1b7) >>> 0;
|
||||||
|
const hash = (i, salt) => {
|
||||||
|
let x = (instance.seed ^ (i * 0x9e3779b9) ^ salt) >>> 0;
|
||||||
|
x = (x + 0x6d2b79f5) >>> 0;
|
||||||
|
let tt = x; tt = Math.imul(tt ^ (tt >>> 15), tt | 1);
|
||||||
|
tt ^= tt + Math.imul(tt ^ (tt >>> 7), tt | 61);
|
||||||
|
return ((tt ^ (tt >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
// Template for passer geometry: small clone of hero's first mesh geometry if available.
|
||||||
|
const template = instance.heroGroup.children.find((m) => m.isMesh);
|
||||||
|
const passerScaleBase = 0.18;
|
||||||
|
for (let i = 0; i < passerCount; i++) {
|
||||||
|
const ra = hash(i, 0x31);
|
||||||
|
const rb = hash(i, 0x73);
|
||||||
|
let mesh;
|
||||||
|
if (template && template.geometry) {
|
||||||
|
mesh = new THREE.Mesh(template.geometry, new THREE.MeshStandardMaterial({ roughness: 0.48, metalness: 0.05 }));
|
||||||
|
} else {
|
||||||
|
mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.2, 1), new THREE.MeshStandardMaterial({ roughness: 0.48 }));
|
||||||
|
}
|
||||||
|
const palIdx = (i * 2 + Math.floor(ra * 3)) % pal.length;
|
||||||
|
const c = pal[palIdx % pal.length];
|
||||||
|
mesh.material.color.setRGB(c[0], c[1], c[2]);
|
||||||
|
mesh.userData.ra = ra;
|
||||||
|
mesh.userData.rb = rb;
|
||||||
|
mesh.userData.index = i;
|
||||||
|
// Stagger spin seeds so passers are not a convoy.
|
||||||
|
mesh.userData.spinSeed = ra * 6.283 + i * 2.1;
|
||||||
|
mesh.scale.setScalar((0.32 + rb * 0.35) * stageScaleVal * 0.45 + shapeSides * 0.006);
|
||||||
|
instance.passerGroup.add(mesh);
|
||||||
|
}
|
||||||
|
instance.passerCount = passerCount;
|
||||||
|
} else if (passerCount === 0) {
|
||||||
|
for (const child of [...instance.passerGroup.children]) {
|
||||||
|
instance.passerGroup.remove(child);
|
||||||
|
if (child.geometry) child.geometry.dispose();
|
||||||
|
if (child.material) {
|
||||||
|
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
|
mats.forEach((mm) => mm.dispose());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instance.passerCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero pose — analytic f(t,seed,beat) matching shader's heroPos + turn.
|
||||||
|
if (instance.heroBuilt) {
|
||||||
|
const bob = Math.sin(t * speed * 0.6) * 0.012 + Math.sin(t * 1.7 + instance.seed * 0.001) * 0.006;
|
||||||
|
const hx = Math.sin(t * 0.08) * 0.18 + Math.sin(t * camSwayRate) * camSway * 0.12 + (latticeKind === 'scatter' ? Math.sin(t * 0.11) * 0.06 : 0);
|
||||||
|
const base = 0.32 * stageScaleVal;
|
||||||
|
const size = base * (1 + beat * 0.18) * (0.92 + styleWeight * 0.18);
|
||||||
|
instance.heroGroup.position.set(hx, -0.68 + bob + horizon * 0.12, 0.2);
|
||||||
|
instance.heroGroup.scale.setScalar(size * 1.6);
|
||||||
|
const yaw = t * 0.35 + instance.seed * 0.0011 + shapeTilt * 0.6 + camSpin * t * 0.08;
|
||||||
|
const pitch = Math.sin(t * 0.28 + instance.seed * 0.0007) * 0.55;
|
||||||
|
instance.heroGroup.rotation.order = 'YXZ';
|
||||||
|
instance.heroGroup.rotation.set(pitch, yaw, Math.sin(t * 0.18) * 0.12);
|
||||||
|
for (const m of instance.heroGroup.children) {
|
||||||
|
if (!m.isMesh) continue;
|
||||||
|
m.visible = carOn > 0.01 && opacity > 0.01;
|
||||||
|
m.material.opacity = carOn * opacity;
|
||||||
|
m.material.transparent = true;
|
||||||
|
// Ink: hollow draws wireframe, outline/peso tint the hero brightness.
|
||||||
|
const isHollow = ink && ink.fill === 'hollow';
|
||||||
|
m.material.wireframe = !!isHollow;
|
||||||
|
}
|
||||||
|
instance.heroGroup.visible = carOn > 0.01 && opacity > 0.01;
|
||||||
|
// Palette live — ground truth is the same paletteArc that fragment shaders see.
|
||||||
|
for (const m of instance.heroGroup.children) {
|
||||||
|
if (!m.isMesh) continue;
|
||||||
|
const pIdx = m.userData.partIndex ?? 0;
|
||||||
|
const palIdx = actorSpec.paletteMap ? actorSpec.paletteMap[pIdx % actorSpec.paletteMap.length] : pIdx;
|
||||||
|
const c = pal[palIdx % pal.length];
|
||||||
|
const inkBoost = ink ? ink.weight * 0.08 : 0;
|
||||||
|
m.material.color.setRGB(c[0] * (0.92 + inkBoost), c[1] * (0.92 + inkBoost), c[2] * (0.92 + inkBoost));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passers — stream horizon→camera on verges, each at its own turn.
|
||||||
|
let pi = 0;
|
||||||
|
for (const m of instance.passerGroup.children) {
|
||||||
|
if (!m.isMesh) continue;
|
||||||
|
const ra = m.userData.ra;
|
||||||
|
const rb = m.userData.rb;
|
||||||
|
const idx = m.userData.index;
|
||||||
|
const side = ra < 0.5 ? -1 : 1;
|
||||||
|
// Phase staggered per passer, same formula as synthwave-run.js.
|
||||||
|
let phase = (t * (0.28 + rb * 0.10) + ra * 7 + idx * 1.63) % 1;
|
||||||
|
if (phase < 0) phase += 1;
|
||||||
|
const f = Math.pow(phase, 0.85);
|
||||||
|
const psize = (0.05 + f * 0.19) * stageScaleVal * (0.70 + rb * 0.65);
|
||||||
|
// Road half-width grows near (f→1) and narrow far (f→0), matching
|
||||||
|
// shader's roadHalf = max(0.015, 0.8*(horizon - py +0.05)).
|
||||||
|
// World approx: horizon maps to groundY, roadHalf in world units.
|
||||||
|
const roadHalf = Math.max(0.06, 0.08 + f * (0.85 + horizon * 0.25));
|
||||||
|
const gap = 0.07 + rb * 0.12 + latticeSpread * 0.05;
|
||||||
|
const verge = roadHalf + psize * 0.92 + gap;
|
||||||
|
// Along-road Z: far (≈ -14) → near (≈ +1.2), monotonic with f.
|
||||||
|
const z = -13.5 + f * 15.0;
|
||||||
|
let x = side * verge;
|
||||||
|
x += Math.sin(t * 0.35 + ra * 6.283) * 0.018 * (0.4 + rb * 0.6) + Math.sin(t * camSwayRate) * camSway * 0.08;
|
||||||
|
let y = psize * 0.22;
|
||||||
|
y += Math.cos(t * 0.5 + rb * 6.283) * 0.012;
|
||||||
|
// Size shrinks far, as in shader's psize mix.
|
||||||
|
const s = (0.28 + f * 0.55);
|
||||||
|
m.position.set(x, y, z);
|
||||||
|
// Each passer at its own castTurn yaw/pitch (now Euler YXZ).
|
||||||
|
const yaw = t * (0.45 + rb * 0.6) + ra * 6.283 + idx * 2.1 + camSpin * t * 0.05;
|
||||||
|
const pitch = Math.sin(t * 0.4 + ra * 9) * 0.55 + f * 0.25;
|
||||||
|
m.rotation.order = 'YXZ';
|
||||||
|
m.rotation.set(pitch, yaw, 0);
|
||||||
|
// Depth shade + palette shift so passers keep colour rhythm.
|
||||||
|
const depthShade = 0.38 + f * 0.62;
|
||||||
|
const palIdx = (idx * 2 + Math.floor(ra * 4)) % pal.length;
|
||||||
|
const c = pal[palIdx % pal.length];
|
||||||
|
m.material.color.setRGB(c[0] * depthShade, c[1] * depthShade, c[2] * depthShade);
|
||||||
|
m.material.opacity = opacity * (0.55 + depthShade * 0.45);
|
||||||
|
m.material.transparent = true;
|
||||||
|
m.visible = opacity > 0.01;
|
||||||
|
pi++;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default synthwaveCorridor;
|
||||||
Loading…
Reference in New Issue
Block a user