Repository navigation
perf(locker): load the 3D hero preview faster - #430
Merged
Merged
Conversation
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.
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.
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.
…ry 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.
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.
The preview GLB embedded every texture at full size (35-70 MB, up to 4096 px), and GLTFLoader was forced onto <img> 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 <img> path. Other loads (soul-container grid, import modals) keep <img> since ImageBitmaps pin their pixels in renderer memory and the grid shows many models at once. - The <img> 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.
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.
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.
…verage loadGltfPreview no longer hides createImageBitmap globally (the <img> fallback swaps a per-parser texture loader), so the module-load capture is dead weight.
- 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.
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`).
Slush97
marked this pull request as ready for review
October 9, 2026 06:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #420. Same goal, now with the follow-up fixes from the #406 viewer work and a few fixes #420 was missing, on current
main.What
Opening a hero's 3D preview was slow on both ends: the export on a cache miss (full-size textures, sequential metadata decodes, physics and attachments decoded after the model) and the load every time (full-size
<img>textures decoded on the main thread at the first render, plus a second full decode of every albedo just to average it).Main process (cache miss):
--max-texture 1024, so each texture embeds at its largest mip that fits (drifter skin: 39 MB -> 14 MB).POSE_PIPELINE_VERSION16 /RIGGED_PIPELINE_VERSION15 so cached full-size GLBs regenerate. The constant isPREVIEW_MAX_TEXTURE.readVpkEntryByteskeeps a small LRU of entry indexes instead of walking pak01's whole tree (~25 ms) on every lookup.Renderer:
<img>(ImageBitmaps pin decoded pixels and the grid shows many models), through a per-parser GLTFLoader plugin instead of hidingcreateImageBitmapglobally.arrayBuffer()and parsed withparseAsync, skipping FileLoader's per-chunk progress re-streaming.[scene]effect. Disposing in the loader cleanup left one frame drawing closed bitmaps when a toggle re-ran the load.Measured
Per change, on the original branches (not re-run end to end on this combined branch yet):
model clips --json: 127 -> 50 ms.<img><img>/ bitmap)<img>and ImageBitmap frames are pixel-identical at every cap. At the closest zoom, 1024 is slightly soft on fine print (drifter's hat card, coat damask); 2048 matches full size for ~300 MB more renderer memory.vpkmerge
Bumps the pin to v0.21.0 (Slush97/vpkmerge#48, released via #54): fetch script version + sha256s, the Nix flake input and
cargoHash(a fullnix build .#vpkmergereports 0.21.0 and accepts--max-texture). The released Linux binary runs both export shapes this PR issues (--pose --require-pose --max-texture 1024and--clip ... --max-texture 1024).Known, not fixed here
From review, low impact:
RIGGED_PIPELINE_VERSIONonly. A vpkmerge or game update that changes clip export needs a version bump to clear it.Checks
tsc -b,eslint .,vitest run(183 files, 2718 tests) andui:checkpass.interchange.test.tsfails locally while Deadlock is running (it hits the real "Game is running" check); unrelated.loadGltfPreview.test.tscovers the plugin swap, the ImageBitmap opt-in, the failed-fetch path,materialTexturesanddisposeTexture.