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:
@@ -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) {
|
||||
|
||||
19
src/scene/world/sign-layout.ts
Normal file
19
src/scene/world/sign-layout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import { usesRoadsideSign } from './sign-layout';
|
||||
|
||||
const stageOwners = new WeakMap<HTMLElement, object>();
|
||||
|
||||
/** 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<HTMLElement>('.portfolio-main');
|
||||
const nav = document.querySelector<HTMLElement>('.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<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(
|
||||
'--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);
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
130
src/scene/world/speed-wake.ts
Normal file
130
src/scene/world/speed-wake.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,20 +294,14 @@ 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user