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 { 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) {
|
||||||
|
|||||||
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 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);
|
||||||
|
|||||||
@@ -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';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
Reference in New Issue
Block a user