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

@@ -6,6 +6,8 @@ import { renderQualityFor } from '@/scene/render-quality';
import { createHorizonAir } from '@/scene/world/horizon-air'; import { createHorizonAir } from '@/scene/world/horizon-air';
import { createExpressway } from '@/scene/world/expressway'; import { createExpressway } from '@/scene/world/expressway';
import { createSignStage } from '@/scene/world/sign-stage'; 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 { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain'; import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun'; import { createSun } from '@/scene/world/sun';
@@ -111,6 +113,7 @@ export async function createProceduralSceneGeneration(
const road = createRoad(context.resources, quality); const road = createRoad(context.resources, quality);
const terrain = createTerrain(context.resources, quality); const terrain = createTerrain(context.resources, quality);
const speedWake = createSpeedWake(context.resources);
const expressway = createExpressway(context.resources, quality); const expressway = createExpressway(context.resources, quality);
const signStage = createSignStage(context.resources); const signStage = createSignStage(context.resources);
const sun = createSun(context.resources); const sun = createSun(context.resources);
@@ -132,6 +135,7 @@ export async function createProceduralSceneGeneration(
sun.group, sun.group,
horizonAir.group, horizonAir.group,
car.group, car.group,
speedWake.group,
sign.group, sign.group,
); );
@@ -158,6 +162,8 @@ export async function createProceduralSceneGeneration(
let disposed = false; let disposed = false;
let renderedFrames = 0; let renderedFrames = 0;
let atmosphericTime = 0; let atmosphericTime = 0;
let signCameraWeight = 0;
let overdrive = 0;
let firstFrameSuppressionRecorded = false; let firstFrameSuppressionRecorded = false;
const resize = (): void => { const resize = (): void => {
@@ -240,6 +246,17 @@ export async function createProceduralSceneGeneration(
lastNow = now; lastNow = now;
dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds); dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds);
const response = dynamics.snapshot(); 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); writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
dampPalette(currentPalette, targetPalette, deltaSeconds); dampPalette(currentPalette, targetPalette, deltaSeconds);
const driveDistance = travelDistance.advance( const driveDistance = travelDistance.advance(
@@ -271,6 +288,7 @@ export async function createProceduralSceneGeneration(
snapshot.chapter, snapshot.chapter,
snapshot.routeProgress, snapshot.routeProgress,
currentPalette, currentPalette,
overdrive,
); );
atmosphericTime += deltaSeconds; atmosphericTime += deltaSeconds;
const elapsedSeconds = atmosphericTime; const elapsedSeconds = atmosphericTime;
@@ -287,6 +305,12 @@ export async function createProceduralSceneGeneration(
elapsedSeconds, elapsedSeconds,
driveDistance, driveDistance,
); );
speedWake.update(
overdrive,
elapsedSeconds,
car.group.position,
width < 1100 || height < 620 ? 2.32 : 2.78,
);
sign.update(snapshot.sign, now); sign.update(snapshot.sign, now);
atmosphere.update( atmosphere.update(
driveDistance, driveDistance,
@@ -302,7 +326,7 @@ export async function createProceduralSceneGeneration(
ambient.groundColor.copy(currentPalette.fog); ambient.groundColor.copy(currentPalette.fog);
ambient.intensity = 1.05 + currentPalette.warmth * 0.18; ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
carKeyLight.color.setHex(0xb9e3ef); 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.color.setHex(0x809ec1);
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8; carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4; 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. // The document stays native. Only the rendered lens eases to the chapter's safe zone.
const cameraX = wideComposition ? composition.x : 0; const cameraX = wideComposition ? composition.x : 0;
const targetX = 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 = const targetShift =
height < 560 && width >= 768 height < 560 && width >= 768
? 0.22 ? 0.22
: wideComposition : wideComposition
? composition.shift ? THREE.MathUtils.lerp(
composition.shift,
composition.shift * 0.28,
signCameraWeight,
)
: 0; : 0;
const targetVerticalShift = wideComposition const targetVerticalShift = wideComposition
? 0 ? 0
@@ -434,7 +470,14 @@ export async function createProceduralSceneGeneration(
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
} }
const stage = signStage.update(width, height); 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) { if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height); const presentation = sign.inspect(camera, width, height);
if (presentation !== null) { if (presentation !== null) {

View File

@@ -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,
};
}

View File

@@ -1,18 +1,24 @@
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
import { usesRoadsideSign } from './sign-layout';
const stageOwners = new WeakMap<HTMLElement, object>(); const stageOwners = new WeakMap<HTMLElement, object>();
/** Reserve real negative space in the document, below the navigation. The /** Feather the reading edge into the world. This is a spatial alpha ramp,
* canvas keeps its original stacking order. Content scrolls through this edge * not a time-based disappearance: reversing scroll restores the same pixels. */
* exactly as it does through the top of the viewport; no content is removed. */
export function createSignStage(resources: SceneResourceBag) { export function createSignStage(resources: SceneResourceBag) {
const main = document.querySelector<HTMLElement>('.portfolio-main'); const main = document.querySelector<HTMLElement>('.portfolio-main');
const nav = document.querySelector<HTMLElement>('.site-navigation'); const nav = document.querySelector<HTMLElement>('.site-navigation');
const root = document.documentElement; const root = document.documentElement;
const panels = main
? Array.from(main.children).filter(
(element): element is HTMLElement => element instanceof HTMLElement,
)
: [];
let dirty = true; let dirty = true;
let cutoff = 0; let cutoff = 0;
let stageBottom = 176; let stageBottom = 176;
let navigationBottom = 66; let navigationBottom = 66;
let readingTop = 220;
const token = {}; const token = {};
const mark = () => { const mark = () => {
dirty = true; dirty = true;
@@ -24,7 +30,7 @@ export function createSignStage(resources: SceneResourceBag) {
return; return;
if (event.target.matches('h1,h2,main')) return; if (event.target.matches('h1,h2,main')) return;
const rect = event.target.getBoundingClientRect(); const rect = event.target.getBoundingClientRect();
const top = window.innerHeight < 560 ? navigationBottom + 10 : stageBottom; const top = readingTop;
const bottom = const bottom =
window.innerHeight < 560 && window.innerWidth < 768 window.innerHeight < 560 && window.innerWidth < 768
? window.innerHeight - 164 ? window.innerHeight - 164
@@ -42,35 +48,95 @@ export function createSignStage(resources: SceneResourceBag) {
if (!main || stageOwners.get(main) !== token) return; if (!main || stageOwners.get(main) !== token) return;
stageOwners.delete(main); stageOwners.delete(main);
main?.style.removeProperty('clip-path'); 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-stage-bottom');
root.style.removeProperty('--sign-reading-top');
delete root.dataset.expressway; delete root.dataset.expressway;
}, },
}); });
return { return {
update(width: number, height: number) { update(width: number, height: number) {
if (dirty && main) { if (dirty && main) {
const rect = main.getBoundingClientRect();
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66; navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
const faceWidth = Math.min( const faceWidth = Math.min(
width - 44, width - 44,
width < 900 ? 288 : 356, width < 900 ? 288 : 356,
height < 650 ? 250 : 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. // Gradually establish the strip while the opening leaves the viewport.
const reveal = Math.min( const reveal = Math.min(
1, 1,
Math.max(0, window.scrollY / Math.max(240, height * 0.58)), Math.max(0, window.scrollY / Math.max(240, height * 0.58)),
); );
const short = height < 560; const short = height < 560;
const topEdge = short ? navigationBottom + 10 : stageBottom; const proposedTopEdge = short
cutoff = Math.max(0, topEdge * reveal - rect.top); ? navigationBottom + 10
const bottomCutoff = : height < 650
short && width < 768 ? Math.max(0, rect.bottom - (height - 164)) : 0; ? shelfBottom - 22
main.style.clipPath = `inset(${cutoff.toFixed(2)}px 0 ${bottomCutoff.toFixed(2)}px 0)`; : 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<HTMLElement>(':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( root.style.setProperty(
'--sign-stage-bottom', '--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'; root.dataset.expressway = 'live';
stageOwners.set(main, token); stageOwners.set(main, token);

View File

@@ -9,6 +9,7 @@ import {
type SignViewportProfile, type SignViewportProfile,
} from '@/journey/sign-routes'; } from '@/journey/sign-routes';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
import { roadsideSignMetrics, usesRoadsideSign } from './sign-layout';
interface ProjectingCamera { interface ProjectingCamera {
position: { x: number; y: number; z: number }; position: { x: number; y: number; z: number };
@@ -32,6 +33,7 @@ export interface SignWorld {
height: number, height: number,
navigationBottom: number, navigationBottom: number,
now: number, now: number,
carAnchor?: { x: number; z: number },
): void; ): void;
setViewportAspect(aspect: number): void; setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void; setViewportSize(width: number, height: number): void;
@@ -407,6 +409,11 @@ export function createSign(
let screenY = 120; let screenY = 120;
let screenWidth = 340; let screenWidth = 340;
let initialized = false; 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 point = new THREE.Vector3();
const ray = new THREE.Vector3(); const ray = new THREE.Vector3();
const forward = new THREE.Vector3(); const forward = new THREE.Vector3();
@@ -498,9 +505,13 @@ export function createSign(
if (!sign) { if (!sign) {
group.visible = false; group.visible = false;
initialized = false; initialized = false;
previousPhase = null;
dwellDrift = 0;
return; return;
} }
if (target !== sign.targetId) { if (target !== sign.targetId) {
previousPhase = null;
dwellDrift = 0;
target = sign.targetId; target = sign.targetId;
face.userData.textureUpdateCount += 1; face.userData.textureUpdateCount += 1;
face.userData.fontReadyWhenDrawn = face.userData.fontReadyWhenDrawn =
@@ -516,7 +527,14 @@ export function createSign(
initialized = false; initialized = false;
} }
}, },
compose(camera, width, height, navigationBottom, now) { compose(
camera,
width,
height,
navigationBottom,
now,
carAnchor = { x: 0, z: 0.72 },
) {
if (!snapshot) return; if (!snapshot) return;
viewportWidth = width; viewportWidth = width;
viewportHeight = height; viewportHeight = height;
@@ -546,6 +564,8 @@ export function createSign(
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000)); const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
previousNow = now; previousNow = now;
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1; 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; screenX += (targetX - screenX) * mix;
screenY += (targetY - screenY) * mix; screenY += (targetY - screenY) * mix;
screenWidth += (targetWidth - screenWidth) * mix; screenWidth += (targetWidth - screenWidth) * mix;
@@ -583,9 +603,43 @@ export function createSign(
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera); worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera); worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
if (lowerStrip) point.y = Math.max(1.4, point.y); 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.position.set(point.x, point.y, point.z);
group.scale.setScalar(worldRight.distanceTo(worldLeft) / 8.64); group.scale.setScalar(
group.rotation.y = camera.rotation.y - side * 0.065; 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(); plantMounting();
const opacity = clamp01(snapshot.opacity); const opacity = clamp01(snapshot.opacity);
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight; for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
@@ -596,6 +650,7 @@ export function createSign(
sweepMaterial.uniforms.uOpacity.value = sweepMaterial.uniforms.uOpacity.value =
snapshot.phase === 'approach' ? opacity * 0.14 : 0; snapshot.phase === 'approach' ? opacity * 0.14 : 0;
group.visible = opacity > 0; group.visible = opacity > 0;
group.userData.anchoring = roadside ? 'roadside' : 'compact-shelf';
}, },
}; };
} }

View File

@@ -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 <tonemapping_fragment>
#include <colorspace_fragment>
}`,
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;
},
};
}

View File

@@ -21,6 +21,7 @@ export interface TerrainWorld {
chapter: EnvironmentChapter, chapter: EnvironmentChapter,
routeProgress: number, routeProgress: number,
palette?: Readonly<MutableChapterPalette>, palette?: Readonly<MutableChapterPalette>,
overdrive?: number,
): void; ): void;
} }
@@ -30,9 +31,10 @@ interface WorldGroup {
} }
const TERRAIN_WIDTH = 620; const TERRAIN_WIDTH = 620;
const ROAD_CLEAR_HALF_WIDTH = 10.3; const ROAD_CLEAR_HALF_WIDTH = 8.2;
const RIDGE_START = 46; const RIDGE_START = 38;
const NOISE_PERIOD = 5_694; const NOISE_PERIOD = 5_694;
const GRID_STEP = 6;
export function createTerrain( export function createTerrain(
resources: SceneResourceBag, resources: SceneResourceBag,
@@ -51,6 +53,7 @@ export function createTerrain(
uDistance: { value: 0 }, uDistance: { value: 0 },
uFlowDistance: { value: 0 }, uFlowDistance: { value: 0 },
uSpeed: { value: 0 }, uSpeed: { value: 0 },
uOverdrive: { value: 0 },
uRouteProgress: { value: 0 }, uRouteProgress: { value: 0 },
uTerrainEnergy: { value: initialPalette.terrainEnergy }, uTerrainEnergy: { value: initialPalette.terrainEnergy },
uOctaves: { value: quality.terrainOctaves }, uOctaves: { value: quality.terrainOctaves },
@@ -70,6 +73,7 @@ export function createTerrain(
uniform float uDistance; uniform float uDistance;
uniform float uFlowDistance; uniform float uFlowDistance;
uniform float uSpeed; uniform float uSpeed;
uniform float uOverdrive;
uniform float uRouteProgress; uniform float uRouteProgress;
uniform float uTerrainEnergy; uniform float uTerrainEnergy;
uniform float uOctaves; uniform float uOctaves;
@@ -101,20 +105,24 @@ export function createTerrain(
float multiOctaveNoise(vec2 point) { float multiOctaveNoise(vec2 point) {
float result = valueNoise(point) * 0.58; float result = valueNoise(point) * 0.58;
if (uOctaves > 1.5) result += valueNoise(point * 2.03 + 17.2) * 0.28; if (uOctaves > 1.5) result += valueNoise(point * 2.0 + 17.2) * 0.28;
if (uOctaves > 2.5) result += valueNoise(point * 4.07 - 9.4) * 0.14; if (uOctaves > 2.5) result += valueNoise(point * 4.0 - 9.4) * 0.14;
return result; return result;
} }
void main() { void main() {
vec3 displaced = position; vec3 displaced = position;
float wrappedPhase = mod(uDistance, ${NOISE_PERIOD.toFixed(1)}); 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; 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 ridgeNoise = multiOctaveNoise(world * S);
float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x))); float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x)));
float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy; 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; vTerrainPosition = displaced;
vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0); vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0);
@@ -123,7 +131,8 @@ export function createTerrain(
float viewDistance = -viewPosition.z; float viewDistance = -viewPosition.z;
vDepthFade = smoothstep(4.0, 18.0, viewDistance) vDepthFade = smoothstep(4.0, 18.0, viewDistance)
* (1.0 - smoothstep(${(HORIZON_FOG_START + 150).toFixed(1)}, ${HORIZON_FOG_END.toFixed(1)}, 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; gl_Position = projectionMatrix * viewPosition;
} }
`, `,
@@ -133,6 +142,7 @@ export function createTerrain(
uniform vec3 uFogColor; uniform vec3 uFogColor;
uniform vec3 uHazeColor; uniform vec3 uHazeColor;
uniform float uRouteProgress; uniform float uRouteProgress;
uniform float uOverdrive;
varying float vHeight; varying float vHeight;
varying float vRoadMask; varying float vRoadMask;
varying float vDepthFade; varying float vDepthFade;
@@ -140,13 +150,20 @@ export function createTerrain(
varying vec3 vTerrainPosition; varying vec3 vTerrainPosition;
void main() { void main() {
float ridgeMix = smoothstep(0.16, 0.78, vHeight); float ridgeMix = smoothstep(0.025, 0.29, vHeight);
vec3 color = mix(uLowColor, uHighColor, ridgeMix); 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); color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade);
float pulse = 0.88 + 0.12 * smoothstep(0.16, 0.55, vFlow); 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 routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.30, 0.83, ridgeMix); float road = 1.0 - smoothstep(7.6, 8.4, abs(vTerrainPosition.x));
gl_FragColor = vec4(color * mix(1.15, 1.75, ridgeMix), alpha); 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 <tonemapping_fragment>
#include <colorspace_fragment> #include <colorspace_fragment>
} }
@@ -160,7 +177,7 @@ export function createTerrain(
); );
const groundGeometry = resources.own('geometry', geometry.clone()); 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 columns = geometry.attributes.position.count / (rows + 1) - 1;
const triangles: number[] = []; const triangles: number[] = [];
for (let row = 0; row < rows; row++) for (let row = 0; row < rows; row++)
@@ -175,7 +192,7 @@ export function createTerrain(
new THREE.ShaderMaterial({ new THREE.ShaderMaterial({
uniforms, uniforms,
vertexShader: material.vertexShader.replace( vertexShader: material.vertexShader.replace(
'displaced.y += height;', 'displaced.y += height + 0.028;',
'displaced.y += height - 0.12;', 'displaced.y += height - 0.12;',
), ),
fragmentShader: ` fragmentShader: `
@@ -214,12 +231,13 @@ export function createTerrain(
return { return {
group, group,
update(distance, speed, chapter, routeProgress, palette) { update(distance, speed, chapter, routeProgress, palette, overdrive = 0) {
const chapterPalette = CHAPTER_PALETTES[chapter]; const chapterPalette = CHAPTER_PALETTES[chapter];
uniforms.uDistance.value = uniforms.uDistance.value =
((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD; ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
uniforms.uFlowDistance.value = uniforms.uDistance.value; uniforms.uFlowDistance.value = uniforms.uDistance.value;
uniforms.uSpeed.value = clamp01(speed); uniforms.uSpeed.value = clamp01(speed);
uniforms.uOverdrive.value = clamp01(overdrive);
uniforms.uRouteProgress.value = clamp01(routeProgress); uniforms.uRouteProgress.value = clamp01(routeProgress);
uniforms.uTerrainEnergy.value = uniforms.uTerrainEnergy.value =
palette?.terrainEnergy ?? chapterPalette.terrainEnergy; palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
@@ -237,12 +255,16 @@ export function createTerrain(
} }
function createIndexedTerrainGeometry(segments: number) { function createIndexedTerrainGeometry(segments: number) {
const zSegments = Math.max(16, Math.floor(segments)); const zSegments = Math.ceil(WORLD_DEPTH / GRID_STEP);
const approximateCell = WORLD_DEPTH / zSegments; // Concentrate columns in the road valley; large distant ridges need fewer.
const xSegments = Math.max( const outerStep = segments >= 100 ? 12 : 18;
48, const xs: number[] = [];
Math.floor(TERRAIN_WIDTH / approximateCell / 2) * 2, 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 columnCount = xSegments + 1;
const rowCount = zSegments + 1; const rowCount = zSegments + 1;
const positions = new Float32Array(columnCount * rowCount * 3); const positions = new Float32Array(columnCount * rowCount * 3);
@@ -251,9 +273,9 @@ function createIndexedTerrainGeometry(segments: number) {
let maskOffset = 0; let maskOffset = 0;
for (let row = 0; row < rowCount; row += 1) { 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) { 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] = x;
positions[vertexOffset + 1] = 0; positions[vertexOffset + 1] = 0;
positions[vertexOffset + 2] = z; positions[vertexOffset + 2] = z;
@@ -272,17 +294,11 @@ function createIndexedTerrainGeometry(segments: number) {
row * columnCount + column; row * columnCount + column;
for (let row = 0; row < rowCount; row += 1) { for (let row = 0; row < rowCount; row += 1) {
for (let column = 0; column < columnCount; column += 1) { for (let column = 0; column < columnCount; column += 1) {
const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH; if (row < zSegments) {
if (row < zSegments && Math.abs(x) > ROAD_CLEAR_HALF_WIDTH) {
indices.push(vertexIndex(column, row), vertexIndex(column, row + 1)); indices.push(vertexIndex(column, row), vertexIndex(column, row + 1));
} }
if (column < xSegments) { if (column < xSegments) {
const nextX = indices.push(vertexIndex(column, row), vertexIndex(column + 1, row));
-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));
}
} }
} }
} }

View File

@@ -2,7 +2,7 @@
Existing content, navigation and audio controls retain their contracts. */ Existing content, navigation and audio controls retain their contracts. */
:root { :root {
--canvas: #080d18; --canvas: #080d18;
--surface: rgb(12 20 32 / 96%); --surface: rgb(12 20 32 / 98.5%);
--surface-strong: #0c1420; --surface-strong: #0c1420;
--surface-soft: #132130; --surface-soft: #132130;
--line: rgb(160 196 212 / 18%); --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 { 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 { @media print {
.portfolio-main { .portfolio-main,
.portfolio-main > * {
clip-path: none !important; clip-path: none !important;
mask-image: none !important;
} }
} }
@@ -956,7 +975,13 @@ html[data-expressway='live'] .portfolio-section {
} }
@media (max-height: 650px) { @media (max-height: 650px) {
html[data-expressway='live'] .portfolio-section { 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) { @media (min-width: 768px) and (max-width: 900px) {
@@ -971,6 +996,15 @@ html[data-expressway='live'] .portfolio-section {
font-size: 32px; 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) { @media (min-width: 768px) and (max-height: 559px) {
html[data-expressway='live'] .portfolio-section[data-layout] { html[data-expressway='live'] .portfolio-section[data-layout] {
inline-size: 60%; inline-size: 60%;