Skip to content

perf(locker): load the 3D hero preview faster - #430

Merged
Slush97 merged 13 commits into
mainfrom
perf/hero-preview-integrated
Oct 9, 2026
Merged

Slush97 merged 13 commits into
mainfrom
perf/hero-preview-integrated

Conversation

@Slush97

@Slush97 Slush97 commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

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):

  • Pose and rigged exports pass --max-texture 1024, so each texture embeds at its largest mip that fits (drifter skin: 39 MB -> 14 MB). POSE_PIPELINE_VERSION 16 / RIGGED_PIPELINE_VERSION 15 so cached full-size GLBs regenerate. The constant is PREVIEW_MAX_TEXTURE.
  • readVpkEntryBytes keeps a small LRU of entry indexes instead of walking pak01's whole tree (~25 ms) on every lookup.
  • Attachment and clip metadata blocks decode 8 at a time instead of one after another.
  • Rigged physics and attachments decode while the model exports, not after.
  • Skins whose rigged export drops every clip get a versioned marker, so later opens go straight to the static pose instead of exporting and loading both.

Renderer:

  • The hero viewer decodes textures with ImageBitmap, off the main thread. The soul-container grid and import modals keep <img> (ImageBitmaps pin decoded pixels and the grid shows many models), through a per-parser GLTFLoader plugin instead of hiding createImageBitmap globally.
  • GLBs are fetched with one native arrayBuffer() and parsed with parseAsync, skipping FileLoader's per-chunk progress re-streaming.
  • The albedo average reads a 256 px off-thread decode of the source image instead of drawing the full albedo into a canvas on the main thread (75 to 90 percent of model load time before).
  • Cloth bones write parent-first without per-write subtree updates (was O(L^2) per chain per step); bit-identical on the Dynamo fixture.
  • Disposal closes ImageBitmaps, frees every texture a material holds (sheen maps were missed), and runs in a [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):

  • vpkmerge export, vanilla drifter with its 6-clip menu: 816 ms (v0.20.0, full size) -> ~135 ms (feat(model): parallelize GLB texture export and add --max-texture vpkmerge#48 at 1024; heroes with 4096 normal maps pay up to ~200 ms more for the top-mip normal probe). model clips --json: 127 -> 50 ms.
  • Dev app over CDP, drifter + pak09 skin: 0.72 s cold (export + load), ~0.6 s cached, longest main-thread block ~120 ms.
  • Electron 35, same GLB, load to first frame:
GLB <img> ImageBitmap renderer memory (<img> / bitmap)
full size 1396 ms 760 ms 285 / 721 MB
2048 cap 1048 ms 515 ms 270 / 607 MB
1024 cap 449 ms ~300 ms 219 / 303 MB

<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 full nix build .#vpkmerge reports 0.21.0 and accepts --max-texture). The released Linux binary runs both export shapes this PR issues (--pose --require-pose --max-texture 1024 and --clip ... --max-texture 1024).

Known, not fixed here

From review, low impact:

  • With a trippy paint or the unified NPR material active, the original albedo (and maps the NPR clone nulls) live outside the scene's materials when it is disposed, so their ImageBitmaps are left to GC instead of closed.
  • R3F unmounts asynchronously, so a rAF can still render the disposed scene into the detached canvas. three catches the texture upload errors; at worst it's console noise.
  • The clipless marker, like the cached GLBs, is versioned by RIGGED_PIPELINE_VERSION only. 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) and ui:check pass. interchange.test.ts fails locally while Deadlock is running (it hits the real "Game is running" check); unrelated. loadGltfPreview.test.ts covers the plugin swap, the ImageBitmap opt-in, the failed-fetch path, materialTextures and disposeTexture.

Slush97 added 12 commits October 8, 2026 22:02
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
Slush97 marked this pull request as ready for review October 9, 2026 06:40
@Slush97
Slush97 merged commit 6908575 into main Oct 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant