feat: Blender-authored roadside districts that rise per chapter

This commit is contained in:
2026-10-10 18:25:55 +03:00
parent 71959f7541
commit a170abc093
10 changed files with 693 additions and 1 deletions

View 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),
};
}

View File

@@ -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,