feat(scene): phone LOD car and clearcoat coachwork on desktop

Phones and the low tier load a decimated grand tourer built by a headless
Blender script (31k to 11k faces, same nodes and materials). Desktop gets
clearcoat paint and a soft halo parented to the tail bar.
This commit is contained in:
2026-10-10 22:41:12 +03:00
parent 06b6e996a0
commit 582bf6dc43
6 changed files with 167 additions and 5 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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}")

Binary file not shown.

View File

@@ -1,6 +1,9 @@
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-chromatic.glb?url'; 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 { SceneResourceBag } from '@/scene/resources';
import type { SceneDetailMode } from '@/scene/types'; import type { SceneDetailMode } from '@/scene/types';
@@ -58,18 +61,21 @@ interface AuthoredCarGroup extends CarGroup {
const FULL_BASE_Y = 0.58; 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;
const GRAND_TOURER_LITE_URL = grandTourerLiteUrl;
export async function loadGrandTourer( export async function loadGrandTourer(
signal: AbortSignal, signal: AbortSignal,
lite = false,
): Promise<AuthoredCarGroup> { ): Promise<AuthoredCarGroup> {
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) { 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) if (signal.aborted)
throw new DOMException('Scene build aborted', 'AbortError'); 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) if (signal.aborted)
throw new DOMException('Scene build aborted', 'AbortError'); throw new DOMException('Scene build aborted', 'AbortError');
const authored = const authored =
@@ -80,11 +86,18 @@ export async function loadGrandTourer(
return authored as AuthoredCarGroup; return authored as AuthoredCarGroup;
} }
export interface CarOptions {
/** Desktop coachwork: clearcoat paint and a haloed tail bar. */
coachwork?: boolean;
}
export function createCar( export function createCar(
resources: SceneResourceBag, resources: SceneResourceBag,
authoredModel?: AuthoredCarGroup, authoredModel?: AuthoredCarGroup,
options: CarOptions = {},
): CarWorld { ): CarWorld {
if (authoredModel) return createAuthoredCar(resources, authoredModel); if (authoredModel)
return createAuthoredCar(resources, authoredModel, options.coachwork ?? false);
const group = new THREE.Group(); const group = new THREE.Group();
group.name = 'chase-car'; group.name = 'chase-car';
let baseY = FULL_BASE_Y; let baseY = FULL_BASE_Y;
@@ -439,6 +452,7 @@ export function createCar(
function createAuthoredCar( function createAuthoredCar(
resources: SceneResourceBag, resources: SceneResourceBag,
authoredModel: AuthoredCarGroup, authoredModel: AuthoredCarGroup,
coachwork: boolean,
): CarWorld { ): CarWorld {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = 'chase-car'; group.name = 'chase-car';
@@ -450,6 +464,7 @@ function createAuthoredCar(
const bodyParts: Array<{ object: AuthoredObject; y: number }> = []; const bodyParts: Array<{ object: AuthoredObject; y: number }> = [];
const tailMaterials: AuthoredMaterial[] = []; const tailMaterials: AuthoredMaterial[] = [];
const cyanMaterials: AuthoredMaterial[] = []; const cyanMaterials: AuthoredMaterial[] = [];
let tailBar: AuthoredObject | null = null;
authoredModel.traverse((object) => { authoredModel.traverse((object) => {
if (object.name.startsWith('evolution-')) if (object.name.startsWith('evolution-'))
object.name = object.name.slice('evolution-'.length); object.name = object.name.slice('evolution-'.length);
@@ -477,7 +492,24 @@ function createAuthoredCar(
) { ) {
material.emissive?.setHex(0x491034); material.emissive?.setHex(0x491034);
material.emissiveIntensity = 0.16; 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') { if (material.isMeshStandardMaterial && object.name === 'rear-glass') {
material.emissive?.setHex(0x0f143d); material.emissive?.setHex(0x0f143d);
material.emissiveIntensity = 0.31; 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 = { const underglowUniforms = {
uColor: { value: new THREE.Color(0x47ddec) }, uColor: { value: new THREE.Color(0x47ddec) },
uOpacity: { value: 0.12 }, uOpacity: { value: 0.12 },
@@ -600,6 +635,7 @@ function createAuthoredCar(
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) for (const material of tailMaterials)
material.emissiveIntensity = 2.2 + response * 1.2; material.emissiveIntensity = 2.2 + response * 1.2;
haloUniforms.uOpacity.value = 0.2 + response * 0.16;
for (const material of cyanMaterials) for (const material of cyanMaterials)
material.emissiveIntensity = 2.2 + response * 1.6; material.emissiveIntensity = 2.2 + response * 1.6;
underglowUniforms.uOpacity.value = 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 <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
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( function createContactShadow(
resources: SceneResourceBag, resources: SceneResourceBag,
width: number, width: number,

View File

@@ -127,8 +127,13 @@ export async function createProceduralSceneGeneration(
const horizonAir = createHorizonAir(context.resources); const horizonAir = createHorizonAir(context.resources);
const skyline = createSkyline(context.resources); const skyline = createSkyline(context.resources);
const traffic = createTraffic(context.resources, quality); const traffic = createTraffic(context.resources, quality);
const authoredCar = await loadGrandTourer(context.signal); // Phones and the low tier get the decimated car; it is about 200 px wide
const car = createCar(context.resources, authoredCar); // 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'); car.setDetail(mobileOrTablet ? 'reduced' : 'full');
await prepareSignTypography(context.signal); await prepareSignTypography(context.signal);
const sign = createSign(context.resources, { const sign = createSign(context.resources, {