Actors and objects change only at camera cuts. int/bool/discrete params skip drift, lookahead and reactivity; discrete slowAxis steps on the section-held journey. Venues fade instead of toggling on interpolated popMul; roadside density is arrangement-only. DESIGN.md collects the project's design decisions; AGENTS.md points agents at it and the build-visualizer loop.
994 lines
49 KiB
JavaScript
994 lines
49 KiB
JavaScript
// VENUE: Ride — a night drive. A randomly generated vehicle (the track's
|
|
// monolith body as chassis, signature-form panels) carries actor passengers;
|
|
// the viz plays on the car radio screen; the road scrolls beneath in chase
|
|
// mode or the world sits still in stationary mode. Camera orbits, pushes in
|
|
// and pulls out per section.
|
|
//
|
|
// One venue = one place (this car, this road). Primitives + actorToGeometry
|
|
// only — no glTF kit. Per-section evolution via venueState
|
|
// {light,fog,popMul,reachMul} from VenueSpec.perSection, interpolated by the
|
|
// ArcDriver (never stepped).
|
|
|
|
import { actorToGeometry } from '../actors/meshes.js';
|
|
import { buildSky, paintSky } from './sky.js';
|
|
|
|
export const ride = {
|
|
name: 'Ride',
|
|
archetype: 'ride',
|
|
family: 'structural',
|
|
kind: 'model',
|
|
venue: true,
|
|
// This venue drives its own camera rig outright (see the camera block at
|
|
// the end of update()). The shared rig is ignored for this layer — kept
|
|
// for every other model layer. Analytic f(timeline, seed, framing,
|
|
// venueState) like everything else, so seek === playback still holds.
|
|
directCamera: true,
|
|
consumes: ['form', 'ink', 'staging'],
|
|
traits: ['shape', 'space', 'camera', 'style'],
|
|
texture: 0.4,
|
|
// The radio is a small bright screen in a dark cabin — it earns the blaze
|
|
// about as often as the stage wall.
|
|
reach: 0.6,
|
|
|
|
params: {
|
|
density: { type: 'float', range: [0.2, 0.9], default: 0.5, uniform: 'u_density', bias: 'density' },
|
|
speed: { type: 'float', range: [0, 1.5], default: 0.7, uniform: 'u_speed', bias: 'motion' },
|
|
light: { type: 'float', range: [0.4, 1.3], default: 0.85, uniform: 'u_light', bias: 'energy' },
|
|
palette: { type: 'palette', count: 4 },
|
|
},
|
|
|
|
reactive: {},
|
|
|
|
// Venue RTT: the compositor calls this with the finished viz texture after
|
|
// compositing the viz stack into vizTarget. The radio screen samples it,
|
|
// so the viz plays ON the dash rather than composited over the venue —
|
|
// and so does the roadside billboard when the seed placed one.
|
|
bindVizTexture(instance, texture) {
|
|
if (!instance || !texture) return;
|
|
if (instance.radioMat && instance.radioMat.emissiveMap !== texture) {
|
|
instance.radioMat.emissiveMap = texture;
|
|
instance.radioMat.needsUpdate = true;
|
|
}
|
|
if (instance.billMat && instance.billMat.emissiveMap !== texture) {
|
|
instance.billMat.emissiveMap = texture;
|
|
instance.billMat.needsUpdate = true;
|
|
}
|
|
},
|
|
|
|
// Radio-glow wash for the viz composite — arcs with venueState.light.
|
|
// See Layer.washColor.
|
|
washColor(instance) {
|
|
const st = (instance && instance.venueState) || null;
|
|
const light = st ? st.light : 0.7;
|
|
const glow = 0.08 + light * 0.1;
|
|
return [glow, glow, glow * 1.15];
|
|
},
|
|
|
|
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
|
|
const T = THREE;
|
|
const rng = mulberry(seed >>> 0 || 1);
|
|
|
|
// Road: long dark plane the car sits over. In chase mode its texture
|
|
// offset scrolls (see update); in stationary it holds still.
|
|
const roadMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.09, 0.09, 0.11),
|
|
roughness: 0.9,
|
|
metalness: 0.05,
|
|
});
|
|
const road = new T.Mesh(new T.PlaneGeometry(30, 120), roadMat);
|
|
road.rotation.x = -Math.PI / 2;
|
|
road.position.set(0, -1.15, -30);
|
|
scene.add(road);
|
|
|
|
// Lane dashes: small emissive quads scrolling with the road, so speed
|
|
// reads even when the asphalt is near-black.
|
|
const dashMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.05, 0.05, 0.05),
|
|
emissive: new T.Color(0.9, 0.85, 0.6),
|
|
emissiveIntensity: 0.7,
|
|
roughness: 0.6,
|
|
});
|
|
const dashGeo = new T.PlaneGeometry(0.18, 2.2);
|
|
const dashes = new T.Group();
|
|
dashes.name = 'dashes';
|
|
for (let i = 0; i < 12; i++) {
|
|
const d = new T.Mesh(dashGeo, dashMat);
|
|
d.rotation.x = -Math.PI / 2;
|
|
d.position.set(i % 2 === 0 ? -1.6 : 1.6, -1.13, -70 + i * 6);
|
|
dashes.add(d);
|
|
}
|
|
scene.add(dashes);
|
|
|
|
// Chevron strips: wide faint chevrons across the lane that stream with
|
|
// the dashes — road texture with no texture map. Same scroll loop.
|
|
const chevMat = new T.MeshBasicMaterial({
|
|
color: new T.Color(0.35, 0.38, 0.5), transparent: true, opacity: 0.28,
|
|
depthWrite: false,
|
|
});
|
|
const chevGeo = new T.PlaneGeometry(5.6, 0.5);
|
|
const chevrons = new T.Group();
|
|
chevrons.name = 'chevrons';
|
|
for (let i = 0; i < 8; i++) {
|
|
const c = new T.Mesh(chevGeo, chevMat);
|
|
c.rotation.x = -Math.PI / 2;
|
|
c.position.set(0, -1.14, -72 + i * 10);
|
|
chevrons.add(c);
|
|
}
|
|
scene.add(chevrons);
|
|
|
|
// Wind streaks: faint long quads at the frame edges rushing past in
|
|
// chase mode — pure speed read, no geometry to hit. Billboarded
|
|
// manually each frame (see update); analytic scroll like everything.
|
|
const streakMat = new T.MeshBasicMaterial({
|
|
color: new T.Color(0.5, 0.55, 0.7), transparent: true, opacity: 0.16,
|
|
depthWrite: false, side: T.DoubleSide,
|
|
});
|
|
const streakGeo = new T.PlaneGeometry(0.06, 7);
|
|
const streaks = new T.Group();
|
|
streaks.name = 'streaks';
|
|
const streakSeeds = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const s = new T.Mesh(streakGeo, streakMat);
|
|
const side = i % 2 === 0 ? -1 : 1;
|
|
s.position.set(side * (5.5 + (i % 5)), 1.5 + ((i * 37) % 40) / 10, -60 + i * 7);
|
|
s.userData.baseX = s.position.x;
|
|
s.userData.baseY = s.position.y;
|
|
streaks.add(s);
|
|
streakSeeds.push(i * 0.7);
|
|
}
|
|
scene.add(streaks);
|
|
|
|
// Roadside posts: the stuff that streams past — alternating near/far
|
|
// sentinels off both shoulders, palette-lit, so chase speed reads even
|
|
// with eyes closed to the dashes. Analytic scroll (see update).
|
|
const postMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.08, 0.08, 0.09),
|
|
emissive: new T.Color(0.4, 0.5, 0.75),
|
|
emissiveIntensity: 0.55,
|
|
roughness: 0.7,
|
|
});
|
|
const postGeo = new T.BoxGeometry(0.28, 2.6, 0.28);
|
|
const capGeo = new T.BoxGeometry(0.4, 0.22, 0.4);
|
|
const posts = new T.Group();
|
|
posts.name = 'posts';
|
|
for (let i = 0; i < 14; i++) {
|
|
const g = new T.Group();
|
|
const side = i % 2 === 0 ? -1 : 1;
|
|
const near = i % 4 < 2;
|
|
const pole = new T.Mesh(postGeo, postMat);
|
|
const cap = new T.Mesh(capGeo, postMat);
|
|
cap.position.y = 1.35;
|
|
g.add(pole); g.add(cap);
|
|
// Far shoulder: angular velocity falls with distance, so pushed-out
|
|
// posts glide instead of whipping past (measured flip-by at ±4.6).
|
|
g.position.set(side * (near ? 7 : 12), 0.15, -78 + i * 6);
|
|
posts.add(g);
|
|
}
|
|
scene.add(posts);
|
|
|
|
// Lane edge glow: two long emissive strips so the road reads as a
|
|
// road even when the asphalt is near-black.
|
|
const edgeMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.05, 0.05, 0.06),
|
|
emissive: new T.Color(0.5, 0.55, 0.8),
|
|
emissiveIntensity: 0.5,
|
|
roughness: 0.7,
|
|
});
|
|
const edgeGeo = new T.PlaneGeometry(0.12, 120);
|
|
const edges = [];
|
|
for (const ex of [-3.2, 3.2]) {
|
|
const edge = new T.Mesh(edgeGeo, edgeMat);
|
|
edge.rotation.x = -Math.PI / 2;
|
|
edge.position.set(ex, -1.12, -30);
|
|
scene.add(edge);
|
|
edges.push(edge);
|
|
}
|
|
|
|
// Roadside set dressing: houses, road signs, trees, and one seeded
|
|
// billboard that can carry the viz. All primitives, all palette-lit,
|
|
// all scrolling with the same analytic span as the dashes (see
|
|
// update) — the world streams past in chase mode, holds in parked.
|
|
// Seeded per arrangement so a story jump re-hangs the roadside too.
|
|
const dressMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.07, 0.07, 0.08),
|
|
roughness: 0.85,
|
|
metalness: 0.05,
|
|
});
|
|
const leafMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.05, 0.09, 0.05),
|
|
emissive: new T.Color(0.1, 0.3, 0.12),
|
|
emissiveIntensity: 0.35,
|
|
roughness: 0.9,
|
|
});
|
|
const signMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.06, 0.06, 0.07),
|
|
emissive: new T.Color(0.85, 0.7, 0.3),
|
|
emissiveIntensity: 0.6,
|
|
roughness: 0.6,
|
|
});
|
|
const billMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.02, 0.02, 0.02),
|
|
emissive: new T.Color(1, 1, 1),
|
|
emissiveIntensity: 1.1,
|
|
roughness: 0.6,
|
|
});
|
|
const roadside = new T.Group();
|
|
roadside.name = 'roadside';
|
|
// 12 slots spaced 8 over [-84, +4]: house / sign / trees / billboard.
|
|
// Billboard appears at most once, on one side, placed mid-span so it
|
|
// is on screen for a stretch, not a flash.
|
|
const billSlot = 3 + Math.floor(rng() * 6);
|
|
const billSide = rng() < 0.5 ? -1 : 1;
|
|
const roadsideMeta = [];
|
|
const roadsidePopulation = 12;
|
|
for (let i = 0; i < roadsidePopulation; i++) {
|
|
const g = new T.Group();
|
|
const side = i % 2 === 0 ? -1 : 1;
|
|
const kind = i === billSlot ? 'billboard'
|
|
: i % 3 === 0 ? 'house' : i % 3 === 1 ? 'sign' : 'trees';
|
|
// Dressing lives far out (houses ±13-18, signs/trees ±9-13): near
|
|
// items cross the frame in a blink, far items linger and let the
|
|
// camera actually see them. Scaled up ~1.3x with distance so they
|
|
// don't shrink to dots.
|
|
const bx = side * (kind === 'house' ? 13 + rng() * 5 : 9 + rng() * 4);
|
|
const farK = kind === 'house' ? 1.35 : 1.2;
|
|
if (kind === 'house') {
|
|
// Box + prism roof + one lit window quad.
|
|
const w = 2.6 + rng() * 1.6, h = 1.8 + rng() * 1.2, d = 2.2 + rng() * 1.2;
|
|
const walls = new T.Mesh(new T.BoxGeometry(w, h, d), dressMat);
|
|
walls.position.y = h / 2 - 1.15;
|
|
g.add(walls);
|
|
const roof = new T.Mesh(new T.ConeGeometry(Math.max(w, d) * 0.72, 1.1 + rng() * 0.6, 4), dressMat);
|
|
roof.position.y = h - 1.15 + 0.55;
|
|
roof.rotation.y = Math.PI / 4;
|
|
g.add(roof);
|
|
const win = new T.Mesh(new T.PlaneGeometry(0.5 + rng() * 0.4, 0.6),
|
|
new T.MeshStandardMaterial({
|
|
color: new T.Color(0.05, 0.05, 0.05),
|
|
emissive: new T.Color(0.95, 0.75, 0.4),
|
|
emissiveIntensity: 0.9, roughness: 0.6,
|
|
}));
|
|
win.position.set((rng() - 0.5) * w * 0.5, 0.1 + rng() * 0.5, d / 2 + 0.01);
|
|
if (side > 0) win.rotation.y = Math.PI;
|
|
g.add(win);
|
|
g.userData.winMat = win.material;
|
|
} else if (kind === 'sign') {
|
|
// Pole + reflective diamond.
|
|
const pole = new T.Mesh(new T.CylinderGeometry(0.06, 0.06, 2.2, 6), dressMat);
|
|
pole.position.y = -0.05;
|
|
g.add(pole);
|
|
const diamond = new T.Mesh(new T.BoxGeometry(0.7, 0.7, 0.06), signMat);
|
|
diamond.position.y = 1.25;
|
|
diamond.rotation.z = Math.PI / 4;
|
|
diamond.rotation.y = side > 0 ? -0.35 : 0.35;
|
|
g.add(diamond);
|
|
} else if (kind === 'billboard') {
|
|
// Two posts + big panel carrying the viz (bound in
|
|
// bindVizTexture alongside the radio). Faces the road.
|
|
const pGeo = new T.CylinderGeometry(0.12, 0.12, 3.4, 6);
|
|
for (const px of [-1.4, 1.4]) {
|
|
const post = new T.Mesh(pGeo, dressMat);
|
|
post.position.set(px, 0.55, 0);
|
|
g.add(post);
|
|
}
|
|
const panel = new T.Mesh(new T.PlaneGeometry(4.4, 2.5), billMat);
|
|
panel.position.y = 3.1;
|
|
// Faces rearward (+z, toward the chase camera and the car it
|
|
// follows): a billboard is for approaching traffic, and our
|
|
// traffic approaches from +z. Slight toe-in per side so it
|
|
// isn't a flat wall from the shoulder.
|
|
panel.rotation.y = side < 0 ? 0.18 : -0.18;
|
|
panel.name = 'billboard';
|
|
g.add(panel);
|
|
g.userData.billPanel = panel;
|
|
} else {
|
|
// Trees: 2-3 cones on cylinders, dark green glow.
|
|
const n = 2 + Math.floor(rng() * 2);
|
|
for (let k = 0; k < n; k++) {
|
|
const th = 1.6 + rng() * 1.6;
|
|
const trunk = new T.Mesh(new T.CylinderGeometry(0.09, 0.12, 0.8, 5), dressMat);
|
|
trunk.position.set((rng() - 0.5) * 2.2, -0.75, (rng() - 0.5) * 2.2);
|
|
const crown = new T.Mesh(new T.ConeGeometry(0.65 + rng() * 0.4, th, 7), leafMat);
|
|
crown.position.copy(trunk.position);
|
|
crown.position.y += 0.4 + th / 2;
|
|
g.add(trunk); g.add(crown);
|
|
}
|
|
}
|
|
g.position.set(bx, 0, -84 + i * 8);
|
|
g.scale.setScalar(farK);
|
|
roadside.add(g);
|
|
roadsideMeta.push({ kind, side, baseX: bx });
|
|
}
|
|
scene.add(roadside);
|
|
|
|
// The car: a group at the origin facing -z (driving away from camera
|
|
// start). Body panels come from the track's actor; chassis/cabin/wheels
|
|
// are seeded primitives so every song's vehicle differs.
|
|
const car = new T.Group();
|
|
car.name = 'car';
|
|
scene.add(car);
|
|
|
|
// Cabin glow + headlights so the car reads at night.
|
|
const ambient = new T.AmbientLight(0xffffff, 0.5);
|
|
scene.add(ambient);
|
|
const key = new T.DirectionalLight(0xcfe0ff, 1.0);
|
|
key.position.set(3.5, 5.0, 6.0);
|
|
key.castShadow = false;
|
|
scene.add(key);
|
|
const tail = new T.PointLight(0xff2a1a, 2.5, 10);
|
|
tail.position.set(0, 0.2, 2.6);
|
|
scene.add(tail);
|
|
|
|
scene.background = null;
|
|
scene.fog = new T.Fog(0x07070c, 14, 46);
|
|
scene.userData.transparentBackground = true;
|
|
|
|
// Song sky over the night road — radius 90 clears the 120-unit road.
|
|
const sky = buildSky(T, { radius: 90 });
|
|
scene.add(sky.mesh);
|
|
|
|
// Passenger container — actor riders seated in the cabin.
|
|
const crew = new T.Group();
|
|
crew.name = 'crew';
|
|
car.add(crew);
|
|
|
|
return {
|
|
car, road, roadMat, dashes, dashMat, chevrons, chevMat, streaks, streakMat, streakSeeds,
|
|
posts, postMat, edges, edgeMat,
|
|
roadside, roadsideMeta, dressMat, leafMat, signMat, billMat,
|
|
skyMesh: sky.mesh, skyMat: sky.mat,
|
|
crew,
|
|
ambient, key, tail,
|
|
radioMat: null, radioMesh: null, billPanel: null,
|
|
bodyMeshes: [],
|
|
seed,
|
|
venueSpec: venueSpec || null,
|
|
builtKey: null,
|
|
popCount: 0,
|
|
// Chase (road scrolls, world rushes) vs stationary (parked, camera
|
|
// does all the moving). Drawn once per video from the venue seed.
|
|
chase: (rng() < 0.62),
|
|
bodyHue: rng(),
|
|
};
|
|
},
|
|
|
|
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, THREE }) {
|
|
const T = THREE;
|
|
const t = timeline.time;
|
|
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]];
|
|
const identity = personality ? personality.identity : null;
|
|
const shape = personality ? personality.shape : null;
|
|
const space = personality ? personality.space : null;
|
|
const style = personality ? personality.style : null;
|
|
|
|
const shapeTilt = shape ? shape.tilt : 0;
|
|
const styleWeight = style ? style.lineWeight : 0.5;
|
|
const spaceHorizon = space ? space.horizon : 0.5;
|
|
void shapeTilt;
|
|
|
|
const vs = venueSpec || instance.venueSpec;
|
|
const vState = venueState || instance.venueState || null;
|
|
if (vState) instance.venueState = vState;
|
|
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
|
|
instance.venueArrangement = arrangement;
|
|
|
|
const c0 = pal[0];
|
|
const fieldLight = vState ? vState.light : 0.7;
|
|
// Asphalt takes hue from the palette, level from the section — a night
|
|
// road you can still see.
|
|
const tMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
|
|
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
|
|
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
|
|
const roadLevel = (0.16 + fieldLight * 0.24) * comp;
|
|
instance.roadMat.color.setRGB(
|
|
(c0[0] / tMax) * roadLevel, (c0[1] / tMax) * roadLevel, (c0[2] / tMax) * roadLevel);
|
|
if (instance.key) instance.key.intensity = 0.7 + fieldLight * 1.1;
|
|
if (instance.ambient) instance.ambient.intensity = 0.4 + fieldLight * 0.5;
|
|
if (instance.tail) instance.tail.intensity = 1.2 + fieldLight * 2;
|
|
// Song sky + matched fog, same contract as chamber/stage.
|
|
paintSky(instance.skyMat, {
|
|
palette: pal, light: fieldLight, style,
|
|
seed: instance.seed,
|
|
});
|
|
if (scene.fog) {
|
|
const fogMul = vState ? vState.fog : 0.35;
|
|
const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
|
|
scene.fog.near = fogDepth;
|
|
scene.fog.far = fogDepth + 30;
|
|
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
|
|
if (mid) scene.fog.color.copy(mid);
|
|
else scene.fog.color.setRGB(c0[0] * 0.05, c0[1] * 0.05, c0[2] * 0.07);
|
|
}
|
|
|
|
// --- vehicle body (built once; arrangement re-hangs it) ---
|
|
const popKey = vs
|
|
? `${vs.seed}:ride:${identity ? identity.cast.protagonist.sides : 0}:${actorSpec ? actorSpec.seed : 0}:a${arrangement}`
|
|
: `no-vs:${instance.seed}:a${arrangement}`;
|
|
if ((instance.builtKey !== popKey || !instance.popCount) && identity && actorSpec && actorSpec.form) {
|
|
// Clear previous build.
|
|
for (const m of instance.bodyMeshes) {
|
|
instance.car.remove(m);
|
|
m.traverse((o) => {
|
|
if (o.isMesh) {
|
|
if (o.geometry) o.geometry.dispose();
|
|
if (o.material) {
|
|
const mats = Array.isArray(o.material) ? o.material : [o.material];
|
|
mats.forEach((mm) => mm.dispose());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
instance.bodyMeshes = [];
|
|
while (instance.crew.children.length) instance.crew.remove(instance.crew.children[0]);
|
|
|
|
const rng = mulberry(((vs ? vs.seed : instance.seed) ^ Math.imul(arrangement + 1, 0x9e3779b9)) >>> 0);
|
|
const cc = (i) => pal[(vs && vs.paletteMap ? vs.paletteMap[i % vs.paletteMap.length] : i) % pal.length] || pal[0];
|
|
|
|
// Chassis: the track's monolith body, squashed wide, low and long.
|
|
// Same character as the viz hero, so the car reads as this track's
|
|
// vehicle rather than a stock model.
|
|
try {
|
|
const built = actorToGeometry(actorSpec, identity, T);
|
|
const parts = built.children.slice();
|
|
const body = new T.Group();
|
|
body.name = 'chassis';
|
|
for (let i = 0; i < Math.min(3, parts.length); i++) {
|
|
const src = parts[(i + arrangement) % parts.length];
|
|
const col = cc(i);
|
|
const m = new T.Mesh(src.geometry, new T.MeshStandardMaterial({
|
|
color: new T.Color(col[0] * 0.9, col[1] * 0.9, col[2] * 0.9),
|
|
emissive: new T.Color(col[0] * 0.15, col[1] * 0.15, col[2] * 0.15),
|
|
roughness: 0.3 + styleWeight * 0.15,
|
|
metalness: 0.3,
|
|
transparent: opacity < 0.999, opacity,
|
|
}));
|
|
m.userData.partIndex = src.userData.partIndex ?? i;
|
|
m.scale.set(1.5, 0.55, 2.4);
|
|
m.position.set(0, -0.15 + i * 0.12, -0.4 + i * 0.35);
|
|
m.rotation.y = (rng() - 0.5) * 0.6;
|
|
body.add(m);
|
|
}
|
|
instance.car.add(body);
|
|
instance.bodyMeshes.push(body);
|
|
} catch { /* body optional; cabin + wheels still read as a car */ }
|
|
|
|
// Cabin: low dark-glass tub so passenger heads clear the roofline
|
|
// from rear angles — a tall box hid the whole crew.
|
|
const glassCol = cc(3);
|
|
const cabin = new T.Mesh(
|
|
new T.BoxGeometry(1.7, 0.4, 2.1),
|
|
new T.MeshStandardMaterial({
|
|
color: new T.Color(0.04, 0.045, 0.06),
|
|
emissive: new T.Color(glassCol[0] * 0.12, glassCol[1] * 0.12, glassCol[2] * 0.14),
|
|
roughness: 0.15, metalness: 0.7,
|
|
transparent: opacity < 0.999, opacity,
|
|
}));
|
|
cabin.position.set(0, 0.5, -0.2);
|
|
instance.car.add(cabin);
|
|
instance.bodyMeshes.push(cabin);
|
|
|
|
// Wheels: four dark torus + hub, spin with speed (see below).
|
|
const wheelGeo = new T.TorusGeometry(0.34, 0.13, 8, 18);
|
|
const hubGeo = new T.CylinderGeometry(0.2, 0.2, 0.1, 10);
|
|
hubGeo.rotateX(Math.PI / 2);
|
|
instance.wheels = [];
|
|
for (const [wx, wz] of [[-0.95, 1.05], [0.95, 1.05], [-0.95, -1.35], [0.95, -1.35]]) {
|
|
const wcol = cc(1);
|
|
const wheel = new T.Group();
|
|
const tire = new T.Mesh(wheelGeo, new T.MeshStandardMaterial({
|
|
color: new T.Color(0.03, 0.03, 0.035), roughness: 0.9,
|
|
}));
|
|
const hub = new T.Mesh(hubGeo, new T.MeshStandardMaterial({
|
|
color: new T.Color(wcol[0], wcol[1], wcol[2]),
|
|
emissive: new T.Color(wcol[0] * 0.25, wcol[1] * 0.25, wcol[2] * 0.25),
|
|
roughness: 0.4, metalness: 0.6,
|
|
}));
|
|
wheel.add(tire); wheel.add(hub);
|
|
// Torus lies in the XY plane (axle along z) — rotate 90° about
|
|
// Y so the axle runs across the car (x) and the tire rolls
|
|
// along z. Spin is then rotation.x on the group... which the
|
|
// yaw would fight — so spin the tire/hub meshes, not the group.
|
|
wheel.rotation.y = Math.PI / 2;
|
|
wheel.position.set(wx, -0.72, wz);
|
|
wheel.userData.tire = tire;
|
|
wheel.userData.hub = hub;
|
|
instance.car.add(wheel);
|
|
instance.bodyMeshes.push(wheel);
|
|
instance.wheels.push(wheel);
|
|
}
|
|
|
|
// Radio: the viz screen, on the dash ahead of the crew, facing BACK
|
|
// at the passengers (+z face toward the cabin — a screen is for
|
|
// watching, and the audience that can see it rides inside). The car
|
|
// drives toward -z, so the radio sits forward (z < crew) with its
|
|
// face rearward. A small dash unit, NOT a billboard. EmissiveMap
|
|
// bound per-frame by bindVizTexture, emissive stays near-white so
|
|
// the viz keeps its own colours. From a front chase angle it reads
|
|
// dark — correct, a dash screen is not for oncoming traffic.
|
|
const radioMat = new T.MeshStandardMaterial({
|
|
color: new T.Color(0.02, 0.02, 0.02),
|
|
emissive: new T.Color(1, 1, 1),
|
|
emissiveIntensity: 1.4,
|
|
roughness: 0.5, metalness: 0.2,
|
|
});
|
|
const dash = new T.Mesh(
|
|
new T.BoxGeometry(0.85, 0.3, 0.28),
|
|
new T.MeshStandardMaterial({ color: new T.Color(0.05, 0.05, 0.06), roughness: 0.6 }));
|
|
dash.position.set(0, 0.72, -0.95);
|
|
instance.car.add(dash);
|
|
instance.bodyMeshes.push(dash);
|
|
const radio = new T.Mesh(new T.PlaneGeometry(0.62, 0.35), radioMat);
|
|
radio.position.set(0, 0.82, -0.8);
|
|
radio.rotation.x = 0.15;
|
|
radio.rotation.y = 0;
|
|
radio.name = 'radio';
|
|
instance.car.add(radio);
|
|
instance.radioMat = radioMat;
|
|
instance.radioMesh = radio;
|
|
instance.bodyMeshes.push(radio);
|
|
|
|
// Headlight cones: two small translucent wedges so chase mode
|
|
// reads. Kept short and faint — long cones filled the frame.
|
|
const beamMat = new T.MeshBasicMaterial({
|
|
color: new T.Color(0.75, 0.8, 0.9), transparent: true, opacity: 0.05,
|
|
depthWrite: false, side: T.DoubleSide,
|
|
});
|
|
for (const bx of [-0.55, 0.55]) {
|
|
const beamGeo = new T.ConeGeometry(0.45, 3.5, 12, 1, true);
|
|
beamGeo.translate(0, -1.75, 0);
|
|
// Tip at origin, base forward (-z, direction of travel):
|
|
// rotateX(+PI/2) maps -y to -z. The old -PI/2 aimed them
|
|
// rearward over the cabin, 180° off.
|
|
beamGeo.rotateX(Math.PI / 2 - 0.06);
|
|
const beam = new T.Mesh(beamGeo, beamMat);
|
|
beam.position.set(bx, -0.1, -2.2);
|
|
instance.car.add(beam);
|
|
instance.bodyMeshes.push(beam);
|
|
}
|
|
|
|
// Passengers: actor riders — one part of the track's body each,
|
|
// dealt round like chamber memories, seated in a row in the cabin.
|
|
// Same limbs as the viz hero, so the crew reads as its passengers.
|
|
let bodyParts = null;
|
|
try {
|
|
const body = actorToGeometry(actorSpec, identity, T);
|
|
bodyParts = body.children.slice();
|
|
} catch { bodyParts = null; }
|
|
const count = vs && vs.population ? Math.max(2, Math.min(4, vs.population.count | 0)) : 2;
|
|
for (let i = 0; i < count; i++) {
|
|
let m;
|
|
if (bodyParts && bodyParts.length) {
|
|
const src = bodyParts[(i + arrangement) % bodyParts.length];
|
|
const col = cc(i + 1);
|
|
m = new T.Mesh(src.geometry, new T.MeshStandardMaterial({
|
|
color: new T.Color(col[0], col[1], col[2]),
|
|
emissive: new T.Color(col[0] * 0.3, col[1] * 0.3, col[2] * 0.3),
|
|
emissiveIntensity: 1.0,
|
|
roughness: 0.5, metalness: 0.1,
|
|
transparent: opacity < 0.999, opacity,
|
|
}));
|
|
m.userData.partIndex = src.userData.partIndex ?? 0;
|
|
} else {
|
|
const col = cc(i + 1);
|
|
m = new T.Mesh(
|
|
new T.SphereGeometry(0.22, 12, 10),
|
|
new T.MeshStandardMaterial({
|
|
color: new T.Color(col[0], col[1], col[2]),
|
|
emissive: new T.Color(col[0] * 0.3, col[1] * 0.3, col[2] * 0.3),
|
|
roughness: 0.5,
|
|
}));
|
|
}
|
|
const g = new T.Group();
|
|
g.add(m);
|
|
// Seat row: spread across the cabin width, heads above the glass.
|
|
const fx = (i - (count - 1) / 2) * 0.62 + (rng() - 0.5) * 0.1;
|
|
g.scale.setScalar(0.5);
|
|
g.position.set(fx, 0.78, -0.25 + (rng() - 0.5) * 0.3);
|
|
g.rotation.y = Math.PI + (rng() - 0.5) * 0.5;
|
|
g.userData.baseY = g.position.y;
|
|
g.userData.index = i;
|
|
g.userData.phase = rng() * Math.PI * 2;
|
|
instance.crew.add(g);
|
|
}
|
|
|
|
instance.builtKey = popKey;
|
|
instance.popCount = count;
|
|
}
|
|
|
|
// --- motion: analytic, no integration ---
|
|
// Drive speed: the sampled param floored so even calm songs roll, then
|
|
// scaled by section energy (venueState is interpolated, so this swells
|
|
// into drops rather than stepping). Parked mode rolls at ~zero.
|
|
// Deliberately NOT modulated by instantaneous bands: multiplying the
|
|
// scroll clock by bandLow fast-forwards the world on every loud hit
|
|
// and rewinds it in quiet moments — the car surged back and forth
|
|
// with the volume. Speed-up/slow-down lives in the smooth energy arc.
|
|
const energyK = vState ? (0.55 + vState.light * 0.65) : 1;
|
|
const speed = (0.35 + (params.speed || 0.7) * 0.75) * energyK * (instance.chase ? 1 : 0);
|
|
const rate = 1;
|
|
// Passerby: the camera is planted, so the CAR does the travelling —
|
|
// far +z at cue start, past the lens mid-cue, far -z at the end — and
|
|
// the world holds still around it. (A scrolling world under a driving
|
|
// car would double the motion and read as a slide projector.)
|
|
// Billboard: same planted grammar, but aimed at the roadside
|
|
// billboard while the car drives through in front of it.
|
|
const shot = framing && framing.venueShot ? framing.venueShot : null;
|
|
const passerby = !!shot && shot.target === 'passerby';
|
|
const billboard = !!shot && shot.target === 'billboard';
|
|
const driveT = (passerby || billboard) && typeof shot.drive === 'number'
|
|
? Math.max(0, Math.min(1, shot.drive)) : null;
|
|
// World scroll: dashes, posts, roadside, chevrons and streaks stream
|
|
// past toward +z (the car drives toward -z, so the world comes at the
|
|
// camera). Frozen during passerby (see above) and during billboard —
|
|
// except the billboard slot itself, which keeps its analytic scroll
|
|
// (at half rate: it's a distant parallax layer, and holding it still
|
|
// while aimed at it would read as parked). Parked mode keeps a 12%
|
|
// idle creep plus a floor so the world never reads dead — a frozen
|
|
// world with a crawling camera measured as "no movement at all".
|
|
// Analytic in t (not += per frame): a seek must land on the same
|
|
// positions as playback. The chase rate reads as driving at a glance
|
|
// (~3x the original).
|
|
{
|
|
const cruise = (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate;
|
|
const dz = (passerby || billboard) ? 0 : cruise;
|
|
const kids = instance.dashes.children;
|
|
for (let i = 0; i < kids.length; i++) {
|
|
const z0 = -70 + i * 6;
|
|
const span = 76;
|
|
const z = z0 + t * dz;
|
|
kids[i].position.z = z0 + (((z - z0) % span) + span) % span;
|
|
}
|
|
const pk = instance.posts.children;
|
|
for (let i = 0; i < pk.length; i++) {
|
|
const z0 = -78 + i * 6;
|
|
const span = 84;
|
|
const z = z0 + t * dz;
|
|
pk[i].position.z = z0 + (((z - z0) % span) + span) % span;
|
|
}
|
|
// Roadside set dressing streams on the same clock: 12 slots spaced
|
|
// 8 over [-84, +12]. Same wrap math, so houses/signs/trees and the
|
|
// billboard hold formation with the posts. Frozen whole during
|
|
// billboard shots (the snap lands under the cut, like passerby) —
|
|
// the motion in a billboard shot comes from the car, not the world.
|
|
const rs = instance.roadside.children;
|
|
// During a billboard shot the board is seated at a showcase spot
|
|
// in view of the plant below — analytic in the shot flag, so no
|
|
// state and no mid-shot drift. (The freeze already snaps the world
|
|
// to base under the cut; seating the subject somewhere worth
|
|
// looking at costs nothing extra.)
|
|
const seatBill = billboard ? billSlotIndex(instance) : -1;
|
|
for (let i = 0; i < rs.length; i++) {
|
|
if (i === seatBill) { rs[i].position.z = -8; continue; }
|
|
const z0 = -84 + i * 8;
|
|
const span = 96;
|
|
const z = z0 + t * dz;
|
|
rs[i].position.z = z0 + (((z - z0) % span) + span) % span;
|
|
}
|
|
// Chevrons ride the same clock, wider spacing.
|
|
const ch = instance.chevrons.children;
|
|
for (let i = 0; i < ch.length; i++) {
|
|
const z0 = -72 + i * 10;
|
|
const span = 80;
|
|
const z = z0 + t * dz;
|
|
ch[i].position.z = z0 + (((z - z0) % span) + span) % span;
|
|
}
|
|
// Wind streaks rush past at ~1.6x road speed, hugging the frame
|
|
// edges; faint in parked mode, strong at speed.
|
|
for (let i = 0; i < instance.streaks.children.length; i++) {
|
|
const s = instance.streaks.children[i];
|
|
const z0 = -60 + i * 7;
|
|
const span = 70;
|
|
const z = z0 + t * dz * 1.6;
|
|
s.position.z = z0 + (((z - z0) % span) + span) % span;
|
|
s.position.x = s.userData.baseX + Math.sin(t * 0.7 + instance.streakSeeds[i]) * 0.4;
|
|
// Face the camera: yaw-only billboard around the streak's long
|
|
// (y) axis toward the camera position.
|
|
const dx = camera.position.x - s.position.x;
|
|
const dzz = camera.position.z - s.position.z;
|
|
s.rotation.y = Math.atan2(dx, dzz);
|
|
}
|
|
instance.streakMat.opacity = instance.chase
|
|
? 0.05 + Math.min(0.2, speed * 0.12)
|
|
: 0.04;
|
|
}
|
|
// Suspension + weave: the car floats on its springs (two detuned sines
|
|
// so it never loops visibly), leans into a slow lane-change sweep, and
|
|
// shudders with section energy — never the instantaneous band, which
|
|
// jerks the body on every hit like the old scroll clock did. All
|
|
// analytic f(t): weave period ~47 s so a song holds two crossings;
|
|
// parked mode nearly still.
|
|
const shudder = vState ? (0.5 + vState.light * 0.7) : 1;
|
|
const bob = (Math.sin(t * 2.1) * 0.5 + Math.sin(t * 3.7 + 1.3) * 0.5)
|
|
* 0.045 * shudder
|
|
// Idle engine shake: parked cars shiver faintly at ~9 Hz so a
|
|
// stationary frame never sits pixel-dead.
|
|
+ (instance.chase ? 0 : Math.sin(t * 9.3) * 0.006);
|
|
const sweepAmp = instance.chase ? 1.1 : 0.15;
|
|
const sweep = Math.sin(t * 0.133 + instance.seed * 0.001);
|
|
instance.car.position.x = sweep * sweepAmp;
|
|
// Passerby/billboard drive-through: +12 (far, small) to -14 (far,
|
|
// gone) eased across the cue. Otherwise the car lives at the origin.
|
|
if ((passerby || billboard) && driveT !== null) {
|
|
const e = driveT * driveT * (3 - 2 * driveT);
|
|
instance.car.position.z = 12 + (-14 - 12) * e;
|
|
} else {
|
|
instance.car.position.z = 0;
|
|
}
|
|
instance.car.position.y = bob * (instance.chase ? 1 : 0.3);
|
|
// Lean INTO the sweep (roll) and yaw with it, like a lane change.
|
|
const leanK = instance.chase ? 1 : 0.2;
|
|
instance.car.rotation.z = -Math.cos(t * 0.133 + instance.seed * 0.001) * 0.035 * leanK
|
|
+ Math.sin(t * 0.9) * 0.008 * leanK;
|
|
instance.car.rotation.y = Math.cos(t * 0.133 + instance.seed * 0.001) * 0.045 * (instance.chase ? 1 : 0.2);
|
|
instance.car.rotation.x = Math.sin(t * 1.7 + 0.6) * 0.012 * (instance.chase ? 1 : 0.3);
|
|
if (instance.wheels) {
|
|
// Rolling spin on the meshes (local axes), not the yawed group:
|
|
// tire about its hole axis (local z), hub about its cylinder
|
|
// axis (local x after the geometry pre-rotation). During a
|
|
// passerby the car covers ~26 units a cue, so the wheels spin at
|
|
// drive speed rather than cruise speed.
|
|
const spin = (passerby || billboard) ? t * 14 : t * speed * 6 * rate;
|
|
for (const w of instance.wheels) {
|
|
if (w.userData.tire) w.userData.tire.rotation.z = spin;
|
|
if (w.userData.hub) w.userData.hub.rotation.z = spin;
|
|
}
|
|
}
|
|
// Passengers bob gently, out of phase — analytic f(t), seek-stable.
|
|
// popMul gates how many ride (setup: driver + 1; climax: full car) as
|
|
// a SMOOTH opacity fade, never a visible toggle: the interpolated
|
|
// venueState crosses thresholds at arbitrary mid-section moments, and
|
|
// hard cuts there read as random flashes. Pure f(threshold, index).
|
|
const popMul = vState ? vState.popMul : 1;
|
|
const crewLevel = 0.35 + popMul * 0.75;
|
|
for (const g of instance.crew.children) {
|
|
const idx = g.userData.index || 0;
|
|
g.position.y = (g.userData.baseY || 0) + Math.sin(t * 0.5 + (g.userData.phase || 0)) * 0.03;
|
|
g.rotation.y = Math.PI + Math.sin(t * 0.18 + idx * 1.3) * 0.22;
|
|
const showIndex = idx / Math.max(1, instance.popCount);
|
|
const fade = Math.max(0, Math.min(1, (crewLevel - showIndex) / 0.12));
|
|
g.visible = opacity > 0.01 && fade > 0.01;
|
|
for (const mesh of g.children) {
|
|
if (mesh.isMesh) {
|
|
mesh.material.opacity = opacity * fade;
|
|
mesh.material.transparent = opacity * fade < 0.999;
|
|
if (vState && mesh.material.emissiveIntensity !== undefined) {
|
|
mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Roadside density is fixed per arrangement (re-hung only on story
|
|
// jumps, under a camera cut) — gating it by the interpolated popMul
|
|
// toggled houses mid-section at arbitrary moments, which reads as
|
|
// random flashes. Sparse arrangements show sentinels, dense ones the
|
|
// whole town; light/fog still arc within each.
|
|
if (instance.roadside) {
|
|
const n = instance.roadside.children.length;
|
|
const dense = (arrangement % 2 === 1);
|
|
const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45));
|
|
// A billboard shot with a hidden billboard is an empty road: the
|
|
// board's own slot is always on while it is the subject.
|
|
const forced = billboard ? billSlotIndex(instance) : -1;
|
|
for (let i = 0; i < n; i++) {
|
|
instance.roadside.children[i].visible = opacity > 0.001 && (i < showUpTo || i === forced);
|
|
}
|
|
}
|
|
// Dressing takes the palette tint so houses/trees belong to the song;
|
|
// the billboard glows with the section light (its IMAGE is the viz).
|
|
if (instance.dressMat) {
|
|
const tintM = 0.5 + fieldLight * 0.7;
|
|
instance.dressMat.color.setRGB(c0[0] * 0.14 * tintM + 0.03, c0[1] * 0.14 * tintM + 0.03, c0[2] * 0.16 * tintM + 0.04);
|
|
}
|
|
if (instance.billMat) {
|
|
const l = vState ? vState.light : 1;
|
|
instance.billMat.emissive.setRGB(1, 1, 1);
|
|
instance.billMat.emissiveIntensity = (params.light || 0.85) * (0.7 + l * 0.7);
|
|
}
|
|
// Radio glow follows the section light; the IMAGE is the viz texture.
|
|
if (instance.radioMat) {
|
|
const l = vState ? vState.light : 1;
|
|
instance.radioMat.emissive.setRGB(1, 1, 1);
|
|
instance.radioMat.emissiveIntensity = (params.light || 0.85) * (0.7 + l * 0.7);
|
|
}
|
|
// Ground follows horizon like the stage field.
|
|
instance.road.position.y = -1.15 + (spaceHorizon - 0.5) * 0.6;
|
|
|
|
// --- camera: this venue's own rig (directCamera). The chase unit
|
|
// orbits the car once per video while pushing in, tracking the car so
|
|
// the orbit never loses it. Moved here from Compositor so the venue
|
|
// owns its whole grammar; the shared rig keeps serving every other
|
|
// model layer. All analytic f(time, progress, seed) — seek stable.
|
|
driveRideCamera({ instance, camera, timeline, framing, personality, venueState: vState, THREE: T });
|
|
|
|
void framing; void params;
|
|
},
|
|
};
|
|
|
|
/**
|
|
* The ride's own camera rig. Same grammar the shared rig used to serve for
|
|
* this archetype, owned here now: traverse body (continuous, monotonic — the
|
|
* thing that arcs) + planned-look head (screen / room) + one orbit per video
|
|
* on top + car tracking so wide swings keep their subject. Jump sections cut
|
|
* the body to the arrangement side. Box-clamped, focus-floored, handheld.
|
|
*/
|
|
function driveRideCamera({ instance, camera, timeline, framing, personality, venueState }) {
|
|
const t = timeline.time;
|
|
const trav = typeof timeline.progress === 'number' ? timeline.progress : 0.5;
|
|
const frame = framing || { scale: 1, shift: [0, 0] };
|
|
const shift = frame.shift || [0, 0];
|
|
const scale = frame.scale || 1;
|
|
const pCam = personality ? personality.camera : null;
|
|
const motion = personality ? personality.motion : null;
|
|
const seed = ((instance.seed >>> 0) || 1);
|
|
const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029);
|
|
const room = 1.1;
|
|
const still = motion ? motion.stillness : 0.5;
|
|
const churn = motion ? motion.churn : 0.25;
|
|
const planned = frame && frame.venueShot ? frame.venueShot : null;
|
|
|
|
const walkDir = s1 >= 0 ? 1 : -1;
|
|
const walkSpan = (1.1 + still * 0.9) * walkDir;
|
|
const walk = (trav - 0.5) * 2 * walkSpan
|
|
+ Math.cos(t * 0.043 + s2 * 4.2) * walkSpan * 0.55
|
|
+ Math.sin(t * 0.23 + s1 * 3.1) * 0.08
|
|
+ Math.sin(t * 0.11 + s2 * 5.0) * 0.06;
|
|
// No journey on this path — drive the whole push off monotonic progress
|
|
// instead (smoother than the old staged+progress split, same travel).
|
|
const push = (trav - 0.5) * 3.8 * room;
|
|
const hx = Math.sin(t * 0.9 + s1 * 7.0) * 0.5 * (0.4 + churn)
|
|
+ Math.sin(t * 0.23 + s2 * 5.0) * 0.5 * (0.4 + churn);
|
|
const hy = Math.cos(t * 0.77 + s2 * 9.0) * 0.5 * (0.4 + churn)
|
|
+ Math.sin(t * 0.31 + s1 * 4.0) * 0.5 * (0.4 + churn);
|
|
const sway = pCam ? pCam.sway : 0.01;
|
|
const swayRate = pCam ? pCam.swayRate : 0.09;
|
|
const lim = { x: 6.0, yLo: -0.2, yHi: 3.2, zLo: -10.0, zHi: 10.0 };
|
|
const isJump = frame.venueJump !== undefined && frame.venueJump !== null;
|
|
|
|
// Passerby: the camera is a planted bystander — static plant, fixed road
|
|
// point, handheld micro only. The car drives through on driveT (see the
|
|
// car block below): far +z at cue start, past the lens mid-cue, far -z at
|
|
// the end. No orbit, no traverse, no tracking corrections — a static
|
|
// camera is the whole point of the shot. (Checked before the generic
|
|
// chase branch below, which would otherwise swallow every planned shot.)
|
|
if (planned && !isJump && planned.target === 'passerby') {
|
|
camera.position.set(
|
|
Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx * 0.5)),
|
|
Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy * 0.5)),
|
|
Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])),
|
|
);
|
|
camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0;
|
|
camera.lookAt(planned.look[0], planned.look[1], planned.look[2]);
|
|
camera.updateMatrixWorld();
|
|
return;
|
|
}
|
|
|
|
// Billboard: same planted grammar, aimed at the seated panel. The board
|
|
// holds at its showcase spot (see above) so the plant is static with
|
|
// handheld micro only; the plant sits on the opposite shoulder so the
|
|
// car crosses between camera and board on its drive-through.
|
|
if (planned && !isJump && planned.target === 'billboard') {
|
|
const bi = billSlotIndex(instance);
|
|
const bg = bi >= 0 ? instance.roadside.children[bi] : null;
|
|
const bx = bg ? bg.position.x : 9;
|
|
// Panel centre in world: group pos + scaled local offset (farK 1.2).
|
|
const panelPos = [bx, 3.1 * 1.2, -8];
|
|
const side = bx >= 0 ? -1 : 1;
|
|
camera.position.set(
|
|
Math.max(-lim.x, Math.min(lim.x, side * 4.2 + hx * 0.5)),
|
|
Math.max(lim.yLo, Math.min(lim.yHi, 1.5 + hy * 0.5)),
|
|
Math.max(lim.zLo, Math.min(lim.zHi, -2)),
|
|
);
|
|
camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0;
|
|
camera.lookAt(panelPos[0], panelPos[1], panelPos[2]);
|
|
camera.updateMatrixWorld();
|
|
return;
|
|
}
|
|
|
|
if (planned && !isJump) {
|
|
// Wide base: the chase unit works FAR from the car (wides pull back
|
|
// to ~7) and HIGH (up to ~3). The old 3.4 base parked every shot in
|
|
// the car's lap.
|
|
const baseZ = 5.2;
|
|
const sizeDolly = (planned.dolly && planned.dolly > 0) ? planned.dolly : 1.0;
|
|
const dolly = (baseZ / Math.max(scale, 0.05) - Math.max(0, push)) * sizeDolly;
|
|
const rise = (trav - 0.5) * 1.4 * room;
|
|
const shiftX = shift[0] * 0.6;
|
|
const shiftY = shift[1] * 0.35;
|
|
let px = walk * 1.5 * room + Math.sin(t * swayRate) * sway * 4 + shiftX + hx;
|
|
let py = 0.8 + rise + Math.cos(t * swayRate * 0.83) * sway * 3 + shiftY + hy;
|
|
let pz = dolly;
|
|
// Orbit the car once per video (~200° arc, seeded direction).
|
|
// Radius tracks the shot dolly (wides orbit far, closes near) with a
|
|
// floor that keeps the camera out of the bodywork. Orbit-weighted
|
|
// 65/35 over the traverse so the chase unit actually leaves the car's
|
|
// lap — 50/50 parked it near the paint.
|
|
const oDir = s1 >= 0 ? 1 : -1;
|
|
const oAng = oDir * (0.5 + trav * 3.5);
|
|
const oRad = Math.max(4.5, dolly * 1.15);
|
|
px = px * 0.35 + Math.sin(oAng) * oRad * 0.65;
|
|
pz = pz * 0.35 + Math.cos(oAng) * oRad * 0.65;
|
|
py = py * 0.35 + (1.2 + Math.sin(trav * 6.28 + s2 * 3.0) * 1.6) * 0.65;
|
|
// Minimum focus distance from the car (origin): blending can still
|
|
// land the camera inside the paint. Push out along the xz ray.
|
|
{
|
|
const r = Math.hypot(px, pz);
|
|
const minR = 4.2;
|
|
if (r < minR && r > 1e-4) {
|
|
const k = minR / r;
|
|
px *= k; pz *= k;
|
|
} else if (r <= 1e-4) {
|
|
px = minR; pz = 0;
|
|
}
|
|
}
|
|
camera.position.set(
|
|
Math.max(-lim.x, Math.min(lim.x, px)),
|
|
Math.max(lim.yLo, Math.min(lim.yHi, py)),
|
|
Math.max(lim.zLo, Math.min(lim.zHi, pz)),
|
|
);
|
|
camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0;
|
|
// Track the car: blend the plan's motive look with the car's analytic
|
|
// position (same formula as the module sweep) so the chase unit keeps
|
|
// its subject while still panning with motive.
|
|
const carX = Math.sin(t * 0.133 + ((instance.seed >>> 0) || 1) * 0.001) * 1.1;
|
|
camera.lookAt(
|
|
carX * 0.55 + planned.look[0] * 0.45,
|
|
0.3 * 0.55 + planned.look[1] * 0.45,
|
|
-0.3 * 0.55 + planned.look[2] * 0.45,
|
|
);
|
|
camera.updateMatrixWorld();
|
|
return;
|
|
}
|
|
|
|
if (planned && isJump) {
|
|
camera.position.set(
|
|
Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx)),
|
|
Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy)),
|
|
Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])),
|
|
);
|
|
camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0;
|
|
camera.lookAt(planned.look[0], planned.look[1], planned.look[2]);
|
|
camera.updateMatrixWorld();
|
|
return;
|
|
}
|
|
|
|
// No plan (hand-built looks, tests): legacy traverse, forward look.
|
|
const baseZ = 3.4;
|
|
const dolly = baseZ / Math.max(scale, 0.05) - Math.max(0, push);
|
|
camera.position.set(
|
|
Math.max(-lim.x, Math.min(lim.x,
|
|
walk * 1.5 * room + Math.sin(t * swayRate) * sway * 4 + shift[0] * 0.6 + hx)),
|
|
0.35 + (trav - 0.5) * 0.9 * room
|
|
+ Math.cos(t * swayRate * 0.83) * sway * 3 + shift[1] * 0.35 + hy,
|
|
Math.max(0.6, Math.min(7.5, dolly)),
|
|
);
|
|
camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0;
|
|
camera.lookAt(camera.position.x * 0.15, camera.position.y * 0.3 - 0.25, camera.position.z - 5);
|
|
camera.updateMatrixWorld();
|
|
}
|
|
|
|
/**
|
|
* Index of the billboard slot in the roadside group (-1 if none — defensive;
|
|
* every build places exactly one). Read off roadsideMeta so plan and module
|
|
* agree without sharing rng streams.
|
|
*/
|
|
function billSlotIndex(instance) {
|
|
const meta = instance && instance.roadsideMeta;
|
|
if (!meta) return -1;
|
|
for (let i = 0; i < meta.length; i++) {
|
|
if (meta[i] && meta[i].kind === 'billboard') return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function mulberry(seed) {
|
|
let a = seed >>> 0;
|
|
return function () {
|
|
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
export default ride;
|