From 407f6206825ea183febbb846e3992620d4791f35 Mon Sep 17 00:00:00 2001 From: slush97 Date: Sat, 3 Oct 2026 11:23:42 -0700 Subject: [PATCH 01/13] fix(vpk): index VPK entries instead of rescanning the tree per lookup readVpkEntryBytes read and walked the whole directory tree on every call (about 25 ms for pak01's 140K entries) and the viewer exporters look up dozens of entries per open. Keep a small LRU of typed-array indexes keyed on (path, mtime, size), dropped by invalidateVpkParseCache, and add vpkHasEntry for the particle owner lookups. Results are byte-identical to the linear scan. --- .../main/services/heroParticleExport.test.ts | 3 +- electron/main/services/heroParticleExport.ts | 8 +- electron/main/services/vpk.ts | 231 +++++++++++------- 3 files changed, 150 insertions(+), 92 deletions(-) diff --git a/electron/main/services/heroParticleExport.test.ts b/electron/main/services/heroParticleExport.test.ts index 106a5d91..03c64fe2 100644 --- a/electron/main/services/heroParticleExport.test.ts +++ b/electron/main/services/heroParticleExport.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it, vi } from 'vitest'; const h = vi.hoisted(() => ({ run: vi.fn(), stdout: vi.fn(), index: vi.fn(), attachments: vi.fn() })); vi.mock('./modMerger', () => ({ runVpkmerge: h.run, runVpkmergeStdout: h.stdout })); -vi.mock('./vpk', () => ({ parseVpkDirectoryCached: h.index })); +vi.mock('./vpk', () => ({ parseVpkDirectoryCached: h.index, vpkHasEntry: (path: string, entry: string) => (h.index(path) as string[] | null)?.includes(entry) ?? false })); vi.mock('./modelAttachments', () => ({ exportModelAttachments: h.attachments })); import { exportParticleBundle, particleDescriptor } from './heroParticleExport'; const resource = (overrides = {}) => ({ _class: 'CParticleSystemDefinition', m_Renderers: [], ...overrides }); @@ -87,7 +87,6 @@ describe('compiled particle export', () => { for (const [name, owner] of [['override', 'skin.vpk'], ['base', 'base.vpk'], ['core', 'core.vpk']]) { expect(await fs.readFile(join(dir, 'tex', `materials_particle_${name}_vtex.png`), 'utf8')).toBe(owner); } - expect(h.index).toHaveBeenCalledTimes(3); expect(h.run).toHaveBeenCalledTimes(3); expect(h.stdout.mock.calls[0][0]).toContain('base.vpk'); expect(await fs.readdir(join(dir, 'tex'))).toHaveLength(3); diff --git a/electron/main/services/heroParticleExport.ts b/electron/main/services/heroParticleExport.ts index 0260481b..578d1b7f 100644 --- a/electron/main/services/heroParticleExport.ts +++ b/electron/main/services/heroParticleExport.ts @@ -4,7 +4,7 @@ import type { FxControlPoint, FxDescriptor, FxNode, FxRenderer } from '../../../ import { fxTexturePngName } from '../../../src/components/locker/fxDescriptor'; import { runVpkmerge, runVpkmergeStdout } from './modMerger'; import { readParticleSheet } from './particleSheet'; -import { parseVpkDirectoryCached, readVpkEntryBytes } from './vpk'; +import { readVpkEntryBytes, vpkHasEntry } from './vpk'; import { readParticleSnapshot } from './particleSnapshot'; import { exportModelAttachments } from './modelAttachments'; @@ -95,7 +95,7 @@ export async function exportParticleBundle(pak: string, entry: string, descripto if (typeof snapshot === 'string') { if (!/^particles\/[a-zA-Z0-9_./-]+\.vsnap$/.test(snapshot) || snapshot.includes('..')) throw new Error('Invalid particle snapshot path.'); const entry = `${snapshot}_c`; - const owner = [...new Set([...texturePaks, pak])].find((p) => parseVpkDirectoryCached(p)?.includes(entry)); + const owner = [...new Set([...texturePaks, pak])].find((p) => vpkHasEntry(p, entry)); const bytes = owner ? readVpkEntryBytes(owner, entry) : null; if (!bytes || bytes.length > 4*1024*1024) throw new Error('Particle snapshot is unavailable or exceeds preview limits.'); const metadata: unknown = JSON.parse(await runVpkmergeStdout(['soundevents', entry, '--from-vpk', owner!])); @@ -160,9 +160,9 @@ export async function exportParticleBundle(pak: string, entry: string, descripto const owners = new Map(); // Caller supplies mounted priority: selected skin stack, Citadel, core. // Exact entries avoid decoding the same texture from a lower-priority VPK. - const indexes = texturePaks.length ? [...new Set(texturePaks)].map((path) => ({ path, entries: new Set(parseVpkDirectoryCached(path) ?? []) })) : null; + const mounted = texturePaks.length ? [...new Set(texturePaks)] : null; for (const texture of textures) { - const owner = indexes ? indexes.find(({ entries }) => entries.has(`${texture}_c`))?.path : pak; + const owner = mounted ? mounted.find((path) => vpkHasEntry(path, `${texture}_c`)) : pak; if (!owner) throw new Error(`Particle texture is absent from mounted packages: ${texture}`); const group = owners.get(owner) ?? []; group.push(texture); diff --git a/electron/main/services/vpk.ts b/electron/main/services/vpk.ts index 4042b12c..8f0d3f65 100644 --- a/electron/main/services/vpk.ts +++ b/electron/main/services/vpk.ts @@ -96,9 +96,11 @@ const inflightParses = new Map>(); export function invalidateVpkParseCache(vpkPath?: string): void { if (vpkPath) { vpkParseCache.delete(vpkPath); + vpkEntryIndexes.delete(vpkPath); cacheEpochs.set(vpkPath, (cacheEpochs.get(vpkPath) ?? 0) + 1); } else { vpkParseCache.clear(); + vpkEntryIndexes.clear(); globalEpoch++; } } @@ -508,126 +510,183 @@ export function parseVpkEntryStats(vpkPath: string): VpkEntryStat[] | null { * root-level entries like the embedded addoninfo.txt; it reads the whole entry * into memory. */ -export function readVpkEntryBytes(vpkPath: string, entryPath: string): Buffer | null { - if (!existsSync(vpkPath)) { - return null; - } - - const target = entryPath.replace(/\\/g, '/').toLowerCase(); - let fd: number | null = null; +// Lookup index for readVpkEntryBytes, keyed like vpkParseCache on (path, +// mtime, size). A single lookup used to read and walk the whole directory tree +// (~25 ms for pak01's 140K entries), and the 3D preview exporters look up +// dozens of entries per open. Typed arrays keep pak01's index to a few MB on +// top of the tree buffer, which is retained for preload bytes. Small LRU: the +// callers hit pak01 plus the active skin VPKs. +interface VpkEntryIndex { + mtimeMs: number; + size: number; + dataStart: number; + tree: Buffer; + slots: Map; + preloadStart: Uint32Array; + preloadBytes: Uint16Array; + archiveIndex: Uint16Array; + entryOffset: Uint32Array; + entryLength: Uint32Array; +} +const VPK_ENTRY_INDEX_LIMIT = 4; +const vpkEntryIndexes = new Map(); +function buildVpkEntryIndex(vpkPath: string, mtimeMs: number, size: number): VpkEntryIndex | null { + const fd = openSync(vpkPath, 'r'); try { - fd = openSync(vpkPath, 'r'); - const headerBuffer = Buffer.alloc(12); readSync(fd, headerBuffer, 0, 12, 0); - - if (headerBuffer.readUInt32LE(0) !== VPK_SIGNATURE) { - closeSync(fd); - return null; - } + if (headerBuffer.readUInt32LE(0) !== VPK_SIGNATURE) return null; const version = headerBuffer.readUInt32LE(4); const treeSize = headerBuffer.readUInt32LE(8); const headerSize = version === 2 ? 28 : 12; - - if (!isPlausibleVpkLength(treeSize, fstatSync(fd).size, headerSize)) { + if (!isPlausibleVpkLength(treeSize, size, headerSize)) { console.warn(`[vpk] ${vpkPath}: implausible tree size ${treeSize}, refusing to parse`); - closeSync(fd); return null; } - const treeBuffer = Buffer.alloc(treeSize); - readSync(fd, treeBuffer, 0, treeSize, headerSize); + const tree = Buffer.alloc(treeSize); + readSync(fd, tree, 0, treeSize, headerSize); + + const slots = new Map(); + const preloadStart: number[] = []; + const preloadBytes: number[] = []; + const archiveIndex: number[] = []; + const entryOffset: number[] = []; + const entryLength: number[] = []; let offset = 0; - while (offset < treeBuffer.length) { - const extResult = readNullTerminatedString(treeBuffer, offset); + walk: while (offset < tree.length) { + const extResult = readNullTerminatedString(tree, offset); offset += extResult.bytesRead; if (extResult.str === '') break; const extension = extResult.str; - while (offset < treeBuffer.length) { - const pathResult = readNullTerminatedString(treeBuffer, offset); + while (offset < tree.length) { + const pathResult = readNullTerminatedString(tree, offset); offset += pathResult.bytesRead; if (pathResult.str === '') break; const dirPath = pathResult.str === ' ' ? '' : pathResult.str; - while (offset < treeBuffer.length) { - const nameResult = readNullTerminatedString(treeBuffer, offset); + while (offset < tree.length) { + const nameResult = readNullTerminatedString(tree, offset); offset += nameResult.bytesRead; if (nameResult.str === '') break; - const filename = nameResult.str; + // A truncated metadata block ends the walk: entries past it + // were unreachable for the old linear scan as well. + if (offset + 18 > tree.length) break walk; const fullPath = dirPath - ? `${dirPath}/${filename}.${extension}` - : `${filename}.${extension}`; - - // Entry metadata block (18 bytes): - // CRC(4) PreloadBytes(2) ArchiveIndex(2) EntryOffset(4) EntryLength(4) Terminator(2) - if (offset + 18 > treeBuffer.length) { - closeSync(fd); - return null; - } - const preloadBytes = treeBuffer.readUInt16LE(offset + 4); - const archiveIndex = treeBuffer.readUInt16LE(offset + 6); - const entryOffset = treeBuffer.readUInt32LE(offset + 8); - const entryLength = treeBuffer.readUInt32LE(offset + 12); - const preloadStart = offset + 18; - - if (fullPath.replace(/\\/g, '/').toLowerCase() === target) { - const preload = preloadBytes > 0 - ? treeBuffer.subarray(preloadStart, preloadStart + preloadBytes) - : Buffer.alloc(0); - - let archiveData = Buffer.alloc(0); - if (entryLength > 0) { - // entryLength is another untrusted uint32, so it gets - // the same treatment as treeSize: check it against the - // file that actually holds the data before allocating. - // Throwing lands in the catch below, which closes fd. - if (archiveIndex === VPK_DIR_ARCHIVE_INDEX) { - // Data lives in the dir VPK after header + tree. - const dataOffset = headerSize + treeSize + entryOffset; - if (!isPlausibleVpkLength(entryLength, fstatSync(fd).size, dataOffset, MAX_VPK_ENTRY_SIZE)) { - throw new Error(`implausible entry length ${entryLength} for ${fullPath}`); - } - archiveData = Buffer.alloc(entryLength); - readSync(fd, archiveData, 0, entryLength, dataOffset); - } else { - // Data lives in a sibling _NNN.vpk archive. - const archivePath = vpkPath.replace( - /_dir\.vpk$/i, - `_${String(archiveIndex).padStart(3, '0')}.vpk` - ); - const afd = openSync(archivePath, 'r'); - try { - if (!isPlausibleVpkLength(entryLength, fstatSync(afd).size, entryOffset, MAX_VPK_ENTRY_SIZE)) { - throw new Error(`implausible entry length ${entryLength} for ${fullPath}`); - } - archiveData = Buffer.alloc(entryLength); - readSync(afd, archiveData, 0, entryLength, entryOffset); - } finally { - closeSync(afd); - } - } - } - - closeSync(fd); - return Buffer.concat([preload, archiveData]); + ? `${dirPath}/${nameResult.str}.${extension}` + : `${nameResult.str}.${extension}`; + const key = fullPath.replace(/\\/g, '/').toLowerCase(); + const preload = tree.readUInt16LE(offset + 4); + if (!slots.has(key)) { + slots.set(key, preloadStart.length); + preloadStart.push(offset + 18); + preloadBytes.push(preload); + archiveIndex.push(tree.readUInt16LE(offset + 6)); + entryOffset.push(tree.readUInt32LE(offset + 8)); + entryLength.push(tree.readUInt32LE(offset + 12)); } - - offset = preloadStart + preloadBytes; + offset += 18 + preload; } } } + return { + mtimeMs, + size, + dataStart: headerSize + treeSize, + tree, + slots, + preloadStart: Uint32Array.from(preloadStart), + preloadBytes: Uint16Array.from(preloadBytes), + archiveIndex: Uint16Array.from(archiveIndex), + entryOffset: Uint32Array.from(entryOffset), + entryLength: Uint32Array.from(entryLength), + }; + } finally { closeSync(fd); + } +} + +function vpkEntryIndex(vpkPath: string): VpkEntryIndex | null { + let stat; + try { + stat = statSync(vpkPath); + } catch { + vpkEntryIndexes.delete(vpkPath); return null; - } catch (error) { - if (fd !== null) { - try { closeSync(fd); } catch { /* already closed */ } + } + const cached = vpkEntryIndexes.get(vpkPath); + vpkEntryIndexes.delete(vpkPath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + vpkEntryIndexes.set(vpkPath, cached); + return cached; + } + const index = buildVpkEntryIndex(vpkPath, stat.mtimeMs, stat.size); + if (!index) return null; + vpkEntryIndexes.set(vpkPath, index); + if (vpkEntryIndexes.size > VPK_ENTRY_INDEX_LIMIT) { + vpkEntryIndexes.delete(vpkEntryIndexes.keys().next().value!); + } + return index; +} + +/** Whether a VPK contains an entry (case-insensitive), via the lookup index. */ +export function vpkHasEntry(vpkPath: string, entryPath: string): boolean { + try { + return vpkEntryIndex(vpkPath)?.slots.has(entryPath.replace(/\\/g, '/').toLowerCase()) ?? false; + } catch { + return false; + } +} + +export function readVpkEntryBytes(vpkPath: string, entryPath: string): Buffer | null { + try { + const index = vpkEntryIndex(vpkPath); + const slot = index?.slots.get(entryPath.replace(/\\/g, '/').toLowerCase()); + if (!index || slot === undefined) return null; + + const preloadStart = index.preloadStart[slot]; + const preload = index.tree.subarray(preloadStart, preloadStart + index.preloadBytes[slot]); + const entryLength = index.entryLength[slot]; + const entryOffset = index.entryOffset[slot]; + const archiveIndex = index.archiveIndex[slot]; + if (entryLength === 0) return Buffer.from(preload); + + // entryLength is an untrusted uint32: check it against the file that + // actually holds the data before allocating. + let archiveData: Buffer; + if (archiveIndex === VPK_DIR_ARCHIVE_INDEX) { + const dataOffset = index.dataStart + entryOffset; + if (!isPlausibleVpkLength(entryLength, index.size, dataOffset, MAX_VPK_ENTRY_SIZE)) { + throw new Error(`implausible entry length ${entryLength} for ${entryPath}`); + } + const fd = openSync(vpkPath, 'r'); + try { + archiveData = Buffer.alloc(entryLength); + readSync(fd, archiveData, 0, entryLength, dataOffset); + } finally { + closeSync(fd); + } + } else { + const archivePath = vpkPath.replace(/_dir\.vpk$/i, `_${String(archiveIndex).padStart(3, '0')}.vpk`); + const afd = openSync(archivePath, 'r'); + try { + if (!isPlausibleVpkLength(entryLength, fstatSync(afd).size, entryOffset, MAX_VPK_ENTRY_SIZE)) { + throw new Error(`implausible entry length ${entryLength} for ${entryPath}`); + } + archiveData = Buffer.alloc(entryLength); + readSync(afd, archiveData, 0, entryLength, entryOffset); + } finally { + closeSync(afd); + } } + return Buffer.concat([preload, archiveData]); + } catch (error) { console.warn(`[readVpkEntryBytes] Error reading ${entryPath} from ${vpkPath}:`, error); return null; } From ac8a0da0fb573a2e84d5243dc2e57a6f638b94c5 Mon Sep 17 00:00:00 2001 From: slush97 Date: Sat, 3 Oct 2026 11:23:47 -0700 Subject: [PATCH 02/13] fix(viewer): average albedo from a 256px off-thread decode decodedAlbedoAverage drew every pbr.vfx albedo into a canvas at up to 2048px on the UI thread. That was a second full decode of each texture (the GPU upload does not reuse it) plus a Math.pow per pixel, 75 to 90 percent of model load time. Decode the glTF image source at 256px with createImageBitmap instead, point-sampled so the mean stays in linear space (max error 0.0018 against full resolution), with an sRGB lookup table. The canvas path remains as the fallback. --- src/lib/source2ColorCorrection.ts | 111 +++++++++++++++++++++++------- src/lib/source2NprMaterial.ts | 10 ++- 2 files changed, 94 insertions(+), 27 deletions(-) diff --git a/src/lib/source2ColorCorrection.ts b/src/lib/source2ColorCorrection.ts index 9b59020b..3750d92b 100644 --- a/src/lib/source2ColorCorrection.ts +++ b/src/lib/source2ColorCorrection.ts @@ -65,34 +65,97 @@ export function source2TintPlan(morphic: MorphicExtras, factor: THREE.Color): So return { matrix, offset, ownsExportedFactor, linearTint }; } +// An average pivot does not need every texel. Point-sampling 256 px on the long +// edge stays within ~0.001 of the full-resolution linear mean on hero albedos; +// a filtered downscale averages in sRGB first and drifts ~0.01. +const ALBEDO_SAMPLE_EDGE = 256; +const SRGB_BYTE_TO_LINEAR = Float32Array.from({ length: 256 }, (_, i) => + new THREE.Color().setRGB(i / 255, 0, 0).convertSRGBToLinear().r); +// Captured at module load: loadGltfPreview hides createImageBitmap from the +// GLTFLoader while a model loads, which is exactly when the averages run. +const nativeCreateImageBitmap = typeof createImageBitmap === 'function' ? createImageBitmap : undefined; + +function sampleSize(width: number, height: number): { width: number; height: number } { + const scale = Math.min(1, ALBEDO_SAMPLE_EDGE / Math.max(width, height)); + return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }; +} + +function readCanvasPixels(source: CanvasImageSource, width: number, height: number): Uint8ClampedArray | null { + if (typeof OffscreenCanvas === 'undefined') return null; + const context = new OffscreenCanvas(width, height).getContext('2d', { willReadFrequently: true }); + if (!context) return null; + context.imageSmoothingEnabled = false; + context.drawImage(source, 0, 0, width, height); + return context.getImageData(0, 0, width, height).data; +} + +function averagePixels(pixels: ArrayLike, width: number, height: number, srgb: boolean): THREE.Vector3 | null { + if (pixels.length < width * height * 4) return null; + // Centered grid sample: a flat stride aliases with row width. + const stepX = Math.max(1, Math.floor(width / ALBEDO_SAMPLE_EDGE)); + const stepY = Math.max(1, Math.floor(height / ALBEDO_SAMPLE_EDGE)); + const floatData = pixels instanceof Float32Array || pixels instanceof Float64Array; + const color = new THREE.Color(); + const sum = new THREE.Vector3(); + let samples = 0; + for (let y = stepY >> 1; y < height; y += stepY) { + for (let x = stepX >> 1; x < width; x += stepX, samples++) { + const i = (y * width + x) * 4; + if (floatData) { + color.setRGB(pixels[i], pixels[i + 1], pixels[i + 2]); + if (srgb) color.convertSRGBToLinear(); + sum.x += color.r; sum.y += color.g; sum.z += color.b; + } else if (srgb) { + sum.x += SRGB_BYTE_TO_LINEAR[pixels[i]]; sum.y += SRGB_BYTE_TO_LINEAR[pixels[i + 1]]; sum.z += SRGB_BYTE_TO_LINEAR[pixels[i + 2]]; + } else { + sum.x += pixels[i] / 255; sum.y += pixels[i + 1] / 255; sum.z += pixels[i + 2] / 255; + } + } + } + return samples ? sum.divideScalar(samples) : null; +} + /** Decoded top-mip fallback only. It cannot reproduce a compiled VTEX header's * reflectivity exactly, but avoids an invented fixed gray pivot for legacy GLBs. */ export function decodedAlbedoAverage(texture: THREE.Texture | null): THREE.Vector3 | null { const image = texture?.image as { width: number; height: number; data?: ArrayLike } | undefined; if (!image?.width || !image.height) return null; - let pixels: ArrayLike | undefined = image.data; - if (!pixels) { - if (typeof OffscreenCanvas === 'undefined') return null; + const srgb = texture?.colorSpace === THREE.SRGBColorSpace; + if (image.data) return averagePixels(image.data, image.width, image.height, srgb); + const size = sampleSize(image.width, image.height); + try { + const pixels = readCanvasPixels(image as unknown as CanvasImageSource, size.width, size.height); + return pixels ? averagePixels(pixels, size.width, size.height, srgb) : null; + } catch { return null; } +} + +interface GltfImageSource { + json: { textures?: { source?: number }[]; images?: { bufferView?: number; mimeType?: string }[] }; + associations: Map; + getDependency(type: 'bufferView', index: number): Promise; +} + +/** Same average from the texture's encoded glTF source, decoded at sample size + * off the UI thread. The decoded the loader holds is not reused by the + * GPU upload, so drawing it into a canvas here decoded every albedo twice. */ +export async function sourceAlbedoAverage(parser: GltfImageSource, texture: THREE.Texture | null): Promise { + const image = texture?.image as { width?: number; height?: number } | undefined; + if (!texture || !nativeCreateImageBitmap || !image?.width || !image.height) return null; + const index = parser.associations.get(texture)?.textures; + const source = index === undefined ? undefined : parser.json.textures?.[index]?.source; + const def = source === undefined ? undefined : parser.json.images?.[source]; + if (def?.bufferView === undefined || !def.mimeType) return null; + try { + const bytes = await parser.getDependency('bufferView', def.bufferView); + const size = sampleSize(image.width, image.height); + const bitmap = await nativeCreateImageBitmap(new Blob([bytes], { type: def.mimeType }), { + resizeWidth: size.width, resizeHeight: size.height, resizeQuality: 'pixelated', + }); try { - // Bounded fallback for unusually large atlases. Downsampling is approximate. - const scale = Math.min(1, 2048 / Math.max(image.width, image.height)); - const canvas = new OffscreenCanvas(Math.max(1, Math.round(image.width * scale)), Math.max(1, Math.round(image.height * scale))); - const context = canvas.getContext('2d', { willReadFrequently: true }); - if (!context) return null; - context.drawImage(image as unknown as CanvasImageSource, 0, 0, canvas.width, canvas.height); - pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; - } catch { return null; } - } - const count = Math.floor(pixels.length / 4); - if (!count) return null; - const sum = new THREE.Vector3(); - const color = new THREE.Color(); - const floatData = pixels instanceof Float32Array || pixels instanceof Float64Array; - const divisor = floatData ? 1 : 255; - for (let i = 0; i < count; i++) { - color.setRGB(pixels[i * 4] / divisor, pixels[i * 4 + 1] / divisor, pixels[i * 4 + 2] / divisor); - if (texture?.colorSpace === THREE.SRGBColorSpace) color.convertSRGBToLinear(); - sum.x += color.r; sum.y += color.g; sum.z += color.b; - } - return sum.divideScalar(count); + const pixels = readCanvasPixels(bitmap, size.width, size.height); + return pixels ? averagePixels(pixels, size.width, size.height, texture.colorSpace === THREE.SRGBColorSpace) : null; + } finally { + bitmap.close(); + } + } catch { return null; } } diff --git a/src/lib/source2NprMaterial.ts b/src/lib/source2NprMaterial.ts index e14db311..31f4603f 100644 --- a/src/lib/source2NprMaterial.ts +++ b/src/lib/source2NprMaterial.ts @@ -3,7 +3,7 @@ import CustomShaderMaterial, { type CSMPatchMap } from 'three-custom-shader-mate import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'; // Shared blend-mode resolver (the cycle-free leaf of the source2Preview core). import { resolveBlendMode } from './source2Preview/blendMode'; -import { decodedAlbedoAverage, source2TintPlan, SOURCE2_SATURATION_WEIGHTS } from './source2ColorCorrection'; +import { decodedAlbedoAverage, source2TintPlan, sourceAlbedoAverage, SOURCE2_SATURATION_WEIGHTS } from './source2ColorCorrection'; /** * Source 2 NPR (cel / rim / tint) restyle for the Locker hero preview. @@ -415,6 +415,7 @@ export async function resolveMorphicTextures(gltf: GLTF): Promise { (Array.isArray(mat) ? mat : [mat]).forEach((m) => materials.add(m)); }); + const averages: Promise[] = []; for (const material of materials) { const morphic = getMorphic(material); if (!morphic || morphic.shader.toLowerCase() !== 'pbr.vfx') continue; @@ -423,10 +424,13 @@ export async function resolveMorphicTextures(gltf: GLTF): Promise { const tint = source2TintPlan(morphic, standard.color); if (tint.ownsExportedFactor) standard.color.copy(tint.linearTint); if (!morphic.texture_reflectivity?.g_tColor && !morphic.preview_albedo_average) { - const average = decodedAlbedoAverage(standard.map); - if (average) morphic.preview_albedo_average = average.toArray(); + averages.push(sourceAlbedoAverage(gltf.parser, standard.map).then((average) => { + average ??= decodedAlbedoAverage(standard.map); + if (average) morphic.preview_albedo_average = average.toArray(); + })); } } + await Promise.all(averages); const targets = [...materials].filter((m) => getMorphic(m)?.textures); if (targets.length === 0) return; From ff94acfd2172f2640e1c9ec6ddd770f7af81d6cb Mon Sep 17 00:00:00 2001 From: slush97 Date: Sat, 3 Oct 2026 11:23:47 -0700 Subject: [PATCH 03/13] fix(viewer): decode attachment and clip metadata concurrently Each metadata block is a separate vpkmerge soundevents run and some hero models carry 100+ blocks, all awaited one after another. Run up to 8 at a time; the merge still processes results in block order. --- .../main/services/heroAnimationMetadata.ts | 10 ++++---- electron/main/services/modelAttachments.ts | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/electron/main/services/heroAnimationMetadata.ts b/electron/main/services/heroAnimationMetadata.ts index 26d41ee1..a33b4c18 100644 --- a/electron/main/services/heroAnimationMetadata.ts +++ b/electron/main/services/heroAnimationMetadata.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import { join, posix } from 'node:path'; import { tmpdir } from 'node:os'; -import { attachmentMetadataResources } from './modelAttachments'; +import { attachmentMetadataResources, mapConcurrent } from './modelAttachments'; import { readVpkEntryBytes } from './vpk'; import { runVpkmergeStdout } from './modMerger'; import type { HeroAnimationInfo } from '../../../src/lib/heroAnimationCatalog'; @@ -60,8 +60,8 @@ export async function readHeroAnimationMetadata(vpk: string, base: string, entry if (!/^models\/heroes[^/]*\/[a-zA-Z0-9_./-]+\.vmdl_c$/.test(entry) || entry.includes('..')) return metadata; const dir = await fs.mkdtemp(join(tmpdir(), 'grimoire-animation-metadata-')); try { - for (const [index, clip] of clips.slice(0, 8).entries()) { - if (!/^[a-zA-Z0-9_-]+$/.test(clip.name)) continue; + await mapConcurrent(clips.slice(0, 8), 8, async (clip, index) => { + if (!/^[a-zA-Z0-9_-]+$/.test(clip.name)) return; for (const path of heroAnimationMetadataPaths(entry, clip.name)) { const bytes = readVpkEntryBytes(vpk, path) ?? (vpk !== base ? readVpkEntryBytes(base, path) : null); if (!bytes || bytes.length > 2 * 1024 * 1024) continue; @@ -72,13 +72,13 @@ export async function readHeroAnimationMetadata(vpk: string, base: string, entry try { const raw: unknown = JSON.parse(await runVpkmergeStdout(['soundevents', file])); const flags = parseHeroAnimationMetadata(raw, clip); - if (flags) { metadata.set(clip.name, flags); break; } + if (flags) { metadata.set(clip.name, flags); return; } } catch { // Older exporters may not decode this graph generation. The reviewed // catalog remains the fallback; missing flags are not invented. } } - } + }); } finally { await fs.rm(dir, { recursive: true, force: true }); } return metadata; } diff --git a/electron/main/services/modelAttachments.ts b/electron/main/services/modelAttachments.ts index 1e1218ef..ca708c45 100644 --- a/electron/main/services/modelAttachments.ts +++ b/electron/main/services/modelAttachments.ts @@ -11,6 +11,23 @@ const MAX_METADATA_BLOCK_BYTES = 8 * 1024 * 1024; // Embedded mesh LODs can each carry MDAT (the current chessboard model exceeds // 128). Keep the byte budgets as well as a bounded block count. const MAX_METADATA_BLOCKS = 256; +// Each decode is a separate vpkmerge process; run a few at once instead of +// one after another (a hero model can carry 100+ metadata blocks). +const DECODE_CONCURRENCY = 8; + +/** Map with at most `limit` calls in flight; results keep input order. */ +export async function mapConcurrent(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index], index); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} /** Preserve the compiled KV3 bytes, changing only their resource block wrapper. * The pinned CLI's generic KV3 decoder reads DATA from a loose resource. @@ -93,10 +110,12 @@ export async function exportModelAttachments(vpk: string, base: string, entry: s const attachments = new Map(); const conflicts = new Set(); try { - for (let i = 0; i < resources.length; i++) { + const decoded = await mapConcurrent(resources, DECODE_CONCURRENCY, async (resource, i) => { const file = join(dir, `${i}.vsndevts_c`); - await fs.writeFile(file, resources[i]); - const data: unknown = JSON.parse(await runVpkmergeStdout(['soundevents', file])); + await fs.writeFile(file, resource); + return JSON.parse(await runVpkmergeStdout(['soundevents', file])) as unknown; + }); + for (const data of decoded) { for (const attachment of parseModelAttachments(data)) { const existing = attachments.get(attachment.name); if (existing && JSON.stringify(existing) !== JSON.stringify(attachment)) conflicts.add(attachment.name); From 9926b5bd7ebc412f2bcf5cf39fc01aab8368d1db Mon Sep 17 00:00:00 2001 From: slush97 Date: Sat, 3 Oct 2026 11:23:47 -0700 Subject: [PATCH 04/13] fix(viewer): skip the rigged preview for skins whose export drops every clip Some skins list clips that the exporter then omits, so the rigged GLB has no animation. The viewer only learned that after a full GLB load and then exported and loaded the static pose as well, on every open. Count animations from the GLB JSON chunk after export and record a versioned marker so later opens go straight to the static pose. --- electron/main/services/heroPoseModels.test.ts | 14 +++++++ electron/main/services/heroPoseModels.ts | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/electron/main/services/heroPoseModels.test.ts b/electron/main/services/heroPoseModels.test.ts index 4d4e4209..68d624de 100644 --- a/electron/main/services/heroPoseModels.test.ts +++ b/electron/main/services/heroPoseModels.test.ts @@ -166,6 +166,20 @@ describe('rigged preview physics bundle', () => { expect((await getRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false); }); + it('records a clipless rigged export once and skips re-exporting it', async () => { + const json = Buffer.from(JSON.stringify({ asset: { version: '2.0' }, animations: [] }).padEnd(48, ' ')); + const glb = Buffer.alloc(20 + json.length); + glb.writeUInt32LE(0x46546c67, 0); glb.writeUInt32LE(2, 4); glb.writeUInt32LE(glb.length, 8); + glb.writeUInt32LE(json.length, 12); glb.writeUInt32LE(0x4e4f534a, 16); json.copy(glb, 20); + h.run.mockImplementation(async (args) => fs.writeFile(argument(args, '--out'), glb)); + expect((await exportRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false); + expect(h.stdout.mock.calls.filter(([args]) => args[1] === 'femodel')).toHaveLength(0); + const spawns = h.run.mock.calls.length + h.stdout.mock.calls.length; + expect((await getRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false); + expect((await exportRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false); + expect(h.run.mock.calls.length + h.stdout.mock.calls.length).toBe(spawns); + }); + it('preserves a current rigged-only cache when sweeping old static entries', async () => { const info = await exportRiggedHeroPose(game(), 'Yamato'); await sweepHeroPoseCache(); diff --git a/electron/main/services/heroPoseModels.ts b/electron/main/services/heroPoseModels.ts index 6c5114e3..0ac04dfb 100644 --- a/electron/main/services/heroPoseModels.ts +++ b/electron/main/services/heroPoseModels.ts @@ -347,6 +347,39 @@ function riggedVersionFile(key: string): string { return join(modelDir(key), RIGGED_VERSION_FILENAME); } +// Some skins list clips that the exporter then drops, leaving a rigged GLB with +// no animation. The viewer can only discover that after a full GLB load, so +// record it once and let the preview go straight to the static pose. +const RIGGED_NO_CLIPS_FILENAME = '.rigged-no-clips'; + +function riggedNoClipsFile(key: string): string { + return join(modelDir(key), RIGGED_NO_CLIPS_FILENAME); +} + +async function hasNoRiggedClips(key: string): Promise { + const marker = await fs.readFile(riggedNoClipsFile(key), 'utf8').catch(() => ''); + return marker.trim() === RIGGED_CACHE_VERSION; +} + +/** Animation count from a GLB's JSON chunk, without reading the binary chunk. + * Null when the file is not a readable GLB, so only a proven zero is recorded. */ +async function glbAnimationCount(file: string): Promise { + const handle = await fs.open(file, 'r'); + try { + const header = Buffer.alloc(20); + const { bytesRead } = await handle.read(header, 0, 20, 0); + if (bytesRead < 20 || header.readUInt32LE(0) !== 0x46546c67 || header.readUInt32LE(16) !== 0x4e4f534a) return null; + const json = Buffer.alloc(header.readUInt32LE(12)); + await handle.read(json, 0, json.length, 20); + const parsed = JSON.parse(json.toString('utf8')) as { animations?: unknown[] }; + return Array.isArray(parsed.animations) ? parsed.animations.length : 0; + } catch { + return null; + } finally { + await handle.close(); + } +} + /** * Curated per-hero ambient idle effect (`.vpcf_c`), keyed by display name. The * effects-preview axis is a hand-validated roster, NOT auto-discovered (the raw @@ -778,6 +811,7 @@ export async function getHeroPoseInfo( async function infoForRiggedKey(key: string): Promise { try { + if (await hasNoRiggedClips(key)) return { hasModel: false, mtimeMs: null, key }; const stat = await fs.stat(riggedModelFile(key)); const version = await fs.readFile(riggedVersionFile(key), 'utf8').catch(() => ''); if (version.trim() !== RIGGED_CACHE_VERSION) { @@ -940,6 +974,8 @@ export async function exportRiggedHeroPose( const requestKey = poseKey(heroName, normalized); const existing = inFlightRiggedExports.get(requestKey); if (existing) return existing; + const cachedKey = await resolvePoseKey(deadlockPath, heroName, normalized); + if (await hasNoRiggedClips(cachedKey)) return { hasModel: false, mtimeMs: null, key: cachedKey }; const work = runRiggedHeroExport(deadlockPath, heroName, normalized, fallbackSkinMetaKey); inFlightRiggedExports.set(requestKey, work); @@ -1020,6 +1056,11 @@ async function runRiggedHeroExportForSources( '--out', out, ]); + if (await glbAnimationCount(out) === 0) { + await fs.writeFile(riggedNoClipsFile(key), RIGGED_CACHE_VERSION); + return { hasModel: false, mtimeMs: null, key }; + } + await fs.rm(riggedNoClipsFile(key), { force: true }); let cloth: unknown = null; try { cloth = JSON.parse(await runVpkmergeStdout([ @@ -1046,6 +1087,7 @@ async function runRiggedHeroExportForSources( } } if (listedAny && !foundUsableClip) { + await fs.writeFile(riggedNoClipsFile(key), RIGGED_CACHE_VERSION); return { hasModel: false, mtimeMs: null, key }; } throw lastError instanceof Error From 1738ddb59142707ab508d79630cacbcf7efc919a Mon Sep 17 00:00:00 2001 From: slush97 Date: Sat, 3 Oct 2026 11:23:47 -0700 Subject: [PATCH 05/13] fix(viewer): write cloth bones parent-first without subtree updates Every bone write called updateMatrixWorld on its whole subtree, so a chain cost O(L^2) per step, and writeBack re-walked ancestors and re-sorted by depth each frame. Write parent-first updating only the written bone's matrix, memoize the ancestor checks, cache depths at build time and reuse scratch vectors. Bit-identical to the previous solver on the Dynamo fixture; falls back to the old path when any bone opts out of matrix auto-update. --- src/lib/useClothSim.ts | 161 ++++++++++++++++++++++++++++++++++------- 1 file changed, 136 insertions(+), 25 deletions(-) diff --git a/src/lib/useClothSim.ts b/src/lib/useClothSim.ts index 1f75e3e0..2215cec7 100644 --- a/src/lib/useClothSim.ts +++ b/src/lib/useClothSim.ts @@ -643,6 +643,11 @@ interface ClothRuntime { simulationSteps: number; recoveryCount: number; teleportDistance: number; + /** Scene depth of each node bone; bones are not reparented during a runtime. */ + boneDepth: Map; + /** Every node bone and its ancestors auto-update their matrices, so the + * incremental world-matrix paths are exact. */ + fastBoneWrites: boolean; } export interface ClothSimulationCoverage { @@ -771,6 +776,78 @@ function writeBoneQuaternion(root: THREE.Object3D, rt: ClothRuntime, bone: THREE setBoneWorldQuaternion(bone, modelToWorldQuat(root, rt, q)); } +const _writeWorld = new THREE.Vector3(); +const _writeInverse = new THREE.Matrix4(); +const _writeWorldQuat = new THREE.Quaternion(); +const _writeParentQuat = new THREE.Quaternion(); +const _writeModelQuat = new THREE.Quaternion(); +const _decomposePos = new THREE.Vector3(); +const _decomposeScale = new THREE.Vector3(); + +// Same arithmetic as setBoneWorldPosition/setBoneWorldQuaternion, without the +// per-write subtree recursion. Writes run parent-first, so a parent written +// earlier in the same pass (in `fresh`) already reflects every ancestor write; +// any other parent gets the same full ancestor walk. The trailing +// root.updateWorldMatrix(true, true) refreshes the rest of the tree. +function freshParent(bone: THREE.Bone, fresh: Set): THREE.Object3D | null { + const parent = bone.parent; + if (parent && !fresh.has(parent)) parent.updateWorldMatrix(true, false); + return parent; +} + +function writeBonePositionFresh( + root: THREE.Object3D, rt: ClothRuntime, bone: THREE.Bone, pos: THREE.Vector3, fresh: Set, +): void { + const local = _writeWorld.copy(pos).applyMatrix4(rt.modelToRoot).applyMatrix4(root.matrixWorld); + const parent = freshParent(bone, fresh); + if (parent) local.applyMatrix4(_writeInverse.copy(parent.matrixWorld).invert()); + bone.position.copy(local); + bone.updateWorldMatrix(false, false); + fresh.add(bone); +} + +function writeBoneQuaternionFresh( + rootQuat: THREE.Quaternion, rt: ClothRuntime, bone: THREE.Bone, q: THREE.Quaternion, fresh: Set, +): void { + const world = _writeWorldQuat.copy(rootQuat).multiply(_writeModelQuat.copy(rt.modelToRootRot).multiply(q)).normalize(); + const parent = freshParent(bone, fresh); + if (parent) parent.matrixWorld.decompose(_decomposePos, _writeParentQuat, _decomposeScale); + else _writeParentQuat.identity(); + bone.quaternion.copy(_writeParentQuat.invert().multiply(world).normalize()); + bone.updateWorldMatrix(false, false); + fresh.add(bone); +} + +/** Memoized `start` or any ancestor satisfies `matches`. Valid while the + * matched set only grows by objects that already have a matching ancestor. */ +function ancestorMatches( + start: THREE.Object3D | null, matches: (obj: THREE.Object3D) => boolean, memo: Map, +): boolean { + const path: THREE.Object3D[] = []; + let result = false; + for (let obj = start; obj; obj = obj.parent) { + const known = memo.get(obj); + if (known !== undefined) { + result = known; + break; + } + path.push(obj); + if (matches(obj)) { + result = true; + break; + } + } + for (const obj of path) memo.set(obj, result); + return result; +} + +function autoUpdatedChain(bone: THREE.Object3D): boolean { + for (let obj: THREE.Object3D | null = bone; obj; obj = obj.parent) { + if (!obj.matrixAutoUpdate || !obj.matrixWorldAutoUpdate) return false; + } + return true; +} + function canCollide(node: Pick, rigid: ColliderFilterRuntime): boolean { if (rigid.vertexNodes !== null) return rigid.vertexNodes.has(node.index); return (node.collisionMask & rigid.mask) !== 0; @@ -1120,6 +1197,8 @@ function buildRuntime(root: THREE.Object3D, model: ClothModel): ClothRuntime | n // Source units. Ordinary animation remains untouched; discontinuous body // motion spanning multiple rest-pose bounds starts a new drape. teleportDistance: Math.max(128, new THREE.Box3().setFromPoints(nodes.map((node) => vec3(node.initPos))).getSize(new THREE.Vector3()).length() * 4), + boneDepth: new Map(nodes.flatMap((node) => (node.bone ? [[node.bone, objectDepth(node.bone)] as const] : []))), + fastBoneWrites: nodes.every((node) => !node.bone || autoUpdatedChain(node.bone)), }; } @@ -1133,6 +1212,12 @@ function restoreAnimationPose(rt: ClothRuntime): void { rt.writtenBones.clear(); } +const _refreshRootInverse = new THREE.Matrix4(); +const _refreshRootQuat = new THREE.Quaternion(); +const _refreshTarget = new THREE.Vector3(); +const _refreshBoneQuat = new THREE.Quaternion(); +const _refreshRotation = new THREE.Quaternion(); +const _refreshRelative = new THREE.Quaternion(); function refreshTargets(root: THREE.Object3D, rt: ClothRuntime): void { // Capture after the mixer update. Unkeyed channels must start the next update // from this clean pose, while body anchors remain owned by animation. @@ -1149,6 +1234,10 @@ function refreshTargets(root: THREE.Object3D, rt: ClothRuntime): void { node.animationScale?.copy(node.bone.scale); } root.updateWorldMatrix(true, true); + // The full update above leaves every node bone's matrixWorld current, so the + // fast path reads it directly instead of re-walking ancestors per node. + const rootInverse = _refreshRootInverse.copy(root.matrixWorld).invert(); + const rootInverseQuat = root.getWorldQuaternion(_refreshRootQuat).invert(); for (const node of rt.nodes) { if (!node.bone) { @@ -1156,8 +1245,16 @@ function refreshTargets(root: THREE.Object3D, rt: ClothRuntime): void { node.targetRot.copy(quat(node.initRot)); continue; } - const target = worldToModelPos(root, rt, node.bone.getWorldPosition(new THREE.Vector3())); - const rotation = worldToModelQuat(root, rt, node.bone.getWorldQuaternion(new THREE.Quaternion())); + let target: THREE.Vector3; + let rotation: THREE.Quaternion; + if (rt.fastBoneWrites) { + target = _refreshTarget.setFromMatrixPosition(node.bone.matrixWorld).applyMatrix4(rootInverse).applyMatrix4(rt.rootToModel); + node.bone.matrixWorld.decompose(_decomposePos, _refreshBoneQuat, _decomposeScale); + rotation = _refreshRotation.copy(rt.rootToModelRot).multiply(_refreshRelative.copy(rootInverseQuat).multiply(_refreshBoneQuat)).normalize(); + } else { + target = worldToModelPos(root, rt, node.bone.getWorldPosition(new THREE.Vector3())); + rotation = worldToModelQuat(root, rt, node.bone.getWorldQuaternion(new THREE.Quaternion())); + } if (!finiteVector(target) || !finiteRotation(rotation)) continue; if (rt.warmStarted && node.kinematic && target.distanceTo(node.target) > rt.teleportDistance) { resetRuntimeHistory(rt); @@ -1241,6 +1338,7 @@ function warmStartRuntime(rt: ClothRuntime, substepDt: number): void { rt.warmStarted = true; } +const _integrateVelocity = new THREE.Vector3(); function integrate(rt: ClothRuntime, gravity: THREE.Vector3, dt: number): void { const dt2 = dt * dt; const lastDt = rt.lastSubstepDt; @@ -1254,7 +1352,7 @@ function integrate(rt: ClothRuntime, gravity: THREE.Vector3, dt: number): void { const damping = node.integratorMode === 'unknown' ? Math.max(node.damping, MIN_VELOCITY_DAMPING) : Math.max(0, node.damping * dt); - const velocity = node.pos.clone().sub(node.prev).multiplyScalar( + const velocity = _integrateVelocity.copy(node.pos).sub(node.prev).multiplyScalar( verletVelocityScale(dt, lastDt, damping), ); node.prev.copy(node.pos); @@ -1294,6 +1392,7 @@ function solveGoalDampedNodes(rt: ClothRuntime): void { } } +const _rodDelta = new THREE.Vector3(); function solveFixedRods(rt: ClothRuntime): void { if (rt.model.rodBatches.length > 0) { for (const batch of rt.model.rodBatches) projectRodBatch(rt.nodes, batch); @@ -1303,7 +1402,7 @@ function solveFixedRods(rt: ClothRuntime): void { const a = rt.nodes[rod.a]; const b = rt.nodes[rod.b]; if (!a || !b) continue; - const delta = b.pos.clone().sub(a.pos); + const delta = _rodDelta.copy(b.pos).sub(a.pos); const d = delta.length(); if (d < 1e-6) continue; let wanted = d; @@ -1396,6 +1495,7 @@ function solveRods(rt: ClothRuntime): void { for (const batch of rt.animatedRodBatches) projectRodBatch(rt.nodes, batch); } +const _writeRootQuat = new THREE.Quaternion(); function writeBack(root: THREE.Object3D, rt: ClothRuntime): void { recoverInvalidSolverState(rt); updateSolvedRotations(rt); @@ -1425,31 +1525,41 @@ function writeBack(root: THREE.Object3D, rt: ClothRuntime): void { for (const [bone, transform] of fitWrites) rotationWrites.set(bone, transform.rotation); + // A bone added below already has a rotated ancestor, so growing the set + // during the loop cannot change any memoized answer. + const rotatedAncestorMemo = new Map(); + const isRotatedBone = (obj: THREE.Object3D) => obj instanceof THREE.Bone && rotationWrites.has(obj); for (const node of rt.nodes) { if (!node.bone || rotationWrites.has(node.bone)) continue; - for (let parent = node.bone.parent; parent; parent = parent.parent) { - if (parent instanceof THREE.Bone && rotationWrites.has(parent)) { - rotationWrites.set(node.bone, node.targetRot.clone()); - break; - } + if (ancestorMatches(node.bone.parent, isRotatedBone, rotatedAncestorMemo)) { + rotationWrites.set(node.bone, node.targetRot.clone()); } } - for (const [bone, rot] of orderBonesParentFirst(rotationWrites.entries())) { - writeBoneQuaternion(root, rt, bone, rot); - rt.writtenBones.add(bone); + const depthOf = (bone: THREE.Object3D) => rt.boneDepth.get(bone) ?? objectDepth(bone); + const rotationOrder = [...rotationWrites.entries()].sort(([a], [b]) => depthOf(a) - depthOf(b)); + if (rt.fastBoneWrites) { + const rootQuat = root.getWorldQuaternion(_writeRootQuat); + const fresh = new Set(); + for (const [bone, rot] of rotationOrder) { + writeBoneQuaternionFresh(rootQuat, rt, bone, rot, fresh); + rt.writtenBones.add(bone); + } + } else { + for (const [bone, rot] of rotationOrder) { + writeBoneQuaternion(root, rt, bone, rot); + rt.writtenBones.add(bone); + } } const movedBones = new Set(rt.nodes.filter((node) => (!node.kinematic || node.generatedTarget) && node.bone).map((node) => node.bone)); const positionWrites = new Map(); + const ownerMemo = new Map(); + const isSimulatedOwner = (obj: THREE.Object3D) => obj instanceof THREE.Bone && (rotationWrites.has(obj) || movedBones.has(obj)); for (const node of rt.nodes) { if (!node.bone) continue; - if (node.kinematic && !node.generatedTarget) { - // A simulated ancestor must not carry an animation-owned attachment away - // from its sampled world position, even when that ancestor only translates. - let parent: THREE.Object3D | null = node.bone; - while (parent && !(parent instanceof THREE.Bone && (rotationWrites.has(parent) || movedBones.has(parent)))) parent = parent.parent; - if (!parent) continue; - } + // A simulated ancestor must not carry an animation-owned attachment away + // from its sampled world position, even when that ancestor only translates. + if (node.kinematic && !node.generatedTarget && !ancestorMatches(node.bone, isSimulatedOwner, ownerMemo)) continue; positionWrites.set(node.bone, node.pos.clone()); } @@ -1464,12 +1574,13 @@ function writeBack(root: THREE.Object3D, rt: ClothRuntime): void { for (const [bone, transform] of fitWrites) positionWrites.set(bone, transform.position); - [...positionWrites.entries()] - .sort(([a], [b]) => objectDepth(a) - objectDepth(b)) - .forEach(([bone, pos]) => { - writeBonePosition(root, rt, bone, pos); - rt.writtenBones.add(bone); - }); + const positionOrder = [...positionWrites.entries()].sort(([a], [b]) => depthOf(a) - depthOf(b)); + const fresh = new Set(); + for (const [bone, pos] of positionOrder) { + if (rt.fastBoneWrites) writeBonePositionFresh(root, rt, bone, pos, fresh); + else writeBonePosition(root, rt, bone, pos); + rt.writtenBones.add(bone); + } root.updateWorldMatrix(true, true); } From 8aa19bcbbad7dd053d528660737fbcce69ed2ab0 Mon Sep 17 00:00:00 2001 From: slush97 Date: Sun, 4 Oct 2026 04:05:26 -0700 Subject: [PATCH 06/13] fix(viewer): decode rigged physics and attachments while the model exports --- electron/main/services/heroPoseModels.test.ts | 5 +-- electron/main/services/heroPoseModels.ts | 36 +++++++++---------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/electron/main/services/heroPoseModels.test.ts b/electron/main/services/heroPoseModels.test.ts index 68d624de..d789741a 100644 --- a/electron/main/services/heroPoseModels.test.ts +++ b/electron/main/services/heroPoseModels.test.ts @@ -139,7 +139,8 @@ describe('rigged preview physics bundle', () => { expect(info.key).toContain('::one_dir.vpk::'); const cloth = JSON.parse(await fs.readFile(join(cacheDir(info.key), 'cloth-rigged.json'), 'utf8')); expect(cloth.source).toBe(join(addons, 'one_dir.vpk')); - expect(h.stdout.mock.calls.filter(([args]) => args[1] === 'femodel')).toHaveLength(1); + const physics = h.stdout.mock.calls.filter(([args]) => args[1] === 'femodel'); + expect(argument(physics.at(-1)![0], '--vpk')).toBe(join(addons, 'one_dir.vpk')); }); it('retains animation with an explicit null sidecar when physics extraction fails', async () => { @@ -173,7 +174,7 @@ describe('rigged preview physics bundle', () => { glb.writeUInt32LE(json.length, 12); glb.writeUInt32LE(0x4e4f534a, 16); json.copy(glb, 20); h.run.mockImplementation(async (args) => fs.writeFile(argument(args, '--out'), glb)); expect((await exportRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false); - expect(h.stdout.mock.calls.filter(([args]) => args[1] === 'femodel')).toHaveLength(0); + expect(await fs.readFile(join(cacheDir((await getRiggedHeroPose(game(), 'Yamato')).key), 'cloth-rigged.json'), 'utf8').catch(() => null)).toBeNull(); const spawns = h.run.mock.calls.length + h.stdout.mock.calls.length; expect((await getRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false); expect((await exportRiggedHeroPose(game(), 'Yamato')).hasModel).toBe(false); diff --git a/electron/main/services/heroPoseModels.ts b/electron/main/services/heroPoseModels.ts index 0ac04dfb..c6f0819d 100644 --- a/electron/main/services/heroPoseModels.ts +++ b/electron/main/services/heroPoseModels.ts @@ -1041,6 +1041,21 @@ async function runRiggedHeroExportForSources( if (!clips.length) continue; foundUsableClip = true; + // Physics and attachments read the model entry, not the exported GLB, + // so they decode while the export runs instead of after it. + const cloth = runVpkmergeStdout(['model', 'femodel', '--vpk', source.vpk, ...selector, '--base', pak01]) + .then((json): unknown => JSON.parse(json)) + .catch((error: unknown) => { + console.warn('[heroPoseModels] rigged physics unavailable:', heroName, error); + return null; + }); + const entryIndex = selector.indexOf('--entry'); + const attachments = entryIndex >= 0 && selector[entryIndex + 1] + ? exportModelAttachments(source.vpk, pak01, selector[entryIndex + 1]).catch((error: unknown) => { + console.warn('[heroPoseModels] rigged attachments unavailable:', heroName, error); + return []; + }) + : Promise.resolve([]); try { await fs.rm(riggedVersionFile(key), { force: true }); await runVpkmerge([ @@ -1061,25 +1076,8 @@ async function runRiggedHeroExportForSources( return { hasModel: false, mtimeMs: null, key }; } await fs.rm(riggedNoClipsFile(key), { force: true }); - let cloth: unknown = null; - try { - cloth = JSON.parse(await runVpkmergeStdout([ - 'model', 'femodel', '--vpk', source.vpk, ...selector, '--base', pak01, - ])); - } catch (error) { - console.warn('[heroPoseModels] rigged physics unavailable:', heroName, error); - } - await fs.writeFile(riggedClothFile(key), JSON.stringify(cloth)); - let attachments: Awaited> = []; - const entryIndex = selector.indexOf('--entry'); - if (entryIndex >= 0 && selector[entryIndex + 1]) { - try { - attachments = await exportModelAttachments(source.vpk, pak01, selector[entryIndex + 1]); - } catch (error) { - console.warn('[heroPoseModels] rigged attachments unavailable:', heroName, error); - } - } - await fs.writeFile(join(dir, RIGGED_ATTACHMENTS_FILENAME), JSON.stringify(attachments)); + await fs.writeFile(riggedClothFile(key), JSON.stringify(await cloth)); + await fs.writeFile(join(dir, RIGGED_ATTACHMENTS_FILENAME), JSON.stringify(await attachments)); await fs.writeFile(riggedVersionFile(key), RIGGED_CACHE_VERSION); return infoForRiggedKey(key); } catch (err) { From 70ded527ef6599bbb6413fe089d20edd391d8aa1 Mon Sep 17 00:00:00 2001 From: slush97 Date: Sun, 4 Oct 2026 03:41:36 -0700 Subject: [PATCH 07/13] perf(locker): load the 3D hero preview faster The preview GLB embedded every texture at full size (35-70 MB, up to 4096 px), and GLTFLoader was forced onto decoding, which decodes synchronously on the main thread at the first render: drifter took 1.4 s to load with a 1.3 s UI freeze, after a 1 s export on a cache miss. - Export pose and rigged GLBs with `--max-texture 1024` (drifter: 39 -> 14 MB). Pose cache v15 and rigged v8 so old full-size GLBs regenerate. - The hero viewer decodes with ImageBitmap, off the main thread; frames render pixel-identical to the path. Other loads (soul-container grid, import modals) keep since ImageBitmaps pin their pixels in renderer memory and the grid shows many models at once. - The fallback is a per-parser GLTFLoader plugin instead of hiding createImageBitmap globally, which would break a concurrent ImageBitmap load. Scene disposal now closes ImageBitmaps and frees every texture a material holds (sheen maps were missed). In app: 0.72 s cold, ~0.6 s cached, longest main-thread block ~120 ms. Needs a vpkmerge release with `--max-texture` (Slush97/vpkmerge#48) before the bundled v0.19.1 binary is bumped. --- electron/main/services/heroPoseModels.ts | 24 ++++- src/components/locker/HeroPoseViewer.tsx | 13 +-- src/components/locker/soulModel.ts | 9 +- src/lib/loadGltfPreview.test.ts | 103 ++++++++++++++------ src/lib/loadGltfPreview.ts | 117 +++++++++++------------ src/lib/loadRiggedHeroPreview.ts | 2 +- 6 files changed, 163 insertions(+), 105 deletions(-) diff --git a/electron/main/services/heroPoseModels.ts b/electron/main/services/heroPoseModels.ts index c6f0819d..55fe31ee 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'; @@ -933,6 +949,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, ]); @@ -1068,6 +1086,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.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}`), ]); From ee492863312f24396051d23ea0ad50365b6d5fc8 Mon Sep 17 00:00:00 2001 From: slush97 Date: Sun, 4 Oct 2026 04:30:58 -0700 Subject: [PATCH 08/13] test(locker): expect ImageBitmap decode in the rigged preview loader --- src/lib/loadRiggedHeroPreview.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 2d37e4d631b5d81253f3fe23e3194f91c4db954c Mon Sep 17 00:00:00 2001 From: slush97 Date: Thu, 8 Oct 2026 22:17:52 -0700 Subject: [PATCH 09/13] fix(viewer): dispose the hero scene when it leaves the Canvas The loader effect's cleanup disposed the old scene while the Canvas was still mounted, so a toggle that re-ran the effect (animated, cloth) left one frame drawing textures whose ImageBitmaps were already closed. Dispose in a [scene] effect instead, in the commit that drops the scene. --- src/components/locker/HeroPoseViewer.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/locker/HeroPoseViewer.tsx b/src/components/locker/HeroPoseViewer.tsx index 226fb16e..85fb7a05 100644 --- a/src/components/locker/HeroPoseViewer.tsx +++ b/src/components/locker/HeroPoseViewer.tsx @@ -1078,7 +1078,6 @@ export default function HeroPoseViewer({ // The caller remounts this component (via a hero+skin `key`) when the // selection changes, so initial state is already fresh here. let cancelled = false; - let loaded: THREE.Object3D | null = null; setFailed(false); setScene(null); setClips([]); @@ -1112,7 +1111,6 @@ export default function HeroPoseViewer({ disposeScene(gltf.scene); throw new Error('Rigged preview GLB has no animated clip.'); } - loaded = gltf.scene; setClips((gltf.animations ?? []).filter((c) => Number.isFinite(c.duration) && c.duration > 0.001)); setClipName((current) => gltf.animations.some((candidate) => candidate.name === current && Number.isFinite(candidate.duration) && candidate.duration > 0.001) ? current : clip.name); @@ -1146,7 +1144,6 @@ export default function HeroPoseViewer({ disposeScene(gltf.scene); return; } - loaded = gltf.scene; setRigged(false); setClips([]); setClothModel(null); // static path has no skeleton; nothing to simulate. @@ -1161,7 +1158,6 @@ export default function HeroPoseViewer({ return () => { cancelled = true; - if (loaded) disposeScene(loaded); }; // `skinSources` is deliberately not a dependency: `sourceKey` already // encodes its contents, and the array reference changes on every parent @@ -1176,6 +1172,14 @@ export default function HeroPoseViewer({ features.clothPreviewEnabled, ]); + // Dispose in the commit that drops the scene from the Canvas. Disposing in the + // loader cleanup left a frame where the still-mounted Canvas re-uploaded + // textures whose ImageBitmaps were already closed. + useEffect(() => { + if (!scene) return; + return () => disposeScene(scene); + }, [scene]); + useEffect(() => { if (!scene || !features.nprDebugEnabled) return; console.info('[HeroPoseViewer] NPR material summary', heroName, summarizeNprScene(scene)); From bfdb449c12616accf0139879b505ae411f0475de Mon Sep 17 00:00:00 2001 From: slush97 Date: Thu, 8 Oct 2026 22:17:52 -0700 Subject: [PATCH 10/13] perf(viewer): fetch preview GLBs with one arrayBuffer read GLTFLoader.load goes through FileLoader, which re-streams every response chunk through JS to report progress. Fetch the GLB with one native arrayBuffer() and hand it to parseAsync. --- src/lib/loadGltfPreview.test.ts | 19 ++++++++++++------- src/lib/loadGltfPreview.ts | 26 ++++++++++++-------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/lib/loadGltfPreview.test.ts b/src/lib/loadGltfPreview.test.ts index e7e4f7ba..9f48d64c 100644 --- a/src/lib/loadGltfPreview.test.ts +++ b/src/lib/loadGltfPreview.test.ts @@ -41,14 +41,9 @@ vi.mock('three/examples/jsm/loaders/GLTFLoader.js', () => ({ loaderMock.textureLoaders.push(parser.textureLoader); } - load(_url: string, onLoad: (gltf: GLTF) => void): void { + async parseAsync(_buffer: ArrayBuffer, _path: string): Promise { this.runParser(); - onLoad(fakeGltf()); - } - - parse(_buffer: ArrayBuffer, _path: string, onLoad: (gltf: GLTF) => void): void { - this.runParser(); - onLoad(fakeGltf()); + return fakeGltf(); } }, })); @@ -62,6 +57,8 @@ describe('loadGltfPreview texture decoding', () => { loaderMock.textureLoaders = []; source2Mock.resolveMorphicTextures.mockReset(); source2Mock.resolveMorphicTextures.mockResolvedValue(undefined); + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ArrayBuffer(4)))); + return () => vi.unstubAllGlobals(); }); it('decodes with textures by default, per parser', async () => { @@ -86,6 +83,14 @@ describe('loadGltfPreview texture decoding', () => { expect(loaderMock.textureLoaders).toEqual(['image-bitmap-loader']); expect(source2Mock.resolveMorphicTextures).toHaveBeenCalledWith(gltf); }); + + it('rejects a failed GLB request', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 }))); + const { loadGltfPreview } = await import('./loadGltfPreview'); + + await expect(loadGltfPreview('grimoire-hero://m/missing/model.glb')).rejects.toThrow('404'); + expect(loaderMock.textureLoaders).toEqual([]); + }); }); describe('texture disposal', () => { diff --git a/src/lib/loadGltfPreview.ts b/src/lib/loadGltfPreview.ts index 3e39f9a9..cbcd64be 100644 --- a/src/lib/loadGltfPreview.ts +++ b/src/lib/loadGltfPreview.ts @@ -27,30 +27,28 @@ function previewLoader({ imageBitmaps = false }: GltfPreviewOptions): GLTFLoader return loader; } +/** Fetch a self-contained `.glb` and parse it. One native `arrayBuffer()` read + * instead of GLTFLoader.load, whose FileLoader re-streams every body chunk + * through JS to report progress. */ 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; + const response = await fetch(url); + if (!response.ok) throw new Error(`GLB request failed (${response.status}): ${url}`); + return parseGltfPreview(await response.arrayBuffer(), options); } -/** 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). */ +/** Parse an in-memory `.glb` (ArrayBuffer). Also used directly by the Soul + * Container import preview, which loads the dropped/picked file's bytes before + * any build. Morphic preview texture indices (the only part of the morphic + * contract the stock loader does not surface) resolve while gltf.parser is + * still live; no-op when no material carries them. */ export async function parseGltfPreview( buffer: ArrayBuffer, options: GltfPreviewOptions = {} ): Promise { - const gltf = await new Promise((resolve, reject) => { - previewLoader(options).parse(buffer, '', resolve, reject); - }); + const gltf = await previewLoader(options).parseAsync(buffer, ''); await resolveMorphicTextures(gltf); return gltf; } From 54c41679803c53f6f0301bcf31c1886a04cc0dc3 Mon Sep 17 00:00:00 2001 From: slush97 Date: Thu, 8 Oct 2026 22:17:52 -0700 Subject: [PATCH 11/13] refactor(viewer): drop the captured createImageBitmap in the albedo average loadGltfPreview no longer hides createImageBitmap globally (the fallback swaps a per-parser texture loader), so the module-load capture is dead weight. --- src/lib/source2ColorCorrection.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lib/source2ColorCorrection.ts b/src/lib/source2ColorCorrection.ts index 3750d92b..71c46757 100644 --- a/src/lib/source2ColorCorrection.ts +++ b/src/lib/source2ColorCorrection.ts @@ -71,9 +71,6 @@ export function source2TintPlan(morphic: MorphicExtras, factor: THREE.Color): So const ALBEDO_SAMPLE_EDGE = 256; const SRGB_BYTE_TO_LINEAR = Float32Array.from({ length: 256 }, (_, i) => new THREE.Color().setRGB(i / 255, 0, 0).convertSRGBToLinear().r); -// Captured at module load: loadGltfPreview hides createImageBitmap from the -// GLTFLoader while a model loads, which is exactly when the averages run. -const nativeCreateImageBitmap = typeof createImageBitmap === 'function' ? createImageBitmap : undefined; function sampleSize(width: number, height: number): { width: number; height: number } { const scale = Math.min(1, ALBEDO_SAMPLE_EDGE / Math.max(width, height)); @@ -140,7 +137,7 @@ interface GltfImageSource { * GPU upload, so drawing it into a canvas here decoded every albedo twice. */ export async function sourceAlbedoAverage(parser: GltfImageSource, texture: THREE.Texture | null): Promise { const image = texture?.image as { width?: number; height?: number } | undefined; - if (!texture || !nativeCreateImageBitmap || !image?.width || !image.height) return null; + if (!texture || typeof createImageBitmap !== 'function' || !image?.width || !image.height) return null; const index = parser.associations.get(texture)?.textures; const source = index === undefined ? undefined : parser.json.textures?.[index]?.source; const def = source === undefined ? undefined : parser.json.images?.[source]; @@ -148,7 +145,7 @@ export async function sourceAlbedoAverage(parser: GltfImageSource, texture: THRE try { const bytes = await parser.getDependency('bufferView', def.bufferView); const size = sampleSize(image.width, image.height); - const bitmap = await nativeCreateImageBitmap(new Blob([bytes], { type: def.mimeType }), { + const bitmap = await createImageBitmap(new Blob([bytes], { type: def.mimeType }), { resizeWidth: size.width, resizeHeight: size.height, resizeQuality: 'pixelated', }); try { From 4d1d1a7180bc85b996d06a8fd017a027b37681a8 Mon Sep 17 00:00:00 2001 From: slush97 Date: Thu, 8 Oct 2026 22:44:27 -0700 Subject: [PATCH 12/13] fix(viewer): close races in the rigged export path - exportRiggedHeroPose awaited the pose key and the clipless marker between reading and registering the in-flight export, so two requests for the same stack (StrictMode, a quick toggle) each ran `model export` into the same files. Register the run before the first await. - A selector whose clip listing threw, next to one that listed nothing usable, recorded the clipless marker, so a transient read failure hid the rigged preview until the next cache version. Record it only when every selector answered. - Rigged physics and attachments now start with the export; a failed or clipless export returned while they still read the merged stack VPK the outer finally deletes. Let them settle first. --- electron/main/services/heroPoseModels.test.ts | 23 ++++++++++++++++++- electron/main/services/heroPoseModels.ts | 18 +++++++++++---- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/electron/main/services/heroPoseModels.test.ts b/electron/main/services/heroPoseModels.test.ts index d789741a..5bba44a7 100644 --- a/electron/main/services/heroPoseModels.test.ts +++ b/electron/main/services/heroPoseModels.test.ts @@ -20,7 +20,9 @@ vi.mock('./modMerger', () => ({ runVpkmerge: h.run, runVpkmergeStdout: h.stdout, verifyVpkOutput: vi.fn().mockResolvedValue(undefined), })); vi.mock('./heroParticleExport', () => ({ exportParticleBundle: h.particles })); -vi.mock('./heroPortraits', () => ({ codenamesForHero: (name: string) => name === 'Seven' ? ['gigawatt'] : ['yamato'] })); +vi.mock('./heroPortraits', () => ({ + codenamesForHero: (name: string) => name === 'Seven' ? ['gigawatt'] : name === 'Twin' ? ['twin_a', 'twin_b'] : ['yamato'], +})); vi.mock('./deadlock', () => ({ getCitadelPath: (path: string) => join(path, 'game', 'citadel'), getAddonsPath: (path: string) => join(path, 'game', 'citadel', 'addons'), @@ -181,6 +183,25 @@ describe('rigged preview physics bundle', () => { expect(h.run.mock.calls.length + h.stdout.mock.calls.length).toBe(spawns); }); + it('shares one rigged export between concurrent requests for the same stack', async () => { + const [first, second] = await Promise.all([exportRiggedHeroPose(game(), 'Yamato'), exportRiggedHeroPose(game(), 'Yamato')]); + expect(second).toEqual(first); + expect(h.run.mock.calls.filter(([args]) => args[0] === 'model' && args[1] === 'export')).toHaveLength(1); + }); + + it('does not record a clipless marker when another selector failed to list clips', async () => { + h.stdout.mockImplementation(async (args) => { + if (args[1] !== 'clips') return 'null'; + if (argument(args, '--hero') === 'twin_a') throw new Error('transient read failure'); + return '[]'; + }); + expect((await exportRiggedHeroPose(game(), 'Twin')).hasModel).toBe(false); + const listings = () => h.stdout.mock.calls.filter(([args]) => args[1] === 'clips').length; + const before = listings(); + expect((await exportRiggedHeroPose(game(), 'Twin')).hasModel).toBe(false); + expect(listings()).toBeGreaterThan(before); + }); + it('preserves a current rigged-only cache when sweeping old static entries', async () => { const info = await exportRiggedHeroPose(game(), 'Yamato'); await sweepHeroPoseCache(); diff --git a/electron/main/services/heroPoseModels.ts b/electron/main/services/heroPoseModels.ts index 55fe31ee..c93371c7 100644 --- a/electron/main/services/heroPoseModels.ts +++ b/electron/main/services/heroPoseModels.ts @@ -992,10 +992,14 @@ export async function exportRiggedHeroPose( const requestKey = poseKey(heroName, normalized); const existing = inFlightRiggedExports.get(requestKey); if (existing) return existing; - const cachedKey = await resolvePoseKey(deadlockPath, heroName, normalized); - if (await hasNoRiggedClips(cachedKey)) return { hasModel: false, mtimeMs: null, key: cachedKey }; - const work = runRiggedHeroExport(deadlockPath, heroName, normalized, fallbackSkinMetaKey); + // Registered before the first await, so a concurrent request for the same + // stack joins this run instead of exporting into the same files. + const work = (async (): Promise => { + const cachedKey = await resolvePoseKey(deadlockPath, heroName, normalized); + if (await hasNoRiggedClips(cachedKey)) return { hasModel: false, mtimeMs: null, key: cachedKey }; + return runRiggedHeroExport(deadlockPath, heroName, normalized, fallbackSkinMetaKey); + })(); inFlightRiggedExports.set(requestKey, work); try { const info = await work; @@ -1102,10 +1106,16 @@ async function runRiggedHeroExportForSources( return infoForRiggedKey(key); } catch (err) { lastError = err; + } finally { + // Both read source.vpk, which the outer finally deletes when it is a + // merged stack. + await Promise.all([cloth, attachments]); } } if (listedAny && !foundUsableClip) { - await fs.writeFile(riggedNoClipsFile(key), RIGGED_CACHE_VERSION); + // A selector that failed to list may still carry clips; remember + // "nothing usable" only when every selector answered. + if (lastError === undefined) await fs.writeFile(riggedNoClipsFile(key), RIGGED_CACHE_VERSION); return { hasModel: false, mtimeMs: null, key }; } throw lastError instanceof Error From 5a6da85cabcc0a3ffb9517a1c4994ad680c0edf1 Mon Sep 17 00:00:00 2001 From: slush97 Date: Thu, 8 Oct 2026 23:39:53 -0700 Subject: [PATCH 13/13] chore: bump vpkmerge to v0.21.0 Picks up `model export --max-texture` and the faster textured export (Slush97/vpkmerge#48), which the 1024 px hero previews need: v0.20.1 rejects the flag, so packaged builds would fall back to the 2D portrait. Updates the fetch pin and sha256s plus the Nix flake input and cargoHash (confirmed with a full `nix build .#vpkmerge`). --- flake.lock | 8 ++++---- flake.nix | 2 +- nix/vpkmerge.nix | 4 ++-- scripts/fetch-vpkmerge.mjs | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/flake.lock b/flake.lock index deaace3b..794c4c68 100644 --- a/flake.lock +++ b/flake.lock @@ -42,16 +42,16 @@ "vpkmerge": { "flake": false, "locked": { - "lastModified": 1791342180, - "narHash": "sha256-psqA9exe88RibdvvASIxKUFWlBNd10ElQ+jiS6sUBvA=", + "lastModified": 1791527573, + "narHash": "sha256-Df1nNxqh2rX6Xn4uVjrXEEhP60bfEGd9XLRmi4Aqwl0=", "owner": "Slush97", "repo": "vpkmerge", - "rev": "1660b9ff7e64f674087350c77c2087a97842cf5e", + "rev": "beb03ebfc9842929905d631b86f09a67eecb87dd", "type": "github" }, "original": { "owner": "Slush97", - "ref": "v0.20.1", + "ref": "v0.21.0", "repo": "vpkmerge", "type": "github" } diff --git a/flake.nix b/flake.nix index e59ce33a..9113ed48 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ # Rust CLI the app shells out to for VPK merge/split. Keep the ref in # sync with VPKMERGE_VERSION in scripts/fetch-vpkmerge.mjs. vpkmerge = { - url = "github:Slush97/vpkmerge?ref=v0.20.1"; + url = "github:Slush97/vpkmerge?ref=v0.21.0"; flake = false; }; }; diff --git a/nix/vpkmerge.nix b/nix/vpkmerge.nix index 6a69e57e..12f9e244 100644 --- a/nix/vpkmerge.nix +++ b/nix/vpkmerge.nix @@ -10,10 +10,10 @@ rustPlatform.buildRustPackage { pname = "vpkmerge"; - version = "0.20.1"; + version = "0.21.0"; inherit src; - cargoHash = "sha256-DrEVHvqtD4jtOhAHhzajxuU/Na0rztO/sPnGYMROffM="; + cargoHash = "sha256-8OTUP4GkSYjV5s46wY45NbBRb3yjV3aEB/DXVK01tos="; buildAndTestSubdir = "vpkmerge-cli"; diff --git a/scripts/fetch-vpkmerge.mjs b/scripts/fetch-vpkmerge.mjs index 77f0c45f..956f00c6 100644 --- a/scripts/fetch-vpkmerge.mjs +++ b/scripts/fetch-vpkmerge.mjs @@ -16,12 +16,12 @@ import { fileURLToPath } from 'node:url'; import { get as httpsGet } from 'node:https'; import { pipeline } from 'node:stream/promises'; -const VPKMERGE_VERSION = 'v0.20.1'; +const VPKMERGE_VERSION = 'v0.21.0'; const ASSETS = { - 'linux-x64': { name: 'vpkmerge-linux-x86_64', sha256: '1fb26f831559401f4b121992282e7c319b5a668f55addd73c4fd9fd6d743c139' }, - 'darwin-arm64': { name: 'vpkmerge-macos-aarch64', sha256: '5998c31da842ffcdba6bb0efe96e4f4a6a124445efda13795357c9598a9cbdc5' }, - 'win32-x64': { name: 'vpkmerge-windows-x86_64.exe', sha256: '8ec9484379555367f99bcf7bf874b91af148266552a96c560d069b94bc94f617' }, + 'linux-x64': { name: 'vpkmerge-linux-x86_64', sha256: '1222b5aad5974b659949693409d205d1eb719e2c296dc7337de0a43a7e4f2240' }, + 'darwin-arm64': { name: 'vpkmerge-macos-aarch64', sha256: 'c44da72b5ef26e95fa356485d7e9c5368b7bf58e044afde03bb2d37d3e96442b' }, + 'win32-x64': { name: 'vpkmerge-windows-x86_64.exe', sha256: 'd0b378261a5fdd37046f3ae8251daf508f740ba2b53f1f96fe0effded9acb355' }, }; const here = dirname(fileURLToPath(import.meta.url));