diff --git a/assets/blender/districts/author-districts.py b/assets/blender/districts/author-districts.py new file mode 100644 index 0000000..4003e17 --- /dev/null +++ b/assets/blender/districts/author-districts.py @@ -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: _body (graphite) and _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) diff --git a/assets/blender/districts/export-districts.py b/assets/blender/districts/export-districts.py new file mode 100644 index 0000000..bd02ba4 --- /dev/null +++ b/assets/blender/districts/export-districts.py @@ -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) diff --git a/assets/blender/districts/nightdrive-districts.asset.json b/assets/blender/districts/nightdrive-districts.asset.json new file mode 100644 index 0000000..5cff713 --- /dev/null +++ b/assets/blender/districts/nightdrive-districts.asset.json @@ -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" +} diff --git a/assets/blender/districts/nightdrive-districts.blend b/assets/blender/districts/nightdrive-districts.blend new file mode 100644 index 0000000..cb307a1 Binary files /dev/null and b/assets/blender/districts/nightdrive-districts.blend differ diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs index a600d8e..bbdbc08 100644 --- a/scripts/lib/bundle-budgets.mjs +++ b/scripts/lib/bundle-budgets.mjs @@ -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'; diff --git a/src/assets/nightdrive-districts.glb b/src/assets/nightdrive-districts.glb new file mode 100644 index 0000000..b1f80dc Binary files /dev/null and b/src/assets/nightdrive-districts.glb differ diff --git a/src/scene/world/districts.ts b/src/scene/world/districts.ts new file mode 100644 index 0000000..fe482aa --- /dev/null +++ b/src/scene/world/districts.ts @@ -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>> +> = { + 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 + #include + } + `, + 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 + #include + } + `, + 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), + }; +} diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 6488086..8080744 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -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, diff --git a/tests/performance/budgets.test.ts b/tests/performance/budgets.test.ts index b0afb81..10ebfa4 100644 --- a/tests/performance/budgets.test.ts +++ b/tests/performance/budgets.test.ts @@ -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'); diff --git a/tests/unit/districts.test.ts b/tests/unit/districts.test.ts new file mode 100644 index 0000000..329620f --- /dev/null +++ b/tests/unit/districts.test.ts @@ -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); + }); +});