Skip to content

perf(environments): stop the picker loading full-size images - #49

Merged
rosspeili merged 2 commits into
ARPAHLS:mainfrom
AUDOSt0ck1ng:perf/environment-picker-images
Aug 13, 2026
Merged

perf(environments): stop the picker loading full-size images#49
rosspeili merged 2 commits into
ARPAHLS:mainfrom
AUDOSt0ck1ng:perf/environment-picker-images

Conversation

@AUDOSt0ck1ng

@AUDOSt0ck1ng AUDOSt0ck1ng commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Refs #22.

The environment picker pointed at the same assets as the stage, and a custom environments folder was read into memory in full the moment it was configured. This changes both. It is not framed as a fix for the flicker in #22 — I cannot reproduce that, and the decisive question there is still open — but it removes both of the things that issue identified as suspicious, and it stands on its own regardless. Rationale and measurements are in this comment.

Two commits, in order.

1. Still posters for the built-in picker thumbs

Opening Appearance handed the compositor stars + code + bloom — ~32MB and ~480 frames — to play inside 40px boxes, and loaded all three whether or not one was selected. Built-ins now carry a committed 192×112 poster (141KB for all three) that the picker uses. The stage is untouched, so it becomes the only thing that ever loads a GIF, and only for the environment in use.

Posters are generated by the existing npm run thumbs path rather than a new dependency — no GIF decoder ships with the project and the renderer already has one, the same argument generateBundledThumbnails.js makes for avatar portraits. They are globbed rather than imported by name, following config/avatars.js: a named import would deadlock the generator, which imports that module, so adding an environment would fail to resolve the poster the run was meant to create.

npm run thumbs now regenerates both sets; docs updated accordingly.

2. Read a custom folder one image at a time

loadLibraryEnvironments read every file in a configured folder fully into the renderer as a blob URL — no cap, no laziness, and triggered by configuring the folder rather than by opening the Custom expander. Blob URLs pin their bytes, so Chromium could not discard and refetch them the way it can an ordinary image.

Entries now carry no image data. The picker draws a poster generated once per file and cached under userData/thumbnails/, reusing the cache and IPC channel avatar portraits already use — keying on the absolute path means the two kinds share it without colliding, so the Electron side needed no new cache. Only the selected environment has its bytes read.

Posters are generated with ImageDecoder, three at a time rather than strictly serially: unlike a VRM parse this is decode work Chromium runs off-thread, so the cap bounds resident source files rather than protecting the frame rate.

Reading at selection time costs a delay that a resident folder did not, so two things soften it:

  • the read moved off the Electron main process, where fs.readFileSync of a 20MB GIF blocked the window itself (this helps avatars and animations too, since they share the channel);
  • the stage holds the background it is already showing — image, glow and bar contrast together — until the new one is ready. pending distinguishes an image still being read from a selection that has no image, so None and Color fade still take effect at once.

Bridging with the low-resolution poster was tried first and read as a fault rather than as a load, hence holding the previous background instead.

Behaviour changes worth calling out

  • Picker thumbs no longer animate, built-in and custom alike. The stage still does.
  • Selecting a large custom environment is no longer instant. It shows the current background until the new one is ready rather than blanking.
  • Custom tiles pulse while their poster is generated on first sight of a folder; cached from then on, including across restarts.

Testing

npm run lint, npm test (49) and npm run build are green.

scripts/custom-envs.test.mjs asserted that no import.meta.glob survived a production build, which was a proxy for "the custom/ glob is gone" that only held while environments.js had exactly one glob. It now asserts on the custom/ path itself and that the thumbs glob is kept — the embargo from #44 is unchanged and still covered.

New Electron coverage for the async read. Per CONTRIBUTING, everything touching React, the DOM or URL.createObjectURL has no automated coverage: the built-in posters and the first cut of the custom-folder path were checked by hand on Windows 11, and the final hold-the-previous-background behaviour is verified.

Not in this PR

Re-encoding or shortening the bundled GIFs (suggestion 2 on #22). Worth doing, but it changes visual assets and its benefit is stage-side RAM with no causal link to the flicker.

🤖 Generated with Claude Code

AUDOSt0ck1ng and others added 2 commits August 13, 2026 01:04
The picker pointed at the same asset as the stage, so opening Appearance
handed the compositor three animated GIFs — ~32MB and ~480 frames between
them — to play inside 40px boxes. All three loaded whether or not one was
selected, and stayed resident for the rest of the session.

Built-ins now carry a committed 192x112 poster that the picker uses
instead. The stage is untouched, which makes it the only thing that ever
loads a GIF, and only for the environment actually in use.

The posters are generated by the existing `npm run thumbs` path rather
than a new dependency: no GIF decoder ships with the project and the
renderer already has one, the same argument generateBundledThumbnails.js
makes for avatar portraits. That also puts the image-thumbnail helper in
place for the custom environment folder, which needs the same thing at
runtime.

Posters are globbed rather than imported by name, following
config/avatars.js. A named import would deadlock the generator, which
imports this module: adding an environment would fail to resolve the
poster that the run was supposed to create.

`loading="lazy"` comes along on the built-in row for parity with the
Custom grid, though it does nothing for elements already in the viewport.

The custom/ embargo test asserted that no `import.meta.glob` survived a
production build, which was a proxy for "the custom glob is gone" that
only held while environments.js had exactly one glob. It now asserts on
the custom/ path itself, and that the thumbs glob is kept.

Refs ARPAHLS#22

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
loadLibraryEnvironments pulled every file in a configured folder fully
into the renderer as a blob url — no cap, no laziness, and it ran when
the folder was configured rather than when the Custom expander was
opened. Blob urls pin their bytes, so unlike ordinary images Chromium
could not discard and refetch them: a folder of large gifs held hundreds
of MB resident for the session, and the picker animated all of it inside
40px boxes.

Entries now carry no image data. The picker draws a poster generated once
per file and cached under userData/thumbnails/, reusing the cache and the
IPC channel avatar portraits already use — keying on the absolute path
means the two kinds share it without colliding. Only the environment
actually selected has its bytes read.

Posters are generated with ImageDecoder rather than three.js, and run
three at a time rather than strictly serially: unlike a VRM parse this is
decode work Chromium runs off-thread, so the cap is there to bound
resident source files, not to protect the frame rate.

Reading at selection time costs a delay a resident folder did not, so two
things soften it. The read moved off the Electron main process, which a
synchronous read of a 20MB gif blocked — window and all. And the stage
holds the background it is already showing, glow included, until the new
one is ready: `pending` distinguishes an image still being read from a
selection that has no image, so None and Color fade still take effect at
once. Bridging with the low-resolution poster was tried first and read as
a fault rather than as a load.

Refs ARPAHLS#22

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AUDOSt0ck1ng

Copy link
Copy Markdown
Collaborator Author

@rosspeili

Copy link
Copy Markdown
Contributor

Thanks @AUDOSt0ck1ng, solid work as always. Merging as-is, and we can close the issue, don't need to open until we re-check the intermittent flicker, and leave stage GIF re-encode as a follow-up if it poses an imminent need. we can close the issue with a relevant comment. <3

@rosspeili
rosspeili merged commit 4a90862 into ARPAHLS:main Aug 13, 2026
1 check 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.

2 participants