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:
BIN
src/assets/nightdrive-grand-tourer.glb
Normal file
BIN
src/assets/nightdrive-grand-tourer.glb
Normal file
Binary file not shown.
@@ -43,6 +43,7 @@ export function useScrollDrive(): ScrollDriveBinding {
|
||||
scrollY: scrollingElement.scrollTop,
|
||||
now,
|
||||
maxScrollY: Math.max(0, scrollingElement.scrollHeight - viewportHeight),
|
||||
viewportHeight,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
107
src/scene/drive-dynamics.ts
Normal 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));
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
2
src/scene/three.d.ts
vendored
2
src/scene/three.d.ts
vendored
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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: '';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user