feat: sculpt portfolio into an immersive midnight expressway
Author physical road furniture, layered terrain and atmosphere, projected illuminated route signs, and detailed Blender coachwork. Refine camera mass, wheel contact, chapter composition, and compact reading space while preserving approved content and audio contracts. Include isolated Blender source, before/after and hardware Chrome motion evidence, preservation hashes, validated preview checks, and documented inherited unit failures.
This commit is contained in:
BIN
src/assets/hero-expressway-mobile.jpg
Normal file
BIN
src/assets/hero-expressway-mobile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/hero-expressway.jpg
Normal file
BIN
src/assets/hero-expressway.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
src/assets/nightdrive-grand-tourer-expressway.glb
Normal file
BIN
src/assets/nightdrive-grand-tourer-expressway.glb
Normal file
Binary file not shown.
@@ -4,8 +4,8 @@ import { useSceneController, type SceneHostState } from '@/composables/useSceneC
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
import posterUrl from '@/assets/hero-evolution.jpg';
|
||||
import mobilePosterUrl from '@/assets/hero-evolution-mobile.jpg';
|
||||
import posterUrl from '@/assets/hero-expressway.jpg';
|
||||
import mobilePosterUrl from '@/assets/hero-expressway-mobile.jpg';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
@@ -57,7 +57,7 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
|
||||
>
|
||||
<picture v-if="!posterFailed">
|
||||
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">
|
||||
<img :src="posterUrl" class="journey-poster" alt="" width="1600" height="900" @error="posterFailed = true">
|
||||
<img :src="posterUrl" class="journey-poster" alt="" width="1440" height="900" @error="posterFailed = true">
|
||||
</picture>
|
||||
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as THREE from 'three';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette, MutableColor } from '@/scene/world/palette';
|
||||
import type {
|
||||
MutableChapterPalette,
|
||||
MutableColor,
|
||||
} from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
@@ -36,22 +39,24 @@ export function createAtmosphere(
|
||||
const night = CHAPTER_PALETTES['deep-night'];
|
||||
|
||||
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: `
|
||||
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 = vec4(position.xy, 1.0, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
fragmentShader: `
|
||||
uniform vec3 uSkyColor;
|
||||
uniform vec3 uFogColor;
|
||||
uniform vec3 uHazeColor;
|
||||
@@ -78,11 +83,12 @@ export function createAtmosphere(
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
fog: false,
|
||||
toneMapped: true,
|
||||
}));
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
fog: false,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const sky = new THREE.Mesh(skyGeometry, skyMaterial);
|
||||
sky.name = 'atmosphere-sky';
|
||||
sky.frustumCulled = false;
|
||||
@@ -93,14 +99,20 @@ export function createAtmosphere(
|
||||
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.58 - band * 0.1,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
toneMapped: true,
|
||||
}));
|
||||
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.58 - band * 0.1,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
silhouetteMaterials.push(material);
|
||||
const ridge = new THREE.Mesh(
|
||||
resources.own('geometry', createSilhouetteGeometry(band)),
|
||||
@@ -112,16 +124,21 @@ export function createAtmosphere(
|
||||
}
|
||||
group.add(silhouettes);
|
||||
|
||||
const starGeometry = resources.own('geometry', createStarGeometry(quality.starCount));
|
||||
const starMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uVisibility: { value: night.starVisibility },
|
||||
uDistance: { value: 0 },
|
||||
uPointScale: { value: 18 },
|
||||
uMaxPointSize: { value: 4.5 },
|
||||
uColor: { value: new THREE.Color(0xb9ecf2) },
|
||||
},
|
||||
vertexShader: `
|
||||
const starGeometry = resources.own(
|
||||
'geometry',
|
||||
createStarGeometry(quality.starCount),
|
||||
);
|
||||
const starMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uVisibility: { value: night.starVisibility },
|
||||
uDistance: { value: 0 },
|
||||
uPointScale: { value: 18 },
|
||||
uMaxPointSize: { value: 4.5 },
|
||||
uColor: { value: new THREE.Color(0xb9ecf2) },
|
||||
},
|
||||
vertexShader: `
|
||||
attribute float starSize;
|
||||
attribute float brightness;
|
||||
uniform float uDistance;
|
||||
@@ -141,7 +158,7 @@ export function createAtmosphere(
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uVisibility;
|
||||
varying float vBrightness;
|
||||
@@ -153,46 +170,32 @@ export function createAtmosphere(
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const stars = new THREE.Points(starGeometry, starMaterial);
|
||||
stars.name = 'atmosphere-stars';
|
||||
stars.frustumCulled = false;
|
||||
group.add(stars);
|
||||
|
||||
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.frustumCulled = false;
|
||||
farHaze.renderOrder = 60;
|
||||
hazeGroup.add(farHaze);
|
||||
const nearHaze = new THREE.Mesh(hazeGeometry, nearHazeMaterial);
|
||||
nearHaze.name = 'horizon-haze-near';
|
||||
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({
|
||||
uniforms: {
|
||||
uDistance: { value: 0 },
|
||||
uAcceleration: { value: 0 },
|
||||
uVisibility: { value: 0 },
|
||||
uColor: { value: new THREE.Color(0x79dae5) },
|
||||
},
|
||||
vertexShader: `
|
||||
const streakGeometry = resources.own(
|
||||
'geometry',
|
||||
createStreakGeometry(quality.streakCount),
|
||||
);
|
||||
const streakMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uDistance: { value: 0 },
|
||||
uAcceleration: { value: 0 },
|
||||
uVisibility: { value: 0 },
|
||||
uColor: { value: new THREE.Color(0x79dae5) },
|
||||
},
|
||||
vertexShader: `
|
||||
attribute float endFactor;
|
||||
uniform float uDistance;
|
||||
uniform float uAcceleration;
|
||||
@@ -208,7 +211,7 @@ export function createAtmosphere(
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uVisibility;
|
||||
varying float vTail;
|
||||
@@ -218,12 +221,13 @@ export function createAtmosphere(
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const streaks = new THREE.LineSegments(streakGeometry, streakMaterial);
|
||||
streaks.name = 'speed-streaks';
|
||||
streaks.frustumCulled = false;
|
||||
@@ -232,17 +236,18 @@ export function createAtmosphere(
|
||||
return {
|
||||
group,
|
||||
setViewportAspect(aspect) {
|
||||
const safeAspect = Number.isFinite(aspect) ? THREE.MathUtils.clamp(aspect, 0.35, 4) : 1;
|
||||
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 = safeAcceleration
|
||||
* (0.62 + Math.sqrt(safeAcceleration) * 0.38)
|
||||
* palette.streakEnergy
|
||||
* 0.72;
|
||||
const visibility =
|
||||
safeAcceleration *
|
||||
(0.62 + Math.sqrt(safeAcceleration) * 0.38) *
|
||||
palette.streakEnergy *
|
||||
0.72;
|
||||
starMaterial.uniforms.uDistance.value = finiteOrZero(distance);
|
||||
starMaterial.uniforms.uVisibility.value = palette.starVisibility;
|
||||
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance);
|
||||
@@ -252,18 +257,11 @@ export function createAtmosphere(
|
||||
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.07 + palette.warmth * 0.05;
|
||||
farHazeMaterial.uniforms.uEnergy.value = 0.045 + palette.warmth * 0.04;
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -274,9 +272,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) * 680 - 340;
|
||||
positions[offset + 1] = 16 + seeded(index, 23) * 230;
|
||||
positions[offset + 2] = -48 - seeded(index, 37) * 540;
|
||||
positions[offset] = seeded(index, 11) * 1800 - 900;
|
||||
positions[offset + 1] = 85 + seeded(index, 23) * 640;
|
||||
positions[offset + 2] = -780 - seeded(index, 37) * 260;
|
||||
sizes[index] = 2.4 + seeded(index, 53) * 4.8;
|
||||
brightness[index] = 0.28 + seeded(index, 71) * 0.72;
|
||||
}
|
||||
@@ -291,7 +289,8 @@ 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) * (14 + seeded(index, 103) * 102);
|
||||
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;
|
||||
@@ -310,52 +309,6 @@ function createStreakGeometry(count: number) {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
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 = 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 = 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);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
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);
|
||||
@@ -366,7 +319,9 @@ function createSilhouetteGeometry(band: number) {
|
||||
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 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;
|
||||
@@ -388,68 +343,6 @@ function createSilhouetteGeometry(band: number) {
|
||||
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 - ${WORLD_FAR_Z.toFixed(1)},
|
||||
${WORLD_DEPTH.toFixed(1)}
|
||||
) + ${WORLD_FAR_Z.toFixed(1)};
|
||||
vec4 viewPosition = modelViewMatrix * anchor;
|
||||
vDepthFade = (1.0 - smoothstep(540.0, ${HORIZON_FOG_END.toFixed(1)}, -viewPosition.z))
|
||||
* smoothstep(12.0, 42.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);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
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 laneProgress = (lane + seeded(index, 191) * 0.82)
|
||||
/ Math.max(1, Math.ceil(count / 2));
|
||||
const z = WORLD_NEAR_Z - 24 - laneProgress * (WORLD_DEPTH - 52);
|
||||
const x = side * (10.8 + seeded(index, 181) * 1.4);
|
||||
matrix.makeTranslation(x, 0.38, z);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
}
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
return { mesh, uniforms };
|
||||
}
|
||||
|
||||
function seeded(index: number, salt: number): number {
|
||||
const result = Math.sin((index + 1) * 91.713 + salt * 17.17) * 43_758.5453;
|
||||
return result - Math.floor(result);
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import grandTourerUrl from '@/assets/nightdrive-grand-tourer-evolution.glb?url';
|
||||
import grandTourerUrl from '@/assets/nightdrive-grand-tourer-expressway.glb?url';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { SceneDetailMode } from '@/scene/types';
|
||||
|
||||
export interface CarWorld {
|
||||
group: CarGroup;
|
||||
setDetail(detail: SceneDetailMode): void;
|
||||
update(speed: number, acceleration: number, elapsedSeconds: number): void;
|
||||
update(
|
||||
speed: number,
|
||||
acceleration: number,
|
||||
elapsedSeconds: number,
|
||||
distance?: number,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface CarGroup {
|
||||
name: string;
|
||||
children: unknown[];
|
||||
position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
set(x: number, y: number, z: number): void;
|
||||
};
|
||||
rotation: { x: number; y: number; z: number };
|
||||
scale: { set(x: number, y: number, z: number): void };
|
||||
visible: boolean;
|
||||
@@ -35,6 +45,9 @@ interface AuthoredObject {
|
||||
isMesh?: boolean;
|
||||
geometry?: { dispose(): void };
|
||||
material?: AuthoredMaterial | AuthoredMaterial[];
|
||||
parent?: { name: string };
|
||||
position: { y: number };
|
||||
rotation: { x: number; z: number };
|
||||
}
|
||||
|
||||
interface AuthoredCarGroup extends CarGroup {
|
||||
@@ -46,16 +59,23 @@ const FULL_BASE_Y = 0.58;
|
||||
const REDUCED_BASE_Y = 0.42;
|
||||
const GRAND_TOURER_URL = grandTourerUrl;
|
||||
|
||||
export async function loadGrandTourer(signal: AbortSignal): Promise<AuthoredCarGroup> {
|
||||
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');
|
||||
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('evolution-chase-car') ?? gltf.scene.getObjectByName('chase-car') ?? gltf.scene;
|
||||
if (signal.aborted)
|
||||
throw new DOMException('Scene build aborted', 'AbortError');
|
||||
const authored =
|
||||
gltf.scene.getObjectByName('evolution-chase-car') ??
|
||||
gltf.scene.getObjectByName('chase-car') ??
|
||||
gltf.scene;
|
||||
authored.removeFromParent();
|
||||
return authored as AuthoredCarGroup;
|
||||
}
|
||||
@@ -71,85 +91,118 @@ export function createCar(
|
||||
group.position.set(0, baseY, 0.72);
|
||||
group.scale.set(1.1, 0.82, 0.98);
|
||||
|
||||
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x304e68,
|
||||
emissive: 0x091725,
|
||||
emissiveIntensity: 0.42,
|
||||
roughness: 0.36,
|
||||
metalness: 0.68,
|
||||
}));
|
||||
const shoulderMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x365b77,
|
||||
emissive: 0x0a1b2a,
|
||||
emissiveIntensity: 0.36,
|
||||
roughness: 0.3,
|
||||
metalness: 0.74,
|
||||
}));
|
||||
const lowerBodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x111c29,
|
||||
roughness: 0.48,
|
||||
metalness: 0.54,
|
||||
}));
|
||||
const glassMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x071522,
|
||||
emissive: 0x061522,
|
||||
emissiveIntensity: 0.34,
|
||||
roughness: 0.12,
|
||||
metalness: 0.42,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
}));
|
||||
const fasciaMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x080d15,
|
||||
roughness: 0.52,
|
||||
metalness: 0.42,
|
||||
}));
|
||||
const tailMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x8f0b2d,
|
||||
emissive: 0xff174f,
|
||||
emissiveIntensity: 1.25,
|
||||
roughness: 0.26,
|
||||
metalness: 0.18,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const cyanMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x2b9bab,
|
||||
emissive: 0x44dceb,
|
||||
emissiveIntensity: 0.7,
|
||||
roughness: 0.34,
|
||||
metalness: 0.32,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const tireMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x020407,
|
||||
roughness: 0.94,
|
||||
metalness: 0.04,
|
||||
}));
|
||||
const hubMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x354451,
|
||||
roughness: 0.42,
|
||||
metalness: 0.78,
|
||||
}));
|
||||
const bodyMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x304e68,
|
||||
emissive: 0x091725,
|
||||
emissiveIntensity: 0.42,
|
||||
roughness: 0.36,
|
||||
metalness: 0.68,
|
||||
}),
|
||||
);
|
||||
const shoulderMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x365b77,
|
||||
emissive: 0x0a1b2a,
|
||||
emissiveIntensity: 0.36,
|
||||
roughness: 0.3,
|
||||
metalness: 0.74,
|
||||
}),
|
||||
);
|
||||
const lowerBodyMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x111c29,
|
||||
roughness: 0.48,
|
||||
metalness: 0.54,
|
||||
}),
|
||||
);
|
||||
const glassMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x071522,
|
||||
emissive: 0x061522,
|
||||
emissiveIntensity: 0.34,
|
||||
roughness: 0.12,
|
||||
metalness: 0.42,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
}),
|
||||
);
|
||||
const fasciaMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x080d15,
|
||||
roughness: 0.52,
|
||||
metalness: 0.42,
|
||||
}),
|
||||
);
|
||||
const tailMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x8f0b2d,
|
||||
emissive: 0xff174f,
|
||||
emissiveIntensity: 1.25,
|
||||
roughness: 0.26,
|
||||
metalness: 0.18,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const cyanMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x2b9bab,
|
||||
emissive: 0x44dceb,
|
||||
emissiveIntensity: 0.7,
|
||||
roughness: 0.34,
|
||||
metalness: 0.32,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const tireMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x020407,
|
||||
roughness: 0.94,
|
||||
metalness: 0.04,
|
||||
}),
|
||||
);
|
||||
const hubMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x354451,
|
||||
roughness: 0.42,
|
||||
metalness: 0.78,
|
||||
}),
|
||||
);
|
||||
|
||||
const rearProfile = createRearProfile();
|
||||
const silhouetteGeometry = resources.own('geometry', new THREE.ExtrudeGeometry(rearProfile, {
|
||||
depth: 5.8,
|
||||
steps: 1,
|
||||
bevelEnabled: true,
|
||||
bevelSegments: 1,
|
||||
bevelSize: 0.11,
|
||||
bevelThickness: 0.1,
|
||||
}));
|
||||
const silhouetteGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.ExtrudeGeometry(rearProfile, {
|
||||
depth: 5.8,
|
||||
steps: 1,
|
||||
bevelEnabled: true,
|
||||
bevelSegments: 1,
|
||||
bevelSize: 0.11,
|
||||
bevelThickness: 0.1,
|
||||
}),
|
||||
);
|
||||
silhouetteGeometry.translate(0, 0, -2.9);
|
||||
silhouetteGeometry.computeVertexNormals();
|
||||
const silhouette = new THREE.Mesh(silhouetteGeometry, bodyMaterial);
|
||||
silhouette.name = 'rear-silhouette';
|
||||
group.add(silhouette);
|
||||
|
||||
const rearFaceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0x1b3246,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const rearFaceMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x1b3246,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const rearFace = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.ShapeGeometry(rearProfile)),
|
||||
rearFaceMaterial,
|
||||
@@ -184,12 +237,15 @@ export function createCar(
|
||||
rearGlassProfile.lineTo(1.15, 1.41);
|
||||
rearGlassProfile.lineTo(1.58, 0.8);
|
||||
rearGlassProfile.closePath();
|
||||
const rearGlassMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0x0b2537,
|
||||
transparent: true,
|
||||
opacity: 0.94,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const rearGlassMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x0b2537,
|
||||
transparent: true,
|
||||
opacity: 0.94,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const rearGlass = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.ShapeGeometry(rearGlassProfile)),
|
||||
rearGlassMaterial,
|
||||
@@ -198,13 +254,19 @@ export function createCar(
|
||||
rearGlass.position.z = 1.44;
|
||||
group.add(rearGlass);
|
||||
|
||||
const fasciaGeometry = resources.own('geometry', new THREE.BoxGeometry(4.84, 0.56, 0.16));
|
||||
const fasciaGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(4.84, 0.56, 0.16),
|
||||
);
|
||||
const fascia = new THREE.Mesh(fasciaGeometry, fasciaMaterial);
|
||||
fascia.name = 'rear-fascia';
|
||||
fascia.position.set(0, 0.02, 3.07);
|
||||
group.add(fascia);
|
||||
|
||||
const lightGeometry = resources.own('geometry', new THREE.BoxGeometry(4.55, 0.13, 0.09));
|
||||
const lightGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(4.55, 0.13, 0.09),
|
||||
);
|
||||
const lightBar = new THREE.Mesh(lightGeometry, tailMaterial);
|
||||
lightBar.name = 'rear-light-bar';
|
||||
lightBar.position.set(0, 0.46, 3.17);
|
||||
@@ -216,9 +278,18 @@ export function createCar(
|
||||
diffuser.position.z = 3.08;
|
||||
group.add(diffuser);
|
||||
|
||||
const wheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.66, 0.66, 0.5, 24));
|
||||
const hubGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.31, 0.31, 0.515, 16));
|
||||
for (const [x, side] of [[-2.38, 'left'], [2.38, 'right']] as const) {
|
||||
const wheelGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.CylinderGeometry(0.66, 0.66, 0.5, 24),
|
||||
);
|
||||
const hubGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.CylinderGeometry(0.31, 0.31, 0.515, 16),
|
||||
);
|
||||
for (const [x, side] of [
|
||||
[-2.38, 'left'],
|
||||
[2.38, 'right'],
|
||||
] as const) {
|
||||
const wheel = new THREE.Mesh(wheelGeometry, tireMaterial);
|
||||
wheel.name = `rear-wheel-${side}`;
|
||||
wheel.rotation.z = Math.PI / 2;
|
||||
@@ -235,17 +306,22 @@ export function createCar(
|
||||
uColor: { value: new THREE.Color(0x49d7e5) },
|
||||
uOpacity: { value: 0.12 },
|
||||
};
|
||||
const underglowGeometry = resources.own('geometry', new THREE.PlaneGeometry(3.9, 4.2));
|
||||
const underglowMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: underglowUniforms,
|
||||
vertexShader: `
|
||||
const underglowGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.PlaneGeometry(3.9, 4.2),
|
||||
);
|
||||
const underglowMaterial = 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: `
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying vec2 vGlowUv;
|
||||
@@ -257,12 +333,13 @@ export function createCar(
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const underglow = new THREE.Mesh(underglowGeometry, underglowMaterial);
|
||||
underglow.name = 'car-underglow';
|
||||
underglow.rotation.x = -Math.PI / 2;
|
||||
@@ -274,18 +351,27 @@ export function createCar(
|
||||
|
||||
const secondary = new THREE.Group();
|
||||
secondary.name = 'secondary-detail';
|
||||
const trimGeometry = resources.own('geometry', new THREE.BoxGeometry(0.38, 0.075, 0.07));
|
||||
const trimGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(0.38, 0.075, 0.07),
|
||||
);
|
||||
for (const x of [-1.98, 1.98]) {
|
||||
const trim = new THREE.Mesh(trimGeometry, cyanMaterial);
|
||||
trim.position.set(x, -0.15, 3.13);
|
||||
secondary.add(trim);
|
||||
}
|
||||
const deckGeometry = resources.own('geometry', new THREE.BoxGeometry(3.25, 0.075, 0.62));
|
||||
const deckGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(3.25, 0.075, 0.62),
|
||||
);
|
||||
const rearDeck = new THREE.Mesh(deckGeometry, shoulderMaterial);
|
||||
rearDeck.position.set(0, 0.86, 2.15);
|
||||
rearDeck.rotation.x = -0.08;
|
||||
secondary.add(rearDeck);
|
||||
const frontWheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.58, 0.58, 0.42, 18));
|
||||
const frontWheelGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.CylinderGeometry(0.58, 0.58, 0.42, 18),
|
||||
);
|
||||
for (const x of [-2.18, 2.18]) {
|
||||
const wheel = new THREE.Mesh(frontWheelGeometry, tireMaterial);
|
||||
wheel.rotation.z = Math.PI / 2;
|
||||
@@ -321,24 +407,30 @@ export function createCar(
|
||||
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);
|
||||
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.088;
|
||||
const springAcceleration = (targetSquat - squat) * 34 - squatVelocity * 10;
|
||||
const springAcceleration =
|
||||
(targetSquat - squat) * 34 - squatVelocity * 10;
|
||||
squatVelocity += springAcceleration * deltaSeconds;
|
||||
squat += squatVelocity * deltaSeconds;
|
||||
const vibration = Math.sin(elapsed * (8.2 + safeSpeed * 2.4)) * (0.002 + safeSpeed * 0.003);
|
||||
const vibration =
|
||||
Math.sin(elapsed * (8.2 + safeSpeed * 2.4)) *
|
||||
(0.002 + safeSpeed * 0.003);
|
||||
group.position.y = baseY - squat + vibration;
|
||||
group.rotation.x = -squat * 0.045;
|
||||
group.rotation.z = (
|
||||
Math.sin(elapsed * 0.78) * safeSpeed * 0.0024
|
||||
+ Math.sin(elapsed * 1.31) * response * 0.0014
|
||||
);
|
||||
group.rotation.z =
|
||||
Math.sin(elapsed * 0.78) * safeSpeed * 0.0024 +
|
||||
Math.sin(elapsed * 1.31) * response * 0.0014;
|
||||
tailMaterial.emissiveIntensity = 1.25 + response * 1.35;
|
||||
underglowUniforms.uOpacity.value = 0.1 + safeSpeed * 0.055 + response * 0.07;
|
||||
underglowUniforms.uOpacity.value =
|
||||
0.1 + safeSpeed * 0.055 + response * 0.07;
|
||||
contact.uniforms.uOpacity.value = 0.2 + response * 0.06;
|
||||
},
|
||||
};
|
||||
@@ -354,21 +446,34 @@ function createAuthoredCar(
|
||||
authoredModel.rotation.y = Math.PI;
|
||||
group.add(authoredModel);
|
||||
|
||||
const wheels: Array<{ object: AuthoredObject; radius: number }> = [];
|
||||
const bodyParts: Array<{ object: AuthoredObject; y: number }> = [];
|
||||
const tailMaterials: AuthoredMaterial[] = [];
|
||||
const cyanMaterials: AuthoredMaterial[] = [];
|
||||
authoredModel.traverse((object) => {
|
||||
if (object.name.startsWith('evolution-')) object.name = object.name.slice('evolution-'.length);
|
||||
if (object.name.startsWith('evolution-'))
|
||||
object.name = object.name.slice('evolution-'.length);
|
||||
// Three removes Blender's suffix dot when sanitizing imported node names.
|
||||
object.name = object.name.replace(/\.?\d{3}$/u, '');
|
||||
if (/^wheel-(front|rear)-(left|right)$/u.test(object.name)) {
|
||||
wheels.push({
|
||||
object,
|
||||
radius: object.name.includes('front') ? 0.42 : 0.46,
|
||||
});
|
||||
}
|
||||
if (!object.isMesh || !object.geometry || !object.material) return;
|
||||
if (!object.parent?.name.startsWith('wheel-'))
|
||||
bodyParts.push({ object, y: object.position.y });
|
||||
resources.own('geometry', object.geometry);
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
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.isMeshStandardMaterial &&
|
||||
(object.name === 'car-body' || object.name === 'body-haunches')
|
||||
) {
|
||||
material.emissive?.setHex(0x0b2738);
|
||||
material.emissiveIntensity = 0.26;
|
||||
@@ -380,7 +485,10 @@ function createAuthoredCar(
|
||||
if (object.name === 'rear-light-bar' && material.isMeshStandardMaterial) {
|
||||
tailMaterials.push(material);
|
||||
}
|
||||
if (object.name === 'car-underglow-source' && material.isMeshStandardMaterial) {
|
||||
if (
|
||||
object.name === 'car-underglow-source' &&
|
||||
material.isMeshStandardMaterial
|
||||
) {
|
||||
cyanMaterials.push(material);
|
||||
}
|
||||
}
|
||||
@@ -392,16 +500,18 @@ function createAuthoredCar(
|
||||
};
|
||||
const underglow = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(1.72, 3.82)),
|
||||
resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: underglowUniforms,
|
||||
vertexShader: `
|
||||
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: `
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uOpacity;
|
||||
varying vec2 vGlowUv;
|
||||
@@ -413,12 +523,13 @@ function createAuthoredCar(
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
})),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
underglow.name = 'car-underglow';
|
||||
underglow.rotation.x = -Math.PI / 2;
|
||||
@@ -454,25 +565,45 @@ function createAuthoredCar(
|
||||
detail = nextDetail;
|
||||
setScale(detail);
|
||||
},
|
||||
update(speed, acceleration, elapsedSeconds) {
|
||||
update(speed, acceleration, elapsedSeconds, distance) {
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
const vibration =
|
||||
Math.sin(elapsed * (7.4 + safeSpeed * 2.2)) *
|
||||
(0.0014 + safeSpeed * 0.0022);
|
||||
group.position.y = baseY + vibration * 0.2;
|
||||
for (const part of bodyParts) {
|
||||
part.object.position.y = part.y - squat * 0.42;
|
||||
part.object.rotation.x = -squat * 0.11;
|
||||
part.object.rotation.z = Math.sin(elapsed * 0.65) * safeSpeed * 0.0015;
|
||||
}
|
||||
if (distance !== undefined && Number.isFinite(distance)) {
|
||||
const scale = detail === 'full' ? 2.78 : 2.32;
|
||||
for (const wheel of wheels)
|
||||
wheel.object.rotation.x =
|
||||
(distance / (wheel.radius * scale)) % (Math.PI * 2);
|
||||
}
|
||||
group.rotation.x = 0;
|
||||
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
|
||||
for (const material of tailMaterials) material.emissiveIntensity = 2.2 + response * 1.2;
|
||||
for (const material of cyanMaterials) material.emissiveIntensity = 2.2 + response * 1.6;
|
||||
underglowUniforms.uOpacity.value = 0.09 + safeSpeed * 0.04 + response * 0.075;
|
||||
for (const material of tailMaterials)
|
||||
material.emissiveIntensity = 2.2 + response * 1.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;
|
||||
},
|
||||
};
|
||||
@@ -487,16 +618,18 @@ function createContactShadow(
|
||||
const uniforms = {
|
||||
uOpacity: { value: opacity },
|
||||
};
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
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: `
|
||||
fragmentShader: `
|
||||
uniform float uOpacity;
|
||||
varying vec2 vShadowUv;
|
||||
void main() {
|
||||
@@ -507,12 +640,13 @@ function createContactShadow(
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.NormalBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
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,
|
||||
@@ -535,7 +669,8 @@ function ownMaterialTextures(
|
||||
'metalnessMap',
|
||||
'aoMap',
|
||||
]) {
|
||||
const texture = material[key] as { isTexture?: boolean; dispose(): void } | null | undefined;
|
||||
const texture = material[key] as
|
||||
{ isTexture?: boolean; dispose(): void } | null | undefined;
|
||||
if (texture?.isTexture) resources.own('texture', texture);
|
||||
}
|
||||
}
|
||||
@@ -559,24 +694,36 @@ function createHaunchGeometry(side: -1 | 1) {
|
||||
const outer = 2.62 * side;
|
||||
const frontOuter = 2.25 * side;
|
||||
const positions = new Float32Array([
|
||||
inner, 0.48, -1.75,
|
||||
frontOuter, 0.38, -1.75,
|
||||
inner, 0.92, -1.75,
|
||||
outer, 0.68, -1.75,
|
||||
inner, 0.48, 2.62,
|
||||
outer, 0.33, 2.62,
|
||||
inner, 0.92, 2.62,
|
||||
outer, 0.73, 2.62,
|
||||
inner,
|
||||
0.48,
|
||||
-1.75,
|
||||
frontOuter,
|
||||
0.38,
|
||||
-1.75,
|
||||
inner,
|
||||
0.92,
|
||||
-1.75,
|
||||
outer,
|
||||
0.68,
|
||||
-1.75,
|
||||
inner,
|
||||
0.48,
|
||||
2.62,
|
||||
outer,
|
||||
0.33,
|
||||
2.62,
|
||||
inner,
|
||||
0.92,
|
||||
2.62,
|
||||
outer,
|
||||
0.73,
|
||||
2.62,
|
||||
]);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setIndex([
|
||||
0, 1, 2, 2, 1, 3,
|
||||
4, 6, 5, 6, 7, 5,
|
||||
0, 4, 1, 1, 4, 5,
|
||||
2, 3, 6, 3, 7, 6,
|
||||
1, 5, 3, 3, 5, 7,
|
||||
0, 2, 4, 2, 6, 4,
|
||||
0, 1, 2, 2, 1, 3, 4, 6, 5, 6, 7, 5, 0, 4, 1, 1, 4, 5, 2, 3, 6, 3, 7, 6, 1,
|
||||
5, 3, 3, 5, 7, 0, 2, 4, 2, 6, 4,
|
||||
]);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
@@ -584,24 +731,14 @@ function createHaunchGeometry(side: -1 | 1) {
|
||||
|
||||
function createCanopyGeometry() {
|
||||
const positions = new Float32Array([
|
||||
-1.18, 0.78, -1.9,
|
||||
1.18, 0.78, -1.9,
|
||||
-0.86, 1.82, -1.65,
|
||||
0.86, 1.82, -1.65,
|
||||
-1.62, 0.78, 1.42,
|
||||
1.62, 0.78, 1.42,
|
||||
-1.18, 1.42, 1.06,
|
||||
1.18, 1.42, 1.06,
|
||||
-1.18, 0.78, -1.9, 1.18, 0.78, -1.9, -0.86, 1.82, -1.65, 0.86, 1.82, -1.65,
|
||||
-1.62, 0.78, 1.42, 1.62, 0.78, 1.42, -1.18, 1.42, 1.06, 1.18, 1.42, 1.06,
|
||||
]);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setIndex([
|
||||
0, 1, 2, 2, 1, 3,
|
||||
4, 6, 5, 6, 7, 5,
|
||||
0, 2, 4, 2, 6, 4,
|
||||
1, 5, 3, 3, 5, 7,
|
||||
2, 3, 6, 3, 7, 6,
|
||||
0, 4, 1, 1, 4, 5,
|
||||
0, 1, 2, 2, 1, 3, 4, 6, 5, 6, 7, 5, 0, 2, 4, 2, 6, 4, 1, 5, 3, 3, 5, 7, 2,
|
||||
3, 6, 3, 7, 6, 0, 4, 1, 1, 4, 5,
|
||||
]);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
|
||||
@@ -11,13 +11,79 @@ export interface ChapterComposition {
|
||||
shift: number;
|
||||
}
|
||||
|
||||
export const CHAPTER_COMPOSITIONS: Readonly<Record<SectionId, ChapterComposition>> = {
|
||||
start: { x: -2.6, y: 6.4, z: 21, targetY: 3.9, targetZ: -90, fov: 56, shift: 0.2 },
|
||||
profile: { x: -3.6, y: 7.4, z: 23, targetY: 4.3, targetZ: -108, fov: 59, shift: -0.23 },
|
||||
experience: { x: 3.2, y: 8.1, z: 24, targetY: 4.8, targetZ: -120, fov: 61, shift: 0.25 },
|
||||
infrastructure: { x: -1.8, y: 9.5, z: 26, targetY: 5.2, targetZ: -128, fov: 64, shift: 0 },
|
||||
'selected-builds': { x: 2.5, y: 7, z: 23, targetY: 4.2, targetZ: -105, fov: 59, shift: 0.04 },
|
||||
'additional-work': { x: -3.2, y: 7.8, z: 24, targetY: 4.7, targetZ: -110, fov: 61, shift: -0.12 },
|
||||
toolkit: { x: 0, y: 8.6, z: 25, targetY: 5, targetZ: -120, fov: 62, shift: 0 },
|
||||
contact: { x: -2.8, y: 5.8, z: 23, targetY: 3.4, targetZ: -95, fov: 55, shift: 0.24 },
|
||||
export const CHAPTER_COMPOSITIONS: Readonly<
|
||||
Record<SectionId, ChapterComposition>
|
||||
> = {
|
||||
start: {
|
||||
x: -5.6,
|
||||
y: 5.6,
|
||||
z: 24,
|
||||
targetY: 3.5,
|
||||
targetZ: -88,
|
||||
fov: 54,
|
||||
shift: 0.14,
|
||||
},
|
||||
profile: {
|
||||
x: 4.5,
|
||||
y: 6.2,
|
||||
z: 25,
|
||||
targetY: 4.3,
|
||||
targetZ: -108,
|
||||
fov: 59,
|
||||
shift: -0.23,
|
||||
},
|
||||
experience: {
|
||||
x: -4.0,
|
||||
y: 6.8,
|
||||
z: 25,
|
||||
targetY: 4.8,
|
||||
targetZ: -120,
|
||||
fov: 61,
|
||||
shift: 0.25,
|
||||
},
|
||||
infrastructure: {
|
||||
x: -1.8,
|
||||
y: 9.5,
|
||||
z: 26,
|
||||
targetY: 5.2,
|
||||
targetZ: -128,
|
||||
fov: 64,
|
||||
shift: 0,
|
||||
},
|
||||
'selected-builds': {
|
||||
x: 2.5,
|
||||
y: 7,
|
||||
z: 23,
|
||||
targetY: 4.2,
|
||||
targetZ: -105,
|
||||
fov: 59,
|
||||
shift: 0.04,
|
||||
},
|
||||
'additional-work': {
|
||||
x: -3.2,
|
||||
y: 7.8,
|
||||
z: 24,
|
||||
targetY: 4.7,
|
||||
targetZ: -110,
|
||||
fov: 61,
|
||||
shift: -0.12,
|
||||
},
|
||||
toolkit: {
|
||||
x: 0,
|
||||
y: 8.6,
|
||||
z: 25,
|
||||
targetY: 5,
|
||||
targetZ: -120,
|
||||
fov: 62,
|
||||
shift: 0,
|
||||
},
|
||||
contact: {
|
||||
x: -4.8,
|
||||
y: 4.8,
|
||||
z: 26,
|
||||
targetY: 3.4,
|
||||
targetZ: -95,
|
||||
fov: 55,
|
||||
shift: 0.24,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { JourneySnapshot } from '@/journey/types';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
import { createHorizonAir } from '@/scene/world/horizon-air';
|
||||
import { createExpressway } from '@/scene/world/expressway';
|
||||
import { createSignStage } from '@/scene/world/sign-stage';
|
||||
import { createRoad } from '@/scene/world/road';
|
||||
import { createTerrain } from '@/scene/world/terrain';
|
||||
import { createSun } from '@/scene/world/sun';
|
||||
@@ -11,10 +14,7 @@ import { createSign, prepareSignTypography } from '@/scene/world/sign';
|
||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||
import { createPostProcessor } from '@/scene/post-processing';
|
||||
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
} from '@/scene/world/metrics';
|
||||
import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
|
||||
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
|
||||
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
|
||||
import { createTravelDistance } from '@/scene/world/travel-distance';
|
||||
@@ -26,12 +26,14 @@ import {
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
const TEST_BUILD =
|
||||
typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
|
||||
export function createChaseCamera(aspect = 1) {
|
||||
const camera = new THREE.PerspectiveCamera(56, aspect, 0.1, 1_200);
|
||||
camera.position.set(-2.6, 6.4, 21);
|
||||
camera.lookAt(0, 3.9, -90);
|
||||
const c = CHAPTER_COMPOSITIONS.start;
|
||||
const camera = new THREE.PerspectiveCamera(c.fov, aspect, 0.1, 1_200);
|
||||
camera.position.set(c.x, c.y, c.z);
|
||||
camera.lookAt(0, c.targetY, c.targetZ);
|
||||
return camera;
|
||||
}
|
||||
|
||||
@@ -54,17 +56,27 @@ export async function createProceduralSceneGeneration(
|
||||
recordTestFaultEvent('car-rejected');
|
||||
throw new Error('Injected car failure');
|
||||
}
|
||||
if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||
if (context.signal.aborted)
|
||||
throw new DOMException('Scene build aborted', 'AbortError');
|
||||
const canvas = context.canvas as HTMLCanvasElement;
|
||||
if (typeof canvas.getContext !== 'function') throw new Error('A canvas element is required');
|
||||
const mobileOrTablet = Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
|
||||
const quality = renderQualityFor(context.quality, globalThis.devicePixelRatio ?? 1, mobileOrTablet);
|
||||
const renderer = context.resources.own('renderer', new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
alpha: false,
|
||||
antialias: context.quality !== 'low',
|
||||
powerPreference: 'high-performance',
|
||||
}));
|
||||
if (typeof canvas.getContext !== 'function')
|
||||
throw new Error('A canvas element is required');
|
||||
const mobileOrTablet =
|
||||
Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
|
||||
const quality = renderQualityFor(
|
||||
context.quality,
|
||||
globalThis.devicePixelRatio ?? 1,
|
||||
mobileOrTablet,
|
||||
);
|
||||
const renderer = context.resources.own(
|
||||
'renderer',
|
||||
new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
alpha: false,
|
||||
antialias: context.quality !== 'low',
|
||||
powerPreference: 'high-performance',
|
||||
}),
|
||||
);
|
||||
renderer.setPixelRatio(quality.pixelRatio);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
@@ -80,6 +92,8 @@ export async function createProceduralSceneGeneration(
|
||||
scene.background = background;
|
||||
scene.fog = createWorldFog(currentPalette.fog.getHex());
|
||||
const camera = createChaseCamera();
|
||||
camera.name = 'journey-camera';
|
||||
scene.add(camera);
|
||||
|
||||
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
||||
scene.add(ambient);
|
||||
@@ -97,7 +111,10 @@ export async function createProceduralSceneGeneration(
|
||||
|
||||
const road = createRoad(context.resources, quality);
|
||||
const terrain = createTerrain(context.resources, quality);
|
||||
const expressway = createExpressway(context.resources, quality);
|
||||
const signStage = createSignStage(context.resources);
|
||||
const sun = createSun(context.resources);
|
||||
const horizonAir = createHorizonAir(context.resources);
|
||||
const authoredCar = await loadGrandTourer(context.signal);
|
||||
const car = createCar(context.resources, authoredCar);
|
||||
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
||||
@@ -110,8 +127,10 @@ export async function createProceduralSceneGeneration(
|
||||
scene.add(
|
||||
atmosphere.group,
|
||||
road.group,
|
||||
expressway.group,
|
||||
terrain.group,
|
||||
sun.group,
|
||||
horizonAir.group,
|
||||
car.group,
|
||||
sign.group,
|
||||
);
|
||||
@@ -127,7 +146,7 @@ export async function createProceduralSceneGeneration(
|
||||
targetX: 0,
|
||||
targetY: 3.9,
|
||||
targetZ: -90,
|
||||
shift: 0.2,
|
||||
shift: CHAPTER_COMPOSITIONS.start.shift,
|
||||
verticalShift: 0,
|
||||
fov: camera.fov,
|
||||
roll: 0,
|
||||
@@ -138,6 +157,7 @@ export async function createProceduralSceneGeneration(
|
||||
let lastNow: number | null = null;
|
||||
let disposed = false;
|
||||
let renderedFrames = 0;
|
||||
let atmosphericTime = 0;
|
||||
let firstFrameSuppressionRecorded = false;
|
||||
|
||||
const resize = (): void => {
|
||||
@@ -151,9 +171,11 @@ export async function createProceduralSceneGeneration(
|
||||
camera.updateProjectionMatrix();
|
||||
if (lastNow === null) {
|
||||
const wideComposition = camera.aspect > 1.18;
|
||||
cameraState.x = wideComposition ? -2.6 : 0;
|
||||
cameraState.x = wideComposition ? CHAPTER_COMPOSITIONS.start.x : 0;
|
||||
cameraState.targetX = 0;
|
||||
cameraState.shift = wideComposition ? 0.2 : 0;
|
||||
cameraState.shift = wideComposition
|
||||
? CHAPTER_COMPOSITIONS.start.shift
|
||||
: 0;
|
||||
cameraState.verticalShift = wideComposition ? 0 : 0.16;
|
||||
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
|
||||
camera.lookAt(
|
||||
@@ -166,18 +188,24 @@ export async function createProceduralSceneGeneration(
|
||||
car.group.visible = height >= 340;
|
||||
sign.setViewportSize(width, height);
|
||||
atmosphere.setViewportAspect(camera.aspect);
|
||||
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
|
||||
postProcessor?.resize(
|
||||
width,
|
||||
height,
|
||||
quality.pixelRatio * quality.postScale,
|
||||
);
|
||||
if (TEST_BUILD && context.quality !== 'medium') {
|
||||
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
|
||||
tier: context.quality,
|
||||
defaultFramebuffer: {
|
||||
width: Math.max(1, canvas.width),
|
||||
height: Math.max(1, canvas.height),
|
||||
bytesPerPixel: 4,
|
||||
attachments: 2,
|
||||
samples: context.quality === 'low' ? 1 : 4,
|
||||
},
|
||||
}));
|
||||
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(
|
||||
context.resources.gpuBudgetSnapshot({
|
||||
tier: context.quality,
|
||||
defaultFramebuffer: {
|
||||
width: Math.max(1, canvas.width),
|
||||
height: Math.max(1, canvas.height),
|
||||
bytesPerPixel: 4,
|
||||
attachments: 2,
|
||||
samples: context.quality === 'low' ? 1 : 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,15 +225,18 @@ export async function createProceduralSceneGeneration(
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
TEST_BUILD
|
||||
&& isTestFault('F3-post-first-frame-exception')
|
||||
&& renderedFrames > 0
|
||||
TEST_BUILD &&
|
||||
isTestFault('F3-post-first-frame-exception') &&
|
||||
renderedFrames > 0
|
||||
) {
|
||||
recordTestFaultEvent('post-first-frame-exception');
|
||||
throw new Error('Injected post-first-frame exception');
|
||||
}
|
||||
resize();
|
||||
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
|
||||
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();
|
||||
@@ -221,7 +252,19 @@ export async function createProceduralSceneGeneration(
|
||||
0.18,
|
||||
1,
|
||||
);
|
||||
road.update(driveDistance, visualSpeed, response.roadEnergy, currentPalette);
|
||||
road.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
response.roadEnergy,
|
||||
currentPalette,
|
||||
);
|
||||
expressway.update(
|
||||
driveDistance,
|
||||
response.roadEnergy,
|
||||
currentPalette.warmth,
|
||||
snapshot.activeSectionId,
|
||||
deltaSeconds,
|
||||
);
|
||||
terrain.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
@@ -229,14 +272,21 @@ export async function createProceduralSceneGeneration(
|
||||
snapshot.routeProgress,
|
||||
currentPalette,
|
||||
);
|
||||
const elapsedSeconds = now / 1000;
|
||||
atmosphericTime += deltaSeconds;
|
||||
const elapsedSeconds = atmosphericTime;
|
||||
horizonAir.update(currentPalette, elapsedSeconds);
|
||||
sun.update(
|
||||
currentPalette,
|
||||
snapshot.routeProgress,
|
||||
elapsedSeconds,
|
||||
response.bloomEnergy,
|
||||
);
|
||||
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
|
||||
car.update(
|
||||
visualSpeed,
|
||||
response.suspensionEnergy,
|
||||
elapsedSeconds,
|
||||
driveDistance,
|
||||
);
|
||||
sign.update(snapshot.sign, now);
|
||||
atmosphere.update(
|
||||
driveDistance,
|
||||
@@ -246,7 +296,9 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
background.copy(currentPalette.sky);
|
||||
scene.fog.color.copy(currentPalette.fog);
|
||||
ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26);
|
||||
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.setHex(0xb9e3ef);
|
||||
@@ -255,16 +307,63 @@ export async function createProceduralSceneGeneration(
|
||||
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
|
||||
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
|
||||
const wideComposition = camera.aspect > 1.18;
|
||||
const composition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
|
||||
const baseComposition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
|
||||
const upcoming =
|
||||
snapshot.sign && snapshot.sign.phase !== 'depart'
|
||||
? CHAPTER_COMPOSITIONS[snapshot.sign.targetId]
|
||||
: baseComposition;
|
||||
const anticipation =
|
||||
snapshot.sign?.phase === 'approach'
|
||||
? Math.min(0.55, snapshot.sign.progress * 0.55)
|
||||
: snapshot.sign?.phase === 'dwell'
|
||||
? 0.55
|
||||
: 0;
|
||||
const composition = {
|
||||
x: THREE.MathUtils.lerp(baseComposition.x, upcoming.x, anticipation),
|
||||
y: THREE.MathUtils.lerp(baseComposition.y, upcoming.y, anticipation),
|
||||
z: THREE.MathUtils.lerp(baseComposition.z, upcoming.z, anticipation),
|
||||
targetY: THREE.MathUtils.lerp(
|
||||
baseComposition.targetY,
|
||||
upcoming.targetY,
|
||||
anticipation,
|
||||
),
|
||||
targetZ: THREE.MathUtils.lerp(
|
||||
baseComposition.targetZ,
|
||||
upcoming.targetZ,
|
||||
anticipation,
|
||||
),
|
||||
fov: THREE.MathUtils.lerp(
|
||||
baseComposition.fov,
|
||||
upcoming.fov,
|
||||
anticipation,
|
||||
),
|
||||
shift: THREE.MathUtils.lerp(
|
||||
baseComposition.shift,
|
||||
upcoming.shift,
|
||||
anticipation,
|
||||
),
|
||||
};
|
||||
// The document stays native. Only the rendered lens eases to the chapter's safe zone.
|
||||
const cameraX = wideComposition ? composition.x : 0;
|
||||
const targetX = 0;
|
||||
const targetShift = wideComposition ? composition.shift : 0;
|
||||
const targetVerticalShift = wideComposition ? 0 : (snapshot.activeSectionId === 'start' ? 0.16 : 0.07);
|
||||
const roadVibration = Math.sin(driveDistance * 0.105) * (
|
||||
0.012 + response.roadEnergy * 0.024
|
||||
);
|
||||
const cameraMix = 1 - Math.exp(-deltaSeconds / 0.85);
|
||||
const targetShift =
|
||||
height < 560 && width >= 768
|
||||
? 0.22
|
||||
: wideComposition
|
||||
? composition.shift
|
||||
: 0;
|
||||
const targetVerticalShift = wideComposition
|
||||
? 0
|
||||
: snapshot.activeSectionId === 'start'
|
||||
? 0.16
|
||||
: 0.07;
|
||||
const roadVibration =
|
||||
Math.sin(driveDistance * 0.105) * (0.012 + response.roadEnergy * 0.024);
|
||||
const cameraMix =
|
||||
1 -
|
||||
Math.exp(
|
||||
-deltaSeconds / (snapshot.activeSectionId === 'contact' ? 1.5 : 1.05),
|
||||
);
|
||||
cameraState.x = THREE.MathUtils.lerp(
|
||||
cameraState.x,
|
||||
cameraX - response.directionBias * 0.16,
|
||||
@@ -277,7 +376,7 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
cameraState.z = THREE.MathUtils.lerp(
|
||||
cameraState.z,
|
||||
(wideComposition ? composition.z : 29) - response.cameraEnergy * 1.2,
|
||||
(wideComposition ? composition.z : 31) + response.cameraEnergy * 0.9,
|
||||
cameraMix,
|
||||
);
|
||||
cameraState.targetX = THREE.MathUtils.lerp(
|
||||
@@ -297,7 +396,7 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
cameraState.fov = THREE.MathUtils.lerp(
|
||||
cameraState.fov,
|
||||
(wideComposition ? composition.fov : 62) + response.cameraEnergy * 7,
|
||||
(wideComposition ? composition.fov : 61) + response.cameraEnergy * 5.5,
|
||||
cameraMix,
|
||||
);
|
||||
cameraState.roll = THREE.MathUtils.lerp(
|
||||
@@ -305,30 +404,53 @@ export async function createProceduralSceneGeneration(
|
||||
-response.directionBias * 0.003 + roadVibration * 0.018,
|
||||
cameraMix,
|
||||
);
|
||||
cameraState.shift = THREE.MathUtils.lerp(cameraState.shift, targetShift, cameraMix);
|
||||
cameraState.verticalShift = THREE.MathUtils.lerp(cameraState.verticalShift, targetVerticalShift, cameraMix);
|
||||
camera.setViewOffset(width, height, -width * cameraState.shift, -height * cameraState.verticalShift, width, height);
|
||||
cameraState.shift = THREE.MathUtils.lerp(
|
||||
cameraState.shift,
|
||||
targetShift,
|
||||
cameraMix,
|
||||
);
|
||||
cameraState.verticalShift = THREE.MathUtils.lerp(
|
||||
cameraState.verticalShift,
|
||||
targetVerticalShift,
|
||||
cameraMix,
|
||||
);
|
||||
camera.setViewOffset(
|
||||
width,
|
||||
height,
|
||||
-width * cameraState.shift,
|
||||
-height * cameraState.verticalShift,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
|
||||
camera.lookAt(cameraState.targetX, cameraState.targetY, cameraState.targetZ);
|
||||
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();
|
||||
}
|
||||
const stage = signStage.update(width, height);
|
||||
sign.compose(camera, width, height, stage.navigationBottom, now);
|
||||
if (TEST_BUILD && snapshot.sign !== null) {
|
||||
const presentation = sign.inspect(camera, width, height);
|
||||
if (presentation !== null) {
|
||||
window.dispatchEvent(new CustomEvent('portfolio-sign-frame', {
|
||||
detail: {
|
||||
now,
|
||||
...snapshot.sign,
|
||||
activeSectionId: snapshot.activeSectionId,
|
||||
direction: snapshot.direction,
|
||||
...presentation,
|
||||
rendererPixelRatio: renderer.getPixelRatio(),
|
||||
viewport: { width, height },
|
||||
},
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('portfolio-sign-frame', {
|
||||
detail: {
|
||||
now,
|
||||
...snapshot.sign,
|
||||
activeSectionId: snapshot.activeSectionId,
|
||||
direction: snapshot.direction,
|
||||
...presentation,
|
||||
rendererPixelRatio: renderer.getPixelRatio(),
|
||||
viewport: { width, height },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (postProcessor) {
|
||||
@@ -365,7 +487,19 @@ function dampPalette(
|
||||
current.sun.lerp(target.sun, mix);
|
||||
current.haze.lerp(target.haze, mix);
|
||||
current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix);
|
||||
current.terrainEnergy = THREE.MathUtils.lerp(current.terrainEnergy, target.terrainEnergy, mix);
|
||||
current.starVisibility = THREE.MathUtils.lerp(current.starVisibility, target.starVisibility, mix);
|
||||
current.streakEnergy = THREE.MathUtils.lerp(current.streakEnergy, target.streakEnergy, mix);
|
||||
current.terrainEnergy = THREE.MathUtils.lerp(
|
||||
current.terrainEnergy,
|
||||
target.terrainEnergy,
|
||||
mix,
|
||||
);
|
||||
current.starVisibility = THREE.MathUtils.lerp(
|
||||
current.starVisibility,
|
||||
target.starVisibility,
|
||||
mix,
|
||||
);
|
||||
current.streakEnergy = THREE.MathUtils.lerp(
|
||||
current.streakEnergy,
|
||||
target.streakEnergy,
|
||||
mix,
|
||||
);
|
||||
}
|
||||
|
||||
212
src/scene/world/expressway.ts
Normal file
212
src/scene/world/expressway.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
|
||||
|
||||
/** A bounded kit of physical roadside objects. Instance origins wrap behind the
|
||||
* camera; the entire object moves together, so long beams never tear at a seam. */
|
||||
export function createExpressway(
|
||||
resources: SceneResourceBag,
|
||||
quality: RenderQualitySettings,
|
||||
) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'expressway-architecture';
|
||||
const uniforms = {
|
||||
uDistance: { value: 0 },
|
||||
uEnergy: { value: 0 },
|
||||
uArchitecture: { value: 0 },
|
||||
uWarmth: { value: 0 },
|
||||
};
|
||||
const vertexShader = `
|
||||
uniform float uDistance;
|
||||
uniform float uArchitecture;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
void main() {
|
||||
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||
vec4 p = instanceMatrix * vec4(position, 1.0);
|
||||
p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z;
|
||||
p.y *= 1.0 + uArchitecture * 0.22;
|
||||
vNormal = normalize(mat3(instanceMatrix) * normal);
|
||||
vWorld = p.xyz;
|
||||
vec4 view = modelViewMatrix * p;
|
||||
vDepth = -view.z;
|
||||
gl_Position = projectionMatrix * view;
|
||||
}
|
||||
`;
|
||||
function material(luminous: boolean, coral = false) {
|
||||
return resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader,
|
||||
fragmentShader: `
|
||||
uniform float uEnergy;
|
||||
uniform float uWarmth;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
void main() {
|
||||
float farFade = 1.0 - smoothstep(380.0, 660.0, vDepth);
|
||||
float nearFade = smoothstep(0.0, 9.0, vDepth);
|
||||
vec3 cyan = vec3(0.15, 0.69, 0.76);
|
||||
vec3 coral = vec3(1.0, 0.18, 0.095);
|
||||
float facing = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.5, 0.9, 0.6))));
|
||||
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.015, 0.027, 0.044) + vec3(0.055, 0.10, 0.135) * pow(facing, 2.0)'};
|
||||
${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
|
||||
if(farFade * nearFade < 0.02) discard;
|
||||
gl_FragColor = vec4(color, farFade * nearFade);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: !luminous,
|
||||
depthTest: true,
|
||||
blending: luminous ? THREE.AdditiveBlending : THREE.NormalBlending,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const graphite = material(false);
|
||||
const cyan = material(true);
|
||||
const coral = material(true, true);
|
||||
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||
const dummy = new THREE.Object3D();
|
||||
type Part = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
sz: number;
|
||||
rz?: number;
|
||||
};
|
||||
function batch(
|
||||
name: string,
|
||||
parts: Part[],
|
||||
surface: ReturnType<typeof material>,
|
||||
) {
|
||||
const mesh = resources.own(
|
||||
'other',
|
||||
new THREE.InstancedMesh(box, surface, parts.length),
|
||||
);
|
||||
mesh.name = name;
|
||||
mesh.frustumCulled = false;
|
||||
parts.forEach((part, i) => {
|
||||
dummy.position.set(part.x, part.y, part.z);
|
||||
dummy.scale.set(part.sx, part.sy, part.sz);
|
||||
dummy.rotation.set(0, 0, part.rz ?? 0);
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(i, dummy.matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
group.add(mesh);
|
||||
}
|
||||
const posts: Part[] = [],
|
||||
reflectors: Part[] = [],
|
||||
rails: Part[] = [];
|
||||
// Full density retains a recognisable rhythm even at the lowest quality.
|
||||
const pairs = quality.postEffects ? 56 : 40;
|
||||
for (let i = 0; i < pairs; i++)
|
||||
for (const side of [-1, 1]) {
|
||||
const z = 30 - (i * WORLD_DEPTH) / pairs;
|
||||
posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 });
|
||||
reflectors.push({
|
||||
x: side * 9.48,
|
||||
y: 1.1,
|
||||
z: z + 0.18,
|
||||
sx: 0.14,
|
||||
sy: 0.22,
|
||||
sz: 0.03,
|
||||
});
|
||||
rails.push({
|
||||
x: side * 9.5,
|
||||
y: 0.56,
|
||||
z,
|
||||
sx: 0.16,
|
||||
sy: 0.18,
|
||||
sz: WORLD_DEPTH / pairs + 0.1,
|
||||
});
|
||||
}
|
||||
batch('barrier-posts', posts, graphite);
|
||||
batch('barrier-reflectors', reflectors, coral);
|
||||
batch('continuous-low-rails', rails, graphite);
|
||||
const fins: Part[] = [],
|
||||
lights: Part[] = [],
|
||||
feet: Part[] = [];
|
||||
for (let i = 0; i < 8; i++)
|
||||
for (const side of [-1, 1]) {
|
||||
const z = -24 - (i * WORLD_DEPTH) / 8;
|
||||
// The canted cap is the repeated silhouette: road furniture, not a tunnel.
|
||||
fins.push({ x: side * 12.6, y: 5.0, z, sx: 0.38, sy: 10, sz: 0.64 });
|
||||
fins.push({
|
||||
x: side * 10.45,
|
||||
y: 10.55,
|
||||
z,
|
||||
sx: 0.32,
|
||||
sy: 4.6,
|
||||
sz: 0.64,
|
||||
rz: side * 1.31,
|
||||
});
|
||||
lights.push({
|
||||
x: side * 12.36,
|
||||
y: 5.9,
|
||||
z: z + 0.34,
|
||||
sx: 0.075,
|
||||
sy: 7.5,
|
||||
sz: 0.035,
|
||||
});
|
||||
lights.push({
|
||||
x: side * 10.4,
|
||||
y: 10.4,
|
||||
z: z + 0.34,
|
||||
sx: 0.07,
|
||||
sy: 4.35,
|
||||
sz: 0.04,
|
||||
rz: side * 1.31,
|
||||
});
|
||||
feet.push({ x: side * 12.6, y: 0.24, z, sx: 1.1, sy: 0.48, sz: 1.5 });
|
||||
}
|
||||
batch('swept-signal-pylons', fins, graphite);
|
||||
batch('recessed-pylon-lights', lights, cyan);
|
||||
batch('pylon-foundations', feet, graphite);
|
||||
// Far roadside monolith pairs give the waves an occasional architectural edge.
|
||||
const landmarks: Part[] = [];
|
||||
for (let i = 0; i < 6; i++)
|
||||
for (const side of [-1, 1]) {
|
||||
const z = -110 - (i * WORLD_DEPTH) / 6;
|
||||
for (let j = 0; j < 3; j++) {
|
||||
landmarks.push({
|
||||
x: side * (34 + j * 4.5),
|
||||
y: (15 + j * 6) / 2,
|
||||
z: z - j * 3,
|
||||
sx: 1.2,
|
||||
sy: 15 + j * 6,
|
||||
sz: 2.6,
|
||||
rz: side * 0.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
batch('ridge-observatories', landmarks, graphite);
|
||||
let architecture = 0;
|
||||
return {
|
||||
group,
|
||||
update(
|
||||
distance: number,
|
||||
energy: number,
|
||||
warmth: number,
|
||||
section: SectionId,
|
||||
dt: number,
|
||||
) {
|
||||
const target =
|
||||
section === 'infrastructure' ? 1 : section === 'toolkit' ? 0.65 : 0;
|
||||
architecture += (target - architecture) * (1 - Math.exp(-dt * 0.7));
|
||||
uniforms.uDistance.value = distance;
|
||||
uniforms.uEnergy.value = energy;
|
||||
uniforms.uArchitecture.value = architecture;
|
||||
uniforms.uWarmth.value = warmth;
|
||||
},
|
||||
};
|
||||
}
|
||||
60
src/scene/world/horizon-air.ts
Normal file
60
src/scene/world/horizon-air.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette } from './palette';
|
||||
|
||||
/** Thin, spatially separated air masses. No screen-sized render targets. */
|
||||
export function createHorizonAir(resources: SceneResourceBag) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'horizon-air-masses';
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uWarmth: { value: 0 },
|
||||
uColor: { value: new THREE.Color(0xc5646b) },
|
||||
};
|
||||
const material = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform float uTime;
|
||||
uniform float uWarmth;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
void main(){
|
||||
float wave=sin(vUv.x*12.0+uTime*0.018)*0.055+sin(vUv.x*31.0-uTime*0.026)*0.018;
|
||||
float ribbon=exp(-pow((vUv.y-0.45-wave)*5.6,2.0));
|
||||
float thin=exp(-pow((vUv.y-0.51-wave)*37.0,2.0));
|
||||
float horizontal=pow(max(0.0,sin(vUv.x*3.14159)),2.0);
|
||||
vec3 color=mix(uColor,vec3(0.21,0.49,0.59),vUv.y*0.25);
|
||||
gl_FragColor=vec4(color,(ribbon*0.12+thin*0.026)*horizontal*(0.6+uWarmth*0.4));
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
}),
|
||||
);
|
||||
const geometry = resources.own('geometry', new THREE.PlaneGeometry(1450, 95));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.name = `stratified-air-${i + 1}`;
|
||||
mesh.position.set((i - 1) * 85, 15 + i * 11, -500 - i * 70);
|
||||
mesh.scale.set(1 - i * 0.12, 1 - i * 0.18, 1);
|
||||
group.add(mesh);
|
||||
}
|
||||
return {
|
||||
group,
|
||||
update(palette: Readonly<MutableChapterPalette>, time: number) {
|
||||
uniforms.uTime.value = time;
|
||||
uniforms.uWarmth.value = palette.warmth;
|
||||
uniforms.uColor.value.copy(palette.sun);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -48,15 +48,17 @@ export function createRoad(
|
||||
uSpeed: { value: 0 },
|
||||
uAcceleration: { value: 0 },
|
||||
uAsphalt: { value: new THREE.Color(0x03070f) },
|
||||
uAsphaltLift: { value: new THREE.Color(0x0b1927) },
|
||||
uAsphaltLift: { value: new THREE.Color(0x162535) },
|
||||
uEdgeColor: { value: new THREE.Color(0x64d9e7) },
|
||||
uLaneColor: { value: new THREE.Color(0xf1a35e) },
|
||||
uFogColor: { value: new THREE.Color(initialPalette.fog) },
|
||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||
};
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
const material = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
varying vec2 vRoadUv;
|
||||
varying float vRoadX;
|
||||
varying float vViewDistance;
|
||||
@@ -69,11 +71,11 @@ export function createRoad(
|
||||
vWorldZ = position.z;
|
||||
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
vViewDistance = -viewPosition.z;
|
||||
vNearFade = smoothstep(4.0, 18.0, vViewDistance);
|
||||
vNearFade = 0.7 + 0.3 * smoothstep(0.0, 12.0, vViewDistance);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
fragmentShader: `
|
||||
uniform float uDistance;
|
||||
uniform float uSpeed;
|
||||
uniform float uAcceleration;
|
||||
@@ -115,7 +117,7 @@ export function createRoad(
|
||||
* (1.0 - smoothstep(7.46, 7.62, roadX));
|
||||
float outerEdge = smoothstep(7.86, 7.98, roadX)
|
||||
* (1.0 - smoothstep(8.06, 8.18, roadX));
|
||||
float laneWidth = 1.0 - smoothstep(0.07, 0.15, roadX);
|
||||
float laneWidth = 1.0 - smoothstep(0.035, 0.07, abs(roadX - 0.17));
|
||||
float laneCycle = fract(travelZ / 18.0);
|
||||
float laneDash = smoothstep(0.05, 0.1, laneCycle)
|
||||
* (1.0 - smoothstep(0.48, 0.56, laneCycle));
|
||||
@@ -140,6 +142,37 @@ export function createRoad(
|
||||
color += uHazeColor * shoulderSeam * (0.035 + aggregate * 0.025);
|
||||
color += uLaneColor * centerLane * (0.86 + uSpeed * 0.18);
|
||||
|
||||
// Satin moisture breaks light into longitudinal streaks. All masks live
|
||||
// on the asphalt plane and follow the same world-space travel as marks.
|
||||
float fine = valueNoise(vec2(vRoadX * 52.0, travelZ * 4.0));
|
||||
float streak = valueNoise(vec2(vRoadX * 16.0, travelZ * 0.19));
|
||||
float film = mix(0.36, 1.0, streak) * (0.74 + 0.26 * fine);
|
||||
float grazing = smoothstep(8.0, 120.0, vViewDistance);
|
||||
color += vec3(0.027, 0.047, 0.063) * film * asphaltMask * (0.35 + grazing * 0.65);
|
||||
float channel = exp(-pow((roadX - 7.98) * 2.4, 2.0));
|
||||
color += uEdgeColor * channel * (0.06 + 0.10 * film);
|
||||
float shoulderRibs = step(0.77, fract(travelZ / 1.4))
|
||||
* smoothstep(8.28, 8.36, roadX) * (1.0 - smoothstep(8.85, 9.02, roadX));
|
||||
color += vec3(0.065, 0.10, 0.12) * shoulderRibs;
|
||||
float joint = 1.0 - smoothstep(0.02, 0.075, abs(mod(travelZ + 36.0, 72.0) - 36.0));
|
||||
color *= 1.0 - joint * 0.5;
|
||||
float studs = (1.0 - smoothstep(0.07, 0.14, abs(roadX - 7.53)))
|
||||
* (1.0 - smoothstep(0.10, 0.23, abs(mod(travelZ, 12.0) - 6.0)));
|
||||
color += uLaneColor * studs * 0.7;
|
||||
|
||||
// Rear lamp footprint: grows towards the camera from the actual rear axle.
|
||||
float behindCar = smoothstep(7.0, 8.8, vWorldZ);
|
||||
float tailLength = exp(-max(0.0, vWorldZ - 8.2) * 0.13);
|
||||
float tailWidth = exp(-pow(vRoadX / (2.25 + max(0.0, vWorldZ-8.0)*0.035), 6.0));
|
||||
float lampSegments = 0.60 + 0.40 * pow(sin(vRoadX * 8.7), 2.0);
|
||||
color += vec3(1.0, 0.035, 0.055) * behindCar * tailLength * tailWidth
|
||||
* film * lampSegments * (0.34 + uAcceleration * 0.14);
|
||||
float cyanReflection = exp(-pow((roadX-2.7) * 6.0, 2.0));
|
||||
color += uEdgeColor * behindCar * tailLength * cyanReflection * film * 0.4;
|
||||
float sunReflection = exp(-pow(vRoadX / (0.55 + vViewDistance * 0.011), 2.0))
|
||||
* smoothstep(36.0, 180.0, vViewDistance);
|
||||
color += vec3(0.8, 0.15, 0.08) * sunReflection * film * 0.17;
|
||||
|
||||
float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)
|
||||
* (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance))
|
||||
* exp(-roadX * 0.28);
|
||||
@@ -153,10 +186,11 @@ export function createRoad(
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
toneMapped: true,
|
||||
}));
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const surface = new THREE.Mesh(geometry, material);
|
||||
surface.name = 'road-surface';
|
||||
@@ -166,7 +200,8 @@ export function createRoad(
|
||||
return {
|
||||
group,
|
||||
update(distance, speed, acceleration, palette) {
|
||||
uniforms.uDistance.value = ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
|
||||
uniforms.uDistance.value =
|
||||
((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
|
||||
uniforms.uSpeed.value = clamp01(speed);
|
||||
uniforms.uAcceleration.value = clamp01(acceleration);
|
||||
if (palette) {
|
||||
|
||||
82
src/scene/world/sign-stage.ts
Normal file
82
src/scene/world/sign-stage.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
const stageOwners = new WeakMap<HTMLElement, object>();
|
||||
|
||||
/** Reserve real negative space in the document, below the navigation. The
|
||||
* canvas keeps its original stacking order. Content scrolls through this edge
|
||||
* exactly as it does through the top of the viewport; no content is removed. */
|
||||
export function createSignStage(resources: SceneResourceBag) {
|
||||
const main = document.querySelector<HTMLElement>('.portfolio-main');
|
||||
const nav = document.querySelector<HTMLElement>('.site-navigation');
|
||||
const root = document.documentElement;
|
||||
let dirty = true;
|
||||
let cutoff = 0;
|
||||
let stageBottom = 176;
|
||||
let navigationBottom = 66;
|
||||
const token = {};
|
||||
const mark = () => {
|
||||
dirty = true;
|
||||
};
|
||||
resources.listen(window, 'scroll', mark, { passive: true });
|
||||
resources.listen(window, 'resize', mark, { passive: true });
|
||||
resources.listen(document, 'focusin', (event) => {
|
||||
if (!(event.target instanceof HTMLElement) || !main?.contains(event.target))
|
||||
return;
|
||||
if (event.target.matches('h1,h2,main')) return;
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
const top = window.innerHeight < 560 ? navigationBottom + 10 : stageBottom;
|
||||
const bottom =
|
||||
window.innerHeight < 560 && window.innerWidth < 768
|
||||
? window.innerHeight - 164
|
||||
: window.innerHeight - 74;
|
||||
if (rect.top < top)
|
||||
window.scrollBy({ top: rect.top - top - 12, behavior: 'instant' });
|
||||
else if (rect.bottom > bottom)
|
||||
window.scrollBy({ top: rect.bottom - bottom + 12, behavior: 'instant' });
|
||||
});
|
||||
const observer = resources.observe(new ResizeObserver(mark));
|
||||
if (main) observer.observe(main);
|
||||
if (nav) observer.observe(nav);
|
||||
resources.own('other', {
|
||||
dispose() {
|
||||
if (!main || stageOwners.get(main) !== token) return;
|
||||
stageOwners.delete(main);
|
||||
main?.style.removeProperty('clip-path');
|
||||
root.style.removeProperty('--sign-stage-bottom');
|
||||
delete root.dataset.expressway;
|
||||
},
|
||||
});
|
||||
return {
|
||||
update(width: number, height: number) {
|
||||
if (dirty && main) {
|
||||
const rect = main.getBoundingClientRect();
|
||||
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
|
||||
const faceWidth = Math.min(
|
||||
width - 44,
|
||||
width < 900 ? 288 : 356,
|
||||
height < 650 ? 250 : 356,
|
||||
);
|
||||
stageBottom = navigationBottom + 24 + faceWidth / 3.18;
|
||||
// Gradually establish the strip while the opening leaves the viewport.
|
||||
const reveal = Math.min(
|
||||
1,
|
||||
Math.max(0, window.scrollY / Math.max(240, height * 0.58)),
|
||||
);
|
||||
const short = height < 560;
|
||||
const topEdge = short ? navigationBottom + 10 : stageBottom;
|
||||
cutoff = Math.max(0, topEdge * reveal - rect.top);
|
||||
const bottomCutoff =
|
||||
short && width < 768 ? Math.max(0, rect.bottom - (height - 164)) : 0;
|
||||
main.style.clipPath = `inset(${cutoff.toFixed(2)}px 0 ${bottomCutoff.toFixed(2)}px 0)`;
|
||||
root.style.setProperty(
|
||||
'--sign-stage-bottom',
|
||||
`${topEdge.toFixed(2)}px`,
|
||||
);
|
||||
root.dataset.expressway = 'live';
|
||||
stageOwners.set(main, token);
|
||||
dirty = false;
|
||||
}
|
||||
return { navigationBottom, stageBottom };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import * as THREE from 'three';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import type { SignSnapshot } from '@/journey/types';
|
||||
import {
|
||||
SIGN_LIFECYCLE,
|
||||
signRouteFor,
|
||||
signViewportProfileFor,
|
||||
type SignCorridor,
|
||||
@@ -11,106 +10,15 @@ import {
|
||||
} from '@/journey/sign-routes';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
|
||||
interface SignPose {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
interface SignPoseSet {
|
||||
horizon: SignPose;
|
||||
dwell: SignPose;
|
||||
}
|
||||
|
||||
interface ProjectingCamera {
|
||||
position: { x: number; y: number; z: number };
|
||||
rotation: { y: number };
|
||||
updateMatrixWorld(force: boolean): void;
|
||||
}
|
||||
|
||||
const SIGN_MARKER_FONT = '560 46px "Geist Mono Variable", ui-monospace, monospace';
|
||||
const SIGN_LABEL_FONT = '660 164px "Geist Mono Variable", ui-monospace, monospace';
|
||||
const SHORT_CORRIDOR_REFERENCE_ASPECT = 1.58;
|
||||
const SHORT_CORRIDOR_BASE_RELIEF = 20;
|
||||
const SHORT_CORRIDOR_RELIEF_PER_ASPECT = 20;
|
||||
const SHORT_CORRIDOR_MINIMUM_RELIEF = 12;
|
||||
const SHORT_CORRIDOR_MAXIMUM_RELIEF = 34;
|
||||
const RESPONSIVE_REFLOW_MS = 320;
|
||||
const PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH = 480;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_WIDTH = 720;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_SCALE = 0.44;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_Y = 4.5;
|
||||
|
||||
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
|
||||
wide: {
|
||||
left: {
|
||||
horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
|
||||
dwell: { x: -11.9, y: 7.95, z: -16, scale: 1.36 },
|
||||
},
|
||||
right: {
|
||||
horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
|
||||
dwell: { x: 12.8, y: 7.95, z: -16, scale: 1.36 },
|
||||
},
|
||||
'outer-left': {
|
||||
horizon: { x: -21.5, y: 7.4, z: -168, scale: 0.2 },
|
||||
dwell: { x: -30.4, y: 8.65, z: -24, scale: 1.46 },
|
||||
},
|
||||
'outer-right': {
|
||||
horizon: { x: 26.5, y: 7.4, z: -168, scale: 0.2 },
|
||||
dwell: { x: 41.8, y: 8.65, z: -24, scale: 1.46 },
|
||||
},
|
||||
},
|
||||
tall: {
|
||||
left: {
|
||||
horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
|
||||
dwell: { x: -5.4, y: 8.15, z: -24, scale: 1.4 },
|
||||
},
|
||||
right: {
|
||||
horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
|
||||
dwell: { x: 5.4, y: 8.15, z: -24, scale: 1.4 },
|
||||
},
|
||||
'outer-left': {
|
||||
horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
|
||||
dwell: { x: -7.6, y: 8.8, z: -30, scale: 1.48 },
|
||||
},
|
||||
'outer-right': {
|
||||
horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
|
||||
dwell: { x: 7.6, y: 8.8, z: -30, scale: 1.48 },
|
||||
},
|
||||
},
|
||||
short: {
|
||||
left: {
|
||||
horizon: { x: -7.8, y: 6.6, z: -144, scale: 0.18 },
|
||||
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.62 },
|
||||
},
|
||||
right: {
|
||||
horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
|
||||
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.62 },
|
||||
},
|
||||
'outer-left': {
|
||||
horizon: { x: -14.2, y: 6.8, z: -154, scale: 0.18 },
|
||||
dwell: { x: -26.5, y: 5.8, z: -32, scale: 2.3 },
|
||||
},
|
||||
'outer-right': {
|
||||
horizon: { x: 14.2, y: 6.8, z: -154, scale: 0.18 },
|
||||
dwell: { x: 26.5, y: 5.8, z: -32, scale: 2.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PORTRAIT_NAVIGATION_DWELL: Readonly<Record<SignCorridor, SignPose>> = {
|
||||
left: { x: -2.9, y: 3, z: 8, scale: 0.25 },
|
||||
right: { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||
'outer-left': { x: -2.9, y: 3, z: 8, scale: 0.25 },
|
||||
'outer-right': { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||
};
|
||||
|
||||
const EXPERIENCE_PASSIVE_DWELL: Readonly<Partial<Record<SignViewportProfile, SignPose>>> = {
|
||||
wide: { x: 12.8, y: 3.7, z: 0, scale: 0.9 },
|
||||
// The near short pose counterbalances the shared aspect relief while keeping
|
||||
// the projected face in the right-hand gap between both visible panels.
|
||||
short: { x: -7.7, y: 3.3, z: -4, scale: 1.4 },
|
||||
};
|
||||
|
||||
const SIGN_MARKER_FONT =
|
||||
'560 46px "Geist Mono Variable", ui-monospace, monospace';
|
||||
const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
|
||||
export interface SignWorld {
|
||||
group: SignGroup;
|
||||
inspect(
|
||||
@@ -118,6 +26,13 @@ export interface SignWorld {
|
||||
width: number,
|
||||
height: number,
|
||||
): SignPresentation | null;
|
||||
compose(
|
||||
camera: ProjectingCamera,
|
||||
width: number,
|
||||
height: number,
|
||||
navigationBottom: number,
|
||||
now: number,
|
||||
): void;
|
||||
setViewportAspect(aspect: number): void;
|
||||
setViewportSize(width: number, height: number): void;
|
||||
update(sign: SignSnapshot | null, now?: number): void;
|
||||
@@ -151,6 +66,7 @@ export interface SignRenderOptions {
|
||||
|
||||
interface SignGroup {
|
||||
visible: boolean;
|
||||
userData: Record<string, unknown>;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -170,7 +86,9 @@ export function routeLabelFor(id: SectionId): string {
|
||||
return signRouteFor(id).label;
|
||||
}
|
||||
|
||||
export async function prepareSignTypography(signal: AbortSignal): Promise<void> {
|
||||
export async function prepareSignTypography(
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const fonts = document.fonts;
|
||||
if (fonts === undefined) return;
|
||||
await Promise.all([
|
||||
@@ -178,7 +96,8 @@ export async function prepareSignTypography(signal: AbortSignal): Promise<void>
|
||||
fonts.load(SIGN_LABEL_FONT),
|
||||
fonts.ready,
|
||||
]);
|
||||
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||
if (signal.aborted)
|
||||
throw new DOMException('Scene build aborted', 'AbortError');
|
||||
}
|
||||
|
||||
export function createSign(
|
||||
@@ -189,52 +108,80 @@ export function createSign(
|
||||
group.name = 'roadside-sign';
|
||||
group.visible = false;
|
||||
|
||||
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x08131c,
|
||||
roughness: 0.5,
|
||||
metalness: 0.7,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x172731,
|
||||
roughness: 0.72,
|
||||
metalness: 0.38,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const facePanelMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x061018,
|
||||
roughness: 0.9,
|
||||
metalness: 0.12,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x21343e,
|
||||
roughness: 0.56,
|
||||
metalness: 0.68,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x39747a,
|
||||
emissive: 0x55b9c0,
|
||||
emissiveIntensity: 0.42,
|
||||
roughness: 0.42,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x8e6446,
|
||||
emissive: 0xb9723f,
|
||||
emissiveIntensity: 0.28,
|
||||
roughness: 0.48,
|
||||
metalness: 0.42,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const housingMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x334956,
|
||||
emissive: 0x23404e,
|
||||
emissiveIntensity: 0.6,
|
||||
roughness: 0.28,
|
||||
metalness: 0.7,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
}),
|
||||
);
|
||||
const backplateMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x172e3c,
|
||||
roughness: 0.72,
|
||||
metalness: 0.38,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
}),
|
||||
);
|
||||
const facePanelMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x061018,
|
||||
roughness: 0.9,
|
||||
metalness: 0.12,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
}),
|
||||
);
|
||||
const supportMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x21343e,
|
||||
emissive: 0x163845,
|
||||
emissiveIntensity: 0.4,
|
||||
roughness: 0.56,
|
||||
metalness: 0.68,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
}),
|
||||
);
|
||||
const edgeMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x39747a,
|
||||
emissive: 0x55b9c0,
|
||||
emissiveIntensity: 1.1,
|
||||
roughness: 0.42,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
}),
|
||||
);
|
||||
const routeMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x8e6446,
|
||||
emissive: 0xb9723f,
|
||||
emissiveIntensity: 0.8,
|
||||
roughness: 0.48,
|
||||
metalness: 0.42,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
}),
|
||||
);
|
||||
|
||||
const housing = new THREE.Mesh(
|
||||
resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)),
|
||||
@@ -275,18 +222,22 @@ export function createSign(
|
||||
8,
|
||||
Math.max(1, Math.floor(options.maximumAnisotropy ?? 8)),
|
||||
);
|
||||
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.FrontSide,
|
||||
map: texture,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
}));
|
||||
const faceMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
alphaTest: 0.02,
|
||||
side: THREE.FrontSide,
|
||||
map: texture,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
}),
|
||||
);
|
||||
const face = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
|
||||
faceMaterial,
|
||||
@@ -299,14 +250,33 @@ export function createSign(
|
||||
|
||||
const edgeLight = new THREE.Group();
|
||||
edgeLight.name = 'sign-edge-light';
|
||||
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.045, 1.56, 0.045));
|
||||
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.18, 0.04, 0.045));
|
||||
const edgeGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(0.045, 1.56, 0.045),
|
||||
);
|
||||
const upperEdgeGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(7.6, 0.035, 0.045),
|
||||
);
|
||||
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||
leftEdge.position.set(-3.9, 0, 0.57);
|
||||
edgeLight.add(leftEdge);
|
||||
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
||||
upperEdge.position.set(-3.33, 1, 0.57);
|
||||
upperEdge.position.set(0, 1.14, 0.57);
|
||||
edgeLight.add(upperEdge);
|
||||
const lowerEdge = new THREE.Mesh(upperEdgeGeometry, routeMaterial);
|
||||
lowerEdge.position.set(0, -1.14, 0.57);
|
||||
edgeLight.add(lowerEdge);
|
||||
const finGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(0.055, 1.86, 0.48),
|
||||
);
|
||||
for (const side of [-1, 1])
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const fin = new THREE.Mesh(finGeometry, supportMaterial);
|
||||
fin.position.set(side * (4.26 + index * 0.12), 0, -0.04);
|
||||
edgeLight.add(fin);
|
||||
}
|
||||
group.add(edgeLight);
|
||||
|
||||
const routeAccent = new THREE.Mesh(
|
||||
@@ -319,7 +289,10 @@ export function createSign(
|
||||
|
||||
const backRails = new THREE.Group();
|
||||
backRails.name = 'sign-back-rails';
|
||||
const backRailGeometry = resources.own('geometry', new THREE.BoxGeometry(7.15, 0.13, 0.16));
|
||||
const backRailGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(7.15, 0.13, 0.16),
|
||||
);
|
||||
for (const y of [-0.68, 0.68]) {
|
||||
const rail = new THREE.Mesh(backRailGeometry, supportMaterial);
|
||||
rail.position.set(0, y, -0.38);
|
||||
@@ -327,11 +300,11 @@ export function createSign(
|
||||
}
|
||||
group.add(backRails);
|
||||
|
||||
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.22, 5.05, 0.26));
|
||||
const post = new THREE.Mesh(
|
||||
postGeometry,
|
||||
supportMaterial,
|
||||
const postGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(0.22, 5.05, 0.26),
|
||||
);
|
||||
const post = new THREE.Mesh(postGeometry, supportMaterial);
|
||||
post.name = 'sign-post';
|
||||
post.position.set(2.62, -3.58, -0.38);
|
||||
group.add(post);
|
||||
@@ -350,7 +323,10 @@ export function createSign(
|
||||
);
|
||||
arm.position.set(0, -1.24, -0.38);
|
||||
bracket.add(arm);
|
||||
const braceGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 1.18, 0.14));
|
||||
const braceGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.BoxGeometry(0.12, 1.18, 0.14),
|
||||
);
|
||||
for (const side of [-1, 1]) {
|
||||
const brace = new THREE.Mesh(braceGeometry, supportMaterial);
|
||||
brace.position.set(side * 2.18, -1.68, -0.38);
|
||||
@@ -361,7 +337,10 @@ export function createSign(
|
||||
|
||||
const fasteners = new THREE.Group();
|
||||
fasteners.name = 'sign-fasteners';
|
||||
const fastenerGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8));
|
||||
const fastenerGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8),
|
||||
);
|
||||
for (const x of [-3.72, 3.72]) {
|
||||
for (const y of [-0.78, 0.78]) {
|
||||
const fastener = new THREE.Mesh(fastenerGeometry, supportMaterial);
|
||||
@@ -374,7 +353,10 @@ export function createSign(
|
||||
|
||||
const footing = new THREE.Group();
|
||||
footing.name = 'sign-footing';
|
||||
const footingGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8));
|
||||
const footingGeometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8),
|
||||
);
|
||||
for (const x of [-2.62, 2.62]) {
|
||||
const base = new THREE.Mesh(footingGeometry, supportMaterial);
|
||||
base.position.x = x;
|
||||
@@ -383,6 +365,28 @@ export function createSign(
|
||||
footing.position.set(0, -6.18, -0.38);
|
||||
group.add(footing);
|
||||
|
||||
const sweepMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: { uSweep: { value: -1 }, uOpacity: { value: 0 } },
|
||||
vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
|
||||
fragmentShader: `uniform float uSweep;uniform float uOpacity;varying vec2 vUv;
|
||||
void main(){float beam=exp(-pow((vUv.x+vUv.y*0.14-uSweep)*12.0,2.0));
|
||||
gl_FragColor=vec4(0.36,0.82,0.9,beam*uOpacity);}`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
}),
|
||||
);
|
||||
const sweep = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
|
||||
sweepMaterial,
|
||||
);
|
||||
sweep.name = 'sign-illumination-sweep';
|
||||
sweep.position.z = 0.56;
|
||||
group.add(sweep);
|
||||
|
||||
const fadeMaterials = [
|
||||
[housingMaterial, 0.97],
|
||||
[backplateMaterial, 1],
|
||||
@@ -392,166 +396,76 @@ export function createSign(
|
||||
[routeMaterial, 0.82],
|
||||
[faceMaterial, 1],
|
||||
] as const;
|
||||
const departureOrigin = new THREE.Vector3();
|
||||
const poseTransitionOrigin = new THREE.Vector3();
|
||||
const poseTransitionDestination = new THREE.Vector3();
|
||||
let departureScale = 1;
|
||||
let poseTransitionOriginScale = 1;
|
||||
let poseTransitionOriginRotationY = 0;
|
||||
let poseTransitionPending = false;
|
||||
let poseTransitionStartedAt: number | null = null;
|
||||
let target: SectionId | null = null;
|
||||
let pendingViewport: SignViewportProfile = 'wide';
|
||||
let activeViewport: SignViewportProfile = 'wide';
|
||||
let pendingAspect = 16 / 9;
|
||||
let activeAspect = 16 / 9;
|
||||
let pendingViewportWidth = 1_440;
|
||||
let activeViewportWidth = 1_440;
|
||||
let activeNavigationArrival = false;
|
||||
let snapshot: SignSnapshot | null = null;
|
||||
let viewport: SignViewportProfile = 'wide';
|
||||
let activeCorridor: SignCorridor = 'right';
|
||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||
let renderedOpacity = 0;
|
||||
const faceCornerCoordinates = [
|
||||
[-4, -1, 0],
|
||||
[4, -1, 0],
|
||||
[4, 1, 0],
|
||||
[-4, 1, 0],
|
||||
] as const;
|
||||
const projectedCorner = new THREE.Vector3();
|
||||
|
||||
const updateTexture = (id: SectionId): void => {
|
||||
if (target === id) return;
|
||||
target = id;
|
||||
face.userData.textureUpdateCount += 1;
|
||||
face.userData.fontReadyWhenDrawn = document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
||||
if (context) drawLabel(context, signRouteFor(id), canvas.width, canvas.height);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
|
||||
const applyOpacity = (opacity: number): void => {
|
||||
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
|
||||
};
|
||||
|
||||
const shortCorridorRelief = (): number => activeViewport === 'short'
|
||||
? THREE.MathUtils.clamp(
|
||||
SHORT_CORRIDOR_BASE_RELIEF
|
||||
+ (activeAspect - SHORT_CORRIDOR_REFERENCE_ASPECT)
|
||||
* SHORT_CORRIDOR_RELIEF_PER_ASPECT,
|
||||
SHORT_CORRIDOR_MINIMUM_RELIEF,
|
||||
SHORT_CORRIDOR_MAXIMUM_RELIEF,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const resolvedPoseX = (
|
||||
pose: SignPose,
|
||||
side: number,
|
||||
weight: number,
|
||||
routeOffsetX = 0,
|
||||
): number => pose.x + side * shortCorridorRelief() * weight + routeOffsetX * weight;
|
||||
|
||||
const applyPose = (
|
||||
pose: SignPose,
|
||||
side: number,
|
||||
lateralWeight: number,
|
||||
routeOffsetX = 0,
|
||||
scale = pose.scale,
|
||||
y = pose.y,
|
||||
): void => {
|
||||
group.position.set(
|
||||
resolvedPoseX(pose, side, lateralWeight, routeOffsetX),
|
||||
y,
|
||||
pose.z,
|
||||
);
|
||||
group.scale.setScalar(scale);
|
||||
plantMounting(y, scale);
|
||||
};
|
||||
|
||||
const portraitNavigationWidthProgress = (): number => smoothstep(clamp01(
|
||||
(activeViewportWidth - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH)
|
||||
/ (PORTRAIT_NAVIGATION_MEDIUM_WIDTH - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH),
|
||||
));
|
||||
|
||||
const portraitNavigationScale = (phoneScale: number): number => THREE.MathUtils.lerp(
|
||||
phoneScale,
|
||||
PORTRAIT_NAVIGATION_MEDIUM_SCALE,
|
||||
portraitNavigationWidthProgress(),
|
||||
);
|
||||
|
||||
const portraitNavigationY = (phoneY: number): number => THREE.MathUtils.lerp(
|
||||
phoneY,
|
||||
PORTRAIT_NAVIGATION_MEDIUM_Y,
|
||||
portraitNavigationWidthProgress(),
|
||||
);
|
||||
|
||||
const plantMounting = (groupY: number, groupScale: number): void => {
|
||||
const safeScale = Math.max(0.001, groupScale);
|
||||
const footingY = -groupY / safeScale;
|
||||
const postTopY = -1.36;
|
||||
const postHeight = Math.max(0.36, postTopY - footingY);
|
||||
for (const mountedPost of mountedPosts) {
|
||||
mountedPost.position.y = (postTopY + footingY) * 0.5;
|
||||
mountedPost.scale.set(1, postHeight / 5.05, 1);
|
||||
let viewportWidth = 1440;
|
||||
let viewportHeight = 900;
|
||||
let previousNow = 0;
|
||||
let screenX = 0.5;
|
||||
let screenY = 120;
|
||||
let screenWidth = 340;
|
||||
let initialized = false;
|
||||
const point = new THREE.Vector3();
|
||||
const ray = new THREE.Vector3();
|
||||
const forward = new THREE.Vector3();
|
||||
const worldRight = new THREE.Vector3();
|
||||
const worldLeft = new THREE.Vector3();
|
||||
const corner = new THREE.Vector3();
|
||||
const corners = [
|
||||
[-4, -1],
|
||||
[4, -1],
|
||||
[4, 1],
|
||||
[-4, 1],
|
||||
];
|
||||
function plantMounting() {
|
||||
const footY = -group.position.y / Math.max(0.001, group.scale.x);
|
||||
const height = Math.max(0.36, -1.36 - footY);
|
||||
for (const post of mountedPosts) {
|
||||
post.position.y = (-1.36 + footY) * 0.5;
|
||||
post.scale.set(1, height / 5.05, 1);
|
||||
}
|
||||
footing.position.y = footingY;
|
||||
footing.scale.setScalar(1);
|
||||
};
|
||||
|
||||
const stagePoseTransition = (): void => {
|
||||
if (previousPhase === null || previousPhase === 'depart') return;
|
||||
poseTransitionOrigin.copy(group.position);
|
||||
poseTransitionOriginScale = group.scale.x;
|
||||
poseTransitionOriginRotationY = group.rotation.y;
|
||||
poseTransitionPending = true;
|
||||
poseTransitionStartedAt = null;
|
||||
};
|
||||
|
||||
const stageResponsiveViewport = (
|
||||
viewport: SignViewportProfile,
|
||||
aspect: number,
|
||||
width: number,
|
||||
): void => {
|
||||
const changed = viewport !== pendingViewport
|
||||
|| Math.abs(aspect - pendingAspect) > 0.001
|
||||
|| Math.abs(width - pendingViewportWidth) > 0.5;
|
||||
if (changed) stagePoseTransition();
|
||||
pendingViewport = viewport;
|
||||
pendingAspect = aspect;
|
||||
pendingViewportWidth = width;
|
||||
if (previousPhase === null) {
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
}
|
||||
};
|
||||
|
||||
footing.position.y = footY;
|
||||
}
|
||||
function worldPoint(
|
||||
out: typeof point,
|
||||
x: number,
|
||||
y: number,
|
||||
depth: number,
|
||||
camera: ProjectingCamera,
|
||||
) {
|
||||
out
|
||||
.set((x / viewportWidth) * 2 - 1, 1 - (y / viewportHeight) * 2, 0.5)
|
||||
.unproject(camera);
|
||||
ray.copy(out).sub(camera.position).normalize();
|
||||
out
|
||||
.copy(camera.position)
|
||||
.addScaledVector(ray, depth / Math.max(0.05, ray.dot(forward)));
|
||||
}
|
||||
return {
|
||||
group,
|
||||
inspect(camera, width, height) {
|
||||
if (target === null || width <= 0 || height <= 0) return null;
|
||||
if (target === null) return null;
|
||||
group.updateMatrixWorld(true);
|
||||
camera.updateMatrixWorld(true);
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
let top = Number.POSITIVE_INFINITY;
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
let bottom = Number.NEGATIVE_INFINITY;
|
||||
for (const [xPosition, yPosition, zPosition] of faceCornerCoordinates) {
|
||||
projectedCorner
|
||||
.set(xPosition, yPosition, zPosition)
|
||||
.applyMatrix4(face.matrixWorld)
|
||||
.project(camera);
|
||||
const x = (projectedCorner.x + 1) * 0.5 * width;
|
||||
const y = (1 - projectedCorner.y) * 0.5 * height;
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y);
|
||||
right = Math.max(right, x);
|
||||
bottom = Math.max(bottom, y);
|
||||
let left = Infinity,
|
||||
top = Infinity,
|
||||
right = -Infinity,
|
||||
bottom = -Infinity;
|
||||
for (const [x, y] of corners) {
|
||||
corner.set(x, y, 0).applyMatrix4(face.matrixWorld).project(camera);
|
||||
const px = ((corner.x + 1) * width) / 2,
|
||||
py = ((1 - corner.y) * height) / 2;
|
||||
left = Math.min(left, px);
|
||||
right = Math.max(right, px);
|
||||
top = Math.min(top, py);
|
||||
bottom = Math.max(bottom, py);
|
||||
}
|
||||
return {
|
||||
visible: group.visible,
|
||||
corridor: activeCorridor,
|
||||
viewportProfile: activeViewport,
|
||||
viewportProfile: viewport,
|
||||
bounds: {
|
||||
left,
|
||||
top,
|
||||
@@ -570,177 +484,139 @@ export function createSign(
|
||||
};
|
||||
},
|
||||
setViewportAspect(aspect) {
|
||||
const nextAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
||||
stageResponsiveViewport(
|
||||
nextAspect < 0.85 ? 'tall' : 'wide',
|
||||
nextAspect,
|
||||
pendingViewportWidth,
|
||||
);
|
||||
viewport = aspect < 1.05 ? 'tall' : 'wide';
|
||||
},
|
||||
setViewportSize(width, height) {
|
||||
const nextAspect = Number.isFinite(width) && Number.isFinite(height) && height > 0
|
||||
? Math.max(1, width) / height
|
||||
: 1;
|
||||
stageResponsiveViewport(
|
||||
signViewportProfileFor(width, height),
|
||||
nextAspect,
|
||||
Number.isFinite(width) ? Math.max(1, width) : 1,
|
||||
);
|
||||
viewportWidth = Math.max(1, width);
|
||||
viewportHeight = Math.max(1, height);
|
||||
viewport = signViewportProfileFor(width, height);
|
||||
},
|
||||
update(sign, now) {
|
||||
update(sign) {
|
||||
snapshot = sign;
|
||||
group.userData.phase = sign?.phase ?? 'inactive';
|
||||
group.userData.target = sign?.targetId ?? null;
|
||||
if (!sign) {
|
||||
group.visible = false;
|
||||
applyOpacity(0);
|
||||
renderedOpacity = 0;
|
||||
previousPhase = null;
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
activeNavigationArrival = false;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
initialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
target !== null
|
||||
&& target !== sign.targetId
|
||||
&& renderedOpacity > SIGN_LIFECYCLE.concealedOpacity
|
||||
) return;
|
||||
const navigationArrival = sign.navigationArrival === true;
|
||||
if (target !== sign.targetId) {
|
||||
// The renderer owns projection. A quality-tier change can rebuild this world
|
||||
// while a lifecycle is already visible, so its latched profile may describe
|
||||
// the generation that was just disposed rather than this canvas.
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
activeNavigationArrival = navigationArrival;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
updateTexture(sign.targetId);
|
||||
} else if (navigationArrival !== activeNavigationArrival) {
|
||||
stagePoseTransition();
|
||||
activeNavigationArrival = navigationArrival;
|
||||
target = sign.targetId;
|
||||
face.userData.textureUpdateCount += 1;
|
||||
face.userData.fontReadyWhenDrawn =
|
||||
document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
||||
if (context)
|
||||
drawLabel(
|
||||
context,
|
||||
signRouteFor(sign.targetId),
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
texture.needsUpdate = true;
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
if (poseTransitionPending && sign.phase === 'depart') {
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
} else if (poseTransitionPending) {
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
if (poseTransitionStartedAt === null) {
|
||||
poseTransitionStartedAt = Number.isFinite(now) ? now! : 0;
|
||||
}
|
||||
}
|
||||
|
||||
const opacity = clamp01(sign.opacity);
|
||||
const progress = smoothstep(clamp01(sign.progress));
|
||||
const route = signRouteFor(sign.targetId);
|
||||
const corridor = route.corridor ?? 'right';
|
||||
activeCorridor = corridor;
|
||||
const poseSet = SIGN_POSES[activeViewport][corridor];
|
||||
const horizon = poseSet.horizon;
|
||||
let dwell = activeViewport === 'tall' && activeNavigationArrival
|
||||
? PORTRAIT_NAVIGATION_DWELL[corridor]
|
||||
: poseSet.dwell;
|
||||
if (!activeNavigationArrival && sign.targetId === 'experience') {
|
||||
dwell = EXPERIENCE_PASSIVE_DWELL[activeViewport] ?? dwell;
|
||||
}
|
||||
const dwellScale = activeViewport === 'tall' && activeNavigationArrival
|
||||
? portraitNavigationScale(dwell.scale)
|
||||
: dwell.scale;
|
||||
const dwellY = activeViewport === 'tall' && activeNavigationArrival
|
||||
? portraitNavigationY(dwell.y)
|
||||
: dwell.y;
|
||||
const side = corridor.includes('left') ? -1 : 1;
|
||||
const routeOffsetX = route.dwellOffsetX ?? 0;
|
||||
|
||||
if (sign.phase === 'anticipate') {
|
||||
applyPose(horizon, side, 0.35);
|
||||
} else if (sign.phase === 'approach') {
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(
|
||||
resolvedPoseX(horizon, side, 0.35),
|
||||
resolvedPoseX(dwell, side, 1, routeOffsetX),
|
||||
progress,
|
||||
),
|
||||
THREE.MathUtils.lerp(horizon.y, dwellY, progress),
|
||||
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
||||
},
|
||||
compose(camera, width, height, navigationBottom, now) {
|
||||
if (!snapshot) return;
|
||||
viewportWidth = width;
|
||||
viewportHeight = height;
|
||||
camera.updateMatrixWorld(true);
|
||||
forward
|
||||
.set(0, 0, -1)
|
||||
.transformDirection(
|
||||
(camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
|
||||
);
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwellScale, progress));
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
} else if (sign.phase === 'dwell') {
|
||||
applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY);
|
||||
} else {
|
||||
if (previousPhase !== 'depart') {
|
||||
if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY);
|
||||
departureOrigin.copy(group.position);
|
||||
departureScale = group.scale.x;
|
||||
}
|
||||
const outerCorridor = corridor.startsWith('outer');
|
||||
const lateralExit = activeViewport === 'wide'
|
||||
? outerCorridor ? 2.6 : 3.4
|
||||
: activeViewport === 'short' ? 2.2 : 2.4;
|
||||
group.position.set(
|
||||
departureOrigin.x + progress * side * lateralExit,
|
||||
departureOrigin.y,
|
||||
THREE.MathUtils.lerp(departureOrigin.z, 18, progress),
|
||||
);
|
||||
group.scale.setScalar(departureScale);
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
const route = signRouteFor(snapshot.targetId);
|
||||
activeCorridor = route.corridor ?? 'right';
|
||||
const side = activeCorridor.includes('left') ? -1 : 1;
|
||||
const compact = width < 900;
|
||||
const shortSide = height < 560 && width >= 768;
|
||||
const lowerStrip = height < 560 && !shortSide;
|
||||
const targetWidth = Math.min(
|
||||
width - 44,
|
||||
compact ? 288 : 356,
|
||||
height < 650 ? 250 : 356,
|
||||
);
|
||||
const targetX = shortSide ? 0.82 : compact ? 0.5 : side < 0 ? 0.23 : 0.77;
|
||||
const targetY = shortSide
|
||||
? navigationBottom + 32 + (height - navigationBottom - 96) * 0.25
|
||||
: lowerStrip
|
||||
? height - 112
|
||||
: navigationBottom + 12 + targetWidth / 3.18 / 2;
|
||||
const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
|
||||
previousNow = now;
|
||||
const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
|
||||
screenX += (targetX - screenX) * mix;
|
||||
screenY += (targetY - screenY) * mix;
|
||||
screenWidth += (targetWidth - screenWidth) * mix;
|
||||
initialized = true;
|
||||
const p = smoothstep(clamp01(snapshot.progress));
|
||||
let arrival =
|
||||
snapshot.phase === 'anticipate'
|
||||
? 0
|
||||
: snapshot.phase === 'approach'
|
||||
? p
|
||||
: 1;
|
||||
const closeDepth = lowerStrip ? 12 : 18;
|
||||
let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
|
||||
let cx = THREE.MathUtils.lerp(0.5 + side * 0.1, screenX, arrival) * width;
|
||||
let cy = THREE.MathUtils.lerp(
|
||||
height * 0.43,
|
||||
screenY,
|
||||
lowerStrip ? arrival * arrival * arrival : arrival,
|
||||
);
|
||||
let projectedWidth = screenWidth * THREE.MathUtils.lerp(0.1, 1, arrival);
|
||||
if (snapshot.phase === 'depart') {
|
||||
depth = THREE.MathUtils.lerp(closeDepth, lowerStrip ? 4 : 12, p);
|
||||
cx += side * p * width * 0.3;
|
||||
projectedWidth *= 1 + p * 0.3;
|
||||
}
|
||||
|
||||
const desiredRotationY = -side * (activeViewport === 'wide' ? 0.14 : 0.1);
|
||||
if (poseTransitionPending) {
|
||||
const elapsed = Number.isFinite(now)
|
||||
? Math.max(0, now! - (poseTransitionStartedAt ?? now!))
|
||||
: RESPONSIVE_REFLOW_MS;
|
||||
const mix = smoothstep(clamp01(elapsed / RESPONSIVE_REFLOW_MS));
|
||||
poseTransitionDestination.copy(group.position);
|
||||
const poseTransitionDestinationScale = group.scale.x;
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.x, poseTransitionDestination.x, mix),
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.y, poseTransitionDestination.y, mix),
|
||||
THREE.MathUtils.lerp(poseTransitionOrigin.z, poseTransitionDestination.z, mix),
|
||||
if (snapshot.phase !== 'depart') {
|
||||
projectedWidth = Math.min(width - 44, projectedWidth);
|
||||
cx = THREE.MathUtils.clamp(
|
||||
cx,
|
||||
projectedWidth / 2 + 14,
|
||||
width - projectedWidth / 2 - 14,
|
||||
);
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(
|
||||
poseTransitionOriginScale,
|
||||
poseTransitionDestinationScale,
|
||||
mix,
|
||||
));
|
||||
group.rotation.y = THREE.MathUtils.lerp(
|
||||
poseTransitionOriginRotationY,
|
||||
desiredRotationY,
|
||||
mix,
|
||||
);
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
if (mix >= 1) {
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
}
|
||||
} else {
|
||||
group.rotation.y = desiredRotationY;
|
||||
}
|
||||
applyOpacity(opacity);
|
||||
worldPoint(point, cx, cy, depth, camera);
|
||||
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
|
||||
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
|
||||
if (lowerStrip) point.y = Math.max(1.4, point.y);
|
||||
group.position.set(point.x, point.y, point.z);
|
||||
group.scale.setScalar(worldRight.distanceTo(worldLeft) / 8.64);
|
||||
group.rotation.y = camera.rotation.y - side * 0.065;
|
||||
plantMounting();
|
||||
const opacity = clamp01(snapshot.opacity);
|
||||
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
|
||||
// The frame catches light first; one moving highlight resolves into a quiet face.
|
||||
faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
|
||||
edgeMaterial.emissiveIntensity = 0.8 + Math.sin(arrival * Math.PI) * 1.4;
|
||||
sweepMaterial.uniforms.uSweep.value = arrival * 1.5 - 0.2;
|
||||
sweepMaterial.uniforms.uOpacity.value =
|
||||
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
|
||||
group.visible = opacity > 0;
|
||||
renderedOpacity = opacity;
|
||||
previousPhase = sign.phase;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSignBody(width: number, height: number, depth: number, cut: number) {
|
||||
const geometry = new THREE.ExtrudeGeometry(createClippedShape(width, height, cut), {
|
||||
depth,
|
||||
bevelEnabled: true,
|
||||
bevelSegments: 1,
|
||||
bevelSize: 0.045,
|
||||
bevelThickness: 0.045,
|
||||
curveSegments: 1,
|
||||
});
|
||||
function createSignBody(
|
||||
width: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
cut: number,
|
||||
) {
|
||||
const geometry = new THREE.ExtrudeGeometry(
|
||||
createClippedShape(width, height, cut),
|
||||
{
|
||||
depth,
|
||||
bevelEnabled: true,
|
||||
bevelSegments: 1,
|
||||
bevelSize: 0.045,
|
||||
bevelThickness: 0.045,
|
||||
curveSegments: 1,
|
||||
},
|
||||
);
|
||||
geometry.translate(0, 0, -depth * 0.5);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
@@ -799,9 +675,9 @@ function drawLabel(
|
||||
context.fillRect(142, 174, 1_610, 2);
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = '#d7e4e2';
|
||||
context.fillStyle = '#e2f7f4';
|
||||
const labelSize = fittedLabelSize(context, route.label, 1_520);
|
||||
context.font = `660 ${labelSize}px "Geist Mono Variable", ui-monospace, monospace`;
|
||||
context.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
|
||||
context.fillText(route.label, 142, 326);
|
||||
|
||||
context.strokeStyle = '#cb9160';
|
||||
@@ -823,8 +699,8 @@ function fittedLabelSize(
|
||||
label: string,
|
||||
maximumWidth: number,
|
||||
): number {
|
||||
for (let size = 164; size >= 112; size -= 4) {
|
||||
context.font = `660 ${size}px "Geist Mono Variable", ui-monospace, monospace`;
|
||||
for (let size = 180; size >= 128; size -= 4) {
|
||||
context.font = `650 ${size}px "Geist Variable", sans-serif`;
|
||||
if (context.measureText(label).width <= maximumWidth) return size;
|
||||
}
|
||||
return 112;
|
||||
|
||||
@@ -33,10 +33,15 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
uTime: { value: 0 },
|
||||
uDriveEnergy: { value: 0 },
|
||||
};
|
||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(112, 128));
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
const geometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.CircleGeometry(126, 160),
|
||||
);
|
||||
const material = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
varying vec2 vSunUv;
|
||||
|
||||
void main() {
|
||||
@@ -44,7 +49,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uHazeColor;
|
||||
uniform float uScanBands;
|
||||
@@ -55,97 +60,102 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
varying vec2 vSunUv;
|
||||
|
||||
void main() {
|
||||
vec2 centered = vSunUv - 0.5;
|
||||
float radius = length(centered);
|
||||
float softEdge = 1.0 - smoothstep(0.465, 0.5, radius);
|
||||
float limb = smoothstep(0.31, 0.485, radius);
|
||||
float broadField = 0.5 + 0.5 * sin(
|
||||
vSunUv.x * 9.0
|
||||
+ sin(vSunUv.y * 11.0 + uTime * 0.028) * 1.35
|
||||
);
|
||||
float fineField = 0.5 + 0.5 * sin(
|
||||
vSunUv.x * 23.0 - vSunUv.y * 16.0 + sin(uTime * 0.021) * 0.7
|
||||
);
|
||||
float internalField = broadField * 0.7 + fineField * 0.3;
|
||||
float verticalHeat = mix(0.68, 1.3, smoothstep(0.06, 0.92, vSunUv.y));
|
||||
float lowerDisc = 1.0 - smoothstep(0.28, 0.67, vSunUv.y);
|
||||
float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.08) * 0.008);
|
||||
float scanBand = smoothstep(0.09, 0.18, scanPhase)
|
||||
* (1.0 - smoothstep(0.72, 0.82, scanPhase));
|
||||
float bandCut = mix(1.0, 0.06 + scanBand * 0.94, lowerDisc);
|
||||
float horizonFade = smoothstep(0.015, 0.13, vSunUv.y);
|
||||
float radialCore = 1.0 - smoothstep(0.02, 0.48, radius);
|
||||
float luminance = verticalHeat
|
||||
* (1.08 + uWarmth * 0.16 + uRouteProgress * 0.045 + uDriveEnergy * 0.045);
|
||||
vec3 warmCore = mix(uColor, vec3(1.0, 0.56, 0.32), smoothstep(0.25, 0.92, vSunUv.y) * 0.84);
|
||||
vec3 core = mix(uColor * 0.82, warmCore * 0.83, smoothstep(0.05, 0.86, vSunUv.y));
|
||||
core = mix(core, core * mix(0.82, 1.13, internalField), 0.54);
|
||||
core = mix(core, uHazeColor * 0.72, limb * 0.22 + lowerDisc * 0.055);
|
||||
gl_FragColor = vec4(core * luminance, softEdge * bandCut * horizonFade);
|
||||
vec2 solar = vSunUv;
|
||||
float low = 1.0 - solar.y;
|
||||
solar.x += sin(solar.y * 132.0 + uTime * 0.42) * 0.0015 * low * low;
|
||||
float radius = length(solar - 0.5);
|
||||
float softEdge = 1.0 - smoothstep(0.496, 0.5, radius);
|
||||
float stripes = (0.65 - solar.y) * 17.0;
|
||||
float phase = fract(stripes);
|
||||
float gap = mix(0.12, 0.61, clamp(stripes / 11.0, 0.0, 1.0));
|
||||
float separated = smoothstep(gap, gap + 0.024, phase);
|
||||
float bandMask = solar.y > 0.65 ? 1.0 : separated;
|
||||
float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65);
|
||||
float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
|
||||
* sin(solar.y * 31.0 - uTime * 0.032);
|
||||
vec3 vermilion = vec3(0.91, 0.045, 0.075);
|
||||
vec3 coral = mix(uColor, vec3(1.0, 0.19, 0.10), 0.6);
|
||||
vec3 crown = vec3(1.0, 0.54, 0.25);
|
||||
vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
|
||||
core = mix(core, crown, smoothstep(0.47, 1.0, solar.y));
|
||||
float filament = pow(abs(sin(solar.x * 39.0 + solar.y * 25.0 + convection * 2.4)), 16.0);
|
||||
core *= 0.87 + convection * 0.055 + filament * 0.07;
|
||||
float limb = pow(smoothstep(0.38, 0.498, radius), 4.0);
|
||||
core += vec3(1.0, 0.24, 0.095) * (limb * 0.22 + lip * 0.20);
|
||||
float extinction = smoothstep(0.025, 0.19, solar.y);
|
||||
if(softEdge * bandMask * extinction < 0.02) discard;
|
||||
gl_FragColor = vec4(core * (1.0 + uWarmth * 0.10), softEdge * bandMask * extinction);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
transparent: true,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
blending: THREE.NormalBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const disc = new THREE.Mesh(geometry, material);
|
||||
disc.name = 'horizon-sun';
|
||||
disc.position.set(0, 60, SUN_DISTANCE_Z);
|
||||
disc.position.set(0, 79, SUN_DISTANCE_Z);
|
||||
group.add(disc);
|
||||
|
||||
const limbMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: sunColor.clone() },
|
||||
uEnergy: { value: 0 },
|
||||
},
|
||||
vertexShader: `
|
||||
const limbMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: sunColor.clone() },
|
||||
uEnergy: { value: 0 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uEnergy;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float radius = distance(vUv, vec2(0.5));
|
||||
float ring = smoothstep(0.405, 0.475, radius)
|
||||
* (1.0 - smoothstep(0.475, 0.515, radius));
|
||||
float ring = smoothstep(0.477, 0.488, radius)
|
||||
* (1.0 - smoothstep(0.491, 0.499, radius));
|
||||
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025));
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const limb = new THREE.Mesh(geometry, limbMaterial);
|
||||
limb.name = 'sun-limb-glow';
|
||||
limb.position.copy(disc.position);
|
||||
limb.position.z -= 0.3;
|
||||
limb.scale.setScalar(1.075);
|
||||
limb.scale.setScalar(1.034);
|
||||
group.add(limb);
|
||||
|
||||
const haloTexture = resources.own('texture', createHaloTexture());
|
||||
const haloMaterial = resources.own('material', new THREE.SpriteMaterial({
|
||||
map: haloTexture,
|
||||
color: sunColor.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.13,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const haloMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.SpriteMaterial({
|
||||
map: haloTexture,
|
||||
color: sunColor.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.13,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const halo = new THREE.Sprite(haloMaterial);
|
||||
halo.name = 'horizon-halo';
|
||||
halo.position.copy(disc.position);
|
||||
@@ -153,16 +163,19 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
halo.scale.set(330, 232, 1);
|
||||
group.add(halo);
|
||||
|
||||
const bleedMaterial = resources.own('material', new THREE.SpriteMaterial({
|
||||
map: haloTexture,
|
||||
color: sunColor.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.075,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
const bleedMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.SpriteMaterial({
|
||||
map: haloTexture,
|
||||
color: sunColor.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.075,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const bleed = new THREE.Sprite(bleedMaterial);
|
||||
bleed.name = 'horizon-light-bleed';
|
||||
bleed.position.set(0, 7, SUN_DISTANCE_Z - 2.2);
|
||||
@@ -181,13 +194,15 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
limbMaterial.uniforms.uColor.value.copy(palette.sun);
|
||||
uniforms.uWarmth.value = clamp01(palette.warmth);
|
||||
uniforms.uRouteProgress.value = progress;
|
||||
uniforms.uTime.value = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
|
||||
uniforms.uTime.value = Number.isFinite(elapsedSeconds)
|
||||
? elapsedSeconds
|
||||
: 0;
|
||||
uniforms.uDriveEnergy.value = energy;
|
||||
limbMaterial.uniforms.uEnergy.value = energy;
|
||||
haloMaterial.opacity = 0.105 + palette.warmth * 0.05 + energy * 0.015;
|
||||
haloMaterial.opacity = 0.075 + palette.warmth * 0.03 + energy * 0.015;
|
||||
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
|
||||
halo.scale.set(480 + energy * 18, 360 + energy * 10, 1);
|
||||
disc.position.y = 60 + palette.warmth * 14;
|
||||
disc.position.y = 79 + palette.warmth * 14;
|
||||
limb.position.y = disc.position.y;
|
||||
halo.position.y = disc.position.y;
|
||||
bleed.position.y = 7 + palette.warmth * 3;
|
||||
|
||||
@@ -2,7 +2,10 @@ import * as THREE from 'three';
|
||||
import type { EnvironmentChapter } from '@/journey/types';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import { CHAPTER_PALETTES, type MutableChapterPalette } from '@/scene/world/palette';
|
||||
import {
|
||||
CHAPTER_PALETTES,
|
||||
type MutableChapterPalette,
|
||||
} from '@/scene/world/palette';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
@@ -37,7 +40,10 @@ export function createTerrain(
|
||||
): TerrainWorld {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'terrain-world';
|
||||
const geometry = resources.own('geometry', createIndexedTerrainGeometry(quality.terrainSegments));
|
||||
const geometry = resources.own(
|
||||
'geometry',
|
||||
createIndexedTerrainGeometry(quality.terrainSegments),
|
||||
);
|
||||
const initialPalette = CHAPTER_PALETTES['deep-night'];
|
||||
const lowColor = new THREE.Color(initialPalette.terrainLow);
|
||||
const highColor = new THREE.Color(initialPalette.terrainHigh);
|
||||
@@ -55,9 +61,11 @@ export function createTerrain(
|
||||
uFogColor: { value: new THREE.Color(initialPalette.fog) },
|
||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||
};
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
const material = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
attribute float roadMask;
|
||||
uniform float uDistance;
|
||||
uniform float uFlowDistance;
|
||||
@@ -71,6 +79,7 @@ export function createTerrain(
|
||||
varying float vRoadMask;
|
||||
varying float vDepthFade;
|
||||
varying float vFlow;
|
||||
varying vec3 vTerrainPosition;
|
||||
|
||||
float hash21(vec2 point) {
|
||||
point = mod(point, 256.0);
|
||||
@@ -106,6 +115,7 @@ export function createTerrain(
|
||||
float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x)));
|
||||
float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy;
|
||||
displaced.y += height;
|
||||
vTerrainPosition = displaced;
|
||||
|
||||
vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0);
|
||||
vRoadMask = roadMask;
|
||||
@@ -117,7 +127,7 @@ export function createTerrain(
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
fragmentShader: `
|
||||
uniform vec3 uLowColor;
|
||||
uniform vec3 uHighColor;
|
||||
uniform vec3 uFogColor;
|
||||
@@ -127,25 +137,75 @@ export function createTerrain(
|
||||
varying float vRoadMask;
|
||||
varying float vDepthFade;
|
||||
varying float vFlow;
|
||||
varying vec3 vTerrainPosition;
|
||||
|
||||
void main() {
|
||||
float ridgeMix = smoothstep(0.16, 0.78, vHeight);
|
||||
vec3 color = mix(uLowColor, uHighColor, ridgeMix);
|
||||
color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade);
|
||||
float pulse = 0.72 + 0.28 * smoothstep(0.16, 0.55, vFlow);
|
||||
float pulse = 0.88 + 0.12 * smoothstep(0.16, 0.55, vFlow);
|
||||
float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
|
||||
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.54, 0.88, ridgeMix);
|
||||
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.30, 0.83, ridgeMix);
|
||||
gl_FragColor = vec4(color * mix(1.15, 1.75, ridgeMix), alpha);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}));
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const groundGeometry = resources.own('geometry', geometry.clone());
|
||||
const rows = Math.max(16, Math.floor(quality.terrainSegments));
|
||||
const columns = geometry.attributes.position.count / (rows + 1) - 1;
|
||||
const triangles: number[] = [];
|
||||
for (let row = 0; row < rows; row++)
|
||||
for (let column = 0; column < columns; column++) {
|
||||
const a = row * (columns + 1) + column,
|
||||
b = a + columns + 1;
|
||||
triangles.push(a, b, a + 1, a + 1, b, b + 1);
|
||||
}
|
||||
groundGeometry.setIndex(triangles);
|
||||
const groundMaterial = resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: material.vertexShader.replace(
|
||||
'displaced.y += height;',
|
||||
'displaced.y += height - 0.12;',
|
||||
),
|
||||
fragmentShader: `
|
||||
uniform vec3 uLowColor;
|
||||
uniform vec3 uHighColor;
|
||||
uniform vec3 uFogColor;
|
||||
varying float vHeight;
|
||||
varying float vDepthFade;
|
||||
varying vec3 vTerrainPosition;
|
||||
void main() {
|
||||
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
|
||||
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
|
||||
vec3 color = mix(vec3(0.002, 0.008, 0.016), uLowColor * 0.11, light);
|
||||
color += uHighColor * pow(vHeight, 2.0) * light * 0.06;
|
||||
color = mix(uFogColor * 0.55, color, vDepthFade);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
toneMapped: true,
|
||||
}),
|
||||
);
|
||||
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
||||
ground.name = 'sculpted-terrain-surface';
|
||||
ground.frustumCulled = false;
|
||||
group.add(ground);
|
||||
|
||||
const lines = new THREE.LineSegments(geometry, material);
|
||||
lines.name = 'terrain-grid';
|
||||
@@ -156,11 +216,13 @@ export function createTerrain(
|
||||
group,
|
||||
update(distance, speed, chapter, routeProgress, palette) {
|
||||
const chapterPalette = CHAPTER_PALETTES[chapter];
|
||||
uniforms.uDistance.value = ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
|
||||
uniforms.uDistance.value =
|
||||
((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
|
||||
uniforms.uFlowDistance.value = uniforms.uDistance.value;
|
||||
uniforms.uSpeed.value = clamp01(speed);
|
||||
uniforms.uRouteProgress.value = clamp01(routeProgress);
|
||||
uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
|
||||
uniforms.uTerrainEnergy.value =
|
||||
palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
|
||||
if (palette) {
|
||||
lowColor.copy(palette.terrainLow);
|
||||
highColor.copy(palette.terrainHigh);
|
||||
@@ -177,7 +239,10 @@ export function createTerrain(
|
||||
function createIndexedTerrainGeometry(segments: number) {
|
||||
const zSegments = Math.max(16, Math.floor(segments));
|
||||
const approximateCell = WORLD_DEPTH / zSegments;
|
||||
const xSegments = Math.max(48, Math.floor((TERRAIN_WIDTH / approximateCell) / 2) * 2);
|
||||
const 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);
|
||||
@@ -193,13 +258,18 @@ function createIndexedTerrainGeometry(segments: number) {
|
||||
positions[vertexOffset + 1] = 0;
|
||||
positions[vertexOffset + 2] = z;
|
||||
vertexOffset += 3;
|
||||
roadMasks[maskOffset] = smoothstep(ROAD_CLEAR_HALF_WIDTH, RIDGE_START, Math.abs(x));
|
||||
roadMasks[maskOffset] = smoothstep(
|
||||
ROAD_CLEAR_HALF_WIDTH,
|
||||
RIDGE_START,
|
||||
Math.abs(x),
|
||||
);
|
||||
maskOffset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const indices: number[] = [];
|
||||
const vertexIndex = (column: number, row: number): number => row * columnCount + column;
|
||||
const vertexIndex = (column: number, row: number): number =>
|
||||
row * columnCount + column;
|
||||
for (let row = 0; row < rowCount; row += 1) {
|
||||
for (let column = 0; column < columnCount; column += 1) {
|
||||
const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH;
|
||||
@@ -207,7 +277,8 @@ function createIndexedTerrainGeometry(segments: number) {
|
||||
indices.push(vertexIndex(column, row), vertexIndex(column, row + 1));
|
||||
}
|
||||
if (column < xSegments) {
|
||||
const nextX = -TERRAIN_WIDTH / 2 + ((column + 1) / xSegments) * TERRAIN_WIDTH;
|
||||
const nextX =
|
||||
-TERRAIN_WIDTH / 2 + ((column + 1) / xSegments) * TERRAIN_WIDTH;
|
||||
const midpoint = (x + nextX) * 0.5;
|
||||
if (Math.abs(midpoint) > ROAD_CLEAR_HALF_WIDTH) {
|
||||
indices.push(vertexIndex(column, row), vertexIndex(column + 1, row));
|
||||
@@ -225,7 +296,11 @@ function createIndexedTerrainGeometry(segments: number) {
|
||||
}
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, value: number): number {
|
||||
const progress = THREE.MathUtils.clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
||||
const progress = THREE.MathUtils.clamp(
|
||||
(value - edge0) / (edge1 - edge0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
return progress * progress * (3 - 2 * progress);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user