400 lines
15 KiB
TypeScript
400 lines
15 KiB
TypeScript
import * as THREE from 'three';
|
|
import { BEAM_PARS, beamUniforms } from './headlights';
|
|
import { DAWN_PARS, dawnLightUniforms } from './dawn-light';
|
|
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: { ...uniforms, ...beamUniforms, ...dawnLightUniforms },
|
|
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;
|
|
${BEAM_PARS}
|
|
${DAWN_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;
|
|
// END OF SHIFT: the ridges catch first light from the low sun ahead.
|
|
color += dawnRim(normal, normalize(cameraPosition - vTerrainPosition));
|
|
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);
|
|
}
|