From 3c932cb8401aef558373a4886877da3a4f57bb90 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 14:30:03 +0300 Subject: [PATCH] feat: rebuild the nightdrive road and terrain --- src/scene/render-quality.ts | 36 ++++- src/scene/world/environment.ts | 13 +- src/scene/world/palette.ts | 173 +++++++++++++++++++++++ src/scene/world/road.ts | 133 +++++++++++++----- src/scene/world/terrain.ts | 219 +++++++++++++++++++++++++---- tests/unit/scene-resources.test.ts | 30 +++- tests/unit/world-models.test.ts | 164 +++++++++++++++++++++ 7 files changed, 694 insertions(+), 74 deletions(-) create mode 100644 src/scene/world/palette.ts create mode 100644 tests/unit/world-models.test.ts diff --git a/src/scene/render-quality.ts b/src/scene/render-quality.ts index 5c06131..bc0202e 100644 --- a/src/scene/render-quality.ts +++ b/src/scene/render-quality.ts @@ -2,15 +2,42 @@ import type { LiveQualityTier } from '@/scene/types'; export interface RenderQualitySettings { pixelRatio: number; + roadSegments: number; terrainSegments: number; + terrainOctaves: 1 | 2 | 3; particleCount: number; + streakCount: number; postEffects: boolean; } const settings: Readonly & { dprCap: number }>> = { - high: { dprCap: 1.5, terrainSegments: 96, particleCount: 384, postEffects: true }, - medium: { dprCap: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true }, - low: { dprCap: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false }, + high: { + dprCap: 1.5, + roadSegments: 192, + terrainSegments: 96, + terrainOctaves: 3, + particleCount: 384, + streakCount: 48, + postEffects: true, + }, + medium: { + dprCap: 1.5, + roadSegments: 144, + terrainSegments: 64, + terrainOctaves: 2, + particleCount: 192, + streakCount: 28, + postEffects: true, + }, + low: { + dprCap: 1.25, + roadSegments: 96, + terrainSegments: 32, + terrainOctaves: 1, + particleCount: 64, + streakCount: 12, + postEffects: false, + }, }; export function renderQualityFor( @@ -22,8 +49,11 @@ export function renderQualityFor( const finiteDpr = Number.isFinite(devicePixelRatio) ? Math.max(1, devicePixelRatio) : 1; return { pixelRatio: Math.min(finiteDpr, selected.dprCap, mobileOrTablet ? 1.5 : 2), + roadSegments: selected.roadSegments, terrainSegments: selected.terrainSegments, + terrainOctaves: selected.terrainOctaves, particleCount: selected.particleCount, + streakCount: selected.streakCount, postEffects: selected.postEffects, }; } diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index a657c31..382e031 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -73,8 +73,8 @@ export async function createProceduralSceneGeneration( rearLight.position.set(0, 1.2, 7); scene.add(rearLight); - const road = createRoad(context.resources); - const terrain = createTerrain(context.resources, quality.terrainSegments); + const road = createRoad(context.resources, quality); + const terrain = createTerrain(context.resources, quality); const sun = createSun(context.resources); const car = createCar(context.resources); const sign = createSign(context.resources); @@ -177,8 +177,13 @@ export async function createProceduralSceneGeneration( const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000)); lastNow = now; driveDistance += snapshot.driveSpeed * deltaSeconds * 18; - road.update(driveDistance, snapshot.driveSpeed); - terrain.update(driveDistance); + road.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration); + terrain.update( + driveDistance, + snapshot.driveSpeed, + snapshot.chapter, + snapshot.routeProgress, + ); sun.update(snapshot.chapter, snapshot.routeProgress); car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000); sign.update(snapshot.sign); diff --git a/src/scene/world/palette.ts b/src/scene/world/palette.ts new file mode 100644 index 0000000..a3c6520 --- /dev/null +++ b/src/scene/world/palette.ts @@ -0,0 +1,173 @@ +import * as THREE from 'three'; +import type { EnvironmentChapter } from '@/journey/types'; + +export interface ChapterPaletteDefinition { + sky: number; + fog: number; + terrainLow: number; + terrainHigh: number; + sun: number; + haze: number; + warmth: number; + terrainEnergy: number; + starVisibility: number; + streakEnergy: number; +} + +export interface MutableChapterPalette { + sky: MutableColor; + fog: MutableColor; + terrainLow: MutableColor; + terrainHigh: MutableColor; + sun: MutableColor; + haze: MutableColor; + warmth: number; + terrainEnergy: number; + starVisibility: number; + streakEnergy: number; +} + +export interface MutableColor { + clone(): MutableColor; + getHex(): number; + lerpColors(from: MutableColor, to: MutableColor, progress: number): MutableColor; + setHex(value: number): MutableColor; +} + +export const CHAPTER_PALETTES: Readonly> = { + 'deep-night': { + sky: 0x040713, + fog: 0x07101c, + terrainLow: 0x126a7c, + terrainHigh: 0xa53a74, + sun: 0xc7437e, + haze: 0x65225f, + warmth: 0.12, + terrainEnergy: 0.78, + starVisibility: 1, + streakEnergy: 0.72, + }, + systems: { + sky: 0x050918, + fog: 0x081622, + terrainLow: 0x168aa0, + terrainHigh: 0xa83f79, + sun: 0xd05282, + haze: 0x6d2968, + warmth: 0.22, + terrainEnergy: 0.9, + starVisibility: 0.94, + streakEnergy: 0.86, + }, + infrastructure: { + sky: 0x060a1b, + fog: 0x091a2a, + terrainLow: 0x197f98, + terrainHigh: 0xa13d7a, + sun: 0xd45b82, + haze: 0x6f2d70, + warmth: 0.3, + terrainEnergy: 0.88, + starVisibility: 0.86, + streakEnergy: 0.82, + }, + builds: { + sky: 0x0b0d22, + fog: 0x111d2d, + terrainLow: 0x1a8ba0, + terrainHigh: 0xb04479, + sun: 0xe16d7a, + haze: 0x7f356c, + warmth: 0.46, + terrainEnergy: 1, + starVisibility: 0.72, + streakEnergy: 1, + }, + 'pre-dawn': { + sky: 0x15142a, + fog: 0x1c2433, + terrainLow: 0x277f91, + terrainHigh: 0x9c4b78, + sun: 0xf09667, + haze: 0xa45468, + warmth: 0.76, + terrainEnergy: 0.72, + starVisibility: 0.38, + streakEnergy: 0.52, + }, + destination: { + sky: 0x241b31, + fog: 0x2b2b38, + terrainLow: 0x386f79, + terrainHigh: 0x87506e, + sun: 0xf4b06d, + haze: 0xbb6b65, + warmth: 1, + terrainEnergy: 0.54, + starVisibility: 0.16, + streakEnergy: 0.18, + }, +}; + +const paletteColors = Object.fromEntries( + Object.entries(CHAPTER_PALETTES).map(([chapter, palette]) => [chapter, { + sky: new THREE.Color(palette.sky), + fog: new THREE.Color(palette.fog), + terrainLow: new THREE.Color(palette.terrainLow), + terrainHigh: new THREE.Color(palette.terrainHigh), + sun: new THREE.Color(palette.sun), + haze: new THREE.Color(palette.haze), + }]), +) as Readonly>>; + +export function createMutableChapterPalette( + chapter: EnvironmentChapter = 'deep-night', +): MutableChapterPalette { + const source = CHAPTER_PALETTES[chapter]; + const colors = paletteColors[chapter]; + return { + sky: colors.sky.clone(), + fog: colors.fog.clone(), + terrainLow: colors.terrainLow.clone(), + terrainHigh: colors.terrainHigh.clone(), + sun: colors.sun.clone(), + haze: colors.haze.clone(), + warmth: source.warmth, + terrainEnergy: source.terrainEnergy, + starVisibility: source.starVisibility, + streakEnergy: source.streakEnergy, + }; +} + +export function writeChapterPalette( + target: MutableChapterPalette, + from: EnvironmentChapter, + to: EnvironmentChapter, + progress: number, +): MutableChapterPalette { + const start = CHAPTER_PALETTES[from]; + const end = CHAPTER_PALETTES[to]; + const startColors = paletteColors[from]; + const endColors = paletteColors[to]; + const mix = clamp01(progress); + target.sky.lerpColors(startColors.sky, endColors.sky, mix); + target.fog.lerpColors(startColors.fog, endColors.fog, mix); + target.terrainLow.lerpColors(startColors.terrainLow, endColors.terrainLow, mix); + target.terrainHigh.lerpColors(startColors.terrainHigh, endColors.terrainHigh, mix); + target.sun.lerpColors(startColors.sun, endColors.sun, mix); + target.haze.lerpColors(startColors.haze, endColors.haze, mix); + target.warmth = lerp(start.warmth, end.warmth, mix); + target.terrainEnergy = lerp(start.terrainEnergy, end.terrainEnergy, mix); + target.starVisibility = lerp(start.starVisibility, end.starVisibility, mix); + target.streakEnergy = lerp(start.streakEnergy, end.streakEnergy, mix); + return target; +} + +function lerp(from: number, to: number, progress: number): number { + return from + (to - from) * progress; +} + +function clamp01(value: number): number { + return Number.isFinite(value) ? THREE.MathUtils.clamp(value, 0, 1) : 0; +} diff --git a/src/scene/world/road.ts b/src/scene/world/road.ts index 245d8a4..f5c4e4f 100644 --- a/src/scene/world/road.ts +++ b/src/scene/world/road.ts @@ -1,50 +1,111 @@ import * as THREE from 'three'; +import type { RenderQualitySettings } from '@/scene/render-quality'; import type { SceneResourceBag } from '@/scene/resources'; -export function createRoad(resources: SceneResourceBag) { +export interface RoadWorld { + group: WorldGroup; + update(distance: number, speed: number, acceleration: number): void; +} + +interface WorldGroup { + add(...objects: unknown[]): void; + getObjectByName(name: string): unknown; +} + +const ROAD_WIDTH = 22; +const ROAD_DEPTH = 300; +const ROAD_CENTER_Z = -136; + +export function createRoad( + resources: SceneResourceBag, + quality: RenderQualitySettings, +): RoadWorld { const group = new THREE.Group(); - const roadGeometry = resources.own('geometry', new THREE.PlaneGeometry(18, 240)); - const roadMaterial = resources.own('material', new THREE.MeshStandardMaterial({ - color: 0x070b16, - roughness: 0.88, - metalness: 0.12, + group.name = 'road-world'; + + const geometry = resources.own( + 'geometry', + new THREE.PlaneGeometry(ROAD_WIDTH, ROAD_DEPTH, 16, quality.roadSegments), + ); + geometry.rotateX(-Math.PI / 2); + geometry.translate(0, 0, ROAD_CENTER_Z); + + const uniforms = { + uDistance: { value: 0 }, + uSpeed: { value: 0 }, + uAcceleration: { value: 0 }, + uAsphalt: { value: new THREE.Color(0x03070f) }, + uAsphaltLift: { value: new THREE.Color(0x091422) }, + uEdgeColor: { value: new THREE.Color(0x64d9e7) }, + uLaneColor: { value: new THREE.Color(0xf1a35e) }, + }; + const material = resources.own('material', new THREE.ShaderMaterial({ + uniforms, + vertexShader: ` + varying vec2 vRoadUv; + varying float vDepth; + + void main() { + vRoadUv = uv; + vDepth = smoothstep(0.0, 1.0, uv.y); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uDistance; + uniform float uSpeed; + uniform float uAcceleration; + uniform vec3 uAsphalt; + uniform vec3 uAsphaltLift; + uniform vec3 uEdgeColor; + uniform vec3 uLaneColor; + varying vec2 vRoadUv; + varying float vDepth; + + void main() { + float roadX = abs(vRoadUv.x - 0.5) * 2.0; + float centerValley = 1.0 - smoothstep(0.0, 0.82, roadX); + float outerEdge = smoothstep(0.82, 0.9, roadX) * (1.0 - smoothstep(0.94, 1.0, roadX)); + float shoulder = smoothstep(0.66, 0.92, roadX) * 0.12; + float laneWidth = 1.0 - smoothstep(0.012, 0.03, abs(vRoadUv.x - 0.5)); + float laneCycle = fract(vRoadUv.y * 34.0 + uDistance * (0.12 + uSpeed * 0.025)); + float laneDash = smoothstep(0.16, 0.24, laneCycle) * (1.0 - smoothstep(0.68, 0.78, laneCycle)); + float centerLane = laneWidth * laneDash * smoothstep(0.04, 0.22, vRoadUv.y); + float edgeLuminance = outerEdge * (0.58 + uAcceleration * 0.34); + float grain = sin((vRoadUv.y * 410.0 + vRoadUv.x * 37.0) + uDistance * 0.08) * 0.008; + + vec3 color = mix(uAsphaltLift, uAsphalt, centerValley * 0.84); + color += uEdgeColor * (edgeLuminance + shoulder); + color += uLaneColor * centerLane * (0.66 + uSpeed * 0.18); + color += grain; + color *= mix(0.7, 1.0, smoothstep(0.0, 0.24, vRoadUv.y)); + gl_FragColor = vec4(color, 1.0); + } + `, + depthWrite: true, + depthTest: true, + toneMapped: true, })); - const road = new THREE.Mesh(roadGeometry, roadMaterial); - road.rotation.x = -Math.PI / 2; - road.position.z = -92; - group.add(road); - const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 0.035, 240)); - const edgeMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x38dff2 })); - for (const x of [-8.65, 8.65]) { - const edge = new THREE.Mesh(edgeGeometry, edgeMaterial); - edge.position.set(x, 0.035, -92); - group.add(edge); - } - - const markerGeometry = resources.own('geometry', new THREE.BoxGeometry(0.14, 0.025, 3.8)); - const markerMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff9e68 })); - const markers: Array<{ position: { z: number } }> = []; - for (let index = 0; index < 22; index += 1) { - const marker = new THREE.Mesh(markerGeometry, markerMaterial); - marker.position.set(0, 0.045, 8 - index * 10); - markers.push(marker); - group.add(marker); - } + const surface = new THREE.Mesh(geometry, material); + surface.name = 'road-surface'; + surface.receiveShadow = true; + group.add(surface); return { group, - update(distance: number, speed: number) { - const flow = distance * (5 + speed * 8); - for (let index = 0; index < markers.length; index += 1) { - const marker = markers[index]; - if (!marker) continue; - marker.position.z = 8 - modulo(index * 10 - flow, 220); - } + update(distance, speed, acceleration) { + uniforms.uDistance.value = finiteOrZero(distance); + uniforms.uSpeed.value = clamp01(speed); + uniforms.uAcceleration.value = clamp01(acceleration); }, }; } -function modulo(value: number, divisor: number): number { - return ((value % divisor) + divisor) % divisor; +function finiteOrZero(value: number): number { + return Number.isFinite(value) ? value : 0; +} + +function clamp01(value: number): number { + return THREE.MathUtils.clamp(finiteOrZero(value), 0, 1); } diff --git a/src/scene/world/terrain.ts b/src/scene/world/terrain.ts index 2213db8..918316a 100644 --- a/src/scene/world/terrain.ts +++ b/src/scene/world/terrain.ts @@ -1,43 +1,206 @@ 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 function createTerrain(resources: SceneResourceBag, segments: number) { +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(); - const geometry = resources.own( - 'geometry', - new THREE.PlaneGeometry(72, 240, Math.max(4, Math.floor(segments / 4)), segments), - ); - const cyan = resources.own('material', new THREE.MeshBasicMaterial({ - color: 0x168fae, - wireframe: true, + 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, - opacity: 0.34, - })); - const magenta = resources.own('material', new THREE.MeshBasicMaterial({ - color: 0xbd317f, - wireframe: true, - transparent: true, - opacity: 0.22, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + toneMapped: true, })); - const planes: Array<{ position: { z: number } }> = []; - for (const [x, material] of [[-44, magenta], [44, cyan]] as const) { - const plane = new THREE.Mesh(geometry, material); - plane.rotation.x = -Math.PI / 2; - plane.position.set(x, -0.2, -92); - planes.push(plane); - group.add(plane); - } + const lines = new THREE.LineSegments(geometry, material); + lines.name = 'terrain-lines'; + lines.frustumCulled = false; + group.add(lines); return { group, - update(distance: number) { - const drift = modulo(distance * 1.4, 8); - for (const plane of planes) plane.position.z = -92 + drift; + 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 modulo(value: number, divisor: number): number { - return ((value % divisor) + divisor) % divisor; +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); } diff --git a/tests/unit/scene-resources.test.ts b/tests/unit/scene-resources.test.ts index 2583abf..cbad943 100644 --- a/tests/unit/scene-resources.test.ts +++ b/tests/unit/scene-resources.test.ts @@ -155,9 +155,33 @@ describe('scene render quality', () => { const medium = renderQualityFor('medium', 3, false); const low = renderQualityFor('low', 3, false); - expect(high).toEqual({ pixelRatio: 1.5, terrainSegments: 96, particleCount: 384, postEffects: true }); - expect(medium).toEqual({ pixelRatio: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true }); - expect(low).toEqual({ pixelRatio: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false }); + expect(high).toEqual({ + pixelRatio: 1.5, + roadSegments: 192, + terrainSegments: 96, + terrainOctaves: 3, + particleCount: 384, + streakCount: 48, + postEffects: true, + }); + expect(medium).toEqual({ + pixelRatio: 1.5, + roadSegments: 144, + terrainSegments: 64, + terrainOctaves: 2, + particleCount: 192, + streakCount: 28, + postEffects: true, + }); + expect(low).toEqual({ + pixelRatio: 1.25, + roadSegments: 96, + terrainSegments: 32, + terrainOctaves: 1, + particleCount: 64, + streakCount: 12, + postEffects: false, + }); }); it('caps mobile and tablet pixel ratio at 1.5 even for High', () => { diff --git a/tests/unit/world-models.test.ts b/tests/unit/world-models.test.ts new file mode 100644 index 0000000..58e8009 --- /dev/null +++ b/tests/unit/world-models.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; +import type { EnvironmentChapter } from '@/journey/types'; +import { renderQualityFor } from '@/scene/render-quality'; +import { SceneResourceBag } from '@/scene/resources'; +import { + CHAPTER_PALETTES, + createMutableChapterPalette, + writeChapterPalette, +} from '@/scene/world/palette'; +import { createRoad } from '@/scene/world/road'; +import { createTerrain } from '@/scene/world/terrain'; + +describe('procedural world models', () => { + it('scales geometry and atmosphere work monotonically by live quality tier', () => { + const high = renderQualityFor('high', 3, false); + const medium = renderQualityFor('medium', 3, false); + const low = renderQualityFor('low', 3, false); + + expect(high.roadSegments).toBeGreaterThan(medium.roadSegments); + expect(medium.roadSegments).toBeGreaterThan(low.roadSegments); + expect(high.terrainSegments).toBeGreaterThan(medium.terrainSegments); + expect(medium.terrainSegments).toBeGreaterThan(low.terrainSegments); + expect(high.streakCount).toBeGreaterThan(low.streakCount); + expect(low.postEffects).toBe(false); + }); + + it('builds a shader road whose motion is entirely uniform-driven', () => { + const resources = new SceneResourceBag(); + const road = createRoad(resources, renderQualityFor('high', 1, false)); + const surface = road.group.getObjectByName('road-surface') as ShaderRenderable; + + expect(surface).toBeInstanceOf(THREE.Mesh); + expect(surface.geometry.index).not.toBeNull(); + expect(surface.geometry.getAttribute('position').count).toBeGreaterThan(1_000); + expect(surface.material.uniforms).toMatchObject({ + uDistance: { value: 0 }, + uSpeed: { value: 0 }, + uAcceleration: { value: 0 }, + }); + expect(surface.material.fragmentShader).toContain('centerLane'); + expect(surface.material.fragmentShader).toContain('outerEdge'); + + const positionArray = surface.geometry.getAttribute('position').array; + const before = resources.snapshot(); + for (let index = 0; index < 300; index += 1) { + road.update(index * 0.5, 0.65, 0.4); + } + + expect(resources.snapshot()).toEqual(before); + expect(surface.geometry.getAttribute('position').array).toBe(positionArray); + expect(surface.material.uniforms.uDistance?.value).toBe(149.5); + expect(surface.material.uniforms.uSpeed?.value).toBe(0.65); + expect(surface.material.uniforms.uAcceleration?.value).toBe(0.4); + resources.dispose(); + }); + + it('builds one wide indexed line terrain with a flat road-center mask', () => { + const resources = new SceneResourceBag(); + const terrain = createTerrain(resources, renderQualityFor('medium', 1, false)); + const lines = terrain.group.getObjectByName('terrain-lines') as ShaderRenderable; + + expect(lines).toBeInstanceOf(THREE.LineSegments); + expect(lines.geometry.index).not.toBeNull(); + lines.geometry.computeBoundingBox(); + expect(lines.geometry.boundingBox).not.toBeNull(); + expect( + (lines.geometry.boundingBox?.max.x ?? 0) - (lines.geometry.boundingBox?.min.x ?? 0), + ).toBeGreaterThan(120); + + const roadMask = lines.geometry.getAttribute('roadMask'); + expect(roadMask).toBeDefined(); + const maskValues = Array.from(roadMask.array as ArrayLike); + expect(Math.min(...maskValues)).toBe(0); + expect(Math.max(...maskValues)).toBeGreaterThan(0.99); + expect(lines.material.uniforms).toMatchObject({ + uDistance: { value: 0 }, + uSpeed: { value: 0 }, + uRouteProgress: { value: 0 }, + }); + expect(lines.material.vertexShader).toContain('multiOctaveNoise'); + + const indexArray = lines.geometry.index?.array; + const positionArray = lines.geometry.getAttribute('position').array; + const lowColor = lines.material.uniforms.uLowColor?.value; + const highColor = lines.material.uniforms.uHighColor?.value; + const before = resources.snapshot(); + for (let index = 0; index < 300; index += 1) { + terrain.update(index * 0.4, 0.7, 'builds', index / 299); + } + + expect(resources.snapshot()).toEqual(before); + expect(lines.geometry.index?.array).toBe(indexArray); + expect(lines.geometry.getAttribute('position').array).toBe(positionArray); + expect(lines.material.uniforms.uLowColor?.value).toBe(lowColor); + expect(lines.material.uniforms.uHighColor?.value).toBe(highColor); + expect(lines.material.uniforms.uRouteProgress?.value).toBe(1); + resources.dispose(); + }); + + it('writes deterministic chapter blends into stable color allocations', () => { + const target = createMutableChapterPalette('deep-night'); + const colorReferences = [ + target.sky, + target.fog, + target.terrainLow, + target.terrainHigh, + target.sun, + target.haze, + ]; + + expect(Object.keys(CHAPTER_PALETTES)).toEqual([ + 'deep-night', + 'systems', + 'infrastructure', + 'builds', + 'pre-dawn', + 'destination', + ] satisfies EnvironmentChapter[]); + expect(writeChapterPalette(target, 'deep-night', 'destination', 0.5)).toBe(target); + const first = paletteSnapshot(target); + expect(writeChapterPalette(target, 'deep-night', 'destination', 0.5)).toBe(target); + expect(paletteSnapshot(target)).toEqual(first); + expect([ + target.sky, + target.fog, + target.terrainLow, + target.terrainHigh, + target.sun, + target.haze, + ]).toEqual(colorReferences); + expect(target.warmth).toBeGreaterThan(CHAPTER_PALETTES['deep-night'].warmth); + expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth); + }); +}); + +function paletteSnapshot(palette: ReturnType) { + return { + sky: palette.sky.getHex(), + fog: palette.fog.getHex(), + terrainLow: palette.terrainLow.getHex(), + terrainHigh: palette.terrainHigh.getHex(), + sun: palette.sun.getHex(), + haze: palette.haze.getHex(), + warmth: palette.warmth, + terrainEnergy: palette.terrainEnergy, + starVisibility: palette.starVisibility, + streakEnergy: palette.streakEnergy, + }; +} + +interface ShaderRenderable { + geometry: { + index: { array: ArrayLike } | null; + boundingBox: { min: { x: number }; max: { x: number } } | null; + computeBoundingBox(): void; + getAttribute(name: string): { count: number; array: ArrayLike }; + }; + material: { + uniforms: Record; + vertexShader: string; + fragmentShader: string; + }; +}