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}`),
]);