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