feat: polish nightdrive road sun and signage
This commit is contained in:
@@ -30,6 +30,7 @@ interface VisibleState {
|
||||
targetId: SectionId;
|
||||
latchedAt: number;
|
||||
approachDuration: number;
|
||||
departureRequested: boolean;
|
||||
departStartedAt: number | null;
|
||||
generation: number;
|
||||
}
|
||||
@@ -99,6 +100,7 @@ export function createSignController(): SignController {
|
||||
targetId: input.candidateId,
|
||||
latchedAt: input.now,
|
||||
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
|
||||
departureRequested: false,
|
||||
departStartedAt: null,
|
||||
generation,
|
||||
};
|
||||
@@ -111,6 +113,22 @@ export function createSignController(): SignController {
|
||||
visible.departStartedAt = now;
|
||||
};
|
||||
|
||||
const requestDeparture = (
|
||||
visible: VisibleState,
|
||||
current: SignSnapshot,
|
||||
now: number,
|
||||
): void => {
|
||||
const dwellCompleteAt = visible.latchedAt
|
||||
+ ANTICIPATE_MS
|
||||
+ visible.approachDuration
|
||||
+ DWELL_MS;
|
||||
if (current.phase === 'dwell' && now < dwellCompleteAt) {
|
||||
visible.departureRequested = true;
|
||||
return;
|
||||
}
|
||||
beginDeparture(visible, now);
|
||||
};
|
||||
|
||||
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
|
||||
if (visible.departStartedAt !== null) {
|
||||
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
|
||||
@@ -180,7 +198,7 @@ export function createSignController(): SignController {
|
||||
enterCooldown(input.now);
|
||||
return null;
|
||||
}
|
||||
beginDeparture(state, input.now);
|
||||
requestDeparture(state, current, input.now);
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
@@ -215,26 +233,25 @@ export function createSignController(): SignController {
|
||||
enterCooldown(input.now);
|
||||
return null;
|
||||
}
|
||||
beginDeparture(state, input.now);
|
||||
requestDeparture(state, current, input.now);
|
||||
return snapshotVisible(state, input.now);
|
||||
}
|
||||
|
||||
if (input.direction < 0) {
|
||||
const elapsed = input.now - state.latchedAt;
|
||||
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
|
||||
if (elapsed < ANTICIPATE_MS + state.approachDuration) {
|
||||
if (current.opacity <= CONCEALED_OPACITY) {
|
||||
state = { kind: 'idle' };
|
||||
return null;
|
||||
}
|
||||
if (state.departStartedAt === null && input.now >= dwellStartedAt) {
|
||||
beginDeparture(state, input.now);
|
||||
}
|
||||
if (state.departStartedAt === null) requestDeparture(state, current, input.now);
|
||||
}
|
||||
|
||||
if (state.departStartedAt === null) {
|
||||
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
|
||||
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
|
||||
if (dwellComplete && input.activeSectionId === state.targetId) {
|
||||
if (
|
||||
dwellComplete
|
||||
&& (state.departureRequested || input.activeSectionId === state.targetId)
|
||||
) {
|
||||
beginDeparture(state, input.now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette, MutableColor } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
SILHOUETTE_NEAR_Z,
|
||||
WORLD_DEPTH,
|
||||
WORLD_FAR_Z,
|
||||
WORLD_NEAR_Z,
|
||||
} from '@/scene/world/metrics';
|
||||
|
||||
export interface AtmosphereWorld {
|
||||
group: WorldGroup;
|
||||
@@ -88,7 +95,7 @@ export function createAtmosphere(
|
||||
color: new THREE.Color(night.fog).lerp(new THREE.Color(night.terrainLow), 0.46 + band * 0.12),
|
||||
transparent: true,
|
||||
opacity: 0.2 - band * 0.035,
|
||||
depthWrite: false,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
toneMapped: true,
|
||||
}));
|
||||
@@ -188,8 +195,11 @@ export function createAtmosphere(
|
||||
varying float vTail;
|
||||
void main() {
|
||||
vec3 streak = position;
|
||||
float depthCycle = mod(streak.z + uDistance * 1.55 + 520.0, 526.0) - 520.0;
|
||||
streak.z = depthCycle + endFactor * (1.4 + uAcceleration * 31.0);
|
||||
float depthCycle = mod(
|
||||
streak.z + uDistance - ${WORLD_FAR_Z.toFixed(1)},
|
||||
${WORLD_DEPTH.toFixed(1)}
|
||||
) + ${WORLD_FAR_Z.toFixed(1)};
|
||||
streak.z = depthCycle - endFactor * (1.4 + uAcceleration * 31.0);
|
||||
vTail = endFactor;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0);
|
||||
}
|
||||
@@ -223,10 +233,13 @@ export function createAtmosphere(
|
||||
},
|
||||
update(distance, speed, acceleration, palette) {
|
||||
const safeAcceleration = clamp01(acceleration);
|
||||
const visibility = Math.pow(safeAcceleration, 1.12) * palette.streakEnergy * 0.82;
|
||||
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) * (0.72 + clamp01(speed) * 0.28);
|
||||
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance);
|
||||
streakMaterial.uniforms.uAcceleration.value = safeAcceleration;
|
||||
streakMaterial.uniforms.uVisibility.value = visibility;
|
||||
skyMaterial.uniforms.uSkyColor.value.copy(palette.sky);
|
||||
@@ -340,7 +353,7 @@ function createSilhouetteGeometry(band: number) {
|
||||
const positions = new Float32Array((segments + 1) * 2 * 3);
|
||||
const indices: number[] = [];
|
||||
const width = 1_900;
|
||||
const centerZ = -455 - band * 54;
|
||||
const centerZ = SILHOUETTE_NEAR_Z - band * 46;
|
||||
const baseY = -38;
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const progress = index / segments;
|
||||
@@ -385,10 +398,13 @@ function createRoadsideAnchors(
|
||||
varying float vDepthFade;
|
||||
void main() {
|
||||
vec4 anchor = instanceMatrix * vec4(position, 1.0);
|
||||
anchor.z = mod(anchor.z + uDistance + 410.0, 438.0) - 410.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(250.0, 390.0, -viewPosition.z))
|
||||
* smoothstep(2.0, 20.0, -viewPosition.z);
|
||||
vDepthFade = (1.0 - smoothstep(540.0, ${HORIZON_FOG_END.toFixed(1)}, -viewPosition.z))
|
||||
* smoothstep(12.0, 42.0, -viewPosition.z);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
@@ -413,8 +429,10 @@ function createRoadsideAnchors(
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const side = index % 2 === 0 ? -1 : 1;
|
||||
const lane = Math.floor(index / 2);
|
||||
const z = -8 - (lane / Math.max(1, Math.ceil(count / 2) - 1)) * 420;
|
||||
const x = side * (9.6 + seeded(index, 181) * 1.1);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ import { createSign } from '@/scene/world/sign';
|
||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||
import { createPostProcessor } from '@/scene/post-processing';
|
||||
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
} from '@/scene/world/metrics';
|
||||
import { createTravelDistance } from '@/scene/world/travel-distance';
|
||||
import {
|
||||
createMutableChapterPalette,
|
||||
writeChapterPalette,
|
||||
@@ -22,12 +27,16 @@ declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||
|
||||
export function createChaseCamera(aspect = 1) {
|
||||
const camera = new THREE.PerspectiveCamera(66, aspect, 0.1, 900);
|
||||
const camera = new THREE.PerspectiveCamera(66, aspect, 0.1, 1_200);
|
||||
camera.position.set(0, 8.6, 22);
|
||||
camera.lookAt(0, 4.8, -128);
|
||||
return camera;
|
||||
}
|
||||
|
||||
export function createWorldFog(color: number) {
|
||||
return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END);
|
||||
}
|
||||
|
||||
export async function createProceduralSceneGeneration(
|
||||
context: SceneBuildContext,
|
||||
): Promise<SceneGeneration> {
|
||||
@@ -65,7 +74,7 @@ export async function createProceduralSceneGeneration(
|
||||
const targetPalette = createMutableChapterPalette('deep-night');
|
||||
const background = currentPalette.sky.clone();
|
||||
scene.background = background;
|
||||
scene.fog = new THREE.FogExp2(currentPalette.fog.getHex(), 0.0024);
|
||||
scene.fog = createWorldFog(currentPalette.fog.getHex());
|
||||
const camera = createChaseCamera();
|
||||
|
||||
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
||||
@@ -115,7 +124,7 @@ export async function createProceduralSceneGeneration(
|
||||
};
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let driveDistance = 0;
|
||||
const travelDistance = createTravelDistance();
|
||||
let lastNow: number | null = null;
|
||||
let disposed = false;
|
||||
let renderedFrames = 0;
|
||||
@@ -190,17 +199,17 @@ export async function createProceduralSceneGeneration(
|
||||
const response = dynamics.snapshot();
|
||||
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
|
||||
dampPalette(currentPalette, targetPalette, deltaSeconds);
|
||||
driveDistance += deltaSeconds * (
|
||||
18
|
||||
+ snapshot.driveSpeed * 54
|
||||
+ response.roadEnergy * 48
|
||||
const driveDistance = travelDistance.advance(
|
||||
deltaSeconds,
|
||||
snapshot.driveSpeed,
|
||||
response.roadEnergy,
|
||||
);
|
||||
const visualSpeed = THREE.MathUtils.clamp(
|
||||
0.18 + response.roadEnergy * 0.82,
|
||||
0.18,
|
||||
1,
|
||||
);
|
||||
road.update(driveDistance, visualSpeed, response.roadEnergy);
|
||||
road.update(driveDistance, visualSpeed, response.roadEnergy, currentPalette);
|
||||
terrain.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
@@ -209,7 +218,12 @@ export async function createProceduralSceneGeneration(
|
||||
currentPalette,
|
||||
);
|
||||
const elapsedSeconds = now / 1000;
|
||||
sun.update(currentPalette, snapshot.routeProgress, elapsedSeconds);
|
||||
sun.update(
|
||||
currentPalette,
|
||||
snapshot.routeProgress,
|
||||
elapsedSeconds,
|
||||
response.bloomEnergy,
|
||||
);
|
||||
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
|
||||
sign.update(snapshot.sign);
|
||||
atmosphere.update(
|
||||
@@ -220,7 +234,6 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
background.copy(currentPalette.sky);
|
||||
scene.fog.color.copy(currentPalette.fog);
|
||||
scene.fog.density = 0.0019 + (1 - currentPalette.warmth) * 0.0008;
|
||||
ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26);
|
||||
ambient.groundColor.copy(currentPalette.fog);
|
||||
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
|
||||
|
||||
9
src/scene/world/metrics.ts
Normal file
9
src/scene/world/metrics.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const WORLD_NEAR_Z = 34;
|
||||
export const WORLD_FAR_Z = -760;
|
||||
export const WORLD_DEPTH = WORLD_NEAR_Z - WORLD_FAR_Z;
|
||||
export const WORLD_CENTER_Z = (WORLD_NEAR_Z + WORLD_FAR_Z) * 0.5;
|
||||
|
||||
export const HORIZON_FOG_START = 330;
|
||||
export const HORIZON_FOG_END = 705;
|
||||
export const SILHOUETTE_NEAR_Z = -675;
|
||||
export const SUN_DISTANCE_Z = -720;
|
||||
@@ -1,10 +1,23 @@
|
||||
import * as THREE from 'three';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
WORLD_CENTER_Z,
|
||||
WORLD_DEPTH,
|
||||
} from '@/scene/world/metrics';
|
||||
|
||||
export interface RoadWorld {
|
||||
group: WorldGroup;
|
||||
update(distance: number, speed: number, acceleration: number): void;
|
||||
update(
|
||||
distance: number,
|
||||
speed: number,
|
||||
acceleration: number,
|
||||
palette?: Readonly<MutableChapterPalette>,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface WorldGroup {
|
||||
@@ -13,8 +26,6 @@ interface WorldGroup {
|
||||
}
|
||||
|
||||
const ROAD_WIDTH = 20.4;
|
||||
const ROAD_DEPTH = 438;
|
||||
const ROAD_CENTER_Z = -191;
|
||||
const FLOW_PERIOD = 5_694;
|
||||
|
||||
export function createRoad(
|
||||
@@ -26,36 +37,39 @@ export function createRoad(
|
||||
|
||||
const geometry = resources.own(
|
||||
'geometry',
|
||||
new THREE.PlaneGeometry(ROAD_WIDTH, ROAD_DEPTH, 16, quality.roadSegments),
|
||||
new THREE.PlaneGeometry(ROAD_WIDTH, WORLD_DEPTH, 16, quality.roadSegments),
|
||||
);
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
geometry.translate(0, 0, ROAD_CENTER_Z);
|
||||
geometry.translate(0, 0, WORLD_CENTER_Z);
|
||||
|
||||
const initialPalette = CHAPTER_PALETTES['deep-night'];
|
||||
const uniforms = {
|
||||
uDistance: { value: 0 },
|
||||
uSpeed: { value: 0 },
|
||||
uAcceleration: { value: 0 },
|
||||
uAsphalt: { value: new THREE.Color(0x03070f) },
|
||||
uAsphaltLift: { value: new THREE.Color(0x091422) },
|
||||
uAsphaltLift: { value: new THREE.Color(0x0b1927) },
|
||||
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: `
|
||||
varying vec2 vRoadUv;
|
||||
varying float vRoadX;
|
||||
varying float vDepth;
|
||||
varying float vDistanceFade;
|
||||
varying float vViewDistance;
|
||||
varying float vWorldZ;
|
||||
varying float vNearFade;
|
||||
|
||||
void main() {
|
||||
vRoadUv = uv;
|
||||
vRoadX = position.x;
|
||||
vDepth = smoothstep(0.0, 1.0, uv.y);
|
||||
vWorldZ = position.z;
|
||||
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
float viewDistance = -viewPosition.z;
|
||||
vDistanceFade = smoothstep(4.0, 16.0, viewDistance)
|
||||
* (1.0 - smoothstep(315.0, 430.0, viewDistance));
|
||||
vViewDistance = -viewPosition.z;
|
||||
vNearFade = smoothstep(4.0, 18.0, vViewDistance);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
@@ -67,39 +81,73 @@ export function createRoad(
|
||||
uniform vec3 uAsphaltLift;
|
||||
uniform vec3 uEdgeColor;
|
||||
uniform vec3 uLaneColor;
|
||||
uniform vec3 uFogColor;
|
||||
uniform vec3 uHazeColor;
|
||||
varying vec2 vRoadUv;
|
||||
varying float vRoadX;
|
||||
varying float vDepth;
|
||||
varying float vDistanceFade;
|
||||
varying float vViewDistance;
|
||||
varying float vWorldZ;
|
||||
varying float vNearFade;
|
||||
|
||||
float hash21(vec2 point) {
|
||||
point = fract(point * vec2(123.34, 456.21));
|
||||
point += dot(point, point + 45.32);
|
||||
return fract(point.x * point.y);
|
||||
}
|
||||
|
||||
float valueNoise(vec2 point) {
|
||||
vec2 cell = floor(point);
|
||||
vec2 local = fract(point);
|
||||
local = local * local * (3.0 - 2.0 * local);
|
||||
return mix(
|
||||
mix(hash21(cell), hash21(cell + vec2(1.0, 0.0)), local.x),
|
||||
mix(hash21(cell + vec2(0.0, 1.0)), hash21(cell + vec2(1.0)), local.x),
|
||||
local.y
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float roadX = abs(vRoadX);
|
||||
float coreX = roadX / 8.2;
|
||||
float centerValley = 1.0 - smoothstep(0.0, 0.86, coreX);
|
||||
float outerEdge = smoothstep(7.76, 7.93, roadX)
|
||||
* (1.0 - smoothstep(8.06, 8.19, roadX));
|
||||
float shoulder = smoothstep(7.2, 7.82, roadX)
|
||||
* (1.0 - smoothstep(8.18, 10.0, roadX));
|
||||
float shoulderFalloff = 1.0 - smoothstep(8.1, 10.2, roadX);
|
||||
float laneWidth = 1.0 - smoothstep(0.075, 0.18, roadX);
|
||||
float laneCycle = fract(vRoadUv.y * 33.7 + mod(uDistance, ${FLOW_PERIOD.toFixed(1)}) * 0.078);
|
||||
float laneDash = smoothstep(0.12, 0.2, laneCycle)
|
||||
* (1.0 - smoothstep(0.62, 0.72, laneCycle));
|
||||
float centerLane = laneWidth * laneDash * smoothstep(0.035, 0.18, vRoadUv.y);
|
||||
float edgeLuminance = outerEdge * (1.2 + uAcceleration * 0.68);
|
||||
float coarseAsphalt = sin(vRoadUv.y * 173.0 + roadX * 4.7 + uDistance * 0.08)
|
||||
* sin(vRoadUv.y * 47.0 - roadX * 8.1) * 0.006;
|
||||
float fineGrain = sin((vRoadUv.y * 530.0 + roadX * 5.0) + uDistance * 0.1) * 0.003;
|
||||
float horizonReflection = smoothstep(0.42, 0.94, vRoadUv.y)
|
||||
* exp(-roadX * 0.34) * (0.028 + uAcceleration * 0.018);
|
||||
float travelZ = -vWorldZ + mod(uDistance, ${FLOW_PERIOD.toFixed(1)});
|
||||
float asphaltMask = 1.0 - smoothstep(7.78, 8.2, roadX);
|
||||
float shoulderMask = smoothstep(7.72, 8.08, roadX);
|
||||
float shoulderSeam = smoothstep(7.22, 7.34, roadX)
|
||||
* (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 laneCycle = fract(travelZ / 18.0);
|
||||
float laneDash = smoothstep(0.05, 0.1, laneCycle)
|
||||
* (1.0 - smoothstep(0.48, 0.56, laneCycle));
|
||||
float laneDistanceFade = 1.0 - smoothstep(430.0, 650.0, vViewDistance);
|
||||
float centerLane = laneWidth * laneDash * laneDistanceFade;
|
||||
|
||||
vec3 color = mix(uAsphaltLift, uAsphalt, centerValley * 0.84);
|
||||
color += uEdgeColor * (edgeLuminance * 1.35 + shoulder * 0.18);
|
||||
color += uLaneColor * centerLane * (1.15 + uSpeed * 0.28);
|
||||
color += uLaneColor * horizonReflection;
|
||||
color += coarseAsphalt + fineGrain;
|
||||
color = mix(uAsphalt * 0.68, color, shoulderFalloff);
|
||||
color *= vDistanceFade * mix(0.58, 1.0, smoothstep(0.0, 0.2, vRoadUv.y));
|
||||
float broadVariation = valueNoise(vec2(vRoadX * 0.12 + 8.0, travelZ * 0.018));
|
||||
float aggregate = valueNoise(vec2(vRoadX * 0.48 - 3.0, travelZ * 0.085));
|
||||
float grain = valueNoise(vec2(vRoadX * 2.1 + 19.0, travelZ * 0.31));
|
||||
float tireWear = exp(-pow(abs(roadX - 2.5) * 0.82, 2.0));
|
||||
float longitudinalWear = tireWear * (0.012 + aggregate * 0.014);
|
||||
float shoulderBreakup = mix(0.72, 1.02, aggregate * 0.68 + grain * 0.32);
|
||||
|
||||
vec3 asphalt = mix(uAsphalt, uAsphaltLift, 0.3 + broadVariation * 0.38);
|
||||
asphalt *= 0.9 + aggregate * 0.09 + grain * 0.025;
|
||||
asphalt += uHazeColor * longitudinalWear;
|
||||
vec3 shoulder = mix(uAsphalt * 0.54, uAsphaltLift * 0.7, broadVariation);
|
||||
shoulder *= shoulderBreakup;
|
||||
vec3 color = mix(shoulder, asphalt, asphaltMask);
|
||||
color += uEdgeColor * outerEdge * (0.78 + uAcceleration * 0.34);
|
||||
color += uEdgeColor * shoulderMask * grain * 0.026;
|
||||
color += uHazeColor * shoulderSeam * (0.035 + aggregate * 0.025);
|
||||
color += uLaneColor * centerLane * (0.86 + uSpeed * 0.18);
|
||||
|
||||
float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)
|
||||
* (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance))
|
||||
* exp(-roadX * 0.28);
|
||||
color += mix(uHazeColor, uLaneColor, 0.24) * horizonPickup * (0.08 + uAcceleration * 0.025);
|
||||
float fog = smoothstep(${HORIZON_FOG_START.toFixed(1)}, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance);
|
||||
vec3 atmosphericRoad = mix(uFogColor, uHazeColor, 0.16) * 0.48;
|
||||
color = mix(color, atmosphericRoad, fog);
|
||||
color *= vNearFade;
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
@@ -115,10 +163,14 @@ export function createRoad(
|
||||
|
||||
return {
|
||||
group,
|
||||
update(distance, speed, acceleration) {
|
||||
update(distance, speed, acceleration, palette) {
|
||||
uniforms.uDistance.value = ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
|
||||
uniforms.uSpeed.value = clamp01(speed);
|
||||
uniforms.uAcceleration.value = clamp01(acceleration);
|
||||
if (palette) {
|
||||
uniforms.uFogColor.value.copy(palette.fog);
|
||||
uniforms.uHazeColor.value.copy(palette.haze);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,23 @@ const routeLabels: Readonly<Record<SectionId, string>> = {
|
||||
contact: 'Contact',
|
||||
};
|
||||
|
||||
interface SignPose {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
const NARROW_SIGN_POSES = {
|
||||
horizon: { x: 5.2, y: 6.8, z: -142, scale: 0.15 },
|
||||
dwell: { x: 5.8, y: 4.86, z: -20, scale: 0.82 },
|
||||
} as const;
|
||||
|
||||
const WIDE_SIGN_POSES = {
|
||||
horizon: { x: 9.1, y: 7.4, z: -160, scale: 0.18 },
|
||||
dwell: { x: 10.7, y: 6.64, z: -14, scale: 1.12 },
|
||||
} as const;
|
||||
|
||||
export interface SignWorld {
|
||||
group: SignGroup;
|
||||
setViewportAspect(aspect: number): void;
|
||||
@@ -22,9 +39,16 @@ export interface SignWorld {
|
||||
|
||||
interface SignGroup {
|
||||
visible: boolean;
|
||||
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;
|
||||
clone(): SignGroup['position'];
|
||||
distanceTo(other: SignGroup['position']): number;
|
||||
};
|
||||
rotation: { y: number };
|
||||
scale: { setScalar(scale: number): void };
|
||||
scale: { x: number; setScalar(scale: number): void };
|
||||
add(...objects: unknown[]): void;
|
||||
getObjectByName(name: string): unknown;
|
||||
}
|
||||
@@ -38,89 +62,159 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
group.name = 'roadside-sign';
|
||||
group.visible = false;
|
||||
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x07101a,
|
||||
roughness: 0.7,
|
||||
metalness: 0.42,
|
||||
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x071019,
|
||||
roughness: 0.58,
|
||||
metalness: 0.66,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const frameMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x2d6f79,
|
||||
emissive: 0x43cfdd,
|
||||
emissiveIntensity: 0.68,
|
||||
roughness: 0.38,
|
||||
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x111d27,
|
||||
roughness: 0.78,
|
||||
metalness: 0.34,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x142631,
|
||||
roughness: 0.52,
|
||||
metalness: 0.72,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x3a8892,
|
||||
emissive: 0x53d2dc,
|
||||
emissiveIntensity: 1.15,
|
||||
roughness: 0.34,
|
||||
metalness: 0.58,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
const postMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x172b35,
|
||||
roughness: 0.64,
|
||||
metalness: 0.62,
|
||||
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||
color: 0x8b522d,
|
||||
emissive: 0xe49a58,
|
||||
emissiveIntensity: 0.72,
|
||||
roughness: 0.42,
|
||||
metalness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
}));
|
||||
|
||||
const housing = new THREE.Mesh(
|
||||
resources.own('geometry', createSignBody(7.5, 2.3, 0.34, 0.2)),
|
||||
housingMaterial,
|
||||
);
|
||||
housing.name = 'sign-housing';
|
||||
housing.position.z = -0.05;
|
||||
group.add(housing);
|
||||
|
||||
const backplate = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(8.2, 2.35, 0.24)),
|
||||
resources.own('geometry', createSignBody(7.2, 2.04, 0.18, 0.16)),
|
||||
backplateMaterial,
|
||||
);
|
||||
backplate.name = 'sign-backplate';
|
||||
backplate.position.z = 0.17;
|
||||
group.add(backplate);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1_024;
|
||||
canvas.height = 256;
|
||||
canvas.height = 288;
|
||||
const context = canvas.getContext('2d');
|
||||
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.anisotropy = 4;
|
||||
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
side: THREE.FrontSide,
|
||||
map: texture,
|
||||
toneMapped: true,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
toneMapped: false,
|
||||
}));
|
||||
const face = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(7.72, 1.87)),
|
||||
resources.own('geometry', new THREE.PlaneGeometry(6.92, 1.76)),
|
||||
faceMaterial,
|
||||
);
|
||||
face.name = 'sign-face';
|
||||
face.position.z = 0.132;
|
||||
face.position.z = 0.545;
|
||||
face.userData.textureUpdateCount = 0;
|
||||
group.add(face);
|
||||
|
||||
const frame = new THREE.Group();
|
||||
frame.name = 'sign-frame';
|
||||
const horizontalGeometry = resources.own('geometry', new THREE.BoxGeometry(8.42, 0.12, 0.32));
|
||||
const verticalGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 2.35, 0.32));
|
||||
for (const y of [-1.175, 1.175]) {
|
||||
const rail = new THREE.Mesh(horizontalGeometry, frameMaterial);
|
||||
rail.position.y = y;
|
||||
frame.add(rail);
|
||||
}
|
||||
for (const x of [-4.15, 4.15]) {
|
||||
const rail = new THREE.Mesh(verticalGeometry, frameMaterial);
|
||||
rail.position.x = x;
|
||||
frame.add(rail);
|
||||
}
|
||||
group.add(frame);
|
||||
const edgeLight = new THREE.Group();
|
||||
edgeLight.name = 'sign-edge-light';
|
||||
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.055, 1.42, 0.055));
|
||||
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.16, 0.045, 0.055));
|
||||
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||
leftEdge.position.set(-3.36, 0, 0.585);
|
||||
edgeLight.add(leftEdge);
|
||||
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
||||
upperEdge.position.set(-2.81, 0.88, 0.585);
|
||||
edgeLight.add(upperEdge);
|
||||
group.add(edgeLight);
|
||||
|
||||
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.17, 3.5, 0.17));
|
||||
for (const [x, side] of [[-2.9, 'left'], [2.9, 'right']] as const) {
|
||||
const post = new THREE.Mesh(postGeometry, postMaterial);
|
||||
post.name = `sign-post-${side}`;
|
||||
post.position.set(x, -2.86, -0.08);
|
||||
group.add(post);
|
||||
}
|
||||
const routeAccent = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(1.05, 0.055, 0.065)),
|
||||
routeMaterial,
|
||||
);
|
||||
routeAccent.name = 'sign-route-accent';
|
||||
routeAccent.position.set(2.77, -0.88, 0.585);
|
||||
group.add(routeAccent);
|
||||
|
||||
const fadeMaterials = [backplateMaterial, frameMaterial, postMaterial, faceMaterial];
|
||||
const post = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.14, 4.85, 8)),
|
||||
supportMaterial,
|
||||
);
|
||||
post.name = 'sign-post';
|
||||
post.position.set(2.35, -3.32, -0.14);
|
||||
group.add(post);
|
||||
|
||||
const bracket = new THREE.Group();
|
||||
bracket.name = 'sign-bracket';
|
||||
const arm = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(2.5, 0.11, 0.15)),
|
||||
supportMaterial,
|
||||
);
|
||||
arm.position.set(1.18, -1.08, -0.13);
|
||||
bracket.add(arm);
|
||||
const brace = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.BoxGeometry(0.1, 1.25, 0.12)),
|
||||
supportMaterial,
|
||||
);
|
||||
brace.position.set(1.72, -1.56, -0.13);
|
||||
brace.rotation.z = -0.72;
|
||||
bracket.add(brace);
|
||||
group.add(bracket);
|
||||
|
||||
const footing = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.CylinderGeometry(0.28, 0.34, 0.34, 8)),
|
||||
supportMaterial,
|
||||
);
|
||||
footing.name = 'sign-footing';
|
||||
footing.position.set(2.35, -5.76, -0.14);
|
||||
group.add(footing);
|
||||
|
||||
const fadeMaterials = [
|
||||
[housingMaterial, 0.97],
|
||||
[backplateMaterial, 1],
|
||||
[supportMaterial, 0.94],
|
||||
[edgeMaterial, 0.76],
|
||||
[routeMaterial, 0.82],
|
||||
[faceMaterial, 1],
|
||||
] as const;
|
||||
const departureOrigin = new THREE.Vector3();
|
||||
let departureScale = 1;
|
||||
let target: SectionId | null = null;
|
||||
let narrowViewport = false;
|
||||
let pendingNarrowViewport = false;
|
||||
let activeNarrowViewport = false;
|
||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||
|
||||
const updateTexture = (id: SectionId): void => {
|
||||
if (target === id) return;
|
||||
@@ -131,36 +225,43 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
};
|
||||
|
||||
const applyOpacity = (opacity: number): void => {
|
||||
for (const material of fadeMaterials) material.opacity = opacity;
|
||||
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
|
||||
};
|
||||
|
||||
const applyPose = (pose: SignPose): void => {
|
||||
group.position.set(pose.x, pose.y, pose.z);
|
||||
group.scale.setScalar(pose.scale);
|
||||
};
|
||||
|
||||
return {
|
||||
group,
|
||||
setViewportAspect(aspect) {
|
||||
narrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
||||
pendingNarrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
||||
if (!group.visible) activeNarrowViewport = pendingNarrowViewport;
|
||||
},
|
||||
update(sign) {
|
||||
if (!sign) {
|
||||
group.visible = false;
|
||||
applyOpacity(0);
|
||||
previousPhase = null;
|
||||
activeNarrowViewport = pendingNarrowViewport;
|
||||
return;
|
||||
}
|
||||
|
||||
updateTexture(sign.targetId);
|
||||
if (target !== null && target !== sign.targetId && group.visible) return;
|
||||
if (target !== sign.targetId) {
|
||||
activeNarrowViewport = pendingNarrowViewport;
|
||||
updateTexture(sign.targetId);
|
||||
}
|
||||
|
||||
const opacity = clamp01(sign.opacity);
|
||||
applyOpacity(opacity);
|
||||
group.visible = opacity > 0;
|
||||
const progress = smoothstep(clamp01(sign.progress));
|
||||
const dwell = narrowViewport
|
||||
? { x: 3.1, y: 3.25, z: -20, scale: 0.68 }
|
||||
: { x: 8.25, y: 3.1, z: -8.5, scale: 1 };
|
||||
const horizon = narrowViewport
|
||||
? { x: 4.2, y: 4.1, z: -88, scale: 0.2 }
|
||||
: { x: 12.4, y: 5.3, z: -92, scale: 0.24 };
|
||||
const { horizon, dwell } = activeNarrowViewport
|
||||
? NARROW_SIGN_POSES
|
||||
: WIDE_SIGN_POSES;
|
||||
|
||||
if (sign.phase === 'anticipate') {
|
||||
group.position.set(horizon.x, horizon.y, horizon.z);
|
||||
group.scale.setScalar(horizon.scale);
|
||||
applyPose(horizon);
|
||||
} else if (sign.phase === 'approach') {
|
||||
group.position.set(
|
||||
THREE.MathUtils.lerp(horizon.x, dwell.x, progress),
|
||||
@@ -169,21 +270,59 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
||||
);
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
||||
} else if (sign.phase === 'dwell') {
|
||||
group.position.set(dwell.x, dwell.y, dwell.z);
|
||||
group.scale.setScalar(dwell.scale);
|
||||
applyPose(dwell);
|
||||
} else {
|
||||
if (previousPhase !== 'depart') {
|
||||
if (previousPhase === null) applyPose(dwell);
|
||||
departureOrigin.copy(group.position);
|
||||
departureScale = group.scale.x;
|
||||
}
|
||||
group.position.set(
|
||||
dwell.x + progress * (narrowViewport ? 1.4 : 3.2),
|
||||
dwell.y + progress * 0.7,
|
||||
THREE.MathUtils.lerp(dwell.z, 17, progress),
|
||||
departureOrigin.x + progress * (activeNarrowViewport ? 2.2 : 4.4),
|
||||
departureOrigin.y + progress * 0.62,
|
||||
THREE.MathUtils.lerp(departureOrigin.z, 28, progress),
|
||||
);
|
||||
group.scale.setScalar(dwell.scale * THREE.MathUtils.lerp(1, 1.08, progress));
|
||||
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 1.06, progress));
|
||||
}
|
||||
group.rotation.y = narrowViewport ? -0.08 : -0.16;
|
||||
|
||||
group.rotation.y = activeNarrowViewport ? -0.14 : -0.23;
|
||||
applyOpacity(opacity);
|
||||
group.visible = opacity > 0;
|
||||
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,
|
||||
});
|
||||
geometry.translate(0, 0, -depth * 0.5);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function createClippedShape(width: number, height: number, cut: number) {
|
||||
const halfWidth = width * 0.5;
|
||||
const halfHeight = height * 0.5;
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-halfWidth + cut, -halfHeight);
|
||||
shape.lineTo(halfWidth - cut, -halfHeight);
|
||||
shape.lineTo(halfWidth, -halfHeight + cut);
|
||||
shape.lineTo(halfWidth, halfHeight - cut);
|
||||
shape.lineTo(halfWidth - cut, halfHeight);
|
||||
shape.lineTo(-halfWidth + cut, halfHeight);
|
||||
shape.lineTo(-halfWidth, halfHeight - cut);
|
||||
shape.lineTo(-halfWidth, -halfHeight + cut);
|
||||
shape.closePath();
|
||||
return shape;
|
||||
}
|
||||
|
||||
function drawLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
label: string,
|
||||
@@ -191,22 +330,28 @@ function drawLabel(
|
||||
height: number,
|
||||
): void {
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = '#07111b';
|
||||
context.strokeStyle = '#56d4df';
|
||||
context.lineWidth = 4;
|
||||
context.beginPath();
|
||||
if (typeof context.roundRect === 'function') {
|
||||
context.roundRect(14, 14, width - 28, height - 28, 12);
|
||||
} else {
|
||||
context.rect(14, 14, width - 28, height - 28);
|
||||
}
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = '#eef7f8';
|
||||
context.font = '600 58px "Geist Mono Variable", ui-monospace, monospace';
|
||||
context.textAlign = 'center';
|
||||
context.fillStyle = '#07111a';
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.fillStyle = '#55d4df';
|
||||
context.fillRect(42, 36, 8, height - 72);
|
||||
context.fillStyle = '#91aab3';
|
||||
context.font = '500 23px "Geist Mono Variable", ui-monospace, monospace';
|
||||
context.textAlign = 'left';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText(label, width / 2, height / 2 + 2, width - 88);
|
||||
context.fillText('NEXT / ROUTE', 82, 72);
|
||||
context.fillStyle = '#eef7f8';
|
||||
context.font = '650 74px "Geist Mono Variable", ui-monospace, monospace';
|
||||
context.fillText(label, 82, 170, width - 210);
|
||||
context.strokeStyle = '#d99758';
|
||||
context.lineWidth = 7;
|
||||
context.lineCap = 'square';
|
||||
context.beginPath();
|
||||
context.moveTo(width - 116, 156);
|
||||
context.lineTo(width - 74, 156);
|
||||
context.lineTo(width - 92, 138);
|
||||
context.moveTo(width - 74, 156);
|
||||
context.lineTo(width - 92, 174);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
|
||||
@@ -2,10 +2,16 @@ import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import { SUN_DISTANCE_Z } from '@/scene/world/metrics';
|
||||
|
||||
export interface SunWorld {
|
||||
group: WorldGroup;
|
||||
update(palette: Readonly<MutableChapterPalette>, routeProgress: number, elapsedSeconds: number): void;
|
||||
update(
|
||||
palette: Readonly<MutableChapterPalette>,
|
||||
routeProgress: number,
|
||||
elapsedSeconds: number,
|
||||
driveEnergy?: number,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface WorldGroup {
|
||||
@@ -20,12 +26,14 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
const sunColor = new THREE.Color(night.sun);
|
||||
const uniforms = {
|
||||
uColor: { value: sunColor },
|
||||
uHazeColor: { value: new THREE.Color(night.haze) },
|
||||
uScanBands: { value: 11 },
|
||||
uWarmth: { value: night.warmth },
|
||||
uRouteProgress: { value: 0 },
|
||||
uTime: { value: 0 },
|
||||
uDriveEnergy: { value: 0 },
|
||||
};
|
||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(44, 128));
|
||||
const geometry = resources.own('geometry', new THREE.CircleGeometry(74, 128));
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
@@ -38,24 +46,42 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uHazeColor;
|
||||
uniform float uScanBands;
|
||||
uniform float uWarmth;
|
||||
uniform float uRouteProgress;
|
||||
uniform float uTime;
|
||||
uniform float uDriveEnergy;
|
||||
varying vec2 vSunUv;
|
||||
|
||||
void main() {
|
||||
float radius = distance(vSunUv, vec2(0.5));
|
||||
float softEdge = 1.0 - smoothstep(0.475, 0.5, radius);
|
||||
float lowerDisc = 1.0 - smoothstep(0.46, 0.84, vSunUv.y);
|
||||
float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.12) * 0.012);
|
||||
float scanBand = smoothstep(0.12, 0.2, scanPhase)
|
||||
* (1.0 - smoothstep(0.58, 0.72, scanPhase));
|
||||
float bandCut = mix(1.0, 0.18 + scanBand * 0.82, lowerDisc * 0.9);
|
||||
float horizonFade = smoothstep(0.01, 0.11, vSunUv.y);
|
||||
float verticalHeat = mix(0.78, 1.3, smoothstep(0.1, 0.88, vSunUv.y));
|
||||
float luminance = verticalHeat * (1.28 + uWarmth * 0.24 + uRouteProgress * 0.08);
|
||||
gl_FragColor = vec4(uColor * luminance, softEdge * bandCut * horizonFade);
|
||||
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.54 + scanBand * 0.46, lowerDisc * 0.78);
|
||||
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.61, 0.43), uWarmth * 0.18 + uRouteProgress * 0.035);
|
||||
vec3 core = mix(uColor * 0.78, warmCore * 1.12, radialCore * 0.58);
|
||||
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);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
@@ -66,15 +92,51 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
}));
|
||||
const disc = new THREE.Mesh(geometry, material);
|
||||
disc.name = 'horizon-sun';
|
||||
disc.position.set(0, 28, -410);
|
||||
disc.position.set(0, 48, SUN_DISTANCE_Z);
|
||||
group.add(disc);
|
||||
|
||||
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: `
|
||||
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));
|
||||
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025));
|
||||
}
|
||||
`,
|
||||
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);
|
||||
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.24,
|
||||
opacity: 0.13,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
@@ -83,22 +145,48 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
const halo = new THREE.Sprite(haloMaterial);
|
||||
halo.name = 'horizon-halo';
|
||||
halo.position.copy(disc.position);
|
||||
halo.position.z -= 0.5;
|
||||
halo.scale.set(220, 138, 1);
|
||||
halo.position.z -= 1.2;
|
||||
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 bleed = new THREE.Sprite(bleedMaterial);
|
||||
bleed.name = 'horizon-light-bleed';
|
||||
bleed.position.set(0, 7, SUN_DISTANCE_Z - 2.2);
|
||||
bleed.scale.set(560, 118, 1);
|
||||
group.add(bleed);
|
||||
|
||||
return {
|
||||
group,
|
||||
update(palette, routeProgress, elapsedSeconds) {
|
||||
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0) {
|
||||
const progress = clamp01(routeProgress);
|
||||
const energy = clamp01(driveEnergy);
|
||||
sunColor.copy(palette.sun);
|
||||
haloMaterial.color.copy(palette.sun);
|
||||
uniforms.uHazeColor.value.copy(palette.haze);
|
||||
haloMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22);
|
||||
bleedMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22);
|
||||
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;
|
||||
haloMaterial.opacity = 0.12 + palette.warmth * 0.08;
|
||||
disc.position.y = 28 + palette.warmth * 7;
|
||||
uniforms.uDriveEnergy.value = energy;
|
||||
limbMaterial.uniforms.uEnergy.value = energy;
|
||||
haloMaterial.opacity = 0.105 + palette.warmth * 0.05 + energy * 0.015;
|
||||
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
|
||||
halo.scale.set(330 + energy * 18, 232 + energy * 10, 1);
|
||||
disc.position.y = 48 + palette.warmth * 9;
|
||||
limb.position.y = disc.position.y;
|
||||
halo.position.y = disc.position.y;
|
||||
bleed.position.y = 7 + palette.warmth * 3;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ 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 {
|
||||
HORIZON_FOG_END,
|
||||
HORIZON_FOG_START,
|
||||
WORLD_DEPTH,
|
||||
WORLD_NEAR_Z,
|
||||
} from '@/scene/world/metrics';
|
||||
|
||||
export interface TerrainWorld {
|
||||
group: WorldGroup;
|
||||
@@ -21,9 +27,7 @@ interface WorldGroup {
|
||||
}
|
||||
|
||||
const TERRAIN_WIDTH = 360;
|
||||
const TERRAIN_DEPTH = 438;
|
||||
const NEAR_Z = 28;
|
||||
const ROAD_CLEAR_HALF_WIDTH = 9.4;
|
||||
const ROAD_CLEAR_HALF_WIDTH = 10.3;
|
||||
const RIDGE_START = 46;
|
||||
const NOISE_PERIOD = 5_694;
|
||||
|
||||
@@ -39,20 +43,24 @@ export function createTerrain(
|
||||
const highColor = new THREE.Color(initialPalette.terrainHigh);
|
||||
const uniforms = {
|
||||
uDistance: { value: 0 },
|
||||
uFlowDistance: { value: 0 },
|
||||
uSpeed: { value: 0 },
|
||||
uRouteProgress: { value: 0 },
|
||||
uTerrainEnergy: { value: initialPalette.terrainEnergy },
|
||||
uOctaves: { value: quality.terrainOctaves },
|
||||
uRelief: { value: 34 },
|
||||
uFar: { value: 430 },
|
||||
uFar: { value: HORIZON_FOG_END },
|
||||
uLowColor: { value: lowColor },
|
||||
uHighColor: { value: highColor },
|
||||
uFogColor: { value: new THREE.Color(initialPalette.fog) },
|
||||
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
|
||||
};
|
||||
const material = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `
|
||||
attribute float roadMask;
|
||||
uniform float uDistance;
|
||||
uniform float uFlowDistance;
|
||||
uniform float uSpeed;
|
||||
uniform float uRouteProgress;
|
||||
uniform float uTerrainEnergy;
|
||||
@@ -104,14 +112,16 @@ export function createTerrain(
|
||||
vec4 viewPosition = modelViewMatrix * vec4(displaced, 1.0);
|
||||
float viewDistance = -viewPosition.z;
|
||||
vDepthFade = smoothstep(4.0, 18.0, viewDistance)
|
||||
* (1.0 - smoothstep(uFar * 0.32, uFar * 0.88, viewDistance));
|
||||
vFlow = fract((-position.z + uDistance * (0.9 + uSpeed * 0.25)) / 11.0);
|
||||
* (1.0 - smoothstep(${(HORIZON_FOG_START + 150).toFixed(1)}, ${HORIZON_FOG_END.toFixed(1)}, viewDistance));
|
||||
vFlow = fract((-position.z + uFlowDistance) / 11.0);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uLowColor;
|
||||
uniform vec3 uHighColor;
|
||||
uniform vec3 uFogColor;
|
||||
uniform vec3 uHazeColor;
|
||||
uniform float uRouteProgress;
|
||||
varying float vHeight;
|
||||
varying float vRoadMask;
|
||||
@@ -121,6 +131,7 @@ export function createTerrain(
|
||||
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 routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
|
||||
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.32, 0.82, ridgeMix);
|
||||
@@ -144,12 +155,15 @@ export function createTerrain(
|
||||
update(distance, speed, chapter, routeProgress, palette) {
|
||||
const chapterPalette = CHAPTER_PALETTES[chapter];
|
||||
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;
|
||||
if (palette) {
|
||||
lowColor.copy(palette.terrainLow);
|
||||
highColor.copy(palette.terrainHigh);
|
||||
uniforms.uFogColor.value.copy(palette.fog);
|
||||
uniforms.uHazeColor.value.copy(palette.haze);
|
||||
} else {
|
||||
lowColor.setHex(chapterPalette.terrainLow);
|
||||
highColor.setHex(chapterPalette.terrainHigh);
|
||||
@@ -160,7 +174,7 @@ export function createTerrain(
|
||||
|
||||
function createIndexedTerrainGeometry(segments: number) {
|
||||
const zSegments = Math.max(16, Math.floor(segments));
|
||||
const approximateCell = TERRAIN_DEPTH / zSegments;
|
||||
const approximateCell = WORLD_DEPTH / zSegments;
|
||||
const xSegments = Math.max(48, Math.floor((TERRAIN_WIDTH / approximateCell) / 2) * 2);
|
||||
const columnCount = xSegments + 1;
|
||||
const rowCount = zSegments + 1;
|
||||
@@ -170,7 +184,7 @@ function createIndexedTerrainGeometry(segments: number) {
|
||||
let maskOffset = 0;
|
||||
|
||||
for (let row = 0; row < rowCount; row += 1) {
|
||||
const z = NEAR_Z - (row / zSegments) * TERRAIN_DEPTH;
|
||||
const z = WORLD_NEAR_Z - (row / zSegments) * WORLD_DEPTH;
|
||||
for (let column = 0; column < columnCount; column += 1) {
|
||||
const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH;
|
||||
positions[vertexOffset] = x;
|
||||
|
||||
31
src/scene/world/travel-distance.ts
Normal file
31
src/scene/world/travel-distance.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface TravelDistance {
|
||||
advance(deltaSeconds: number, speed: number, roadEnergy: number): number;
|
||||
snapshot(): number;
|
||||
}
|
||||
|
||||
const MAX_FRAME_SECONDS = 0.1;
|
||||
const CRUISE_UNITS_PER_SECOND = 18;
|
||||
const SPEED_UNITS_PER_SECOND = 54;
|
||||
const ENERGY_UNITS_PER_SECOND = 48;
|
||||
|
||||
export function createTravelDistance(): TravelDistance {
|
||||
let distance = 0;
|
||||
|
||||
return {
|
||||
advance(deltaSeconds, speed, roadEnergy) {
|
||||
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return distance;
|
||||
const dt = Math.min(MAX_FRAME_SECONDS, deltaSeconds);
|
||||
distance += dt * (
|
||||
CRUISE_UNITS_PER_SECOND
|
||||
+ clamp01(speed) * SPEED_UNITS_PER_SECOND
|
||||
+ clamp01(roadEnergy) * ENERGY_UNITS_PER_SECOND
|
||||
);
|
||||
return distance;
|
||||
},
|
||||
snapshot: () => distance,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user