Skip to content

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

Closed
Slush97 wants to merge 2 commits into
mainfrom
perf/hero-preview-load
Closed

Slush97 wants to merge 2 commits into
mainfrom
perf/hero-preview-load

Conversation

@Slush97

@Slush97 Slush97 commented Oct 4, 2026

Copy link
Copy Markdown
Owner

What

Opening a hero's live 3D model took ~1.4 s even with the GLB cached, and the UI froze for ~1.3 s of it. On a cache miss it took ~2.4 s. There were two causes:

  1. Full-size textures. The pose GLB embedded every texture at full size (2048/4096, 35-70 MB per hero), which is far more than the panel shows.
  2. <img> decoding. loadGltfPreview hid createImageBitmap, so GLTFLoader fell back to <img> elements, and Chromium decodes those synchronously on the main thread when the first render uploads them.

Changes:

  • 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 15 / RIGGED_PIPELINE_VERSION 8 so cached full-size GLBs regenerate.
  • HeroPoseViewer loads with { imageBitmaps: true }, which decodes off the main thread. Everything else (soul-container grid and tiles, import modals) keeps <img>: ImageBitmaps pin their decoded pixels in renderer memory, while Chromium can drop an <img>'s, and the grid shows many models at once.
  • The <img> fallback is now a per-loader GLTFLoader plugin that swaps that parser's texture loader. Hiding createImageBitmap globally would break a concurrent ImageBitmap load.
  • disposeTexture closes ImageBitmaps (three.js never does). materialTextures frees every texture a material holds; the old fixed list missed the sheen maps.

Measured

In the dev app, driven over CDP (drifter + pak09 skin): 0.72 s cold (export + load), ~0.6 s cached, longest main-thread block ~120 ms.

Electron 35 harness, 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 (read back from the framebuffer).

Tradeoff: at the closest zoom, 1024 is slightly soft on fine print (drifter's hat card, coat damask). 2048 looks identical to full size but costs ~300 MB more renderer memory with ImageBitmaps. It's one constant: PREVIEW_MAX_TEXTURE.

Before merging

Checks

tsc -b, eslint on touched files, and vitest run (1785 tests) pass. loadGltfPreview.test.ts now covers the plugin swap, the ImageBitmap opt-in, materialTextures and disposeTexture.

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.
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