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:
BIN
docs/redesign/round5/car-before-after-desktop.webp
Normal file
BIN
docs/redesign/round5/car-before-after-desktop.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/redesign/round5/car-lite-390.webp
Normal file
BIN
docs/redesign/round5/car-lite-390.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
63
scripts/blender/make-lite-car.py
Normal file
63
scripts/blender/make-lite-car.py
Normal 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}")
|
||||
BIN
src/assets/nightdrive-grand-tourer-lite.glb
Normal file
BIN
src/assets/nightdrive-grand-tourer-lite.glb
Normal file
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user