feat: Blender-authored roadside districts that rise per chapter
This commit is contained in:
323
src/scene/world/districts.ts
Normal file
323
src/scene/world/districts.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/* three ships untyped here (see src/scene/three.d.ts). */
|
||||
type Mesh = { visible: boolean; geometry?: Geometry };
|
||||
type Geometry = { dispose(): void };
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import districtsUrl from '@/assets/nightdrive-districts.glb?url';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import { signAccentFor } from './sign';
|
||||
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
|
||||
|
||||
/** Roadside districts: each chapter raises its own landscape out of the ground
|
||||
* (server rows for Infrastructure, a workshop and cranes for Builds, a container
|
||||
* yard, radio masts, a destination arch). The kit is authored in Blender
|
||||
* (assets/blender/districts) and drawn as instanced, script-wrapped geometry,
|
||||
* like the expressway furniture. Districts at rest are hidden, so only the
|
||||
* current chapter's pieces cost draw calls. */
|
||||
export type DistrictPiece =
|
||||
| 'gantry'
|
||||
| 'billboard'
|
||||
| 'rack-row'
|
||||
| 'mast'
|
||||
| 'workshop'
|
||||
| 'crane'
|
||||
| 'container'
|
||||
| 'arch';
|
||||
|
||||
interface Placement {
|
||||
x: number;
|
||||
z: number;
|
||||
y?: number;
|
||||
/** Yaw in radians, applied before mirroring. */
|
||||
ry?: number;
|
||||
s?: number;
|
||||
/** Extra vertical stretch (gantries clear the chase camera). */
|
||||
sy?: number;
|
||||
}
|
||||
|
||||
/** How strongly each chapter raises a piece (0 = flat and hidden). */
|
||||
export const DISTRICT_PRESENCE: Readonly<
|
||||
Record<DistrictPiece, Partial<Record<SectionId, number>>>
|
||||
> = {
|
||||
gantry: {
|
||||
profile: 1,
|
||||
experience: 1,
|
||||
infrastructure: 1,
|
||||
'selected-builds': 1,
|
||||
'additional-work': 1,
|
||||
toolkit: 1,
|
||||
},
|
||||
billboard: { profile: 1, experience: 1 },
|
||||
'rack-row': { infrastructure: 1 },
|
||||
mast: { infrastructure: 0.75, toolkit: 1 },
|
||||
workshop: { 'selected-builds': 1, 'additional-work': 0.5 },
|
||||
crane: { 'selected-builds': 1, 'additional-work': 1 },
|
||||
container: { 'additional-work': 1 },
|
||||
arch: { contact: 1 },
|
||||
};
|
||||
|
||||
const span = (count: number, from: number, to: number) =>
|
||||
Array.from({ length: count }, (_, i) => from + ((to - from) * i) / count);
|
||||
|
||||
/** Placements in three.js units: negative x is the left verge. Pieces face +x;
|
||||
* right-side copies are mirrored so they face the carriageway too. */
|
||||
function placementsFor(piece: DistrictPiece, dense: boolean): Placement[] {
|
||||
const n = (full: number) => (dense ? full : Math.ceil(full / 2));
|
||||
switch (piece) {
|
||||
case 'gantry':
|
||||
return span(n(3), -70, -70 - WORLD_DEPTH).map((z) => ({ x: 0, z, sy: 1.42 }));
|
||||
case 'billboard':
|
||||
return span(n(10), -40, -40 - WORLD_DEPTH).map((z, i) => ({
|
||||
x: (i % 2 ? 1 : -1) * 16,
|
||||
z,
|
||||
ry: (i % 2 ? 1 : -1) * 0.28,
|
||||
}));
|
||||
case 'rack-row':
|
||||
return span(n(18), -26, -26 - WORLD_DEPTH).flatMap((z, i) => {
|
||||
const side = i % 2 ? 1 : -1;
|
||||
return [
|
||||
{ x: side * 14.6, z, s: 1.5 },
|
||||
{ x: side * 19.2, z: z - 3, s: 1.5 },
|
||||
];
|
||||
});
|
||||
case 'mast':
|
||||
return span(n(6), -90, -90 - WORLD_DEPTH).map((z, i) => ({
|
||||
x: (i % 2 ? 1 : -1) * (30 + (i % 3) * 7),
|
||||
z,
|
||||
ry: i * 0.6,
|
||||
}));
|
||||
case 'workshop':
|
||||
return span(n(6), -48, -48 - WORLD_DEPTH).map((z, i) => ({
|
||||
x: (i % 2 ? 1 : -1) * 17,
|
||||
z,
|
||||
s: 1.3,
|
||||
}));
|
||||
case 'crane':
|
||||
return span(n(4), -120, -120 - WORLD_DEPTH).map((z, i) => ({
|
||||
x: (i % 2 ? 1 : -1) * 34,
|
||||
z,
|
||||
ry: (i % 2 ? 1 : -1) * 0.9 + i * 0.4,
|
||||
}));
|
||||
case 'container':
|
||||
return span(n(12), -30, -30 - WORLD_DEPTH).flatMap((z, i) => {
|
||||
const side = i % 2 ? 1 : -1;
|
||||
const stack: Placement[] = [
|
||||
{ x: side * 16, z, ry: Math.PI / 2, s: 1.2 },
|
||||
{ x: side * 16.3, z: z - 3.2, ry: Math.PI / 2, s: 1.2 },
|
||||
];
|
||||
if (i % 3 !== 1) stack.push({ x: side * 16.1, y: 3.14, z: z - 1.5, ry: Math.PI / 2 + 0.06, s: 1.2 });
|
||||
return stack;
|
||||
});
|
||||
case 'arch':
|
||||
return span(2, -60, -60 - WORLD_DEPTH).map((z) => ({ x: 0, z, sy: 1.15 }));
|
||||
}
|
||||
}
|
||||
|
||||
const VERTEX = `
|
||||
uniform float uDistance;
|
||||
uniform float uRise;
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
varying float vRise;
|
||||
varying float vHeight;
|
||||
void main() {
|
||||
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||
float stagger = fract(sin(dot(origin.xz, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
float rise = clamp(uRise * 1.45 - stagger * 0.45, 0.0, 1.0);
|
||||
rise = rise * rise * (3.0 - 2.0 * rise);
|
||||
vRise = rise;
|
||||
vHeight = position.y;
|
||||
vec4 p = instanceMatrix * vec4(position.x, position.y * rise - (1.0 - rise) * 0.4, position.z, 1.0);
|
||||
p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z;
|
||||
vWorld = p.xyz;
|
||||
vec4 view = modelViewMatrix * p;
|
||||
vDepth = -view.z;
|
||||
gl_Position = projectionMatrix * view;
|
||||
}
|
||||
`;
|
||||
|
||||
const FADES = `
|
||||
float farFade = 1.0 - smoothstep(360.0, 640.0, vDepth);
|
||||
float nearFade = smoothstep(1.5, 10.0, vDepth);
|
||||
float fade = farFade * nearFade;
|
||||
if (fade < 0.02) discard;
|
||||
`;
|
||||
|
||||
export interface DistrictsWorld {
|
||||
group: { name: string; add(...objects: unknown[]): void };
|
||||
update(
|
||||
distance: number,
|
||||
energy: number,
|
||||
section: SectionId,
|
||||
elapsed: number,
|
||||
dt: number,
|
||||
): void;
|
||||
/** Pieces currently raised (for tests and diagnostics). */
|
||||
raised(): DistrictPiece[];
|
||||
}
|
||||
|
||||
export function createDistricts(
|
||||
resources: SceneResourceBag,
|
||||
quality: RenderQualitySettings,
|
||||
signal: AbortSignal,
|
||||
): DistrictsWorld {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'roadside-districts';
|
||||
const shared = {
|
||||
uDistance: { value: 0 },
|
||||
uEnergy: { value: 0 },
|
||||
uTime: { value: 0 },
|
||||
uAccent: { value: new THREE.Color(signAccentFor('start')) },
|
||||
};
|
||||
const targetAccent = new THREE.Color(signAccentFor('start'));
|
||||
const state = new Map<
|
||||
DistrictPiece,
|
||||
{ rise: { value: number }; meshes: Mesh[] }
|
||||
>();
|
||||
const pieces = Object.keys(DISTRICT_PRESENCE) as DistrictPiece[];
|
||||
for (const piece of pieces) state.set(piece, { rise: { value: 0 }, meshes: [] });
|
||||
|
||||
const bodyMaterial = (rise: { value: number }) =>
|
||||
resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: { ...shared, uRise: rise },
|
||||
vertexShader: VERTEX,
|
||||
fragmentShader: `
|
||||
uniform vec3 uAccent;
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
varying float vRise;
|
||||
varying float vHeight;
|
||||
void main() {
|
||||
${FADES}
|
||||
vec3 n = normalize(cross(dFdx(vWorld), dFdy(vWorld)));
|
||||
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
|
||||
float rim = pow(1.0 - abs(n.y), 3.0) * 0.5;
|
||||
vec3 color = vec3(0.03, 0.016, 0.05) + vec3(0.085, 0.06, 0.14) * key;
|
||||
color += uAccent * rim * 0.06 * (0.4 + 0.6 * smoothstep(0.0, 12.0, vHeight));
|
||||
gl_FragColor = vec4(color, fade * smoothstep(0.0, 0.25, vRise));
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: true,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
const lightMaterial = (rise: { value: number }, accentMix: number) =>
|
||||
resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: { ...shared, uRise: rise },
|
||||
vertexShader: VERTEX,
|
||||
fragmentShader: `
|
||||
uniform vec3 uAccent;
|
||||
uniform float uEnergy;
|
||||
uniform float uTime;
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
varying float vRise;
|
||||
void main() {
|
||||
${FADES}
|
||||
// Neon tubes catch as the district finishes rising: a brief
|
||||
// stutter, then steady (no per-instance CPU work).
|
||||
float on = smoothstep(0.8, 1.0, vRise);
|
||||
float stutter = step(0.35, fract(sin(floor(uTime * 22.0) + vWorld.z * 0.07) * 43758.5));
|
||||
on *= mix(stutter, 1.0, step(0.985, vRise));
|
||||
vec3 tone = mix(vec3(0.55, 0.95, 1.0), uAccent, ${accentMix.toFixed(2)});
|
||||
// Close passes dim, so overhead neon never floods the bloom pass.
|
||||
float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth));
|
||||
vec3 color = tone * (1.1 + uEnergy * 0.4) * near;
|
||||
gl_FragColor = vec4(color * on * fade, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
|
||||
const dummy = new THREE.Object3D();
|
||||
const install = (piece: DistrictPiece, body: Geometry, light: Geometry) => {
|
||||
const entry = state.get(piece);
|
||||
if (!entry) return;
|
||||
const placements = placementsFor(piece, quality.postEffects);
|
||||
const accentMix = piece === 'gantry' || piece === 'arch' || piece === 'billboard' ? 1 : 0.35;
|
||||
for (const [geometry, material] of [
|
||||
[body, bodyMaterial(entry.rise)],
|
||||
[light, lightMaterial(entry.rise, accentMix)],
|
||||
] as const) {
|
||||
resources.own('geometry', geometry);
|
||||
const mesh = resources.own(
|
||||
'other',
|
||||
new THREE.InstancedMesh(geometry, material, placements.length),
|
||||
);
|
||||
mesh.name = `district-${piece}`;
|
||||
mesh.frustumCulled = false;
|
||||
mesh.visible = false;
|
||||
placements.forEach((placement, i) => {
|
||||
const mirror = placement.x > 0 ? -1 : 1;
|
||||
dummy.position.set(placement.x, placement.y ?? 0, placement.z);
|
||||
dummy.rotation.set(0, (placement.ry ?? 0) * mirror, 0);
|
||||
const s = placement.s ?? 1;
|
||||
dummy.scale.set(s * mirror, s * (placement.sy ?? 1), s);
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(i, dummy.matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
entry.meshes.push(mesh);
|
||||
group.add(mesh);
|
||||
}
|
||||
};
|
||||
|
||||
// The kit streams in beside the first frame; a failed fetch leaves the road
|
||||
// exactly as it was before districts existed.
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(districtsUrl, { signal });
|
||||
if (!response.ok) return;
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (signal.aborted) return;
|
||||
const gltf = await new GLTFLoader().parseAsync(bytes, districtsUrl);
|
||||
if (signal.aborted) return;
|
||||
for (const piece of pieces) {
|
||||
const body = gltf.scene.getObjectByName(`${piece}_body`) as Mesh | undefined;
|
||||
const light = gltf.scene.getObjectByName(`${piece}_light`) as Mesh | undefined;
|
||||
if (body?.geometry && light?.geometry) install(piece, body.geometry, light.geometry);
|
||||
}
|
||||
} catch {
|
||||
/* Districts are optional scenery. */
|
||||
}
|
||||
})();
|
||||
|
||||
let current: SectionId = 'start';
|
||||
return {
|
||||
group,
|
||||
update(distance, energy, section, elapsed, dt) {
|
||||
current = section;
|
||||
shared.uDistance.value = distance;
|
||||
shared.uEnergy.value = energy;
|
||||
shared.uTime.value = elapsed;
|
||||
targetAccent.set(signAccentFor(section));
|
||||
shared.uAccent.value.lerp(targetAccent, 1 - Math.exp(-dt * 2.5));
|
||||
for (const [piece, entry] of state) {
|
||||
const target = DISTRICT_PRESENCE[piece][section] ?? 0;
|
||||
const rate = target > entry.rise.value ? 0.9 : 1.4;
|
||||
entry.rise.value += (target - entry.rise.value) * (1 - Math.exp(-dt * rate));
|
||||
if (Math.abs(target - entry.rise.value) < 0.002) entry.rise.value = target;
|
||||
const visible = entry.rise.value > 0.002;
|
||||
for (const mesh of entry.meshes) mesh.visible = visible;
|
||||
}
|
||||
},
|
||||
raised: () =>
|
||||
pieces.filter((piece) => (DISTRICT_PRESENCE[piece][current] ?? 0) > 0),
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 { createDistricts } from '@/scene/world/districts';
|
||||
import { createSignStage } from '@/scene/world/sign-stage';
|
||||
import { usesRoadsideSign } from '@/scene/world/sign-layout';
|
||||
import { createSpeedWake } from '@/scene/world/speed-wake';
|
||||
@@ -116,6 +117,7 @@ export async function createProceduralSceneGeneration(
|
||||
const terrain = createTerrain(context.resources, quality);
|
||||
const speedWake = createSpeedWake(context.resources);
|
||||
const expressway = createExpressway(context.resources, quality);
|
||||
const districts = createDistricts(context.resources, quality, context.signal);
|
||||
const signStage = createSignStage(context.resources);
|
||||
const sun = createSun(context.resources);
|
||||
const horizonAir = createHorizonAir(context.resources);
|
||||
@@ -132,6 +134,7 @@ export async function createProceduralSceneGeneration(
|
||||
atmosphere.group,
|
||||
road.group,
|
||||
expressway.group,
|
||||
districts.group,
|
||||
terrain.group,
|
||||
sun.group,
|
||||
horizonAir.group,
|
||||
@@ -306,6 +309,13 @@ export async function createProceduralSceneGeneration(
|
||||
snapshot.activeSectionId,
|
||||
deltaSeconds,
|
||||
);
|
||||
districts.update(
|
||||
driveDistance,
|
||||
response.roadEnergy,
|
||||
snapshot.activeSectionId,
|
||||
atmosphericTime,
|
||||
deltaSeconds,
|
||||
);
|
||||
terrain.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
|
||||
Reference in New Issue
Block a user