133 lines
5.9 KiB
JavaScript
133 lines
5.9 KiB
JavaScript
/*
|
|
* Scene3D
|
|
* ---------------------------------------------------------------------------
|
|
* Was: ~11.6k lines of gltfjsx-generated <mesh> components (1,372 meshes), each
|
|
* with hardcoded castShadow/receiveShadow. That JSX shipped ~11.6k lines of JS,
|
|
* forced React to reconcile 1,372 mesh fibers on mount (a long main-thread block),
|
|
* and made every mesh cast + receive shadows.
|
|
*
|
|
* Now: the GLTF scene is rendered with a single <primitive>. Animation refs and
|
|
* shadow/LOD policy are applied by traversing the loaded graph once.
|
|
*
|
|
* REF WIRING — verified against the actual GLB node tree (not the gltfjsx JSX,
|
|
* which flattens it):
|
|
* tyre mesh (LCT300007_WheelStock_XX_RB1c_Tire_1k_*)
|
|
* └─ Object_NN (wheel hub wrapper)
|
|
* └─ LCT300007_WheelStock_XX ← wheelRef target (matches the old wheel
|
|
* └─ RootNode group's transform exactly; Y is the axle)
|
|
* └─ *.fbx
|
|
* └─ Sketchfab_model ← truckRef target (a scene root; its
|
|
* transform == the old truckRef group)
|
|
* So: wheelRef = tyre.parent.parent, and truckRef = the tyre's ancestor whose
|
|
* parent is the scene root. These point at the SAME nodes the original JSX did,
|
|
* so TruckAnimation/wheelAnimation behave identically.
|
|
*
|
|
* The original generated file is preserved at Scene3D.gltfjsx.bak.txt.
|
|
*
|
|
* NOTE: still loads the single 31 MB GLB. Splitting + Draco/WebP compression is a
|
|
* binary-asset step (see PR notes) and is what fixes DOWNLOAD weight; the changes
|
|
* here reduce RUNTIME (JS bundle, mount cost, draw calls, shadow fill).
|
|
*/
|
|
import React, { useLayoutEffect } from 'react'
|
|
import { useGLTF } from '@react-three/drei'
|
|
|
|
const GLB = '/models/3d_scene_final.glb'
|
|
const DRACO_PATH = '/draco/' // self-hosted decoder (public/draco/)
|
|
|
|
// Tyre meshes in the exact order the rig expects: [FR, FL, RL, RR]. Order is
|
|
// load-bearing — animateWheels() flips spin direction by index parity.
|
|
const TYRE_ORDER = [
|
|
/WheelStock_FR_RB1c_Tire_1k_0$/i, // FR (suffix _0, NOT .001)
|
|
/WheelStock_FR_RB1c_Tire_1k_0\.001$/i, // FL (the .001 duplicate)
|
|
/WheelStock_RL_RB1c_Tire/i, // RL
|
|
/WheelStock_RR_RB1c_Tire/i, // RR
|
|
]
|
|
|
|
// Shadow policy: keep shadows ONLY on the truck (caster) and ground (receiver).
|
|
const TRUCK_NAME_RX = /^LCT300007/i
|
|
const GROUND_NAME_RX = /road|floor|slab|driveway|apron|grass|ground|pad|curb/i
|
|
const GROUND_MAT_RX = /asphalt|concrete|lane|apron|curb|pavement|tarmac|grass/i
|
|
|
|
// LOD buckets (matched against mesh name OR material name — both are reliable on
|
|
// the <primitive> graph). Hidden meshes are skipped at draw time.
|
|
const FOLIAGE_NAME_RX = /tree|foliage|atlas|bush|hedge|shrub/i
|
|
const FOLIAGE_MAT_RX = /tree|leaf|leaves|bark|foliage|shrub|grass/i
|
|
const CLUTTER_NAME_RX = /carton|cube|crate|package_box|barrel|pallet|bench/i
|
|
const CLUTTER_MAT_RX = /cardboard|pallet/i
|
|
const STREETLIGHT_NAME_RX = /street_light|streetlight|lamp/i
|
|
const STREETLIGHT_MAT_RX = /street_light/i
|
|
const BG_TREE_NAME_RX = /atlas|background_tree/i
|
|
const BG_TREE_MAT_RX = /background_tree_atlas/i
|
|
|
|
const matName = (o) => (Array.isArray(o.material) ? o.material[0]?.name : o.material?.name) || ''
|
|
|
|
export function Model({ truckRef, wheelRefs, tier = 'desktop', /* dashboardRefs (unused) */ ...props }) {
|
|
// String arg = self-hosted Draco decoder path: the GLB is Draco-compressed
|
|
// geometry + WebP textures (31 MB → 3.7 MB). Self-hosting (vs the gstatic CDN)
|
|
// keeps this static-export site free of an external runtime dependency.
|
|
const { scene } = useGLTF(GLB, DRACO_PATH)
|
|
|
|
// useLayoutEffect: wire refs + prune before first paint so TruckAnimation /
|
|
// CameraRig (which run in useFrame) see a fully-configured graph on frame 1.
|
|
useLayoutEffect(() => {
|
|
// 1) Wire animation refs by traversal (drei's `nodes` dict sanitises names
|
|
// like ".001" → reading the live graph avoids that mismatch) -----------
|
|
const tyres = [null, null, null, null]
|
|
let anyTyre = null
|
|
scene.traverse((o) => {
|
|
if (!o.isMesh) return
|
|
const n = o.name || ''
|
|
if (!/WheelStock_(FR|RL|RR)_RB1c_Tire/i.test(n)) return
|
|
anyTyre = o
|
|
TYRE_ORDER.forEach((rx, i) => {
|
|
if (!tyres[i] && rx.test(n)) tyres[i] = o
|
|
})
|
|
})
|
|
|
|
tyres.forEach((tyre, i) => {
|
|
const wheelEmpty = tyre?.parent?.parent ?? null // tyre → Object_NN → WheelStock_XX
|
|
if (wheelEmpty && wheelRefs?.[i]) wheelRefs[i].current = wheelEmpty
|
|
})
|
|
|
|
// Truck root = the tyre's ancestor that is a direct child of the scene root
|
|
// (== Sketchfab_model, the node the old JSX truckRef pointed at).
|
|
if (truckRef) {
|
|
let t = anyTyre
|
|
while (t && t.parent && t.parent !== scene) t = t.parent
|
|
truckRef.current = t ?? null
|
|
if (!t && process.env.NODE_ENV !== 'production') {
|
|
console.warn('[Scene3D] Could not resolve truck root — truck will not animate.')
|
|
}
|
|
}
|
|
|
|
// 2) Shadow pruning + per-tier LOD visibility (single traversal) ----------
|
|
scene.traverse((o) => {
|
|
if (!o.isMesh) return
|
|
const n = o.name || ''
|
|
const m = matName(o)
|
|
const isTruck = TRUCK_NAME_RX.test(n)
|
|
const isGround = GROUND_NAME_RX.test(n) || GROUND_MAT_RX.test(m)
|
|
|
|
// GLTFLoader defaults cast/receive to false. Enable only where it matters.
|
|
o.castShadow = isTruck
|
|
o.receiveShadow = isTruck || isGround
|
|
if (isTruck) o.frustumCulled = false // truck is the subject — never cull it
|
|
|
|
let hidden = false
|
|
if (tier === 'mobile') {
|
|
hidden =
|
|
FOLIAGE_NAME_RX.test(n) || FOLIAGE_MAT_RX.test(m) ||
|
|
CLUTTER_NAME_RX.test(n) || CLUTTER_MAT_RX.test(m) ||
|
|
STREETLIGHT_NAME_RX.test(n) || STREETLIGHT_MAT_RX.test(m)
|
|
} else if (tier === 'tablet') {
|
|
hidden = BG_TREE_NAME_RX.test(n) || BG_TREE_MAT_RX.test(m)
|
|
}
|
|
o.visible = !hidden
|
|
})
|
|
}, [scene, truckRef, wheelRefs, tier])
|
|
|
|
return <primitive object={scene} {...props} dispose={null} />
|
|
}
|
|
|
|
useGLTF.preload(GLB, DRACO_PATH)
|