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 } from '@/scene/world/palette'; export interface TerrainWorld { group: WorldGroup; update( distance: number, speed: number, chapter: EnvironmentChapter, routeProgress: number, ): void; } interface WorldGroup { add(...objects: unknown[]): void; getObjectByName(name: string): unknown; } const TERRAIN_WIDTH = 160; const TERRAIN_DEPTH = 300; const NEAR_Z = 14; const ROAD_CLEAR_HALF_WIDTH = 11.4; const RIDGE_START = 24; 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 }, uSpeed: { value: 0 }, uRouteProgress: { value: 0 }, uTerrainEnergy: { value: initialPalette.terrainEnergy }, uOctaves: { value: quality.terrainOctaves }, uLowColor: { value: lowColor }, uHighColor: { value: highColor }, }; const material = resources.own('material', new THREE.ShaderMaterial({ uniforms, vertexShader: ` attribute float roadMask; uniform float uDistance; uniform float uSpeed; uniform float uRouteProgress; uniform float uTerrainEnergy; uniform float uOctaves; varying float vHeight; varying float vRoadMask; varying float vDepthFade; varying float vFlow; float hash21(vec2 point) { 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.03 + 17.2) * 0.28; if (uOctaves > 2.5) result += valueNoise(point * 4.07 - 9.4) * 0.14; return result; } void main() { vec3 displaced = position; float wrappedPhase = mod(uDistance * (0.18 + uSpeed * 0.06), 96.0); float ridgeNoise = multiOctaveNoise(vec2(position.x * 0.038, (position.z - wrappedPhase) * 0.028)); float macroRidge = multiOctaveNoise(vec2(position.x * 0.014 + 31.0, position.z * 0.012)); float edgeGrowth = mix(0.6, 1.0, smoothstep(0.0, 72.0, abs(position.x))); float height = roadMask * (0.25 + ridgeNoise * ridgeNoise * 8.8 + macroRidge * 2.4); height *= edgeGrowth * uTerrainEnergy; displaced.y += height - 0.22; vHeight = clamp(height / 10.0, 0.0, 1.0); vRoadMask = roadMask; vDepthFade = smoothstep(-286.0, -205.0, position.z) * (1.0 - smoothstep(7.0, 14.0, position.z)); vFlow = fract((-position.z + uDistance * (0.9 + uSpeed * 0.25)) / 11.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0); } `, fragmentShader: ` uniform vec3 uLowColor; uniform vec3 uHighColor; uniform float uRouteProgress; varying float vHeight; varying float vRoadMask; varying float vDepthFade; varying float vFlow; void main() { float ridgeMix = smoothstep(0.16, 0.78, vHeight); vec3 color = mix(uLowColor, uHighColor, ridgeMix); float pulse = 0.78 + 0.22 * smoothstep(0.18, 0.52, vFlow); float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress)); float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.34, 0.66, ridgeMix); gl_FragColor = vec4(color, alpha); } `, transparent: true, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, toneMapped: true, })); const lines = new THREE.LineSegments(geometry, material); lines.name = 'terrain-lines'; lines.frustumCulled = false; group.add(lines); return { group, update(distance, speed, chapter, routeProgress) { const palette = CHAPTER_PALETTES[chapter]; uniforms.uDistance.value = finiteOrZero(distance); uniforms.uSpeed.value = clamp01(speed); uniforms.uRouteProgress.value = clamp01(routeProgress); uniforms.uTerrainEnergy.value = palette.terrainEnergy; lowColor.setHex(palette.terrainLow); highColor.setHex(palette.terrainHigh); }, }; } function createIndexedTerrainGeometry(segments: number) { const zSegments = Math.max(16, Math.floor(segments)); const xSegments = Math.max(24, Math.floor(zSegments * 0.75 / 2) * 2); 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 = NEAR_Z - (row / zSegments) * TERRAIN_DEPTH; for (let column = 0; column < columnCount; column += 1) { const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH; 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) { const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH; if (row < zSegments && Math.abs(x) > ROAD_CLEAR_HALF_WIDTH) { indices.push(vertexIndex(column, row), vertexIndex(column, row + 1)); } if (column < xSegments) { const nextX = -TERRAIN_WIDTH / 2 + ((column + 1) / xSegments) * TERRAIN_WIDTH; const midpoint = (x + nextX) * 0.5; if (Math.abs(midpoint) > ROAD_CLEAR_HALF_WIDTH) { 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); }