diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index a40dea9..a0217f2 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -6,6 +6,8 @@ import { renderQualityFor } from '@/scene/render-quality'; import { createHorizonAir } from '@/scene/world/horizon-air'; import { createExpressway } from '@/scene/world/expressway'; import { createSignStage } from '@/scene/world/sign-stage'; +import { usesRoadsideSign } from '@/scene/world/sign-layout'; +import { createSpeedWake } from '@/scene/world/speed-wake'; import { createRoad } from '@/scene/world/road'; import { createTerrain } from '@/scene/world/terrain'; import { createSun } from '@/scene/world/sun'; @@ -111,6 +113,7 @@ export async function createProceduralSceneGeneration( const road = createRoad(context.resources, quality); const terrain = createTerrain(context.resources, quality); + const speedWake = createSpeedWake(context.resources); const expressway = createExpressway(context.resources, quality); const signStage = createSignStage(context.resources); const sun = createSun(context.resources); @@ -132,6 +135,7 @@ export async function createProceduralSceneGeneration( sun.group, horizonAir.group, car.group, + speedWake.group, sign.group, ); @@ -158,6 +162,8 @@ export async function createProceduralSceneGeneration( let disposed = false; let renderedFrames = 0; let atmosphericTime = 0; + let signCameraWeight = 0; + let overdrive = 0; let firstFrameSuppressionRecorded = false; const resize = (): void => { @@ -240,6 +246,17 @@ export async function createProceduralSceneGeneration( lastNow = now; dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds); const response = dynamics.snapshot(); + // Read the existing speed contract; visual charge has its own slow release + // and cannot change the soundtrack or the canonical drive dynamics. + const chargeTarget = THREE.MathUtils.smoothstep( + snapshot.driveSpeed, + 0.68, + 0.9, + ); + overdrive += + (chargeTarget - overdrive) * + (1 - + Math.exp(-deltaSeconds / (chargeTarget > overdrive ? 0.28 : 1.05))); writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0); dampPalette(currentPalette, targetPalette, deltaSeconds); const driveDistance = travelDistance.advance( @@ -271,6 +288,7 @@ export async function createProceduralSceneGeneration( snapshot.chapter, snapshot.routeProgress, currentPalette, + overdrive, ); atmosphericTime += deltaSeconds; const elapsedSeconds = atmosphericTime; @@ -287,6 +305,12 @@ export async function createProceduralSceneGeneration( elapsedSeconds, driveDistance, ); + speedWake.update( + overdrive, + elapsedSeconds, + car.group.position, + width < 1100 || height < 620 ? 2.32 : 2.78, + ); sign.update(snapshot.sign, now); atmosphere.update( driveDistance, @@ -302,7 +326,7 @@ export async function createProceduralSceneGeneration( ambient.groundColor.copy(currentPalette.fog); ambient.intensity = 1.05 + currentPalette.warmth * 0.18; carKeyLight.color.setHex(0xb9e3ef); - carKeyLight.intensity = 32 + response.bloomEnergy * 3.6; + carKeyLight.intensity = 32 + response.bloomEnergy * 3.6 + overdrive * 8; carFillLight.color.setHex(0x809ec1); carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8; rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4; @@ -346,11 +370,23 @@ export async function createProceduralSceneGeneration( // The document stays native. Only the rendered lens eases to the chapter's safe zone. const cameraX = wideComposition ? composition.x : 0; const targetX = 0; + const signTarget = + usesRoadsideSign(width, height) && snapshot.sign !== null + ? snapshot.sign.phase === 'depart' + ? 1 - snapshot.sign.progress + : 1 + : 0; + signCameraWeight += + (signTarget - signCameraWeight) * (1 - Math.exp(-deltaSeconds / 0.48)); const targetShift = height < 560 && width >= 768 ? 0.22 : wideComposition - ? composition.shift + ? THREE.MathUtils.lerp( + composition.shift, + composition.shift * 0.28, + signCameraWeight, + ) : 0; const targetVerticalShift = wideComposition ? 0 @@ -434,7 +470,14 @@ export async function createProceduralSceneGeneration( camera.updateProjectionMatrix(); } const stage = signStage.update(width, height); - sign.compose(camera, width, height, stage.navigationBottom, now); + sign.compose( + camera, + width, + height, + stage.navigationBottom, + now, + car.group.position, + ); if (TEST_BUILD && snapshot.sign !== null) { const presentation = sign.inspect(camera, width, height); if (presentation !== null) { diff --git a/src/scene/world/sign-layout.ts b/src/scene/world/sign-layout.ts new file mode 100644 index 0000000..0a7119b --- /dev/null +++ b/src/scene/world/sign-layout.ts @@ -0,0 +1,19 @@ +/** 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) { + // 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; + return { + scale, + // The entire housing and both supports clear the road and the car envelope. + x: 11.6 + scale * 4.55, + y: 25.5, + dwellZ: -28, + }; +} diff --git a/src/scene/world/sign-stage.ts b/src/scene/world/sign-stage.ts index 1e2aa15..872a711 100644 --- a/src/scene/world/sign-stage.ts +++ b/src/scene/world/sign-stage.ts @@ -1,18 +1,24 @@ import type { SceneResourceBag } from '@/scene/resources'; +import { usesRoadsideSign } from './sign-layout'; const stageOwners = new WeakMap(); -/** Reserve real negative space in the document, below the navigation. The - * canvas keeps its original stacking order. Content scrolls through this edge - * exactly as it does through the top of the viewport; no content is removed. */ +/** Feather the reading edge into the world. This is a spatial alpha ramp, + * not a time-based disappearance: reversing scroll restores the same pixels. */ export function createSignStage(resources: SceneResourceBag) { const main = document.querySelector('.portfolio-main'); const nav = document.querySelector('.site-navigation'); const root = document.documentElement; + const panels = main + ? Array.from(main.children).filter( + (element): element is HTMLElement => element instanceof HTMLElement, + ) + : []; let dirty = true; let cutoff = 0; let stageBottom = 176; let navigationBottom = 66; + let readingTop = 220; const token = {}; const mark = () => { dirty = true; @@ -24,7 +30,7 @@ export function createSignStage(resources: SceneResourceBag) { return; if (event.target.matches('h1,h2,main')) return; const rect = event.target.getBoundingClientRect(); - const top = window.innerHeight < 560 ? navigationBottom + 10 : stageBottom; + const top = readingTop; const bottom = window.innerHeight < 560 && window.innerWidth < 768 ? window.innerHeight - 164 @@ -42,35 +48,95 @@ export function createSignStage(resources: SceneResourceBag) { if (!main || stageOwners.get(main) !== token) return; stageOwners.delete(main); main?.style.removeProperty('clip-path'); + main.style.removeProperty('mask-image'); + main.style.removeProperty('-webkit-mask-image'); + for (const panel of panels) { + panel.style.removeProperty('mask-image'); + panel.style.removeProperty('-webkit-mask-image'); + panel.style.removeProperty('--chapter-heading-offset'); + } root.style.removeProperty('--sign-stage-bottom'); + root.style.removeProperty('--sign-reading-top'); delete root.dataset.expressway; }, }); return { update(width: number, height: number) { if (dirty && main) { - const rect = main.getBoundingClientRect(); navigationBottom = nav?.getBoundingClientRect().bottom ?? 66; const faceWidth = Math.min( width - 44, width < 900 ? 288 : 356, height < 650 ? 250 : 356, ); - stageBottom = navigationBottom + 24 + faceWidth / 3.18; + const shelfBottom = navigationBottom + 24 + faceWidth / 3.18; + const feather = height < 650 ? 28 : 48; + stageBottom = shelfBottom + (height < 650 ? feather : 0); // Gradually establish the strip while the opening leaves the viewport. const reveal = Math.min( 1, Math.max(0, window.scrollY / Math.max(240, height * 0.58)), ); const short = height < 560; - const topEdge = short ? navigationBottom + 10 : stageBottom; - cutoff = Math.max(0, topEdge * reveal - rect.top); - const bottomCutoff = - short && width < 768 ? Math.max(0, rect.bottom - (height - 164)) : 0; - main.style.clipPath = `inset(${cutoff.toFixed(2)}px 0 ${bottomCutoff.toFixed(2)}px 0)`; + const proposedTopEdge = short + ? navigationBottom + 10 + : height < 650 + ? shelfBottom - 22 + : usesRoadsideSign(width, height) + ? Math.max(shelfBottom, height * 0.245) + : shelfBottom; + // Keep the heading and its eyebrow readable above the existing activation line. + const topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36); + readingTop = topEdge + feather; + // Only the panel crossing the edge needs a composited mask. Masking + // the complete document would allocate a very tall intermediate layer. + for (const panel of panels) { + const bounds = panel.getBoundingClientRect(); + const heading = panel.querySelector(':scope > h2'); + if (heading) + panel.style.setProperty( + '--chapter-heading-offset', + `${(heading.getBoundingClientRect().top - bounds.top).toFixed(2)}px`, + ); + const top = topEdge * reveal; + const bottom = short && width < 768 ? height - 164 : Infinity; + if ( + bounds.bottom > 0 && + (bounds.bottom <= top || bounds.top >= bottom) + ) { + panel.style.maskImage = 'linear-gradient(transparent, transparent)'; + panel.style.setProperty( + '-webkit-mask-image', + 'linear-gradient(transparent, transparent)', + ); + continue; + } + if ( + bounds.bottom <= 0 || + (bounds.top >= top + feather && bounds.bottom <= bottom - feather) + ) { + panel.style.removeProperty('mask-image'); + panel.style.removeProperty('-webkit-mask-image'); + continue; + } + cutoff = Math.max(0, top - bounds.top); + const bottomEdge = Number.isFinite(bottom) + ? Math.max(cutoff + feather, bottom - bounds.top) + : bounds.height + feather; + const mask = `linear-gradient(to bottom, transparent ${cutoff.toFixed(2)}px, rgb(0 0 0 / 18%) ${(cutoff + feather * 0.25).toFixed(2)}px, rgb(0 0 0 / 78%) ${(cutoff + feather * 0.72).toFixed(2)}px, black ${(cutoff + feather).toFixed(2)}px, black ${(bottomEdge - feather).toFixed(2)}px, transparent ${bottomEdge.toFixed(2)}px)`; + panel.style.maskImage = mask; + panel.style.setProperty('-webkit-mask-image', mask); + } + main.style.removeProperty('clip-path'); + main.style.removeProperty('mask-image'); + main.style.removeProperty('-webkit-mask-image'); root.style.setProperty( '--sign-stage-bottom', - `${topEdge.toFixed(2)}px`, + `${(short ? topEdge : stageBottom).toFixed(2)}px`, + ); + root.style.setProperty( + '--sign-reading-top', + `${readingTop.toFixed(2)}px`, ); root.dataset.expressway = 'live'; stageOwners.set(main, token); diff --git a/src/scene/world/sign.ts b/src/scene/world/sign.ts index 7031007..1c3588f 100644 --- a/src/scene/world/sign.ts +++ b/src/scene/world/sign.ts @@ -9,6 +9,7 @@ import { type SignViewportProfile, } from '@/journey/sign-routes'; import type { SceneResourceBag } from '@/scene/resources'; +import { roadsideSignMetrics, usesRoadsideSign } from './sign-layout'; interface ProjectingCamera { position: { x: number; y: number; z: number }; @@ -32,6 +33,7 @@ export interface SignWorld { height: number, navigationBottom: number, now: number, + carAnchor?: { x: number; z: number }, ): void; setViewportAspect(aspect: number): void; setViewportSize(width: number, height: number): void; @@ -407,6 +409,11 @@ export function createSign( let screenY = 120; let screenWidth = 340; let initialized = false; + let roadsideMix = 0; + let previousPhase: SignSnapshot['phase'] | null = null; + let dwellDrift = 0; + let departureDrift = 0; + const physical = roadsideSignMetrics(900); const point = new THREE.Vector3(); const ray = new THREE.Vector3(); const forward = new THREE.Vector3(); @@ -498,9 +505,13 @@ export function createSign( if (!sign) { group.visible = false; initialized = false; + previousPhase = null; + dwellDrift = 0; return; } if (target !== sign.targetId) { + previousPhase = null; + dwellDrift = 0; target = sign.targetId; face.userData.textureUpdateCount += 1; face.userData.fontReadyWhenDrawn = @@ -516,7 +527,14 @@ export function createSign( initialized = false; } }, - compose(camera, width, height, navigationBottom, now) { + compose( + camera, + width, + height, + navigationBottom, + now, + carAnchor = { x: 0, z: 0.72 }, + ) { if (!snapshot) return; viewportWidth = width; viewportHeight = height; @@ -546,6 +564,8 @@ export function createSign( const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000)); previousNow = now; const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1; + const roadside = usesRoadsideSign(width, height); + roadsideMix += ((roadside ? 1 : 0) - roadsideMix) * mix; screenX += (targetX - screenX) * mix; screenY += (targetY - screenY) * mix; screenWidth += (targetWidth - screenWidth) * mix; @@ -583,9 +603,43 @@ export function createSign( worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera); worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera); if (lowerStrip) point.y = Math.max(1.4, point.y); + // 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); + for (const key of ['x', 'y', 'scale', 'dwellZ'] as const) { + physical[key] += (desiredPhysical[key] - physical[key]) * mix; + } + // Canonical dwell progress is always 1; visual drift must integrate time. + if (snapshot.phase === 'dwell') + dwellDrift = Math.min(3, dwellDrift + dt * 1.7); + if (snapshot.phase === 'depart' && previousPhase !== 'depart') + departureDrift = dwellDrift; + previousPhase = snapshot.phase; + const roadZ = + snapshot.phase === 'depart' + ? THREE.MathUtils.lerp(physical.dwellZ + departureDrift, 48, p) + : THREE.MathUtils.lerp(-230, physical.dwellZ, arrival) + dwellDrift; + point.x = THREE.MathUtils.lerp( + point.x, + carAnchor.x + side * physical.x, + roadsideMix, + ); + point.y = THREE.MathUtils.lerp(point.y, physical.y, roadsideMix); + point.z = THREE.MathUtils.lerp(point.z, carAnchor.z + roadZ, roadsideMix); group.position.set(point.x, point.y, point.z); - group.scale.setScalar(worldRight.distanceTo(worldLeft) / 8.64); - group.rotation.y = camera.rotation.y - side * 0.065; + group.scale.setScalar( + THREE.MathUtils.lerp( + worldRight.distanceTo(worldLeft) / 8.64, + physical.scale, + roadsideMix, + ), + ); + group.rotation.y = THREE.MathUtils.lerp( + camera.rotation.y - side * 0.065, + -side * 0.1, + roadsideMix, + ); plantMounting(); const opacity = clamp01(snapshot.opacity); for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight; @@ -596,6 +650,7 @@ export function createSign( sweepMaterial.uniforms.uOpacity.value = snapshot.phase === 'approach' ? opacity * 0.14 : 0; group.visible = opacity > 0; + group.userData.anchoring = roadside ? 'roadside' : 'compact-shelf'; }, }; } diff --git a/src/scene/world/speed-wake.ts b/src/scene/world/speed-wake.ts new file mode 100644 index 0000000..9d4be53 --- /dev/null +++ b/src/scene/world/speed-wake.ts @@ -0,0 +1,130 @@ +import * as THREE from 'three'; +import type { SceneResourceBag } from '@/scene/resources'; + +/** Four exhaust/light ribbons and two ground pressure arcs. Fixed geometry, + * no particles allocated during travel and no screen-space rendering pass. */ +export function createSpeedWake(resources: SceneResourceBag) { + const group = new THREE.Group(); + group.name = 'overdrive-wake'; + const uniforms = { + uPower: { value: 0 }, + uTime: { value: 0 }, + uScale: { value: 1 }, + }; + const positions: number[] = [], + uvs: number[] = [], + indices: number[] = []; + for (const x of [-2.5, -1.55, 1.55, 2.5]) { + const base = positions.length / 3; + const halfWidth = Math.abs(x) < 2 ? 0.2 : 0.095; + for (const [side, along] of [ + [-1, 0], + [1, 0], + [-1, 1], + [1, 1], + ] as const) { + positions.push( + x + side * halfWidth, + Math.abs(x) < 2 ? 0.42 : 1.65, + along * 20, + ); + uvs.push((side + 1) / 2, along); + } + indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3); + } + const geometry = resources.own('geometry', new THREE.BufferGeometry()); + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(positions, 3), + ); + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + const material = resources.own( + 'material', + new THREE.ShaderMaterial({ + uniforms, + vertexShader: ` + uniform float uPower; uniform float uTime; uniform float uScale; + varying vec2 vUv; + void main(){ + vUv=uv; vec3 p=position; + p.x*=uScale; p.y*=uScale; + p.z*=0.25+uPower*0.75; + p.x+=sin(uv.y*8.0-uTime*4.0)*uv.y*uv.y*0.12; + p.y*=1.0-uv.y*0.82; + gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0); + }`, + fragmentShader: ` + uniform float uPower; uniform float uTime; varying vec2 vUv; + void main(){ + float crossFade=pow(max(0.0,1.0-abs(vUv.x*2.0-1.0)),1.3); + float tip=pow(1.0-vUv.y,1.4)*smoothstep(0.0,0.055,vUv.y); + float filament=0.76+0.24*sin(vUv.y*35.0-uTime*15.0); + vec3 color=mix(vec3(0.45,0.91,1.0),vec3(0.66,0.12,0.64),vUv.y); + color+=vec3(0.7,0.85,0.9)*pow(crossFade,5.0)*0.7; + gl_FragColor=vec4(color*1.8,crossFade*tip*filament*uPower*0.8); + #include + #include + }`, + transparent: true, + depthWrite: false, + depthTest: true, + side: THREE.DoubleSide, + blending: THREE.AdditiveBlending, + }), + ); + const ribbons = new THREE.Mesh(geometry, material); + ribbons.name = 'rear-ion-ribbons'; + ribbons.frustumCulled = false; + group.add(ribbons); + + const arcs: number[] = []; + for (let ring = 0; ring < 2; ring++) + for (let i = 0; i < 40; i++) { + for (const n of [i, i + 1]) { + const angle = Math.PI * (0.14 + (n / 40) * 0.72); + arcs.push(Math.cos(angle) * 3.5, ring, Math.sin(angle) * 1.8); + } + } + const arcGeometry = resources.own('geometry', new THREE.BufferGeometry()); + arcGeometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(arcs, 3), + ); + const arcMaterial = resources.own( + 'material', + new THREE.ShaderMaterial({ + uniforms, + vertexShader: `uniform float uPower;uniform float uTime;varying float vAlpha; + void main(){float phase=fract(uTime*0.66+position.y*0.5); + vec3 p=vec3(position.x*(0.85+phase*0.85),0.04,position.z+phase*15.0); + vAlpha=sin(phase*3.14159)*(1.0-phase)*uPower*uPower; + gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0);}`, + fragmentShader: `varying float vAlpha;void main(){gl_FragColor=vec4(0.3,0.85,1.0,vAlpha*0.48);}`, + transparent: true, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + }), + ); + const pressure = new THREE.LineSegments(arcGeometry, arcMaterial); + pressure.name = 'ground-pressure-arcs'; + pressure.frustumCulled = false; + group.add(pressure); + return { + group, + update( + power: number, + time: number, + anchor: { x: number; z: number }, + scale: number, + ) { + uniforms.uPower.value = power; + uniforms.uTime.value = time; + uniforms.uScale.value = scale / 2.78; + group.position.set(anchor.x, 0.03, anchor.z + 2.74 * scale); + group.visible = power > 0.015; + group.userData.power = power; + }, + }; +} diff --git a/src/scene/world/terrain.ts b/src/scene/world/terrain.ts index 8938f32..5b4e2a9 100644 --- a/src/scene/world/terrain.ts +++ b/src/scene/world/terrain.ts @@ -21,6 +21,7 @@ export interface TerrainWorld { chapter: EnvironmentChapter, routeProgress: number, palette?: Readonly, + 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 #include } @@ -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)); } } } diff --git a/src/styles/evolution.css b/src/styles/evolution.css index 243b0af..5461a58 100644 --- a/src/styles/evolution.css +++ b/src/styles/evolution.css @@ -2,7 +2,7 @@ Existing content, navigation and audio controls retain their contracts. */ :root { --canvas: #080d18; - --surface: rgb(12 20 32 / 96%); + --surface: rgb(12 20 32 / 98.5%); --surface-strong: #0c1420; --surface-soft: #132130; --line: rgb(160 196 212 / 18%); @@ -871,13 +871,32 @@ body { } } -/* The live scene has a genuine viewing corridor below navigation. */ +/* Preserve the sign corridor while landing headings above the unchanged + journey activation line, including short windows and padded panels. */ html[data-expressway='live'] .portfolio-section { - scroll-margin-block-start: calc(var(--sign-stage-bottom, 188px) + 18px); + scroll-margin-block-start: min( + max( + var( + --chapter-landing-space, + calc(var(--sign-stage-bottom, 188px) + 18px) + ), + calc( + var(--sign-reading-top, 220px) + var(--chapter-reading-inset, 4px) - + var(--chapter-heading-offset, 70px) + ) + ), + calc(35svh - var(--chapter-heading-offset, 70px) - 6px) + ); +} +html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] { + --chapter-landing-space: calc(var(--sign-stage-bottom, 188px) + 50px); + --chapter-reading-inset: 30px; } @media print { - .portfolio-main { + .portfolio-main, + .portfolio-main > * { clip-path: none !important; + mask-image: none !important; } } @@ -956,7 +975,13 @@ html[data-expressway='live'] .portfolio-section { } @media (max-height: 650px) { html[data-expressway='live'] .portfolio-section { - scroll-margin-block-start: calc(var(--sign-stage-bottom) - 28px); + --chapter-landing-space: calc(var(--sign-stage-bottom) - 28px); + } + html[data-expressway='live'] #contact { + --chapter-landing-space: calc(var(--sign-stage-bottom) - 52px); + } + html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] { + --chapter-landing-space: calc(var(--sign-stage-bottom) - 22px); } } @media (min-width: 768px) and (max-width: 900px) { @@ -971,6 +996,15 @@ html[data-expressway='live'] .portfolio-section { font-size: 32px; } } +@media (max-height: 559px) { + html[data-expressway='live'] .portfolio-section, + html[data-expressway='live'] #contact { + --chapter-landing-space: calc(var(--sign-stage-bottom) + 12px); + } + html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] { + --chapter-landing-space: calc(var(--sign-stage-bottom) + 34px); + } +} @media (min-width: 768px) and (max-height: 559px) { html[data-expressway='live'] .portfolio-section[data-layout] { inline-size: 60%;