diff --git a/electron/main/services/heroAnimationMetadata.ts b/electron/main/services/heroAnimationMetadata.ts index 26d41ee1f..a33b4c18a 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/heroParticleExport.test.ts b/electron/main/services/heroParticleExport.test.ts index 106a5d915..03c64fe29 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 0260481be..578d1b7fe 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/heroPoseModels.test.ts b/electron/main/services/heroPoseModels.test.ts index 4d4e4209f..5bba44a78 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'), @@ -139,7 +141,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 () => { @@ -166,6 +169,39 @@ 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(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); + 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 6c5114e38..c93371c7f 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'; @@ -347,6 +363,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 +827,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) { @@ -899,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, ]); @@ -941,7 +993,13 @@ export async function exportRiggedHeroPose( const existing = inFlightRiggedExports.get(requestKey); if (existing) return existing; - 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; @@ -1005,6 +1063,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([ @@ -1017,35 +1090,32 @@ 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, ]); - 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); - } + if (await glbAnimationCount(out) === 0) { + await fs.writeFile(riggedNoClipsFile(key), RIGGED_CACHE_VERSION); + return { hasModel: false, mtimeMs: null, key }; } - await fs.writeFile(join(dir, RIGGED_ATTACHMENTS_FILENAME), JSON.stringify(attachments)); + await fs.rm(riggedNoClipsFile(key), { force: true }); + 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) { 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) { + // 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 diff --git a/electron/main/services/modelAttachments.ts b/electron/main/services/modelAttachments.ts index 1e1218ef6..ca708c45f 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); diff --git a/electron/main/services/vpk.ts b/electron/main/services/vpk.ts index 4042b12cc..8f0d3f656 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; } diff --git a/flake.lock b/flake.lock index deaace3b9..794c4c681 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 e59ce33a8..9113ed48f 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 6a69e57e1..12f9e244f 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 77f0c45f4..956f00c65 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)); diff --git a/src/components/locker/HeroPoseViewer.tsx b/src/components/locker/HeroPoseViewer.tsx index 91c0466ff..85fb7a058 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); @@ -1081,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([]); @@ -1115,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); @@ -1144,12 +1139,11 @@ 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; } - loaded = gltf.scene; setRigged(false); setClips([]); setClothModel(null); // static path has no skeleton; nothing to simulate. @@ -1164,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 @@ -1179,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)); diff --git a/src/components/locker/soulModel.ts b/src/components/locker/soulModel.ts index 00525c853..3ea04f001 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 7c407019a..9f48d64c9 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,14 +24,26 @@ function fakeGltf(): GLTF { vi.mock('three/examples/jsm/loaders/GLTFLoader.js', () => ({ GLTFLoader: class { - load(_url: string, onLoad: (gltf: GLTF) => void): void { - loaderMock.loadCreateImageBitmapValues.push(globalThis.createImageBitmap); - onLoad(fakeGltf()); + 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); } - parse(_buffer: ArrayBuffer, _path: string, onLoad: (gltf: GLTF) => void): void { - loaderMock.parseCreateImageBitmapValues.push(globalThis.createImageBitmap); - onLoad(fakeGltf()); + async parseAsync(_buffer: ArrayBuffer, _path: string): Promise { + this.runParser(); + return fakeGltf(); } }, })); @@ -40,42 +52,78 @@ 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); + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ArrayBuffer(4)))); + return () => vi.unstubAllGlobals(); }); - 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); + }); + + 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.parseCreateImageBitmapValues).toEqual([undefined]); - expect(globalThis.createImageBitmap).toBe(sentinel); + 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; - + it('rejects a failed GLB request', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 }))); const { loadGltfPreview } = await import('./loadGltfPreview'); - const gltf = await loadGltfPreview('grimoire-hero://m/test/model.glb?v=1'); - expect(loaderMock.loadCreateImageBitmapValues).toEqual([undefined]); - expect('createImageBitmap' in globalThis).toBe(false); - expect(source2Mock.resolveMorphicTextures).toHaveBeenCalledWith(gltf); + await expect(loadGltfPreview('grimoire-hero://m/missing/model.glb')).rejects.toThrow('404'); + expect(loaderMock.textureLoaders).toEqual([]); + }); +}); + +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 }); + + expect(materialTextures(material)).toEqual(expect.arrayContaining([map, sheen])); + expect(materialTextures(material)).toHaveLength(2); + }); + + 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 207d4f23a..cbcd64be4 100644 --- a/src/lib/loadGltfPreview.ts +++ b/src/lib/loadGltfPreview.ts @@ -1,73 +1,70 @@ +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; - } +/** 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 response = await fetch(url); + if (!response.ok) throw new Error(`GLB request failed (${response.status}): ${url}`); + return parseGltfPreview(await response.arrayBuffer(), options); +} - hadCreateImageBitmap = false; - savedCreateImageBitmap = undefined; +/** 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 previewLoader(options).parseAsync(buffer, ''); + await resolveMorphicTextures(gltf); + return gltf; } -export async function loadGltfPreview(url: string): Promise { - disableImageBitmapLoader(); - try { - const gltf = await new Promise((resolve, reject) => { - new GLTFLoader().load(url, resolve, undefined, reject); - }); - // Resolve morphic preview texture indices (the only part of the morphic - // contract the stock loader does not surface) while gltf.parser is still live - // and the ImageBitmap suppression is in effect. No-op when no material - // carries preview-only textures. - await resolveMorphicTextures(gltf); - return gltf; - } finally { - restoreImageBitmapLoader(); - } +/** Every texture a material holds directly (map, normalMap, sheenColorMap, ...). */ +export function materialTextures(material: THREE.Material): THREE.Texture[] { + return Object.values(material).filter( + (value): value is THREE.Texture => (value as THREE.Texture | null)?.isTexture === true + ); } -/** Parse an in-memory `.glb` (ArrayBuffer) instead of fetching a URL. Used by - * the Soul Container import preview, which loads the dropped/picked file's bytes - * directly (before any build). Shares the createImageBitmap guard. */ -export async function parseGltfPreview(buffer: ArrayBuffer): Promise { - disableImageBitmapLoader(); - try { - const gltf = await new Promise((resolve, reject) => { - new GLTFLoader().parse(buffer, '', resolve, reject); - }); - await resolveMorphicTextures(gltf); - return gltf; - } finally { - restoreImageBitmapLoader(); - } +/** Dispose a texture and close its ImageBitmap, if it has one (three.js never + * does). Clones share the bitmap, so only call this when freeing the whole scene + * the texture came from. */ +export function disposeTexture(texture: THREE.Texture): void { + texture.dispose(); + const image: unknown = texture.image; + if (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap) image.close(); } diff --git a/src/lib/loadRiggedHeroPreview.test.ts b/src/lib/loadRiggedHeroPreview.test.ts index 5a7ad3ac6..67bd015e0 100644 --- a/src/lib/loadRiggedHeroPreview.test.ts +++ b/src/lib/loadRiggedHeroPreview.test.ts @@ -42,7 +42,7 @@ describe('loadRiggedHeroPreview', () => { await Promise.resolve(); expect(ready).toBe(false); const base = 'grimoire-hero://m/Yamato%3A%3Aaddons2%2Fskin_dir.vpk%3A%3Ac123'; - expect(loadGltfPreview).toHaveBeenCalledWith(`${base}/model-rigged.glb?v=42`); + expect(loadGltfPreview).toHaveBeenCalledWith(`${base}/model-rigged.glb?v=42`, { imageBitmaps: true }); expect(fetch).toHaveBeenCalledWith(`${base}/cloth-rigged.json?v=42`); complete(Response.json(raw)); const result = await loading; diff --git a/src/lib/loadRiggedHeroPreview.ts b/src/lib/loadRiggedHeroPreview.ts index fe8803533..392d95aa7 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}`), ]); diff --git a/src/lib/source2ColorCorrection.ts b/src/lib/source2ColorCorrection.ts index 9b59020be..71c467570 100644 --- a/src/lib/source2ColorCorrection.ts +++ b/src/lib/source2ColorCorrection.ts @@ -65,34 +65,94 @@ 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); + +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 || 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]; + 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 createImageBitmap(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 e14db3116..31f4603f8 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; diff --git a/src/lib/useClothSim.ts b/src/lib/useClothSim.ts index 1f75e3e0c..2215cec7d 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); }