Files
frontend-miniapp/src/services/model/GuideModelLoadManager.ts
lyf 39f1fc1198
Some checks failed
CI / verify (push) Has been cancelled
修复馆内三维点位显示异常
2026-07-04 23:42:05 +08:00

460 lines
13 KiB
TypeScript

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
}
type GuideModelDiagnosticEvent =
| 'cache-hit'
| 'cache-miss'
| 'in-flight-hit'
| 'load-start'
| 'load-complete'
| 'load-failed'
| 'preload-skip'
// 当前 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 getNow = () => (
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
: Date.now()
)
const isModelDiagnosticsEnabled = () => {
if (!import.meta.env.DEV) return false
if (typeof window === 'undefined') return true
const diagnosticsWindow = window as unknown as {
__GUIDE_3D_MODEL_DIAGNOSTICS__?: boolean
}
return diagnosticsWindow.__GUIDE_3D_MODEL_DIAGNOSTICS__ !== false
}
const logModelDiagnostic = (
event: GuideModelDiagnosticEvent,
payload: Record<string, unknown>
) => {
if (!isModelDiagnosticsEnabled()) return
console.debug(`[GuideModelLoadManager] ${event}`, payload)
}
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)) {
logModelDiagnostic('preload-skip', {
label: options.label,
url,
key,
reason: !key ? 'empty-key' : this.sourceCache.has(key) ? 'cache-hit' : 'in-flight-hit',
diagnostics: this.getDiagnostics()
})
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
logModelDiagnostic('cache-hit', {
label: options.label,
url,
key,
diagnostics: this.getDiagnostics()
})
return cached
}
this.diagnostics.misses += 1
logModelDiagnostic('cache-miss', {
label: options.label,
url,
key,
diagnostics: this.getDiagnostics()
})
let inFlight = this.inFlightCache.get(key)
const startedAt = getNow()
if (inFlight) {
this.diagnostics.inFlightHits += 1
logModelDiagnostic('in-flight-hit', {
label: options.label,
url,
key,
diagnostics: this.getDiagnostics()
})
} else {
logModelDiagnostic('load-start', {
label: options.label,
url,
key
})
inFlight = options.loadSource(url)
this.inFlightCache.set(key, inFlight)
}
let gltf: GLTF
try {
gltf = await inFlight
} catch (error) {
logModelDiagnostic('load-failed', {
label: options.label,
url,
key,
elapsedMs: Math.round(getNow() - startedAt),
error: error instanceof Error ? error.message : String(error)
})
throw error
} 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()
logModelDiagnostic('load-complete', {
label: options.label,
url,
key,
elapsedMs: Math.round(getNow() - startedAt),
diagnostics: this.getDiagnostics()
})
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()