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,239 @@
"""Author the roadside district kit in its own scene and library.
Run headless only, so no open session is touched:
blender -b --factory-startup --python assets/blender/districts/author-districts.py
It builds the "Roadside Districts" scene from scratch, drops the factory scene and
saves nightdrive-districts.blend. Convention: Blender +X faces the road (three.js +X), +Y runs
down the road (three.js -Z), Z is up; every piece stands on z=0 at its origin.
Each piece is two meshes: <piece>_body (graphite) and <piece>_light (emissive).
"""
import bpy, bmesh, math
from mathutils import Matrix, Euler, Vector
from pathlib import Path
if not bpy.app.background:
raise SystemExit('Run author-districts.py headless (blender -b); it saves a new file.')
SCENE = 'Roadside Districts'
LIB = Path(__file__).resolve().parent / 'nightdrive-districts.blend'
if SCENE in bpy.data.scenes:
old = bpy.data.scenes[SCENE]
for obj in list(old.objects):
mesh = obj.data
bpy.data.objects.remove(obj)
if mesh and mesh.users == 0:
bpy.data.meshes.remove(mesh)
bpy.data.scenes.remove(old)
scene = bpy.data.scenes.new(SCENE)
class Part:
def __init__(self):
self.bm = bmesh.new()
def box(self, c, s, r=(0, 0, 0)):
geom = bmesh.ops.create_cube(self.bm, size=1.0)
m = Matrix.Translation(Vector(c)) @ Euler([math.radians(a) for a in r]).to_matrix().to_4x4() @ Matrix.Diagonal(Vector((*s, 1.0)))
bmesh.ops.transform(self.bm, matrix=m, verts=geom['verts'])
def beam(self, a, b, t):
"""Square beam of thickness t between points a and b."""
a, b = Vector(a), Vector(b)
d = b - a
geom = bmesh.ops.create_cube(self.bm, size=1.0)
rot = d.to_track_quat('Z', 'Y').to_matrix().to_4x4()
m = Matrix.Translation((a + b) / 2) @ rot @ Matrix.Diagonal(Vector((t, t, d.length, 1.0)))
bmesh.ops.transform(self.bm, matrix=m, verts=geom['verts'])
def piece(name, body, light):
for suffix, part in (('body', body), ('light', light)):
bmesh.ops.remove_doubles(part.bm, verts=part.bm.verts, dist=1e-4)
mesh = bpy.data.meshes.new(f'{name}_{suffix}')
part.bm.to_mesh(mesh)
part.bm.free()
obj = bpy.data.objects.new(f'{name}_{suffix}', mesh)
scene.collection.objects.link(obj)
# Overhead gantry spanning the carriageway (x -11..11 in three.js units).
b, l = Part(), Part()
for x in (-11.2, 11.2):
b.box((x, 0, 0.2), (1.2, 1.6, 0.4))
b.box((x, -0.45, 3.9), (0.34, 0.34, 7.4))
b.box((x, 0.45, 3.9), (0.34, 0.34, 7.4))
for z in (1.6, 3.4, 5.2):
b.beam((x, -0.45, z), (x, 0.45, z + 1.6), 0.12)
for y in (-0.45, 0.45):
for z in (6.3, 7.5):
b.box((0, y, z), (22.8, 0.22, 0.22))
for i in range(12):
x = -10.4 + i * 1.9
b.beam((x, -0.45, 6.3), (x + 1.9, -0.45, 7.5), 0.1)
b.beam((x, 0.45, 6.3), (x + 1.9, 0.45, 7.5), 0.1)
b.box((x, 0, 6.3), (0.1, 0.9, 0.1))
for cx, w in ((-5.2, 7.0), (4.6, 6.0)):
b.box((cx, -0.62, 5.0), (w, 0.14, 2.6))
l.box((cx, -0.71, 6.32), (w, 0.04, 0.07))
l.box((cx, -0.71, 3.68), (w, 0.04, 0.07))
for k, f in enumerate((0.62, 0.4, 0.5)):
l.box((cx - w * (1 - f) / 2 + 0.4, -0.71, 5.6 - k * 0.55), (w * f - 0.8, 0.04, 0.16))
l.box((-1.4, -0.71, 4.4), (0.9, 0.04, 0.16), (0, 45, 0))
l.box((-1.65, -0.71, 4.6), (0.16, 0.04, 0.7))
for x in range(-10, 11, 4):
l.box((x, 0, 6.15), (0.6, 0.12, 0.05))
piece('gantry', b, l)
# Open-air server row with an overhead cable tray (Infrastructure).
b, l = Part(), Part()
for i in range(6):
y = -2.75 + i * 1.1
b.box((0, y, 1.15), (1.2, 1.0, 2.3))
b.box((0, y, 2.36), (1.0, 0.9, 0.12))
for k in range(9):
width = (0.82, 0.62, 0.74, 0.4, 0.82, 0.55, 0.7, 0.3, 0.6)[(k + i * 2) % 9]
l.box((0.61, y - (0.82 - width) / 2, 0.45 + k * 0.2), (0.03, width, 0.045))
l.box((0.61, y + 0.38, 2.12), (0.03, 0.08, 0.08))
for y in (-3.4, 3.4):
b.box((-0.3, y, 1.6), (0.18, 0.18, 3.2))
b.box((0.2, y, 3.15), (1.2, 0.16, 0.12))
b.box((0.35, 0, 3.05), (0.04, 7.0, 0.16))
b.box((-0.35, 0, 3.05), (0.04, 7.0, 0.16))
for k in range(12):
b.box((0, -3.3 + k * 0.6, 3.0), (0.7, 0.05, 0.04))
l.box((0, 0, 2.94), (0.5, 6.8, 0.03))
piece('rack-row', b, l)
# Lattice radio mast with dishes (Toolkit / Infrastructure horizon).
b, l = Part(), Part()
H = 26.0
corners = [(-1, -1), (1, -1), (1, 1), (-1, 1)]
def half(z):
return 1.6 - z * 0.045
for cx, cy in corners:
b.beam((cx * half(0), cy * half(0), 0), (cx * half(H), cy * half(H), H), 0.16)
for lv in range(9):
z0, z1 = lv * H / 9, (lv + 1) * H / 9
for j in range(4):
(ax, ay), (bx, by) = corners[j], corners[(j + 1) % 4]
b.beam((ax * half(z1), ay * half(z1), z1), (bx * half(z1), by * half(z1), z1), 0.09)
b.beam((ax * half(z0), ay * half(z0), z0), (bx * half(z1), by * half(z1), z1), 0.07)
b.box((0, 0, H + 3), (0.12, 0.12, 6))
for z, ang in ((18, 30), (13, -40), (21, 160)):
d = Vector((math.cos(math.radians(ang)), math.sin(math.radians(ang)), 0))
b.box(tuple(d * 1.4 + Vector((0, 0, z))), (0.18, 1.4, 1.4), (0, 0, ang))
for z in (H + 6, H, 17, 9):
l.box((0, 0, z), (0.34, 0.34, 0.34))
piece('mast', b, l)
# Workshop with half-open roller door and a lift (Selected builds).
b, l = Part(), Part()
W, D, Hh = 8.0, 6.0, 4.2
b.box((-D, 0, Hh / 2), (0.2, W, Hh))
b.box((-D / 2, -W / 2, Hh / 2), (D, 0.2, Hh))
b.box((-D / 2, W / 2, Hh / 2), (D, 0.2, Hh))
for s in (-1, 1):
b.box((-D / 2, s * W / 4, Hh + 0.75), (D + 0.4, W / 2 + 0.25, 0.14), (s * 20.5, 0, 0))
b.box((0, -W / 2 + 0.3, Hh / 2), (0.3, 0.6, Hh))
b.box((0, W / 2 - 0.3, Hh / 2), (0.3, 0.6, Hh))
for k in range(5):
b.box((0.05, 0, Hh - 0.25 - k * 0.32), (0.1, W - 1.2, 0.26))
b.box((-4.8, -2.2, 0.45), (1.6, 1.0, 0.9))
for y in (0.4, 3.0):
b.box((-2.6, y, 1.1), (0.25, 0.25, 2.2))
b.box((-2.6, 1.7, 1.15), (2.4, 2.8, 0.08))
l.box((-D / 2, 0, Hh + 0.1), (D - 0.4, 0.06, 0.05))
l.box((0.25, 0, Hh + 0.35), (0.04, W - 1.4, 0.32))
l.box((0.22, -W / 2 + 0.62, Hh / 2), (0.04, 0.05, Hh))
l.box((0.22, W / 2 - 0.62, Hh / 2), (0.04, 0.05, Hh))
l.box((-4.0, -2.2, 0.96), (0.9, 0.5, 0.03))
piece('workshop', b, l)
# Tower crane (Selected builds / Additional work horizon).
b, l = Part(), Part()
Hc = 30.0
for cx, cy in corners:
b.box((cx * 0.75, cy * 0.75, Hc / 2), (0.18, 0.18, Hc))
for lv in range(10):
z = (lv + 1) * Hc / 10
for j in range(4):
(ax, ay), (bx, by) = corners[j], corners[(j + 1) % 4]
b.beam((ax * 0.75, ay * 0.75, z), (bx * 0.75, by * 0.75, z), 0.08)
b.beam((ax * 0.75, ay * 0.75, z - Hc / 10), (bx * 0.75, by * 0.75, z), 0.06)
b.box((0, 0, Hc + 0.8), (2.2, 2.2, 1.6))
b.box((0, 0, Hc + 4), (0.3, 0.3, 4.8))
J = 26.0
for z in (Hc + 1.6, Hc + 2.8):
b.box((J / 2 - 3, 0.5 if z < Hc + 2 else 0, z), (J, 0.18, 0.18))
b.box((J / 2 - 3, -0.5, Hc + 1.6), (J, 0.18, 0.18))
for k in range(9):
x = -3 + k * J / 9
b.beam((x, 0, Hc + 2.8), (x + J / 9, 0.5, Hc + 1.6), 0.07)
b.beam((x, 0, Hc + 2.8), (x + J / 9, -0.5, Hc + 1.6), 0.07)
b.box((-7, 0, Hc + 1.7), (8, 0.8, 0.3))
b.box((-9.5, 0, Hc + 1.0), (2.2, 1.6, 1.4))
b.beam((J - 3, 0, Hc + 6.4), (-9, 0, Hc + 2.0), 0.05)
b.box((15, 0, Hc - 6), (0.05, 0.05, 12))
b.box((15, 0, Hc - 12.2), (0.8, 0.6, 0.5))
l.box((0, 0, Hc + 6.5), (0.4, 0.4, 0.4))
l.box((J - 3, 0, Hc + 2.0), (0.36, 0.36, 0.36))
l.box((15, 0, Hc - 12.6), (0.5, 0.3, 0.06))
piece('crane', b, l)
# Shipping container, stackable (Additional work).
b, l = Part(), Part()
b.box((0, 0, 1.3), (2.44, 6.06, 2.6))
for k in range(14):
y = -2.7 + k * 0.415
b.box((1.24, y, 1.3), (0.06, 0.18, 2.36))
b.box((-1.24, y, 1.3), (0.06, 0.18, 2.36))
for x in (-1.15, 1.15):
for y in (-3.0, 3.0):
b.box((x, y, 1.3), (0.16, 0.16, 2.62))
l.box((1.28, 0, 2.56), (0.03, 5.9, 0.05))
l.box((0.0, -3.06, 1.3), (1.8, 0.03, 0.05))
piece('container', b, l)
# Roadside billboard (Profile / Experience).
b, l = Part(), Part()
for y in (-2.5, 2.5):
b.box((0, y, 3.0), (0.3, 0.3, 6.0))
b.box((0.1, 0, 7.0), (0.24, 8.4, 3.6))
b.box((0.6, 0, 5.05), (1.0, 8.2, 0.08))
l.box((0.24, 0, 8.86), (0.04, 8.4, 0.08))
l.box((0.24, 0, 5.14), (0.04, 8.4, 0.08))
l.box((0.24, -4.2, 7.0), (0.04, 0.08, 3.8))
l.box((0.24, 4.2, 7.0), (0.04, 0.08, 3.8))
for k, f in enumerate((0.7, 0.45, 0.55)):
l.box((0.24, -4.2 + 8.4 * f / 2 + 0.6, 8.0 - k * 0.75), (0.04, 8.4 * f - 1.2, 0.22))
for y in (-3, 0, 3):
l.box((1.05, y, 5.15), (0.2, 0.5, 0.08))
piece('billboard', b, l)
# Destination arch over the road (Contact).
b, l = Part(), Part()
for s in (-1, 1):
b.box((s * 12.5, 0, 7.0), (1.4, 1.4, 14.0))
b.box((s * 12.5, 0, 0.3), (2.4, 2.4, 0.6))
l.box((s * 11.78, 0, 7.0), (0.05, 0.12, 12.4))
b.box((0, 0, 14.6), (27.4, 1.6, 1.4))
b.box((0, 0, 16.0), (18.0, 1.0, 1.4))
l.box((0, -0.82, 14.2), (25.0, 0.04, 0.12))
l.box((0, -0.82, 15.0), (25.0, 0.04, 0.05))
l.box((0, -0.52, 16.5), (16.0, 0.04, 0.1))
for k in range(9):
l.box((-8 + k * 2, 0, 13.88), (0.9, 0.5, 0.04))
piece('arch', b, l)
tris = 0
for obj in scene.objects:
obj.data.calc_loop_triangles()
tris += len(obj.data.loop_triangles)
for other in [s for s in bpy.data.scenes if s != scene]:
bpy.data.scenes.remove(other)
for block in (bpy.data.objects, bpy.data.meshes, bpy.data.cameras, bpy.data.lights, bpy.data.materials):
for item in list(block):
if item.users == 0:
block.remove(item)
bpy.ops.wm.save_as_mainfile(filepath=str(LIB), compress=True)
print('pieces', len(scene.objects) // 2, 'triangles', tris, 'library', LIB)

View File

@@ -0,0 +1,67 @@
"""Export the district kit to the runtime GLB (positions + indices only).
blender -b assets/blender/districts/nightdrive-districts.blend --python assets/blender/districts/export-districts.py [-- --preview out.png]
Normals are derived per fragment at runtime (flat shading from screen-space
derivatives), so the GLB carries no normals, UVs or materials and shared box
corners stay welded. Writes provenance next to this script.
"""
import bpy, json, hashlib, sys, math
from pathlib import Path
here = Path(__file__).resolve().parent
root = here.parents[2]
scene = bpy.data.scenes['Roadside Districts']
output = root / 'src/assets/nightdrive-districts.glb'
for obj in scene.objects:
obj.select_set(True)
bpy.ops.export_scene.gltf(
filepath=str(output), export_format='GLB', use_selection=True, use_active_scene=True,
export_apply=True, export_normals=False, export_texcoords=False, export_materials='NONE',
export_animations=False, export_cameras=False, export_lights=False, export_yup=True,
)
triangles = 0
for obj in scene.objects:
obj.data.calc_loop_triangles()
triangles += len(obj.data.loop_triangles)
provenance = {
'asset': 'Nightdrive roadside districts',
'source': 'nightdrive-districts.blend',
'authorship': 'Original procedural modelling in Blender for this portfolio (author-districts.py); no external assets.',
'editable_scene': 'Roadside Districts',
'pieces': sorted({o.name.rsplit('_', 1)[0] for o in scene.objects}),
'triangles': triangles,
'attributes': 'POSITION + indices; flat normals derived at runtime',
'export': '../../../src/assets/nightdrive-districts.glb',
'sha256': hashlib.sha256(output.read_bytes()).hexdigest(),
}
(here / 'nightdrive-districts.asset.json').write_text(json.dumps(provenance, indent=2) + '\n')
print(json.dumps(provenance))
if '--preview' in sys.argv:
target = sys.argv[sys.argv.index('--preview') + 1]
names = provenance['pieces']
for i, name in enumerate(names):
for suffix in ('body', 'light'):
obj = scene.objects[f'{name}_{suffix}']
obj.location = ((i % 4) * 34 - 50, (i // 4) * 40, 0)
mat = bpy.data.materials.new(f'preview-{suffix}')
mat.diffuse_color = (0.05, 0.05, 0.08, 1) if suffix == 'body' else (0.1, 0.9, 1.0, 1)
obj.data.materials.append(mat)
cam = bpy.data.objects.new('preview-cam', bpy.data.cameras.new('preview-cam'))
scene.collection.objects.link(cam)
cam.location = (95, -95, 70)
cam.rotation_euler = (math.radians(58), 0, math.radians(45))
cam.data.lens = 30
scene.camera = cam
try:
scene.render.engine = 'BLENDER_WORKBENCH'
except TypeError as e:
print(e)
scene.display.shading.color_type = 'MATERIAL'
scene.display.shading.light = 'STUDIO'
scene.display.shading.show_cavity = True
scene.render.resolution_x, scene.render.resolution_y = 1600, 1000
scene.render.filepath = target
bpy.ops.render.render(write_still=True)

View File

@@ -0,0 +1,20 @@
{
"asset": "Nightdrive roadside districts",
"source": "nightdrive-districts.blend",
"authorship": "Original procedural modelling in Blender for this portfolio (author-districts.py); no external assets.",
"editable_scene": "Roadside Districts",
"pieces": [
"arch",
"billboard",
"container",
"crane",
"gantry",
"mast",
"rack-row",
"workshop"
],
"triangles": 5424,
"attributes": "POSITION + indices; flat normals derived at runtime",
"export": "../../../src/assets/nightdrive-districts.glb",
"sha256": "428a212792ac4d8dbf86b9224e9788f49cfc22b3d0dd1b72dfaf33c89f08f189"
}

Binary file not shown.

View File

@@ -38,7 +38,7 @@ export const classifyArtifact = (path, source = '') => {
if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js';
if (/^assets\/(?:environment-high|high-tier)-[^/]+\.(?:js|avif|webp|png|jpe?g)$/u.test(path)) return 'high-tier-extras';
if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene';
if (/^assets\/nightdrive-grand-tourer-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
if (/^assets\/project-[^/]+\.(?:webm|mp4)$/u.test(path)) return 'project-video';

Binary file not shown.

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,

View File

@@ -62,6 +62,7 @@ describe('release budgets', () => {
expect(classifyArtifact('assets/index-a.js', 'import("./environment-b.js")')).toBe('initial-application-js');
expect(classifyArtifact('assets/environment-b.js', 'three scene')).toBe('first-live-scene');
expect(classifyArtifact('assets/nightdrive-grand-tourer-a.glb', '')).toBe('first-live-scene');
expect(classifyArtifact('assets/nightdrive-districts-a.glb', '')).toBe('first-live-scene');
expect(classifyArtifact('assets/nightdrive-main.opus', '')).toBe('soundtrack-audio');
expect(classifyArtifact('assets/nightdrive-intro.mp3', '')).toBe('soundtrack-audio');
expect(classifyArtifact('assets/nightdrive-afterglow.opus', '')).toBe('soundtrack-audio');

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { DISTRICT_PRESENCE } from "@/scene/world/districts";
const provenance = JSON.parse(
readFileSync("assets/blender/districts/nightdrive-districts.asset.json", "utf8"),
) as { pieces: string[]; triangles: number };
describe("roadside districts", () => {
it("raises every authored piece in at least one chapter and nothing else", () => {
expect(Object.keys(DISTRICT_PRESENCE).sort()).toEqual(provenance.pieces);
for (const presence of Object.values(DISTRICT_PRESENCE)) {
const strengths = Object.values(presence);
expect(strengths.length).toBeGreaterThan(0);
for (const strength of strengths) {
expect(strength).toBeGreaterThan(0);
expect(strength).toBeLessThanOrEqual(1);
}
}
});
it("keeps the opening and the destination clear of mid-route districts", () => {
for (const [piece, presence] of Object.entries(DISTRICT_PRESENCE)) {
expect(presence.start, piece).toBeUndefined();
if (piece !== "arch") expect(presence.contact, piece).toBeUndefined();
}
});
it("stays a light kit", () => {
expect(provenance.triangles).toBeLessThan(8000);
});
});