460 lines
13 KiB
TypeScript
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()
|