feat: weave speed-reactive terrain and ground roadside signs

Anchor desktop signs beside the road and feather the compact reading edge with measured chapter landings. Add a continuous cyan-to-coral terrain grid and a bounded rear wake that builds with existing drive speed.

Preserve approved content, media, soundtrack, and canonical journey contracts.
This commit is contained in:
2026-09-05 06:22:49 +03:00
parent b188cac7ed
commit 51a9c743da
7 changed files with 416 additions and 53 deletions

View File

@@ -21,6 +21,7 @@ export interface TerrainWorld {
chapter: EnvironmentChapter,
routeProgress: number,
palette?: Readonly<MutableChapterPalette>,
overdrive?: number,
): void;
}
@@ -30,9 +31,10 @@ interface WorldGroup {
}
const TERRAIN_WIDTH = 620;
const ROAD_CLEAR_HALF_WIDTH = 10.3;
const RIDGE_START = 46;
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,
@@ -51,6 +53,7 @@ export function createTerrain(
uDistance: { value: 0 },
uFlowDistance: { value: 0 },
uSpeed: { value: 0 },
uOverdrive: { value: 0 },
uRouteProgress: { value: 0 },
uTerrainEnergy: { value: initialPalette.terrainEnergy },
uOctaves: { value: quality.terrainOctaves },
@@ -70,6 +73,7 @@ export function createTerrain(
uniform float uDistance;
uniform float uFlowDistance;
uniform float uSpeed;
uniform float uOverdrive;
uniform float uRouteProgress;
uniform float uTerrainEnergy;
uniform float uOctaves;
@@ -101,20 +105,24 @@ export function createTerrain(
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;
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, position.z - wrappedPhase);
vec2 world = vec2(position.x, displaced.z - wrappedPhase);
float ridgeNoise = multiOctaveNoise(world * S);
float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x)));
float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy;
displaced.y += height;
height += roadMask * uOverdrive * (0.7 + 0.7 * sin(world.y * ${((Math.PI * 2 * 34) / NOISE_PERIOD).toFixed(10)} + abs(position.x) * 0.075));
displaced.y += height + 0.028;
vTerrainPosition = displaced;
vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0);
@@ -123,7 +131,8 @@ export function createTerrain(
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));
vFlow = fract((-position.z + uFlowDistance) / 11.0);
// 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;
}
`,
@@ -133,6 +142,7 @@ export function createTerrain(
uniform vec3 uFogColor;
uniform vec3 uHazeColor;
uniform float uRouteProgress;
uniform float uOverdrive;
varying float vHeight;
varying float vRoadMask;
varying float vDepthFade;
@@ -140,13 +150,20 @@ export function createTerrain(
varying vec3 vTerrainPosition;
void main() {
float ridgeMix = smoothstep(0.16, 0.78, vHeight);
vec3 color = mix(uLowColor, uHighColor, ridgeMix);
float ridgeMix = smoothstep(0.025, 0.29, vHeight);
vec3 valley = mix(uLowColor, vec3(0.10, 0.64, 1.0), uOverdrive * 0.55);
vec3 crest = mix(vec3(0.7, 0.025, 0.22), vec3(1.0, 0.12, 0.065), uOverdrive * 0.75 + vFlow * 0.18);
vec3 color = mix(valley, mix(vec3(0.28, 0.06, 0.52), crest, ridgeMix), ridgeMix);
color = mix(color, uHighColor, 0.13);
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 alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.30, 0.83, ridgeMix);
gl_FragColor = vec4(color * mix(1.15, 1.75, ridgeMix), alpha);
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);
float charge = uOverdrive * (0.35 + vFlow * 0.8) * (0.5 + apron * 0.5);
gl_FragColor = vec4(color * (1.3 + ridgeMix * 0.5 + charge), alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
@@ -160,7 +177,7 @@ export function createTerrain(
);
const groundGeometry = resources.own('geometry', geometry.clone());
const rows = Math.max(16, Math.floor(quality.terrainSegments));
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++)
@@ -175,7 +192,7 @@ export function createTerrain(
new THREE.ShaderMaterial({
uniforms,
vertexShader: material.vertexShader.replace(
'displaced.y += height;',
'displaced.y += height + 0.028;',
'displaced.y += height - 0.12;',
),
fragmentShader: `
@@ -214,12 +231,13 @@ export function createTerrain(
return {
group,
update(distance, speed, chapter, routeProgress, palette) {
update(distance, speed, chapter, routeProgress, palette, overdrive = 0) {
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.uRouteProgress.value = clamp01(routeProgress);
uniforms.uTerrainEnergy.value =
palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
@@ -237,12 +255,16 @@ export function createTerrain(
}
function createIndexedTerrainGeometry(segments: number) {
const zSegments = Math.max(16, Math.floor(segments));
const approximateCell = WORLD_DEPTH / zSegments;
const xSegments = Math.max(
48,
Math.floor(TERRAIN_WIDTH / approximateCell / 2) * 2,
);
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);
@@ -251,9 +273,9 @@ function createIndexedTerrainGeometry(segments: number) {
let maskOffset = 0;
for (let row = 0; row < rowCount; row += 1) {
const z = WORLD_NEAR_Z - (row / zSegments) * WORLD_DEPTH;
const z = Math.ceil(WORLD_NEAR_Z / GRID_STEP) * GRID_STEP - row * GRID_STEP;
for (let column = 0; column < columnCount; column += 1) {
const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH;
const x = xs[column]!;
positions[vertexOffset] = x;
positions[vertexOffset + 1] = 0;
positions[vertexOffset + 2] = z;
@@ -272,17 +294,11 @@ function createIndexedTerrainGeometry(segments: 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) {
if (row < zSegments) {
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));
}
indices.push(vertexIndex(column, row), vertexIndex(column + 1, row));
}
}
}