feat: refine nightdrive world and authored car

Integrate the Blender grand tourer with a cohesive shader-driven road, terrain, atmosphere, lighting, and post-processing pass. Refine responsive drive dynamics and sign lifecycle behavior, and add durable visual, accessibility, and performance verification evidence.
This commit is contained in:
2026-09-02 23:16:25 +03:00
parent 7dae835ac5
commit bc18bc2043
73 changed files with 1555 additions and 293 deletions

Binary file not shown.

View File

@@ -43,6 +43,7 @@ export function useScrollDrive(): ScrollDriveBinding {
scrollY: scrollingElement.scrollTop,
now,
maxScrollY: Math.max(0, scrollingElement.scrollHeight - viewportHeight),
viewportHeight,
};
};

View File

@@ -4,12 +4,14 @@ import type {
ScrollDriveSnapshot,
} from './types';
const MAX_RAW_SPEED = 2.5;
const RISE_TIME_CONSTANT_MS = 90;
const DECAY_TIME_CONSTANT_MS = 420;
const MAX_VIEWPORTS_PER_SECOND = 3;
const RISE_TIME_CONSTANT_MS = 180;
const DECAY_TIME_CONSTANT_MS = 1_050;
const CRUISE_SPEED = 0.18;
const ACCELERATION_SPEED_RANGE = 0.82;
const BACKGROUND_GAP_MS = 250;
const MAX_INTEGRATION_INTERVAL_MS = 120;
const DEFAULT_VIEWPORT_HEIGHT = 800;
const DECAY_SETTLE_EPSILON = 0.001;
const clamp = (value: number, minimum: number, maximum: number): number =>
@@ -18,7 +20,10 @@ const clamp = (value: number, minimum: number, maximum: number): number =>
const isFiniteSample = (input: ScrollDriveSample): boolean =>
Number.isFinite(input.scrollY) &&
Number.isFinite(input.now) &&
Number.isFinite(input.maxScrollY);
Number.isFinite(input.maxScrollY) &&
(input.viewportHeight === undefined || (
Number.isFinite(input.viewportHeight) && input.viewportHeight > 0
));
const initialSnapshot = (): ScrollDriveSnapshot => ({
scrollY: 0,
@@ -76,18 +81,25 @@ export function createScrollDriveController(): ScrollDriveController {
const deltaY = input.scrollY - previousSample.scrollY;
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;
if (elapsedMs > BACKGROUND_GAP_MS) {
previousSample = { ...input };
smoothedAcceleration = 0;
return { ...cruiseSnapshot(input), direction };
}
const rawSpeed = Math.abs(deltaY) / elapsedMs;
const impulse = clamp(rawSpeed / MAX_RAW_SPEED, 0, 1);
const integrationMs = elapsedMs > BACKGROUND_GAP_MS
? MAX_INTEGRATION_INTERVAL_MS
: elapsedMs;
const viewportHeight = Math.max(
1,
input.viewportHeight ?? previousSample.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT,
);
const viewportVelocity = elapsedMs > BACKGROUND_GAP_MS
? 0
: (Math.abs(deltaY) / viewportHeight) / (elapsedMs / 1_000);
const impulse = clamp(
viewportVelocity / MAX_VIEWPORTS_PER_SECOND,
0,
1,
);
const timeConstantMs = impulse > smoothedAcceleration
? RISE_TIME_CONSTANT_MS
: DECAY_TIME_CONSTANT_MS;
const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs);
const alpha = 1 - Math.exp(-integrationMs / timeConstantMs);
smoothedAcceleration += (impulse - smoothedAcceleration) * alpha;
if (impulse === 0 && smoothedAcceleration < DECAY_SETTLE_EPSILON) {
smoothedAcceleration = 0;

View File

@@ -13,6 +13,17 @@ const DEPART_MS = 700;
const COOLDOWN_MS = 350;
const ELIGIBLE_DISTANCE_VH = 1.2;
const JUMP_DISTANCE_VH = 0.75;
const CONCEALED_OPACITY = 0.02;
const SECTION_ORDER: readonly SectionId[] = [
'start',
'profile',
'experience',
'infrastructure',
'selected-builds',
'additional-work',
'toolkit',
'contact',
];
interface VisibleState {
kind: 'visible';
@@ -20,6 +31,7 @@ interface VisibleState {
latchedAt: number;
approachDuration: number;
departStartedAt: number | null;
generation: number;
}
type State =
@@ -62,16 +74,43 @@ const canLatch = (input: SignControllerSample): input is SignControllerSample &
export function createSignController(): SignController {
let state: State = { kind: 'idle' };
let lastNow: number | null = null;
let generation = 0;
const reset = (now?: number): void => {
generation += 1;
state = { kind: 'idle' };
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
};
const enterCooldown = (now: number): void => {
generation += 1;
state = { kind: 'cooldown', until: now + COOLDOWN_MS };
};
const latch = (
input: SignControllerSample & {
candidateId: SectionId;
candidateDistanceVh: number;
},
): VisibleState => {
generation += 1;
return {
kind: 'visible',
targetId: input.candidateId,
latchedAt: input.now,
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
departStartedAt: null,
generation,
};
};
const beginDeparture = (visible: VisibleState, now: number): void => {
if (visible.departStartedAt !== null) return;
generation += 1;
visible.generation = generation;
visible.departStartedAt = now;
};
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
if (visible.departStartedAt !== null) {
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
@@ -126,15 +165,23 @@ export function createSignController(): SignController {
}
lastNow = input.now;
const suppressed =
!input.motionAllowed ||
input.source !== 'passive-scroll' ||
if (!input.motionAllowed || input.source !== 'passive-scroll') {
reset(input.now);
return null;
}
const discontinuous =
input.crossedBoundaries > 1 ||
Math.abs(input.deltaY) > input.viewportHeight * JUMP_DISTANCE_VH;
if (suppressed) {
if (!input.motionAllowed) reset(input.now);
else if (state.kind === 'visible') enterCooldown(input.now);
return null;
if (discontinuous) {
if (state.kind !== 'visible') return null;
const current = snapshotVisible(state, input.now);
if (current === null || current.opacity <= CONCEALED_OPACITY) {
enterCooldown(input.now);
return null;
}
beginDeparture(state, input.now);
return snapshotVisible(state, input.now);
}
if (state.kind === 'cooldown') {
@@ -144,13 +191,31 @@ export function createSignController(): SignController {
if (state.kind === 'idle') {
if (!canLatch(input)) return null;
state = {
kind: 'visible',
targetId: input.candidateId,
latchedAt: input.now,
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
departStartedAt: null,
};
state = latch(input);
return snapshotVisible(state, input.now);
}
const current = snapshotVisible(state, input.now);
if (current === null) return null;
if (
current.opacity <= CONCEALED_OPACITY
&& canLatch(input)
&& input.candidateId !== state.targetId
) {
state = latch(input);
return snapshotVisible(state, input.now);
}
const activeIndex = SECTION_ORDER.indexOf(input.activeSectionId);
const targetIndex = SECTION_ORDER.indexOf(state.targetId);
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
if (targetPassed && state.departStartedAt === null) {
if (current.opacity <= CONCEALED_OPACITY) {
enterCooldown(input.now);
return null;
}
beginDeparture(state, input.now);
return snapshotVisible(state, input.now);
}
@@ -162,7 +227,7 @@ export function createSignController(): SignController {
return null;
}
if (state.departStartedAt === null && input.now >= dwellStartedAt) {
state.departStartedAt = input.now;
beginDeparture(state, input.now);
}
}
@@ -170,7 +235,7 @@ export function createSignController(): SignController {
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
if (dwellComplete && input.activeSectionId === state.targetId) {
state.departStartedAt = input.now;
beginDeparture(state, input.now);
}
}

View File

@@ -4,6 +4,7 @@ export interface ScrollDriveSample {
scrollY: number;
now: number;
maxScrollY: number;
viewportHeight?: number;
}
export interface ScrollDriveSnapshot {

107
src/scene/drive-dynamics.ts Normal file
View File

@@ -0,0 +1,107 @@
export interface DriveDynamicsSnapshot {
roadEnergy: number;
cameraEnergy: number;
streakEnergy: number;
bloomEnergy: number;
suspensionEnergy: number;
directionBias: number;
}
export interface DriveDynamics {
update(energy: number, direction: -1 | 0 | 1, deltaSeconds: number): void;
snapshot(): Readonly<DriveDynamicsSnapshot>;
reset(): void;
}
interface ResponseCurve {
attackSeconds: number;
releaseSeconds: number;
}
const RESPONSE = {
roadEnergy: { attackSeconds: 0.14, releaseSeconds: 0.62 },
cameraEnergy: { attackSeconds: 0.26, releaseSeconds: 0.92 },
streakEnergy: { attackSeconds: 0.2, releaseSeconds: 1.18 },
bloomEnergy: { attackSeconds: 0.42, releaseSeconds: 1.35 },
suspensionEnergy: { attackSeconds: 0.18, releaseSeconds: 0.78 },
} as const satisfies Record<
Exclude<keyof DriveDynamicsSnapshot, "directionBias">,
ResponseCurve
>;
const RESPONSE_KEYS = [
"roadEnergy",
"cameraEnergy",
"streakEnergy",
"bloomEnergy",
"suspensionEnergy",
] as const satisfies readonly (keyof typeof RESPONSE)[];
const MAX_FRAME_SECONDS = 0.1;
export function createDriveDynamics(): DriveDynamics {
const state: DriveDynamicsSnapshot = {
roadEnergy: 0,
cameraEnergy: 0,
streakEnergy: 0,
bloomEnergy: 0,
suspensionEnergy: 0,
directionBias: 0,
};
const reset = (): void => {
state.roadEnergy = 0;
state.cameraEnergy = 0;
state.streakEnergy = 0;
state.bloomEnergy = 0;
state.suspensionEnergy = 0;
state.directionBias = 0;
};
return {
update(energy, direction, deltaSeconds) {
const target = clamp01(energy);
const dt = clampFinite(deltaSeconds, 0, MAX_FRAME_SECONDS);
if (dt <= 0) return;
for (const key of RESPONSE_KEYS) {
const curve = RESPONSE[key];
const current = state[key];
const seconds = target > current
? curve.attackSeconds
: curve.releaseSeconds;
state[key] = damp(current, target, dt, seconds);
}
const directionTarget = direction * target;
state.directionBias = damp(
state.directionBias,
directionTarget,
dt,
direction === 0 ? 0.82 : 0.32,
);
if (target === 0 && Math.abs(state.directionBias) < 0.0005)
state.directionBias = 0;
},
snapshot: () => state,
reset,
};
}
function damp(
current: number,
target: number,
deltaSeconds: number,
timeConstantSeconds: number,
): number {
const alpha = 1 - Math.exp(-deltaSeconds / timeConstantSeconds);
return current + (target - current) * alpha;
}
function clamp01(value: number): number {
return clampFinite(value, 0, 1);
}
function clampFinite(value: number, minimum: number, maximum: number): number {
if (!Number.isFinite(value)) return minimum;
return Math.min(maximum, Math.max(minimum, value));
}

View File

@@ -2,6 +2,7 @@ import * as THREE from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
import type {
GpuRenderTargetAllocation,
GpuRenderTargetReporter,
@@ -10,6 +11,7 @@ import type {
export interface PostProcessor extends GpuRenderTargetReporter {
resize(width: number, height: number, pixelRatio: number): void;
setDynamics(acceleration: number): void;
render(scene: unknown, camera: unknown): void;
dispose(): void;
}
@@ -38,23 +40,32 @@ interface RenderPassLike {
}
interface BloomPassLike {
strength: number;
radius: number;
threshold: number;
renderTargetBright?: RenderTargetLike;
renderTargetsHorizontal?: readonly RenderTargetLike[];
renderTargetsVertical?: readonly RenderTargetLike[];
dispose(): void;
}
interface OutputPassLike {
dispose(): void;
}
export interface PostProcessingFactories {
createComposer(renderer: unknown): ComposerLike;
createRenderPass(scene: unknown, camera: unknown): RenderPassLike;
createBloomPass(): BloomPassLike;
createOutputPass(): OutputPassLike;
}
const productionFactories: PostProcessingFactories = {
createComposer: (renderer) => new EffectComposer(renderer),
createRenderPass: (scene, camera) => new RenderPass(scene, camera),
createBloomPass: () =>
new UnrealBloomPass(new THREE.Vector2(1, 1), 0.46, 0.32, 0.84),
new UnrealBloomPass(new THREE.Vector2(1, 1), 0.54, 0.42, 0.88),
createOutputPass: () => new OutputPass(),
};
export function createPostProcessor(
@@ -68,8 +79,10 @@ export function createPostProcessor(
new THREE.Camera(),
);
const bloomPass = factories.createBloomPass();
const outputPass = factories.createOutputPass();
composer.addPass(renderPass);
composer.addPass(bloomPass);
composer.addPass(outputPass);
let disposed = false;
const processor: PostProcessor = {
@@ -84,6 +97,13 @@ export function createPostProcessor(
Math.max(1, Math.floor(finiteOr(height, 1))),
);
},
setDynamics(acceleration) {
if (disposed) return;
const energy = clamp(acceleration, 0, 1);
bloomPass.strength = 0.54 + energy * 0.22;
bloomPass.radius = 0.42 + energy * 0.08;
bloomPass.threshold = 0.88 - energy * 0.03;
},
render(scene, camera) {
if (disposed) return;
renderPass.scene = scene;
@@ -95,6 +115,7 @@ export function createPostProcessor(
disposed = true;
renderPass.dispose();
bloomPass.dispose();
outputPass.dispose();
composer.dispose();
},
};

View File

@@ -14,31 +14,31 @@ export interface RenderQualitySettings {
const settings: Readonly<Record<LiveQualityTier, Omit<RenderQualitySettings, 'pixelRatio'> & { dprCap: number }>> = {
high: {
dprCap: 1.5,
roadSegments: 192,
terrainSegments: 96,
roadSegments: 224,
terrainSegments: 112,
terrainOctaves: 3,
starCount: 384,
streakCount: 48,
starCount: 760,
streakCount: 112,
postScale: 1,
postEffects: true,
},
medium: {
dprCap: 1.5,
roadSegments: 144,
terrainSegments: 64,
roadSegments: 168,
terrainSegments: 80,
terrainOctaves: 2,
starCount: 192,
streakCount: 28,
starCount: 420,
streakCount: 64,
postScale: 0.7,
postEffects: true,
},
low: {
dprCap: 1.25,
roadSegments: 96,
terrainSegments: 32,
roadSegments: 112,
terrainSegments: 48,
terrainOctaves: 1,
starCount: 64,
streakCount: 12,
starCount: 160,
streakCount: 28,
postScale: 0,
postEffects: false,
},

View File

@@ -5,3 +5,5 @@ declare module 'three';
declare module 'three/examples/jsm/postprocessing/EffectComposer.js';
declare module 'three/examples/jsm/postprocessing/RenderPass.js';
declare module 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
declare module 'three/examples/jsm/postprocessing/OutputPass.js';
declare module 'three/examples/jsm/loaders/GLTFLoader.js';

View File

@@ -1,11 +1,12 @@
import * as THREE from 'three';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
import type { MutableChapterPalette } from '@/scene/world/palette';
import type { MutableChapterPalette, MutableColor } from '@/scene/world/palette';
import { CHAPTER_PALETTES } from '@/scene/world/palette';
export interface AtmosphereWorld {
group: WorldGroup;
setViewportAspect(aspect: number): void;
update(
distance: number,
speed: number,
@@ -27,30 +28,44 @@ export function createAtmosphere(
group.name = 'atmosphere-world';
const night = CHAPTER_PALETTES['deep-night'];
const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(520, 280));
const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2));
const skyMaterial = resources.own('material', new THREE.ShaderMaterial({
uniforms: {
uSkyColor: { value: new THREE.Color(night.sky) },
uFogColor: { value: new THREE.Color(night.fog) },
uHazeColor: { value: new THREE.Color(night.haze) },
uWarmth: { value: night.warmth },
uAspect: { value: 1 },
},
vertexShader: `
varying vec2 vSkyUv;
void main() {
vSkyUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_Position = vec4(position.xy, 1.0, 1.0);
}
`,
fragmentShader: `
uniform vec3 uSkyColor;
uniform vec3 uFogColor;
uniform vec3 uHazeColor;
uniform float uWarmth;
uniform float uAspect;
varying vec2 vSkyUv;
void main() {
float horizon = 1.0 - smoothstep(0.08, 0.72, vSkyUv.y);
float upperNight = smoothstep(0.35, 1.0, vSkyUv.y);
vec3 color = mix(uSkyColor, uHazeColor, horizon * (0.38 + uWarmth * 0.2));
color *= mix(1.04, 0.72, upperNight);
float verticalSky = smoothstep(0.08, 0.66, vSkyUv.y);
vec3 lowerField = mix(uSkyColor, uFogColor, 0.34);
vec3 color = mix(lowerField * 0.72, uSkyColor * 0.48, verticalSky);
float horizonBand = exp(-pow(abs(vSkyUv.y - 0.47) * 6.2, 2.0));
float normalizedX = abs(vSkyUv.x - 0.5) * 2.0 * min(uAspect, 2.35) / 2.35;
float centerGlow = 1.0 - smoothstep(0.18, 1.16, normalizedX);
color = mix(
color,
uHazeColor * (0.72 + uWarmth * 0.14),
horizonBand * mix(0.13, 0.42, centerGlow)
);
float topFalloff = smoothstep(0.54, 1.0, vSkyUv.y);
float vignette = smoothstep(0.36, 1.18, normalizedX);
color *= 1.0 - topFalloff * 0.28 - vignette * 0.08;
gl_FragColor = vec4(color, 1.0);
}
`,
@@ -61,10 +76,33 @@ export function createAtmosphere(
}));
const sky = new THREE.Mesh(skyGeometry, skyMaterial);
sky.name = 'atmosphere-sky';
sky.position.set(0, 69, -300);
sky.frustumCulled = false;
sky.renderOrder = -100;
group.add(sky);
const silhouettes = new THREE.Group();
silhouettes.name = 'distant-silhouettes';
const silhouetteMaterials: Array<{ color: MutableColor }> = [];
for (let band = 0; band < 3; band += 1) {
const material = resources.own('material', new THREE.MeshBasicMaterial({
color: new THREE.Color(night.fog).lerp(new THREE.Color(night.terrainLow), 0.46 + band * 0.12),
transparent: true,
opacity: 0.2 - band * 0.035,
depthWrite: false,
depthTest: true,
toneMapped: true,
}));
silhouetteMaterials.push(material);
const ridge = new THREE.Mesh(
resources.own('geometry', createSilhouetteGeometry(band)),
material,
);
ridge.name = `distant-silhouette-${band + 1}`;
ridge.renderOrder = -30 + band;
silhouettes.add(ridge);
}
group.add(silhouettes);
const starGeometry = resources.own('geometry', createStarGeometry(quality.starCount));
const starMaterial = resources.own('material', new THREE.ShaderMaterial({
uniforms: {
@@ -115,18 +153,25 @@ export function createAtmosphere(
stars.frustumCulled = false;
group.add(stars);
const hazeGeometry = resources.own('geometry', new THREE.PlaneGeometry(260, 58));
const nearHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.2));
const farHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.12));
const hazeGroup = new THREE.Group();
hazeGroup.name = 'horizon-haze';
const hazeGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2));
const nearHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.15, 0.47, 8.4));
const farHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.1, 0.5, 5.8));
const farHaze = new THREE.Mesh(hazeGeometry, farHazeMaterial);
farHaze.name = 'horizon-haze-far';
farHaze.position.set(0, 10, -214);
farHaze.scale.set(1.35, 1.1, 1);
group.add(farHaze);
farHaze.frustumCulled = false;
farHaze.renderOrder = 60;
hazeGroup.add(farHaze);
const nearHaze = new THREE.Mesh(hazeGeometry, nearHazeMaterial);
nearHaze.name = 'horizon-haze-near';
nearHaze.position.set(0, 8, -132);
group.add(nearHaze);
nearHaze.frustumCulled = false;
nearHaze.renderOrder = 61;
hazeGroup.add(nearHaze);
group.add(hazeGroup);
const anchors = createRoadsideAnchors(resources, quality);
group.add(anchors.mesh);
const streakGeometry = resources.own('geometry', createStreakGeometry(quality.streakCount));
const streakMaterial = resources.own('material', new THREE.ShaderMaterial({
@@ -143,8 +188,8 @@ export function createAtmosphere(
varying float vTail;
void main() {
vec3 streak = position;
float depthCycle = mod(streak.z + uDistance * 1.4 + 280.0, 286.0) - 280.0;
streak.z = depthCycle + endFactor * (1.2 + uAcceleration * 18.0);
float depthCycle = mod(streak.z + uDistance * 1.55 + 520.0, 526.0) - 520.0;
streak.z = depthCycle + endFactor * (1.4 + uAcceleration * 31.0);
vTail = endFactor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0);
}
@@ -154,7 +199,7 @@ export function createAtmosphere(
uniform float uVisibility;
varying float vTail;
void main() {
gl_FragColor = vec4(uColor, uVisibility * mix(0.15, 0.72, vTail));
gl_FragColor = vec4(uColor * (1.0 + uVisibility * 0.45), uVisibility * mix(0.08, 0.62, vTail));
}
`,
transparent: true,
@@ -164,27 +209,42 @@ export function createAtmosphere(
toneMapped: true,
}));
const streaks = new THREE.LineSegments(streakGeometry, streakMaterial);
streaks.name = 'atmosphere-streaks';
streaks.name = 'speed-streaks';
streaks.frustumCulled = false;
group.add(streaks);
return {
group,
setViewportAspect(aspect) {
const safeAspect = Number.isFinite(aspect) ? THREE.MathUtils.clamp(aspect, 0.35, 4) : 1;
skyMaterial.uniforms.uAspect.value = safeAspect;
nearHazeMaterial.uniforms.uAspect.value = safeAspect;
farHazeMaterial.uniforms.uAspect.value = safeAspect;
},
update(distance, speed, acceleration, palette) {
const safeAcceleration = clamp01(acceleration);
const visibility = smoothstep(0.18, 0.82, safeAcceleration) * palette.streakEnergy;
const visibility = Math.pow(safeAcceleration, 1.12) * palette.streakEnergy * 0.82;
starMaterial.uniforms.uDistance.value = finiteOrZero(distance);
starMaterial.uniforms.uVisibility.value = palette.starVisibility;
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance) * (0.72 + clamp01(speed) * 0.28);
streakMaterial.uniforms.uAcceleration.value = safeAcceleration;
streakMaterial.uniforms.uVisibility.value = visibility;
skyMaterial.uniforms.uSkyColor.value.copy(palette.sky);
skyMaterial.uniforms.uFogColor.value.copy(palette.fog);
skyMaterial.uniforms.uHazeColor.value.copy(palette.haze);
skyMaterial.uniforms.uWarmth.value = palette.warmth;
nearHazeMaterial.uniforms.uColor.value.copy(palette.haze);
farHazeMaterial.uniforms.uColor.value.copy(palette.haze);
nearHazeMaterial.uniforms.uEnergy.value = 0.16 + palette.warmth * 0.08;
farHazeMaterial.uniforms.uEnergy.value = 0.09 + palette.warmth * 0.06;
for (let index = 0; index < silhouetteMaterials.length; index += 1) {
silhouetteMaterials[index]?.color
.copy(palette.fog)
.lerp(palette.terrainLow, 0.46 + index * 0.12);
}
anchors.uniforms.uDistance.value = finiteOrZero(distance);
anchors.uniforms.uEnergy.value = 0.38 + palette.terrainEnergy * 0.22;
anchors.uniforms.uColor.value.copy(palette.terrainHigh);
},
};
}
@@ -195,9 +255,9 @@ function createStarGeometry(count: number) {
const brightness = new Float32Array(count);
for (let index = 0; index < count; index += 1) {
const offset = index * 3;
positions[offset] = seeded(index, 11) * 210 - 105;
positions[offset + 1] = 8 + seeded(index, 23) * 72;
positions[offset + 2] = -18 - seeded(index, 37) * 270;
positions[offset] = seeded(index, 11) * 680 - 340;
positions[offset + 1] = 16 + seeded(index, 23) * 230;
positions[offset + 2] = -48 - seeded(index, 37) * 540;
sizes[index] = 2.4 + seeded(index, 53) * 4.8;
brightness[index] = 0.28 + seeded(index, 71) * 0.72;
}
@@ -212,9 +272,9 @@ function createStreakGeometry(count: number) {
const positions = new Float32Array(count * 2 * 3);
const ends = new Float32Array(count * 2);
for (let index = 0; index < count; index += 1) {
const x = (seeded(index, 101) > 0.5 ? 1 : -1) * (12 + seeded(index, 103) * 52);
const y = 0.8 + seeded(index, 107) * 12;
const z = -8 - seeded(index, 109) * 272;
const x = (seeded(index, 101) > 0.5 ? 1 : -1) * (14 + seeded(index, 103) * 102);
const y = 0.8 + seeded(index, 107) * 32;
const z = -8 - seeded(index, 109) * 512;
const offset = index * 6;
positions[offset] = x;
positions[offset + 1] = y;
@@ -231,27 +291,113 @@ function createStreakGeometry(count: number) {
return geometry;
}
function createHazeMaterial(color: number, energy: number) {
function createHazeMaterial(
color: number,
energy: number,
center: number,
spread: number,
) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color(color) },
uEnergy: { value: energy },
uCenter: { value: center },
uSpread: { value: spread },
uAspect: { value: 1 },
},
vertexShader: `
varying vec2 vHazeUv;
void main() {
vHazeUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_Position = vec4(position.xy, 1.0, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uEnergy;
uniform float uCenter;
uniform float uSpread;
uniform float uAspect;
varying vec2 vHazeUv;
void main() {
float vertical = 1.0 - smoothstep(0.04, 0.84, abs(vHazeUv.y - 0.42) * 1.9);
float horizontal = 1.0 - smoothstep(0.26, 0.5, abs(vHazeUv.x - 0.5));
gl_FragColor = vec4(uColor, vertical * horizontal * uEnergy);
float vertical = exp(-pow(abs(vHazeUv.y - uCenter) * uSpread, 2.0));
float normalizedX = abs(vHazeUv.x - 0.5) * 2.0 * min(uAspect, 2.35) / 2.35;
float centerLift = 1.0 - smoothstep(0.12, 1.2, normalizedX);
float horizontal = mix(0.64, 1.0, centerLift);
gl_FragColor = vec4(uColor * 1.08, vertical * horizontal * uEnergy);
}
`,
transparent: true,
depthWrite: false,
depthTest: false,
blending: THREE.NormalBlending,
toneMapped: true,
});
}
function createSilhouetteGeometry(band: number) {
const segments = 18;
const positions = new Float32Array((segments + 1) * 2 * 3);
const indices: number[] = [];
const width = 1_900;
const centerZ = -455 - band * 54;
const baseY = -38;
for (let index = 0; index <= segments; index += 1) {
const progress = index / segments;
const x = (progress - 0.5) * width;
const lowWave = Math.sin(progress * Math.PI * (2.2 + band * 0.35) + band * 1.7);
const broadWave = Math.sin(progress * Math.PI * 5.2 + band * 2.4);
const topY = 4 + band * 2.6 + lowWave * (8 + band * 1.8) + broadWave * 3.2;
const offset = index * 6;
positions[offset] = x;
positions[offset + 1] = baseY;
positions[offset + 2] = centerZ;
positions[offset + 3] = x;
positions[offset + 4] = topY;
positions[offset + 5] = centerZ;
if (index < segments) {
const base = index * 2;
indices.push(base, base + 1, base + 2, base + 1, base + 3, base + 2);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
return geometry;
}
function createRoadsideAnchors(
resources: SceneResourceBag,
quality: RenderQualitySettings,
) {
const count = Math.max(12, Math.min(32, Math.round(quality.terrainSegments / 4)));
const geometry = resources.own('geometry', new THREE.BoxGeometry(0.15, 0.72, 0.15));
const uniforms = {
uDistance: { value: 0 },
uEnergy: { value: 0.5 },
uColor: { value: new THREE.Color(CHAPTER_PALETTES['deep-night'].terrainHigh) },
};
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms,
vertexShader: `
uniform float uDistance;
varying float vDepthFade;
void main() {
vec4 anchor = instanceMatrix * vec4(position, 1.0);
anchor.z = mod(anchor.z + uDistance + 410.0, 438.0) - 410.0;
vec4 viewPosition = modelViewMatrix * anchor;
vDepthFade = (1.0 - smoothstep(250.0, 390.0, -viewPosition.z))
* smoothstep(2.0, 20.0, -viewPosition.z);
gl_Position = projectionMatrix * viewPosition;
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uEnergy;
varying float vDepthFade;
void main() {
gl_FragColor = vec4(uColor * (0.7 + uEnergy * 0.55), vDepthFade * uEnergy);
}
`,
transparent: true,
@@ -259,7 +405,21 @@ function createHazeMaterial(color: number, energy: number) {
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
});
}));
const mesh = new THREE.InstancedMesh(geometry, material, count);
mesh.name = 'roadside-anchors';
mesh.frustumCulled = false;
const matrix = new THREE.Matrix4();
for (let index = 0; index < count; index += 1) {
const side = index % 2 === 0 ? -1 : 1;
const lane = Math.floor(index / 2);
const z = -8 - (lane / Math.max(1, Math.ceil(count / 2) - 1)) * 420;
const x = side * (9.6 + seeded(index, 181) * 1.1);
matrix.makeTranslation(x, 0.38, z);
mesh.setMatrixAt(index, matrix);
}
mesh.instanceMatrix.needsUpdate = true;
return { mesh, uniforms };
}
function seeded(index: number, salt: number): number {
@@ -274,8 +434,3 @@ function finiteOrZero(value: number): number {
function clamp01(value: number): number {
return THREE.MathUtils.clamp(finiteOrZero(value), 0, 1);
}
function smoothstep(edge0: number, edge1: number, value: number): number {
const progress = THREE.MathUtils.clamp((value - edge0) / (edge1 - edge0), 0, 1);
return progress * progress * (3 - 2 * progress);
}

View File

@@ -1,4 +1,6 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import grandTourerUrl from '@/assets/nightdrive-grand-tourer.glb?url';
import type { SceneResourceBag } from '@/scene/resources';
import type { SceneDetailMode } from '@/scene/types';
@@ -9,6 +11,7 @@ export interface CarWorld {
}
interface CarGroup {
name: string;
children: unknown[];
position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
rotation: { x: number; y: number; z: number };
@@ -19,15 +22,54 @@ interface CarGroup {
updateMatrixWorld(force: boolean): void;
}
interface AuthoredMaterial {
dispose(): void;
isMeshStandardMaterial?: boolean;
emissive?: { setHex(value: number): void };
emissiveIntensity: number;
[key: string]: unknown;
}
interface AuthoredObject {
name: string;
isMesh?: boolean;
geometry?: { dispose(): void };
material?: AuthoredMaterial | AuthoredMaterial[];
}
interface AuthoredCarGroup extends CarGroup {
removeFromParent(): void;
traverse(callback: (object: AuthoredObject) => void): void;
}
const FULL_BASE_Y = 0.58;
const REDUCED_BASE_Y = 0.42;
const GRAND_TOURER_URL = grandTourerUrl;
export function createCar(resources: SceneResourceBag): CarWorld {
export async function loadGrandTourer(signal: AbortSignal): Promise<AuthoredCarGroup> {
const response = await fetch(GRAND_TOURER_URL, { signal });
if (!response.ok) {
throw new Error(`Grand tourer asset failed to load (${response.status}).`);
}
const bytes = await response.arrayBuffer();
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
const gltf = await new GLTFLoader().parseAsync(bytes, GRAND_TOURER_URL);
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
const authored = gltf.scene.getObjectByName('chase-car') ?? gltf.scene;
authored.removeFromParent();
return authored as AuthoredCarGroup;
}
export function createCar(
resources: SceneResourceBag,
authoredModel?: AuthoredCarGroup,
): CarWorld {
if (authoredModel) return createAuthoredCar(resources, authoredModel);
const group = new THREE.Group();
group.name = 'chase-car';
let baseY = FULL_BASE_Y;
group.position.set(0, baseY, 0.72);
group.scale.set(0.9, 0.72, 0.82);
group.scale.set(1.1, 0.82, 0.98);
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x304e68,
@@ -222,8 +264,11 @@ export function createCar(resources: SceneResourceBag): CarWorld {
const underglow = new THREE.Mesh(underglowGeometry, underglowMaterial);
underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2;
underglow.position.set(0, -0.5, 0.45);
underglow.position.set(0, -0.67, 0.45);
group.add(underglow);
const contact = createContactShadow(resources, 4.5, 4.8, 0.22);
contact.mesh.position.set(0, -0.695, 0.52);
group.add(contact.mesh);
const secondary = new THREE.Group();
secondary.name = 'secondary-detail';
@@ -258,11 +303,11 @@ export function createCar(resources: SceneResourceBag): CarWorld {
secondary.visible = detail === 'full';
if (detail === 'reduced') {
baseY = REDUCED_BASE_Y;
group.scale.set(0.7, 0.56, 0.66);
group.scale.set(0.86, 0.66, 0.76);
group.position.z = 0.62;
} else {
baseY = FULL_BASE_Y;
group.scale.set(0.9, 0.72, 0.82);
group.scale.set(1.1, 0.82, 0.98);
group.position.z = 0.72;
}
group.position.y = baseY - squat;
@@ -292,10 +337,201 @@ export function createCar(resources: SceneResourceBag): CarWorld {
);
tailMaterial.emissiveIntensity = 1.25 + response * 1.35;
underglowUniforms.uOpacity.value = 0.1 + safeSpeed * 0.055 + response * 0.07;
contact.uniforms.uOpacity.value = 0.2 + response * 0.06;
},
};
}
function createAuthoredCar(
resources: SceneResourceBag,
authoredModel: AuthoredCarGroup,
): CarWorld {
const group = new THREE.Group();
group.name = 'chase-car';
authoredModel.name = 'grand-tourer-model';
authoredModel.rotation.y = Math.PI;
group.add(authoredModel);
const tailMaterials: AuthoredMaterial[] = [];
const cyanMaterials: AuthoredMaterial[] = [];
authoredModel.traverse((object) => {
if (object.name.endsWith('.001')) object.name = object.name.slice(0, -4);
if (!object.isMesh || !object.geometry || !object.material) return;
resources.own('geometry', object.geometry);
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
resources.own('material', material);
ownMaterialTextures(resources, material);
if (
material.isMeshStandardMaterial
&& (object.name === 'car-body' || object.name === 'body-haunches')
) {
material.emissive?.setHex(0x0b2738);
material.emissiveIntensity = 0.58;
}
if (material.isMeshStandardMaterial && object.name === 'rear-glass') {
material.emissive?.setHex(0x041725);
material.emissiveIntensity = 0.31;
}
if (object.name === 'rear-light-bar' && material.isMeshStandardMaterial) {
tailMaterials.push(material);
}
if (object.name === 'car-underglow-source' && material.isMeshStandardMaterial) {
cyanMaterials.push(material);
}
}
});
const underglowUniforms = {
uColor: { value: new THREE.Color(0x47ddec) },
uOpacity: { value: 0.12 },
};
const underglow = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(1.72, 3.82)),
resources.own('material', new THREE.ShaderMaterial({
uniforms: underglowUniforms,
vertexShader: `
varying vec2 vGlowUv;
void main() {
vGlowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vGlowUv;
void main() {
vec2 centered = (vGlowUv - 0.5) * vec2(1.7, 1.0);
float glow = 1.0 - smoothstep(0.08, 0.74, length(centered));
gl_FragColor = vec4(uColor * 1.35, glow * uOpacity);
}
`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
})),
);
underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2;
underglow.position.set(0, 0.012, 0.08);
group.add(underglow);
const contact = createContactShadow(resources, 1.98, 3.95, 0.24);
contact.mesh.position.set(0, 0.004, 0.14);
group.add(contact.mesh);
let detail: SceneDetailMode = 'full';
let baseY = 0.02;
let squat = 0;
let squatVelocity = 0;
let previousElapsed: number | null = null;
const setScale = (nextDetail: SceneDetailMode): void => {
if (nextDetail === 'reduced') {
baseY = -0.02;
group.scale.set(2.32, 2.32, 2.32);
group.position.z = 0.62;
} else {
baseY = 0.02;
group.scale.set(2.78, 2.78, 2.78);
group.position.z = 0.72;
}
group.position.y = baseY - squat;
};
setScale(detail);
return {
group,
setDetail(nextDetail) {
if (detail === nextDetail) return;
detail = nextDetail;
setScale(detail);
},
update(speed, acceleration, elapsedSeconds) {
const safeSpeed = clamp01(speed);
const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;
const deltaSeconds = previousElapsed === null
? 1 / 60
: THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
previousElapsed = elapsed;
const targetSquat = response * 0.04;
const springAcceleration = (targetSquat - squat) * 34 - squatVelocity * 10;
squatVelocity += springAcceleration * deltaSeconds;
squat += squatVelocity * deltaSeconds;
const vibration = Math.sin(elapsed * (7.4 + safeSpeed * 2.2)) * (0.0014 + safeSpeed * 0.0022);
group.position.y = baseY - squat + vibration;
group.rotation.x = -squat * 0.035;
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
for (const material of tailMaterials) material.emissiveIntensity = 4.4 + response * 3.2;
for (const material of cyanMaterials) material.emissiveIntensity = 2.2 + response * 1.6;
underglowUniforms.uOpacity.value = 0.09 + safeSpeed * 0.04 + response * 0.075;
contact.uniforms.uOpacity.value = 0.21 + response * 0.055;
},
};
}
function createContactShadow(
resources: SceneResourceBag,
width: number,
depth: number,
opacity: number,
) {
const uniforms = {
uOpacity: { value: opacity },
};
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms,
vertexShader: `
varying vec2 vShadowUv;
void main() {
vShadowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uOpacity;
varying vec2 vShadowUv;
void main() {
vec2 centered = (vShadowUv - 0.5) * vec2(1.65, 1.0);
float shadow = 1.0 - smoothstep(0.16, 0.72, length(centered));
gl_FragColor = vec4(vec3(0.002, 0.004, 0.008), shadow * uOpacity);
}
`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.NormalBlending,
toneMapped: true,
}));
const mesh = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(width, depth)),
material,
);
mesh.name = 'car-contact-shadow';
mesh.rotation.x = -Math.PI / 2;
mesh.renderOrder = 2;
return { mesh, uniforms };
}
function ownMaterialTextures(
resources: SceneResourceBag,
material: AuthoredMaterial,
): void {
for (const key of [
'map',
'emissiveMap',
'normalMap',
'roughnessMap',
'metalnessMap',
'aoMap',
]) {
const texture = material[key] as { isTexture?: boolean; dispose(): void } | null | undefined;
if (texture?.isTexture) resources.own('texture', texture);
}
}
function createRearProfile() {
const shape = new THREE.Shape();
shape.moveTo(-2.7, -0.42);

View File

@@ -6,10 +6,11 @@ import { renderQualityFor } from '@/scene/render-quality';
import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun';
import { createCar } from '@/scene/world/car';
import { createCar, loadGrandTourer } from '@/scene/world/car';
import { createSign } from '@/scene/world/sign';
import { createAtmosphere } from '@/scene/world/atmosphere';
import { createPostProcessor } from '@/scene/post-processing';
import { createDriveDynamics } from '@/scene/drive-dynamics';
import {
createMutableChapterPalette,
writeChapterPalette,
@@ -21,9 +22,9 @@ declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export function createChaseCamera(aspect = 1) {
const camera = new THREE.PerspectiveCamera(70, aspect, 0.1, 320);
camera.position.set(0, 4.8, 12.5);
camera.lookAt(0, 1.6, -22);
const camera = new THREE.PerspectiveCamera(66, aspect, 0.1, 900);
camera.position.set(0, 8.6, 22);
camera.lookAt(0, 4.8, -128);
return camera;
}
@@ -59,11 +60,12 @@ export async function createProceduralSceneGeneration(
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
scene.name = 'world-root';
const currentPalette = createMutableChapterPalette('deep-night');
const targetPalette = createMutableChapterPalette('deep-night');
const background = currentPalette.sky.clone();
scene.background = background;
scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.012);
scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.0024);
const camera = createChaseCamera();
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
@@ -71,11 +73,20 @@ export async function createProceduralSceneGeneration(
const rearLight = new THREE.PointLight(0xff365f, 5, 18);
rearLight.position.set(0, 1.2, 7);
scene.add(rearLight);
const carKeyLight = new THREE.PointLight(0x72dce8, 18, 34, 2);
carKeyLight.name = 'car-key-light';
carKeyLight.position.set(-4.8, 5.4, 9.5);
scene.add(carKeyLight);
const carFillLight = new THREE.PointLight(0xb83f93, 11, 28, 2);
carFillLight.name = 'car-fill-light';
carFillLight.position.set(4.2, 3.4, 8);
scene.add(carFillLight);
const road = createRoad(context.resources, quality);
const terrain = createTerrain(context.resources, quality);
const sun = createSun(context.resources);
const car = createCar(context.resources);
const authoredCar = await loadGrandTourer(context.signal);
const car = createCar(context.resources, authoredCar);
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
const sign = createSign(context.resources);
const atmosphere = createAtmosphere(context.resources, quality);
@@ -91,6 +102,17 @@ export async function createProceduralSceneGeneration(
const postProcessor = quality.postEffects
? createPostProcessor(context.resources, renderer)
: null;
const dynamics = createDriveDynamics();
const cameraState = {
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
targetX: 0,
targetY: 4.8,
targetZ: -128,
fov: camera.fov,
roll: 0,
};
let width = 0;
let height = 0;
let driveDistance = 0;
@@ -108,9 +130,21 @@ export async function createProceduralSceneGeneration(
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
if (lastNow === null) {
const wideComposition = camera.aspect > 1.18;
cameraState.x = wideComposition ? -1.35 : 0;
cameraState.targetX = wideComposition ? 1.6 : 0;
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(
cameraState.targetX,
cameraState.targetY,
cameraState.targetZ,
);
}
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
car.group.visible = height >= 340;
sign.setViewportAspect(camera.aspect);
atmosphere.setViewportAspect(camera.aspect);
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
if (TEST_BUILD && context.quality !== 'medium') {
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
@@ -152,35 +186,105 @@ export async function createProceduralSceneGeneration(
resize();
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
lastNow = now;
dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds);
const response = dynamics.snapshot();
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
dampPalette(currentPalette, targetPalette, deltaSeconds);
driveDistance += snapshot.driveSpeed * deltaSeconds * 18;
road.update(driveDistance, snapshot.driveSpeed, snapshot.acceleration);
driveDistance += deltaSeconds * (
18
+ snapshot.driveSpeed * 54
+ response.roadEnergy * 48
);
const visualSpeed = THREE.MathUtils.clamp(
0.18 + response.roadEnergy * 0.82,
0.18,
1,
);
road.update(driveDistance, visualSpeed, response.roadEnergy);
terrain.update(
driveDistance,
snapshot.driveSpeed,
visualSpeed,
snapshot.chapter,
snapshot.routeProgress,
currentPalette,
);
sun.update(currentPalette, snapshot.routeProgress);
car.update(snapshot.driveSpeed, snapshot.acceleration, now / 1000);
const elapsedSeconds = now / 1000;
sun.update(currentPalette, snapshot.routeProgress, elapsedSeconds);
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
sign.update(snapshot.sign);
atmosphere.update(
driveDistance,
snapshot.driveSpeed,
snapshot.acceleration,
visualSpeed,
response.streakEnergy,
currentPalette,
);
background.copy(currentPalette.sky);
scene.fog.color.copy(currentPalette.fog);
scene.fog.density = 0.0105 + (1 - currentPalette.warmth) * 0.0025;
const fov = 70 + Math.min(5, snapshot.acceleration * 4.2);
if (camera.fov !== fov) {
camera.fov = fov;
scene.fog.density = 0.0019 + (1 - currentPalette.warmth) * 0.0008;
ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26);
ambient.groundColor.copy(currentPalette.fog);
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
carKeyLight.color.copy(currentPalette.terrainHigh).lerp(currentPalette.haze, 0.22);
carKeyLight.intensity = 21 + response.bloomEnergy * 3.6;
carFillLight.color.copy(currentPalette.haze).lerp(currentPalette.sun, 0.18);
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
const wideComposition = camera.aspect > 1.18;
const cameraX = wideComposition ? -1.35 : 0;
const targetX = wideComposition ? 1.6 : 0;
const roadVibration = Math.sin(driveDistance * 0.105) * (
0.012 + response.roadEnergy * 0.024
);
const cameraMix = 1 - Math.exp(-deltaSeconds / 0.2);
cameraState.x = THREE.MathUtils.lerp(
cameraState.x,
cameraX - response.directionBias * 0.16,
cameraMix,
);
cameraState.y = THREE.MathUtils.lerp(
cameraState.y,
8.6 + roadVibration,
cameraMix,
);
cameraState.z = THREE.MathUtils.lerp(
cameraState.z,
22 - response.cameraEnergy * 1.65,
cameraMix,
);
cameraState.targetX = THREE.MathUtils.lerp(
cameraState.targetX,
targetX + response.directionBias * 0.32,
cameraMix,
);
cameraState.targetY = THREE.MathUtils.lerp(
cameraState.targetY,
4.8 + response.cameraEnergy * 0.58,
cameraMix,
);
cameraState.targetZ = THREE.MathUtils.lerp(
cameraState.targetZ,
-128 - response.cameraEnergy * 24,
cameraMix,
);
cameraState.fov = THREE.MathUtils.lerp(
cameraState.fov,
66 + response.cameraEnergy * 13,
cameraMix,
);
cameraState.roll = THREE.MathUtils.lerp(
cameraState.roll,
-response.directionBias * 0.003 + roadVibration * 0.018,
cameraMix,
);
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(cameraState.targetX, cameraState.targetY, cameraState.targetZ);
camera.rotation.z += cameraState.roll;
if (Math.abs(camera.fov - cameraState.fov) > 0.001) {
camera.fov = cameraState.fov;
camera.updateProjectionMatrix();
}
if (postProcessor) {
postProcessor.setDynamics(response.bloomEnergy);
postProcessor.render(scene, camera);
} else {
renderer.render(scene, camera);

View File

@@ -12,9 +12,10 @@ interface WorldGroup {
getObjectByName(name: string): unknown;
}
const ROAD_WIDTH = 22;
const ROAD_DEPTH = 300;
const ROAD_CENTER_Z = -136;
const ROAD_WIDTH = 20.4;
const ROAD_DEPTH = 438;
const ROAD_CENTER_Z = -191;
const FLOW_PERIOD = 5_694;
export function createRoad(
resources: SceneResourceBag,
@@ -43,12 +44,19 @@ export function createRoad(
uniforms,
vertexShader: `
varying vec2 vRoadUv;
varying float vRoadX;
varying float vDepth;
varying float vDistanceFade;
void main() {
vRoadUv = uv;
vRoadX = position.x;
vDepth = smoothstep(0.0, 1.0, uv.y);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
float viewDistance = -viewPosition.z;
vDistanceFade = smoothstep(4.0, 16.0, viewDistance)
* (1.0 - smoothstep(315.0, 430.0, viewDistance));
gl_Position = projectionMatrix * viewPosition;
}
`,
fragmentShader: `
@@ -60,25 +68,38 @@ export function createRoad(
uniform vec3 uEdgeColor;
uniform vec3 uLaneColor;
varying vec2 vRoadUv;
varying float vRoadX;
varying float vDepth;
varying float vDistanceFade;
void main() {
float roadX = abs(vRoadUv.x - 0.5) * 2.0;
float centerValley = 1.0 - smoothstep(0.0, 0.82, roadX);
float outerEdge = smoothstep(0.82, 0.9, roadX) * (1.0 - smoothstep(0.94, 1.0, roadX));
float shoulder = smoothstep(0.66, 0.92, roadX) * 0.12;
float laneWidth = 1.0 - smoothstep(0.012, 0.03, abs(vRoadUv.x - 0.5));
float laneCycle = fract(vRoadUv.y * 34.0 + uDistance * (0.12 + uSpeed * 0.025));
float laneDash = smoothstep(0.16, 0.24, laneCycle) * (1.0 - smoothstep(0.68, 0.78, laneCycle));
float centerLane = laneWidth * laneDash * smoothstep(0.04, 0.22, vRoadUv.y);
float edgeLuminance = outerEdge * (0.58 + uAcceleration * 0.34);
float grain = sin((vRoadUv.y * 410.0 + vRoadUv.x * 37.0) + uDistance * 0.08) * 0.008;
float roadX = abs(vRoadX);
float coreX = roadX / 8.2;
float centerValley = 1.0 - smoothstep(0.0, 0.86, coreX);
float outerEdge = smoothstep(7.76, 7.93, roadX)
* (1.0 - smoothstep(8.06, 8.19, roadX));
float shoulder = smoothstep(7.2, 7.82, roadX)
* (1.0 - smoothstep(8.18, 10.0, roadX));
float shoulderFalloff = 1.0 - smoothstep(8.1, 10.2, roadX);
float laneWidth = 1.0 - smoothstep(0.075, 0.18, roadX);
float laneCycle = fract(vRoadUv.y * 33.7 + mod(uDistance, ${FLOW_PERIOD.toFixed(1)}) * 0.078);
float laneDash = smoothstep(0.12, 0.2, laneCycle)
* (1.0 - smoothstep(0.62, 0.72, laneCycle));
float centerLane = laneWidth * laneDash * smoothstep(0.035, 0.18, vRoadUv.y);
float edgeLuminance = outerEdge * (1.2 + uAcceleration * 0.68);
float coarseAsphalt = sin(vRoadUv.y * 173.0 + roadX * 4.7 + uDistance * 0.08)
* sin(vRoadUv.y * 47.0 - roadX * 8.1) * 0.006;
float fineGrain = sin((vRoadUv.y * 530.0 + roadX * 5.0) + uDistance * 0.1) * 0.003;
float horizonReflection = smoothstep(0.42, 0.94, vRoadUv.y)
* exp(-roadX * 0.34) * (0.028 + uAcceleration * 0.018);
vec3 color = mix(uAsphaltLift, uAsphalt, centerValley * 0.84);
color += uEdgeColor * (edgeLuminance + shoulder);
color += uLaneColor * centerLane * (0.66 + uSpeed * 0.18);
color += grain;
color *= mix(0.7, 1.0, smoothstep(0.0, 0.24, vRoadUv.y));
color += uEdgeColor * (edgeLuminance * 1.35 + shoulder * 0.18);
color += uLaneColor * centerLane * (1.15 + uSpeed * 0.28);
color += uLaneColor * horizonReflection;
color += coarseAsphalt + fineGrain;
color = mix(uAsphalt * 0.68, color, shoulderFalloff);
color *= vDistanceFade * mix(0.58, 1.0, smoothstep(0.0, 0.2, vRoadUv.y));
gl_FragColor = vec4(color, 1.0);
}
`,
@@ -95,7 +116,7 @@ export function createRoad(
return {
group,
update(distance, speed, acceleration) {
uniforms.uDistance.value = finiteOrZero(distance);
uniforms.uDistance.value = ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
uniforms.uSpeed.value = clamp01(speed);
uniforms.uAcceleration.value = clamp01(acceleration);
},

View File

@@ -5,7 +5,7 @@ import { CHAPTER_PALETTES } from '@/scene/world/palette';
export interface SunWorld {
group: WorldGroup;
update(palette: Readonly<MutableChapterPalette>, routeProgress: number): void;
update(palette: Readonly<MutableChapterPalette>, routeProgress: number, elapsedSeconds: number): void;
}
interface WorldGroup {
@@ -20,11 +20,12 @@ export function createSun(resources: SceneResourceBag): SunWorld {
const sunColor = new THREE.Color(night.sun);
const uniforms = {
uColor: { value: sunColor },
uScanBands: { value: 14 },
uScanBands: { value: 11 },
uWarmth: { value: night.warmth },
uRouteProgress: { value: 0 },
uTime: { value: 0 },
};
const geometry = resources.own('geometry', new THREE.CircleGeometry(20, 96));
const geometry = resources.own('geometry', new THREE.CircleGeometry(44, 128));
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms,
vertexShader: `
@@ -40,17 +41,21 @@ export function createSun(resources: SceneResourceBag): SunWorld {
uniform float uScanBands;
uniform float uWarmth;
uniform float uRouteProgress;
uniform float uTime;
varying vec2 vSunUv;
void main() {
float radius = distance(vSunUv, vec2(0.5));
float softEdge = 1.0 - smoothstep(0.435, 0.5, radius);
float lowerDisc = 1.0 - smoothstep(0.42, 0.76, vSunUv.y);
float scanBand = smoothstep(0.16, 0.28, fract(vSunUv.y * uScanBands));
float bandCut = mix(1.0, 0.32 + scanBand * 0.68, lowerDisc * 0.82);
float horizonFade = smoothstep(0.02, 0.14, vSunUv.y);
float luminance = 0.72 + uWarmth * 0.18 + uRouteProgress * 0.06;
gl_FragColor = vec4(uColor * luminance, softEdge * bandCut * horizonFade * 0.9);
float softEdge = 1.0 - smoothstep(0.475, 0.5, radius);
float lowerDisc = 1.0 - smoothstep(0.46, 0.84, vSunUv.y);
float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.12) * 0.012);
float scanBand = smoothstep(0.12, 0.2, scanPhase)
* (1.0 - smoothstep(0.58, 0.72, scanPhase));
float bandCut = mix(1.0, 0.18 + scanBand * 0.82, lowerDisc * 0.9);
float horizonFade = smoothstep(0.01, 0.11, vSunUv.y);
float verticalHeat = mix(0.78, 1.3, smoothstep(0.1, 0.88, vSunUv.y));
float luminance = verticalHeat * (1.28 + uWarmth * 0.24 + uRouteProgress * 0.08);
gl_FragColor = vec4(uColor * luminance, softEdge * bandCut * horizonFade);
}
`,
transparent: true,
@@ -60,8 +65,8 @@ export function createSun(resources: SceneResourceBag): SunWorld {
toneMapped: true,
}));
const disc = new THREE.Mesh(geometry, material);
disc.name = 'sun-disc';
disc.position.set(0, 11.5, -142);
disc.name = 'horizon-sun';
disc.position.set(0, 28, -410);
group.add(disc);
const haloTexture = resources.own('texture', createHaloTexture());
@@ -76,22 +81,23 @@ export function createSun(resources: SceneResourceBag): SunWorld {
toneMapped: true,
}));
const halo = new THREE.Sprite(haloMaterial);
halo.name = 'sun-halo';
halo.name = 'horizon-halo';
halo.position.copy(disc.position);
halo.position.z -= 0.5;
halo.scale.set(57, 43, 1);
halo.scale.set(220, 138, 1);
group.add(halo);
return {
group,
update(palette, routeProgress) {
update(palette, routeProgress, elapsedSeconds) {
const progress = clamp01(routeProgress);
sunColor.copy(palette.sun);
haloMaterial.color.copy(palette.sun);
uniforms.uWarmth.value = clamp01(palette.warmth);
uniforms.uRouteProgress.value = progress;
haloMaterial.opacity = 0.18 + palette.warmth * 0.12;
disc.position.y = 11.5 + palette.warmth * 4.8;
uniforms.uTime.value = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
haloMaterial.opacity = 0.12 + palette.warmth * 0.08;
disc.position.y = 28 + palette.warmth * 7;
halo.position.y = disc.position.y;
},
};

View File

@@ -20,11 +20,12 @@ interface WorldGroup {
getObjectByName(name: string): unknown;
}
const TERRAIN_WIDTH = 160;
const TERRAIN_DEPTH = 300;
const NEAR_Z = 14;
const ROAD_CLEAR_HALF_WIDTH = 11.4;
const RIDGE_START = 24;
const TERRAIN_WIDTH = 360;
const TERRAIN_DEPTH = 438;
const NEAR_Z = 28;
const ROAD_CLEAR_HALF_WIDTH = 9.4;
const RIDGE_START = 46;
const NOISE_PERIOD = 5_694;
export function createTerrain(
resources: SceneResourceBag,
@@ -42,6 +43,8 @@ export function createTerrain(
uRouteProgress: { value: 0 },
uTerrainEnergy: { value: initialPalette.terrainEnergy },
uOctaves: { value: quality.terrainOctaves },
uRelief: { value: 34 },
uFar: { value: 430 },
uLowColor: { value: lowColor },
uHighColor: { value: highColor },
};
@@ -54,12 +57,15 @@ export function createTerrain(
uniform float uRouteProgress;
uniform float uTerrainEnergy;
uniform float uOctaves;
uniform float uRelief;
uniform float uFar;
varying float vHeight;
varying float vRoadMask;
varying float vDepthFade;
varying float vFlow;
float hash21(vec2 point) {
point = mod(point, 256.0);
point = fract(point * vec2(123.34, 456.21));
point += dot(point, point + 45.32);
return fract(point.x * point.y);
@@ -85,19 +91,22 @@ export function createTerrain(
void main() {
vec3 displaced = position;
float wrappedPhase = mod(uDistance * (0.18 + uSpeed * 0.06), 96.0);
float ridgeNoise = multiOctaveNoise(vec2(position.x * 0.038, (position.z - wrappedPhase) * 0.028));
float macroRidge = multiOctaveNoise(vec2(position.x * 0.014 + 31.0, position.z * 0.012));
float edgeGrowth = mix(0.6, 1.0, smoothstep(0.0, 72.0, abs(position.x)));
float height = roadMask * (0.25 + ridgeNoise * ridgeNoise * 8.8 + macroRidge * 2.4);
height *= edgeGrowth * uTerrainEnergy;
displaced.y += height - 0.22;
float wrappedPhase = mod(uDistance, ${NOISE_PERIOD.toFixed(1)});
const float S = 0.04495961;
vec2 world = vec2(position.x, position.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;
vHeight = clamp(height / 10.0, 0.0, 1.0);
vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0);
vRoadMask = roadMask;
vDepthFade = smoothstep(-286.0, -205.0, position.z) * (1.0 - smoothstep(7.0, 14.0, position.z));
vec4 viewPosition = modelViewMatrix * vec4(displaced, 1.0);
float viewDistance = -viewPosition.z;
vDepthFade = smoothstep(4.0, 18.0, viewDistance)
* (1.0 - smoothstep(uFar * 0.32, uFar * 0.88, viewDistance));
vFlow = fract((-position.z + uDistance * (0.9 + uSpeed * 0.25)) / 11.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
gl_Position = projectionMatrix * viewPosition;
}
`,
fragmentShader: `
@@ -112,10 +121,10 @@ export function createTerrain(
void main() {
float ridgeMix = smoothstep(0.16, 0.78, vHeight);
vec3 color = mix(uLowColor, uHighColor, ridgeMix);
float pulse = 0.78 + 0.22 * smoothstep(0.18, 0.52, vFlow);
float pulse = 0.72 + 0.28 * 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.34, 0.66, ridgeMix);
gl_FragColor = vec4(color, alpha);
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.32, 0.82, ridgeMix);
gl_FragColor = vec4(color * mix(0.78, 1.55, ridgeMix), alpha);
}
`,
transparent: true,
@@ -126,7 +135,7 @@ export function createTerrain(
}));
const lines = new THREE.LineSegments(geometry, material);
lines.name = 'terrain-lines';
lines.name = 'terrain-grid';
lines.frustumCulled = false;
group.add(lines);
@@ -134,7 +143,7 @@ export function createTerrain(
group,
update(distance, speed, chapter, routeProgress, palette) {
const chapterPalette = CHAPTER_PALETTES[chapter];
uniforms.uDistance.value = finiteOrZero(distance);
uniforms.uDistance.value = ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
uniforms.uSpeed.value = clamp01(speed);
uniforms.uRouteProgress.value = clamp01(routeProgress);
uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
@@ -151,7 +160,8 @@ export function createTerrain(
function createIndexedTerrainGeometry(segments: number) {
const zSegments = Math.max(16, Math.floor(segments));
const xSegments = Math.max(24, Math.floor(zSegments * 0.75 / 2) * 2);
const approximateCell = TERRAIN_DEPTH / zSegments;
const xSegments = Math.max(48, Math.floor((TERRAIN_WIDTH / approximateCell) / 2) * 2);
const columnCount = xSegments + 1;
const rowCount = zSegments + 1;
const positions = new Float32Array(columnCount * rowCount * 3);

View File

@@ -6,7 +6,7 @@
}
.hero-journey h1 {
font-size: var(--step-4);
font-size: clamp(3.45rem, 2.3rem + 4.4vw, 6.35rem);
letter-spacing: -0.055em;
}
@@ -181,6 +181,18 @@
max-inline-size: 78ch;
}
.infrastructure-case > h3,
.infrastructure-case > p,
.portfolio-section[data-layout="full-diagram"] > h2 {
inline-size: fit-content;
padding: 0.42rem 0.62rem;
background: rgb(5 12 23 / 90%);
}
.portfolio-section[data-layout="full-diagram"] figure {
background: rgb(10 24 38 / 72%);
}
.infrastructure-flow {
min-width: 0;
padding: clamp(3.7rem, 6vw, 5.2rem) clamp(1rem, 2.5vw, 2rem) 2rem;

View File

@@ -85,14 +85,20 @@
.hero-journey__content {
display: grid;
inline-size: min(39rem, 46vw);
gap: 1.1rem;
padding: clamp(1.5rem, 2.5vw, 2.5rem);
inline-size: min(34rem, 39vw);
gap: 1rem;
padding: clamp(1.4rem, 2.2vw, 2.1rem);
border: 1px solid var(--line);
border-inline-start: 3px solid var(--cyan);
border-radius: var(--radius-surface);
background: var(--surface);
box-shadow: var(--panel-shadow);
border-inline-end: 0;
border-radius: 3px 0 0 3px;
background: linear-gradient(
90deg,
rgb(5 12 23 / 96%) 0 72%,
rgb(5 12 23 / 74%) 88%,
rgb(5 12 23 / 0%) 100%
);
box-shadow: -1.4rem 2rem 4rem rgb(0 5 15 / 24%);
}
.portfolio-section {
@@ -120,8 +126,12 @@
}
.portfolio-section[data-layout="full-diagram"] {
inline-size: 78%;
margin-inline: 11%;
inline-size: 68%;
margin-inline: 16%;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.portfolio-section[data-layout="project-stage"] {
@@ -147,13 +157,13 @@
}
.portfolio-section[data-layout="toolkit-field"] {
inline-size: 78%;
margin-inline: 11%;
inline-size: 68%;
margin-inline: 16%;
}
.portfolio-section[data-layout="destination"] {
inline-size: min(54rem, 62%);
margin-inline: auto;
inline-size: min(42rem, 46%);
margin-inline: 4% auto;
}
.portfolio-section footer {
@@ -166,7 +176,7 @@
}
.hero-journey__content {
inline-size: min(34rem, 62vw);
inline-size: min(31rem, 58vw);
}
.portfolio-section[data-layout="right-rail"] {
@@ -269,8 +279,13 @@
.hero-journey__content {
inline-size: 100%;
gap: 0.9rem;
padding: 1.4rem;
background: var(--surface-strong);
padding: 1.25rem 1.15rem 1.35rem;
background: linear-gradient(
90deg,
rgb(5 12 23 / 97%) 0 82%,
rgb(5 12 23 / 58%) 94%,
rgb(5 12 23 / 0%) 100%
);
}
.portfolio-section,

View File

@@ -28,9 +28,9 @@
:root {
color-scheme: dark;
--canvas: #040814;
--surface: rgb(7 14 25 / 95%);
--surface: rgb(7 14 25 / 88%);
--surface-strong: rgb(7 14 25 / 97%);
--surface-soft: rgb(15 28 43 / 92%);
--surface-soft: rgb(15 28 43 / 86%);
--surface-raised: var(--surface-soft);
--line: rgb(126 200 214 / 22%);
--line-strong: rgb(126 220 232 / 44%);

View File

@@ -28,8 +28,8 @@
.journey-poster {
object-fit: cover;
background:
radial-gradient(ellipse at 50% 48%, rgb(135 28 105 / 58%) 0, transparent 44%),
linear-gradient(180deg, #02040b 0 30%, #08071a 49%, #1b0b27 67%, #02060b 100%);
radial-gradient(ellipse at 50% 47%, rgb(242 31 139 / 34%) 0 7%, rgb(110 21 113 / 25%) 20%, transparent 48%),
linear-gradient(180deg, #01030a 0 25%, #070817 43%, #1b0b27 59%, #02060b 100%);
background-size: cover;
}
@@ -55,8 +55,8 @@
}
.journey-poster__sun {
inset: 33% auto auto 50%;
inline-size: clamp(7rem, 14vw, 15rem);
inset: 34% auto auto 50%;
inline-size: clamp(8rem, 13.5vw, 14.5rem);
aspect-ratio: 1;
translate: -50% 0;
border-radius: 50%;
@@ -68,24 +68,29 @@
transparent 11.5% 15%
),
linear-gradient(180deg, #ff4d9b, #d52378 58%, #8e155c);
box-shadow: 0 0 3rem rgb(255 44 151 / 38%);
box-shadow:
0 0 2.4rem rgb(255 44 151 / 42%),
0 1.2rem 8rem 2.4rem rgb(126 29 143 / 28%);
}
.journey-poster__terrain {
inset: 48% -8% -10%;
inset: 45% -12% -8%;
background:
repeating-linear-gradient(0deg, transparent 0 9%, rgb(76 201 220 / 16%) 9.2% 9.5%, transparent 9.7% 13%),
repeating-linear-gradient(78deg, transparent 0 7.5%, rgb(76 201 220 / 14%) 7.7% 7.9%, transparent 8.1% 12%),
linear-gradient(180deg, rgb(79 18 76 / 70%), rgb(2 8 15 / 92%));
clip-path: polygon(0 17%, 13% 4%, 27% 15%, 39% 2%, 50% 14%, 64% 4%, 78% 16%, 90% 5%, 100% 18%, 100% 100%, 0 100%);
repeating-linear-gradient(0deg, transparent 0 7.6%, rgb(84 221 233 / 19%) 7.8% 8.15%, transparent 8.35% 12%),
repeating-linear-gradient(76deg, transparent 0 6.8%, rgb(220 50 168 / 17%) 7% 7.25%, transparent 7.5% 11%),
repeating-linear-gradient(104deg, transparent 0 8.4%, rgb(73 207 228 / 14%) 8.6% 8.85%, transparent 9.1% 13%),
linear-gradient(180deg, rgb(104 21 101 / 34%), rgb(3 10 17 / 86%));
clip-path: polygon(0 17%, 6% 8%, 12% 20%, 18% 13%, 24% 25%, 30% 17%, 36% 29%, 42% 23%, 47% 34%, 50% 41%, 53% 34%, 58% 23%, 64% 29%, 70% 17%, 76% 25%, 82% 13%, 88% 20%, 94% 8%, 100% 17%, 100% 100%, 0 100%);
filter: drop-shadow(0 -0.35rem 1.5rem rgb(223 40 169 / 22%));
}
.journey-poster__road {
inset: 48% -14% -12%;
inset: 46% -8% -10%;
background:
repeating-linear-gradient(180deg, transparent 0 7%, rgb(229 158 65 / 78%) 7.4% 9%, transparent 9.4% 15%) 50% 0 / clamp(0.22rem, 0.55vw, 0.7rem) 100% no-repeat,
linear-gradient(180deg, #050811, #000207);
clip-path: polygon(49.3% 0, 50.7% 0, 84% 100%, 16% 100%);
repeating-linear-gradient(180deg, transparent 0 7%, rgb(244 177 80 / 84%) 7.35% 8.35%, transparent 8.7% 15%) 50% 0 / clamp(0.15rem, 0.32vw, 0.34rem) 100% no-repeat,
linear-gradient(90deg, rgb(46 188 212 / 15%), transparent 5% 95%, rgb(46 188 212 / 15%)),
linear-gradient(180deg, #050813, #000207);
clip-path: polygon(49.55% 0, 50.45% 0, 76% 100%, 24% 100%);
}
.journey-poster__rail {
@@ -95,37 +100,45 @@
}
.journey-poster__rail--left {
clip-path: polygon(49.15% 48%, 49.35% 48%, 1.2% 100%, 0 100%);
clip-path: polygon(49.38% 46%, 49.5% 46%, 24.3% 100%, 23.55% 100%);
}
.journey-poster__rail--right {
clip-path: polygon(50.65% 48%, 50.85% 48%, 100% 100%, 98.8% 100%);
clip-path: polygon(50.5% 46%, 50.62% 46%, 76.45% 100%, 75.7% 100%);
}
.journey-poster__car {
inset: auto auto 1.2% 50%;
inline-size: clamp(7.5rem, 18vw, 19rem);
block-size: clamp(3.2rem, 7vw, 7.4rem);
inset: auto auto 1.6% 50%;
inline-size: clamp(9.5rem, 20vw, 21rem);
block-size: clamp(3.6rem, 7.4vw, 7.8rem);
translate: -50% 0;
background: #051526;
clip-path: polygon(22% 0, 78% 0, 100% 70%, 94% 100%, 6% 100%, 0 70%);
filter: drop-shadow(0 0.8rem 1.2rem rgb(0 0 0 / 76%));
border-radius: 40% 40% 8% 8% / 34% 34% 12% 12%;
background:
linear-gradient(106deg, transparent 0 24%, rgb(13 41 56 / 98%) 24.5% 42%, rgb(3 13 22 / 98%) 42.5% 57.5%, rgb(13 41 56 / 98%) 58% 75.5%, transparent 76%) 50% 6% / 78% 48% no-repeat,
linear-gradient(180deg, #173444 0 26%, #0a2231 46%, #06131f 68%, #020711 100%);
clip-path: polygon(29% 0, 71% 0, 82% 25%, 95% 43%, 100% 76%, 94% 100%, 6% 100%, 0 76%, 5% 43%, 18% 25%);
filter:
drop-shadow(0 0.8rem 1.2rem rgb(0 0 0 / 82%))
drop-shadow(0 0.2rem 1.1rem rgb(46 181 207 / 12%));
}
.journey-poster__car::before {
position: absolute;
inset: 68% 10% auto;
block-size: clamp(0.24rem, 0.48vw, 0.5rem);
background: #ff315f;
box-shadow: 0 0 0.8rem rgb(255 49 95 / 48%);
inset: 61% 8% auto;
block-size: clamp(0.24rem, 0.38vw, 0.42rem);
border-radius: 50%;
background: linear-gradient(90deg, #ff244f, #ff7592 48% 52%, #ff244f);
box-shadow: 0 0 1rem rgb(255 49 95 / 64%);
content: '';
}
.journey-poster__car::after {
position: absolute;
inset: 82% 14% auto;
block-size: 0.22rem;
border-inline: clamp(0.7rem, 1.5vw, 1.5rem) solid #67d4e4;
inset: 88% 20% auto;
block-size: 0.16rem;
border-radius: 50%;
background: #67d4e4;
box-shadow: 0 0 1.1rem 0.36rem rgb(61 208 229 / 48%);
content: '';
}