feat: rebuild the nightdrive road and terrain
This commit is contained in:
@@ -2,15 +2,42 @@ import type { LiveQualityTier } from '@/scene/types';
|
|||||||
|
|
||||||
export interface RenderQualitySettings {
|
export interface RenderQualitySettings {
|
||||||
pixelRatio: number;
|
pixelRatio: number;
|
||||||
|
roadSegments: number;
|
||||||
terrainSegments: number;
|
terrainSegments: number;
|
||||||
|
terrainOctaves: 1 | 2 | 3;
|
||||||
particleCount: number;
|
particleCount: number;
|
||||||
|
streakCount: number;
|
||||||
postEffects: boolean;
|
postEffects: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pixelRatio'> & { dprCap: number }>> = {
|
const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pixelRatio'> & { dprCap: number }>> = {
|
||||||
high: { dprCap: 1.5, terrainSegments: 96, particleCount: 384, postEffects: true },
|
high: {
|
||||||
medium: { dprCap: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true },
|
dprCap: 1.5,
|
||||||
low: { dprCap: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false },
|
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(
|
export function renderQualityFor(
|
||||||
@@ -22,8 +49,11 @@ export function renderQualityFor(
|
|||||||
const finiteDpr = Number.isFinite(devicePixelRatio) ? Math.max(1, devicePixelRatio) : 1;
|
const finiteDpr = Number.isFinite(devicePixelRatio) ? Math.max(1, devicePixelRatio) : 1;
|
||||||
return {
|
return {
|
||||||
pixelRatio: Math.min(finiteDpr, selected.dprCap, mobileOrTablet ? 1.5 : 2),
|
pixelRatio: Math.min(finiteDpr, selected.dprCap, mobileOrTablet ? 1.5 : 2),
|
||||||
|
roadSegments: selected.roadSegments,
|
||||||
terrainSegments: selected.terrainSegments,
|
terrainSegments: selected.terrainSegments,
|
||||||
|
terrainOctaves: selected.terrainOctaves,
|
||||||
particleCount: selected.particleCount,
|
particleCount: selected.particleCount,
|
||||||
|
streakCount: selected.streakCount,
|
||||||
postEffects: selected.postEffects,
|
postEffects: selected.postEffects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export async function createProceduralSceneGeneration(
|
|||||||
rearLight.position.set(0, 1.2, 7);
|
rearLight.position.set(0, 1.2, 7);
|
||||||
scene.add(rearLight);
|
scene.add(rearLight);
|
||||||
|
|
||||||
const road = createRoad(context.resources);
|
const road = createRoad(context.resources, quality);
|
||||||
const terrain = createTerrain(context.resources, quality.terrainSegments);
|
const terrain = createTerrain(context.resources, quality);
|
||||||
const sun = createSun(context.resources);
|
const sun = createSun(context.resources);
|
||||||
const car = createCar(context.resources);
|
const car = createCar(context.resources);
|
||||||
const sign = createSign(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));
|
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
||||||
lastNow = now;
|
lastNow = now;
|
||||||
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
|
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
|
||||||
road.update(driveDistance, snapshot.driveSpeed);
|
road.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
|
||||||
terrain.update(driveDistance);
|
terrain.update(
|
||||||
|
driveDistance,
|
||||||
|
snapshot.driveSpeed,
|
||||||
|
snapshot.chapter,
|
||||||
|
snapshot.routeProgress,
|
||||||
|
);
|
||||||
sun.update(snapshot.chapter, snapshot.routeProgress);
|
sun.update(snapshot.chapter, snapshot.routeProgress);
|
||||||
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
|
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
|
||||||
sign.update(snapshot.sign);
|
sign.update(snapshot.sign);
|
||||||
|
|||||||
173
src/scene/world/palette.ts
Normal file
173
src/scene/world/palette.ts
Normal file
@@ -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<Record<EnvironmentChapter, ChapterPaletteDefinition>> = {
|
||||||
|
'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<Record<EnvironmentChapter, Pick<MutableChapterPalette,
|
||||||
|
'sky' | 'fog' | 'terrainLow' | 'terrainHigh' | 'sun' | 'haze'>>>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,50 +1,111 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
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 group = new THREE.Group();
|
||||||
const roadGeometry = resources.own('geometry', new THREE.PlaneGeometry(18, 240));
|
group.name = 'road-world';
|
||||||
const roadMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
|
||||||
color: 0x070b16,
|
const geometry = resources.own(
|
||||||
roughness: 0.88,
|
'geometry',
|
||||||
metalness: 0.12,
|
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 surface = new THREE.Mesh(geometry, material);
|
||||||
const edgeMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x38dff2 }));
|
surface.name = 'road-surface';
|
||||||
for (const x of [-8.65, 8.65]) {
|
surface.receiveShadow = true;
|
||||||
const edge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
group.add(surface);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
update(distance: number, speed: number) {
|
update(distance, speed, acceleration) {
|
||||||
const flow = distance * (5 + speed * 8);
|
uniforms.uDistance.value = finiteOrZero(distance);
|
||||||
for (let index = 0; index < markers.length; index += 1) {
|
uniforms.uSpeed.value = clamp01(speed);
|
||||||
const marker = markers[index];
|
uniforms.uAcceleration.value = clamp01(acceleration);
|
||||||
if (!marker) continue;
|
|
||||||
marker.position.z = 8 - modulo(index * 10 - flow, 220);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function modulo(value: number, divisor: number): number {
|
function finiteOrZero(value: number): number {
|
||||||
return ((value % divisor) + divisor) % divisor;
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return THREE.MathUtils.clamp(finiteOrZero(value), 0, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,206 @@
|
|||||||
import * as THREE from 'three';
|
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 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 group = new THREE.Group();
|
||||||
const geometry = resources.own(
|
group.name = 'terrain-world';
|
||||||
'geometry',
|
const geometry = resources.own('geometry', createIndexedTerrainGeometry(quality.terrainSegments));
|
||||||
new THREE.PlaneGeometry(72, 240, Math.max(4, Math.floor(segments / 4)), segments),
|
const initialPalette = CHAPTER_PALETTES['deep-night'];
|
||||||
);
|
const lowColor = new THREE.Color(initialPalette.terrainLow);
|
||||||
const cyan = resources.own('material', new THREE.MeshBasicMaterial({
|
const highColor = new THREE.Color(initialPalette.terrainHigh);
|
||||||
color: 0x168fae,
|
const uniforms = {
|
||||||
wireframe: true,
|
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,
|
transparent: true,
|
||||||
opacity: 0.34,
|
depthWrite: false,
|
||||||
}));
|
depthTest: true,
|
||||||
const magenta = resources.own('material', new THREE.MeshBasicMaterial({
|
blending: THREE.AdditiveBlending,
|
||||||
color: 0xbd317f,
|
toneMapped: true,
|
||||||
wireframe: true,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.22,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const planes: Array<{ position: { z: number } }> = [];
|
const lines = new THREE.LineSegments(geometry, material);
|
||||||
for (const [x, material] of [[-44, magenta], [44, cyan]] as const) {
|
lines.name = 'terrain-lines';
|
||||||
const plane = new THREE.Mesh(geometry, material);
|
lines.frustumCulled = false;
|
||||||
plane.rotation.x = -Math.PI / 2;
|
group.add(lines);
|
||||||
plane.position.set(x, -0.2, -92);
|
|
||||||
planes.push(plane);
|
|
||||||
group.add(plane);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
update(distance: number) {
|
update(distance, speed, chapter, routeProgress) {
|
||||||
const drift = modulo(distance * 1.4, 8);
|
const palette = CHAPTER_PALETTES[chapter];
|
||||||
for (const plane of planes) plane.position.z = -92 + drift;
|
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 {
|
function createIndexedTerrainGeometry(segments: number) {
|
||||||
return ((value % divisor) + divisor) % divisor;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,9 +155,33 @@ describe('scene render quality', () => {
|
|||||||
const medium = renderQualityFor('medium', 3, false);
|
const medium = renderQualityFor('medium', 3, false);
|
||||||
const low = renderQualityFor('low', 3, false);
|
const low = renderQualityFor('low', 3, false);
|
||||||
|
|
||||||
expect(high).toEqual({ pixelRatio: 1.5, terrainSegments: 96, particleCount: 384, postEffects: true });
|
expect(high).toEqual({
|
||||||
expect(medium).toEqual({ pixelRatio: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true });
|
pixelRatio: 1.5,
|
||||||
expect(low).toEqual({ pixelRatio: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false });
|
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', () => {
|
it('caps mobile and tablet pixel ratio at 1.5 even for High', () => {
|
||||||
|
|||||||
164
tests/unit/world-models.test.ts
Normal file
164
tests/unit/world-models.test.ts
Normal file
@@ -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<number>);
|
||||||
|
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<typeof createMutableChapterPalette>) {
|
||||||
|
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<number> } | null;
|
||||||
|
boundingBox: { min: { x: number }; max: { x: number } } | null;
|
||||||
|
computeBoundingBox(): void;
|
||||||
|
getAttribute(name: string): { count: number; array: ArrayLike<number> };
|
||||||
|
};
|
||||||
|
material: {
|
||||||
|
uniforms: Record<string, { value: unknown }>;
|
||||||
|
vertexShader: string;
|
||||||
|
fragmentShader: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user