修复点位楼层跳转并优化模型加载缓存
This commit is contained in:
373
src/services/model/GuideModelLoadManager.ts
Normal file
373
src/services/model/GuideModelLoadManager.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'
|
||||
|
||||
export interface GuideModelResourceSet {
|
||||
geometries: Set<THREE.BufferGeometry>
|
||||
materials: Set<THREE.Material>
|
||||
textures: Set<THREE.Texture>
|
||||
}
|
||||
|
||||
export interface GuideModelLoadOptions {
|
||||
urls: string[]
|
||||
label: string
|
||||
semanticKey?: string
|
||||
loadSource: (url: string) => Promise<GLTF>
|
||||
shouldStopOnError?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
export interface GuideModelLoadDiagnostics {
|
||||
sourceEntries: number
|
||||
inFlightEntries: number
|
||||
hits: number
|
||||
misses: number
|
||||
inFlightHits: number
|
||||
evictions: number
|
||||
}
|
||||
|
||||
interface CachedModelEntry {
|
||||
key: string
|
||||
url: string
|
||||
gltf: GLTF
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
// 当前 SDK 数据包含 8 个可导览楼层;缓存需覆盖多层展开、全楼模型和相邻预加载余量,避免加载中途淘汰刚加载过的单层模型。
|
||||
const defaultMaxModelSourceEntries = 10
|
||||
|
||||
const volatileQueryKeys = new Set([
|
||||
'token',
|
||||
'access_token',
|
||||
'expires',
|
||||
'expires_at',
|
||||
'expiration',
|
||||
'timestamp',
|
||||
'ts',
|
||||
'time',
|
||||
't',
|
||||
'_'
|
||||
])
|
||||
|
||||
const emptyResources = (): GuideModelResourceSet => ({
|
||||
geometries: new Set(),
|
||||
materials: new Set(),
|
||||
textures: new Set()
|
||||
})
|
||||
|
||||
const collectMaterialTextures = (material: THREE.Material) => {
|
||||
const textures = new Set<THREE.Texture>()
|
||||
const materialRecord = material as unknown as Record<string, unknown>
|
||||
|
||||
Object.keys(materialRecord).forEach((key) => {
|
||||
const value = materialRecord[key]
|
||||
if (value instanceof THREE.Texture) {
|
||||
textures.add(value)
|
||||
}
|
||||
})
|
||||
|
||||
return textures
|
||||
}
|
||||
|
||||
const isRenderableObject = (
|
||||
object: THREE.Object3D
|
||||
): object is (THREE.Mesh | THREE.Line | THREE.Points) => (
|
||||
object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points
|
||||
)
|
||||
|
||||
const cloneMaterialWithTextures = <TMaterial extends THREE.Material>(material: TMaterial): TMaterial => {
|
||||
const clonedMaterial = material.clone() as TMaterial
|
||||
const sourceRecord = material as unknown as Record<string, unknown>
|
||||
const cloneRecord = clonedMaterial as unknown as Record<string, unknown>
|
||||
|
||||
Object.keys(sourceRecord).forEach((key) => {
|
||||
const value = sourceRecord[key]
|
||||
if (value instanceof THREE.Texture) {
|
||||
const clonedTexture = value.clone()
|
||||
clonedTexture.needsUpdate = true
|
||||
cloneRecord[key] = clonedTexture
|
||||
}
|
||||
})
|
||||
|
||||
return clonedMaterial
|
||||
}
|
||||
|
||||
const cloneObjectResources = (object: THREE.Object3D) => {
|
||||
object.traverse((child) => {
|
||||
if (isRenderableObject(child)) {
|
||||
if (child.geometry) {
|
||||
child.geometry = child.geometry.clone()
|
||||
}
|
||||
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material = child.material.map((material) => cloneMaterialWithTextures(material))
|
||||
} else if (child.material) {
|
||||
child.material = cloneMaterialWithTextures(child.material)
|
||||
}
|
||||
}
|
||||
|
||||
if (child instanceof THREE.Sprite && child.material) {
|
||||
child.material = cloneMaterialWithTextures(child.material)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const collectResources = (object: THREE.Object3D | null): GuideModelResourceSet => {
|
||||
const resources = emptyResources()
|
||||
|
||||
object?.traverse((child) => {
|
||||
if (isRenderableObject(child)) {
|
||||
if (child.geometry) {
|
||||
resources.geometries.add(child.geometry)
|
||||
}
|
||||
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
materials.forEach((material) => {
|
||||
if (!material) return
|
||||
resources.materials.add(material)
|
||||
collectMaterialTextures(material).forEach((texture) => resources.textures.add(texture))
|
||||
})
|
||||
}
|
||||
|
||||
if (child instanceof THREE.Sprite && child.material) {
|
||||
resources.materials.add(child.material)
|
||||
collectMaterialTextures(child.material).forEach((texture) => resources.textures.add(texture))
|
||||
}
|
||||
})
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
const disposeMaterialTextures = (
|
||||
material: THREE.Material,
|
||||
protectedResources: GuideModelResourceSet,
|
||||
disposedTextures: Set<THREE.Texture>
|
||||
) => {
|
||||
collectMaterialTextures(material).forEach((texture) => {
|
||||
if (!protectedResources.textures.has(texture) && !disposedTextures.has(texture)) {
|
||||
texture.dispose()
|
||||
disposedTextures.add(texture)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const disposeObject = (
|
||||
object: THREE.Object3D,
|
||||
protectedResources = emptyResources()
|
||||
) => {
|
||||
const disposedGeometries = new Set<THREE.BufferGeometry>()
|
||||
const disposedMaterials = new Set<THREE.Material>()
|
||||
const disposedTextures = new Set<THREE.Texture>()
|
||||
|
||||
object.traverse((child) => {
|
||||
if (isRenderableObject(child)) {
|
||||
if (
|
||||
child.geometry
|
||||
&& !protectedResources.geometries.has(child.geometry)
|
||||
&& !disposedGeometries.has(child.geometry)
|
||||
) {
|
||||
child.geometry.dispose()
|
||||
disposedGeometries.add(child.geometry)
|
||||
}
|
||||
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
materials.forEach((material) => {
|
||||
if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) {
|
||||
disposeMaterialTextures(material, protectedResources, disposedTextures)
|
||||
material.dispose()
|
||||
disposedMaterials.add(material)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (child instanceof THREE.Sprite && child.material) {
|
||||
if (!protectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) {
|
||||
disposeMaterialTextures(child.material, protectedResources, disposedTextures)
|
||||
child.material.dispose()
|
||||
disposedMaterials.add(child.material)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export class GuideModelLoadManager {
|
||||
private readonly sourceCache = new Map<string, CachedModelEntry>()
|
||||
private readonly inFlightCache = new Map<string, Promise<GLTF>>()
|
||||
private readonly diagnostics: GuideModelLoadDiagnostics = {
|
||||
sourceEntries: 0,
|
||||
inFlightEntries: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
inFlightHits: 0,
|
||||
evictions: 0
|
||||
}
|
||||
|
||||
constructor(private readonly maxEntries = defaultMaxModelSourceEntries) {}
|
||||
|
||||
getDiagnostics(): GuideModelLoadDiagnostics {
|
||||
return {
|
||||
...this.diagnostics,
|
||||
sourceEntries: this.sourceCache.size,
|
||||
inFlightEntries: this.inFlightCache.size
|
||||
}
|
||||
}
|
||||
|
||||
collectProtectedResources(): GuideModelResourceSet {
|
||||
const resources = emptyResources()
|
||||
|
||||
this.sourceCache.forEach((entry) => {
|
||||
const entryResources = collectResources(entry.gltf.scene)
|
||||
entryResources.geometries.forEach((geometry) => resources.geometries.add(geometry))
|
||||
entryResources.materials.forEach((material) => resources.materials.add(material))
|
||||
entryResources.textures.forEach((texture) => resources.textures.add(texture))
|
||||
})
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
createCacheKey(url: string, semanticKey?: string) {
|
||||
const normalizedSemanticKey = semanticKey?.trim()
|
||||
if (normalizedSemanticKey) return `semantic:${normalizedSemanticKey}`
|
||||
|
||||
const normalizedUrl = url.trim()
|
||||
if (!normalizedUrl) return ''
|
||||
|
||||
try {
|
||||
const baseUrl = typeof window === 'undefined' ? 'http://localhost' : window.location.origin
|
||||
const parsedUrl = new URL(normalizedUrl, baseUrl)
|
||||
Array.from(parsedUrl.searchParams.keys())
|
||||
.filter((key) => volatileQueryKeys.has(key.toLowerCase()))
|
||||
.forEach((key) => parsedUrl.searchParams.delete(key))
|
||||
|
||||
const sortedParams = [...parsedUrl.searchParams.entries()]
|
||||
.sort(([leftKey, leftValue], [rightKey, rightValue]) => (
|
||||
leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)
|
||||
))
|
||||
parsedUrl.search = ''
|
||||
sortedParams.forEach(([key, value]) => parsedUrl.searchParams.append(key, value))
|
||||
|
||||
return parsedUrl.href
|
||||
} catch {
|
||||
const [path, query = ''] = normalizedUrl.split('?')
|
||||
if (!query) return path
|
||||
|
||||
const filteredQuery = query
|
||||
.split('&')
|
||||
.map((part) => part.split('='))
|
||||
.filter(([key]) => !volatileQueryKeys.has(decodeURIComponent(key || '').toLowerCase()))
|
||||
.sort(([leftKey, leftValue = ''], [rightKey, rightValue = '']) => (
|
||||
leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)
|
||||
))
|
||||
.map((parts) => parts.join('='))
|
||||
.join('&')
|
||||
|
||||
return filteredQuery ? `${path}?${filteredQuery}` : path
|
||||
}
|
||||
}
|
||||
|
||||
has(url: string, semanticKey?: string) {
|
||||
const key = this.createCacheKey(url, semanticKey)
|
||||
return Boolean(key && this.sourceCache.has(key))
|
||||
}
|
||||
|
||||
isInFlight(url: string, semanticKey?: string) {
|
||||
const key = this.createCacheKey(url, semanticKey)
|
||||
return Boolean(key && this.inFlightCache.has(key))
|
||||
}
|
||||
|
||||
async load(options: GuideModelLoadOptions): Promise<GLTF> {
|
||||
const candidates = options.urls.map((url) => url.trim()).filter(Boolean)
|
||||
if (!candidates.length) throw new Error('模型 URL 缺失')
|
||||
|
||||
let lastError: unknown = null
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const entry = await this.loadSource(url, options)
|
||||
return this.clone(entry)
|
||||
} catch (error) {
|
||||
if (options.shouldStopOnError?.(error)) throw error
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('模型资源加载失败')
|
||||
}
|
||||
|
||||
async preload(options: GuideModelLoadOptions) {
|
||||
const candidates = options.urls.map((url) => url.trim()).filter(Boolean)
|
||||
for (const url of candidates) {
|
||||
const key = this.createCacheKey(url, options.semanticKey)
|
||||
if (!key || this.sourceCache.has(key) || this.inFlightCache.has(key)) return
|
||||
await this.loadSource(url, options)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.sourceCache.forEach((entry) => disposeObject(entry.gltf.scene))
|
||||
this.sourceCache.clear()
|
||||
this.inFlightCache.clear()
|
||||
}
|
||||
|
||||
private async loadSource(url: string, options: GuideModelLoadOptions) {
|
||||
const key = this.createCacheKey(url, options.semanticKey)
|
||||
if (!key) throw new Error('模型 URL 缺失')
|
||||
|
||||
const cached = this.sourceCache.get(key)
|
||||
if (cached) {
|
||||
cached.lastUsedAt = Date.now()
|
||||
this.diagnostics.hits += 1
|
||||
return cached
|
||||
}
|
||||
|
||||
this.diagnostics.misses += 1
|
||||
let inFlight = this.inFlightCache.get(key)
|
||||
if (inFlight) {
|
||||
this.diagnostics.inFlightHits += 1
|
||||
} else {
|
||||
inFlight = options.loadSource(url)
|
||||
this.inFlightCache.set(key, inFlight)
|
||||
}
|
||||
|
||||
const gltf = await inFlight.finally(() => {
|
||||
if (this.inFlightCache.get(key) === inFlight) {
|
||||
this.inFlightCache.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
const entry: CachedModelEntry = {
|
||||
key,
|
||||
url,
|
||||
gltf,
|
||||
lastUsedAt: Date.now()
|
||||
}
|
||||
this.sourceCache.set(key, entry)
|
||||
this.trim()
|
||||
return entry
|
||||
}
|
||||
|
||||
private clone(entry: CachedModelEntry): GLTF {
|
||||
entry.lastUsedAt = Date.now()
|
||||
const scene = entry.gltf.scene.clone(true)
|
||||
cloneObjectResources(scene)
|
||||
scene.userData.modelCacheKey = entry.key
|
||||
|
||||
return {
|
||||
...entry.gltf,
|
||||
scene
|
||||
}
|
||||
}
|
||||
|
||||
private trim() {
|
||||
while (this.sourceCache.size > this.maxEntries) {
|
||||
const lru = [...this.sourceCache.values()]
|
||||
.sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0]
|
||||
|
||||
if (!lru) return
|
||||
|
||||
this.sourceCache.delete(lru.key)
|
||||
disposeObject(lru.gltf.scene)
|
||||
this.diagnostics.evictions += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const guideModelLoadManager = new GuideModelLoadManager()
|
||||
Reference in New Issue
Block a user