feat(viewer): distance-based LOD switching in GLB scene
GlbScene accepts an optional lodUrls list and swaps rendered detail by camera distance to the building's world-space center: lod0 (interactive — identity, selection, zones, clips all bind here) for normal + building-view distances, simpler visual-only levels only past ~2.5R / ~8R. Single-URL callers are unchanged. useGLTFKTX2 now accepts a URL array. Manual visibility toggling rather than THREE.LOD: THREE.LOD measures distance to its own origin (0,0,0), which mis-selected levels (blanking the scene) when the baked geometry is offset from the origin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
064be98d7f
commit
0469b25639
@@ -13,6 +13,7 @@ import useViewer from '../../store/use-viewer'
|
|||||||
|
|
||||||
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
|
/** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */
|
||||||
const EXPLODED_GAP = 5
|
const EXPLODED_GAP = 5
|
||||||
|
const LOD_FALLBACK_RADIUS = 10
|
||||||
|
|
||||||
/** A building floor discovered in the baked GLB, ordered bottom-to-top. */
|
/** A building floor discovered in the baked GLB, ordered bottom-to-top. */
|
||||||
export type GlbLevel = { id: `level_${string}`; label: string }
|
export type GlbLevel = { id: `level_${string}`; label: string }
|
||||||
@@ -48,6 +49,10 @@ type PascalExtras = {
|
|||||||
/** The resolved drill target for a raycast hit, given the current selection. */
|
/** The resolved drill target for a raycast hit, given the current selection. */
|
||||||
type Target = { object: THREE.Object3D; id: string; kind: string; label: string }
|
type Target = { object: THREE.Object3D; id: string; kind: string; label: string }
|
||||||
type HitCandidate = { object: THREE.Object3D; point?: THREE.Vector3 }
|
type HitCandidate = { object: THREE.Object3D; point?: THREE.Vector3 }
|
||||||
|
type GlbLoadResult = {
|
||||||
|
scene: THREE.Group
|
||||||
|
animations: THREE.AnimationClip[]
|
||||||
|
}
|
||||||
|
|
||||||
function findIdentityAncestor(object: THREE.Object3D): THREE.Object3D | null {
|
function findIdentityAncestor(object: THREE.Object3D): THREE.Object3D | null {
|
||||||
let current: THREE.Object3D | null = object
|
let current: THREE.Object3D | null = object
|
||||||
@@ -228,19 +233,49 @@ function createZoneWallGeometry(polygon: [number, number][]): THREE.BufferGeomet
|
|||||||
*/
|
*/
|
||||||
export function GlbScene({
|
export function GlbScene({
|
||||||
url,
|
url,
|
||||||
|
lodUrls,
|
||||||
onLevelsChange,
|
onLevelsChange,
|
||||||
onIdentityChange,
|
onIdentityChange,
|
||||||
onHoverChange,
|
onHoverChange,
|
||||||
}: {
|
}: {
|
||||||
url: string
|
url: string
|
||||||
|
lodUrls?: string[]
|
||||||
onLevelsChange?: (levels: GlbLevel[]) => void
|
onLevelsChange?: (levels: GlbLevel[]) => void
|
||||||
onIdentityChange?: (identity: GlbIdentity) => void
|
onIdentityChange?: (identity: GlbIdentity) => void
|
||||||
onHoverChange?: (hover: GlbHover) => void
|
onHoverChange?: (hover: GlbHover) => void
|
||||||
}) {
|
}) {
|
||||||
const gltf = useGLTFKTX2(url) as unknown as {
|
const urls = useMemo(() => (lodUrls && lodUrls.length > 1 ? lodUrls : [url]), [lodUrls, url])
|
||||||
scene: THREE.Group
|
const loadedGltfs = useGLTFKTX2(urls) as unknown as GlbLoadResult | GlbLoadResult[]
|
||||||
animations: THREE.AnimationClip[]
|
const gltfs = Array.isArray(loadedGltfs) ? loadedGltfs : [loadedGltfs]
|
||||||
|
const gltf = gltfs[0]!
|
||||||
|
const lodScenes = useMemo(() => gltfs.map((g) => g.scene), [gltfs])
|
||||||
|
// Distance-based LOD. We toggle scene visibility by camera distance to the
|
||||||
|
// building's world-space CENTER — `THREE.LOD` measures distance to its own
|
||||||
|
// origin (0,0,0), which mis-selects levels when the baked scene is offset
|
||||||
|
// from the origin. Interaction stays bound to lod0 only; the simpler levels
|
||||||
|
// are visual-only and appear when the camera pulls well past the building
|
||||||
|
// (so normal + building-view distances keep the interactive lod0).
|
||||||
|
const lodInfo = useMemo(() => {
|
||||||
|
if (lodScenes.length <= 1) return null
|
||||||
|
const box = new THREE.Box3().setFromObject(lodScenes[0]!)
|
||||||
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
const radius = box.getBoundingSphere(new THREE.Sphere()).radius
|
||||||
|
const r = Number.isFinite(radius) && radius > 0 ? radius : LOD_FALLBACK_RADIUS
|
||||||
|
return { center, dist1: r * 2.5, dist2: r * 8, maxLevel: lodScenes.length - 1 }
|
||||||
|
}, [lodScenes])
|
||||||
|
useEffect(() => {
|
||||||
|
lodScenes.forEach((scene, i) => {
|
||||||
|
scene.visible = i === 0
|
||||||
|
})
|
||||||
|
}, [lodScenes])
|
||||||
|
useFrame(({ camera }) => {
|
||||||
|
if (!lodInfo) return
|
||||||
|
const d = camera.position.distanceTo(lodInfo.center)
|
||||||
|
const level = Math.min(d >= lodInfo.dist2 ? 2 : d >= lodInfo.dist1 ? 1 : 0, lodInfo.maxLevel)
|
||||||
|
for (let i = 0; i < lodScenes.length; i++) {
|
||||||
|
lodScenes[i]!.visible = i === level
|
||||||
}
|
}
|
||||||
|
})
|
||||||
const rootRef = useRef<THREE.Group>(null!)
|
const rootRef = useRef<THREE.Group>(null!)
|
||||||
const { actions } = useAnimations(gltf.animations, rootRef)
|
const { actions } = useAnimations(gltf.animations, rootRef)
|
||||||
const camera = useThree((state) => state.camera)
|
const camera = useThree((state) => state.camera)
|
||||||
@@ -345,7 +380,10 @@ export function GlbScene({
|
|||||||
const built: ZoneFill[] = []
|
const built: ZoneFill[] = []
|
||||||
for (const entry of zoneEntries) {
|
for (const entry of zoneEntries) {
|
||||||
const shape = new THREE.Shape()
|
const shape = new THREE.Shape()
|
||||||
entry.polygon.forEach(([x, z], i) => (i === 0 ? shape.moveTo(x, -z) : shape.lineTo(x, -z)))
|
entry.polygon.forEach(([x, z], i) => {
|
||||||
|
if (i === 0) shape.moveTo(x, -z)
|
||||||
|
else shape.lineTo(x, -z)
|
||||||
|
})
|
||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
const floorMaterial = createZoneFloorMaterial(entry.color)
|
const floorMaterial = createZoneFloorMaterial(entry.color)
|
||||||
@@ -668,6 +706,7 @@ export function GlbScene({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={rootRef}>
|
<group ref={rootRef}>
|
||||||
|
{/* lod0: the interactive level (identity/selection/zones all bind here). */}
|
||||||
<primitive
|
<primitive
|
||||||
object={gltf.scene}
|
object={gltf.scene}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -675,6 +714,11 @@ export function GlbScene({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerOut={handlePointerOut}
|
onPointerOut={handlePointerOut}
|
||||||
/>
|
/>
|
||||||
|
{/* Simpler LODs are visual-only; the useFrame above toggles their
|
||||||
|
visibility by camera distance. They carry no interaction handlers. */}
|
||||||
|
{gltfs.slice(1).map((g, i) => (
|
||||||
|
<primitive key={lodScenes[i + 1]?.uuid ?? i} object={g.scene} />
|
||||||
|
))}
|
||||||
{/* Floating room labels. Each group's matrix is synced to its zone node
|
{/* Floating room labels. Each group's matrix is synced to its zone node
|
||||||
every frame (above) so the label rides level stacking; the div fades
|
every frame (above) so the label rides level stacking; the div fades
|
||||||
with the room fill via a CSS transition. */}
|
with the room fill via a CSS transition. */}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useGLTF } from '@react-three/drei'
|
import { useGLTF } from '@react-three/drei'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { type ObjectMap, useThree } from '@react-three/fiber'
|
||||||
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
||||||
|
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||||
import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader'
|
import { ensureKtx2Support, ktx2Loader } from '../lib/ktx2-loader'
|
||||||
|
|
||||||
const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
|
type GLTFKTX2Path = string | string[]
|
||||||
|
type GLTFKTX2Result<T extends GLTFKTX2Path> = T extends string[]
|
||||||
|
? Array<GLTF & ObjectMap>
|
||||||
|
: GLTF & ObjectMap
|
||||||
|
|
||||||
|
const useGLTFKTX2 = <T extends GLTFKTX2Path>(path: T): GLTFKTX2Result<T> => {
|
||||||
const gl = useThree((state) => state.gl)
|
const gl = useThree((state) => state.gl)
|
||||||
|
|
||||||
return useGLTF(path, true, true, (loader) => {
|
return useGLTF(path, true, true, (loader) => {
|
||||||
@@ -12,7 +18,7 @@ const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
|
|||||||
loader.setKTX2Loader(ktx2Loader as any)
|
loader.setKTX2Loader(ktx2Loader as any)
|
||||||
}
|
}
|
||||||
loader.setMeshoptDecoder(MeshoptDecoder)
|
loader.setMeshoptDecoder(MeshoptDecoder)
|
||||||
})
|
}) as unknown as GLTFKTX2Result<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useGLTFKTX2 }
|
export { useGLTFKTX2 }
|
||||||
|
|||||||
Reference in New Issue
Block a user