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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user