Skip to content

perf(appearance): draw avatar thumbnails from images instead of live VRM previews - #20

Merged
rosspeili merged 5 commits into
ARPAHLS:mainfrom
AUDOSt0ck1ng:feat/avatar-thumbnails
Aug 6, 2026
Merged

perf(appearance): draw avatar thumbnails from images instead of live VRM previews#20
rosspeili merged 5 commits into
ARPAHLS:mainfrom
AUDOSt0ck1ng:feat/avatar-thumbnails

Conversation

@AUDOSt0ck1ng

@AUDOSt0ck1ng AUDOSt0ck1ng commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

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-filter all measured free, and the entire cost was parsing
three 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

  • Bundled avatars ship a pre-rendered PNG, generated by npm run thumbs.
  • User-folder avatars render once and cache under 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 rather than freezing it.
  • Generation reuses the app's own renderer rather than a separate headless-GL
    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: loadLibraryAvatars places no cap on how many .vrm
files 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:

  • fc7ecf2VrmAvatar detached models without ever disposing them, so every
    avatar swap stranded a model's worth of geometries, materials and textures in
    VRAM for the rest of the session.
  • 0d2ee51dev:desktop waited on and loaded 127.0.0.1:5173 while Vite
    binds localhost (::1 here), so wait-on never resolved and Electron was
    never 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 has
    no VRM payload (a .glb renamed .vrm), a stale portrait after replacing a
    file in place, and a cache-write failure discarding an already-rendered
    thumbnail.

Testing

  • Verified by hand: Appearance opens without a stall.
  • Bundled thumbnails regenerate byte-identically across the review refactors.
  • eslint clean apart from two findings that predate the branch.

Not covered

  • VRoid Hub characters use a different path and are untouched.
  • The disk cache does not prune thumbnails for avatars removed from a folder.
    Bounded in practice (~10KB each) but unbounded in principle.
  • The environments section is untouched. As noted in the issue, its flicker
    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

AUDOSt0ck1ng and others added 4 commits August 6, 2026 12:48
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>
@AUDOSt0ck1ng

Copy link
Copy Markdown
Collaborator Author

@AUDOSt0ck1ng

Copy link
Copy Markdown
Collaborator Author

Potential Issue:

  1. Thumbnail looks like empty when model charactor is not tall enough.
  2. Choosing Custom VRM Folder at first time will lead to a thumbnails building process (still latency).
  3. Thumbnail cache will not be remove when you try to remove VRM from folder.

@rosspeili

Copy link
Copy Markdown
Contributor

Thanks @AUDOSt0ck1ng, strong approach on this.

Some tips before merge

  • CHANGELOG.md [Unreleased] entry (perf + bundled thumbs + custom-folder cache, mention dev:desktop fix if kept here)
  • Docs: short contributor note for npm run thumbs and regenerating src/assets/avatars/thumbs/ when bundled avatars change (CONTRIBUTING.md and/or docs/development/project-layout.md)
  • Optional one line in docs/using-the-app.md that picker thumbs are static images (custom folder fills in progressively on first scan)

Known limits (fine to track as follow-ups)

  • Short models / empty-looking portraits
  • First sight of a new custom folder still builds thumbs progressively
  • Cache doesn’t prune thumbs for files removed from the folder (you noted, I'd say low priority)

Demo

Happy to merge once changelog + contributor docs are in. Thanks again for the thorough measurement and the self-review pass in cf3acd2. <3

@rosspeili

Copy link
Copy Markdown
Contributor

Re your three follow-ups, agree they’re acceptable for merge, tracking the rest in a follow-up issue (sidecar + pre-warm on directory save + cache prune on scan #21). Short-model framing and env GIF flicker stay out of scope here and will be in #22. Thanks for calling them out explicitly.

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>
@AUDOSt0ck1ng

Copy link
Copy Markdown
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.

@rosspeili

Copy link
Copy Markdown
Contributor

This looks good too @AUDOSt0ck1ng. Happy to merge. Again, follow-ups stay in #21/#22. Thanks! <3

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.

2 participants