feat: sculpt portfolio into an immersive midnight expressway

Author physical road furniture, layered terrain and atmosphere, projected illuminated route signs, and detailed Blender coachwork. Refine camera mass, wheel contact, chapter composition, and compact reading space while preserving approved content and audio contracts.

Include isolated Blender source, before/after and hardware Chrome motion evidence, preservation hashes, validated preview checks, and documented inherited unit failures.
This commit is contained in:
2026-09-05 05:10:17 +03:00
parent c9ac36e249
commit b188cac7ed
74 changed files with 3168 additions and 1223 deletions

View File

@@ -0,0 +1,12 @@
# Expressway grand tourer
Original portfolio coachwork developed from the approved `../evolution/nightdrive-evolution.blend` at `c9ac36e`. No third-party model, texture, or generated project evidence is used.
- `nightdrive-expressway.blend`: isolated editable source. Open the **Midnight Expressway** scene. Body components, optical cells, intake vanes, wheel machining, panel seams, wing details, and look-development setup remain editable.
- `author-expressway.py`: appends the approved source into a separate scene and authors this edition. Run once in a fresh Blender session with `__file__` set to the script path. It saves only the isolated expressway library; it does not overwrite the approved evolution source.
- `export-expressway.py`: run with the expressway source open. It evaluates modifiers into a separate export scene, batches by material and wheel, exports the runtime GLB, and writes its provenance JSON. Four wheel parents remain independently rotatable. Cameras and look-development lights are excluded.
- `nightdrive-expressway.asset.json`: export path, SHA-256, triangle count, and source lineage.
Runtime: 31,288 triangles, 19 material batches, four independently rotating wheel groups. Geometry follows the existing Blender-to-glTF coordinate convention and runtime scale. Body suspension is applied separately from grounded wheels.
The runtime asset is `src/assets/nightdrive-grand-tourer-expressway.glb`. The review contains a Blender look-development render and actual in-browser views. The Static-mode JPEGs are captures of this portfolio's live Three.js world; they contain no fabricated project media.

View File

@@ -0,0 +1,124 @@
"""Append the approved coachwork into its own scene; author only the new edition.
Run inside Blender with __file__ pointing here. No open project is overwritten.
"""
import bpy, math
from pathlib import Path
from mathutils import Vector
root_path = Path(__file__).resolve().parents[3]
source = root_path / 'assets/blender/evolution/nightdrive-evolution.blend'
with bpy.data.libraries.load(str(source), link=False) as (available, loaded):
loaded.scenes = ['Portfolio Evolution']
scene = loaded.scenes[0]
scene.name = 'Midnight Expressway'
bpy.context.window.scene = scene
car = next(o for o in scene.objects if o.type=='EMPTY' and o.name.startswith('chase-car'))
materials = {m.name.split('.')[0]: m for o in scene.objects if o.type=='MESH' for m in o.data.materials}
def finish(obj, name, material, bevel=0.007):
obj.name = 'expressway-' + name
obj.parent = car
obj.data.materials.append(materials[material])
if bevel:
mod = obj.modifiers.new('Machined radii', 'BEVEL'); mod.width=bevel; mod.segments=2
obj.modifiers.new('Surface normals', 'WEIGHTED_NORMAL')
return obj
def box(name, loc, dims, mat, bevel=0.007):
bpy.ops.mesh.primitive_cube_add(size=1, location=loc)
o=bpy.context.object; o.dimensions=dims
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return finish(o,name,mat,bevel)
def shell(name, vertices, faces, mat, bevel=0.005):
mesh=bpy.data.meshes.new(name); mesh.from_pydata(vertices,[],faces); mesh.update()
o=bpy.data.objects.new(name,mesh); scene.collection.objects.link(o)
return finish(o,name,mat,bevel)
def line(name, coords, mat, radius=0.004):
curve=bpy.data.curves.new(name,'CURVE'); curve.dimensions='3D'
curve.resolution_u=1; curve.bevel_depth=radius; curve.bevel_resolution=2
spline=curve.splines.new('POLY'); spline.points.add(len(coords)-1)
for p,co in zip(spline.points,coords): p.co=(*co,1)
obj=bpy.data.objects.new(name,curve); scene.collection.objects.link(obj)
obj.parent=car; obj.data.materials.append(materials[mat]); obj.name='expressway-'+name
return obj
# Material story: cold painted metal, brushed alloy, smoke and deep graphite.
for name,color,metal,rough in [
('M_Body_Gunmetal',(0.18,0.28,0.36,1),0.72,0.27),
('M_Body_Haunch',(0.30,0.41,0.48,1),0.78,0.22),
('M_Lower_Graphite',(0.018,0.028,0.039,1),0.6,0.38),
('M_Wheel',(0.45,0.53,0.57,1),0.92,0.21),
('M_Tire',(0.009,0.012,0.016,1),0.0,0.9),
('M_Glass_Smoke',(0.014,0.045,0.065,1),0.36,0.14)]:
bsdf=materials[name].node_tree.nodes.get('Principled BSDF')
bsdf.inputs['Base Color'].default_value=color
bsdf.inputs['Metallic'].default_value=metal; bsdf.inputs['Roughness'].default_value=rough
if 'Body' in name:
bsdf.inputs['Coat Weight'].default_value=0.42; bsdf.inputs['Coat Roughness'].default_value=0.19
# Replace the plain lamp with fourteen paired optical cells and lit outer returns.
for obj in list(scene.objects):
if obj.name.startswith(('rear-light-bar','evolution-lamp-divider','signature-turbine-vane')):
bpy.data.objects.remove(obj,do_unlink=True)
box('rear-optical-cassette',(0,2.684,0.700),(1.79,0.06,0.21),'M_Lower_Graphite',0.025)
for side in [-1,1]:
for i in range(7):
x=side*(0.11+i*0.109)
for z in [0.664,0.746]:
box('rear-optic-cell',(x,2.720,z),(0.091,0.022,0.034),'M_Rear_Light',0.006)
box('lamp-return',(side*0.852,2.704,0.705),(0.025,0.04,0.139),'M_Rear_Light',0.009)
# Side intake recess, bevelled upper lip and three angled internal vanes.
x=side*1.073
box('side-intake',(x,0.16,0.53),(0.027,1.03,0.255),'M_Lower_Graphite',0.033)
box('intake-upper-lip',(side*1.093,0.13,0.670),(0.035,1.09,0.025),'M_Body_Haunch')
for i in range(4):
o=box('intake-vane',(side*1.095,-0.18+i*0.22,0.54),(0.025,0.022,0.21),'M_Wheel',0.003)
o.rotation_euler.x=-0.32
# Deep lower blade has a distinct kicked-up rear end.
poly=[(side*1.05,-1.03,0.24),(side*1.13,-1.03,0.20),(side*1.13,1.1,0.20),(side*1.11,1.30,0.36),(side*1.05,1.30,0.36)]
shell('sculpted-skirt',poly,[tuple(range(5))],'M_Body_Haunch')
line('skirt-channel',[(side*1.137,-0.8,0.224),(side*1.137,0.92,0.224),(side*1.117,1.17,0.31)],'M_Cyan_Accent',0.006)
# Rear deck channel and restrained panel separation follow the wedge surfaces.
line('rear-deck-seam',[(side*.72,1.12,.928),(side*.76,1.78,.876),(side*.85,2.25,.825)],'M_Lower_Graphite',0.006)
line('haunch-pinstripe',[(side*.91,1.2,.905),(side*1.005,1.67,.857),(side*.997,2.20,.759)],'M_Wheel',0.0035)
for i in range(4):
box('deck-extraction-slot',(side*.49,1.36+i*.116,.906-i*.012),(.31,.039,.018),'M_Lower_Graphite',0.009)
# Larger aerofoil endplates, kinked with a recessed coral slit.
box('wing-endplate-shell',(side*1.195,2.35,1.268),(.032,.43,.24),'M_Body_Haunch',.016)
box('wing-endplate-inlay',(side*1.213,2.39,1.28),(.009,.22,.025),'M_Rear_Light',.004)
# Exhausts inside the graphite recess, with dark cores instead of solid cylinders.
for x0 in [0.29,0.43]:
x=side*x0
box('rear-exhaust-surround',(x,2.7,.37),(.105,.115,.085),'M_Wheel',.018)
box('rear-exhaust-core',(x,2.762,.37),(.079,.01,.059),'M_Lower_Graphite',.014)
for i in range(5):
box('rear-cooling-slot',(side*(.53+i*.06),2.704,.497),(.028,.018,.064),'M_Lower_Graphite',.005)
# Diffuser undertray: channels and an alloy trailing edge remain readable at speed.
line('diffuser-frame',[(-.82,2.68,.29),(-.63,2.78,.24),(.63,2.78,.24),(.82,2.68,.29)],'M_Wheel',.009)
for x in [-.66,-.33,0,.33,.66]:
shell('diffuser-strake',[(x,2.25,.22),(x,2.74,.26),(x,2.78,.105),(x,2.36,.105)],[(0,1,2,3)],'M_Lower_Graphite',0)
# Turbine machining has a dished barrel, swept spokes and a shoulder ring.
for side in [-1,1]:
for axle,y,z,radius in [('front',-1.62,.42,.42),('rear',1.62,.46,.46)]:
label=f'wheel-{axle}-{"left" if side<0 else "right"}'
for rad,xoff,thick,mat in [(radius-.016,1.153,.009,'M_Tire'),(.282 if axle=='rear' else .26,1.181,.008,'M_Wheel')]:
coords=[(side*xoff,y+math.sin(i*math.tau/64)*rad,z+math.cos(i*math.tau/64)*rad) for i in range(65)]
line(label+'-shoulder',coords,mat,thick)
for i in range(10):
angle=i*math.tau/10
verts=[]
for rad,a,xoff in [(.075,angle,1.17),(.255,angle+.30,1.193),(.263,angle+.48,1.193),(.088,angle+.35,1.17)]:
verts.append((side*xoff,y+math.sin(a)*rad,z+math.cos(a)*rad))
shell(label+'-turbine',verts,[(0,1,2,3)],'M_Wheel',.004)
bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,radius=1,location=(side*1.20,y,z))
hub=bpy.context.object; hub.scale=(.025,.064,.064)
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
finish(hub,label+'-hub','M_Lower_Graphite',0)
# Write only this scene and its own dependencies, retaining individually editable objects.
output=root_path/'assets/blender/expressway/nightdrive-expressway.blend'
bpy.data.libraries.write(str(output),{scene},fake_user=True,compress=True)
print('AUTHORED',scene.name,len(scene.objects),'objects',output)

View File

@@ -0,0 +1,51 @@
"""Export isolated authored source into material batches; leave lookdev editable."""
import bpy, json, hashlib
from mathutils import Matrix, Vector
from pathlib import Path
root_path=Path(__file__).resolve().parents[3]
scene=bpy.data.scenes['Midnight Expressway']
bpy.context.window.scene=scene
export_scene=bpy.data.scenes.new('Expressway Export')
root=bpy.data.objects.new('evolution-chase-car',None); export_scene.collection.objects.link(root)
names={'M_Body_Gunmetal':'car-body','M_Body_Haunch':'body-haunches','M_Glass_Smoke':'rear-glass','M_Rear_Light':'rear-light-bar','M_Cyan_Accent':'car-underglow-source','M_Lower_Graphite':'body-trim','M_Wheel':'wheel-machining','M_Tire':'tires'}
depsgraph=bpy.context.evaluated_depsgraph_get(); groups={}
for obj in scene.objects:
if obj.type not in ['MESH','CURVE'] or obj.name.startswith('LOOKDEV'): continue
evaluated=obj.evaluated_get(depsgraph)
mesh=bpy.data.meshes.new_from_object(evaluated,depsgraph=depsgraph); mesh.transform(obj.matrix_world)
clone=bpy.data.objects.new('export-'+obj.name,mesh); export_scene.collection.objects.link(clone)
key=obj.data.materials[0].name.split('.')[0]
wheel=None
for axle in ['front','rear']:
for side in ['left','right']:
if any(token in obj.name for token in [f'{axle}-wheel-{side}', f'{axle}-rim-{side}', f'{axle}-rim-ring-{side}', f'wheel-{axle}-{side}']):
wheel=f'wheel-{axle}-{side}'
groups.setdefault((key,wheel),[]).append(clone)
bpy.context.window.scene=export_scene
wheel_roots={}
for (key,wheel),objects in groups.items():
bpy.ops.object.select_all(action='DESELECT')
for obj in objects: obj.select_set(True)
bpy.context.view_layer.objects.active=objects[0]
if len(objects)>1: bpy.ops.object.join()
joined=bpy.context.object; joined.name='evolution-'+names[key]
if wheel:
if wheel not in wheel_roots:
axle='front' if 'front' in wheel else 'rear'; side=-1 if 'left' in wheel else 1
wheel_root=bpy.data.objects.new(wheel,None); export_scene.collection.objects.link(wheel_root)
wheel_root.parent=root; wheel_root.location=(side*1.01,-1.62 if axle=='front' else 1.62,.42 if axle=='front' else .46)
wheel_roots[wheel]=wheel_root
wheel_root=wheel_roots[wheel]
joined.data.transform(Matrix.Translation(-wheel_root.location))
joined.parent=wheel_root
else: joined.parent=root
bpy.ops.object.select_all(action='SELECT')
output=root_path/'src/assets/nightdrive-grand-tourer-expressway.glb'
bpy.ops.export_scene.gltf(filepath=str(output),export_format='GLB',use_selection=True,use_active_scene=True,export_apply=True,export_animations=False,export_cameras=False,export_lights=False)
triangles=0
for obj in export_scene.objects:
if obj.type=='MESH': obj.data.calc_loop_triangles(); triangles+=len(obj.data.loop_triangles)
provenance={'asset':'Nightdrive expressway grand tourer','source':'nightdrive-expressway.blend','base':'../evolution/nightdrive-evolution.blend at c9ac36e','authorship':'Original procedural modelling in Blender for this portfolio; no external assets or generated project evidence.','editable_scene':'Midnight Expressway','material_groups':len(groups),'independent_wheels':4,'triangles':triangles,'export':'../../../src/assets/nightdrive-grand-tourer-expressway.glb','sha256':hashlib.sha256(output.read_bytes()).hexdigest()}
(root_path/'assets/blender/expressway/nightdrive-expressway.asset.json').write_text(json.dumps(provenance,indent=2)+'\n')
bpy.context.window.scene=scene
print(json.dumps(provenance))

View File

@@ -0,0 +1,12 @@
{
"asset": "Nightdrive expressway grand tourer",
"source": "nightdrive-expressway.blend",
"base": "../evolution/nightdrive-evolution.blend at c9ac36e",
"authorship": "Original procedural modelling in Blender for this portfolio; no external assets or generated project evidence.",
"editable_scene": "Midnight Expressway",
"material_groups": 19,
"independent_wheels": 4,
"triangles": 31288,
"export": "../../../src/assets/nightdrive-grand-tourer-expressway.glb",
"sha256": "8418ff060b75c6fef200eaa526adae7fe51286b7fc30628671b9eb242641d1c3"
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

@@ -0,0 +1,150 @@
> portfolio-phase-1@1.0.0 build:preview
> npm run verify:approved-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets:transfer
> portfolio-phase-1@1.0.0 verify:approved-assets
> node scripts/materialize-empty-asset-set.mjs
> portfolio-phase-1@1.0.0 typecheck
> vue-tsc --noEmit
> portfolio-phase-1@1.0.0 test:unit
> vitest run
RUN v4.1.11 /home/luca/Documents/Portfolio/.worktrees/midnight-expressway
❯ tests/unit/world-models.test.ts (29 tests | 7 failed) 272ms
× disposes a composer and its passes exactly once 32ms
× keeps the grand tourer anatomy readable in desktop and portrait chase views 29ms
× reflows a visible portrait sign into its navigator-safe pose without a teleport 4ms
× keeps a short-landscape left sign inside the narrow desktop roadside gutter 3ms
× places a portrait navigator arrival below its full-width destination panel 3ms
× grows a medium portrait navigator sign without enlarging the phone pose 7ms
× places route signs in their authored transition-safe corridors 10ms
❯ tests/unit/SceneCanvas.test.ts (16 tests | 2 failed) 134ms
× does not request or render the scene for reduced motion 36ms
× does not request or render the scene for saved Static mode 6ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 9 ⎯⎯⎯⎯⎯⎯⎯
FAIL tests/unit/SceneCanvas.test.ts > SceneCanvas > does not request or render the scene for reduced motion
FAIL tests/unit/SceneCanvas.test.ts > SceneCanvas > does not request or render the scene for saved Static mode
AssertionError: expected [] to have a length of 7 but got +0
- Expected
+ Received
- 7
+ 0
❯ tests/unit/SceneCanvas.test.ts:95:9
93| expect(
94| wrapper.findAll(".journey-poster-composition > span"),
95| ).toHaveLength(7);
| ^
96| expect(wrapper.find(".journey-poster__sun").exists()).toBe(true);
97| expect(wrapper.find(".journey-poster__road").exists()).toBe(true…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/9]⎯
FAIL tests/unit/world-models.test.ts > procedural world models > disposes a composer and its passes exactly once
AssertionError: expected 0.39 to be greater than 0.54
❯ tests/unit/world-models.test.ts:439:32
437| expect(outputPass.dispose).toHaveBeenCalledOnce();
438| expect(composer.dispose).toHaveBeenCalledOnce();
439| expect(bloomPass.strength).toBeGreaterThan(0.54);
| ^
440| });
441|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/9]⎯
FAIL tests/unit/world-models.test.ts > procedural world models > keeps the grand tourer anatomy readable in desktop and portrait chase views
AssertionError: expected 56 to be 66 // Object.is equality
- Expected
+ Received
- 66
+ 56
❯ tests/unit/world-models.test.ts:461:52
459|
460| const desktopCamera = createChaseCamera(16 / 9);
461| expect((desktopCamera as { fov: number }).fov).toBe(66);
| ^
462| const desktop = projectedBounds(car.group, desktopCamera);
463| expect(desktop.maxX - desktop.minX).toBeGreaterThan(0.28);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/9]⎯
FAIL tests/unit/world-models.test.ts > procedural world models > reflows a visible portrait sign into its navigator-safe pose without a teleport
AssertionError: expected 595.8091348948393 to be greater than or equal to 626
❯ tests/unit/world-models.test.ts:818:37
816| if (presentation === null) throw new Error("Missing navigator-safe…
817| expect(sign.group.position.distanceTo(midpoint)).toBeGreaterThan(0…
818| expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
| ^
819| expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
820| resources.dispose();
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/9]⎯
FAIL tests/unit/world-models.test.ts > procedural world models > keeps a short-landscape left sign inside the narrow desktop roadside gutter
AssertionError: expected -10.199278524338355 to be greater than or equal to 8
❯ tests/unit/world-models.test.ts:884:38
882| const presentation = sign.inspect(camera, 780, 493);
883| if (presentation === null) throw new Error("Missing short-landscap…
884| expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
| ^
885| expect(presentation.bounds.right).toBeLessThanOrEqual(265);
886| resources.dispose();
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/9]⎯
FAIL tests/unit/world-models.test.ts > procedural world models > places a portrait navigator arrival below its full-width destination panel
AssertionError: expected 595.8091348948393 to be greater than or equal to 626
❯ tests/unit/world-models.test.ts:921:37
919| if (presentation === null) throw new Error("Missing portrait navig…
920| expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
921| expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
| ^
922| expect(presentation.bounds.right).toBeLessThanOrEqual(382);
923| expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/9]⎯
FAIL tests/unit/world-models.test.ts > procedural world models > grows a medium portrait navigator sign without enlarging the phone pose
AssertionError: expected 122.59356940139988 to be less than or equal to 110
❯ tests/unit/world-models.test.ts:958:32
956|
957| expect(phone.bounds.width).toBeGreaterThanOrEqual(85);
958| expect(phone.bounds.width).toBeLessThanOrEqual(110);
| ^
959| expect(medium.viewportProfile).toBe("tall");
960| expect(medium.bounds.width).toBeGreaterThanOrEqual(145);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/9]⎯
FAIL tests/unit/world-models.test.ts > procedural world models > places route signs in their authored transition-safe corridors
AssertionError: expected 656.9555824147709 to be less than or equal to 630
❯ tests/unit/world-models.test.ts:1013:51
1011| expect(experiencePresentation?.corridor).toBe("right");
1012| expect(experiencePresentation?.bounds.top).toBeGreaterThanOrEqual(…
1013| expect(experiencePresentation?.bounds.bottom).toBeLessThanOrEqual(…
| ^
1014|
1015| sign.update(null);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/9]⎯
Test Files 2 failed | 47 passed (49)
Tests 9 failed | 653 passed (662)
Start at 04:04:19
Duration 23.81s (transform 17.57s, setup 0ms, import 30.29s, tests 45.44s, environment 19.53s)

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -0,0 +1,48 @@
{
"browser": "152.0.7977.64",
"renderer": {
"renderer": "ANGLE (Intel, Mesa Intel(R) Graphics (ARL), OpenGL ES 3.2)",
"vendor": "Google Inc. (Intel)",
"canvas": {
"width": 1425,
"height": 900
},
"dpr": 1.0000000298023224
},
"samples": [
{
"label": "1440x900 opening high preference",
"frames": 379,
"seconds": 6.004099999999999,
"averageFps": 63.12353225296048,
"p50Ms": 16.600000000000364,
"p95Ms": 20.900000000001455
},
{
"label": "1440x900 infrastructure reading",
"frames": 377,
"seconds": 6.012599999999998,
"averageFps": 62.70165984765328,
"p50Ms": 16.600000000002183,
"p95Ms": 20.900000000001455
},
{
"label": "1440x900 native scrolling and coasting",
"frames": 393,
"seconds": 6.0125,
"averageFps": 65.36382536382537,
"p50Ms": 16.599999999998545,
"p95Ms": 20.899999999997817
},
{
"label": "390x844 opening high preference",
"frames": 1132,
"seconds": 6.000000000000001,
"averageFps": 188.66666666666663,
"p50Ms": 4.199999999999818,
"p95Ms": 8.400000000000546
}
],
"errors": [],
"notes": "Headed host Google Chrome in an isolated temporary profile. Native devicePixelRatio 1. High maximum-quality preference; normal adaptive quality behavior preserved. requestAnimationFrame measurements; no profiler injection."
}

View File

@@ -0,0 +1,46 @@
{
"baseline": {
"text": "8621cc0f9fab07e86edb0308cc7b7f9d7ce519b2a55b8a39bd1a01f6820b1520",
"rendered": "59a5678b6efe114292c5113ba7922c427d6911823a980ccbe43c284adabf80f5",
"head": "b5571c4ca03daff042ebc496a5c4e01ea70fee7ed2fab08d4b0b126440ab790d",
"links": "11e4e3627e0d5f1bf1140e7c73ab86a6d305ceaf8286c0649e4a4a3266e48977",
"sections": [
"start",
"profile",
"experience",
"infrastructure",
"selected-builds",
"additional-work",
"toolkit",
"contact"
],
"scene": "static",
"canvas": 0
},
"implementation": {
"text": "8621cc0f9fab07e86edb0308cc7b7f9d7ce519b2a55b8a39bd1a01f6820b1520",
"rendered": "59a5678b6efe114292c5113ba7922c427d6911823a980ccbe43c284adabf80f5",
"head": "b5571c4ca03daff042ebc496a5c4e01ea70fee7ed2fab08d4b0b126440ab790d",
"links": "11e4e3627e0d5f1bf1140e7c73ab86a6d305ceaf8286c0649e4a4a3266e48977",
"sections": [
"start",
"profile",
"experience",
"infrastructure",
"selected-builds",
"additional-work",
"toolkit",
"contact"
],
"scene": "static",
"canvas": 0
},
"sameText": true,
"sameRenderedText": true,
"sameMetadata": true,
"sameLinks": true,
"noJavaScript": {
"sections": 8,
"characters": 12333
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Midnight expressway — visual review</title>
<style>
:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#070d15;color:#e6edf2}*{box-sizing:border-box}body{margin:0}header,main,footer{width:min(1280px,calc(100% - 48px));margin:auto}header{padding:64px 0 40px;border-bottom:1px solid #29404d}p{color:#aebdc7;line-height:1.65;max-width:780px}h1{font-weight:500;font-size:clamp(40px,6vw,76px);letter-spacing:-.06em;line-height:1;margin:18px 0 28px}h2{font-size:30px;font-weight:500;letter-spacing:-.03em;margin:0 0 12px}h3{font-weight:500}.eyebrow,figcaption,label{font-size:12px;letter-spacing:.09em;color:#a8cbd0}.eyebrow{text-transform:uppercase}a{color:#a3e1e4;text-underline-offset:5px}a:focus-visible,input:focus-visible,video:focus-visible{outline:2px solid #f39378;outline-offset:6px}.actions{display:flex;gap:24px;flex-wrap:wrap;margin-top:26px}.actions a{padding:12px 18px;border:1px solid #42616c;text-decoration:none}section{padding:54px 0;border-bottom:1px solid #243744}.compare{position:relative;overflow:hidden;border:1px solid #36505d;margin-top:24px;background:#0c111a;--cut:50%}.compare img{width:100%;display:block}.compare .old{position:absolute;inset:0;clip-path:inset(0 calc(100% - var(--cut)) 0 0)}.compare::after{content:'';position:absolute;top:0;bottom:0;left:var(--cut);width:2px;background:#bfe8e8}.badge{position:absolute;z-index:1;top:12px;background:#060b12df;padding:7px 10px;font-size:11px;letter-spacing:.13em}.before{left:12px}.after{right:12px}.slider{display:flex;align-items:center;gap:18px;margin-top:16px}input[type=range]{flex:1;accent-color:#9ad8dd}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}.grid img,video,.wide{width:100%;display:block;border:1px solid #2b424e}figure{margin:0}figcaption{line-height:1.7;margin-top:12px;letter-spacing:.02em}.phones{max-width:760px;margin:30px auto 0}.phones img{max-width:390px;margin:auto}.note{padding:20px 24px;border-left:2px solid #f0957c;background:#0e1923}footer{padding:36px 0 56px;font-size:13px}@media(max-width:650px){header,main,footer{width:calc(100% - 28px)}header{padding-top:36px}.grid{grid-template-columns:1fr}.phones{grid-template-columns:1fr 1fr;gap:12px}section{padding:38px 0}.badge{font-size:9px;padding:5px}.actions{gap:12px}}
</style>
</head>
<body>
<header><div class="eyebrow">Portfolio / September 2026 / Local visual review</div><h1>Midnight<br>expressway.</h1><p>A more physical road, coachbuilt silver bodywork, illuminated route signs, and a layered coral horizon. The approved portfolio content and adaptive soundtrack remain intact.</p><div class="actions"><a href="http://localhost:41821" target="_blank" rel="noopener">Open the live portfolio ↗</a><a href="#motion">Watch the journey ↓</a></div></header>
<main>
<section><div class="eyebrow">01 / The opening</div><h2>The same identity, a deeper world.</h2><p>Drag the divider to compare the merged baseline with this implementation. These are actual browser captures; moving objects naturally occupy different positions.</p><div class="compare" id="opening"><img src="after-desktop.png" alt="New expressway opening with detailed road, silver car, opaque cyan terrain and separated coral sun bands"><img class="old" src="before-desktop.png" alt="Approved previous opening"><span class="badge before">BEFORE</span><span class="badge after">AFTER</span></div><div class="slider"><label for="opening-range">COMPARE</label><input id="opening-range" type="range" min="0" max="100" value="50" aria-label="Opening before and after divider" oninput="document.getElementById('opening').style.setProperty('--cut',this.value+'%')"></div></section>
<section id="motion"><div class="eyebrow">02 / Motion study</div><h2>Travel, settle, arrive.</h2><p>Actual hardware-backed Chrome capture: forward acceleration, coasting, reverse document scrolling, and navigation to Infrastructure, Selected builds, and Contact. Silent recording with the High maximum-quality preference and normal adaptive behavior.</p><video controls playsinline preload="metadata" poster="after-desktop.png" src="journey-motion.mp4"></video></section>
<section><div class="eyebrow">03 / Route objects</div><h2>Signs with their own space.</h2><div class="grid"><figure><img loading="lazy" src="before-infrastructure.png" alt="Infrastructure in the approved baseline"><figcaption>Before — merged version.</figcaption></figure><figure><img loading="lazy" src="after-infrastructure.png" alt="New beveled Infrastructure sign clearly above its reading surface"><figcaption>After — beveled housing, luminous edges, inset label, and a reserved approach corridor.</figcaption></figure></div><div class="grid phones"><figure><img loading="lazy" src="after-phone-sign.png" alt="Infrastructure sign and reading surface on phone"><figcaption>Phone sign arrival. The canvas retains its original stacking order.</figcaption></figure><figure><img loading="lazy" src="narrow-infrastructure.png" alt="Infrastructure reading layout at 320 pixels wide"><figcaption>320 px reading layout after the sign has departed.</figcaption></figure></div></section>
<section><div class="eyebrow">04 / Coachwork</div><h2>A more memorable grand tourer.</h2><img loading="lazy" class="wide" src="blender-coachwork.png" alt="Blender render showing articulated rear lamps, machined wheels, side channels and refined wing"><p>Editable Blender source retains the individual parts and look-development setup. The runtime export batches materials and preserves four independently rotating wheels.</p></section>
<section><div class="eyebrow">05 / Small screens</div><h2>The night drive travels with you.</h2><div class="grid phones"><figure><img loading="lazy" src="before-phone.png" alt="Previous phone introduction"><figcaption>Before.</figcaption></figure><figure><img loading="lazy" src="after-phone.png" alt="New phone introduction"><figcaption>After.</figcaption></figure></div></section>
<section><div class="eyebrow">06 / Evidence and arrival</div><h2>The work stays central.</h2><div class="grid"><figure><img loading="lazy" src="after-builds.png" alt="Selected builds with approved real medication tracker media"><figcaption>Unchanged project evidence, staged within the continuous world.</figcaption></figure><figure><img loading="lazy" src="after-contact.png" alt="Warm Contact arrival"><figcaption>Contact resolves into a warmer horizon and slower camera recovery.</figcaption></figure></div></section>
<section><h2>Verified, with clear limits.</h2><p>Approved wording, links, metadata, section order, and all 15 approved media/audio files match the baseline. Asset validation, typecheck, client/SSR build, prerender, artifact verification, and transfer checks pass. Static and reduced-motion views retain the full content.</p><p class="note">Host Chrome uses the Intel hardware GPU: desktop samples averaged 63–65 fps across opening, reading, and native scrolling at 1440×900, with p95 frame time 20.9 ms. Physical phones and high-DPI displays remain unverified. The untouched baseline already had nine failing unit assertions; tests were preserved. Ready for visual review.</p><div class="actions"><a href="chrome-hardware-performance.json">Hardware performance</a><a href="content-preservation.json">Content comparison</a><a href="media-audio-preservation.json">Media and audio hashes</a><a href="baseline-checks.log">Baseline check log</a></div></section>
</main><footer>Local review artifact · codex/midnight-expressway · Based on c9ac36e · No deployment</footer>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@@ -0,0 +1,96 @@
{
"assetCount": 15,
"allIdentical": true,
"assets": [
{
"asset": "nightdrive-afterglow.mp3",
"sha256": "685687ad131d61c34e42bcee29af16a4c8180d788c67d75f2331fa9082a3a44e",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "nightdrive-afterglow.opus",
"sha256": "693215397e8ae4c2c13a4a6e4cc74b0059fb26b396c7f35d99e0e2c3e738d4b1",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "nightdrive-drive-lift.mp3",
"sha256": "edcb802938d3d1e53392f9ccd2e153f72a24b494c205ce6f556d73de5fe709ec",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "nightdrive-drive-lift.opus",
"sha256": "d580e58039c9d700599cf100c96b50d4b5816d93a0af738a93a1e8646fbb7566",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "nightdrive-intro.mp3",
"sha256": "008794ebd7f545c32f19aab4ada6aba40d9fc3341830a66e77f21d87a41c009b",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "nightdrive-intro.opus",
"sha256": "561c05cea71d0920ec323375644039007b91b0a4c346dc663e233ad42d6ab490",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "nightdrive-main.mp3",
"sha256": "796de8247c4cb8012d7c6883b534112ac09a40255a2c33cd68acf0f87c066659",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "nightdrive-main.opus",
"sha256": "81933fc8e589428ef07fb265572a79975f183848b6a23616cb289e33fe3fb88c",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "project-currency-converter.webp",
"sha256": "57159ae72a075ba5317217233c3c0eb210a485c51b5f2c37b5ed156d5ea37efb",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "project-medication-data-settings.webp",
"sha256": "d951dd723047b737ab6a7da46b82f3f3f3aca7ec8853314f74ac57e1950ce1ba",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "project-medication-logs.webp",
"sha256": "d34b2d41855e6dca4762fb9fc7728b64a94fef60de36149e465ce50821bc1dc7",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "project-medication-notification-settings.webp",
"sha256": "d2fe0076d6581e76c361cb656350c58f6685f6fe91c5b6c5871e60977e8d5be7",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "project-opinion-final-distribution.webp",
"sha256": "2f2f6d64b8bb4f79ecbce1a8bc486cf7bbdf4087eb4f10062a6fe889246dbdf1",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "project-opinion-polarization.webp",
"sha256": "77f0d5076769e6e45b566c5f322d8f535686f9e9f928e18fe4c16b3dac989c43",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
},
{
"asset": "project-social-preview.webp",
"sha256": "ed470760643ef3d413b6ca4a0e1434771fcc9b61b6ac34011feae77a13f2daca",
"matchesApprovedManifest": true,
"samePreparedBaselineAndPreview": true
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -0,0 +1,39 @@
{
"renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"hardwareVerified": false,
"quality": "low",
"samples": [
{
"viewport": "390x844",
"seconds": 3.05,
"frames": 95,
"averageFps": 31.1,
"p50Ms": 33.3,
"p95Ms": 49.9
},
{
"viewport": "1440x900",
"seconds": 4.02,
"frames": 48,
"averageFps": 12,
"p50Ms": 83.3,
"p95Ms": 99.9
}
],
"inactiveSignScene": {
"drawCalls": 45,
"triangles": 44572,
"points": 160,
"lines": 4586,
"rendererGeometries": 34,
"rendererTextures": 3,
"programs": 21
},
"afterStaticHighLow": {
"orphanedGeometries": 0,
"orphanedTextures": 0
},
"limitations": "Software rendering with instrumentation. Physical devices and hardware frame rates unverified. Static quality removes all live canvases and clipping.",
"scope": "Initial software renderer inspection only",
"hardwareReport": "chrome-hardware-performance.json"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -0,0 +1,25 @@
# Midnight expressway
Base: local main c9ac36e. Branch: codex/midnight-expressway. All public wording, metadata, media, privacy decisions, section order, canonical journey state and audio are immutable.
## Creative direction
A silver coachbuilt wedge travelling a graphite causeway through cyan topographic waves toward a burning coral horizon. Architecture uses swept graphite uprights, machined silver edges, recessed cyan glass, and narrow coral indicators. The road has satin moisture, not a perfect mirror: directional broken reflections sit on its surface. Only finite objects are authored in Blender; the road and landscape remain continuous runtime geometry.
Design read: a personal professional portfolio for technical hiring and collaboration, with expressive synthwave cinematography and stable, spacious reading surfaces. Native Vue/CSS/Three.js; no new dependencies. Variance 9, motion 8 during travel / 3 during reading, content density 4.
## Scene ledger
Start: low rear three-quarter coachwork, sun and expressway to the right of the open name. Profile: left world corridor and right reading plate. Experience: right world corridor; roadside fins frame the timeline. Infrastructure: taller architectural pylons and precise road sequencing. Selected builds: broad evidence stages with panoramic gaps. Additional work: quieter mid-distance rhythm. Toolkit: ordered vertical structures. Contact: low, warm, slower-feeling arrival and silver bodywork.
Signs retain the approved route labels, semantic destinations and lifecycle. Replace brittle world-coordinate offsets with camera-aware projection into measured empty document corridors; retain physical depth, mounting and a real approach/departure. Housing catches light before the text face; label remains still in its dwell. At compact sizes reserve a scene strip below navigation by clipping the reading document there, using matching section scroll margins. Signs remain behind content, never on a higher stacking layer.
## Materials and motion
Road: charcoal aggregate, subtle wheel polishing, paired center markings, recessed cyan edge channels, shoulder ribs, reflectors, expansion joints, broken coral tail-light and sun response. No planar reflection pass. Landscape: dark opaque underlay beneath the cyan wave grid, layered silhouettes and sparse paired landmarks. Sun: variable-width separated bands, peach crown, coral body, vermilion lower limb, subtle refraction and fine chromosphere arc. Car: deeper aero channels, articulated rear lamps, side intakes, diffuser, machined wheels, edited materials. All new resources owned by the existing disposal bag; repeated details instanced and bounded.
Render-only motion uses current journey energy; camera, road, car, sign and lighting updates never write back to audio or journey. Native scrolling, quality controls, Static and reduced-motion remain.
## Practical verification
Capture baseline before editing; run the established complete content validation and prerender flow. The untouched baseline unit suite currently has 9 failures (653 passed), in scene fallback expectations and old camera/sign/bloom assertions. Preserve tests per request and record this pre-existing result. Continue required client/SSR/prerender/artifact/transfer validation separately. Inspect all eight chapters at desktop, tablet, phone, narrow and short sizes; scroll, reversal, jumps, keyboard, resize, fallback and console. Identify the GPU, capture actual motion, make a second visual pass, preserve editable isolated Blender source, compare content and media hashes, then commit without merge/push/deploy.

View File

@@ -0,0 +1,56 @@
# Midnight expressway — ready for visual review
Implementation branch: `codex/midnight-expressway`, based on local main `c9ac36e`. Worktree: `/home/luca/Documents/Portfolio/.worktrees/midnight-expressway`. No merge, push, or deployment.
## Open the work
- [Live validated preview](http://localhost:41821)
- [Before/after gallery and motion](http://localhost:41822)
- [Motion recording](evidence/midnight-expressway/journey-motion.mp4)
- [Editable Blender source](../../assets/blender/expressway/nightdrive-expressway.blend)
- [Blender provenance and export instructions](../../assets/blender/expressway/README.md)
The gallery is also available locally as `evidence/midnight-expressway/index.html`. Preview serves this worktree's validated, prerendered `dist`; the baseline was separately built from the merged version and serves on port 41820. The previous port 41812 was not running.
## What changed
A satin graphite expressway now has directional grain, broken coral reflections, cyan recessed channels, shoulder ribs, reflectors, expansion joints, low rails, and swept signal pylons. Sparse ridge observatories create a slower outer rhythm. All repeated scenery uses bounded instancing and continuous wrap; no additional reflection pass is required.
The cyan terrain waves now sit on opaque displaced ground. Dark surfaces occlude distant contours and give the landscape actual depth. Thin, separated atmospheric layers replace full-screen haze. The striped sun has a peach crown, a coral lower limb, luminous band edges, convection filaments, and restrained distortion. Stars sit behind its solid surface.
The car has twin articulated rear lamp rails, deep side intakes, machined dished wheels, shoulder rings, lower blades, deck seams, larger wing endplates, exhaust surrounds, and a more defined diffuser. Four wheels rotate independently while suspension acts on the body. The isolated Blender source remains editable.
Signs are beveled illuminated objects with fins, inset faces, twin supports, footings, hardware, and an approach sweep. Camera projection places readable dwell in measured empty screen space. A reserved scene strip protects that space without placing the signs above the content. Very short landscape layouts put reading on the left and the sign on the right. Reflow is damped, keyboard focus stays in the reading corridor, and Static mode releases the clipping entirely.
Camera lag, forward anticipation, coasting, and warm Contact recovery are visual consumers of existing journey state. Typography, project staging, diagram sizing, and compact reading surfaces were refined around the unchanged copy.
## Refinement after the first implementation
Browser observation led to a second pass: removal of flat full-screen haze and distracting old anchors; opaque ground beneath the grid; a crisper sun limb with correct star occlusion; closer sign dwell to prevent foreground pylons crossing labels; corrected short-height composition; stable heading activation after navigation; and independent wheel rotation with grounded contact. Static posters were captured from the live world.
## Preservation
The baseline and new preview have identical normalized main text, rendered wording, links, title, metadata, canonical information, structured metadata, and ordered section IDs. The 15 approved media/audio files match the approved manifest and both built previews byte for byte. Content, audio, canonical journey state, motion preferences, route definitions, tests, and preview configuration have no source changes.
Evidence: [content comparison](evidence/midnight-expressway/content-preservation.json), [all approved media/audio hashes](evidence/midnight-expressway/media-audio-preservation.json).
The soundtrack reached its running state through the existing controls. Mute/unmute, volume, motion pause/resume, and quality controls were exercised. The capture is silent; it does not replace the soundtrack assets or certify acoustic playback through physical speakers.
## Practical verification
- Approved-asset validation, typecheck, client build, SSR build, validated prerender, artifact verification, and transfer checks pass. Validation was not disabled.
- All eight chapters were traversed at 1440×900, 768×1024, 390×844, 320×568, 1024×600, and 844×390. Intermediate resizing also included 900×760. No horizontal page overflow was observed; narrow and short navigation settled on every requested section.
- Ordinary scrolling, acceleration, coasting, reverse scrolling, long navigation jumps, Contact arrival, keyboard skip link, successive media/link focus, and resizing were exercised. The motion recording shows the road, sign transitions, reversal, Builds, and Contact.
- Static mode: zero canvas, no live clipping. Reduced motion: static artwork and all eight sections. JavaScript disabled: all eight sections and the approved content remain available.
- Direct-preview console inspection found no application exceptions or WebGL shader errors. An inherited `/favicon.ico` 404 remains. The instrumentation proxy emitted its own WebGL warning.
- Static → High → Low transitions were exercised. Scene disposal inspection found zero orphaned geometries or textures. Repeated roadside objects are bounded; the final Low scene has 45 draw calls and approximately 44,572 triangles while the sign is inactive. No new full-resolution post-processing pass was added.
## Performance and limits
The initial inspection renderer was **ANGLE / Vulkan / SwiftShader Device (Subzero)**. At Low quality the instrumented 1440×900 sample averaged 12 fps (4 seconds, p95 99.9 ms); 390×844 averaged 31.1 fps (3 seconds, p95 49.9 ms). The Chrome app connector returned `Transport closed`, including its reset call. A direct isolated host Chrome window provided the working hardware-backed alternative.
**Host Chrome 152.0.7977.64 uses ANGLE / Mesa Intel Graphics (ARL), OpenGL ES 3.2.** With the High maximum-quality preference and normal adaptive behavior intact, six-second requestAnimationFrame samples averaged 63.1 fps at the opening, 62.7 fps while reading Infrastructure, and 65.4 fps during native scrolling/coasting at 1440×900. Desktop p95 was 20.9 ms. The 390×844 host-browser viewport averaged 188.7 fps (p95 8.4 ms). These samples use the host GPU at devicePixelRatio 1; they do not certify a physical phone, high-DPI screen, or every browser. No application exceptions occurred. [Raw hardware results](evidence/midnight-expressway/chrome-hardware-performance.json) include the renderer and measurement method. A second motion capture uses this hardware-backed Chrome window.
Transfer report: initial application JS 90,264 bytes; first live scene code plus car 299,457 bytes; both responsive posters 136,680 bytes, measured by the existing Brotli/raw accounting. No transfer violations. The raw car export is 1,185,588 bytes. Vite retains its large scene-chunk warning.
The untouched baseline's established `build:preview` stopped at **9 failing / 653 passing unit tests**: fallback counts and old bloom/camera/sign assertions. [The original log](evidence/midnight-expressway/baseline-checks.log) was captured before editing. Tests were not written, changed, or repeatedly run, as requested. The complete validated rendering and artifact stages were run separately and pass; the unit suite is not claimed green.

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

View File

@@ -4,8 +4,8 @@ import { useSceneController, type SceneHostState } from '@/composables/useSceneC
import type { JourneySnapshot } from '@/journey/types'; import type { JourneySnapshot } from '@/journey/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy'; import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import posterUrl from '@/assets/hero-evolution.jpg'; import posterUrl from '@/assets/hero-expressway.jpg';
import mobilePosterUrl from '@/assets/hero-evolution-mobile.jpg'; import mobilePosterUrl from '@/assets/hero-expressway-mobile.jpg';
declare const __PORTFOLIO_TEST_BUILD__: boolean; declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
@@ -57,7 +57,7 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
> >
<picture v-if="!posterFailed"> <picture v-if="!posterFailed">
<source media="(max-width: 767px)" :srcset="mobilePosterUrl"> <source media="(max-width: 767px)" :srcset="mobilePosterUrl">
<img :src="posterUrl" class="journey-poster" alt="" width="1600" height="900" @error="posterFailed = true"> <img :src="posterUrl" class="journey-poster" alt="" width="1440" height="900" @error="posterFailed = true">
</picture> </picture>
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" /> <canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
</div> </div>

View File

@@ -1,7 +1,10 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { RenderQualitySettings } from '@/scene/render-quality'; import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
import type { MutableChapterPalette, MutableColor } from '@/scene/world/palette'; import type {
MutableChapterPalette,
MutableColor,
} from '@/scene/world/palette';
import { CHAPTER_PALETTES } from '@/scene/world/palette'; import { CHAPTER_PALETTES } from '@/scene/world/palette';
import { import {
HORIZON_FOG_END, HORIZON_FOG_END,
@@ -36,22 +39,24 @@ export function createAtmosphere(
const night = CHAPTER_PALETTES['deep-night']; const night = CHAPTER_PALETTES['deep-night'];
const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2)); const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2));
const skyMaterial = resources.own('material', new THREE.ShaderMaterial({ const skyMaterial = resources.own(
uniforms: { 'material',
uSkyColor: { value: new THREE.Color(night.sky) }, new THREE.ShaderMaterial({
uFogColor: { value: new THREE.Color(night.fog) }, uniforms: {
uHazeColor: { value: new THREE.Color(night.haze) }, uSkyColor: { value: new THREE.Color(night.sky) },
uWarmth: { value: night.warmth }, uFogColor: { value: new THREE.Color(night.fog) },
uAspect: { value: 1 }, uHazeColor: { value: new THREE.Color(night.haze) },
}, uWarmth: { value: night.warmth },
vertexShader: ` uAspect: { value: 1 },
},
vertexShader: `
varying vec2 vSkyUv; varying vec2 vSkyUv;
void main() { void main() {
vSkyUv = uv; vSkyUv = uv;
gl_Position = vec4(position.xy, 1.0, 1.0); gl_Position = vec4(position.xy, 1.0, 1.0);
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform vec3 uSkyColor; uniform vec3 uSkyColor;
uniform vec3 uFogColor; uniform vec3 uFogColor;
uniform vec3 uHazeColor; uniform vec3 uHazeColor;
@@ -78,11 +83,12 @@ export function createAtmosphere(
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
depthWrite: false, depthWrite: false,
depthTest: false, depthTest: false,
fog: false, fog: false,
toneMapped: true, toneMapped: true,
})); }),
);
const sky = new THREE.Mesh(skyGeometry, skyMaterial); const sky = new THREE.Mesh(skyGeometry, skyMaterial);
sky.name = 'atmosphere-sky'; sky.name = 'atmosphere-sky';
sky.frustumCulled = false; sky.frustumCulled = false;
@@ -93,14 +99,20 @@ export function createAtmosphere(
silhouettes.name = 'distant-silhouettes'; silhouettes.name = 'distant-silhouettes';
const silhouetteMaterials: Array<{ color: MutableColor }> = []; const silhouetteMaterials: Array<{ color: MutableColor }> = [];
for (let band = 0; band < 3; band += 1) { for (let band = 0; band < 3; band += 1) {
const material = resources.own('material', new THREE.MeshBasicMaterial({ const material = resources.own(
color: new THREE.Color(night.fog).lerp(new THREE.Color(night.terrainLow), 0.46 + band * 0.12), 'material',
transparent: true, new THREE.MeshBasicMaterial({
opacity: 0.58 - band * 0.1, color: new THREE.Color(night.fog).lerp(
depthWrite: true, new THREE.Color(night.terrainLow),
depthTest: true, 0.46 + band * 0.12,
toneMapped: true, ),
})); transparent: true,
opacity: 0.58 - band * 0.1,
depthWrite: true,
depthTest: true,
toneMapped: true,
}),
);
silhouetteMaterials.push(material); silhouetteMaterials.push(material);
const ridge = new THREE.Mesh( const ridge = new THREE.Mesh(
resources.own('geometry', createSilhouetteGeometry(band)), resources.own('geometry', createSilhouetteGeometry(band)),
@@ -112,16 +124,21 @@ export function createAtmosphere(
} }
group.add(silhouettes); group.add(silhouettes);
const starGeometry = resources.own('geometry', createStarGeometry(quality.starCount)); const starGeometry = resources.own(
const starMaterial = resources.own('material', new THREE.ShaderMaterial({ 'geometry',
uniforms: { createStarGeometry(quality.starCount),
uVisibility: { value: night.starVisibility }, );
uDistance: { value: 0 }, const starMaterial = resources.own(
uPointScale: { value: 18 }, 'material',
uMaxPointSize: { value: 4.5 }, new THREE.ShaderMaterial({
uColor: { value: new THREE.Color(0xb9ecf2) }, uniforms: {
}, uVisibility: { value: night.starVisibility },
vertexShader: ` uDistance: { value: 0 },
uPointScale: { value: 18 },
uMaxPointSize: { value: 4.5 },
uColor: { value: new THREE.Color(0xb9ecf2) },
},
vertexShader: `
attribute float starSize; attribute float starSize;
attribute float brightness; attribute float brightness;
uniform float uDistance; uniform float uDistance;
@@ -141,7 +158,7 @@ export function createAtmosphere(
gl_Position = projectionMatrix * viewPosition; gl_Position = projectionMatrix * viewPosition;
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform vec3 uColor; uniform vec3 uColor;
uniform float uVisibility; uniform float uVisibility;
varying float vBrightness; varying float vBrightness;
@@ -153,46 +170,32 @@ export function createAtmosphere(
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: true, depthTest: true,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
toneMapped: true, toneMapped: true,
})); }),
);
const stars = new THREE.Points(starGeometry, starMaterial); const stars = new THREE.Points(starGeometry, starMaterial);
stars.name = 'atmosphere-stars'; stars.name = 'atmosphere-stars';
stars.frustumCulled = false; stars.frustumCulled = false;
group.add(stars); group.add(stars);
const hazeGroup = new THREE.Group(); const streakGeometry = resources.own(
hazeGroup.name = 'horizon-haze'; 'geometry',
const hazeGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2)); createStreakGeometry(quality.streakCount),
const nearHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.15, 0.47, 8.4)); );
const farHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.1, 0.5, 5.8)); const streakMaterial = resources.own(
const farHaze = new THREE.Mesh(hazeGeometry, farHazeMaterial); 'material',
farHaze.name = 'horizon-haze-far'; new THREE.ShaderMaterial({
farHaze.frustumCulled = false; uniforms: {
farHaze.renderOrder = 60; uDistance: { value: 0 },
hazeGroup.add(farHaze); uAcceleration: { value: 0 },
const nearHaze = new THREE.Mesh(hazeGeometry, nearHazeMaterial); uVisibility: { value: 0 },
nearHaze.name = 'horizon-haze-near'; uColor: { value: new THREE.Color(0x79dae5) },
nearHaze.frustumCulled = false; },
nearHaze.renderOrder = 61; vertexShader: `
hazeGroup.add(nearHaze);
group.add(hazeGroup);
const anchors = createRoadsideAnchors(resources, quality);
group.add(anchors.mesh);
const streakGeometry = resources.own('geometry', createStreakGeometry(quality.streakCount));
const streakMaterial = resources.own('material', new THREE.ShaderMaterial({
uniforms: {
uDistance: { value: 0 },
uAcceleration: { value: 0 },
uVisibility: { value: 0 },
uColor: { value: new THREE.Color(0x79dae5) },
},
vertexShader: `
attribute float endFactor; attribute float endFactor;
uniform float uDistance; uniform float uDistance;
uniform float uAcceleration; uniform float uAcceleration;
@@ -208,7 +211,7 @@ export function createAtmosphere(
gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0);
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform vec3 uColor; uniform vec3 uColor;
uniform float uVisibility; uniform float uVisibility;
varying float vTail; varying float vTail;
@@ -218,12 +221,13 @@ export function createAtmosphere(
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: true, depthTest: true,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
toneMapped: true, toneMapped: true,
})); }),
);
const streaks = new THREE.LineSegments(streakGeometry, streakMaterial); const streaks = new THREE.LineSegments(streakGeometry, streakMaterial);
streaks.name = 'speed-streaks'; streaks.name = 'speed-streaks';
streaks.frustumCulled = false; streaks.frustumCulled = false;
@@ -232,17 +236,18 @@ export function createAtmosphere(
return { return {
group, group,
setViewportAspect(aspect) { setViewportAspect(aspect) {
const safeAspect = Number.isFinite(aspect) ? THREE.MathUtils.clamp(aspect, 0.35, 4) : 1; const safeAspect = Number.isFinite(aspect)
? THREE.MathUtils.clamp(aspect, 0.35, 4)
: 1;
skyMaterial.uniforms.uAspect.value = safeAspect; skyMaterial.uniforms.uAspect.value = safeAspect;
nearHazeMaterial.uniforms.uAspect.value = safeAspect;
farHazeMaterial.uniforms.uAspect.value = safeAspect;
}, },
update(distance, speed, acceleration, palette) { update(distance, speed, acceleration, palette) {
const safeAcceleration = clamp01(acceleration); const safeAcceleration = clamp01(acceleration);
const visibility = safeAcceleration const visibility =
* (0.62 + Math.sqrt(safeAcceleration) * 0.38) safeAcceleration *
* palette.streakEnergy (0.62 + Math.sqrt(safeAcceleration) * 0.38) *
* 0.72; palette.streakEnergy *
0.72;
starMaterial.uniforms.uDistance.value = finiteOrZero(distance); starMaterial.uniforms.uDistance.value = finiteOrZero(distance);
starMaterial.uniforms.uVisibility.value = palette.starVisibility; starMaterial.uniforms.uVisibility.value = palette.starVisibility;
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance); streakMaterial.uniforms.uDistance.value = finiteOrZero(distance);
@@ -252,18 +257,11 @@ export function createAtmosphere(
skyMaterial.uniforms.uFogColor.value.copy(palette.fog); skyMaterial.uniforms.uFogColor.value.copy(palette.fog);
skyMaterial.uniforms.uHazeColor.value.copy(palette.haze); skyMaterial.uniforms.uHazeColor.value.copy(palette.haze);
skyMaterial.uniforms.uWarmth.value = palette.warmth; skyMaterial.uniforms.uWarmth.value = palette.warmth;
nearHazeMaterial.uniforms.uColor.value.copy(palette.haze);
farHazeMaterial.uniforms.uColor.value.copy(palette.haze);
nearHazeMaterial.uniforms.uEnergy.value = 0.07 + palette.warmth * 0.05;
farHazeMaterial.uniforms.uEnergy.value = 0.045 + palette.warmth * 0.04;
for (let index = 0; index < silhouetteMaterials.length; index += 1) { for (let index = 0; index < silhouetteMaterials.length; index += 1) {
silhouetteMaterials[index]?.color silhouetteMaterials[index]?.color
.copy(palette.fog) .copy(palette.fog)
.lerp(palette.terrainLow, 0.46 + index * 0.12); .lerp(palette.terrainLow, 0.46 + index * 0.12);
} }
anchors.uniforms.uDistance.value = finiteOrZero(distance);
anchors.uniforms.uEnergy.value = 0.38 + palette.terrainEnergy * 0.22;
anchors.uniforms.uColor.value.copy(palette.terrainHigh);
}, },
}; };
} }
@@ -274,9 +272,9 @@ function createStarGeometry(count: number) {
const brightness = new Float32Array(count); const brightness = new Float32Array(count);
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const offset = index * 3; const offset = index * 3;
positions[offset] = seeded(index, 11) * 680 - 340; positions[offset] = seeded(index, 11) * 1800 - 900;
positions[offset + 1] = 16 + seeded(index, 23) * 230; positions[offset + 1] = 85 + seeded(index, 23) * 640;
positions[offset + 2] = -48 - seeded(index, 37) * 540; positions[offset + 2] = -780 - seeded(index, 37) * 260;
sizes[index] = 2.4 + seeded(index, 53) * 4.8; sizes[index] = 2.4 + seeded(index, 53) * 4.8;
brightness[index] = 0.28 + seeded(index, 71) * 0.72; brightness[index] = 0.28 + seeded(index, 71) * 0.72;
} }
@@ -291,7 +289,8 @@ function createStreakGeometry(count: number) {
const positions = new Float32Array(count * 2 * 3); const positions = new Float32Array(count * 2 * 3);
const ends = new Float32Array(count * 2); const ends = new Float32Array(count * 2);
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const x = (seeded(index, 101) > 0.5 ? 1 : -1) * (14 + seeded(index, 103) * 102); const x =
(seeded(index, 101) > 0.5 ? 1 : -1) * (14 + seeded(index, 103) * 102);
const y = 0.8 + seeded(index, 107) * 32; const y = 0.8 + seeded(index, 107) * 32;
const z = -8 - seeded(index, 109) * 512; const z = -8 - seeded(index, 109) * 512;
const offset = index * 6; const offset = index * 6;
@@ -310,52 +309,6 @@ function createStreakGeometry(count: number) {
return geometry; return geometry;
} }
function createHazeMaterial(
color: number,
energy: number,
center: number,
spread: number,
) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color(color) },
uEnergy: { value: energy },
uCenter: { value: center },
uSpread: { value: spread },
uAspect: { value: 1 },
},
vertexShader: `
varying vec2 vHazeUv;
void main() {
vHazeUv = uv;
gl_Position = vec4(position.xy, 1.0, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uEnergy;
uniform float uCenter;
uniform float uSpread;
uniform float uAspect;
varying vec2 vHazeUv;
void main() {
float vertical = exp(-pow(abs(vHazeUv.y - uCenter) * uSpread, 2.0));
float normalizedX = abs(vHazeUv.x - 0.5) * 2.0 * min(uAspect, 2.35) / 2.35;
float centerLift = 1.0 - smoothstep(0.12, 1.2, normalizedX);
float horizontal = mix(0.64, 1.0, centerLift);
gl_FragColor = vec4(uColor * 1.08, vertical * horizontal * uEnergy);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: false,
depthTest: false,
blending: THREE.NormalBlending,
toneMapped: true,
});
}
function createSilhouetteGeometry(band: number) { function createSilhouetteGeometry(band: number) {
const segments = 18; const segments = 18;
const positions = new Float32Array((segments + 1) * 2 * 3); const positions = new Float32Array((segments + 1) * 2 * 3);
@@ -366,7 +319,9 @@ function createSilhouetteGeometry(band: number) {
for (let index = 0; index <= segments; index += 1) { for (let index = 0; index <= segments; index += 1) {
const progress = index / segments; const progress = index / segments;
const x = (progress - 0.5) * width; const x = (progress - 0.5) * width;
const lowWave = Math.sin(progress * Math.PI * (2.2 + band * 0.35) + band * 1.7); const lowWave = Math.sin(
progress * Math.PI * (2.2 + band * 0.35) + band * 1.7,
);
const broadWave = Math.sin(progress * Math.PI * 5.2 + band * 2.4); const broadWave = Math.sin(progress * Math.PI * 5.2 + band * 2.4);
const topY = 4 + band * 2.6 + lowWave * (8 + band * 1.8) + broadWave * 3.2; const topY = 4 + band * 2.6 + lowWave * (8 + band * 1.8) + broadWave * 3.2;
const offset = index * 6; const offset = index * 6;
@@ -388,68 +343,6 @@ function createSilhouetteGeometry(band: number) {
return geometry; return geometry;
} }
function createRoadsideAnchors(
resources: SceneResourceBag,
quality: RenderQualitySettings,
) {
const count = Math.max(12, Math.min(32, Math.round(quality.terrainSegments / 4)));
const geometry = resources.own('geometry', new THREE.BoxGeometry(0.15, 0.72, 0.15));
const uniforms = {
uDistance: { value: 0 },
uEnergy: { value: 0.5 },
uColor: { value: new THREE.Color(CHAPTER_PALETTES['deep-night'].terrainHigh) },
};
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms,
vertexShader: `
uniform float uDistance;
varying float vDepthFade;
void main() {
vec4 anchor = instanceMatrix * vec4(position, 1.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(540.0, ${HORIZON_FOG_END.toFixed(1)}, -viewPosition.z))
* smoothstep(12.0, 42.0, -viewPosition.z);
gl_Position = projectionMatrix * viewPosition;
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uEnergy;
varying float vDepthFade;
void main() {
gl_FragColor = vec4(uColor * (0.7 + uEnergy * 0.55), vDepthFade * uEnergy);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
toneMapped: true,
}));
const mesh = new THREE.InstancedMesh(geometry, material, count);
mesh.name = 'roadside-anchors';
mesh.frustumCulled = false;
const matrix = new THREE.Matrix4();
for (let index = 0; index < count; index += 1) {
const side = index % 2 === 0 ? -1 : 1;
const lane = Math.floor(index / 2);
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);
}
mesh.instanceMatrix.needsUpdate = true;
return { mesh, uniforms };
}
function seeded(index: number, salt: number): number { function seeded(index: number, salt: number): number {
const result = Math.sin((index + 1) * 91.713 + salt * 17.17) * 43_758.5453; const result = Math.sin((index + 1) * 91.713 + salt * 17.17) * 43_758.5453;
return result - Math.floor(result); return result - Math.floor(result);

View File

@@ -1,19 +1,29 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import grandTourerUrl from '@/assets/nightdrive-grand-tourer-evolution.glb?url'; import grandTourerUrl from '@/assets/nightdrive-grand-tourer-expressway.glb?url';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
import type { SceneDetailMode } from '@/scene/types'; import type { SceneDetailMode } from '@/scene/types';
export interface CarWorld { export interface CarWorld {
group: CarGroup; group: CarGroup;
setDetail(detail: SceneDetailMode): void; setDetail(detail: SceneDetailMode): void;
update(speed: number, acceleration: number, elapsedSeconds: number): void; update(
speed: number,
acceleration: number,
elapsedSeconds: number,
distance?: number,
): void;
} }
interface CarGroup { interface CarGroup {
name: string; name: string;
children: unknown[]; children: unknown[];
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;
};
rotation: { x: number; y: number; z: number }; rotation: { x: number; y: number; z: number };
scale: { set(x: number, y: number, z: number): void }; scale: { set(x: number, y: number, z: number): void };
visible: boolean; visible: boolean;
@@ -35,6 +45,9 @@ interface AuthoredObject {
isMesh?: boolean; isMesh?: boolean;
geometry?: { dispose(): void }; geometry?: { dispose(): void };
material?: AuthoredMaterial | AuthoredMaterial[]; material?: AuthoredMaterial | AuthoredMaterial[];
parent?: { name: string };
position: { y: number };
rotation: { x: number; z: number };
} }
interface AuthoredCarGroup extends CarGroup { interface AuthoredCarGroup extends CarGroup {
@@ -46,16 +59,23 @@ const FULL_BASE_Y = 0.58;
const REDUCED_BASE_Y = 0.42; const REDUCED_BASE_Y = 0.42;
const GRAND_TOURER_URL = grandTourerUrl; const GRAND_TOURER_URL = grandTourerUrl;
export async function loadGrandTourer(signal: AbortSignal): Promise<AuthoredCarGroup> { export async function loadGrandTourer(
signal: AbortSignal,
): Promise<AuthoredCarGroup> {
const response = await fetch(GRAND_TOURER_URL, { signal }); const response = await fetch(GRAND_TOURER_URL, { signal });
if (!response.ok) { if (!response.ok) {
throw new Error(`Grand tourer asset failed to load (${response.status}).`); throw new Error(`Grand tourer asset failed to load (${response.status}).`);
} }
const bytes = await response.arrayBuffer(); const bytes = await response.arrayBuffer();
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError'); if (signal.aborted)
throw new DOMException('Scene build aborted', 'AbortError');
const gltf = await new GLTFLoader().parseAsync(bytes, GRAND_TOURER_URL); const gltf = await new GLTFLoader().parseAsync(bytes, GRAND_TOURER_URL);
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError'); if (signal.aborted)
const authored = gltf.scene.getObjectByName('evolution-chase-car') ?? gltf.scene.getObjectByName('chase-car') ?? gltf.scene; throw new DOMException('Scene build aborted', 'AbortError');
const authored =
gltf.scene.getObjectByName('evolution-chase-car') ??
gltf.scene.getObjectByName('chase-car') ??
gltf.scene;
authored.removeFromParent(); authored.removeFromParent();
return authored as AuthoredCarGroup; return authored as AuthoredCarGroup;
} }
@@ -71,85 +91,118 @@ export function createCar(
group.position.set(0, baseY, 0.72); group.position.set(0, baseY, 0.72);
group.scale.set(1.1, 0.82, 0.98); group.scale.set(1.1, 0.82, 0.98);
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({ const bodyMaterial = resources.own(
color: 0x304e68, 'material',
emissive: 0x091725, new THREE.MeshStandardMaterial({
emissiveIntensity: 0.42, color: 0x304e68,
roughness: 0.36, emissive: 0x091725,
metalness: 0.68, emissiveIntensity: 0.42,
})); roughness: 0.36,
const shoulderMaterial = resources.own('material', new THREE.MeshStandardMaterial({ metalness: 0.68,
color: 0x365b77, }),
emissive: 0x0a1b2a, );
emissiveIntensity: 0.36, const shoulderMaterial = resources.own(
roughness: 0.3, 'material',
metalness: 0.74, new THREE.MeshStandardMaterial({
})); color: 0x365b77,
const lowerBodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({ emissive: 0x0a1b2a,
color: 0x111c29, emissiveIntensity: 0.36,
roughness: 0.48, roughness: 0.3,
metalness: 0.54, metalness: 0.74,
})); }),
const glassMaterial = resources.own('material', new THREE.MeshStandardMaterial({ );
color: 0x071522, const lowerBodyMaterial = resources.own(
emissive: 0x061522, 'material',
emissiveIntensity: 0.34, new THREE.MeshStandardMaterial({
roughness: 0.12, color: 0x111c29,
metalness: 0.42, roughness: 0.48,
transparent: true, metalness: 0.54,
opacity: 0.92, }),
})); );
const fasciaMaterial = resources.own('material', new THREE.MeshStandardMaterial({ const glassMaterial = resources.own(
color: 0x080d15, 'material',
roughness: 0.52, new THREE.MeshStandardMaterial({
metalness: 0.42, color: 0x071522,
})); emissive: 0x061522,
const tailMaterial = resources.own('material', new THREE.MeshStandardMaterial({ emissiveIntensity: 0.34,
color: 0x8f0b2d, roughness: 0.12,
emissive: 0xff174f, metalness: 0.42,
emissiveIntensity: 1.25, transparent: true,
roughness: 0.26, opacity: 0.92,
metalness: 0.18, }),
toneMapped: true, );
})); const fasciaMaterial = resources.own(
const cyanMaterial = resources.own('material', new THREE.MeshStandardMaterial({ 'material',
color: 0x2b9bab, new THREE.MeshStandardMaterial({
emissive: 0x44dceb, color: 0x080d15,
emissiveIntensity: 0.7, roughness: 0.52,
roughness: 0.34, metalness: 0.42,
metalness: 0.32, }),
toneMapped: true, );
})); const tailMaterial = resources.own(
const tireMaterial = resources.own('material', new THREE.MeshStandardMaterial({ 'material',
color: 0x020407, new THREE.MeshStandardMaterial({
roughness: 0.94, color: 0x8f0b2d,
metalness: 0.04, emissive: 0xff174f,
})); emissiveIntensity: 1.25,
const hubMaterial = resources.own('material', new THREE.MeshStandardMaterial({ roughness: 0.26,
color: 0x354451, metalness: 0.18,
roughness: 0.42, toneMapped: true,
metalness: 0.78, }),
})); );
const cyanMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x2b9bab,
emissive: 0x44dceb,
emissiveIntensity: 0.7,
roughness: 0.34,
metalness: 0.32,
toneMapped: true,
}),
);
const tireMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x020407,
roughness: 0.94,
metalness: 0.04,
}),
);
const hubMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x354451,
roughness: 0.42,
metalness: 0.78,
}),
);
const rearProfile = createRearProfile(); const rearProfile = createRearProfile();
const silhouetteGeometry = resources.own('geometry', new THREE.ExtrudeGeometry(rearProfile, { const silhouetteGeometry = resources.own(
depth: 5.8, 'geometry',
steps: 1, new THREE.ExtrudeGeometry(rearProfile, {
bevelEnabled: true, depth: 5.8,
bevelSegments: 1, steps: 1,
bevelSize: 0.11, bevelEnabled: true,
bevelThickness: 0.1, bevelSegments: 1,
})); bevelSize: 0.11,
bevelThickness: 0.1,
}),
);
silhouetteGeometry.translate(0, 0, -2.9); silhouetteGeometry.translate(0, 0, -2.9);
silhouetteGeometry.computeVertexNormals(); silhouetteGeometry.computeVertexNormals();
const silhouette = new THREE.Mesh(silhouetteGeometry, bodyMaterial); const silhouette = new THREE.Mesh(silhouetteGeometry, bodyMaterial);
silhouette.name = 'rear-silhouette'; silhouette.name = 'rear-silhouette';
group.add(silhouette); group.add(silhouette);
const rearFaceMaterial = resources.own('material', new THREE.MeshBasicMaterial({ const rearFaceMaterial = resources.own(
color: 0x1b3246, 'material',
toneMapped: true, new THREE.MeshBasicMaterial({
})); color: 0x1b3246,
toneMapped: true,
}),
);
const rearFace = new THREE.Mesh( const rearFace = new THREE.Mesh(
resources.own('geometry', new THREE.ShapeGeometry(rearProfile)), resources.own('geometry', new THREE.ShapeGeometry(rearProfile)),
rearFaceMaterial, rearFaceMaterial,
@@ -184,12 +237,15 @@ export function createCar(
rearGlassProfile.lineTo(1.15, 1.41); rearGlassProfile.lineTo(1.15, 1.41);
rearGlassProfile.lineTo(1.58, 0.8); rearGlassProfile.lineTo(1.58, 0.8);
rearGlassProfile.closePath(); rearGlassProfile.closePath();
const rearGlassMaterial = resources.own('material', new THREE.MeshBasicMaterial({ const rearGlassMaterial = resources.own(
color: 0x0b2537, 'material',
transparent: true, new THREE.MeshBasicMaterial({
opacity: 0.94, color: 0x0b2537,
toneMapped: true, transparent: true,
})); opacity: 0.94,
toneMapped: true,
}),
);
const rearGlass = new THREE.Mesh( const rearGlass = new THREE.Mesh(
resources.own('geometry', new THREE.ShapeGeometry(rearGlassProfile)), resources.own('geometry', new THREE.ShapeGeometry(rearGlassProfile)),
rearGlassMaterial, rearGlassMaterial,
@@ -198,13 +254,19 @@ export function createCar(
rearGlass.position.z = 1.44; rearGlass.position.z = 1.44;
group.add(rearGlass); group.add(rearGlass);
const fasciaGeometry = resources.own('geometry', new THREE.BoxGeometry(4.84, 0.56, 0.16)); const fasciaGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(4.84, 0.56, 0.16),
);
const fascia = new THREE.Mesh(fasciaGeometry, fasciaMaterial); const fascia = new THREE.Mesh(fasciaGeometry, fasciaMaterial);
fascia.name = 'rear-fascia'; fascia.name = 'rear-fascia';
fascia.position.set(0, 0.02, 3.07); fascia.position.set(0, 0.02, 3.07);
group.add(fascia); group.add(fascia);
const lightGeometry = resources.own('geometry', new THREE.BoxGeometry(4.55, 0.13, 0.09)); const lightGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(4.55, 0.13, 0.09),
);
const lightBar = new THREE.Mesh(lightGeometry, tailMaterial); const lightBar = new THREE.Mesh(lightGeometry, tailMaterial);
lightBar.name = 'rear-light-bar'; lightBar.name = 'rear-light-bar';
lightBar.position.set(0, 0.46, 3.17); lightBar.position.set(0, 0.46, 3.17);
@@ -216,9 +278,18 @@ export function createCar(
diffuser.position.z = 3.08; diffuser.position.z = 3.08;
group.add(diffuser); group.add(diffuser);
const wheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.66, 0.66, 0.5, 24)); const wheelGeometry = resources.own(
const hubGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.31, 0.31, 0.515, 16)); 'geometry',
for (const [x, side] of [[-2.38, 'left'], [2.38, 'right']] as const) { new THREE.CylinderGeometry(0.66, 0.66, 0.5, 24),
);
const hubGeometry = resources.own(
'geometry',
new THREE.CylinderGeometry(0.31, 0.31, 0.515, 16),
);
for (const [x, side] of [
[-2.38, 'left'],
[2.38, 'right'],
] as const) {
const wheel = new THREE.Mesh(wheelGeometry, tireMaterial); const wheel = new THREE.Mesh(wheelGeometry, tireMaterial);
wheel.name = `rear-wheel-${side}`; wheel.name = `rear-wheel-${side}`;
wheel.rotation.z = Math.PI / 2; wheel.rotation.z = Math.PI / 2;
@@ -235,17 +306,22 @@ export function createCar(
uColor: { value: new THREE.Color(0x49d7e5) }, uColor: { value: new THREE.Color(0x49d7e5) },
uOpacity: { value: 0.12 }, uOpacity: { value: 0.12 },
}; };
const underglowGeometry = resources.own('geometry', new THREE.PlaneGeometry(3.9, 4.2)); const underglowGeometry = resources.own(
const underglowMaterial = resources.own('material', new THREE.ShaderMaterial({ 'geometry',
uniforms: underglowUniforms, new THREE.PlaneGeometry(3.9, 4.2),
vertexShader: ` );
const underglowMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: underglowUniforms,
vertexShader: `
varying vec2 vGlowUv; varying vec2 vGlowUv;
void main() { void main() {
vGlowUv = uv; vGlowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform vec3 uColor; uniform vec3 uColor;
uniform float uOpacity; uniform float uOpacity;
varying vec2 vGlowUv; varying vec2 vGlowUv;
@@ -257,12 +333,13 @@ export function createCar(
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: true, depthTest: true,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
toneMapped: true, toneMapped: true,
})); }),
);
const underglow = new THREE.Mesh(underglowGeometry, underglowMaterial); const underglow = new THREE.Mesh(underglowGeometry, underglowMaterial);
underglow.name = 'car-underglow'; underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2; underglow.rotation.x = -Math.PI / 2;
@@ -274,18 +351,27 @@ export function createCar(
const secondary = new THREE.Group(); const secondary = new THREE.Group();
secondary.name = 'secondary-detail'; secondary.name = 'secondary-detail';
const trimGeometry = resources.own('geometry', new THREE.BoxGeometry(0.38, 0.075, 0.07)); const trimGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(0.38, 0.075, 0.07),
);
for (const x of [-1.98, 1.98]) { for (const x of [-1.98, 1.98]) {
const trim = new THREE.Mesh(trimGeometry, cyanMaterial); const trim = new THREE.Mesh(trimGeometry, cyanMaterial);
trim.position.set(x, -0.15, 3.13); trim.position.set(x, -0.15, 3.13);
secondary.add(trim); secondary.add(trim);
} }
const deckGeometry = resources.own('geometry', new THREE.BoxGeometry(3.25, 0.075, 0.62)); const deckGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(3.25, 0.075, 0.62),
);
const rearDeck = new THREE.Mesh(deckGeometry, shoulderMaterial); const rearDeck = new THREE.Mesh(deckGeometry, shoulderMaterial);
rearDeck.position.set(0, 0.86, 2.15); rearDeck.position.set(0, 0.86, 2.15);
rearDeck.rotation.x = -0.08; rearDeck.rotation.x = -0.08;
secondary.add(rearDeck); secondary.add(rearDeck);
const frontWheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.58, 0.58, 0.42, 18)); const frontWheelGeometry = resources.own(
'geometry',
new THREE.CylinderGeometry(0.58, 0.58, 0.42, 18),
);
for (const x of [-2.18, 2.18]) { for (const x of [-2.18, 2.18]) {
const wheel = new THREE.Mesh(frontWheelGeometry, tireMaterial); const wheel = new THREE.Mesh(frontWheelGeometry, tireMaterial);
wheel.rotation.z = Math.PI / 2; wheel.rotation.z = Math.PI / 2;
@@ -321,24 +407,30 @@ export function createCar(
update(speed, acceleration, elapsedSeconds) { update(speed, acceleration, elapsedSeconds) {
const safeSpeed = clamp01(speed); const safeSpeed = clamp01(speed);
const response = clamp01(acceleration); const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0; const elapsed = Number.isFinite(elapsedSeconds)
const deltaSeconds = previousElapsed === null ? Math.max(0, elapsedSeconds)
? 1 / 60 : 0;
: THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05); const deltaSeconds =
previousElapsed === null
? 1 / 60
: THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
previousElapsed = elapsed; previousElapsed = elapsed;
const targetSquat = response * 0.088; const targetSquat = response * 0.088;
const springAcceleration = (targetSquat - squat) * 34 - squatVelocity * 10; const springAcceleration =
(targetSquat - squat) * 34 - squatVelocity * 10;
squatVelocity += springAcceleration * deltaSeconds; squatVelocity += springAcceleration * deltaSeconds;
squat += squatVelocity * deltaSeconds; squat += squatVelocity * deltaSeconds;
const vibration = Math.sin(elapsed * (8.2 + safeSpeed * 2.4)) * (0.002 + safeSpeed * 0.003); const vibration =
Math.sin(elapsed * (8.2 + safeSpeed * 2.4)) *
(0.002 + safeSpeed * 0.003);
group.position.y = baseY - squat + vibration; group.position.y = baseY - squat + vibration;
group.rotation.x = -squat * 0.045; group.rotation.x = -squat * 0.045;
group.rotation.z = ( group.rotation.z =
Math.sin(elapsed * 0.78) * safeSpeed * 0.0024 Math.sin(elapsed * 0.78) * safeSpeed * 0.0024 +
+ Math.sin(elapsed * 1.31) * response * 0.0014 Math.sin(elapsed * 1.31) * response * 0.0014;
);
tailMaterial.emissiveIntensity = 1.25 + response * 1.35; tailMaterial.emissiveIntensity = 1.25 + response * 1.35;
underglowUniforms.uOpacity.value = 0.1 + safeSpeed * 0.055 + response * 0.07; underglowUniforms.uOpacity.value =
0.1 + safeSpeed * 0.055 + response * 0.07;
contact.uniforms.uOpacity.value = 0.2 + response * 0.06; contact.uniforms.uOpacity.value = 0.2 + response * 0.06;
}, },
}; };
@@ -354,21 +446,34 @@ function createAuthoredCar(
authoredModel.rotation.y = Math.PI; authoredModel.rotation.y = Math.PI;
group.add(authoredModel); group.add(authoredModel);
const wheels: Array<{ object: AuthoredObject; radius: number }> = [];
const bodyParts: Array<{ object: AuthoredObject; y: number }> = [];
const tailMaterials: AuthoredMaterial[] = []; const tailMaterials: AuthoredMaterial[] = [];
const cyanMaterials: AuthoredMaterial[] = []; const cyanMaterials: AuthoredMaterial[] = [];
authoredModel.traverse((object) => { authoredModel.traverse((object) => {
if (object.name.startsWith('evolution-')) object.name = object.name.slice('evolution-'.length); if (object.name.startsWith('evolution-'))
object.name = object.name.slice('evolution-'.length);
// Three removes Blender's suffix dot when sanitizing imported node names. // Three removes Blender's suffix dot when sanitizing imported node names.
object.name = object.name.replace(/\.?\d{3}$/u, ''); object.name = object.name.replace(/\.?\d{3}$/u, '');
if (/^wheel-(front|rear)-(left|right)$/u.test(object.name)) {
wheels.push({
object,
radius: object.name.includes('front') ? 0.42 : 0.46,
});
}
if (!object.isMesh || !object.geometry || !object.material) return; if (!object.isMesh || !object.geometry || !object.material) return;
if (!object.parent?.name.startsWith('wheel-'))
bodyParts.push({ object, y: object.position.y });
resources.own('geometry', object.geometry); resources.own('geometry', object.geometry);
const materials = Array.isArray(object.material) ? object.material : [object.material]; const materials = Array.isArray(object.material)
? object.material
: [object.material];
for (const material of materials) { for (const material of materials) {
resources.own('material', material); resources.own('material', material);
ownMaterialTextures(resources, material); ownMaterialTextures(resources, material);
if ( if (
material.isMeshStandardMaterial material.isMeshStandardMaterial &&
&& (object.name === 'car-body' || object.name === 'body-haunches') (object.name === 'car-body' || object.name === 'body-haunches')
) { ) {
material.emissive?.setHex(0x0b2738); material.emissive?.setHex(0x0b2738);
material.emissiveIntensity = 0.26; material.emissiveIntensity = 0.26;
@@ -380,7 +485,10 @@ function createAuthoredCar(
if (object.name === 'rear-light-bar' && material.isMeshStandardMaterial) { if (object.name === 'rear-light-bar' && material.isMeshStandardMaterial) {
tailMaterials.push(material); tailMaterials.push(material);
} }
if (object.name === 'car-underglow-source' && material.isMeshStandardMaterial) { if (
object.name === 'car-underglow-source' &&
material.isMeshStandardMaterial
) {
cyanMaterials.push(material); cyanMaterials.push(material);
} }
} }
@@ -392,16 +500,18 @@ function createAuthoredCar(
}; };
const underglow = new THREE.Mesh( const underglow = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(1.72, 3.82)), resources.own('geometry', new THREE.PlaneGeometry(1.72, 3.82)),
resources.own('material', new THREE.ShaderMaterial({ resources.own(
uniforms: underglowUniforms, 'material',
vertexShader: ` new THREE.ShaderMaterial({
uniforms: underglowUniforms,
vertexShader: `
varying vec2 vGlowUv; varying vec2 vGlowUv;
void main() { void main() {
vGlowUv = uv; vGlowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform vec3 uColor; uniform vec3 uColor;
uniform float uOpacity; uniform float uOpacity;
varying vec2 vGlowUv; varying vec2 vGlowUv;
@@ -413,12 +523,13 @@ function createAuthoredCar(
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: true, depthTest: true,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
toneMapped: true, toneMapped: true,
})), }),
),
); );
underglow.name = 'car-underglow'; underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2; underglow.rotation.x = -Math.PI / 2;
@@ -454,25 +565,45 @@ function createAuthoredCar(
detail = nextDetail; detail = nextDetail;
setScale(detail); setScale(detail);
}, },
update(speed, acceleration, elapsedSeconds) { update(speed, acceleration, elapsedSeconds, distance) {
const safeSpeed = clamp01(speed); const safeSpeed = clamp01(speed);
const response = clamp01(acceleration); const response = clamp01(acceleration);
const elapsed = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0; const elapsed = Number.isFinite(elapsedSeconds)
const deltaSeconds = previousElapsed === null ? Math.max(0, elapsedSeconds)
? 1 / 60 : 0;
: THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05); const deltaSeconds =
previousElapsed === null
? 1 / 60
: THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
previousElapsed = elapsed; previousElapsed = elapsed;
const targetSquat = response * 0.04; const targetSquat = response * 0.04;
const springAcceleration = (targetSquat - squat) * 34 - squatVelocity * 10; const springAcceleration =
(targetSquat - squat) * 34 - squatVelocity * 10;
squatVelocity += springAcceleration * deltaSeconds; squatVelocity += springAcceleration * deltaSeconds;
squat += squatVelocity * deltaSeconds; squat += squatVelocity * deltaSeconds;
const vibration = Math.sin(elapsed * (7.4 + safeSpeed * 2.2)) * (0.0014 + safeSpeed * 0.0022); const vibration =
group.position.y = baseY - squat + vibration; Math.sin(elapsed * (7.4 + safeSpeed * 2.2)) *
group.rotation.x = -squat * 0.035; (0.0014 + safeSpeed * 0.0022);
group.position.y = baseY + vibration * 0.2;
for (const part of bodyParts) {
part.object.position.y = part.y - squat * 0.42;
part.object.rotation.x = -squat * 0.11;
part.object.rotation.z = Math.sin(elapsed * 0.65) * safeSpeed * 0.0015;
}
if (distance !== undefined && Number.isFinite(distance)) {
const scale = detail === 'full' ? 2.78 : 2.32;
for (const wheel of wheels)
wheel.object.rotation.x =
(distance / (wheel.radius * scale)) % (Math.PI * 2);
}
group.rotation.x = 0;
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018; group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
for (const material of tailMaterials) material.emissiveIntensity = 2.2 + response * 1.2; for (const material of tailMaterials)
for (const material of cyanMaterials) material.emissiveIntensity = 2.2 + response * 1.6; material.emissiveIntensity = 2.2 + response * 1.2;
underglowUniforms.uOpacity.value = 0.09 + safeSpeed * 0.04 + response * 0.075; for (const material of cyanMaterials)
material.emissiveIntensity = 2.2 + response * 1.6;
underglowUniforms.uOpacity.value =
0.09 + safeSpeed * 0.04 + response * 0.075;
contact.uniforms.uOpacity.value = 0.21 + response * 0.055; contact.uniforms.uOpacity.value = 0.21 + response * 0.055;
}, },
}; };
@@ -487,16 +618,18 @@ function createContactShadow(
const uniforms = { const uniforms = {
uOpacity: { value: opacity }, uOpacity: { value: opacity },
}; };
const material = resources.own('material', new THREE.ShaderMaterial({ const material = resources.own(
uniforms, 'material',
vertexShader: ` new THREE.ShaderMaterial({
uniforms,
vertexShader: `
varying vec2 vShadowUv; varying vec2 vShadowUv;
void main() { void main() {
vShadowUv = uv; vShadowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform float uOpacity; uniform float uOpacity;
varying vec2 vShadowUv; varying vec2 vShadowUv;
void main() { void main() {
@@ -507,12 +640,13 @@ function createContactShadow(
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: true, depthTest: true,
blending: THREE.NormalBlending, blending: THREE.NormalBlending,
toneMapped: true, toneMapped: true,
})); }),
);
const mesh = new THREE.Mesh( const mesh = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(width, depth)), resources.own('geometry', new THREE.PlaneGeometry(width, depth)),
material, material,
@@ -535,7 +669,8 @@ function ownMaterialTextures(
'metalnessMap', 'metalnessMap',
'aoMap', 'aoMap',
]) { ]) {
const texture = material[key] as { isTexture?: boolean; dispose(): void } | null | undefined; const texture = material[key] as
{ isTexture?: boolean; dispose(): void } | null | undefined;
if (texture?.isTexture) resources.own('texture', texture); if (texture?.isTexture) resources.own('texture', texture);
} }
} }
@@ -559,24 +694,36 @@ function createHaunchGeometry(side: -1 | 1) {
const outer = 2.62 * side; const outer = 2.62 * side;
const frontOuter = 2.25 * side; const frontOuter = 2.25 * side;
const positions = new Float32Array([ const positions = new Float32Array([
inner, 0.48, -1.75, inner,
frontOuter, 0.38, -1.75, 0.48,
inner, 0.92, -1.75, -1.75,
outer, 0.68, -1.75, frontOuter,
inner, 0.48, 2.62, 0.38,
outer, 0.33, 2.62, -1.75,
inner, 0.92, 2.62, inner,
outer, 0.73, 2.62, 0.92,
-1.75,
outer,
0.68,
-1.75,
inner,
0.48,
2.62,
outer,
0.33,
2.62,
inner,
0.92,
2.62,
outer,
0.73,
2.62,
]); ]);
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex([ geometry.setIndex([
0, 1, 2, 2, 1, 3, 0, 1, 2, 2, 1, 3, 4, 6, 5, 6, 7, 5, 0, 4, 1, 1, 4, 5, 2, 3, 6, 3, 7, 6, 1,
4, 6, 5, 6, 7, 5, 5, 3, 3, 5, 7, 0, 2, 4, 2, 6, 4,
0, 4, 1, 1, 4, 5,
2, 3, 6, 3, 7, 6,
1, 5, 3, 3, 5, 7,
0, 2, 4, 2, 6, 4,
]); ]);
geometry.computeVertexNormals(); geometry.computeVertexNormals();
return geometry; return geometry;
@@ -584,24 +731,14 @@ function createHaunchGeometry(side: -1 | 1) {
function createCanopyGeometry() { function createCanopyGeometry() {
const positions = new Float32Array([ const positions = new Float32Array([
-1.18, 0.78, -1.9, -1.18, 0.78, -1.9, 1.18, 0.78, -1.9, -0.86, 1.82, -1.65, 0.86, 1.82, -1.65,
1.18, 0.78, -1.9, -1.62, 0.78, 1.42, 1.62, 0.78, 1.42, -1.18, 1.42, 1.06, 1.18, 1.42, 1.06,
-0.86, 1.82, -1.65,
0.86, 1.82, -1.65,
-1.62, 0.78, 1.42,
1.62, 0.78, 1.42,
-1.18, 1.42, 1.06,
1.18, 1.42, 1.06,
]); ]);
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex([ geometry.setIndex([
0, 1, 2, 2, 1, 3, 0, 1, 2, 2, 1, 3, 4, 6, 5, 6, 7, 5, 0, 2, 4, 2, 6, 4, 1, 5, 3, 3, 5, 7, 2,
4, 6, 5, 6, 7, 5, 3, 6, 3, 7, 6, 0, 4, 1, 1, 4, 5,
0, 2, 4, 2, 6, 4,
1, 5, 3, 3, 5, 7,
2, 3, 6, 3, 7, 6,
0, 4, 1, 1, 4, 5,
]); ]);
geometry.computeVertexNormals(); geometry.computeVertexNormals();
return geometry; return geometry;

View File

@@ -11,13 +11,79 @@ export interface ChapterComposition {
shift: number; shift: number;
} }
export const CHAPTER_COMPOSITIONS: Readonly<Record<SectionId, ChapterComposition>> = { export const CHAPTER_COMPOSITIONS: Readonly<
start: { x: -2.6, y: 6.4, z: 21, targetY: 3.9, targetZ: -90, fov: 56, shift: 0.2 }, Record<SectionId, ChapterComposition>
profile: { x: -3.6, y: 7.4, z: 23, targetY: 4.3, targetZ: -108, fov: 59, shift: -0.23 }, > = {
experience: { x: 3.2, y: 8.1, z: 24, targetY: 4.8, targetZ: -120, fov: 61, shift: 0.25 }, start: {
infrastructure: { x: -1.8, y: 9.5, z: 26, targetY: 5.2, targetZ: -128, fov: 64, shift: 0 }, x: -5.6,
'selected-builds': { x: 2.5, y: 7, z: 23, targetY: 4.2, targetZ: -105, fov: 59, shift: 0.04 }, y: 5.6,
'additional-work': { x: -3.2, y: 7.8, z: 24, targetY: 4.7, targetZ: -110, fov: 61, shift: -0.12 }, z: 24,
toolkit: { x: 0, y: 8.6, z: 25, targetY: 5, targetZ: -120, fov: 62, shift: 0 }, targetY: 3.5,
contact: { x: -2.8, y: 5.8, z: 23, targetY: 3.4, targetZ: -95, fov: 55, shift: 0.24 }, targetZ: -88,
fov: 54,
shift: 0.14,
},
profile: {
x: 4.5,
y: 6.2,
z: 25,
targetY: 4.3,
targetZ: -108,
fov: 59,
shift: -0.23,
},
experience: {
x: -4.0,
y: 6.8,
z: 25,
targetY: 4.8,
targetZ: -120,
fov: 61,
shift: 0.25,
},
infrastructure: {
x: -1.8,
y: 9.5,
z: 26,
targetY: 5.2,
targetZ: -128,
fov: 64,
shift: 0,
},
'selected-builds': {
x: 2.5,
y: 7,
z: 23,
targetY: 4.2,
targetZ: -105,
fov: 59,
shift: 0.04,
},
'additional-work': {
x: -3.2,
y: 7.8,
z: 24,
targetY: 4.7,
targetZ: -110,
fov: 61,
shift: -0.12,
},
toolkit: {
x: 0,
y: 8.6,
z: 25,
targetY: 5,
targetZ: -120,
fov: 62,
shift: 0,
},
contact: {
x: -4.8,
y: 4.8,
z: 26,
targetY: 3.4,
targetZ: -95,
fov: 55,
shift: 0.24,
},
}; };

View File

@@ -3,6 +3,9 @@ import type { JourneySnapshot } from '@/journey/types';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
import type { SceneBuildContext, SceneGeneration } from '@/scene/types'; import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
import { renderQualityFor } from '@/scene/render-quality'; import { renderQualityFor } from '@/scene/render-quality';
import { createHorizonAir } from '@/scene/world/horizon-air';
import { createExpressway } from '@/scene/world/expressway';
import { createSignStage } from '@/scene/world/sign-stage';
import { createRoad } from '@/scene/world/road'; import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain'; import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun'; import { createSun } from '@/scene/world/sun';
@@ -11,10 +14,7 @@ import { createSign, prepareSignTypography } from '@/scene/world/sign';
import { createAtmosphere } from '@/scene/world/atmosphere'; import { createAtmosphere } from '@/scene/world/atmosphere';
import { createPostProcessor } from '@/scene/post-processing'; import { createPostProcessor } from '@/scene/post-processing';
import { createDriveDynamics } from '@/scene/drive-dynamics'; import { createDriveDynamics } from '@/scene/drive-dynamics';
import { import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
HORIZON_FOG_END,
HORIZON_FOG_START,
} from '@/scene/world/metrics';
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition'; import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light'; import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance'; import { createTravelDistance } from '@/scene/world/travel-distance';
@@ -26,12 +26,14 @@ import {
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean; declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; const TEST_BUILD =
typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export function createChaseCamera(aspect = 1) { export function createChaseCamera(aspect = 1) {
const camera = new THREE.PerspectiveCamera(56, aspect, 0.1, 1_200); const c = CHAPTER_COMPOSITIONS.start;
camera.position.set(-2.6, 6.4, 21); const camera = new THREE.PerspectiveCamera(c.fov, aspect, 0.1, 1_200);
camera.lookAt(0, 3.9, -90); camera.position.set(c.x, c.y, c.z);
camera.lookAt(0, c.targetY, c.targetZ);
return camera; return camera;
} }
@@ -54,17 +56,27 @@ export async function createProceduralSceneGeneration(
recordTestFaultEvent('car-rejected'); recordTestFaultEvent('car-rejected');
throw new Error('Injected car failure'); throw new Error('Injected car failure');
} }
if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError'); if (context.signal.aborted)
throw new DOMException('Scene build aborted', 'AbortError');
const canvas = context.canvas as HTMLCanvasElement; const canvas = context.canvas as HTMLCanvasElement;
if (typeof canvas.getContext !== 'function') throw new Error('A canvas element is required'); if (typeof canvas.getContext !== 'function')
const mobileOrTablet = Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280; throw new Error('A canvas element is required');
const quality = renderQualityFor(context.quality, globalThis.devicePixelRatio ?? 1, mobileOrTablet); const mobileOrTablet =
const renderer = context.resources.own('renderer', new THREE.WebGLRenderer({ Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
canvas, const quality = renderQualityFor(
alpha: false, context.quality,
antialias: context.quality !== 'low', globalThis.devicePixelRatio ?? 1,
powerPreference: 'high-performance', mobileOrTablet,
})); );
const renderer = context.resources.own(
'renderer',
new THREE.WebGLRenderer({
canvas,
alpha: false,
antialias: context.quality !== 'low',
powerPreference: 'high-performance',
}),
);
renderer.setPixelRatio(quality.pixelRatio); renderer.setPixelRatio(quality.pixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMapping = THREE.ACESFilmicToneMapping;
@@ -80,6 +92,8 @@ export async function createProceduralSceneGeneration(
scene.background = background; scene.background = background;
scene.fog = createWorldFog(currentPalette.fog.getHex()); scene.fog = createWorldFog(currentPalette.fog.getHex());
const camera = createChaseCamera(); const camera = createChaseCamera();
camera.name = 'journey-camera';
scene.add(camera);
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25); const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
scene.add(ambient); scene.add(ambient);
@@ -97,7 +111,10 @@ export async function createProceduralSceneGeneration(
const road = createRoad(context.resources, quality); const road = createRoad(context.resources, quality);
const terrain = createTerrain(context.resources, quality); const terrain = createTerrain(context.resources, quality);
const expressway = createExpressway(context.resources, quality);
const signStage = createSignStage(context.resources);
const sun = createSun(context.resources); const sun = createSun(context.resources);
const horizonAir = createHorizonAir(context.resources);
const authoredCar = await loadGrandTourer(context.signal); const authoredCar = await loadGrandTourer(context.signal);
const car = createCar(context.resources, authoredCar); const car = createCar(context.resources, authoredCar);
car.setDetail(mobileOrTablet ? 'reduced' : 'full'); car.setDetail(mobileOrTablet ? 'reduced' : 'full');
@@ -110,8 +127,10 @@ export async function createProceduralSceneGeneration(
scene.add( scene.add(
atmosphere.group, atmosphere.group,
road.group, road.group,
expressway.group,
terrain.group, terrain.group,
sun.group, sun.group,
horizonAir.group,
car.group, car.group,
sign.group, sign.group,
); );
@@ -127,7 +146,7 @@ export async function createProceduralSceneGeneration(
targetX: 0, targetX: 0,
targetY: 3.9, targetY: 3.9,
targetZ: -90, targetZ: -90,
shift: 0.2, shift: CHAPTER_COMPOSITIONS.start.shift,
verticalShift: 0, verticalShift: 0,
fov: camera.fov, fov: camera.fov,
roll: 0, roll: 0,
@@ -138,6 +157,7 @@ export async function createProceduralSceneGeneration(
let lastNow: number | null = null; let lastNow: number | null = null;
let disposed = false; let disposed = false;
let renderedFrames = 0; let renderedFrames = 0;
let atmosphericTime = 0;
let firstFrameSuppressionRecorded = false; let firstFrameSuppressionRecorded = false;
const resize = (): void => { const resize = (): void => {
@@ -151,9 +171,11 @@ export async function createProceduralSceneGeneration(
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
if (lastNow === null) { if (lastNow === null) {
const wideComposition = camera.aspect > 1.18; const wideComposition = camera.aspect > 1.18;
cameraState.x = wideComposition ? -2.6 : 0; cameraState.x = wideComposition ? CHAPTER_COMPOSITIONS.start.x : 0;
cameraState.targetX = 0; cameraState.targetX = 0;
cameraState.shift = wideComposition ? 0.2 : 0; cameraState.shift = wideComposition
? CHAPTER_COMPOSITIONS.start.shift
: 0;
cameraState.verticalShift = wideComposition ? 0 : 0.16; cameraState.verticalShift = wideComposition ? 0 : 0.16;
camera.position.set(cameraState.x, cameraState.y, cameraState.z); camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt( camera.lookAt(
@@ -166,18 +188,24 @@ export async function createProceduralSceneGeneration(
car.group.visible = height >= 340; car.group.visible = height >= 340;
sign.setViewportSize(width, height); sign.setViewportSize(width, height);
atmosphere.setViewportAspect(camera.aspect); atmosphere.setViewportAspect(camera.aspect);
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale); postProcessor?.resize(
width,
height,
quality.pixelRatio * quality.postScale,
);
if (TEST_BUILD && context.quality !== 'medium') { if (TEST_BUILD && context.quality !== 'medium') {
window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({ window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(
tier: context.quality, context.resources.gpuBudgetSnapshot({
defaultFramebuffer: { tier: context.quality,
width: Math.max(1, canvas.width), defaultFramebuffer: {
height: Math.max(1, canvas.height), width: Math.max(1, canvas.width),
bytesPerPixel: 4, height: Math.max(1, canvas.height),
attachments: 2, bytesPerPixel: 4,
samples: context.quality === 'low' ? 1 : 4, attachments: 2,
}, samples: context.quality === 'low' ? 1 : 4,
})); },
}),
);
} }
}; };
@@ -197,15 +225,18 @@ export async function createProceduralSceneGeneration(
return false; return false;
} }
if ( if (
TEST_BUILD TEST_BUILD &&
&& isTestFault('F3-post-first-frame-exception') isTestFault('F3-post-first-frame-exception') &&
&& renderedFrames > 0 renderedFrames > 0
) { ) {
recordTestFaultEvent('post-first-frame-exception'); recordTestFaultEvent('post-first-frame-exception');
throw new Error('Injected post-first-frame exception'); throw new Error('Injected post-first-frame exception');
} }
resize(); resize();
const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000)); const deltaSeconds =
lastNow === null
? 0
: Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
lastNow = now; lastNow = now;
dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds); dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds);
const response = dynamics.snapshot(); const response = dynamics.snapshot();
@@ -221,7 +252,19 @@ export async function createProceduralSceneGeneration(
0.18, 0.18,
1, 1,
); );
road.update(driveDistance, visualSpeed, response.roadEnergy, currentPalette); road.update(
driveDistance,
visualSpeed,
response.roadEnergy,
currentPalette,
);
expressway.update(
driveDistance,
response.roadEnergy,
currentPalette.warmth,
snapshot.activeSectionId,
deltaSeconds,
);
terrain.update( terrain.update(
driveDistance, driveDistance,
visualSpeed, visualSpeed,
@@ -229,14 +272,21 @@ export async function createProceduralSceneGeneration(
snapshot.routeProgress, snapshot.routeProgress,
currentPalette, currentPalette,
); );
const elapsedSeconds = now / 1000; atmosphericTime += deltaSeconds;
const elapsedSeconds = atmosphericTime;
horizonAir.update(currentPalette, elapsedSeconds);
sun.update( sun.update(
currentPalette, currentPalette,
snapshot.routeProgress, snapshot.routeProgress,
elapsedSeconds, elapsedSeconds,
response.bloomEnergy, response.bloomEnergy,
); );
car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds); car.update(
visualSpeed,
response.suspensionEnergy,
elapsedSeconds,
driveDistance,
);
sign.update(snapshot.sign, now); sign.update(snapshot.sign, now);
atmosphere.update( atmosphere.update(
driveDistance, driveDistance,
@@ -246,7 +296,9 @@ export async function createProceduralSceneGeneration(
); );
background.copy(currentPalette.sky); background.copy(currentPalette.sky);
scene.fog.color.copy(currentPalette.fog); scene.fog.color.copy(currentPalette.fog);
ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26); ambient.color
.copy(currentPalette.haze)
.lerp(currentPalette.terrainHigh, 0.26);
ambient.groundColor.copy(currentPalette.fog); ambient.groundColor.copy(currentPalette.fog);
ambient.intensity = 1.05 + currentPalette.warmth * 0.18; ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
carKeyLight.color.setHex(0xb9e3ef); carKeyLight.color.setHex(0xb9e3ef);
@@ -255,16 +307,63 @@ export async function createProceduralSceneGeneration(
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8; carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4; rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
const wideComposition = camera.aspect > 1.18; const wideComposition = camera.aspect > 1.18;
const composition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId]; const baseComposition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
const upcoming =
snapshot.sign && snapshot.sign.phase !== 'depart'
? CHAPTER_COMPOSITIONS[snapshot.sign.targetId]
: baseComposition;
const anticipation =
snapshot.sign?.phase === 'approach'
? Math.min(0.55, snapshot.sign.progress * 0.55)
: snapshot.sign?.phase === 'dwell'
? 0.55
: 0;
const composition = {
x: THREE.MathUtils.lerp(baseComposition.x, upcoming.x, anticipation),
y: THREE.MathUtils.lerp(baseComposition.y, upcoming.y, anticipation),
z: THREE.MathUtils.lerp(baseComposition.z, upcoming.z, anticipation),
targetY: THREE.MathUtils.lerp(
baseComposition.targetY,
upcoming.targetY,
anticipation,
),
targetZ: THREE.MathUtils.lerp(
baseComposition.targetZ,
upcoming.targetZ,
anticipation,
),
fov: THREE.MathUtils.lerp(
baseComposition.fov,
upcoming.fov,
anticipation,
),
shift: THREE.MathUtils.lerp(
baseComposition.shift,
upcoming.shift,
anticipation,
),
};
// The document stays native. Only the rendered lens eases to the chapter's safe zone. // The document stays native. Only the rendered lens eases to the chapter's safe zone.
const cameraX = wideComposition ? composition.x : 0; const cameraX = wideComposition ? composition.x : 0;
const targetX = 0; const targetX = 0;
const targetShift = wideComposition ? composition.shift : 0; const targetShift =
const targetVerticalShift = wideComposition ? 0 : (snapshot.activeSectionId === 'start' ? 0.16 : 0.07); height < 560 && width >= 768
const roadVibration = Math.sin(driveDistance * 0.105) * ( ? 0.22
0.012 + response.roadEnergy * 0.024 : wideComposition
); ? composition.shift
const cameraMix = 1 - Math.exp(-deltaSeconds / 0.85); : 0;
const targetVerticalShift = wideComposition
? 0
: snapshot.activeSectionId === 'start'
? 0.16
: 0.07;
const roadVibration =
Math.sin(driveDistance * 0.105) * (0.012 + response.roadEnergy * 0.024);
const cameraMix =
1 -
Math.exp(
-deltaSeconds / (snapshot.activeSectionId === 'contact' ? 1.5 : 1.05),
);
cameraState.x = THREE.MathUtils.lerp( cameraState.x = THREE.MathUtils.lerp(
cameraState.x, cameraState.x,
cameraX - response.directionBias * 0.16, cameraX - response.directionBias * 0.16,
@@ -277,7 +376,7 @@ export async function createProceduralSceneGeneration(
); );
cameraState.z = THREE.MathUtils.lerp( cameraState.z = THREE.MathUtils.lerp(
cameraState.z, cameraState.z,
(wideComposition ? composition.z : 29) - response.cameraEnergy * 1.2, (wideComposition ? composition.z : 31) + response.cameraEnergy * 0.9,
cameraMix, cameraMix,
); );
cameraState.targetX = THREE.MathUtils.lerp( cameraState.targetX = THREE.MathUtils.lerp(
@@ -297,7 +396,7 @@ export async function createProceduralSceneGeneration(
); );
cameraState.fov = THREE.MathUtils.lerp( cameraState.fov = THREE.MathUtils.lerp(
cameraState.fov, cameraState.fov,
(wideComposition ? composition.fov : 62) + response.cameraEnergy * 7, (wideComposition ? composition.fov : 61) + response.cameraEnergy * 5.5,
cameraMix, cameraMix,
); );
cameraState.roll = THREE.MathUtils.lerp( cameraState.roll = THREE.MathUtils.lerp(
@@ -305,30 +404,53 @@ export async function createProceduralSceneGeneration(
-response.directionBias * 0.003 + roadVibration * 0.018, -response.directionBias * 0.003 + roadVibration * 0.018,
cameraMix, cameraMix,
); );
cameraState.shift = THREE.MathUtils.lerp(cameraState.shift, targetShift, cameraMix); cameraState.shift = THREE.MathUtils.lerp(
cameraState.verticalShift = THREE.MathUtils.lerp(cameraState.verticalShift, targetVerticalShift, cameraMix); cameraState.shift,
camera.setViewOffset(width, height, -width * cameraState.shift, -height * cameraState.verticalShift, width, height); targetShift,
cameraMix,
);
cameraState.verticalShift = THREE.MathUtils.lerp(
cameraState.verticalShift,
targetVerticalShift,
cameraMix,
);
camera.setViewOffset(
width,
height,
-width * cameraState.shift,
-height * cameraState.verticalShift,
width,
height,
);
camera.position.set(cameraState.x, cameraState.y, cameraState.z); camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(cameraState.targetX, cameraState.targetY, cameraState.targetZ); camera.lookAt(
cameraState.targetX,
cameraState.targetY,
cameraState.targetZ,
);
camera.rotation.z += cameraState.roll; camera.rotation.z += cameraState.roll;
if (Math.abs(camera.fov - cameraState.fov) > 0.001) { if (Math.abs(camera.fov - cameraState.fov) > 0.001) {
camera.fov = cameraState.fov; camera.fov = cameraState.fov;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
} }
const stage = signStage.update(width, height);
sign.compose(camera, width, height, stage.navigationBottom, now);
if (TEST_BUILD && snapshot.sign !== null) { if (TEST_BUILD && snapshot.sign !== null) {
const presentation = sign.inspect(camera, width, height); const presentation = sign.inspect(camera, width, height);
if (presentation !== null) { if (presentation !== null) {
window.dispatchEvent(new CustomEvent('portfolio-sign-frame', { window.dispatchEvent(
detail: { new CustomEvent('portfolio-sign-frame', {
now, detail: {
...snapshot.sign, now,
activeSectionId: snapshot.activeSectionId, ...snapshot.sign,
direction: snapshot.direction, activeSectionId: snapshot.activeSectionId,
...presentation, direction: snapshot.direction,
rendererPixelRatio: renderer.getPixelRatio(), ...presentation,
viewport: { width, height }, rendererPixelRatio: renderer.getPixelRatio(),
}, viewport: { width, height },
})); },
}),
);
} }
} }
if (postProcessor) { if (postProcessor) {
@@ -365,7 +487,19 @@ function dampPalette(
current.sun.lerp(target.sun, mix); current.sun.lerp(target.sun, mix);
current.haze.lerp(target.haze, mix); current.haze.lerp(target.haze, mix);
current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix); current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix);
current.terrainEnergy = THREE.MathUtils.lerp(current.terrainEnergy, target.terrainEnergy, mix); current.terrainEnergy = THREE.MathUtils.lerp(
current.starVisibility = THREE.MathUtils.lerp(current.starVisibility, target.starVisibility, mix); current.terrainEnergy,
current.streakEnergy = THREE.MathUtils.lerp(current.streakEnergy, target.streakEnergy, mix); target.terrainEnergy,
mix,
);
current.starVisibility = THREE.MathUtils.lerp(
current.starVisibility,
target.starVisibility,
mix,
);
current.streakEnergy = THREE.MathUtils.lerp(
current.streakEnergy,
target.streakEnergy,
mix,
);
} }

View File

@@ -0,0 +1,212 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SectionId } from '@/content/types';
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
/** A bounded kit of physical roadside objects. Instance origins wrap behind the
* camera; the entire object moves together, so long beams never tear at a seam. */
export function createExpressway(
resources: SceneResourceBag,
quality: RenderQualitySettings,
) {
const group = new THREE.Group();
group.name = 'expressway-architecture';
const uniforms = {
uDistance: { value: 0 },
uEnergy: { value: 0 },
uArchitecture: { value: 0 },
uWarmth: { value: 0 },
};
const vertexShader = `
uniform float uDistance;
uniform float uArchitecture;
varying vec3 vNormal;
varying vec3 vWorld;
varying float vDepth;
void main() {
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
vec4 p = instanceMatrix * vec4(position, 1.0);
p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z;
p.y *= 1.0 + uArchitecture * 0.22;
vNormal = normalize(mat3(instanceMatrix) * normal);
vWorld = p.xyz;
vec4 view = modelViewMatrix * p;
vDepth = -view.z;
gl_Position = projectionMatrix * view;
}
`;
function material(luminous: boolean, coral = false) {
return resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader,
fragmentShader: `
uniform float uEnergy;
uniform float uWarmth;
varying vec3 vNormal;
varying vec3 vWorld;
varying float vDepth;
void main() {
float farFade = 1.0 - smoothstep(380.0, 660.0, vDepth);
float nearFade = smoothstep(0.0, 9.0, vDepth);
vec3 cyan = vec3(0.15, 0.69, 0.76);
vec3 coral = vec3(1.0, 0.18, 0.095);
float facing = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.5, 0.9, 0.6))));
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.015, 0.027, 0.044) + vec3(0.055, 0.10, 0.135) * pow(facing, 2.0)'};
${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
if(farFade * nearFade < 0.02) discard;
gl_FragColor = vec4(color, farFade * nearFade);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: !luminous,
depthTest: true,
blending: luminous ? THREE.AdditiveBlending : THREE.NormalBlending,
}),
);
}
const graphite = material(false);
const cyan = material(true);
const coral = material(true, true);
const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
const dummy = new THREE.Object3D();
type Part = {
x: number;
y: number;
z: number;
sx: number;
sy: number;
sz: number;
rz?: number;
};
function batch(
name: string,
parts: Part[],
surface: ReturnType<typeof material>,
) {
const mesh = resources.own(
'other',
new THREE.InstancedMesh(box, surface, parts.length),
);
mesh.name = name;
mesh.frustumCulled = false;
parts.forEach((part, i) => {
dummy.position.set(part.x, part.y, part.z);
dummy.scale.set(part.sx, part.sy, part.sz);
dummy.rotation.set(0, 0, part.rz ?? 0);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
});
mesh.instanceMatrix.needsUpdate = true;
group.add(mesh);
}
const posts: Part[] = [],
reflectors: Part[] = [],
rails: Part[] = [];
// Full density retains a recognisable rhythm even at the lowest quality.
const pairs = quality.postEffects ? 56 : 40;
for (let i = 0; i < pairs; i++)
for (const side of [-1, 1]) {
const z = 30 - (i * WORLD_DEPTH) / pairs;
posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 });
reflectors.push({
x: side * 9.48,
y: 1.1,
z: z + 0.18,
sx: 0.14,
sy: 0.22,
sz: 0.03,
});
rails.push({
x: side * 9.5,
y: 0.56,
z,
sx: 0.16,
sy: 0.18,
sz: WORLD_DEPTH / pairs + 0.1,
});
}
batch('barrier-posts', posts, graphite);
batch('barrier-reflectors', reflectors, coral);
batch('continuous-low-rails', rails, graphite);
const fins: Part[] = [],
lights: Part[] = [],
feet: Part[] = [];
for (let i = 0; i < 8; i++)
for (const side of [-1, 1]) {
const z = -24 - (i * WORLD_DEPTH) / 8;
// The canted cap is the repeated silhouette: road furniture, not a tunnel.
fins.push({ x: side * 12.6, y: 5.0, z, sx: 0.38, sy: 10, sz: 0.64 });
fins.push({
x: side * 10.45,
y: 10.55,
z,
sx: 0.32,
sy: 4.6,
sz: 0.64,
rz: side * 1.31,
});
lights.push({
x: side * 12.36,
y: 5.9,
z: z + 0.34,
sx: 0.075,
sy: 7.5,
sz: 0.035,
});
lights.push({
x: side * 10.4,
y: 10.4,
z: z + 0.34,
sx: 0.07,
sy: 4.35,
sz: 0.04,
rz: side * 1.31,
});
feet.push({ x: side * 12.6, y: 0.24, z, sx: 1.1, sy: 0.48, sz: 1.5 });
}
batch('swept-signal-pylons', fins, graphite);
batch('recessed-pylon-lights', lights, cyan);
batch('pylon-foundations', feet, graphite);
// Far roadside monolith pairs give the waves an occasional architectural edge.
const landmarks: Part[] = [];
for (let i = 0; i < 6; i++)
for (const side of [-1, 1]) {
const z = -110 - (i * WORLD_DEPTH) / 6;
for (let j = 0; j < 3; j++) {
landmarks.push({
x: side * (34 + j * 4.5),
y: (15 + j * 6) / 2,
z: z - j * 3,
sx: 1.2,
sy: 15 + j * 6,
sz: 2.6,
rz: side * 0.1,
});
}
}
batch('ridge-observatories', landmarks, graphite);
let architecture = 0;
return {
group,
update(
distance: number,
energy: number,
warmth: number,
section: SectionId,
dt: number,
) {
const target =
section === 'infrastructure' ? 1 : section === 'toolkit' ? 0.65 : 0;
architecture += (target - architecture) * (1 - Math.exp(-dt * 0.7));
uniforms.uDistance.value = distance;
uniforms.uEnergy.value = energy;
uniforms.uArchitecture.value = architecture;
uniforms.uWarmth.value = warmth;
},
};
}

View File

@@ -0,0 +1,60 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import type { MutableChapterPalette } from './palette';
/** Thin, spatially separated air masses. No screen-sized render targets. */
export function createHorizonAir(resources: SceneResourceBag) {
const group = new THREE.Group();
group.name = 'horizon-air-masses';
const uniforms = {
uTime: { value: 0 },
uWarmth: { value: 0 },
uColor: { value: new THREE.Color(0xc5646b) },
};
const material = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader: `
varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}
`,
fragmentShader: `
uniform float uTime;
uniform float uWarmth;
uniform vec3 uColor;
varying vec2 vUv;
void main(){
float wave=sin(vUv.x*12.0+uTime*0.018)*0.055+sin(vUv.x*31.0-uTime*0.026)*0.018;
float ribbon=exp(-pow((vUv.y-0.45-wave)*5.6,2.0));
float thin=exp(-pow((vUv.y-0.51-wave)*37.0,2.0));
float horizontal=pow(max(0.0,sin(vUv.x*3.14159)),2.0);
vec3 color=mix(uColor,vec3(0.21,0.49,0.59),vUv.y*0.25);
gl_FragColor=vec4(color,(ribbon*0.12+thin*0.026)*horizontal*(0.6+uWarmth*0.4));
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
const geometry = resources.own('geometry', new THREE.PlaneGeometry(1450, 95));
for (let i = 0; i < 3; i++) {
const mesh = new THREE.Mesh(geometry, material);
mesh.name = `stratified-air-${i + 1}`;
mesh.position.set((i - 1) * 85, 15 + i * 11, -500 - i * 70);
mesh.scale.set(1 - i * 0.12, 1 - i * 0.18, 1);
group.add(mesh);
}
return {
group,
update(palette: Readonly<MutableChapterPalette>, time: number) {
uniforms.uTime.value = time;
uniforms.uWarmth.value = palette.warmth;
uniforms.uColor.value.copy(palette.sun);
},
};
}

View File

@@ -48,15 +48,17 @@ export function createRoad(
uSpeed: { value: 0 }, uSpeed: { value: 0 },
uAcceleration: { value: 0 }, uAcceleration: { value: 0 },
uAsphalt: { value: new THREE.Color(0x03070f) }, uAsphalt: { value: new THREE.Color(0x03070f) },
uAsphaltLift: { value: new THREE.Color(0x0b1927) }, uAsphaltLift: { value: new THREE.Color(0x162535) },
uEdgeColor: { value: new THREE.Color(0x64d9e7) }, uEdgeColor: { value: new THREE.Color(0x64d9e7) },
uLaneColor: { value: new THREE.Color(0xf1a35e) }, uLaneColor: { value: new THREE.Color(0xf1a35e) },
uFogColor: { value: new THREE.Color(initialPalette.fog) }, uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) }, uHazeColor: { value: new THREE.Color(initialPalette.haze) },
}; };
const material = resources.own('material', new THREE.ShaderMaterial({ const material = resources.own(
uniforms, 'material',
vertexShader: ` new THREE.ShaderMaterial({
uniforms,
vertexShader: `
varying vec2 vRoadUv; varying vec2 vRoadUv;
varying float vRoadX; varying float vRoadX;
varying float vViewDistance; varying float vViewDistance;
@@ -69,11 +71,11 @@ export function createRoad(
vWorldZ = position.z; vWorldZ = position.z;
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0); vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
vViewDistance = -viewPosition.z; vViewDistance = -viewPosition.z;
vNearFade = smoothstep(4.0, 18.0, vViewDistance); vNearFade = 0.7 + 0.3 * smoothstep(0.0, 12.0, vViewDistance);
gl_Position = projectionMatrix * viewPosition; gl_Position = projectionMatrix * viewPosition;
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform float uDistance; uniform float uDistance;
uniform float uSpeed; uniform float uSpeed;
uniform float uAcceleration; uniform float uAcceleration;
@@ -115,7 +117,7 @@ export function createRoad(
* (1.0 - smoothstep(7.46, 7.62, roadX)); * (1.0 - smoothstep(7.46, 7.62, roadX));
float outerEdge = smoothstep(7.86, 7.98, roadX) float outerEdge = smoothstep(7.86, 7.98, roadX)
* (1.0 - smoothstep(8.06, 8.18, roadX)); * (1.0 - smoothstep(8.06, 8.18, roadX));
float laneWidth = 1.0 - smoothstep(0.07, 0.15, roadX); float laneWidth = 1.0 - smoothstep(0.035, 0.07, abs(roadX - 0.17));
float laneCycle = fract(travelZ / 18.0); float laneCycle = fract(travelZ / 18.0);
float laneDash = smoothstep(0.05, 0.1, laneCycle) float laneDash = smoothstep(0.05, 0.1, laneCycle)
* (1.0 - smoothstep(0.48, 0.56, laneCycle)); * (1.0 - smoothstep(0.48, 0.56, laneCycle));
@@ -140,6 +142,37 @@ export function createRoad(
color += uHazeColor * shoulderSeam * (0.035 + aggregate * 0.025); color += uHazeColor * shoulderSeam * (0.035 + aggregate * 0.025);
color += uLaneColor * centerLane * (0.86 + uSpeed * 0.18); color += uLaneColor * centerLane * (0.86 + uSpeed * 0.18);
// Satin moisture breaks light into longitudinal streaks. All masks live
// on the asphalt plane and follow the same world-space travel as marks.
float fine = valueNoise(vec2(vRoadX * 52.0, travelZ * 4.0));
float streak = valueNoise(vec2(vRoadX * 16.0, travelZ * 0.19));
float film = mix(0.36, 1.0, streak) * (0.74 + 0.26 * fine);
float grazing = smoothstep(8.0, 120.0, vViewDistance);
color += vec3(0.027, 0.047, 0.063) * film * asphaltMask * (0.35 + grazing * 0.65);
float channel = exp(-pow((roadX - 7.98) * 2.4, 2.0));
color += uEdgeColor * channel * (0.06 + 0.10 * film);
float shoulderRibs = step(0.77, fract(travelZ / 1.4))
* smoothstep(8.28, 8.36, roadX) * (1.0 - smoothstep(8.85, 9.02, roadX));
color += vec3(0.065, 0.10, 0.12) * shoulderRibs;
float joint = 1.0 - smoothstep(0.02, 0.075, abs(mod(travelZ + 36.0, 72.0) - 36.0));
color *= 1.0 - joint * 0.5;
float studs = (1.0 - smoothstep(0.07, 0.14, abs(roadX - 7.53)))
* (1.0 - smoothstep(0.10, 0.23, abs(mod(travelZ, 12.0) - 6.0)));
color += uLaneColor * studs * 0.7;
// Rear lamp footprint: grows towards the camera from the actual rear axle.
float behindCar = smoothstep(7.0, 8.8, vWorldZ);
float tailLength = exp(-max(0.0, vWorldZ - 8.2) * 0.13);
float tailWidth = exp(-pow(vRoadX / (2.25 + max(0.0, vWorldZ-8.0)*0.035), 6.0));
float lampSegments = 0.60 + 0.40 * pow(sin(vRoadX * 8.7), 2.0);
color += vec3(1.0, 0.035, 0.055) * behindCar * tailLength * tailWidth
* film * lampSegments * (0.34 + uAcceleration * 0.14);
float cyanReflection = exp(-pow((roadX-2.7) * 6.0, 2.0));
color += uEdgeColor * behindCar * tailLength * cyanReflection * film * 0.4;
float sunReflection = exp(-pow(vRoadX / (0.55 + vViewDistance * 0.011), 2.0))
* smoothstep(36.0, 180.0, vViewDistance);
color += vec3(0.8, 0.15, 0.08) * sunReflection * film * 0.17;
float horizonPickup = smoothstep(280.0, 620.0, vViewDistance) float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)
* (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance)) * (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance))
* exp(-roadX * 0.28); * exp(-roadX * 0.28);
@@ -153,10 +186,11 @@ export function createRoad(
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
depthWrite: true, depthWrite: true,
depthTest: true, depthTest: true,
toneMapped: true, toneMapped: true,
})); }),
);
const surface = new THREE.Mesh(geometry, material); const surface = new THREE.Mesh(geometry, material);
surface.name = 'road-surface'; surface.name = 'road-surface';
@@ -166,7 +200,8 @@ export function createRoad(
return { return {
group, group,
update(distance, speed, acceleration, palette) { update(distance, speed, acceleration, palette) {
uniforms.uDistance.value = ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD; uniforms.uDistance.value =
((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
uniforms.uSpeed.value = clamp01(speed); uniforms.uSpeed.value = clamp01(speed);
uniforms.uAcceleration.value = clamp01(acceleration); uniforms.uAcceleration.value = clamp01(acceleration);
if (palette) { if (palette) {

View File

@@ -0,0 +1,82 @@
import type { SceneResourceBag } from '@/scene/resources';
const stageOwners = new WeakMap<HTMLElement, object>();
/** Reserve real negative space in the document, below the navigation. The
* canvas keeps its original stacking order. Content scrolls through this edge
* exactly as it does through the top of the viewport; no content is removed. */
export function createSignStage(resources: SceneResourceBag) {
const main = document.querySelector<HTMLElement>('.portfolio-main');
const nav = document.querySelector<HTMLElement>('.site-navigation');
const root = document.documentElement;
let dirty = true;
let cutoff = 0;
let stageBottom = 176;
let navigationBottom = 66;
const token = {};
const mark = () => {
dirty = true;
};
resources.listen(window, 'scroll', mark, { passive: true });
resources.listen(window, 'resize', mark, { passive: true });
resources.listen(document, 'focusin', (event) => {
if (!(event.target instanceof HTMLElement) || !main?.contains(event.target))
return;
if (event.target.matches('h1,h2,main')) return;
const rect = event.target.getBoundingClientRect();
const top = window.innerHeight < 560 ? navigationBottom + 10 : stageBottom;
const bottom =
window.innerHeight < 560 && window.innerWidth < 768
? window.innerHeight - 164
: window.innerHeight - 74;
if (rect.top < top)
window.scrollBy({ top: rect.top - top - 12, behavior: 'instant' });
else if (rect.bottom > bottom)
window.scrollBy({ top: rect.bottom - bottom + 12, behavior: 'instant' });
});
const observer = resources.observe(new ResizeObserver(mark));
if (main) observer.observe(main);
if (nav) observer.observe(nav);
resources.own('other', {
dispose() {
if (!main || stageOwners.get(main) !== token) return;
stageOwners.delete(main);
main?.style.removeProperty('clip-path');
root.style.removeProperty('--sign-stage-bottom');
delete root.dataset.expressway;
},
});
return {
update(width: number, height: number) {
if (dirty && main) {
const rect = main.getBoundingClientRect();
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
const faceWidth = Math.min(
width - 44,
width < 900 ? 288 : 356,
height < 650 ? 250 : 356,
);
stageBottom = navigationBottom + 24 + faceWidth / 3.18;
// Gradually establish the strip while the opening leaves the viewport.
const reveal = Math.min(
1,
Math.max(0, window.scrollY / Math.max(240, height * 0.58)),
);
const short = height < 560;
const topEdge = short ? navigationBottom + 10 : stageBottom;
cutoff = Math.max(0, topEdge * reveal - rect.top);
const bottomCutoff =
short && width < 768 ? Math.max(0, rect.bottom - (height - 164)) : 0;
main.style.clipPath = `inset(${cutoff.toFixed(2)}px 0 ${bottomCutoff.toFixed(2)}px 0)`;
root.style.setProperty(
'--sign-stage-bottom',
`${topEdge.toFixed(2)}px`,
);
root.dataset.expressway = 'live';
stageOwners.set(main, token);
dirty = false;
}
return { navigationBottom, stageBottom };
},
};
}

View File

@@ -2,7 +2,6 @@ import * as THREE from 'three';
import type { SectionId } from '@/content/types'; import type { SectionId } from '@/content/types';
import type { SignSnapshot } from '@/journey/types'; import type { SignSnapshot } from '@/journey/types';
import { import {
SIGN_LIFECYCLE,
signRouteFor, signRouteFor,
signViewportProfileFor, signViewportProfileFor,
type SignCorridor, type SignCorridor,
@@ -11,106 +10,15 @@ import {
} from '@/journey/sign-routes'; } from '@/journey/sign-routes';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
interface SignPose {
x: number;
y: number;
z: number;
scale: number;
}
interface SignPoseSet {
horizon: SignPose;
dwell: SignPose;
}
interface ProjectingCamera { interface ProjectingCamera {
position: { x: number; y: number; z: number };
rotation: { y: number };
updateMatrixWorld(force: boolean): void; updateMatrixWorld(force: boolean): void;
} }
const SIGN_MARKER_FONT = '560 46px "Geist Mono Variable", ui-monospace, monospace'; const SIGN_MARKER_FONT =
const SIGN_LABEL_FONT = '660 164px "Geist Mono Variable", ui-monospace, monospace'; '560 46px "Geist Mono Variable", ui-monospace, monospace';
const SHORT_CORRIDOR_REFERENCE_ASPECT = 1.58; const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
const SHORT_CORRIDOR_BASE_RELIEF = 20;
const SHORT_CORRIDOR_RELIEF_PER_ASPECT = 20;
const SHORT_CORRIDOR_MINIMUM_RELIEF = 12;
const SHORT_CORRIDOR_MAXIMUM_RELIEF = 34;
const RESPONSIVE_REFLOW_MS = 320;
const PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH = 480;
const PORTRAIT_NAVIGATION_MEDIUM_WIDTH = 720;
const PORTRAIT_NAVIGATION_MEDIUM_SCALE = 0.44;
const PORTRAIT_NAVIGATION_MEDIUM_Y = 4.5;
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
wide: {
left: {
horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
dwell: { x: -11.9, y: 7.95, z: -16, scale: 1.36 },
},
right: {
horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
dwell: { x: 12.8, y: 7.95, z: -16, scale: 1.36 },
},
'outer-left': {
horizon: { x: -21.5, y: 7.4, z: -168, scale: 0.2 },
dwell: { x: -30.4, y: 8.65, z: -24, scale: 1.46 },
},
'outer-right': {
horizon: { x: 26.5, y: 7.4, z: -168, scale: 0.2 },
dwell: { x: 41.8, y: 8.65, z: -24, scale: 1.46 },
},
},
tall: {
left: {
horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
dwell: { x: -5.4, y: 8.15, z: -24, scale: 1.4 },
},
right: {
horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
dwell: { x: 5.4, y: 8.15, z: -24, scale: 1.4 },
},
'outer-left': {
horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
dwell: { x: -7.6, y: 8.8, z: -30, scale: 1.48 },
},
'outer-right': {
horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
dwell: { x: 7.6, y: 8.8, z: -30, scale: 1.48 },
},
},
short: {
left: {
horizon: { x: -7.8, y: 6.6, z: -144, scale: 0.18 },
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.62 },
},
right: {
horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.62 },
},
'outer-left': {
horizon: { x: -14.2, y: 6.8, z: -154, scale: 0.18 },
dwell: { x: -26.5, y: 5.8, z: -32, scale: 2.3 },
},
'outer-right': {
horizon: { x: 14.2, y: 6.8, z: -154, scale: 0.18 },
dwell: { x: 26.5, y: 5.8, z: -32, scale: 2.3 },
},
},
};
const PORTRAIT_NAVIGATION_DWELL: Readonly<Record<SignCorridor, SignPose>> = {
left: { x: -2.9, y: 3, z: 8, scale: 0.25 },
right: { x: 2.9, y: 3, z: 8, scale: 0.25 },
'outer-left': { x: -2.9, y: 3, z: 8, scale: 0.25 },
'outer-right': { x: 2.9, y: 3, z: 8, scale: 0.25 },
};
const EXPERIENCE_PASSIVE_DWELL: Readonly<Partial<Record<SignViewportProfile, SignPose>>> = {
wide: { x: 12.8, y: 3.7, z: 0, scale: 0.9 },
// The near short pose counterbalances the shared aspect relief while keeping
// the projected face in the right-hand gap between both visible panels.
short: { x: -7.7, y: 3.3, z: -4, scale: 1.4 },
};
export interface SignWorld { export interface SignWorld {
group: SignGroup; group: SignGroup;
inspect( inspect(
@@ -118,6 +26,13 @@ export interface SignWorld {
width: number, width: number,
height: number, height: number,
): SignPresentation | null; ): SignPresentation | null;
compose(
camera: ProjectingCamera,
width: number,
height: number,
navigationBottom: number,
now: number,
): void;
setViewportAspect(aspect: number): void; setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void; setViewportSize(width: number, height: number): void;
update(sign: SignSnapshot | null, now?: number): void; update(sign: SignSnapshot | null, now?: number): void;
@@ -151,6 +66,7 @@ export interface SignRenderOptions {
interface SignGroup { interface SignGroup {
visible: boolean; visible: boolean;
userData: Record<string, unknown>;
position: { position: {
x: number; x: number;
y: number; y: number;
@@ -170,7 +86,9 @@ export function routeLabelFor(id: SectionId): string {
return signRouteFor(id).label; return signRouteFor(id).label;
} }
export async function prepareSignTypography(signal: AbortSignal): Promise<void> { export async function prepareSignTypography(
signal: AbortSignal,
): Promise<void> {
const fonts = document.fonts; const fonts = document.fonts;
if (fonts === undefined) return; if (fonts === undefined) return;
await Promise.all([ await Promise.all([
@@ -178,7 +96,8 @@ export async function prepareSignTypography(signal: AbortSignal): Promise<void>
fonts.load(SIGN_LABEL_FONT), fonts.load(SIGN_LABEL_FONT),
fonts.ready, fonts.ready,
]); ]);
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError'); if (signal.aborted)
throw new DOMException('Scene build aborted', 'AbortError');
} }
export function createSign( export function createSign(
@@ -189,52 +108,80 @@ export function createSign(
group.name = 'roadside-sign'; group.name = 'roadside-sign';
group.visible = false; group.visible = false;
const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({ const housingMaterial = resources.own(
color: 0x08131c, 'material',
roughness: 0.5, new THREE.MeshStandardMaterial({
metalness: 0.7, color: 0x334956,
transparent: true, emissive: 0x23404e,
opacity: 0, emissiveIntensity: 0.6,
})); roughness: 0.28,
const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({ metalness: 0.7,
color: 0x172731, transparent: true,
roughness: 0.72, opacity: 0,
metalness: 0.38, alphaTest: 0.02,
transparent: true, }),
opacity: 0, );
})); const backplateMaterial = resources.own(
const facePanelMaterial = resources.own('material', new THREE.MeshStandardMaterial({ 'material',
color: 0x061018, new THREE.MeshStandardMaterial({
roughness: 0.9, color: 0x172e3c,
metalness: 0.12, roughness: 0.72,
transparent: true, metalness: 0.38,
opacity: 0, transparent: true,
})); opacity: 0,
const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({ alphaTest: 0.02,
color: 0x21343e, }),
roughness: 0.56, );
metalness: 0.68, const facePanelMaterial = resources.own(
transparent: true, 'material',
opacity: 0, new THREE.MeshStandardMaterial({
})); color: 0x061018,
const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({ roughness: 0.9,
color: 0x39747a, metalness: 0.12,
emissive: 0x55b9c0, transparent: true,
emissiveIntensity: 0.42, opacity: 0,
roughness: 0.42, alphaTest: 0.02,
metalness: 0.5, }),
transparent: true, );
opacity: 0, const supportMaterial = resources.own(
})); 'material',
const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color: 0x8e6446, color: 0x21343e,
emissive: 0xb9723f, emissive: 0x163845,
emissiveIntensity: 0.28, emissiveIntensity: 0.4,
roughness: 0.48, roughness: 0.56,
metalness: 0.42, metalness: 0.68,
transparent: true, transparent: true,
opacity: 0, opacity: 0,
})); alphaTest: 0.02,
}),
);
const edgeMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x39747a,
emissive: 0x55b9c0,
emissiveIntensity: 1.1,
roughness: 0.42,
metalness: 0.5,
transparent: true,
opacity: 0,
alphaTest: 0.02,
}),
);
const routeMaterial = resources.own(
'material',
new THREE.MeshStandardMaterial({
color: 0x8e6446,
emissive: 0xb9723f,
emissiveIntensity: 0.8,
roughness: 0.48,
metalness: 0.42,
transparent: true,
opacity: 0,
alphaTest: 0.02,
}),
);
const housing = new THREE.Mesh( const housing = new THREE.Mesh(
resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)), resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)),
@@ -275,18 +222,22 @@ export function createSign(
8, 8,
Math.max(1, Math.floor(options.maximumAnisotropy ?? 8)), Math.max(1, Math.floor(options.maximumAnisotropy ?? 8)),
); );
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({ const faceMaterial = resources.own(
color: 0xffffff, 'material',
transparent: true, new THREE.MeshBasicMaterial({
opacity: 0, color: 0xffffff,
side: THREE.FrontSide, transparent: true,
map: texture, opacity: 0,
polygonOffset: true, alphaTest: 0.02,
polygonOffsetFactor: -1, side: THREE.FrontSide,
polygonOffsetUnits: -1, map: texture,
depthWrite: false, polygonOffset: true,
toneMapped: false, polygonOffsetFactor: -1,
})); polygonOffsetUnits: -1,
depthWrite: false,
toneMapped: false,
}),
);
const face = new THREE.Mesh( const face = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(8, 2)), resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
faceMaterial, faceMaterial,
@@ -299,14 +250,33 @@ export function createSign(
const edgeLight = new THREE.Group(); const edgeLight = new THREE.Group();
edgeLight.name = 'sign-edge-light'; edgeLight.name = 'sign-edge-light';
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.045, 1.56, 0.045)); const edgeGeometry = resources.own(
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.18, 0.04, 0.045)); 'geometry',
new THREE.BoxGeometry(0.045, 1.56, 0.045),
);
const upperEdgeGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(7.6, 0.035, 0.045),
);
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial); const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
leftEdge.position.set(-3.9, 0, 0.57); leftEdge.position.set(-3.9, 0, 0.57);
edgeLight.add(leftEdge); edgeLight.add(leftEdge);
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial); const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
upperEdge.position.set(-3.33, 1, 0.57); upperEdge.position.set(0, 1.14, 0.57);
edgeLight.add(upperEdge); edgeLight.add(upperEdge);
const lowerEdge = new THREE.Mesh(upperEdgeGeometry, routeMaterial);
lowerEdge.position.set(0, -1.14, 0.57);
edgeLight.add(lowerEdge);
const finGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(0.055, 1.86, 0.48),
);
for (const side of [-1, 1])
for (let index = 0; index < 3; index++) {
const fin = new THREE.Mesh(finGeometry, supportMaterial);
fin.position.set(side * (4.26 + index * 0.12), 0, -0.04);
edgeLight.add(fin);
}
group.add(edgeLight); group.add(edgeLight);
const routeAccent = new THREE.Mesh( const routeAccent = new THREE.Mesh(
@@ -319,7 +289,10 @@ export function createSign(
const backRails = new THREE.Group(); const backRails = new THREE.Group();
backRails.name = 'sign-back-rails'; backRails.name = 'sign-back-rails';
const backRailGeometry = resources.own('geometry', new THREE.BoxGeometry(7.15, 0.13, 0.16)); const backRailGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(7.15, 0.13, 0.16),
);
for (const y of [-0.68, 0.68]) { for (const y of [-0.68, 0.68]) {
const rail = new THREE.Mesh(backRailGeometry, supportMaterial); const rail = new THREE.Mesh(backRailGeometry, supportMaterial);
rail.position.set(0, y, -0.38); rail.position.set(0, y, -0.38);
@@ -327,11 +300,11 @@ export function createSign(
} }
group.add(backRails); group.add(backRails);
const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.22, 5.05, 0.26)); const postGeometry = resources.own(
const post = new THREE.Mesh( 'geometry',
postGeometry, new THREE.BoxGeometry(0.22, 5.05, 0.26),
supportMaterial,
); );
const post = new THREE.Mesh(postGeometry, supportMaterial);
post.name = 'sign-post'; post.name = 'sign-post';
post.position.set(2.62, -3.58, -0.38); post.position.set(2.62, -3.58, -0.38);
group.add(post); group.add(post);
@@ -350,7 +323,10 @@ export function createSign(
); );
arm.position.set(0, -1.24, -0.38); arm.position.set(0, -1.24, -0.38);
bracket.add(arm); bracket.add(arm);
const braceGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 1.18, 0.14)); const braceGeometry = resources.own(
'geometry',
new THREE.BoxGeometry(0.12, 1.18, 0.14),
);
for (const side of [-1, 1]) { for (const side of [-1, 1]) {
const brace = new THREE.Mesh(braceGeometry, supportMaterial); const brace = new THREE.Mesh(braceGeometry, supportMaterial);
brace.position.set(side * 2.18, -1.68, -0.38); brace.position.set(side * 2.18, -1.68, -0.38);
@@ -361,7 +337,10 @@ export function createSign(
const fasteners = new THREE.Group(); const fasteners = new THREE.Group();
fasteners.name = 'sign-fasteners'; fasteners.name = 'sign-fasteners';
const fastenerGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8)); const fastenerGeometry = resources.own(
'geometry',
new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8),
);
for (const x of [-3.72, 3.72]) { for (const x of [-3.72, 3.72]) {
for (const y of [-0.78, 0.78]) { for (const y of [-0.78, 0.78]) {
const fastener = new THREE.Mesh(fastenerGeometry, supportMaterial); const fastener = new THREE.Mesh(fastenerGeometry, supportMaterial);
@@ -374,7 +353,10 @@ export function createSign(
const footing = new THREE.Group(); const footing = new THREE.Group();
footing.name = 'sign-footing'; footing.name = 'sign-footing';
const footingGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8)); const footingGeometry = resources.own(
'geometry',
new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8),
);
for (const x of [-2.62, 2.62]) { for (const x of [-2.62, 2.62]) {
const base = new THREE.Mesh(footingGeometry, supportMaterial); const base = new THREE.Mesh(footingGeometry, supportMaterial);
base.position.x = x; base.position.x = x;
@@ -383,6 +365,28 @@ export function createSign(
footing.position.set(0, -6.18, -0.38); footing.position.set(0, -6.18, -0.38);
group.add(footing); group.add(footing);
const sweepMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { uSweep: { value: -1 }, uOpacity: { value: 0 } },
vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader: `uniform float uSweep;uniform float uOpacity;varying vec2 vUv;
void main(){float beam=exp(-pow((vUv.x+vUv.y*0.14-uSweep)*12.0,2.0));
gl_FragColor=vec4(0.36,0.82,0.9,beam*uOpacity);}`,
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
const sweep = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
sweepMaterial,
);
sweep.name = 'sign-illumination-sweep';
sweep.position.z = 0.56;
group.add(sweep);
const fadeMaterials = [ const fadeMaterials = [
[housingMaterial, 0.97], [housingMaterial, 0.97],
[backplateMaterial, 1], [backplateMaterial, 1],
@@ -392,166 +396,76 @@ export function createSign(
[routeMaterial, 0.82], [routeMaterial, 0.82],
[faceMaterial, 1], [faceMaterial, 1],
] as const; ] as const;
const departureOrigin = new THREE.Vector3();
const poseTransitionOrigin = new THREE.Vector3();
const poseTransitionDestination = new THREE.Vector3();
let departureScale = 1;
let poseTransitionOriginScale = 1;
let poseTransitionOriginRotationY = 0;
let poseTransitionPending = false;
let poseTransitionStartedAt: number | null = null;
let target: SectionId | null = null; let target: SectionId | null = null;
let pendingViewport: SignViewportProfile = 'wide'; let snapshot: SignSnapshot | null = null;
let activeViewport: SignViewportProfile = 'wide'; let viewport: SignViewportProfile = 'wide';
let pendingAspect = 16 / 9;
let activeAspect = 16 / 9;
let pendingViewportWidth = 1_440;
let activeViewportWidth = 1_440;
let activeNavigationArrival = false;
let activeCorridor: SignCorridor = 'right'; let activeCorridor: SignCorridor = 'right';
let previousPhase: SignSnapshot['phase'] | null = null; let viewportWidth = 1440;
let renderedOpacity = 0; let viewportHeight = 900;
const faceCornerCoordinates = [ let previousNow = 0;
[-4, -1, 0], let screenX = 0.5;
[4, -1, 0], let screenY = 120;
[4, 1, 0], let screenWidth = 340;
[-4, 1, 0], let initialized = false;
] as const; const point = new THREE.Vector3();
const projectedCorner = new THREE.Vector3(); const ray = new THREE.Vector3();
const forward = new THREE.Vector3();
const updateTexture = (id: SectionId): void => { const worldRight = new THREE.Vector3();
if (target === id) return; const worldLeft = new THREE.Vector3();
target = id; const corner = new THREE.Vector3();
face.userData.textureUpdateCount += 1; const corners = [
face.userData.fontReadyWhenDrawn = document.fonts?.check(SIGN_LABEL_FONT) ?? true; [-4, -1],
if (context) drawLabel(context, signRouteFor(id), canvas.width, canvas.height); [4, -1],
texture.needsUpdate = true; [4, 1],
}; [-4, 1],
];
const applyOpacity = (opacity: number): void => { function plantMounting() {
for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight; const footY = -group.position.y / Math.max(0.001, group.scale.x);
}; const height = Math.max(0.36, -1.36 - footY);
for (const post of mountedPosts) {
const shortCorridorRelief = (): number => activeViewport === 'short' post.position.y = (-1.36 + footY) * 0.5;
? THREE.MathUtils.clamp( post.scale.set(1, height / 5.05, 1);
SHORT_CORRIDOR_BASE_RELIEF
+ (activeAspect - SHORT_CORRIDOR_REFERENCE_ASPECT)
* SHORT_CORRIDOR_RELIEF_PER_ASPECT,
SHORT_CORRIDOR_MINIMUM_RELIEF,
SHORT_CORRIDOR_MAXIMUM_RELIEF,
)
: 0;
const resolvedPoseX = (
pose: SignPose,
side: number,
weight: number,
routeOffsetX = 0,
): number => pose.x + side * shortCorridorRelief() * weight + routeOffsetX * weight;
const applyPose = (
pose: SignPose,
side: number,
lateralWeight: number,
routeOffsetX = 0,
scale = pose.scale,
y = pose.y,
): void => {
group.position.set(
resolvedPoseX(pose, side, lateralWeight, routeOffsetX),
y,
pose.z,
);
group.scale.setScalar(scale);
plantMounting(y, scale);
};
const portraitNavigationWidthProgress = (): number => smoothstep(clamp01(
(activeViewportWidth - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH)
/ (PORTRAIT_NAVIGATION_MEDIUM_WIDTH - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH),
));
const portraitNavigationScale = (phoneScale: number): number => THREE.MathUtils.lerp(
phoneScale,
PORTRAIT_NAVIGATION_MEDIUM_SCALE,
portraitNavigationWidthProgress(),
);
const portraitNavigationY = (phoneY: number): number => THREE.MathUtils.lerp(
phoneY,
PORTRAIT_NAVIGATION_MEDIUM_Y,
portraitNavigationWidthProgress(),
);
const plantMounting = (groupY: number, groupScale: number): void => {
const safeScale = Math.max(0.001, groupScale);
const footingY = -groupY / safeScale;
const postTopY = -1.36;
const postHeight = Math.max(0.36, postTopY - footingY);
for (const mountedPost of mountedPosts) {
mountedPost.position.y = (postTopY + footingY) * 0.5;
mountedPost.scale.set(1, postHeight / 5.05, 1);
} }
footing.position.y = footingY; footing.position.y = footY;
footing.scale.setScalar(1); }
}; function worldPoint(
out: typeof point,
const stagePoseTransition = (): void => { x: number,
if (previousPhase === null || previousPhase === 'depart') return; y: number,
poseTransitionOrigin.copy(group.position); depth: number,
poseTransitionOriginScale = group.scale.x; camera: ProjectingCamera,
poseTransitionOriginRotationY = group.rotation.y; ) {
poseTransitionPending = true; out
poseTransitionStartedAt = null; .set((x / viewportWidth) * 2 - 1, 1 - (y / viewportHeight) * 2, 0.5)
}; .unproject(camera);
ray.copy(out).sub(camera.position).normalize();
const stageResponsiveViewport = ( out
viewport: SignViewportProfile, .copy(camera.position)
aspect: number, .addScaledVector(ray, depth / Math.max(0.05, ray.dot(forward)));
width: number, }
): void => {
const changed = viewport !== pendingViewport
|| Math.abs(aspect - pendingAspect) > 0.001
|| Math.abs(width - pendingViewportWidth) > 0.5;
if (changed) stagePoseTransition();
pendingViewport = viewport;
pendingAspect = aspect;
pendingViewportWidth = width;
if (previousPhase === null) {
activeViewport = pendingViewport;
activeAspect = pendingAspect;
activeViewportWidth = pendingViewportWidth;
poseTransitionPending = false;
poseTransitionStartedAt = null;
}
};
return { return {
group, group,
inspect(camera, width, height) { inspect(camera, width, height) {
if (target === null || width <= 0 || height <= 0) return null; if (target === null) return null;
group.updateMatrixWorld(true); group.updateMatrixWorld(true);
camera.updateMatrixWorld(true); camera.updateMatrixWorld(true);
let left = Number.POSITIVE_INFINITY; let left = Infinity,
let top = Number.POSITIVE_INFINITY; top = Infinity,
let right = Number.NEGATIVE_INFINITY; right = -Infinity,
let bottom = Number.NEGATIVE_INFINITY; bottom = -Infinity;
for (const [xPosition, yPosition, zPosition] of faceCornerCoordinates) { for (const [x, y] of corners) {
projectedCorner corner.set(x, y, 0).applyMatrix4(face.matrixWorld).project(camera);
.set(xPosition, yPosition, zPosition) const px = ((corner.x + 1) * width) / 2,
.applyMatrix4(face.matrixWorld) py = ((1 - corner.y) * height) / 2;
.project(camera); left = Math.min(left, px);
const x = (projectedCorner.x + 1) * 0.5 * width; right = Math.max(right, px);
const y = (1 - projectedCorner.y) * 0.5 * height; top = Math.min(top, py);
left = Math.min(left, x); bottom = Math.max(bottom, py);
top = Math.min(top, y);
right = Math.max(right, x);
bottom = Math.max(bottom, y);
} }
return { return {
visible: group.visible, visible: group.visible,
corridor: activeCorridor, corridor: activeCorridor,
viewportProfile: activeViewport, viewportProfile: viewport,
bounds: { bounds: {
left, left,
top, top,
@@ -570,177 +484,139 @@ export function createSign(
}; };
}, },
setViewportAspect(aspect) { setViewportAspect(aspect) {
const nextAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1; viewport = aspect < 1.05 ? 'tall' : 'wide';
stageResponsiveViewport(
nextAspect < 0.85 ? 'tall' : 'wide',
nextAspect,
pendingViewportWidth,
);
}, },
setViewportSize(width, height) { setViewportSize(width, height) {
const nextAspect = Number.isFinite(width) && Number.isFinite(height) && height > 0 viewportWidth = Math.max(1, width);
? Math.max(1, width) / height viewportHeight = Math.max(1, height);
: 1; viewport = signViewportProfileFor(width, height);
stageResponsiveViewport(
signViewportProfileFor(width, height),
nextAspect,
Number.isFinite(width) ? Math.max(1, width) : 1,
);
}, },
update(sign, now) { update(sign) {
snapshot = sign;
group.userData.phase = sign?.phase ?? 'inactive';
group.userData.target = sign?.targetId ?? null;
if (!sign) { if (!sign) {
group.visible = false; group.visible = false;
applyOpacity(0); initialized = false;
renderedOpacity = 0;
previousPhase = null;
activeViewport = pendingViewport;
activeAspect = pendingAspect;
activeViewportWidth = pendingViewportWidth;
activeNavigationArrival = false;
poseTransitionPending = false;
poseTransitionStartedAt = null;
return; return;
} }
if (
target !== null
&& target !== sign.targetId
&& renderedOpacity > SIGN_LIFECYCLE.concealedOpacity
) return;
const navigationArrival = sign.navigationArrival === true;
if (target !== sign.targetId) { if (target !== sign.targetId) {
// The renderer owns projection. A quality-tier change can rebuild this world target = sign.targetId;
// while a lifecycle is already visible, so its latched profile may describe face.userData.textureUpdateCount += 1;
// the generation that was just disposed rather than this canvas. face.userData.fontReadyWhenDrawn =
activeViewport = pendingViewport; document.fonts?.check(SIGN_LABEL_FONT) ?? true;
activeAspect = pendingAspect; if (context)
activeViewportWidth = pendingViewportWidth; drawLabel(
activeNavigationArrival = navigationArrival; context,
poseTransitionPending = false; signRouteFor(sign.targetId),
poseTransitionStartedAt = null; canvas.width,
updateTexture(sign.targetId); canvas.height,
} else if (navigationArrival !== activeNavigationArrival) { );
stagePoseTransition(); texture.needsUpdate = true;
activeNavigationArrival = navigationArrival; initialized = false;
} }
},
if (poseTransitionPending && sign.phase === 'depart') { compose(camera, width, height, navigationBottom, now) {
poseTransitionPending = false; if (!snapshot) return;
poseTransitionStartedAt = null; viewportWidth = width;
} else if (poseTransitionPending) { viewportHeight = height;
activeViewport = pendingViewport; camera.updateMatrixWorld(true);
activeAspect = pendingAspect; forward
activeViewportWidth = pendingViewportWidth; .set(0, 0, -1)
if (poseTransitionStartedAt === null) { .transformDirection(
poseTransitionStartedAt = Number.isFinite(now) ? now! : 0; (camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
}
}
const opacity = clamp01(sign.opacity);
const progress = smoothstep(clamp01(sign.progress));
const route = signRouteFor(sign.targetId);
const corridor = route.corridor ?? 'right';
activeCorridor = corridor;
const poseSet = SIGN_POSES[activeViewport][corridor];
const horizon = poseSet.horizon;
let dwell = activeViewport === 'tall' && activeNavigationArrival
? PORTRAIT_NAVIGATION_DWELL[corridor]
: poseSet.dwell;
if (!activeNavigationArrival && sign.targetId === 'experience') {
dwell = EXPERIENCE_PASSIVE_DWELL[activeViewport] ?? dwell;
}
const dwellScale = activeViewport === 'tall' && activeNavigationArrival
? portraitNavigationScale(dwell.scale)
: dwell.scale;
const dwellY = activeViewport === 'tall' && activeNavigationArrival
? portraitNavigationY(dwell.y)
: dwell.y;
const side = corridor.includes('left') ? -1 : 1;
const routeOffsetX = route.dwellOffsetX ?? 0;
if (sign.phase === 'anticipate') {
applyPose(horizon, side, 0.35);
} else if (sign.phase === 'approach') {
group.position.set(
THREE.MathUtils.lerp(
resolvedPoseX(horizon, side, 0.35),
resolvedPoseX(dwell, side, 1, routeOffsetX),
progress,
),
THREE.MathUtils.lerp(horizon.y, dwellY, progress),
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
); );
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwellScale, progress)); const route = signRouteFor(snapshot.targetId);
plantMounting(group.position.y, group.scale.x); activeCorridor = route.corridor ?? 'right';
} else if (sign.phase === 'dwell') { const side = activeCorridor.includes('left') ? -1 : 1;
applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY); const compact = width < 900;
} else { const shortSide = height < 560 && width >= 768;
if (previousPhase !== 'depart') { const lowerStrip = height < 560 && !shortSide;
if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY); const targetWidth = Math.min(
departureOrigin.copy(group.position); width - 44,
departureScale = group.scale.x; compact ? 288 : 356,
} height < 650 ? 250 : 356,
const outerCorridor = corridor.startsWith('outer'); );
const lateralExit = activeViewport === 'wide' const targetX = shortSide ? 0.82 : compact ? 0.5 : side < 0 ? 0.23 : 0.77;
? outerCorridor ? 2.6 : 3.4 const targetY = shortSide
: activeViewport === 'short' ? 2.2 : 2.4; ? navigationBottom + 32 + (height - navigationBottom - 96) * 0.25
group.position.set( : lowerStrip
departureOrigin.x + progress * side * lateralExit, ? height - 112
departureOrigin.y, : navigationBottom + 12 + targetWidth / 3.18 / 2;
THREE.MathUtils.lerp(departureOrigin.z, 18, progress), const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
); previousNow = now;
group.scale.setScalar(departureScale); const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
plantMounting(group.position.y, group.scale.x); screenX += (targetX - screenX) * mix;
screenY += (targetY - screenY) * mix;
screenWidth += (targetWidth - screenWidth) * mix;
initialized = true;
const p = smoothstep(clamp01(snapshot.progress));
let arrival =
snapshot.phase === 'anticipate'
? 0
: snapshot.phase === 'approach'
? p
: 1;
const closeDepth = lowerStrip ? 12 : 18;
let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
let cx = THREE.MathUtils.lerp(0.5 + side * 0.1, screenX, arrival) * width;
let cy = THREE.MathUtils.lerp(
height * 0.43,
screenY,
lowerStrip ? arrival * arrival * arrival : arrival,
);
let projectedWidth = screenWidth * THREE.MathUtils.lerp(0.1, 1, arrival);
if (snapshot.phase === 'depart') {
depth = THREE.MathUtils.lerp(closeDepth, lowerStrip ? 4 : 12, p);
cx += side * p * width * 0.3;
projectedWidth *= 1 + p * 0.3;
} }
if (snapshot.phase !== 'depart') {
const desiredRotationY = -side * (activeViewport === 'wide' ? 0.14 : 0.1); projectedWidth = Math.min(width - 44, projectedWidth);
if (poseTransitionPending) { cx = THREE.MathUtils.clamp(
const elapsed = Number.isFinite(now) cx,
? Math.max(0, now! - (poseTransitionStartedAt ?? now!)) projectedWidth / 2 + 14,
: RESPONSIVE_REFLOW_MS; width - projectedWidth / 2 - 14,
const mix = smoothstep(clamp01(elapsed / RESPONSIVE_REFLOW_MS));
poseTransitionDestination.copy(group.position);
const poseTransitionDestinationScale = group.scale.x;
group.position.set(
THREE.MathUtils.lerp(poseTransitionOrigin.x, poseTransitionDestination.x, mix),
THREE.MathUtils.lerp(poseTransitionOrigin.y, poseTransitionDestination.y, mix),
THREE.MathUtils.lerp(poseTransitionOrigin.z, poseTransitionDestination.z, mix),
); );
group.scale.setScalar(THREE.MathUtils.lerp(
poseTransitionOriginScale,
poseTransitionDestinationScale,
mix,
));
group.rotation.y = THREE.MathUtils.lerp(
poseTransitionOriginRotationY,
desiredRotationY,
mix,
);
plantMounting(group.position.y, group.scale.x);
if (mix >= 1) {
poseTransitionPending = false;
poseTransitionStartedAt = null;
}
} else {
group.rotation.y = desiredRotationY;
} }
applyOpacity(opacity); worldPoint(point, cx, cy, depth, camera);
worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
if (lowerStrip) point.y = Math.max(1.4, point.y);
group.position.set(point.x, point.y, point.z);
group.scale.setScalar(worldRight.distanceTo(worldLeft) / 8.64);
group.rotation.y = camera.rotation.y - side * 0.065;
plantMounting();
const opacity = clamp01(snapshot.opacity);
for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
// The frame catches light first; one moving highlight resolves into a quiet face.
faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
edgeMaterial.emissiveIntensity = 0.8 + Math.sin(arrival * Math.PI) * 1.4;
sweepMaterial.uniforms.uSweep.value = arrival * 1.5 - 0.2;
sweepMaterial.uniforms.uOpacity.value =
snapshot.phase === 'approach' ? opacity * 0.14 : 0;
group.visible = opacity > 0; group.visible = opacity > 0;
renderedOpacity = opacity;
previousPhase = sign.phase;
}, },
}; };
} }
function createSignBody(width: number, height: number, depth: number, cut: number) { function createSignBody(
const geometry = new THREE.ExtrudeGeometry(createClippedShape(width, height, cut), { width: number,
depth, height: number,
bevelEnabled: true, depth: number,
bevelSegments: 1, cut: number,
bevelSize: 0.045, ) {
bevelThickness: 0.045, const geometry = new THREE.ExtrudeGeometry(
curveSegments: 1, 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.translate(0, 0, -depth * 0.5);
geometry.computeVertexNormals(); geometry.computeVertexNormals();
return geometry; return geometry;
@@ -799,9 +675,9 @@ function drawLabel(
context.fillRect(142, 174, 1_610, 2); context.fillRect(142, 174, 1_610, 2);
context.globalAlpha = 1; context.globalAlpha = 1;
context.fillStyle = '#d7e4e2'; context.fillStyle = '#e2f7f4';
const labelSize = fittedLabelSize(context, route.label, 1_520); const labelSize = fittedLabelSize(context, route.label, 1_520);
context.font = `660 ${labelSize}px "Geist Mono Variable", ui-monospace, monospace`; context.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
context.fillText(route.label, 142, 326); context.fillText(route.label, 142, 326);
context.strokeStyle = '#cb9160'; context.strokeStyle = '#cb9160';
@@ -823,8 +699,8 @@ function fittedLabelSize(
label: string, label: string,
maximumWidth: number, maximumWidth: number,
): number { ): number {
for (let size = 164; size >= 112; size -= 4) { for (let size = 180; size >= 128; size -= 4) {
context.font = `660 ${size}px "Geist Mono Variable", ui-monospace, monospace`; context.font = `650 ${size}px "Geist Variable", sans-serif`;
if (context.measureText(label).width <= maximumWidth) return size; if (context.measureText(label).width <= maximumWidth) return size;
} }
return 112; return 112;

View File

@@ -33,10 +33,15 @@ export function createSun(resources: SceneResourceBag): SunWorld {
uTime: { value: 0 }, uTime: { value: 0 },
uDriveEnergy: { value: 0 }, uDriveEnergy: { value: 0 },
}; };
const geometry = resources.own('geometry', new THREE.CircleGeometry(112, 128)); const geometry = resources.own(
const material = resources.own('material', new THREE.ShaderMaterial({ 'geometry',
uniforms, new THREE.CircleGeometry(126, 160),
vertexShader: ` );
const material = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader: `
varying vec2 vSunUv; varying vec2 vSunUv;
void main() { void main() {
@@ -44,7 +49,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform vec3 uColor; uniform vec3 uColor;
uniform vec3 uHazeColor; uniform vec3 uHazeColor;
uniform float uScanBands; uniform float uScanBands;
@@ -55,97 +60,102 @@ export function createSun(resources: SceneResourceBag): SunWorld {
varying vec2 vSunUv; varying vec2 vSunUv;
void main() { void main() {
vec2 centered = vSunUv - 0.5; vec2 solar = vSunUv;
float radius = length(centered); float low = 1.0 - solar.y;
float softEdge = 1.0 - smoothstep(0.465, 0.5, radius); solar.x += sin(solar.y * 132.0 + uTime * 0.42) * 0.0015 * low * low;
float limb = smoothstep(0.31, 0.485, radius); float radius = length(solar - 0.5);
float broadField = 0.5 + 0.5 * sin( float softEdge = 1.0 - smoothstep(0.496, 0.5, radius);
vSunUv.x * 9.0 float stripes = (0.65 - solar.y) * 17.0;
+ sin(vSunUv.y * 11.0 + uTime * 0.028) * 1.35 float phase = fract(stripes);
); float gap = mix(0.12, 0.61, clamp(stripes / 11.0, 0.0, 1.0));
float fineField = 0.5 + 0.5 * sin( float separated = smoothstep(gap, gap + 0.024, phase);
vSunUv.x * 23.0 - vSunUv.y * 16.0 + sin(uTime * 0.021) * 0.7 float bandMask = solar.y > 0.65 ? 1.0 : separated;
); float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65);
float internalField = broadField * 0.7 + fineField * 0.3; float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
float verticalHeat = mix(0.68, 1.3, smoothstep(0.06, 0.92, vSunUv.y)); * sin(solar.y * 31.0 - uTime * 0.032);
float lowerDisc = 1.0 - smoothstep(0.28, 0.67, vSunUv.y); vec3 vermilion = vec3(0.91, 0.045, 0.075);
float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.08) * 0.008); vec3 coral = mix(uColor, vec3(1.0, 0.19, 0.10), 0.6);
float scanBand = smoothstep(0.09, 0.18, scanPhase) vec3 crown = vec3(1.0, 0.54, 0.25);
* (1.0 - smoothstep(0.72, 0.82, scanPhase)); vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
float bandCut = mix(1.0, 0.06 + scanBand * 0.94, lowerDisc); core = mix(core, crown, smoothstep(0.47, 1.0, solar.y));
float horizonFade = smoothstep(0.015, 0.13, vSunUv.y); float filament = pow(abs(sin(solar.x * 39.0 + solar.y * 25.0 + convection * 2.4)), 16.0);
float radialCore = 1.0 - smoothstep(0.02, 0.48, radius); core *= 0.87 + convection * 0.055 + filament * 0.07;
float luminance = verticalHeat float limb = pow(smoothstep(0.38, 0.498, radius), 4.0);
* (1.08 + uWarmth * 0.16 + uRouteProgress * 0.045 + uDriveEnergy * 0.045); core += vec3(1.0, 0.24, 0.095) * (limb * 0.22 + lip * 0.20);
vec3 warmCore = mix(uColor, vec3(1.0, 0.56, 0.32), smoothstep(0.25, 0.92, vSunUv.y) * 0.84); float extinction = smoothstep(0.025, 0.19, solar.y);
vec3 core = mix(uColor * 0.82, warmCore * 0.83, smoothstep(0.05, 0.86, vSunUv.y)); if(softEdge * bandMask * extinction < 0.02) discard;
core = mix(core, core * mix(0.82, 1.13, internalField), 0.54); gl_FragColor = vec4(core * (1.0 + uWarmth * 0.10), softEdge * bandMask * extinction);
core = mix(core, uHazeColor * 0.72, limb * 0.22 + lowerDisc * 0.055);
gl_FragColor = vec4(core * luminance, softEdge * bandCut * horizonFade);
#include <tonemapping_fragment> #include <tonemapping_fragment>
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
depthWrite: false, depthWrite: true,
depthTest: true, depthTest: true,
blending: THREE.AdditiveBlending, blending: THREE.NormalBlending,
toneMapped: true, toneMapped: true,
})); }),
);
const disc = new THREE.Mesh(geometry, material); const disc = new THREE.Mesh(geometry, material);
disc.name = 'horizon-sun'; disc.name = 'horizon-sun';
disc.position.set(0, 60, SUN_DISTANCE_Z); disc.position.set(0, 79, SUN_DISTANCE_Z);
group.add(disc); group.add(disc);
const limbMaterial = resources.own('material', new THREE.ShaderMaterial({ const limbMaterial = resources.own(
uniforms: { 'material',
uColor: { value: sunColor.clone() }, new THREE.ShaderMaterial({
uEnergy: { value: 0 }, uniforms: {
}, uColor: { value: sunColor.clone() },
vertexShader: ` uEnergy: { value: 0 },
},
vertexShader: `
varying vec2 vUv; varying vec2 vUv;
void main() { void main() {
vUv = uv; vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform vec3 uColor; uniform vec3 uColor;
uniform float uEnergy; uniform float uEnergy;
varying vec2 vUv; varying vec2 vUv;
void main() { void main() {
float radius = distance(vUv, vec2(0.5)); float radius = distance(vUv, vec2(0.5));
float ring = smoothstep(0.405, 0.475, radius) float ring = smoothstep(0.477, 0.488, radius)
* (1.0 - smoothstep(0.475, 0.515, radius)); * (1.0 - smoothstep(0.491, 0.499, radius));
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025)); gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025));
#include <tonemapping_fragment> #include <tonemapping_fragment>
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: true, depthTest: true,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
toneMapped: true, toneMapped: true,
})); }),
);
const limb = new THREE.Mesh(geometry, limbMaterial); const limb = new THREE.Mesh(geometry, limbMaterial);
limb.name = 'sun-limb-glow'; limb.name = 'sun-limb-glow';
limb.position.copy(disc.position); limb.position.copy(disc.position);
limb.position.z -= 0.3; limb.position.z -= 0.3;
limb.scale.setScalar(1.075); limb.scale.setScalar(1.034);
group.add(limb); group.add(limb);
const haloTexture = resources.own('texture', createHaloTexture()); const haloTexture = resources.own('texture', createHaloTexture());
const haloMaterial = resources.own('material', new THREE.SpriteMaterial({ const haloMaterial = resources.own(
map: haloTexture, 'material',
color: sunColor.clone(), new THREE.SpriteMaterial({
transparent: true, map: haloTexture,
opacity: 0.13, color: sunColor.clone(),
depthWrite: false, transparent: true,
depthTest: true, opacity: 0.13,
blending: THREE.AdditiveBlending, depthWrite: false,
toneMapped: true, depthTest: true,
})); blending: THREE.AdditiveBlending,
toneMapped: true,
}),
);
const halo = new THREE.Sprite(haloMaterial); const halo = new THREE.Sprite(haloMaterial);
halo.name = 'horizon-halo'; halo.name = 'horizon-halo';
halo.position.copy(disc.position); halo.position.copy(disc.position);
@@ -153,16 +163,19 @@ export function createSun(resources: SceneResourceBag): SunWorld {
halo.scale.set(330, 232, 1); halo.scale.set(330, 232, 1);
group.add(halo); group.add(halo);
const bleedMaterial = resources.own('material', new THREE.SpriteMaterial({ const bleedMaterial = resources.own(
map: haloTexture, 'material',
color: sunColor.clone(), new THREE.SpriteMaterial({
transparent: true, map: haloTexture,
opacity: 0.075, color: sunColor.clone(),
depthWrite: false, transparent: true,
depthTest: true, opacity: 0.075,
blending: THREE.AdditiveBlending, depthWrite: false,
toneMapped: true, depthTest: true,
})); blending: THREE.AdditiveBlending,
toneMapped: true,
}),
);
const bleed = new THREE.Sprite(bleedMaterial); const bleed = new THREE.Sprite(bleedMaterial);
bleed.name = 'horizon-light-bleed'; bleed.name = 'horizon-light-bleed';
bleed.position.set(0, 7, SUN_DISTANCE_Z - 2.2); bleed.position.set(0, 7, SUN_DISTANCE_Z - 2.2);
@@ -181,13 +194,15 @@ export function createSun(resources: SceneResourceBag): SunWorld {
limbMaterial.uniforms.uColor.value.copy(palette.sun); limbMaterial.uniforms.uColor.value.copy(palette.sun);
uniforms.uWarmth.value = clamp01(palette.warmth); uniforms.uWarmth.value = clamp01(palette.warmth);
uniforms.uRouteProgress.value = progress; uniforms.uRouteProgress.value = progress;
uniforms.uTime.value = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0; uniforms.uTime.value = Number.isFinite(elapsedSeconds)
? elapsedSeconds
: 0;
uniforms.uDriveEnergy.value = energy; uniforms.uDriveEnergy.value = energy;
limbMaterial.uniforms.uEnergy.value = energy; limbMaterial.uniforms.uEnergy.value = energy;
haloMaterial.opacity = 0.105 + palette.warmth * 0.05 + energy * 0.015; haloMaterial.opacity = 0.075 + palette.warmth * 0.03 + energy * 0.015;
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01; bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
halo.scale.set(480 + energy * 18, 360 + energy * 10, 1); halo.scale.set(480 + energy * 18, 360 + energy * 10, 1);
disc.position.y = 60 + palette.warmth * 14; disc.position.y = 79 + palette.warmth * 14;
limb.position.y = disc.position.y; limb.position.y = disc.position.y;
halo.position.y = disc.position.y; halo.position.y = disc.position.y;
bleed.position.y = 7 + palette.warmth * 3; bleed.position.y = 7 + palette.warmth * 3;

View File

@@ -2,7 +2,10 @@ import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types'; import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality'; import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
import { CHAPTER_PALETTES, type MutableChapterPalette } from '@/scene/world/palette'; import {
CHAPTER_PALETTES,
type MutableChapterPalette,
} from '@/scene/world/palette';
import { import {
HORIZON_FOG_END, HORIZON_FOG_END,
HORIZON_FOG_START, HORIZON_FOG_START,
@@ -37,7 +40,10 @@ export function createTerrain(
): TerrainWorld { ): TerrainWorld {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = 'terrain-world'; group.name = 'terrain-world';
const geometry = resources.own('geometry', createIndexedTerrainGeometry(quality.terrainSegments)); const geometry = resources.own(
'geometry',
createIndexedTerrainGeometry(quality.terrainSegments),
);
const initialPalette = CHAPTER_PALETTES['deep-night']; const initialPalette = CHAPTER_PALETTES['deep-night'];
const lowColor = new THREE.Color(initialPalette.terrainLow); const lowColor = new THREE.Color(initialPalette.terrainLow);
const highColor = new THREE.Color(initialPalette.terrainHigh); const highColor = new THREE.Color(initialPalette.terrainHigh);
@@ -55,9 +61,11 @@ export function createTerrain(
uFogColor: { value: new THREE.Color(initialPalette.fog) }, uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) }, uHazeColor: { value: new THREE.Color(initialPalette.haze) },
}; };
const material = resources.own('material', new THREE.ShaderMaterial({ const material = resources.own(
uniforms, 'material',
vertexShader: ` new THREE.ShaderMaterial({
uniforms,
vertexShader: `
attribute float roadMask; attribute float roadMask;
uniform float uDistance; uniform float uDistance;
uniform float uFlowDistance; uniform float uFlowDistance;
@@ -71,6 +79,7 @@ export function createTerrain(
varying float vRoadMask; varying float vRoadMask;
varying float vDepthFade; varying float vDepthFade;
varying float vFlow; varying float vFlow;
varying vec3 vTerrainPosition;
float hash21(vec2 point) { float hash21(vec2 point) {
point = mod(point, 256.0); point = mod(point, 256.0);
@@ -106,6 +115,7 @@ export function createTerrain(
float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x))); float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x)));
float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy; float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy;
displaced.y += height; displaced.y += height;
vTerrainPosition = displaced;
vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0); vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0);
vRoadMask = roadMask; vRoadMask = roadMask;
@@ -117,7 +127,7 @@ export function createTerrain(
gl_Position = projectionMatrix * viewPosition; gl_Position = projectionMatrix * viewPosition;
} }
`, `,
fragmentShader: ` fragmentShader: `
uniform vec3 uLowColor; uniform vec3 uLowColor;
uniform vec3 uHighColor; uniform vec3 uHighColor;
uniform vec3 uFogColor; uniform vec3 uFogColor;
@@ -127,25 +137,75 @@ export function createTerrain(
varying float vRoadMask; varying float vRoadMask;
varying float vDepthFade; varying float vDepthFade;
varying float vFlow; varying float vFlow;
varying vec3 vTerrainPosition;
void main() { void main() {
float ridgeMix = smoothstep(0.16, 0.78, vHeight); float ridgeMix = smoothstep(0.16, 0.78, vHeight);
vec3 color = mix(uLowColor, uHighColor, ridgeMix); vec3 color = mix(uLowColor, uHighColor, ridgeMix);
color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade); color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade);
float pulse = 0.72 + 0.28 * smoothstep(0.16, 0.55, vFlow); float pulse = 0.88 + 0.12 * smoothstep(0.16, 0.55, vFlow);
float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress)); float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.54, 0.88, ridgeMix); float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.30, 0.83, ridgeMix);
gl_FragColor = vec4(color * mix(1.15, 1.75, ridgeMix), alpha); gl_FragColor = vec4(color * mix(1.15, 1.75, ridgeMix), alpha);
#include <tonemapping_fragment> #include <tonemapping_fragment>
#include <colorspace_fragment> #include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: true, depthTest: true,
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
toneMapped: true, toneMapped: true,
})); }),
);
const groundGeometry = resources.own('geometry', geometry.clone());
const rows = Math.max(16, Math.floor(quality.terrainSegments));
const columns = geometry.attributes.position.count / (rows + 1) - 1;
const triangles: number[] = [];
for (let row = 0; row < rows; row++)
for (let column = 0; column < columns; column++) {
const a = row * (columns + 1) + column,
b = a + columns + 1;
triangles.push(a, b, a + 1, a + 1, b, b + 1);
}
groundGeometry.setIndex(triangles);
const groundMaterial = resources.own(
'material',
new THREE.ShaderMaterial({
uniforms,
vertexShader: material.vertexShader.replace(
'displaced.y += height;',
'displaced.y += height - 0.12;',
),
fragmentShader: `
uniform vec3 uLowColor;
uniform vec3 uHighColor;
uniform vec3 uFogColor;
varying float vHeight;
varying float vDepthFade;
varying vec3 vTerrainPosition;
void main() {
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
vec3 color = mix(vec3(0.002, 0.008, 0.016), uLowColor * 0.11, light);
color += uHighColor * pow(vHeight, 2.0) * light * 0.06;
color = mix(uFogColor * 0.55, color, vDepthFade);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
side: THREE.DoubleSide,
depthWrite: true,
depthTest: true,
toneMapped: true,
}),
);
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.name = 'sculpted-terrain-surface';
ground.frustumCulled = false;
group.add(ground);
const lines = new THREE.LineSegments(geometry, material); const lines = new THREE.LineSegments(geometry, material);
lines.name = 'terrain-grid'; lines.name = 'terrain-grid';
@@ -156,11 +216,13 @@ export function createTerrain(
group, group,
update(distance, speed, chapter, routeProgress, palette) { update(distance, speed, chapter, routeProgress, palette) {
const chapterPalette = CHAPTER_PALETTES[chapter]; const chapterPalette = CHAPTER_PALETTES[chapter];
uniforms.uDistance.value = ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD; uniforms.uDistance.value =
((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
uniforms.uFlowDistance.value = uniforms.uDistance.value; uniforms.uFlowDistance.value = uniforms.uDistance.value;
uniforms.uSpeed.value = clamp01(speed); uniforms.uSpeed.value = clamp01(speed);
uniforms.uRouteProgress.value = clamp01(routeProgress); uniforms.uRouteProgress.value = clamp01(routeProgress);
uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy; uniforms.uTerrainEnergy.value =
palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
if (palette) { if (palette) {
lowColor.copy(palette.terrainLow); lowColor.copy(palette.terrainLow);
highColor.copy(palette.terrainHigh); highColor.copy(palette.terrainHigh);
@@ -177,7 +239,10 @@ export function createTerrain(
function createIndexedTerrainGeometry(segments: number) { function createIndexedTerrainGeometry(segments: number) {
const zSegments = Math.max(16, Math.floor(segments)); const zSegments = Math.max(16, Math.floor(segments));
const approximateCell = WORLD_DEPTH / zSegments; const approximateCell = WORLD_DEPTH / zSegments;
const xSegments = Math.max(48, Math.floor((TERRAIN_WIDTH / approximateCell) / 2) * 2); const xSegments = Math.max(
48,
Math.floor(TERRAIN_WIDTH / approximateCell / 2) * 2,
);
const columnCount = xSegments + 1; const columnCount = xSegments + 1;
const rowCount = zSegments + 1; const rowCount = zSegments + 1;
const positions = new Float32Array(columnCount * rowCount * 3); const positions = new Float32Array(columnCount * rowCount * 3);
@@ -193,13 +258,18 @@ function createIndexedTerrainGeometry(segments: number) {
positions[vertexOffset + 1] = 0; positions[vertexOffset + 1] = 0;
positions[vertexOffset + 2] = z; positions[vertexOffset + 2] = z;
vertexOffset += 3; vertexOffset += 3;
roadMasks[maskOffset] = smoothstep(ROAD_CLEAR_HALF_WIDTH, RIDGE_START, Math.abs(x)); roadMasks[maskOffset] = smoothstep(
ROAD_CLEAR_HALF_WIDTH,
RIDGE_START,
Math.abs(x),
);
maskOffset += 1; maskOffset += 1;
} }
} }
const indices: number[] = []; const indices: number[] = [];
const vertexIndex = (column: number, row: number): number => row * columnCount + column; const vertexIndex = (column: number, row: number): number =>
row * columnCount + column;
for (let row = 0; row < rowCount; row += 1) { for (let row = 0; row < rowCount; row += 1) {
for (let column = 0; column < columnCount; column += 1) { for (let column = 0; column < columnCount; column += 1) {
const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH; const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH;
@@ -207,7 +277,8 @@ function createIndexedTerrainGeometry(segments: number) {
indices.push(vertexIndex(column, row), vertexIndex(column, row + 1)); indices.push(vertexIndex(column, row), vertexIndex(column, row + 1));
} }
if (column < xSegments) { if (column < xSegments) {
const nextX = -TERRAIN_WIDTH / 2 + ((column + 1) / xSegments) * TERRAIN_WIDTH; const nextX =
-TERRAIN_WIDTH / 2 + ((column + 1) / xSegments) * TERRAIN_WIDTH;
const midpoint = (x + nextX) * 0.5; const midpoint = (x + nextX) * 0.5;
if (Math.abs(midpoint) > ROAD_CLEAR_HALF_WIDTH) { if (Math.abs(midpoint) > ROAD_CLEAR_HALF_WIDTH) {
indices.push(vertexIndex(column, row), vertexIndex(column + 1, row)); indices.push(vertexIndex(column, row), vertexIndex(column + 1, row));
@@ -225,7 +296,11 @@ function createIndexedTerrainGeometry(segments: number) {
} }
function smoothstep(edge0: number, edge1: number, value: number): number { function smoothstep(edge0: number, edge1: number, value: number): number {
const progress = THREE.MathUtils.clamp((value - edge0) / (edge1 - edge0), 0, 1); const progress = THREE.MathUtils.clamp(
(value - edge0) / (edge1 - edge0),
0,
1,
);
return progress * progress * (3 - 2 * progress); return progress * progress * (3 - 2 * progress);
} }

File diff suppressed because it is too large Load Diff