diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 6c2f8ee..ce2a85c 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -164,6 +164,8 @@ export async function createProceduralSceneGeneration( let atmosphericTime = 0; let signCameraWeight = 0; let overdrive = 0; + let terrainSurge = 0; + let terrainSpectrumPhase = 0; let firstFrameSuppressionRecorded = false; const resize = (): void => { @@ -257,6 +259,19 @@ export async function createProceduralSceneGeneration( (chargeTarget - overdrive) * (1 - Math.exp(-deltaSeconds / (chargeTarget > overdrive ? 0.28 : 1.05))); + // Terrain feedback reads acceleration directly, with its own fast attack + // and long release. Audio and the shared drive responses stay untouched. + const surgeTarget = Math.sqrt( + THREE.MathUtils.clamp(snapshot.acceleration, 0, 1), + ); + terrainSurge += + (surgeTarget - terrainSurge) * + (1 - + Math.exp(-deltaSeconds / (surgeTarget > terrainSurge ? 0.11 : 1.15))); + // Integrate color travel instead of multiplying elapsed time by speed: + // lifting off slows the spectrum to rest without rewinding its phase. + terrainSpectrumPhase = + (terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1; writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0); dampPalette(currentPalette, targetPalette, deltaSeconds); const driveDistance = travelDistance.advance( @@ -289,6 +304,8 @@ export async function createProceduralSceneGeneration( snapshot.routeProgress, currentPalette, overdrive, + terrainSurge, + terrainSpectrumPhase, ); atmosphericTime += deltaSeconds; const elapsedSeconds = atmosphericTime; diff --git a/src/scene/world/sign-layout.ts b/src/scene/world/sign-layout.ts index 0a7119b..f2f8c07 100644 --- a/src/scene/world/sign-layout.ts +++ b/src/scene/world/sign-layout.ts @@ -1,19 +1,42 @@ +import type { SectionId } from '@/content/types'; + /** Compact displays keep the readable navigator shelf; wide displays use * physical roadside signs. This decision is visual and never enters journey state. */ export function usesRoadsideSign(width: number, height: number): boolean { return width >= 1100 && height >= 560 && width / height > 1.18; } -export function roadsideSignMetrics(height: number) { +// Mast heights are authored for the chapter's driving composition. They do +// not follow projected screen coordinates or the animated camera position. +const MAST_LIFT: Readonly> = { + start: 0, + profile: 0, + experience: 1, + infrastructure: 4, + 'selected-builds': 1.5, + 'additional-work': 2, + toolkit: 2.7, + contact: -0.5, +}; + +export function roadsideSignMetrics( + height: number, + chapter: SectionId = 'start', +) { // Short desktop windows need a larger physical housing, rather than a // doubly shrunken sign (shorter viewport plus smaller world geometry). const size = Math.min(1.32, Math.max(0.94, 900 / height)); const scale = 2.1 * size; + const chapterLift = + MAST_LIFT[chapter] * Math.min(1, Math.max(0.25, (height - 560) / 160)); + // Perspective height converges on tall displays; a linear lift would + // eventually push the housing through the navigation bar. + const tallLift = Math.max(0, 9 * (1 - 900 / height)); return { scale, // The entire housing and both supports clear the road and the car envelope. x: 11.6 + scale * 4.55, - y: 25.5, + y: 25.5 + chapterLift + tallLift, dwellZ: -28, }; } diff --git a/src/scene/world/sign-stage.ts b/src/scene/world/sign-stage.ts index 872a711..684ca28 100644 --- a/src/scene/world/sign-stage.ts +++ b/src/scene/world/sign-stage.ts @@ -70,7 +70,8 @@ export function createSignStage(resources: SceneResourceBag) { height < 650 ? 250 : 356, ); const shelfBottom = navigationBottom + 24 + faceWidth / 3.18; - const feather = height < 650 ? 28 : 48; + const roadside = usesRoadsideSign(width, height); + const feather = height < 650 ? 28 : roadside ? 32 : 48; stageBottom = shelfBottom + (height < 650 ? feather : 0); // Gradually establish the strip while the opening leaves the viewport. const reveal = Math.min( @@ -82,8 +83,8 @@ export function createSignStage(resources: SceneResourceBag) { ? navigationBottom + 10 : height < 650 ? shelfBottom - 22 - : usesRoadsideSign(width, height) - ? Math.max(shelfBottom, height * 0.245) + : roadside + ? navigationBottom + 128 + (height - 900) * 0.085 : shelfBottom; // Keep the heading and its eyebrow readable above the existing activation line. const topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36); diff --git a/src/scene/world/sign.ts b/src/scene/world/sign.ts index 52b408b..25407f7 100644 --- a/src/scene/world/sign.ts +++ b/src/scene/world/sign.ts @@ -606,7 +606,7 @@ export function createSign( // A wide-screen sign is a real roadside object: fixed lateral offset, // fixed mast height, constant size, and travel only along the road axis. // Camera movement changes its projection, never its world-space anchor. - const desiredPhysical = roadsideSignMetrics(height); + const desiredPhysical = roadsideSignMetrics(height, snapshot.targetId); for (const key of ['x', 'y', 'scale', 'dwellZ'] as const) { physical[key] += (desiredPhysical[key] - physical[key]) * mix; } diff --git a/src/scene/world/terrain.ts b/src/scene/world/terrain.ts index 072115a..b08372c 100644 --- a/src/scene/world/terrain.ts +++ b/src/scene/world/terrain.ts @@ -22,6 +22,8 @@ export interface TerrainWorld { routeProgress: number, palette?: Readonly, overdrive?: number, + surge?: number, + spectrumPhase?: number, ): void; } @@ -54,6 +56,8 @@ export function createTerrain( uFlowDistance: { value: 0 }, uSpeed: { value: 0 }, uOverdrive: { value: 0 }, + uSurge: { value: 0 }, + uSpectrumPhase: { value: 0 }, uRouteProgress: { value: 0 }, uTerrainEnergy: { value: initialPalette.terrainEnergy }, uOctaves: { value: quality.terrainOctaves }, @@ -74,6 +78,8 @@ export function createTerrain( uniform float uFlowDistance; uniform float uSpeed; uniform float uOverdrive; + uniform float uSurge; + uniform float uSpectrumPhase; uniform float uRouteProgress; uniform float uTerrainEnergy; uniform float uOctaves; @@ -85,6 +91,7 @@ export function createTerrain( varying float vFlow; varying vec3 vTerrainPosition; varying vec3 vGridColor; + varying float vCrest; // A closed palette ribbon: its ends meet in cyan so travel never // produces a color seam. Compute it per vertex, not per pixel. @@ -133,9 +140,17 @@ export function createTerrain( const float S = 0.04495961; vec2 world = vec2(position.x, displaced.z - wrappedPhase); float ridgeNoise = multiOctaveNoise(world * S); + // Sharpen and lift the same ridges rather than changing their noise + // coordinates. The road remains flat, and the far horizon stays calm. + float bank = smoothstep(12.0, 52.0, abs(position.x)); + float nearRelief = 1.0 - smoothstep(360.0, 680.0, -displaced.z); + float energy = (uSurge * 0.52 + uOverdrive * 0.24) * bank * nearRelief; + ridgeNoise = mix(ridgeNoise, ridgeNoise * ridgeNoise * 1.8, energy * 0.48); float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x))); - float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy; - height += roadMask * uOverdrive * (0.7 + 0.7 * sin(world.y * ${((Math.PI * 2 * 34) / NOISE_PERIOD).toFixed(10)} + abs(position.x) * 0.075)); + float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy * (1.0 + energy); + float crest = 0.5 + 0.5 * sin(world.y * ${((Math.PI * 2 * 34) / NOISE_PERIOD).toFixed(10)} + abs(position.x) * 0.075); + height += roadMask * bank * nearRelief * crest * crest * (uSurge * 3.8 + uOverdrive * 3.2); + vCrest = crest * crest * bank; displaced.y += height + 0.028; vTerrainPosition = displaced; @@ -146,7 +161,10 @@ export function createTerrain( float chroma = world.y * ${(31 / NOISE_PERIOD).toFixed(10)} + position.x * 0.014 + height * 0.025 + 0.12 * sin(world.y * ${((Math.PI * 2 * 17) / NOISE_PERIOD).toFixed(10)} + position.x * 0.028); - vGridColor = gridSpectrum(chroma); + vec3 cruiseColor = gridSpectrum(chroma + uSpectrumPhase); + vec3 speedSpectrum = gridSpectrum(world.y * ${(57 / NOISE_PERIOD).toFixed(10)} + + abs(position.x) * 0.012 + uSpectrumPhase); + vGridColor = mix(cruiseColor, speedSpectrum, uOverdrive * 0.88); vec4 viewPosition = modelViewMatrix * vec4(displaced, 1.0); float viewDistance = -viewPosition.z; vDepthFade = smoothstep(4.0, 18.0, viewDistance) @@ -163,19 +181,18 @@ export function createTerrain( uniform vec3 uHazeColor; uniform float uRouteProgress; uniform float uOverdrive; + uniform float uSurge; varying float vHeight; varying float vRoadMask; varying float vDepthFade; varying float vFlow; varying vec3 vTerrainPosition; varying vec3 vGridColor; + varying float vCrest; void main() { float ridgeMix = smoothstep(0.025, 0.29, vHeight); vec3 color = mix(vGridColor, uLowColor, 0.06); - // Speed warms passing accents, while the full palette stays present. - float hotAccent = uOverdrive * smoothstep(0.62, 1.0, vFlow) * 0.36; - color = mix(color, vec3(1.0, 0.20, 0.25), hotAccent); color = mix(color, uHighColor, ridgeMix * 0.055); color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade); float pulse = 0.88 + 0.12 * smoothstep(0.16, 0.55, vFlow); @@ -185,6 +202,7 @@ export function createTerrain( 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); + charge += uSurge * (0.28 + vCrest * 0.5) * (1.0 - road); gl_FragColor = vec4(color * (1.46 + ridgeMix * 0.54 + charge), alpha); #include #include @@ -221,6 +239,7 @@ export function createTerrain( uniform vec3 uLowColor; uniform vec3 uHighColor; uniform vec3 uFogColor; + uniform float uSurge; varying float vHeight; varying float vDepthFade; varying vec3 vTerrainPosition; @@ -228,7 +247,7 @@ export function createTerrain( void main() { vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition))); float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3)))); - vec3 color = mix(vec3(0.012, 0.003, 0.024), vGridColor * 0.065, light); + vec3 color = mix(vec3(0.012, 0.003, 0.024), vGridColor * (0.065 + uSurge * 0.016), light); color += uHighColor * pow(vHeight, 2.0) * light * 0.06; color = mix(uFogColor * 0.55, color, vDepthFade); gl_FragColor = vec4(color, 1.0); @@ -254,13 +273,25 @@ export function createTerrain( return { group, - update(distance, speed, chapter, routeProgress, palette, overdrive = 0) { + update( + distance, + speed, + chapter, + routeProgress, + palette, + overdrive = 0, + surge = 0, + spectrumPhase = 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.uSurge.value = clamp01(surge); + uniforms.uSpectrumPhase.value = + ((finiteOrZero(spectrumPhase) % 1) + 1) % 1; uniforms.uRouteProgress.value = clamp01(routeProgress); uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy; diff --git a/src/styles/evolution.css b/src/styles/evolution.css index 178bf59..84276d0 100644 --- a/src/styles/evolution.css +++ b/src/styles/evolution.css @@ -892,6 +892,16 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] { --chapter-landing-space: calc(var(--sign-stage-bottom, 188px) + 50px); --chapter-reading-inset: 30px; } +/* The physical roadside family has a compact reading corridor. Land the + eyebrow immediately beneath its feather instead of retaining the shelf's + extra panel padding. Narrow and short-screen navigation keeps its staging. */ +@media (min-width: 1100px) and (min-height: 650px) and (min-aspect-ratio: 1.18) { + html[data-expressway='live'] .portfolio-section, + html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] { + --chapter-landing-space: 0px; + --chapter-reading-inset: 30px; + } +} @media print { .portfolio-main, .portfolio-main > * {