feat: cursor-steered headlights light the hero name, plates and world
This commit is contained in:
@@ -167,6 +167,8 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
||||
try {
|
||||
const frameStartedAt = scheduler.now();
|
||||
const active = current;
|
||||
const beam = options.sampleBeam?.(now);
|
||||
if (beam) latestJourney.beam = { x: finite(beam.x), y: finite(beam.y), intensity: finite(beam.intensity) };
|
||||
const complete = active.runtime.render(latestJourney, now) !== false;
|
||||
const frameEndedAt = scheduler.now();
|
||||
if (complete && current === active) {
|
||||
@@ -589,6 +591,13 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
|
||||
? null
|
||||
: finite(snapshot.sectionActivation.nextHeadingOffsetVh),
|
||||
},
|
||||
...(snapshot.beam === undefined ? {} : {
|
||||
beam: {
|
||||
x: finite(snapshot.beam.x),
|
||||
y: finite(snapshot.beam.y),
|
||||
intensity: finite(snapshot.beam.intensity),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import type { BeamSample } from '@/journey/headlight-beam';
|
||||
import type { QualityTier } from '@/journey/motion-policy';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
@@ -55,6 +56,8 @@ export interface SceneControllerOptions {
|
||||
page?: ScenePageEnvironment;
|
||||
motionAllowed?: () => boolean;
|
||||
onFrameRendered?: ((start: number, end: number) => unknown) | undefined;
|
||||
/** HIGH BEAM: the live headlight sample, read once per rendered frame. */
|
||||
sampleBeam?: ((now: number) => BeamSample | null) | undefined;
|
||||
onStateChange?: ((state: SceneLifecycleState) => unknown) | undefined;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
206
src/scene/world/headlights.ts
Normal file
206
src/scene/world/headlights.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user