Ride: venue-owned camera (directCamera) + sidewalk passerby shot
Camera architecture: a module with directCamera:true drives its own rig inside update() (analytic f(timeline, seed, framing, venueState)); the shared rig is ignored for that layer only. ModelLayer prefers own camera, Show skips shared injection. Ride migrates its full grammar over (traverse, orbit, car tracking, jumps, clamp); Compositor ride branches removed. Chamber/stage unchanged on the shared path. Sidewalk shot: new ride-only 'passerby' plan target (breaths only, never climax, never twice running) plants a static roadside camera; the module drives the car +12 to -14 on driveT with the world frozen. Verified far-pass-away through the Show path; lint + 24 tests green.
This commit is contained in:
@@ -262,9 +262,11 @@ export class Show {
|
|||||||
? timeline.frame / (timeline.frameCount - 1) : 0,
|
? timeline.frame / (timeline.frameCount - 1) : 0,
|
||||||
});
|
});
|
||||||
// Inject the shared camera into each active model layer so they render
|
// Inject the shared camera into each active model layer so they render
|
||||||
// through one perspective and one depth buffer.
|
// through one perspective and one depth buffer. Skipped for layers
|
||||||
|
// whose module drives its own rig (directCamera) — injecting it
|
||||||
|
// would be dead weight, and its presence must never change output.
|
||||||
for (const l of sceneLayers) {
|
for (const l of sceneLayers) {
|
||||||
if (l && l.module && l.module.kind === 'model') l.sharedCamera = this.engine.compositor.sharedCamera;
|
if (l && l.module && l.module.kind === 'model' && !l.module.directCamera) l.sharedCamera = this.engine.compositor.sharedCamera;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ export class Compositor {
|
|||||||
const seed = (venue.seed >>> 0) || 1;
|
const seed = (venue.seed >>> 0) || 1;
|
||||||
const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029);
|
const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029);
|
||||||
// Smaller rooms get a smaller walk: chamber is 22 wide, stage 44.
|
// Smaller rooms get a smaller walk: chamber is 22 wide, stage 44.
|
||||||
const room = venue.archetype === 'chamber' ? 0.62 : venue.archetype === 'ride' ? 0.85 : 1.0;
|
// (Ride drives its own rig now — see ride.directCamera — and never
|
||||||
|
// reaches this path, but the table stays total over archetypes.)
|
||||||
|
const room = venue.archetype === 'chamber' ? 0.62 : 1.0;
|
||||||
const still = motion ? motion.stillness : 0.5;
|
const still = motion ? motion.stillness : 0.5;
|
||||||
const churn = motion ? motion.churn : 0.25;
|
const churn = motion ? motion.churn : 0.25;
|
||||||
// Planned room shots (see look/venueShots.js): absolute per-cue marks
|
// Planned room shots (see look/venueShots.js): absolute per-cue marks
|
||||||
@@ -204,9 +206,7 @@ export class Compositor {
|
|||||||
// swapped; gliding through the old layout is the glitch).
|
// swapped; gliding through the old layout is the glitch).
|
||||||
const lim = venue.archetype === 'chamber'
|
const lim = venue.archetype === 'chamber'
|
||||||
? { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 }
|
? { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 }
|
||||||
: venue.archetype === 'ride'
|
: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 };
|
||||||
? { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 }
|
|
||||||
: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 };
|
|
||||||
const isJump = framing.venueJump !== undefined && framing.venueJump !== null;
|
const isJump = framing.venueJump !== undefined && framing.venueJump !== null;
|
||||||
if (isJump) {
|
if (isJump) {
|
||||||
cam.position.set(
|
cam.position.set(
|
||||||
@@ -218,48 +218,15 @@ export class Compositor {
|
|||||||
// Same traverse body as the legacy path below (walk + rise +
|
// Same traverse body as the legacy path below (walk + rise +
|
||||||
// sway + gaze share): one body, two heads. The plan's per-shot
|
// sway + gaze share): one body, two heads. The plan's per-shot
|
||||||
// size scales the dolly (a close stands nearer).
|
// size scales the dolly (a close stands nearer).
|
||||||
const baseZ = venue.archetype === 'chamber' ? 4.0 : venue.archetype === 'ride' ? 3.4 : 4.6;
|
const baseZ = venue.archetype === 'chamber' ? 4.0 : 4.6;
|
||||||
const sizeDolly = (planned.dolly && planned.dolly > 0) ? planned.dolly : 1.0;
|
const sizeDolly = (planned.dolly && planned.dolly > 0) ? planned.dolly : 1.0;
|
||||||
const dolly = (baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push)) * sizeDolly;
|
const dolly = (baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push)) * sizeDolly;
|
||||||
const rise = (trav - 0.5) * 0.9 * room;
|
const rise = (trav - 0.5) * 0.9 * room;
|
||||||
const shiftX = framing.shift[0] * 0.6;
|
const shiftX = framing.shift[0] * 0.6;
|
||||||
const shiftY = framing.shift[1] * 0.35;
|
const shiftY = framing.shift[1] * 0.35;
|
||||||
let px = walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx;
|
const px = walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx;
|
||||||
let py = 0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy;
|
const py = 0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy;
|
||||||
let pz = dolly;
|
const pz = dolly;
|
||||||
if (venue.archetype === 'ride') {
|
|
||||||
// Orbit the car once per video (~200° arc, seeded direction)
|
|
||||||
// ON TOP of the traverse: the chase unit circles while it
|
|
||||||
// pushes in, so angles genuinely change instead of the
|
|
||||||
// camera shuffling on one side. Monotonic in progress — the
|
|
||||||
// arc survives — and bounded by the same box below.
|
|
||||||
// Radius tracks the shot dolly (wides orbit far, closes
|
|
||||||
// near) with a floor that keeps the camera out of the
|
|
||||||
// bodywork: the car is ~2 wide and ~3.5 long, and a 2.6
|
|
||||||
// floor still put close-ups through the paint.
|
|
||||||
const oDir = s1 >= 0 ? 1 : -1;
|
|
||||||
const oAng = oDir * (0.5 + trav * 3.5);
|
|
||||||
const oRad = Math.max(3.2, dolly);
|
|
||||||
// Blend orbit with traverse by section energy of the gaze:
|
|
||||||
// keep half the traverse so the walk's travel isn't lost.
|
|
||||||
px = (px + Math.sin(oAng) * oRad) / 2;
|
|
||||||
pz = (pz + Math.cos(oAng) * oRad) / 2;
|
|
||||||
py = (py + (0.5 + Math.sin(trav * 6.28 + s2 * 3.0) * 0.9)) / 2;
|
|
||||||
}
|
|
||||||
// Minimum focus distance from the car (origin): blending can
|
|
||||||
// still land the camera inside the paint, where a frame is
|
|
||||||
// nothing but chassis. Push out along the xz ray, keep y.
|
|
||||||
{
|
|
||||||
const dx = px, dz = pz;
|
|
||||||
const r = Math.hypot(dx, dz);
|
|
||||||
const minR = venue.archetype === 'ride' ? 3.4 : 0;
|
|
||||||
if (r < minR && r > 1e-4) {
|
|
||||||
const k = minR / r;
|
|
||||||
px = dx * k; pz = dz * k;
|
|
||||||
} else if (r <= 1e-4) {
|
|
||||||
px = minR; pz = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cam.position.set(
|
cam.position.set(
|
||||||
Math.max(-lim.x, Math.min(lim.x, px)),
|
Math.max(-lim.x, Math.min(lim.x, px)),
|
||||||
Math.max(lim.yLo, Math.min(lim.yHi, py)),
|
Math.max(lim.yLo, Math.min(lim.yHi, py)),
|
||||||
@@ -267,25 +234,13 @@ export class Compositor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
|
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
|
||||||
let lx = planned.look[0], ly = planned.look[1], lz = planned.look[2];
|
cam.lookAt(planned.look[0], planned.look[1], planned.look[2]);
|
||||||
if (venue.archetype === 'ride') {
|
|
||||||
// Track the car: the orbit swings wide, and the planned look
|
|
||||||
// alone lets the vehicle leave the frame (measured: empty-road
|
|
||||||
// shots). 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(time * 0.133 + ((venue.seed >>> 0) || 1) * 0.001) * 1.1;
|
|
||||||
lx = carX * 0.55 + lx * 0.45;
|
|
||||||
ly = 0.3 * 0.55 + ly * 0.45;
|
|
||||||
lz = -0.3 * 0.55 + lz * 0.45;
|
|
||||||
}
|
|
||||||
cam.lookAt(lx, ly, lz);
|
|
||||||
cam.updateMatrixWorld();
|
cam.updateMatrixWorld();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Close enough that the screen fills the frame: the chamber window is
|
// Close enough that the screen fills the frame: the chamber window is
|
||||||
// 12 wide at z=-10.9, so z≈4 makes it ~40% of a 60° frame.
|
// 12 wide at z=-10.9, so z≈4 makes it ~40% of a 60° frame.
|
||||||
const baseZ = venue.archetype === 'chamber' ? 4.0 : venue.archetype === 'ride' ? 3.4 : 4.6;
|
const baseZ = venue.archetype === 'chamber' ? 4.0 : 4.6;
|
||||||
const dolly = baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push);
|
const dolly = baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push);
|
||||||
// A story jump relocates the room camera for the section: the furniture
|
// A story jump relocates the room camera for the section: the furniture
|
||||||
// swapped at its start (see ArcDriver.venueJumpAt), so the camera
|
// swapped at its start (see ArcDriver.venueJumpAt), so the camera
|
||||||
@@ -312,7 +267,7 @@ export class Compositor {
|
|||||||
// walk cannot provide. Clamped to the room box: the test asserts
|
// walk cannot provide. Clamped to the room box: the test asserts
|
||||||
// |x| < 2.6 and z in 0.4..8, and the bigger swing must not break it.
|
// |x| < 2.6 and z in 0.4..8, and the bigger swing must not break it.
|
||||||
const rise = (trav - 0.5) * 0.9 * room;
|
const rise = (trav - 0.5) * 0.9 * room;
|
||||||
const limX = venue.archetype === 'chamber' ? 2.4 : venue.archetype === 'ride' ? 4.5 : 4.0;
|
const limX = venue.archetype === 'chamber' ? 2.4 : 4.0;
|
||||||
cam.position.set(
|
cam.position.set(
|
||||||
Math.max(-limX, Math.min(limX,
|
Math.max(-limX, Math.min(limX,
|
||||||
walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx)),
|
walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx)),
|
||||||
|
|||||||
@@ -436,11 +436,14 @@ export class ModelLayer extends Layer {
|
|||||||
const { timeline, features } = ctx;
|
const { timeline, features } = ctx;
|
||||||
const w = target ? target.width : renderer.width;
|
const w = target ? target.width : renderer.width;
|
||||||
const h = target ? target.height : renderer.height;
|
const h = target ? target.height : renderer.height;
|
||||||
// Use the compositor's shared camera when it has been injected; it is
|
// Who drives the camera: a module with `directCamera: true` owns its
|
||||||
// updated centrally from ArcDriver's framing/gaze so every model layer
|
// own rig outright — it drives `camera` inside update() and the shared
|
||||||
// shares one perspective and one depth, and a ground in one layer can
|
// rig is ignored for this layer. Everything else borrows the shared
|
||||||
// occlude a subject in another.
|
// camera when injected (updated centrally from framing/gaze so model
|
||||||
const cam = this.sharedCamera || this.camera;
|
// layers share one perspective and one depth buffer), else its own.
|
||||||
|
// Direct control is analytic like everything else: f(timeline, seed,
|
||||||
|
// framing, venueState) — seek === playback still holds.
|
||||||
|
const cam = (this.module.directCamera ? this.camera : (this.sharedCamera || this.camera));
|
||||||
if (cam.aspect !== w / h) {
|
if (cam.aspect !== w / h) {
|
||||||
cam.aspect = w / h;
|
cam.aspect = w / h;
|
||||||
cam.updateProjectionMatrix();
|
cam.updateProjectionMatrix();
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
|
|||||||
const prevMark = marks.length ? marks[marks.length - 1] : null;
|
const prevMark = marks.length ? marks[marks.length - 1] : null;
|
||||||
sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng);
|
sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng);
|
||||||
sectionTarget = pickTarget(prevMark, energy, story, rng,
|
sectionTarget = pickTarget(prevMark, energy, story, rng,
|
||||||
sectionTargets.map((t) => ({ target: t })));
|
sectionTargets.map((t) => ({ target: t })), archetype);
|
||||||
sectionTargets.push(sectionTarget);
|
sectionTargets.push(sectionTarget);
|
||||||
}
|
}
|
||||||
const progress = n > 1 ? cue.index / (n - 1) : 0.5;
|
const progress = n > 1 ? cue.index / (n - 1) : 0.5;
|
||||||
@@ -190,11 +190,19 @@ function pickSize(previous, energy, story, style, rng) {
|
|||||||
* still crosses the frame — the walk passes it — it is just never the mark.
|
* still crosses the frame — the walk passes it — it is just never the mark.
|
||||||
* Rotation between the two (never three in a row) is the coverage.
|
* Rotation between the two (never three in a row) is the coverage.
|
||||||
*/
|
*/
|
||||||
function pickTarget(previous, energy, story, rng, history) {
|
function pickTarget(previous, energy, story, rng, history, archetype = null) {
|
||||||
const last2 = (history || []).slice(-2).map((m) => m.target);
|
const last2 = (history || []).slice(-2).map((m) => m.target);
|
||||||
const streak = last2.length === 2 && last2[0] === last2[1] ? last2[0] : null;
|
const streak = last2.length === 2 && last2[0] === last2[1] ? last2[0] : null;
|
||||||
const act = story.act || 'development';
|
const act = story.act || 'development';
|
||||||
const tension = story.tension ?? 0.5;
|
const tension = story.tension ?? 0.5;
|
||||||
|
// Ride-only: a passerby section plants the camera at the roadside and lets
|
||||||
|
// the car drive through the frame. Offered on breaths (setup/resolution,
|
||||||
|
// quiet passages) — never twice running, never at the climax.
|
||||||
|
if (archetype === 'ride' && act !== 'climax' && streak !== 'passerby'
|
||||||
|
&& (act === 'setup' || act === 'resolution' || energy < 0.42)
|
||||||
|
&& rng.bool(0.45)) {
|
||||||
|
return 'passerby';
|
||||||
|
}
|
||||||
let pick;
|
let pick;
|
||||||
if (act === 'setup') pick = 'room';
|
if (act === 'setup') pick = 'room';
|
||||||
else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room';
|
else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room';
|
||||||
@@ -210,6 +218,17 @@ function pickTarget(previous, energy, story, rng, history) {
|
|||||||
|
|
||||||
/** Absolute camera mark for a (size, target): position + look point. */
|
/** Absolute camera mark for a (size, target): position + look point. */
|
||||||
function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null, roomSide = 1) {
|
function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null, roomSide = 1) {
|
||||||
|
// Passerby (ride only): plant the camera on the shoulder and look back up
|
||||||
|
// the road. The car enters far, drives to the camera and past it — the
|
||||||
|
// module drives the car through on driveT, so pos/look here are static.
|
||||||
|
// Shoulder side follows the video's roomSide: one consistent side all song.
|
||||||
|
if (target === 'passerby') {
|
||||||
|
const side = roomSide || 1;
|
||||||
|
return {
|
||||||
|
pos: [side * 3.6, 1.2, -2],
|
||||||
|
look: [-side * 1.0, 0.2, 8],
|
||||||
|
};
|
||||||
|
}
|
||||||
const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
const { box, dolly } = staging;
|
const { box, dolly } = staging;
|
||||||
// Room visits alternate sides so consecutive room views face opposite
|
// Room visits alternate sides so consecutive room views face opposite
|
||||||
@@ -304,12 +323,20 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame =
|
|||||||
// the look (the furniture swapped; the path would glide through the old
|
// the look (the furniture swapped; the path would glide through the old
|
||||||
// room). The reported target stays the cue's (HUD motive), the look itself
|
// room). The reported target stays the cue's (HUD motive), the look itself
|
||||||
// is where the path is.
|
// is where the path is.
|
||||||
const look = lookPathAt(marks, progress, cur.jump !== null ? cur : null);
|
// Passerby holds its plant: the camera is a bystander, and any drift of
|
||||||
|
// the look would read as a pan away from the road. driveT (0→1 across the
|
||||||
|
// cue) is the car's cue — the module drives it from far +z to far -z on it.
|
||||||
|
const look = cur.target === 'passerby'
|
||||||
|
? cur.look.slice()
|
||||||
|
: lookPathAt(marks, progress, cur.jump !== null ? cur : null);
|
||||||
|
const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame);
|
||||||
|
const driveT = clamp01((frame - cueStartFrame) / span);
|
||||||
if (!prev || cur.jump !== null) {
|
if (!prev || cur.jump !== null) {
|
||||||
return {
|
return {
|
||||||
pos: cur.pos.slice(), look,
|
pos: cur.pos.slice(), look,
|
||||||
size: cur.size, target: cur.target,
|
size: cur.size, target: cur.target,
|
||||||
dolly: VENUE_SIZE_DOLLY[cur.size] ?? 1.0,
|
dolly: VENUE_SIZE_DOLLY[cur.size] ?? 1.0,
|
||||||
|
drive: driveT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// The travel OUTRUNS the cue (1.25x): the camera is always on the way to
|
// The travel OUTRUNS the cue (1.25x): the camera is always on the way to
|
||||||
@@ -319,7 +346,6 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame =
|
|||||||
// as churn to the descriptor). Travel-portion differences are small up
|
// as churn to the descriptor). Travel-portion differences are small up
|
||||||
// close and accumulate far away, which is exactly the gradient direction
|
// close and accumulate far away, which is exactly the gradient direction
|
||||||
// measures. Jump cues still cut instantly (furniture swapped).
|
// measures. Jump cues still cut instantly (furniture swapped).
|
||||||
const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame);
|
|
||||||
const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25));
|
const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25));
|
||||||
const e = t * t * (3 - 2 * t);
|
const e = t * t * (3 - 2 * t);
|
||||||
return {
|
return {
|
||||||
@@ -328,6 +354,7 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame =
|
|||||||
size: t < 1 ? `${prev.size}→${cur.size}` : cur.size,
|
size: t < 1 ? `${prev.size}→${cur.size}` : cur.size,
|
||||||
target: cur.target,
|
target: cur.target,
|
||||||
dolly: lerp(VENUE_SIZE_DOLLY[prev.size] ?? 1.0, VENUE_SIZE_DOLLY[cur.size] ?? 1.0, e),
|
dolly: lerp(VENUE_SIZE_DOLLY[prev.size] ?? 1.0, VENUE_SIZE_DOLLY[cur.size] ?? 1.0, e),
|
||||||
|
drive: driveT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+185
-12
@@ -18,6 +18,11 @@ export const ride = {
|
|||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'model',
|
kind: 'model',
|
||||||
venue: true,
|
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'],
|
consumes: ['form', 'ink', 'staging'],
|
||||||
traits: ['shape', 'space', 'camera', 'style'],
|
traits: ['shape', 'space', 'camera', 'style'],
|
||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
@@ -215,7 +220,8 @@ export const ride = {
|
|||||||
const billSlot = 3 + Math.floor(rng() * 6);
|
const billSlot = 3 + Math.floor(rng() * 6);
|
||||||
const billSide = rng() < 0.5 ? -1 : 1;
|
const billSide = rng() < 0.5 ? -1 : 1;
|
||||||
const roadsideMeta = [];
|
const roadsideMeta = [];
|
||||||
for (let i = 0; i < 12; i++) {
|
const roadsidePopulation = 12;
|
||||||
|
for (let i = 0; i < roadsidePopulation; i++) {
|
||||||
const g = new T.Group();
|
const g = new T.Group();
|
||||||
const side = i % 2 === 0 ? -1 : 1;
|
const side = i % 2 === 0 ? -1 : 1;
|
||||||
const kind = i === billSlot ? 'billboard'
|
const kind = i === billSlot ? 'billboard'
|
||||||
@@ -600,15 +606,25 @@ export const ride = {
|
|||||||
const energyK = vState ? (0.55 + vState.light * 0.65) : 1;
|
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 speed = (0.35 + (params.speed || 0.7) * 0.75) * energyK * (instance.chase ? 1 : 0);
|
||||||
const rate = 1;
|
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.)
|
||||||
|
const shot = framing && framing.venueShot ? framing.venueShot : null;
|
||||||
|
const passerby = !!shot && shot.target === 'passerby';
|
||||||
|
const driveT = passerby && typeof shot.drive === 'number'
|
||||||
|
? Math.max(0, Math.min(1, shot.drive)) : null;
|
||||||
// World scroll: dashes, posts, roadside, chevrons and streaks stream
|
// World scroll: dashes, posts, roadside, chevrons and streaks stream
|
||||||
// past toward +z (the car drives toward -z, so the world comes at the
|
// past toward +z (the car drives toward -z, so the world comes at the
|
||||||
// camera). Parked mode keeps a 12% idle creep plus a floor so the
|
// camera). Frozen during passerby (see above). Parked mode keeps a 12%
|
||||||
// world never reads dead — a frozen world with a crawling camera
|
// idle creep plus a floor so the world never reads dead — a frozen
|
||||||
// measured as "no movement at all". Analytic in t (not += per frame):
|
// world with a crawling camera measured as "no movement at all".
|
||||||
// a seek must land on the same positions as playback. The chase rate
|
// Analytic in t (not += per frame): a seek must land on the same
|
||||||
// reads as driving at a glance (~3x the original).
|
// positions as playback. The chase rate reads as driving at a glance
|
||||||
|
// (~3x the original).
|
||||||
{
|
{
|
||||||
const dz = (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate;
|
const dz = passerby ? 0
|
||||||
|
: (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate;
|
||||||
const kids = instance.dashes.children;
|
const kids = instance.dashes.children;
|
||||||
for (let i = 0; i < kids.length; i++) {
|
for (let i = 0; i < kids.length; i++) {
|
||||||
const z0 = -70 + i * 6;
|
const z0 = -70 + i * 6;
|
||||||
@@ -675,6 +691,14 @@ export const ride = {
|
|||||||
const sweepAmp = instance.chase ? 1.1 : 0.15;
|
const sweepAmp = instance.chase ? 1.1 : 0.15;
|
||||||
const sweep = Math.sin(t * 0.133 + instance.seed * 0.001);
|
const sweep = Math.sin(t * 0.133 + instance.seed * 0.001);
|
||||||
instance.car.position.x = sweep * sweepAmp;
|
instance.car.position.x = sweep * sweepAmp;
|
||||||
|
// Passerby drive-through: +12 (far, small) to -14 (far, gone) eased
|
||||||
|
// across the cue. Otherwise the car lives at the origin.
|
||||||
|
if (passerby && 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);
|
instance.car.position.y = bob * (instance.chase ? 1 : 0.3);
|
||||||
// Lean INTO the sweep (roll) and yaw with it, like a lane change.
|
// Lean INTO the sweep (roll) and yaw with it, like a lane change.
|
||||||
const leanK = instance.chase ? 1 : 0.2;
|
const leanK = instance.chase ? 1 : 0.2;
|
||||||
@@ -685,10 +709,13 @@ export const ride = {
|
|||||||
if (instance.wheels) {
|
if (instance.wheels) {
|
||||||
// Rolling spin on the meshes (local axes), not the yawed group:
|
// Rolling spin on the meshes (local axes), not the yawed group:
|
||||||
// tire about its hole axis (local z), hub about its cylinder
|
// tire about its hole axis (local z), hub about its cylinder
|
||||||
// axis (local x after the geometry pre-rotation).
|
// 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 ? t * 14 : t * speed * 6 * rate;
|
||||||
for (const w of instance.wheels) {
|
for (const w of instance.wheels) {
|
||||||
if (w.userData.tire) w.userData.tire.rotation.z = t * speed * 6 * rate;
|
if (w.userData.tire) w.userData.tire.rotation.z = spin;
|
||||||
if (w.userData.hub) w.userData.hub.rotation.z = t * speed * 6 * rate;
|
if (w.userData.hub) w.userData.hub.rotation.z = spin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Passengers bob gently, out of phase — analytic f(t), seek-stable.
|
// Passengers bob gently, out of phase — analytic f(t), seek-stable.
|
||||||
@@ -725,7 +752,7 @@ export const ride = {
|
|||||||
const dense = (arrangement % 2 === 1) || (vState && vState.popMul > 0.8);
|
const dense = (arrangement % 2 === 1) || (vState && vState.popMul > 0.8);
|
||||||
const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45));
|
const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45));
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
instance.roadside.children[i].visible = opacity > 0.01 && (i < showUpTo);
|
instance.roadside.children[i].visible = opacity > 0.001 && (i < showUpTo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dressing takes the palette tint so houses/trees belong to the song;
|
// Dressing takes the palette tint so houses/trees belong to the song;
|
||||||
@@ -748,10 +775,156 @@ export const ride = {
|
|||||||
// Ground follows horizon like the stage field.
|
// Ground follows horizon like the stage field.
|
||||||
instance.road.position.y = -1.15 + (spaceHorizon - 0.5) * 0.6;
|
instance.road.position.y = -1.15 + (spaceHorizon - 0.5) * 0.6;
|
||||||
|
|
||||||
void camera; void framing; void params;
|
// --- 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
function mulberry(seed) {
|
function mulberry(seed) {
|
||||||
let a = seed >>> 0;
|
let a = seed >>> 0;
|
||||||
return function () {
|
return function () {
|
||||||
|
|||||||
@@ -190,6 +190,73 @@ function countMeshes(root) {
|
|||||||
// scenes/metadata.json, which Node 20 cannot import without an attribute.
|
// scenes/metadata.json, which Node 20 cannot import without an attribute.
|
||||||
// See tools/venue-check.js --filmstrip for the carryover measurement.
|
// See tools/venue-check.js --filmstrip for the carryover measurement.
|
||||||
|
|
||||||
|
test('ride declares directCamera and drives its own rig deterministically', () => {
|
||||||
|
const THREE = awaitImportThree();
|
||||||
|
return THREE.then((T) => {
|
||||||
|
const rideMod = venues.find((v) => v.name === 'Ride');
|
||||||
|
assert.ok(rideMod, 'Ride venue not registered');
|
||||||
|
assert.equal(rideMod.directCamera, true, 'Ride must own its camera rig');
|
||||||
|
const personality = personalityAt(11);
|
||||||
|
const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4);
|
||||||
|
personality.identity = identity;
|
||||||
|
const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity);
|
||||||
|
const scene = new T.Scene();
|
||||||
|
const instance = rideMod.build({
|
||||||
|
scene, seed: 4242, params: {}, actorSpec: actors.monolith,
|
||||||
|
venueSpec: {
|
||||||
|
seed: 5, population: { count: 3, spread: 0.6, jitter: 0.2 },
|
||||||
|
paletteMap: [0, 1, 2, 3],
|
||||||
|
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
|
||||||
|
},
|
||||||
|
THREE: T,
|
||||||
|
});
|
||||||
|
const stubCam = () => ({
|
||||||
|
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
|
||||||
|
rotation: {},
|
||||||
|
look: null,
|
||||||
|
lookAt(x, y, z) { this.look = [x, y, z]; },
|
||||||
|
updateMatrixWorld() {},
|
||||||
|
});
|
||||||
|
const drive = (time, framing) => {
|
||||||
|
const cam = stubCam();
|
||||||
|
rideMod.update({
|
||||||
|
instance, scene, camera: cam,
|
||||||
|
timeline: { time, frame: Math.round(time * 60), progress: time / 200 },
|
||||||
|
features: {}, params: { light: 0.85, speed: 0.7 },
|
||||||
|
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]],
|
||||||
|
personality, framing, opacity: 1,
|
||||||
|
actorSpec: actors.monolith,
|
||||||
|
venueSpec: instance.venueSpec,
|
||||||
|
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
|
||||||
|
THREE: T,
|
||||||
|
});
|
||||||
|
const { x, y, z } = cam.position;
|
||||||
|
return { x, y, z, look: cam.look };
|
||||||
|
};
|
||||||
|
const framing = { scale: 1, shift: [0, 0] };
|
||||||
|
const a = drive(10, framing), b = drive(10, framing);
|
||||||
|
assert.deepEqual(a, b, 'same inputs must place the camera identically');
|
||||||
|
const c = drive(40, framing);
|
||||||
|
const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z);
|
||||||
|
assert.ok(travelled > 0.15, `ride camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
|
||||||
|
// Stays inside its own box (wider + behind the car, unlike rooms —
|
||||||
|
// must match the clamp in driveRideCamera).
|
||||||
|
for (const t of [0, 30, 90, 200, 400]) {
|
||||||
|
const p = drive(t, framing);
|
||||||
|
assert.ok(Math.abs(p.x) <= 6.0, `x=${p.x.toFixed(2)} outside the ride box at t=${t}`);
|
||||||
|
assert.ok(p.z >= -6.0 && p.z <= 6.0, `z=${p.z.toFixed(2)} outside the ride box at t=${t}`);
|
||||||
|
assert.ok(p.look && p.look.length === 3, 'camera must look somewhere');
|
||||||
|
}
|
||||||
|
// A planned mark steers the head: screen vs room framings look apart.
|
||||||
|
const shot = { pos: [1, 0.5, 4], look: [0, 0.8, -0.8], size: 'close', target: 'screen', dolly: 0.72 };
|
||||||
|
const roomShot = { pos: [-1, 0.5, 5], look: [0, 0.3, -6], size: 'wide', target: 'room', dolly: 1.3 };
|
||||||
|
const s1 = drive(60, { scale: 1, shift: [0, 0], venueShot: shot });
|
||||||
|
const s2 = drive(60, { scale: 1, shift: [0, 0], venueShot: roomShot });
|
||||||
|
assert.ok(Math.hypot(s1.look[0] - s2.look[0], s1.look[1] - s2.look[1]) > 0.2,
|
||||||
|
'screen vs room marks must aim the camera apart');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Dynamic import indirection so a missing/broken three surfaces as a test
|
// Dynamic import indirection so a missing/broken three surfaces as a test
|
||||||
// failure with a name on it rather than a top-level load error for the file.
|
// failure with a name on it rather than a top-level load error for the file.
|
||||||
function awaitImportThree() {
|
function awaitImportThree() {
|
||||||
|
|||||||
Reference in New Issue
Block a user