Files
Portfolio/src/scene/world/terrain.ts
LucaP f08dfc3fba feat: engine off at the destination
On arrival the drive winds down to a stop, the tail and running lamps
drop to parking level, the sky darkens about 10% with more stars and
the grid fades. The engine and road noise take a 1.2 s low-pass sweep
to idle. The letterbox stays within 8vh and the closing credit types
into the lower bar; reduced motion keeps only the bars and dimming.
2026-10-10 23:37:51 +03:00

391 lines
14 KiB
TypeScript

import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
import {
CHAPTER_PALETTES,
type MutableChapterPalette,
} from '@/scene/world/palette';
import {
HORIZON_FOG_END,
HORIZON_FOG_START,
WORLD_DEPTH,
WORLD_NEAR_Z,
} from '@/scene/world/metrics';
export interface TerrainWorld {
group: WorldGroup;
update(
distance: number,
speed: number,
chapter: EnvironmentChapter,
routeProgress: number,
palette?: Readonly<MutableChapterPalette>,
overdrive?: number,
surge?: number,
spectrumPhase?: number,
/** 0 driving, 1 parked: the grid fades out under the end shot. */
engineOff?: number,
): void;
}
interface WorldGroup {
add(...objects: unknown[]): void;
getObjectByName(name: string): unknown;
}
const TERRAIN_WIDTH = 620;
const ROAD_CLEAR_HALF_WIDTH = 8.2;
const RIDGE_START = 38;
const NOISE_PERIOD = 5_694;
const GRID_STEP = 6;
export function createTerrain(
resources: SceneResourceBag,
quality: RenderQualitySettings,
): TerrainWorld {
const group = new THREE.Group();
group.name = 'terrain-world';
const geometry = resources.own(
'geometry',
createIndexedTerrainGeometry(quality.terrainSegments),
);
const initialPalette = CHAPTER_PALETTES['deep-night'];
const lowColor = new THREE.Color(initialPalette.terrainLow);
const highColor = new THREE.Color(initialPalette.terrainHigh);
const uniforms = {
uDistance: { value: 0 },
uFlowDistance: { value: 0 },
uSpeed: { value: 0 },
uOverdrive: { value: 0 },
uSurge: { value: 0 },
uSpectrumPhase: { value: 0 },
uRouteProgress: { value: 0 },
uEngineOff: { value: 0 },
uTerrainEnergy: { value: initialPalette.terrainEnergy },
uOctaves: { value: quality.terrainOctaves },
uRelief: { value: 49 },
uFar: { value: HORIZON_FOG_END },
uLowColor: { value: lowColor },
uHighColor: { value: highColor },
uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
};
const material = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader: `
attribute float roadMask;
uniform float uDistance;
uniform float uFlowDistance;
uniform float uSpeed;
uniform float uOverdrive;
uniform float uSurge;
uniform float uSpectrumPhase;
uniform float uRouteProgress;
uniform float uTerrainEnergy;
uniform float uOctaves;
uniform float uRelief;
uniform float uFar;
varying float vHeight;
varying float vRoadMask;
varying float vDepthFade;
varying float vFlow;
varying vec3 vTerrainPosition;
varying vec3 vGridColor;
varying float vCrest;
// A closed palette ribbon: its ends meet in cyan so travel never
// produces a color seam. Compute it per vertex, not per pixel.
vec3 gridSpectrum(float phase) {
float p = fract(phase);
vec3 cyan = vec3(0.015, 0.82, 0.94);
vec3 color = mix(cyan, vec3(0.025, 0.12, 1.0), smoothstep(0.04, 0.22, p));
color = mix(color, vec3(0.48, 0.025, 1.0), smoothstep(0.22, 0.40, p));
color = mix(color, vec3(1.0, 0.02, 0.42), smoothstep(0.40, 0.60, p));
color = mix(color, vec3(1.0, 0.22, 0.045), smoothstep(0.60, 0.78, p));
color = mix(color, vec3(1.0, 0.55, 0.10), smoothstep(0.78, 0.87, p));
return mix(color, cyan, smoothstep(0.87, 1.0, p));
}
float hash21(vec2 point) {
point = mod(point, 256.0);
point = fract(point * vec2(123.34, 456.21));
point += dot(point, point + 45.32);
return fract(point.x * point.y);
}
float valueNoise(vec2 point) {
vec2 cell = floor(point);
vec2 local = fract(point);
local = local * local * (3.0 - 2.0 * local);
return mix(
mix(hash21(cell), hash21(cell + vec2(1.0, 0.0)), local.x),
mix(hash21(cell + vec2(0.0, 1.0)), hash21(cell + vec2(1.0)), local.x),
local.y
);
}
float multiOctaveNoise(vec2 point) {
float result = valueNoise(point) * 0.58;
if (uOctaves > 1.5) result += valueNoise(point * 2.0 + 17.2) * 0.28;
if (uOctaves > 2.5) result += valueNoise(point * 4.0 - 9.4) * 0.14;
return result;
}
void main() {
vec3 displaced = position;
float wrappedPhase = mod(uDistance, ${NOISE_PERIOD.toFixed(1)});
// Move the grid itself as well as its relief. Rows recycle beyond the
// camera and in horizon fog; spacing and noise share an exact period.
displaced.z += mod(wrappedPhase, ${GRID_STEP.toFixed(1)});
const float S = 0.04495961;
vec2 world = vec2(position.x, displaced.z - wrappedPhase);
float ridgeNoise = multiOctaveNoise(world * S);
// Sharpen and lift the same ridges rather than changing their noise
// coordinates. The road remains flat, and the far horizon stays calm.
float bank = smoothstep(12.0, 52.0, abs(position.x));
float nearRelief = 1.0 - smoothstep(360.0, 680.0, -displaced.z);
float energy = (uSurge * 0.52 + uOverdrive * 0.24) * bank * nearRelief;
ridgeNoise = mix(ridgeNoise, ridgeNoise * ridgeNoise * 1.8, energy * 0.48);
float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x)));
float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy * (1.0 + energy);
float crest = 0.5 + 0.5 * sin(world.y * ${((Math.PI * 2 * 34) / NOISE_PERIOD).toFixed(10)} + abs(position.x) * 0.075);
height += roadMask * bank * nearRelief * crest * crest * (uSurge * 3.8 + uOverdrive * 3.2);
vCrest = crest * crest * bank;
displaced.y += height + 0.028;
vTerrainPosition = displaced;
vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0);
vRoadMask = roadMask;
// Signed lateral variation gives each bank its own color rhythm.
// Whole longitudinal cycles preserve the existing terrain wrap.
float chroma = world.y * ${(31 / NOISE_PERIOD).toFixed(10)}
+ position.x * 0.014 + height * 0.025
+ 0.12 * sin(world.y * ${((Math.PI * 2 * 17) / NOISE_PERIOD).toFixed(10)} + position.x * 0.028);
vec3 cruiseColor = gridSpectrum(chroma + uSpectrumPhase);
vec3 speedSpectrum = gridSpectrum(world.y * ${(57 / NOISE_PERIOD).toFixed(10)}
+ abs(position.x) * 0.012 + uSpectrumPhase);
vGridColor = mix(cruiseColor, speedSpectrum, uOverdrive * 0.88);
vec4 viewPosition = modelViewMatrix * vec4(displaced, 1.0);
float viewDistance = -viewPosition.z;
vDepthFade = smoothstep(4.0, 18.0, viewDistance)
* (1.0 - smoothstep(${(HORIZON_FOG_START + 150).toFixed(1)}, ${HORIZON_FOG_END.toFixed(1)}, viewDistance));
// Whole cycles keep the color wave seamless at the terrain wrap.
vFlow = 0.5 + 0.5 * sin(world.y * ${((Math.PI * 2 * 44) / NOISE_PERIOD).toFixed(10)} + abs(position.x) * 0.13);
gl_Position = projectionMatrix * viewPosition;
}
`,
fragmentShader: `
uniform vec3 uLowColor;
uniform vec3 uHighColor;
uniform vec3 uFogColor;
uniform vec3 uHazeColor;
uniform float uRouteProgress;
uniform float uEngineOff;
uniform float uOverdrive;
uniform float uSurge;
varying float vHeight;
varying float vRoadMask;
varying float vDepthFade;
varying float vFlow;
varying vec3 vTerrainPosition;
varying vec3 vGridColor;
varying float vCrest;
void main() {
float ridgeMix = smoothstep(0.025, 0.29, vHeight);
vec3 color = mix(vGridColor, uLowColor, 0.06);
color = mix(color, uHighColor, ridgeMix * 0.055);
color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade);
float pulse = 0.88 + 0.12 * smoothstep(0.16, 0.55, vFlow);
float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
float road = 1.0 - smoothstep(7.6, 8.4, abs(vTerrainPosition.x));
float apron = 1.0 - smoothstep(12.0, 32.0, abs(vTerrainPosition.x));
float surfaceStrength = mix(0.72, 0.12 + uOverdrive * 0.3, road);
float alpha = vDepthFade * pulse * routeCalm * mix(surfaceStrength, 0.91, ridgeMix)
* (1.0 - uEngineOff * 0.72);
float charge = uOverdrive * (0.35 + vFlow * 0.8) * (0.5 + apron * 0.5);
charge += uSurge * (0.28 + vCrest * 0.5) * (1.0 - road);
gl_FragColor = vec4(color * (1.46 + ridgeMix * 0.54 + charge), alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
}),
);
const groundGeometry = resources.own('geometry', geometry.clone());
const rows = Math.ceil(WORLD_DEPTH / GRID_STEP);
const columns = geometry.attributes.position.count / (rows + 1) - 1;
const triangles: number[] = [];
for (let row = 0; row < rows; row++)
for (let column = 0; column < columns; column++) {
const a = row * (columns + 1) + column,
b = a + columns + 1;
triangles.push(a, b, a + 1, a + 1, b, b + 1);
}
groundGeometry.setIndex(triangles);
const groundMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader: material.vertexShader.replace(
'displaced.y += height + 0.028;',
'displaced.y += height - 0.12;',
),
fragmentShader: `
uniform vec3 uLowColor;
uniform vec3 uHighColor;
uniform vec3 uFogColor;
uniform float uSurge;
varying float vHeight;
varying float vDepthFade;
varying vec3 vTerrainPosition;
varying vec3 vGridColor;
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;
color = mix(uFogColor * 0.55, color, vDepthFade);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
side: THREE.DoubleSide,
depthWrite: true,
depthTest: true,
toneMapped: true,
}),
);
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.name = 'sculpted-terrain-surface';
ground.frustumCulled = false;
group.add(ground);
const lines = new THREE.LineSegments(geometry, material);
lines.name = 'terrain-grid';
lines.frustumCulled = false;
group.add(lines);
return {
group,
update(
distance,
speed,
chapter,
routeProgress,
palette,
overdrive = 0,
surge = 0,
spectrumPhase = 0,
engineOff = 0,
) {
uniforms.uEngineOff.value = clamp01(engineOff);
const chapterPalette = CHAPTER_PALETTES[chapter];
uniforms.uDistance.value =
((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
uniforms.uFlowDistance.value = uniforms.uDistance.value;
uniforms.uSpeed.value = clamp01(speed);
uniforms.uOverdrive.value = clamp01(overdrive);
uniforms.uSurge.value = clamp01(surge);
uniforms.uSpectrumPhase.value =
((finiteOrZero(spectrumPhase) % 1) + 1) % 1;
uniforms.uRouteProgress.value = clamp01(routeProgress);
uniforms.uTerrainEnergy.value =
palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
if (palette) {
lowColor.copy(palette.terrainLow);
highColor.copy(palette.terrainHigh);
uniforms.uFogColor.value.copy(palette.fog);
uniforms.uHazeColor.value.copy(palette.haze);
} else {
lowColor.setHex(chapterPalette.terrainLow);
highColor.setHex(chapterPalette.terrainHigh);
}
},
};
}
function createIndexedTerrainGeometry(segments: number) {
const zSegments = Math.ceil(WORLD_DEPTH / GRID_STEP);
// Concentrate columns in the road valley; large distant ridges need fewer.
const outerStep = segments >= 100 ? 12 : 18;
const xs: number[] = [];
for (let x = -TERRAIN_WIDTH / 2; x < -48; x += outerStep) xs.push(x);
for (let x = -48; x <= 48; x += GRID_STEP) xs.push(x);
for (let x = 48 + outerStep; x <= TERRAIN_WIDTH / 2; x += outerStep)
xs.push(x);
if (xs[xs.length - 1] !== TERRAIN_WIDTH / 2) xs.push(TERRAIN_WIDTH / 2);
const xSegments = xs.length - 1;
const columnCount = xSegments + 1;
const rowCount = zSegments + 1;
const positions = new Float32Array(columnCount * rowCount * 3);
const roadMasks = new Float32Array(columnCount * rowCount);
let vertexOffset = 0;
let maskOffset = 0;
for (let row = 0; row < rowCount; row += 1) {
const z = Math.ceil(WORLD_NEAR_Z / GRID_STEP) * GRID_STEP - row * GRID_STEP;
for (let column = 0; column < columnCount; column += 1) {
const x = xs[column]!;
positions[vertexOffset] = x;
positions[vertexOffset + 1] = 0;
positions[vertexOffset + 2] = z;
vertexOffset += 3;
roadMasks[maskOffset] = smoothstep(
ROAD_CLEAR_HALF_WIDTH,
RIDGE_START,
Math.abs(x),
);
maskOffset += 1;
}
}
const indices: number[] = [];
const vertexIndex = (column: number, row: number): number =>
row * columnCount + column;
for (let row = 0; row < rowCount; row += 1) {
for (let column = 0; column < columnCount; column += 1) {
if (row < zSegments) {
indices.push(vertexIndex(column, row), vertexIndex(column, row + 1));
}
if (column < xSegments) {
indices.push(vertexIndex(column, row), vertexIndex(column + 1, row));
}
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('roadMask', new THREE.BufferAttribute(roadMasks, 1));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
return geometry;
}
function smoothstep(edge0: number, edge1: number, value: number): number {
const progress = THREE.MathUtils.clamp(
(value - edge0) / (edge1 - edge0),
0,
1,
);
return progress * progress * (3 - 2 * progress);
}
function finiteOrZero(value: number): number {
return Number.isFinite(value) ? value : 0;
}
function clamp01(value: number): number {
return THREE.MathUtils.clamp(finiteOrZero(value), 0, 1);
}