diff --git a/docs/redesign/round5/car-before-after-desktop.webp b/docs/redesign/round5/car-before-after-desktop.webp new file mode 100644 index 0000000..2c28da0 Binary files /dev/null and b/docs/redesign/round5/car-before-after-desktop.webp differ diff --git a/docs/redesign/round5/car-lite-390.webp b/docs/redesign/round5/car-lite-390.webp new file mode 100644 index 0000000..242b51e Binary files /dev/null and b/docs/redesign/round5/car-lite-390.webp differ diff --git a/scripts/blender/make-lite-car.py b/scripts/blender/make-lite-car.py new file mode 100644 index 0000000..fa8d516 --- /dev/null +++ b/scripts/blender/make-lite-car.py @@ -0,0 +1,63 @@ +"""Builds the phone LOD of the grand tourer. Headless only: + + blender -b --python scripts/blender/make-lite-car.py -- \ + src/assets/nightdrive-grand-tourer-chromatic.glb \ + src/assets/nightdrive-grand-tourer-lite.glb + +On a phone the car is about 200 px wide and seen from behind, so the wheel +machining, the tyre treads and the dense trim carry detail nobody can see. +They are collapsed; the silhouette parts (body, haunches, glass) are kept +as authored so the car reads the same. Node names and the hierarchy are +preserved: car.ts finds the wheels, the light bar and the underglow by name. +""" + +import sys + +import bpy + +source, target = sys.argv[sys.argv.index("--") + 1 :][:2] + +# Fraction of faces kept, by node name prefix (after the "evolution-" prefix). +RATIOS = { + "wheel-machining": 0.22, + "tires": 0.25, + "body-trim": 0.35, + "rear-light-bar": 0.4, + "car-underglow-source": 0.35, +} + +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.import_scene.gltf(filepath=source) + +before = after = 0 +for obj in bpy.data.objects: + if obj.type != "MESH": + continue + name = obj.name.removeprefix("evolution-") + faces = len(obj.data.polygons) + before += faces + ratio = next((r for prefix, r in RATIOS.items() if name.startswith(prefix)), None) + if ratio is not None and faces > 200: + # Meshes can be shared between wheels; give each object its own data. + obj.data = obj.data.copy() + modifier = obj.modifiers.new("lod", "DECIMATE") + modifier.decimate_type = "COLLAPSE" + modifier.ratio = ratio + modifier.use_collapse_triangulate = True + bpy.context.view_layer.objects.active = obj + for other in bpy.context.view_layer.objects: + other.select_set(other is obj) + bpy.ops.object.modifier_apply(modifier=modifier.name) + after += len(obj.data.polygons) + +bpy.ops.export_scene.gltf( + filepath=target, + export_format="GLB", + export_apply=True, + export_yup=True, + export_texcoords=False, + export_normals=True, + export_materials="EXPORT", + export_animations=False, +) +print(f"LITE faces {before} -> {after}") diff --git a/src/assets/nightdrive-grand-tourer-lite.glb b/src/assets/nightdrive-grand-tourer-lite.glb new file mode 100644 index 0000000..57554e3 Binary files /dev/null and b/src/assets/nightdrive-grand-tourer-lite.glb differ diff --git a/src/scene/world/car.ts b/src/scene/world/car.ts index efa64f9..3efdfc0 100644 --- a/src/scene/world/car.ts +++ b/src/scene/world/car.ts @@ -1,6 +1,9 @@ import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import grandTourerUrl from '@/assets/nightdrive-grand-tourer-chromatic.glb?url'; +// Phone LOD: same nodes and materials, about a third of the faces +// (scripts/blender/make-lite-car.py, run headless). +import grandTourerLiteUrl from '@/assets/nightdrive-grand-tourer-lite.glb?url'; import type { SceneResourceBag } from '@/scene/resources'; import type { SceneDetailMode } from '@/scene/types'; @@ -58,18 +61,21 @@ interface AuthoredCarGroup extends CarGroup { const FULL_BASE_Y = 0.58; const REDUCED_BASE_Y = 0.42; const GRAND_TOURER_URL = grandTourerUrl; +const GRAND_TOURER_LITE_URL = grandTourerLiteUrl; export async function loadGrandTourer( signal: AbortSignal, + lite = false, ): Promise { - const response = await fetch(GRAND_TOURER_URL, { signal }); + const url = lite ? GRAND_TOURER_LITE_URL : GRAND_TOURER_URL; + const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`Grand tourer asset failed to load (${response.status}).`); } const bytes = await response.arrayBuffer(); 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, url); if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError'); const authored = @@ -80,11 +86,18 @@ export async function loadGrandTourer( return authored as AuthoredCarGroup; } +export interface CarOptions { + /** Desktop coachwork: clearcoat paint and a haloed tail bar. */ + coachwork?: boolean; +} + export function createCar( resources: SceneResourceBag, authoredModel?: AuthoredCarGroup, + options: CarOptions = {}, ): CarWorld { - if (authoredModel) return createAuthoredCar(resources, authoredModel); + if (authoredModel) + return createAuthoredCar(resources, authoredModel, options.coachwork ?? false); const group = new THREE.Group(); group.name = 'chase-car'; let baseY = FULL_BASE_Y; @@ -439,6 +452,7 @@ export function createCar( function createAuthoredCar( resources: SceneResourceBag, authoredModel: AuthoredCarGroup, + coachwork: boolean, ): CarWorld { const group = new THREE.Group(); group.name = 'chase-car'; @@ -450,6 +464,7 @@ function createAuthoredCar( const bodyParts: Array<{ object: AuthoredObject; y: number }> = []; const tailMaterials: AuthoredMaterial[] = []; const cyanMaterials: AuthoredMaterial[] = []; + let tailBar: AuthoredObject | null = null; authoredModel.traverse((object) => { if (object.name.startsWith('evolution-')) object.name = object.name.slice('evolution-'.length); @@ -477,7 +492,24 @@ function createAuthoredCar( ) { material.emissive?.setHex(0x491034); material.emissiveIntensity = 0.16; + if (coachwork && !Array.isArray(object.material)) { + // A clearcoat over the authored paint: the neon and the sun run + // along the shoulders as sharp highlights. + object.material = resources.own( + 'material', + new THREE.MeshPhysicalMaterial({ + color: material.color, + roughness: Math.min(0.5, Number(material.roughness ?? 0.4)), + metalness: material.metalness, + emissive: material.emissive, + emissiveIntensity: 0.16, + clearcoat: 1, + clearcoatRoughness: 0.07, + }), + ); + } } + if (object.name === 'rear-light-bar' && coachwork) tailBar = object; if (material.isMeshStandardMaterial && object.name === 'rear-glass') { material.emissive?.setHex(0x0f143d); material.emissiveIntensity = 0.31; @@ -494,6 +526,9 @@ function createAuthoredCar( } }); + const haloUniforms = { uOpacity: { value: 0.2 } }; + if (tailBar !== null) attachTailHalo(resources, tailBar, haloUniforms); + const underglowUniforms = { uColor: { value: new THREE.Color(0x47ddec) }, uOpacity: { value: 0.12 }, @@ -600,6 +635,7 @@ function createAuthoredCar( group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018; for (const material of tailMaterials) material.emissiveIntensity = 2.2 + response * 1.2; + haloUniforms.uOpacity.value = 0.2 + response * 0.16; for (const material of cyanMaterials) material.emissiveIntensity = 2.2 + response * 1.6; underglowUniforms.uOpacity.value = @@ -609,6 +645,64 @@ function createAuthoredCar( }; } +/** A soft red halo just behind the tail bar, so the lamp blooms like glass + * rather than reading as a flat stripe. Parented to the bar: it can never + * drift from it. */ +function attachTailHalo( + resources: SceneResourceBag, + bar: AuthoredObject, + uniforms: { uOpacity: { value: number } }, +): void { + const geometry = bar.geometry as unknown as { + computeBoundingBox(): void; + boundingBox: { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } } | null; + }; + geometry.computeBoundingBox(); + const box = geometry.boundingBox; + if (!box) return; + const width = box.max.x - box.min.x; + const height = box.max.y - box.min.y; + const halo = new THREE.Mesh( + resources.own('geometry', new THREE.PlaneGeometry(width * 1.18, Math.max(height * 3.2, width * 0.06))), + resources.own( + 'material', + new THREE.ShaderMaterial({ + uniforms, + vertexShader: ` + varying vec2 vHaloUv; + void main() { + vHaloUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uOpacity; + varying vec2 vHaloUv; + void main() { + vec2 c = (vHaloUv - 0.5) * vec2(1.0, 2.6); + float halo = exp(-dot(c, c) * 9.0); + gl_FragColor = vec4(vec3(1.0, 0.08, 0.16) * halo * uOpacity, 1.0); + #include + #include + } + `, + transparent: true, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + side: THREE.DoubleSide, + }), + ), + ); + halo.name = 'rear-light-halo'; + halo.position.set( + (box.min.x + box.max.x) / 2, + (box.min.y + box.max.y) / 2, + box.min.z - 0.01, + ); + (bar as unknown as { add(object: unknown): void }).add(halo); +} + function createContactShadow( resources: SceneResourceBag, width: number, diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index 6f8f6e4..0ed8a12 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -127,8 +127,13 @@ export async function createProceduralSceneGeneration( const horizonAir = createHorizonAir(context.resources); const skyline = createSkyline(context.resources); const traffic = createTraffic(context.resources, quality); - const authoredCar = await loadGrandTourer(context.signal); - const car = createCar(context.resources, authoredCar); + // Phones and the low tier get the decimated car; it is about 200 px wide + // there and seen from behind. + const liteCar = + Math.min(canvas.clientWidth || 0, canvas.clientHeight || 0) < 600 + || context.quality === 'low'; + const authoredCar = await loadGrandTourer(context.signal, liteCar); + const car = createCar(context.resources, authoredCar, { coachwork: !liteCar }); car.setDetail(mobileOrTablet ? 'reduced' : 'full'); await prepareSignTypography(context.signal); const sign = createSign(context.resources, {