perf/wasm-phone-render-budget #27

Merged
jeroen merged 3 commits from perf/wasm-phone-render-budget into main 2026-08-09 12:21:06 +00:00
Owner
No description provided.
The web build is unusably slow on a Fairphone 4, and a handset differs from
the workstation this codebase was tuned against in three ways at once. Fill:
it reports devicePixelRatio 2.75, so the map is drawn into ~2.5 Mpx of HDR on
an Adreno 619. Threads: bevy_tasks disables its multi-threaded pool on wasm,
so every system, extract, queue and batch step is serial and draw *count*
costs as much as draw cost. Thermals: available_parallelism says 8 cores
without saying 6 are efficiency cores sharing an envelope with the render
thread and the compositor.

`quality` resolves one budget at startup and everything else reads it. It is
shaped like `env_flag` rather than as a Bevy resource deliberately: the tier
is constant for the process, and several consumers either run before the
world exists or sit at Bevy's 16-parameter cap for a system already.
`?quality=` / `VIBERFOX_QUALITY` forces either tier anywhere, which is what
makes the phone budget reproducible on a machine that can profile it.

Bloom is zeroed rather than removed because bevy_post_process early-returns on
exactly 0.0, while dropping the component would take the camera out of
`apply_day_night_cycle`'s and `shot_harness`'s tuple queries — rendering
without glare is the intent, silently falling out of the day/night cycle is
not. The atmosphere stays on in both tiers: it *is* the sky, it A/B'd within
noise of baseline, and the alternative is a void.

The clipmap fan-out is asserted at both tiers (424 desktop, 224 phone) so a
future radius change has to restate the number, and `clipmap` takes the budget
as a parameter so a test can state one it is not running under. The desktop
tier is the identity for every radius and per-frame budget, which is also a
test.
A Fairphone 4 reports devicePixelRatio 2.75, so a canvas across its 393 CSS-px
viewport renders 1080x2340 real pixels — 2.5 Mpx of HDR, atmosphere and
tonemapping on an Adreno 619. Capping that at 1.5 device pixels per CSS pixel
(and 1.3 Mpx overall) takes it to 590x1277, a little under 30% of the fill,
which is the single largest item in the phone budget.

The mechanism is the part worth recording, because none of it is visible from
Rust and the obvious approach does not work. On the web the client does not
choose its render resolution: wgpu's surface `configure` writes
canvas.width/height from the window's physical size, and that size is whatever
winit's ResizeObserver measured the element's *device-pixel content box* to be
— CSS size times DPR. `Window::resolution.set_scale_factor_override` therefore
cannot help: winit's web `request_inner_size` converts the requested physical
size back to logical and sets the CSS size, returning None, so asking for
fewer physical pixels shrinks the visible canvas rather than the buffer behind
it.

What does work is ordinary layout, because a CSS transform is a paint
operation while the content box is a layout box. `#stage` is sized to viewport
divided by k and scaled back up by k, so the canvas still covers the screen
while its backing store is 1/k**2 of it. `transform` and not `zoom`: zoom is a
layout property in the browsers that have it, so it would change the measured
box and cancel itself out.

Pointer input survives the transform — winit takes coordinates from
offsetX/offsetY, which the browser reports in the element's own untransformed
space, the same space the backing store is in. `Canvas::position()` does use
getBoundingClientRect and *is* transform-affected; nothing on the input path
reads it today.

The tier is decided here rather than in `quality.rs` and read back off
`<html data-quality>`. It has to be: the page cannot defer sizing the canvas,
so it must answer before any Rust runs, and a second detector in Rust could
only disagree with this one about a machine they are both looking at. The
canvas is pre-placed and adopted via `Window::canvas` so it can sit inside the
stage, and starts at 1x1 because the loader hands over on the first canvas
wider than 1px — an unsized canvas defaults to 300x150 and would satisfy that
before Bevy had drawn a frame.

The loader gains a row reporting the tier and the chosen resolution, since on
a phone with no console that is the only place either decision is visible.
docs(notes): record what costs frames on a handset
All checks were successful
CI / cargo check (push) Successful in 7m19s
CI / build & test viberfox (push) Successful in 9m59s
CI / cargo check (pull_request) Successful in 2m5s
CI / build & test viberfox (pull_request) Successful in 5m8s
cb71ac15f8
The existing measurement in this note was taken on a workstation GPU, and
everything it ruled out — atmosphere, IBL, haze, clouds, rain — was ruled out
there. That is still true and still worth not re-testing, but it says nothing
about a phone, and someone reading only the "ruled out" section would conclude
the render settings had already been cleared.

Adds what a handset changes, the phone budget's contents with a reason per
row, and the winit/wgpu render-scale mechanism — where the render resolution
actually comes from on the web, why `scale_factor_override` is not the lever,
and why a CSS transform is. That last part cost a while to establish from the
dependency sources and is invisible from this repo's own code, which is
exactly the kind of fact this directory exists to hold.

Also says plainly that none of it has been measured on the device, that the
ordering of the table is a guess about fill-bound versus main-thread-bound,
and what a run that would settle it looks like. A note that reads as results
when it is predictions is worse than no note.
jeroen merged commit 1b8c77b00a into main 2026-08-09 12:21:06 +00:00
Sign in to join this conversation.
No description provided.