207 lines
7.3 KiB
TypeScript
207 lines
7.3 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 } 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);
|
|
}
|