perf(appearance): draw avatar thumbnails from images instead of live VRM previews - #20
Merged
Merged
Conversation
VrmAvatar's cleanup detached the scene from its group but never disposed it, so every avatar swap stranded a full model's geometries, materials and textures in VRAM for the rest of the session. Also covers the model that finishes parsing after the effect has already torn down: it was never attached, so the cleanup could not reach it and nothing else would have freed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vite binds localhost, which resolves to ::1 on Windows, but the script waited on and loaded http://127.0.0.1:5173 — a v4 address nothing is listening on. wait-on never resolved, so Electron was never launched and `npm run dev:desktop` silently started a bare Vite server instead. Also pins the port with --strictPort. Without it Vite quietly moves to the next free port when 5173 is taken, while wait-on and VITE_DEV_SERVER_URL stay pointed at 5173 — so Electron would attach to whatever other server happened to hold that port. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eviews Each picker thumbnail mounted its own <Canvas> and loaded a full VRM into it, so opening Appearance parsed the whole catalog — 49MB across the three bundled models — to draw a few 56px circles. Measured on Windows with an integrated GPU, that was ~3.7s of blocked frames per open, in ~1s chunks as each model landed. The portrait is static, so it is now an image: - Bundled avatars ship a pre-rendered PNG (npm run thumbs). - User-folder avatars render once and are cached in userData/thumbnails, keyed by path + mtime + size so replacing a .vrm regenerates it. - Generation is serialised with a yield between models, so first sight of a new folder fills the picker in progressively instead of freezing it. Appearance now holds one WebGL context instead of one per avatar, which also removes the risk of a large custom folder exhausting the browser's context limit. Note that what made this expensive was never the previews' render loops — those measured free, at 60fps whether the thumbnails animated or not. It was loading the models at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three issues in the thumbnail generator, all found reading it back: - renderThumbnail allocated the model outside its try, so anything that threw before the render leaked it. The realistic trigger is a .glb (or any file) renamed .vrm: it parses, gltf.userData.vrm is undefined, and dereferencing it stranded everything the loader had just built. That case is now detected and disposed explicitly. - The in-memory url cache keys on the avatar id, which is derived from the file path alone. Replacing a .vrm in place and rescanning kept serving the old portrait for the rest of the session, because that cache answered before the disk cache's mtime/size check could miss. revokeThumbnailUrls existed for this but was never called; the library refresh and teardown paths now do. - A failed cache write threw past the code that hands back the freshly rendered blob, so a cache problem cost the user their thumbnail entirely rather than just costing it again next launch, which is what the comment there claimed. Regenerating the bundled thumbnails after these changes produces byte-identical PNGs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator
Author
2 tasks
Collaborator
Author
|
Potential Issue:
|
Contributor
|
Thanks @AUDOSt0ck1ng, strong approach on this. Some tips before merge
Known limits (fine to track as follow-ups)
Demo
Happy to merge once changelog + contributor docs are in. Thanks again for the thorough measurement and the self-review pass in |
This was referenced Aug 6, 2026
Contributor
The thumbnail work changed how bundled avatars are shipped — the picker now reads committed PNGs — but nothing recorded that, so a contributor swapping a bundled .vrm had no way to know the portrait needs regenerating with npm run thumbs. Document the command and when to rerun it in CONTRIBUTING (plus the ripple table and the project-layout scripts table), list the new thumbs/ directory and electron/thumbnails.cjs in the layout tree, and note in the walkthrough that picker thumbnails are static images that fill in progressively on a custom folder's first scan. The changelog entry covers the perf change, the VRAM leak on avatar swap, and the dev:desktop fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator
Author
|
That video merely demonstrates performance improvements; there are no actual new differences, and it isn't really suitable for editing—converting it to a GIF would result in a huge file size. So, I decided to omit the GIF. |
Contributor
|
This looks good too @AUDOSt0ck1ng. Happy to merge. Again, follow-ups stay in #21/#22. Thanks! <3 |
This was referenced Aug 6, 2026
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.
refs #10. Measurements and the reasoning behind the approach are in
#10 (comment) — the short
version is that the previews' render loops, the extra WebGL contexts and the
drawer's
backdrop-filterall measured free, and the entire cost was parsingthree full VRMs (49MB) to draw three 56px circles. That is main-thread
CPU-bound work that cannot be scheduled or parallelised away, so the issue's
"static thumbnails" alternative is the only option with a real ceiling.
On Windows 11 with an integrated GPU, blocked frame time in the 4.5s after
Appearance opens: ~3725ms on first open, ~3492ms on reopen. Both go away —
no VRM is parsed to show the picker.
Since the previews were already motionless portraits, replacing them with an
image loses nothing visually.
Approach
npm run thumbs.userData/thumbnails/,keyed by path + mtime + size, so replacing a
.vrmregenerates it.folder fills the picker in progressively rather than freezing it.
or Puppeteer pipeline, so committed thumbnails cannot drift from the ones
generated for a user's own files.
Appearance now holds one WebGL context instead of one per avatar. That also
removes a latent failure:
loadLibraryAvatarsplaces no cap on how many.vrmfiles a custom folder may contain, and browsers cap live WebGL contexts — a
large folder would have started losing thumbnails to context eviction.
Commits
Two of these are independent of the thumbnail work and can be split out if you
would rather take them separately:
fc7ecf2—VrmAvatardetached models without ever disposing them, so everyavatar swap stranded a model's worth of geometries, materials and textures in
VRAM for the rest of the session.
0d2ee51—dev:desktopwaited on and loaded127.0.0.1:5173while Vitebinds
localhost(::1here), sowait-onnever resolved and Electron wasnever launched; it silently started a bare Vite server instead. Also pins the
port, which otherwise drifts when 5173 is taken while the script keeps
pointing at 5173.
d6f1261— the thumbnail work.cf3acd2— three issues found on review: a leak when a file parses but hasno VRM payload (a
.glbrenamed.vrm), a stale portrait after replacing afile in place, and a cache-write failure discarding an already-rendered
thumbnail.
Testing
eslintclean apart from two findings that predate the branch.Not covered
Bounded in practice (~10KB each) but unbounded in principle.
looks unrelated — that section has no WebGL, only
<img>GIFs totalling~32MB with no
loading="lazy"on the built-in row.Happy to adjust framing, thumbnail size, or the placeholder treatment.
🤖 Generated with Claude Code