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.

View File

@@ -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<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) {
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 <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(
resources: SceneResourceBag,
width: number,

View File

@@ -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, {