feat: cursor-steered headlights light the hero name, plates and world

This commit is contained in:
2026-10-11 05:07:05 +03:00
parent 52b4a1ad14
commit c9e6ac0f29
41 changed files with 1196 additions and 9 deletions

View File

@@ -10,6 +10,7 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SectionId } from '@/content/types';
import { signAccentFor } from './sign';
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
import { BEAM_PARS, beamUniforms } from './headlights';
/** Roadside districts: each chapter raises its own landscape out of the ground
* (server rows for Infrastructure, a workshop and cranes for Builds, a container
@@ -201,7 +202,7 @@ export function createDistricts(
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
uniforms: { ...shared, ...service, ...beamUniforms, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -209,6 +210,7 @@ export function createDistricts(
varying float vDepth;
varying float vRise;
varying float vHeight;
${BEAM_PARS}
${serviced ? SERVICE_PARS : ''}
void main() {
${FADES}
@@ -233,6 +235,9 @@ export function createDistricts(
float power = mix(1.0, mix(0.1, 1.0, relit), dimmed);
color += mix(uAccent, uServiceColor, dimmed * relit) * (rim * 0.22 + crown * 0.05) * power;` : `
color += uAccent * (rim * 0.22 + crown * 0.05);`}
// HIGH BEAM: the headlights rake the facades they face.
vec3 toLamp = normalize(uBeamOrigin - vWorld);
color += vec3(1.0, 0.84, 0.66) * headlightBeam(vWorld) * (0.25 + 0.75 * abs(dot(n, toLamp))) * 0.35;
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>

View File

@@ -32,6 +32,7 @@ import {
type MutableChapterPalette,
} from '@/scene/world/palette';
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { createHeadlights } from '@/scene/world/headlights';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
@@ -145,6 +146,13 @@ export async function createProceduralSceneGeneration(
maximumTextureSize: renderer.capabilities.maxTextureSize,
});
const atmosphere = createAtmosphere(context.resources, quality);
// HIGH BEAM: the cursor steers the headlights (road cones on every tier).
const headlights = createHeadlights(
context.resources,
context.quality,
// A THREE.Group at runtime; CarGroup only types what car.ts uses.
car.group as unknown as Parameters<typeof createHeadlights>[2],
);
// The 03:12 window. Phones, coarse pointers and the low tier keep only the
// power-down, the re-light and the palette nudge: no rain, packets or lightning.
const maintenance = createMaintenanceWindow(context.resources);
@@ -168,6 +176,7 @@ export async function createProceduralSceneGeneration(
speedWake.group,
sign.group,
sign.truss,
...headlights.lights,
);
const postProcessor = quality.postEffects
@@ -612,6 +621,8 @@ export async function createProceduralSceneGeneration(
camera.fov = lensFov;
camera.updateProjectionMatrix();
}
const beam = headlights.update(snapshot.beam, snapshot, camera, 1 - engineOff, deltaSeconds);
road.setBeam(beam.yaw, beam.lift);
const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.dock, now, stage.tilt);
const gantry = sign.dockState();

View File

@@ -0,0 +1,206 @@
import * as THREE from 'three';
import { REST_INTENSITY, type BeamSample } from '@/journey/headlight-beam';
import type { JourneySnapshot } from '@/journey/types';
import type { LiveQualityTier } from '@/scene/types';
import type { SceneResourceBag } from '@/scene/resources';
/* three ships untyped here (see src/scene/three.d.ts). */
type Vec3 = { x: number; y: number; z: number };
type Object3D = {
add(...objects: unknown[]): unknown;
updateWorldMatrix(parents: boolean, children: boolean): void;
worldToLocal<T>(vector: T): T;
matrixWorld: unknown;
};
type Camera = { position: Vec3 };
/**
* HIGH BEAM, scene side. The shared beam (src/journey/headlight-beam.ts)
* yaws the road cones on every tier; high and medium add one warm spotlight
* parented to the car (no shadows) and a matching term in the unlit district
* and terrain shaders; high adds an additive cone of haze while the road is
* open (no reading plate over it).
*/
export const BEAM_YAW_RAD = 0.35;
export const BEAM_PITCH_RAD = 0.12;
const SPOT_COLOR = 0xffd6a8;
const SPOT_ANGLE = 0.42;
const SPOT_PENUMBRA = 0.6;
const SPOT_DISTANCE = 70;
const SPOT_INTENSITY = 400;
const LAMP = new THREE.Vector3(0, 0.55, -2.4);
/** The resting aim: the cone axis meets the road about 25 m ahead. */
const BASE_PITCH = -0.05;
const CONE_LENGTH = 34;
const CONE_WIDTH = 10;
const CONE_OPACITY = 0.13;
/** Shared by the district and terrain shaders (one set for every generation). */
export const beamUniforms = {
uBeamOrigin: { value: new THREE.Vector3(0, 1, -2) },
uBeamDir: { value: new THREE.Vector3(0, -0.05, -1).normalize() },
uBeamGain: { value: 0 },
};
/** GLSL: the spotlight's footprint for unlit ShaderMaterials (0..~1). */
export const BEAM_PARS = /* glsl */ `
uniform vec3 uBeamOrigin;
uniform vec3 uBeamDir;
uniform float uBeamGain;
float headlightBeam(vec3 p) {
if (uBeamGain <= 0.0) return 0.0;
vec3 ray = p - uBeamOrigin;
float d = length(ray);
float cosA = dot(ray / max(d, 0.001), uBeamDir);
float cone = smoothstep(${Math.cos(SPOT_ANGLE).toFixed(4)}, ${Math.cos(SPOT_ANGLE * (1 - SPOT_PENUMBRA)).toFixed(4)}, cosA);
float reach = 1.0 - smoothstep(${(SPOT_DISTANCE * 0.45).toFixed(1)}, ${SPOT_DISTANCE.toFixed(1)}, d);
return uBeamGain * cone * reach * (14.0 / (14.0 + d));
}
`;
/** How much open road is on screen: 1 between chapters, 0 under a plate. */
export function roadOpenness(
snapshot: Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>,
): number {
if (snapshot.activeSectionId === 'start') return 1;
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return 0;
const span = next - active;
if (!(span > 0)) return 0;
const p = -active / span;
return 1 - THREE.MathUtils.smoothstep(p, 0.02, 0.12) * (1 - THREE.MathUtils.smoothstep(p, 0.8, 0.92));
}
/** Presence (0..1, ramps in with the sweep) and high-beam lift (0..1). */
export function beamLevels(beam: BeamSample | undefined): { presence: number; lift: number } {
if (!beam) return { presence: 0, lift: 0 };
const i = THREE.MathUtils.clamp(beam.intensity, 0, 1);
return {
presence: Math.min(1, i / REST_INTENSITY),
lift: Math.max(0, (i - REST_INTENSITY) / (1 - REST_INTENSITY)),
};
}
export interface HeadlightsWorld {
/** World-space lights (the spot and its target); add to the scene root. */
readonly lights: unknown[];
/** Returns the yaw (-1..1) and lift (0..1) for the road cones. */
update(
beam: BeamSample | undefined,
snapshot: Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>,
camera: Camera,
engineOn: number,
dt: number,
): { yaw: number; lift: number };
}
/**
* The spot lives in world space at the scene root, so hiding the car on a
* short viewport never changes the light count (no shader recompiles); the
* haze cone rides on the car.
*/
export function createHeadlights(
resources: SceneResourceBag,
tier: LiveQualityTier,
car: Object3D,
): HeadlightsWorld {
const lit = tier !== 'low';
const spot = lit ? new THREE.SpotLight(SPOT_COLOR, 0, SPOT_DISTANCE, SPOT_ANGLE, SPOT_PENUMBRA, 2) : null;
if (spot) {
spot.name = 'high-beam';
spot.castShadow = false;
spot.target.name = 'high-beam-target';
}
let cone: any = null;
let coneMaterial: any = null;
if (tier === 'high') {
const geometry = resources.own('geometry', new THREE.PlaneGeometry(CONE_WIDTH, CONE_LENGTH));
geometry.translate(0, CONE_LENGTH / 2, 0);
coneMaterial = resources.own('material', new THREE.MeshBasicMaterial({
map: resources.own('texture', coneTexture()),
color: SPOT_COLOR,
transparent: true,
opacity: 0,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
}));
cone = new THREE.Mesh(geometry, coneMaterial);
cone.name = 'high-beam-cone';
cone.visible = false;
cone.position.copy(LAMP);
cone.renderOrder = 2;
car.add(cone);
}
const direction = new THREE.Vector3();
const toCamera = new THREE.Vector3();
const across = new THREE.Vector3();
const basis = new THREE.Matrix4();
const origin = new THREE.Vector3();
let open = 1;
return {
lights: spot ? [spot, spot.target] : [],
update(beam, snapshot, camera, engineOn, dt) {
const { presence, lift } = beamLevels(beam);
const x = beam ? THREE.MathUtils.clamp(beam.x, -1, 1) : 0;
const y = beam ? THREE.MathUtils.clamp(beam.y, -1, 1) : 0;
const yaw = x * BEAM_YAW_RAD * presence;
const pitch = BASE_PITCH - y * BEAM_PITCH_RAD * presence;
direction.set(Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch));
const strength = (beam ? THREE.MathUtils.clamp(beam.intensity, 0, 1) : 0) * engineOn;
if (spot) {
// World space, shared with the unlit districts and terrain.
car.updateWorldMatrix(true, false);
const worldOrigin = beamUniforms.uBeamOrigin.value.copy(LAMP).applyMatrix4(car.matrixWorld);
const worldDir = beamUniforms.uBeamDir.value.copy(direction).transformDirection(car.matrixWorld);
spot.intensity = SPOT_INTENSITY * strength;
spot.position.copy(worldOrigin);
spot.target.position.copy(worldOrigin).addScaledVector(worldDir, 30);
spot.target.updateMatrixWorld();
beamUniforms.uBeamGain.value = strength;
} else {
beamUniforms.uBeamGain.value = 0;
}
if (cone && coneMaterial) {
const target = roadOpenness(snapshot);
open += (target - open) * (1 - Math.exp(-Math.max(0, dt) / 0.3));
coneMaterial.opacity = CONE_OPACITY * strength * open;
cone.visible = coneMaterial.opacity > 0.002;
if (cone.visible) {
// Billboard about the beam axis: the haze always faces the lens.
car.worldToLocal(toCamera.copy(camera.position)).sub(origin.copy(LAMP));
toCamera.addScaledVector(direction, -toCamera.dot(direction));
if (toCamera.lengthSq() < 1e-6) toCamera.set(0, 1, 0);
toCamera.normalize();
across.crossVectors(direction, toCamera).normalize();
cone.quaternion.setFromRotationMatrix(basis.makeBasis(across, direction, toCamera));
}
}
return { yaw: x * presence, lift: lift * engineOn };
},
};
}
/** A small fan of haze: narrow at the lamp, widening and fading down the road. */
function coneTexture() {
const width = 32;
const height = 64;
const data = new Uint8Array(width * height * 4);
for (let row = 0; row < height; row++) {
const v = row / (height - 1);
const half = 0.07 + 0.4 * v;
const along = THREE.MathUtils.smoothstep(v, 0, 0.12) * (1 - v) ** 1.6;
for (let column = 0; column < width; column++) {
const u = column / (width - 1) - 0.5;
const value = Math.round(255 * along * Math.exp(-((u / half) ** 2) * 2.5));
data.set([value, value, value, 255], (row * width + column) * 4);
}
}
const texture = new THREE.DataTexture(data, width, height);
texture.needsUpdate = true;
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearFilter;
return texture;
}

View File

@@ -18,6 +18,8 @@ export interface RoadWorld {
acceleration: number,
palette?: Readonly<MutableChapterPalette>,
): void;
/** HIGH BEAM: yaw (beam x, -1..1, scaled by presence) and high-beam lift (0..1). */
setBeam(yaw: number, lift: number): void;
}
interface WorldGroup {
@@ -56,6 +58,8 @@ export function createRoad(
uLaneColor: { value: new THREE.Color(0xffb568) },
uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
uBeamYaw: { value: 0 },
uBeamLift: { value: 0 },
};
const material = resources.own(
'material',
@@ -89,6 +93,8 @@ export function createRoad(
uniform vec3 uLaneColor;
uniform vec3 uFogColor;
uniform vec3 uHazeColor;
uniform float uBeamYaw;
uniform float uBeamLift;
varying vec2 vRoadUv;
varying float vRoadX;
varying float vViewDistance;
@@ -184,10 +190,14 @@ export function createRoad(
// Headlight throw: two warm cones ahead of the car merging into a pool.
float ahead = -vWorldZ - 5.0;
float throwZ = smoothstep(-3.0, 5.0, ahead) * (1.0 - smoothstep(26.0, 92.0, ahead));
// HIGH BEAM: the cones yaw with the pointer (3.4 units at the pool,
// growing with distance like a turned lamp) and reach further on a flash.
float throwZ = smoothstep(-3.0, 5.0, ahead) * (1.0 - smoothstep(26.0, mix(92.0, 120.0, uBeamLift), ahead));
float spread = 0.9 + max(0.0, ahead) * 0.07;
float cones = exp(-pow((vRoadX - 1.25) / spread, 2.0)) + exp(-pow((vRoadX + 1.25) / spread, 2.0));
color += vec3(1.0, 0.84, 0.66) * min(cones, 1.4) * throwZ * (0.07 + 0.16 * film) * asphaltMask;
float beamX = vRoadX - uBeamYaw * 3.4 * clamp(ahead / 26.0, 0.0, 4.6);
float cones = exp(-pow((beamX - 1.25) / spread, 2.0)) + exp(-pow((beamX + 1.25) / spread, 2.0));
color += vec3(1.0, 0.84, 0.66) * min(cones, 1.4) * throwZ * (0.07 + 0.16 * film) * asphaltMask
* (1.0 + 0.3 * uBeamLift);
// Pylon lamps reflect as long streaks towards the camera.
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
@@ -233,6 +243,10 @@ export function createRoad(
uniforms.uHazeColor.value.copy(palette.haze);
}
},
setBeam(yaw, lift) {
uniforms.uBeamYaw.value = THREE.MathUtils.clamp(finiteOrZero(yaw), -1, 1);
uniforms.uBeamLift.value = clamp01(lift);
},
};
}

View File

@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { BEAM_PARS, beamUniforms } from './headlights';
import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
@@ -235,7 +236,7 @@ export function createTerrain(
const groundMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
uniforms: { ...uniforms, ...beamUniforms },
vertexShader: material.vertexShader.replace(
'displaced.y += height + 0.028;',
'displaced.y += height - 0.12;',
@@ -249,11 +250,15 @@ export function createTerrain(
varying float vDepthFade;
varying vec3 vTerrainPosition;
varying vec3 vGridColor;
${BEAM_PARS}
void main() {
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
vec3 color = mix(vec3(0.012, 0.003, 0.024), vGridColor * (0.065 + uSurge * 0.016), light);
color += uHighColor * pow(vHeight, 2.0) * light * 0.06;
// HIGH BEAM: the ridges beside the road catch the headlights.
float beam = headlightBeam(vTerrainPosition);
color += vec3(1.0, 0.84, 0.66) * beam * (0.3 + 0.7 * abs(dot(normal, normalize(uBeamOrigin - vTerrainPosition)))) * 0.28;
color = mix(uFogColor * 0.55, color, vDepthFade);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>