diff --git a/electron/main/services/heroPoseModels.ts b/electron/main/services/heroPoseModels.ts index 6c5114e3..9b22fbac 100644 --- a/electron/main/services/heroPoseModels.ts +++ b/electron/main/services/heroPoseModels.ts @@ -289,15 +289,29 @@ function riggedClothFile(key: string): string { * Pre-v14 Infernus GLBs were baked via `--hero inferno`, which read the base pak * and so cached the vanilla look over any active skin; force a re-export. * + * v16: textures are baked at PREVIEW_MAX_TEXTURE (`--max-texture`). Pre-v16 GLBs + * carry full-size 2048/4096 textures (35-70 MB) that load several times slower. + * * The Source 2 extras schema version (SOURCE2_EXTRAS_VERSION) is folded into the * effective key below, so a material-extras schema bump auto-busts this cache * with no manual edit here, and the cache version cannot drift from the parser's * expected schema. Bump POSE_PIPELINE_VERSION only for export changes unrelated * to the extras schema (model resolution, index offsets, ...). */ -const POSE_PIPELINE_VERSION = '15'; +const POSE_PIPELINE_VERSION = '16'; const POSE_CACHE_VERSION = `${POSE_PIPELINE_VERSION}.x${SOURCE2_EXTRAS_VERSION}`; +/** + * Longest texture edge baked into preview GLBs (`model export --max-texture`): + * each texture embeds at its largest mip that fits. Full-size hero textures + * (2048/4096) are far more than the Locker viewport shows, and they dominate + * export time, GLB size, and the renderer's decode + GPU upload. 1024 cuts a + * hero GLB ~3x (drifter skin: 39 -> 14 MB). Raising it to 2048 keeps fine print + * sharp at the closest zoom but costs ~300 MB more renderer memory per loaded + * hero (decoded ImageBitmaps; see loadGltfPreview). + */ +const PREVIEW_MAX_TEXTURE = '1024'; + const POSE_VERSION_FILENAME = '.cache-version'; function versionFile(key: string): string { @@ -334,11 +348,13 @@ function versionFile(key: string): string { * v9: export a bounded menu of representative full-body motions. * v10: preserve supported authored attachment frames beside the rigged model. * + * v15: textures baked at PREVIEW_MAX_TEXTURE (same as POSE_CACHE_VERSION v16). + * * Folds in SOURCE2_EXTRAS_VERSION on the same principle as POSE_CACHE_VERSION. */ // v14: Graves uses the complete standing weapon pose; shop parks its spectral // hand at the model origin. Refresh existing menus instead of reusing that pose. -const RIGGED_PIPELINE_VERSION = '14'; +const RIGGED_PIPELINE_VERSION = '15'; const RIGGED_CACHE_VERSION = `${RIGGED_PIPELINE_VERSION}.x${SOURCE2_EXTRAS_VERSION}`; const RIGGED_VERSION_FILENAME = '.rigged-cache-version'; @@ -899,6 +915,8 @@ async function runHeroPoseExportForSources( // (Apollo, Billy, Celeste, Mina, Paige, Rem) errors here and the // Locker falls back to the 2D portrait instead of an unposed model. '--require-pose', + '--max-texture', + PREVIEW_MAX_TEXTURE, '--out', out, ]); @@ -1017,6 +1035,8 @@ async function runRiggedHeroExportForSources( pak01, // The viewer plays one action at a time from this menu. ...clips.flatMap((clip) => ['--clip', clip.name]), + '--max-texture', + PREVIEW_MAX_TEXTURE, '--out', out, ]); diff --git a/src/components/locker/HeroPoseViewer.tsx b/src/components/locker/HeroPoseViewer.tsx index 91c0466f..226fb16e 100644 --- a/src/components/locker/HeroPoseViewer.tsx +++ b/src/components/locker/HeroPoseViewer.tsx @@ -29,7 +29,7 @@ import { exportHeroEffect, previewTrippySprite, } from '../../lib/api'; -import { loadGltfPreview } from '../../lib/loadGltfPreview'; +import { disposeTexture, loadGltfPreview, materialTextures } from '../../lib/loadGltfPreview'; import { prepareSource2VertexColors } from '../../lib/source2VertexColors'; import { loadRiggedHeroPreview } from '../../lib/loadRiggedHeroPreview'; import { ParticleEffect } from './ParticleEffect'; @@ -73,7 +73,7 @@ import type { TrippyPreview } from '../../stores/trippyPreviewStore'; * * Interactive: drag to orbit, scroll to zoom. Loading stays on the custom * GLTFLoader helper because Source 2 morphic texture resolution needs the live - * gltf.parser and ImageBitmap suppression window. + * gltf.parser. */ const HERO_POSE_SCHEME = 'grimoire-hero'; @@ -179,13 +179,10 @@ function disposeScene(root: THREE.Object3D): void { const disposeMaterial = (m: THREE.Material | null | undefined): void => { if (!m || disposedMaterials.has(m)) return; disposedMaterials.add(m); - const sm = m as THREE.MeshStandardMaterial; - [sm.map, sm.normalMap, sm.roughnessMap, sm.metalnessMap, sm.emissiveMap, sm.aoMap].forEach( - (t) => t?.dispose() - ); + materialTextures(m).forEach(disposeTexture); const resolved = getMorphic(m)?.resolvedTextures; if (resolved) { - Object.values(resolved).forEach((t) => t.dispose()); + Object.values(resolved).forEach(disposeTexture); } const csmBase = (m as { __csm?: { baseMaterial?: THREE.Material } }).__csm?.baseMaterial; if (csmBase && csmBase !== m) disposeMaterial(csmBase); @@ -1144,7 +1141,7 @@ export default function HeroPoseViewer({ return; } const url = meshUrlFor(info.key, info.mtimeMs); - const gltf = await loadGltfPreview(url); + const gltf = await loadGltfPreview(url, { imageBitmaps: true }); if (cancelled) { disposeScene(gltf.scene); return; diff --git a/src/components/locker/soulModel.ts b/src/components/locker/soulModel.ts index 00525c85..3ea04f00 100644 --- a/src/components/locker/soulModel.ts +++ b/src/components/locker/soulModel.ts @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import { disposeTexture, materialTextures } from '../../lib/loadGltfPreview'; /** * Shared helpers for the Locker's Global soul-container previews. @@ -53,11 +54,9 @@ export function disposeScene(root: THREE.Object3D): void { mesh.geometry?.dispose(); const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; for (const m of mats) { - const sm = m as THREE.MeshStandardMaterial; - [sm.map, sm.normalMap, sm.roughnessMap, sm.metalnessMap, sm.emissiveMap, sm.aoMap].forEach( - (t) => t?.dispose() - ); - m?.dispose(); + if (!m) continue; + materialTextures(m).forEach(disposeTexture); + m.dispose(); } }); } diff --git a/src/lib/loadGltfPreview.test.ts b/src/lib/loadGltfPreview.test.ts index 7c407019..e7e4f7ba 100644 --- a/src/lib/loadGltfPreview.test.ts +++ b/src/lib/loadGltfPreview.test.ts @@ -1,9 +1,9 @@ +import * as THREE from 'three'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import type { GLTF, GLTFParser } from 'three/examples/jsm/loaders/GLTFLoader.js'; const loaderMock = vi.hoisted(() => ({ - loadCreateImageBitmapValues: [] as unknown[], - parseCreateImageBitmapValues: [] as unknown[], + textureLoaders: [] as unknown[], })); const source2Mock = vi.hoisted(() => ({ @@ -24,13 +24,30 @@ function fakeGltf(): GLTF { vi.mock('three/examples/jsm/loaders/GLTFLoader.js', () => ({ GLTFLoader: class { + private plugins: ((parser: GLTFParser) => { name: string })[] = []; + + register(callback: (parser: GLTFParser) => { name: string }): this { + this.plugins.push(callback); + return this; + } + + // Mirrors GLTFLoader: the parser picks ImageBitmapLoader, then plugins run. + private runParser(): void { + const parser = { + options: { manager: undefined, crossOrigin: 'anonymous', requestHeader: {} }, + textureLoader: 'image-bitmap-loader', + } as unknown as GLTFParser; + this.plugins.forEach((plugin) => plugin(parser)); + loaderMock.textureLoaders.push(parser.textureLoader); + } + load(_url: string, onLoad: (gltf: GLTF) => void): void { - loaderMock.loadCreateImageBitmapValues.push(globalThis.createImageBitmap); + this.runParser(); onLoad(fakeGltf()); } parse(_buffer: ArrayBuffer, _path: string, onLoad: (gltf: GLTF) => void): void { - loaderMock.parseCreateImageBitmapValues.push(globalThis.createImageBitmap); + this.runParser(); onLoad(fakeGltf()); } }, @@ -40,42 +57,68 @@ vi.mock('./source2NprMaterial', () => ({ resolveMorphicTextures: source2Mock.resolveMorphicTextures, })); -function setCreateImageBitmap(value: Window['createImageBitmap'] | undefined): void { - Object.defineProperty(globalThis, 'createImageBitmap', { - configurable: true, - writable: true, - value, - }); -} - -describe('loadGltfPreview createImageBitmap guard', () => { +describe('loadGltfPreview texture decoding', () => { beforeEach(() => { - loaderMock.loadCreateImageBitmapValues = []; - loaderMock.parseCreateImageBitmapValues = []; + loaderMock.textureLoaders = []; source2Mock.resolveMorphicTextures.mockReset(); source2Mock.resolveMorphicTextures.mockResolvedValue(undefined); }); - it('suppresses createImageBitmap while parsing in-memory GLB bytes and restores it', async () => { - const sentinel = (() => Promise.resolve({})) as unknown as Window['createImageBitmap']; - setCreateImageBitmap(sentinel); + it('decodes with textures by default, per parser', async () => { + const { loadGltfPreview, parseGltfPreview } = await import('./loadGltfPreview'); + const loaded = await loadGltfPreview('grimoire-hero://m/test/model.glb?v=1'); + const parsed = await parseGltfPreview(new ArrayBuffer(4)); - const { parseGltfPreview } = await import('./loadGltfPreview'); - const gltf = await parseGltfPreview(new ArrayBuffer(4)); + expect(loaderMock.textureLoaders).toHaveLength(2); + loaderMock.textureLoaders.forEach((loader) => + expect(loader).toBeInstanceOf(THREE.TextureLoader) + ); + expect(source2Mock.resolveMorphicTextures).toHaveBeenCalledWith(loaded); + expect(source2Mock.resolveMorphicTextures).toHaveBeenCalledWith(parsed); + }); - expect(loaderMock.parseCreateImageBitmapValues).toEqual([undefined]); - expect(globalThis.createImageBitmap).toBe(sentinel); + it('keeps the ImageBitmap loader when imageBitmaps is set', async () => { + const { loadGltfPreview } = await import('./loadGltfPreview'); + const gltf = await loadGltfPreview('grimoire-hero://m/test/model.glb?v=1', { + imageBitmaps: true, + }); + + expect(loaderMock.textureLoaders).toEqual(['image-bitmap-loader']); expect(source2Mock.resolveMorphicTextures).toHaveBeenCalledWith(gltf); }); +}); - it('removes createImageBitmap after URL loads when it was originally absent', async () => { - delete (globalThis as { createImageBitmap?: Window['createImageBitmap'] }).createImageBitmap; +describe('texture disposal', () => { + it('lists every texture a material holds', async () => { + const { materialTextures } = await import('./loadGltfPreview'); + const map = new THREE.Texture(); + const sheen = new THREE.Texture(); + const material = new THREE.MeshPhysicalMaterial({ map, sheenColorMap: sheen }); - const { loadGltfPreview } = await import('./loadGltfPreview'); - const gltf = await loadGltfPreview('grimoire-hero://m/test/model.glb?v=1'); + expect(materialTextures(material)).toEqual(expect.arrayContaining([map, sheen])); + expect(materialTextures(material)).toHaveLength(2); + }); - expect(loaderMock.loadCreateImageBitmapValues).toEqual([undefined]); - expect('createImageBitmap' in globalThis).toBe(false); - expect(source2Mock.resolveMorphicTextures).toHaveBeenCalledWith(gltf); + it('closes an ImageBitmap-backed texture when disposing it', async () => { + const { disposeTexture } = await import('./loadGltfPreview'); + const close = vi.fn(); + class FakeImageBitmap { + width = 4; + height = 4; + close = close; + } + vi.stubGlobal('ImageBitmap', FakeImageBitmap); + try { + const texture = new THREE.Texture(new FakeImageBitmap() as unknown as ImageBitmap); + const onDispose = vi.fn(); + texture.addEventListener('dispose', onDispose); + + disposeTexture(texture); + + expect(onDispose).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + } }); }); diff --git a/src/lib/loadGltfPreview.ts b/src/lib/loadGltfPreview.ts index 207d4f23..3e39f9a9 100644 --- a/src/lib/loadGltfPreview.ts +++ b/src/lib/loadGltfPreview.ts @@ -1,73 +1,72 @@ +import * as THREE from 'three'; import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { resolveMorphicTextures } from './source2NprMaterial'; -type CreateImageBitmapFunction = Window['createImageBitmap']; - -let disableDepth = 0; -let hadCreateImageBitmap = false; -let savedCreateImageBitmap: CreateImageBitmapFunction | undefined; - -function defineCreateImageBitmap(value: CreateImageBitmapFunction | undefined): void { - Object.defineProperty(globalThis, 'createImageBitmap', { - configurable: true, - writable: true, - value, - }); +export interface GltfPreviewOptions { + /** Decode embedded images with createImageBitmap, off the main thread, instead + * of elements, which decode synchronously during the first render (a + * ~1 s freeze on a full-size hero GLB). The decoded bitmaps stay pinned in + * renderer memory until disposeTexture closes them, while Chromium can drop + * an 's pixels, so this suits one model at a time (the hero viewer), not + * the soul-container grid. */ + imageBitmaps?: boolean; } -function disableImageBitmapLoader(): void { - if (disableDepth === 0) { - hadCreateImageBitmap = 'createImageBitmap' in globalThis; - savedCreateImageBitmap = globalThis.createImageBitmap; - defineCreateImageBitmap(undefined); +function previewLoader({ imageBitmaps = false }: GltfPreviewOptions): GLTFLoader { + const loader = new GLTFLoader(); + if (!imageBitmaps) { + // Swap this parser's texture loader rather than hiding createImageBitmap + // globally, so a concurrent imageBitmaps load is unaffected. + loader.register((parser) => { + parser.textureLoader = new THREE.TextureLoader(parser.options.manager) + .setCrossOrigin(parser.options.crossOrigin) + .setRequestHeader(parser.options.requestHeader); + return { name: 'grimoire_img_textures' }; + }); } - disableDepth += 1; + return loader; } -function restoreImageBitmapLoader(): void { - if (disableDepth === 0) return; - disableDepth -= 1; - if (disableDepth > 0) return; - - if (hadCreateImageBitmap) { - defineCreateImageBitmap(savedCreateImageBitmap); - } else { - delete (globalThis as { createImageBitmap?: CreateImageBitmapFunction }).createImageBitmap; - } +export async function loadGltfPreview( + url: string, + options: GltfPreviewOptions = {} +): Promise { + const gltf = await new Promise((resolve, reject) => { + previewLoader(options).load(url, resolve, undefined, reject); + }); + // Resolve morphic preview texture indices (the only part of the morphic + // contract the stock loader does not surface) while gltf.parser is still live. + // No-op when no material carries preview-only textures. + await resolveMorphicTextures(gltf); + return gltf; +} - hadCreateImageBitmap = false; - savedCreateImageBitmap = undefined; +/** Parse an in-memory `.glb` (ArrayBuffer) instead of fetching a URL. Used by + * the Soul Container import preview, which loads the dropped/picked file's bytes + * directly (before any build). */ +export async function parseGltfPreview( + buffer: ArrayBuffer, + options: GltfPreviewOptions = {} +): Promise { + const gltf = await new Promise((resolve, reject) => { + previewLoader(options).parse(buffer, '', resolve, reject); + }); + await resolveMorphicTextures(gltf); + return gltf; } -export async function loadGltfPreview(url: string): Promise { - disableImageBitmapLoader(); - try { - const gltf = await new Promise((resolve, reject) => { - new GLTFLoader().load(url, resolve, undefined, reject); - }); - // Resolve morphic preview texture indices (the only part of the morphic - // contract the stock loader does not surface) while gltf.parser is still live - // and the ImageBitmap suppression is in effect. No-op when no material - // carries preview-only textures. - await resolveMorphicTextures(gltf); - return gltf; - } finally { - restoreImageBitmapLoader(); - } +/** Every texture a material holds directly (map, normalMap, sheenColorMap, ...). */ +export function materialTextures(material: THREE.Material): THREE.Texture[] { + return Object.values(material).filter( + (value): value is THREE.Texture => (value as THREE.Texture | null)?.isTexture === true + ); } -/** Parse an in-memory `.glb` (ArrayBuffer) instead of fetching a URL. Used by - * the Soul Container import preview, which loads the dropped/picked file's bytes - * directly (before any build). Shares the createImageBitmap guard. */ -export async function parseGltfPreview(buffer: ArrayBuffer): Promise { - disableImageBitmapLoader(); - try { - const gltf = await new Promise((resolve, reject) => { - new GLTFLoader().parse(buffer, '', resolve, reject); - }); - await resolveMorphicTextures(gltf); - return gltf; - } finally { - restoreImageBitmapLoader(); - } +/** Dispose a texture and close its ImageBitmap, if it has one (three.js never + * does). Clones share the bitmap, so only call this when freeing the whole scene + * the texture came from. */ +export function disposeTexture(texture: THREE.Texture): void { + texture.dispose(); + const image: unknown = texture.image; + if (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap) image.close(); } diff --git a/src/lib/loadRiggedHeroPreview.test.ts b/src/lib/loadRiggedHeroPreview.test.ts index 5a7ad3ac..67bd015e 100644 --- a/src/lib/loadRiggedHeroPreview.test.ts +++ b/src/lib/loadRiggedHeroPreview.test.ts @@ -42,7 +42,7 @@ describe('loadRiggedHeroPreview', () => { await Promise.resolve(); expect(ready).toBe(false); const base = 'grimoire-hero://m/Yamato%3A%3Aaddons2%2Fskin_dir.vpk%3A%3Ac123'; - expect(loadGltfPreview).toHaveBeenCalledWith(`${base}/model-rigged.glb?v=42`); + expect(loadGltfPreview).toHaveBeenCalledWith(`${base}/model-rigged.glb?v=42`, { imageBitmaps: true }); expect(fetch).toHaveBeenCalledWith(`${base}/cloth-rigged.json?v=42`); complete(Response.json(raw)); const result = await loading; diff --git a/src/lib/loadRiggedHeroPreview.ts b/src/lib/loadRiggedHeroPreview.ts index fe880353..392d95aa 100644 --- a/src/lib/loadRiggedHeroPreview.ts +++ b/src/lib/loadRiggedHeroPreview.ts @@ -34,7 +34,7 @@ export async function loadRiggedHeroPreview(info: HeroPoseInfo, physics: boolean // Resolve both before mounting the mixer: cloth calibration needs the bind // pose, and a fallback export's physics must follow its returned cache key. const [gltf, clothModel, attachments] = await Promise.all([ - loadGltfPreview(`${base}/model-rigged.glb?v=${version}`), + loadGltfPreview(`${base}/model-rigged.glb?v=${version}`, { imageBitmaps: true }), physics ? loadClothSidecar(`${base}/cloth-rigged.json?v=${version}`) : null, loadAttachments(`${base}/attachments.json?v=${version}`), ]);