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, 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 #include } `, 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 #include } `, 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); }