perf(stage-ui-mmd): implement opfs loader (#2171)

This commit is contained in:
藍+85CD
2026-07-30 07:46:30 +08:00
committed by GitHub
parent 54a48158cd
commit 36f6ab8d24
5 changed files with 141 additions and 7 deletions
+2 -1
View File
@@ -26,7 +26,8 @@
"./utils/mmd-loader": "./src/utils/mmd-loader.ts",
"./utils/mmd-preview": "./src/utils/mmd-preview.ts",
"./utils/mmd-validator": "./src/utils/mmd-validator.ts",
"./utils/mmd-zip-loader": "./src/utils/mmd-zip-loader.ts"
"./utils/mmd-zip-loader": "./src/utils/mmd-zip-loader.ts",
"./utils/opfs-loader": "./src/utils/opfs-loader.ts"
},
"scripts": {
"typecheck": "vue-tsc --noEmit"
@@ -363,7 +363,7 @@ async function loadModel(src: string) {
disposeModel()
try {
resolved = await loadMMDModelFromSource(src)
resolved = await loadMMDModelFromSource(src, { cacheKey: props.modelId })
mesh = resolved.mesh
modelGroup = new Group()
+1
View File
@@ -9,3 +9,4 @@ export * from './utils/mmd-loader'
export * from './utils/mmd-preview'
export * from './utils/mmd-validator'
export * from './utils/mmd-zip-loader'
export * from './utils/opfs-loader'
+21 -5
View File
@@ -6,6 +6,7 @@ import type { MMDLoadedAssets, MMDModelFormat } from './mmd-zip-loader'
import { createMMDLoaderContext, loadMMD } from '../composables/mmd/loader'
import { prepareMMDMaterials } from './mmd-materials'
import { loadMMDZip } from './mmd-zip-loader'
import { OPFSCache } from './opfs-loader'
export interface ResolvedMMDModel {
/** MMD runtime that owns IK, grant, morph, and optional physics state. */
@@ -20,6 +21,13 @@ export interface ResolvedMMDModel {
}
export interface LoadMMDOptions {
/**
* Stable cache key for a packaged ZIP source. The display-model id is the
* intended value; when provided, OPFS is checked before fetching `src`.
* Bare PMX/PMD URLs are loaded from their original URL so relative texture
* paths keep their server-relative base.
*/
cacheKey?: string
/**
* Wait for the model's textures to finish loading before resolving.
*
@@ -77,11 +85,17 @@ function formatFromUrl(url: string): MMDModelFormat {
* does not dispose the mesh's GPU resources — the scene owns that lifecycle.
*/
export async function loadMMDModelFromSource(src: string, options: LoadMMDOptions = {}): Promise<ResolvedMMDModel> {
const response = await fetch(src)
if (!response.ok)
throw new Error(`Failed to fetch MMD model: ${response.status} ${response.statusText}`)
const buffer = await response.arrayBuffer()
const cachedSource = options.cacheKey ? await OPFSCache.get(options.cacheKey, src) : null
let buffer: ArrayBuffer
if (cachedSource) {
buffer = await cachedSource.arrayBuffer()
}
else {
const response = await fetch(src)
if (!response.ok)
throw new Error(`Failed to fetch MMD model: ${response.status} ${response.statusText}`)
buffer = await response.arrayBuffer()
}
if (isZip(buffer)) {
const assets = await loadMMDZip(buffer)
@@ -92,6 +106,8 @@ export async function loadMMDModelFromSource(src: string, options: LoadMMDOption
prepareMMDMaterials(mmd.mesh)
if (options.waitForTextures)
await waitForManagerIdle(manager)
if (options.cacheKey && !cachedSource)
await OPFSCache.save(options.cacheKey, new Blob([buffer]), src)
return {
mmd,
mesh: mmd.mesh,
@@ -0,0 +1,116 @@
interface OPFSCacheMeta {
sourceUrl?: string
version?: number
}
/**
* Cache schema version for OPFS-stored MMD source files.
*
* Increment when the persisted directory shape changes.
*/
const mmdOpfsCacheVersion = 1
const sourceFileName = '__source.bin'
/**
* Stores the original MMD source bytes in OPFS so a model can be replayed
* without fetching its blob URL again after a reload.
*
* The cache key must be stable for the same imported model (the display-model
* id is the intended key). Blob URLs are deliberately not compared because
* they are recreated for the same IndexedDB-backed file on every session.
*/
export class OPFSCache {
static async clearAll(): Promise<void> {
try {
const root = await navigator.storage.getDirectory()
const entryNames: string[] = []
for await (const entry of root.values())
entryNames.push(entry.name)
await Promise.all(entryNames.map(name => root.removeEntry(name, { recursive: true })))
}
catch (error) {
console.error('[OPFS] Failed to clear MMD cache:', error)
}
}
private static async writeFile(
root: FileSystemDirectoryHandle,
fileName: string,
content: Blob | string,
): Promise<void> {
const fileHandle = await root.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(content)
await writable.close()
}
private static async readMeta(dirHandle: FileSystemDirectoryHandle): Promise<OPFSCacheMeta | null> {
try {
const metaHandle = await dirHandle.getFileHandle('__meta.json', { create: false })
const metaFile = await metaHandle.getFile()
return JSON.parse(await metaFile.text()) as OPFSCacheMeta
}
catch {
return null
}
}
/**
* Returns the cached source for a model, or `null` when the cache is absent
* or no longer matches the requested remote URL.
*/
static async get(key: string, sourceUrl: string): Promise<Blob | null> {
try {
const root = await navigator.storage.getDirectory()
const dirHandle = await root.getDirectoryHandle(key, { create: false })
const meta = await OPFSCache.readMeta(dirHandle)
if (meta?.version !== mmdOpfsCacheVersion) {
// NOTICE:
// The cache stores one source file plus metadata. Invalidating the
// directory keeps a future format change from being interpreted as a
// valid model blob.
// Source/context: OPFSCache source-file persistence.
// Removal condition: the persisted directory format is permanently stable.
await root.removeEntry(dirHandle.name, { recursive: true })
return null
}
const shouldValidateSourceUrl = !sourceUrl.startsWith('blob:')
if (shouldValidateSourceUrl && meta.sourceUrl && meta.sourceUrl !== sourceUrl) {
// A stable model id can outlive a changed remote URL. Never serve the
// old source in that case.
await root.removeEntry(dirHandle.name, { recursive: true })
return null
}
const sourceHandle = await dirHandle.getFileHandle(sourceFileName, { create: false })
return await sourceHandle.getFile()
}
catch {
// OPFS is an optional acceleration layer; a miss falls back to fetch.
return null
}
}
/**
* Persists the original source bytes under a stable model key.
* Cache failures are intentionally swallowed so model loading remains usable
* in browsers where OPFS is unavailable or storage is full.
*/
static async save(key: string, source: Blob, sourceUrl?: string): Promise<void> {
try {
const root = await navigator.storage.getDirectory()
const dirHandle = await root.getDirectoryHandle(key, { create: true })
await OPFSCache.writeFile(dirHandle, sourceFileName, source)
await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({
sourceUrl,
version: mmdOpfsCacheVersion,
}))
}
catch (error) {
console.error('[OPFS] Failed to save MMD cache:', error)
}
}
}