feat: dashcam photo mode with telemetry frame, lens drift and postcard capture
This commit is contained in:
69
src/scene/world/dashcam-lens.ts
Normal file
69
src/scene/world/dashcam-lens.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DashcamFeed } from '@/journey/dashcam-feed';
|
||||
|
||||
/** DASHCAM tunables (scene side). */
|
||||
export const DASHCAM_LENS = Object.freeze({
|
||||
/** Camera x travel at the viewport edge, world units. */
|
||||
driftX: 1.6,
|
||||
/** Look-at height travel at the viewport edge, world units. */
|
||||
driftTargetY: 0.5,
|
||||
/** Pointer smoothing time constant, seconds. */
|
||||
driftSeconds: 0.35,
|
||||
/** No pointer movement for this long returns the lens to centre. */
|
||||
idleMs: 3_000,
|
||||
/** Lens-pass letterbox bar height (each bar, fraction of the frame). */
|
||||
letterbox: 0.16,
|
||||
/** Bars close over this long. */
|
||||
letterboxSeconds: 0.4,
|
||||
});
|
||||
|
||||
/** Where the lens wants to be: the pointer, or centre when closed, idle or without drift. */
|
||||
export function driftTarget(feed: Readonly<DashcamFeed>, now: number): { x: number; y: number } {
|
||||
if (!feed.open || !feed.drift || now - feed.movedAt > DASHCAM_LENS.idleMs) return { x: 0, y: 0 };
|
||||
return {
|
||||
x: Math.max(-1, Math.min(1, feed.px)),
|
||||
y: Math.max(-1, Math.min(1, feed.py)),
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashcamLensState {
|
||||
/** Added to the camera x. */
|
||||
x: number;
|
||||
/** Added to the look-at height. */
|
||||
targetY: number;
|
||||
/** Lens-pass letterbox term. */
|
||||
letterbox: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eases the pointer drift (~0.35 s) and the letterbox (400 ms, smoothstep)
|
||||
* once per frame. Without drift (touch, low tier) the bars snap and the lens
|
||||
* stays centred.
|
||||
*/
|
||||
export function createDashcamLens() {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let bars = 0;
|
||||
let snap = false;
|
||||
const state: DashcamLensState = { x: 0, targetY: 0, letterbox: 0 };
|
||||
return {
|
||||
update(feed: Readonly<DashcamFeed>, deltaSeconds: number, now: number): DashcamLensState {
|
||||
const target = driftTarget(feed, now);
|
||||
const mix = 1 - Math.exp(-Math.max(0, deltaSeconds) / DASHCAM_LENS.driftSeconds);
|
||||
x += (target.x - x) * mix;
|
||||
y += (target.y - y) * mix;
|
||||
const goal = feed.open ? 1 : 0;
|
||||
if (feed.open) snap = !feed.drift;
|
||||
bars = snap
|
||||
? goal
|
||||
: goal > bars
|
||||
? Math.min(goal, bars + deltaSeconds / DASHCAM_LENS.letterboxSeconds)
|
||||
: Math.max(goal, bars - deltaSeconds / DASHCAM_LENS.letterboxSeconds);
|
||||
// Settle exactly at rest so the closed dashcam costs nothing downstream.
|
||||
if (!feed.open && Math.abs(x) < 1e-4 && Math.abs(y) < 1e-4) x = y = 0;
|
||||
state.x = x * DASHCAM_LENS.driftX;
|
||||
state.targetY = -y * DASHCAM_LENS.driftTargetY;
|
||||
state.letterbox = DASHCAM_LENS.letterbox * bars * bars * (3 - 2 * bars);
|
||||
return state;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,8 @@ import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
|
||||
import { dawnStep, type DawnStep } from '@/journey/dawn';
|
||||
import { dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
import { readDashcam, takeDashcamCapture } from '@/journey/dashcam-feed';
|
||||
import { createDashcamLens } from '@/scene/world/dashcam-lens';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD =
|
||||
@@ -244,6 +246,8 @@ export async function createProceduralSceneGeneration(
|
||||
let riseUntil = 0;
|
||||
let gantryState = '';
|
||||
let dawnState: DawnStep | '' = '';
|
||||
// DASHCAM: the pointer drifts the lens and the letterbox closes while open.
|
||||
const dashcamLens = createDashcamLens();
|
||||
|
||||
const resize = (): void => {
|
||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
||||
@@ -651,6 +655,7 @@ export async function createProceduralSceneGeneration(
|
||||
THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1),
|
||||
) * (1 - outro);
|
||||
const craneShift = cameraState.shift * crane;
|
||||
const dashcam = dashcamLens.update(readDashcam(), deltaSeconds, now);
|
||||
camera.setViewOffset(
|
||||
width,
|
||||
height,
|
||||
@@ -660,13 +665,13 @@ export async function createProceduralSceneGeneration(
|
||||
height,
|
||||
);
|
||||
camera.position.set(
|
||||
THREE.MathUtils.lerp(0, cameraState.x, crane),
|
||||
THREE.MathUtils.lerp(0, cameraState.x, crane) + dashcam.x,
|
||||
THREE.MathUtils.lerp(46, cameraState.y, crane),
|
||||
THREE.MathUtils.lerp(92, cameraState.z, crane),
|
||||
);
|
||||
camera.lookAt(
|
||||
THREE.MathUtils.lerp(0, cameraState.targetX, crane),
|
||||
THREE.MathUtils.lerp(64, cameraState.targetY, crane),
|
||||
THREE.MathUtils.lerp(64, cameraState.targetY, crane) + dashcam.targetY,
|
||||
THREE.MathUtils.lerp(-720, cameraState.targetZ, crane),
|
||||
);
|
||||
camera.rotation.z += cameraState.roll * crane;
|
||||
@@ -737,7 +742,10 @@ export async function createProceduralSceneGeneration(
|
||||
postProcessor.setDynamics(response.bloomEnergy, dawnRims ? dawn : 0);
|
||||
postProcessor.setLens({
|
||||
time: elapsedSeconds,
|
||||
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
||||
letterbox: Math.max(
|
||||
0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
||||
dashcam.letterbox,
|
||||
),
|
||||
energy: overdrive,
|
||||
rain: service.rain,
|
||||
rainTime: elapsedSeconds,
|
||||
@@ -747,6 +755,15 @@ export async function createProceduralSceneGeneration(
|
||||
} else {
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
// DASHCAM: the drawing buffer is only valid now, inside this task.
|
||||
const capture = takeDashcamCapture();
|
||||
if (capture !== null) {
|
||||
try {
|
||||
capture(canvas);
|
||||
} catch {
|
||||
// A failed postcard never takes the scene down.
|
||||
}
|
||||
}
|
||||
renderedFrames += 1;
|
||||
},
|
||||
dispose() {
|
||||
|
||||
Reference in New Issue
Block a user