feat: dashcam photo mode with telemetry frame, lens drift and postcard capture

This commit is contained in:
2026-10-11 07:49:56 +03:00
parent d71b0ecfd4
commit 568b0802b4
29 changed files with 1472 additions and 7 deletions

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

View File

@@ -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() {