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 * 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,
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user