Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions electron/main/services/heroPoseModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
]);
Expand Down Expand Up @@ -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,
]);
Expand Down
13 changes: 5 additions & 8 deletions src/components/locker/HeroPoseViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 4 additions & 5 deletions src/components/locker/soulModel.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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();
}
});
}
103 changes: 73 additions & 30 deletions src/lib/loadGltfPreview.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand All @@ -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());
}
},
Expand All @@ -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 <img> 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();
}
});
});
117 changes: 58 additions & 59 deletions src/lib/loadGltfPreview.ts
Original file line number Diff line number Diff line change
@@ -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 <img> 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 <img>'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<GLTF> {
const gltf = await new Promise<GLTF>((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<GLTF> {
const gltf = await new Promise<GLTF>((resolve, reject) => {
previewLoader(options).parse(buffer, '', resolve, reject);
});
await resolveMorphicTextures(gltf);
return gltf;
}

export async function loadGltfPreview(url: string): Promise<GLTF> {
disableImageBitmapLoader();
try {
const gltf = await new Promise<GLTF>((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<GLTF> {
disableImageBitmapLoader();
try {
const gltf = await new Promise<GLTF>((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();
}
Loading
Loading