Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 76 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,15 +715,85 @@ Knobs worth knowing about, because they reshape their ability the most:
- A far cast's targeting circle is two draw calls: one quad and one ring strip.
- The six dynamic point lights are created at boot and parked at zero intensity rather than added
and removed β€” changing the light count forces three to recompile every material.
- Shadow maps update exactly once per frame even though the scene is rendered several times.
- `renderer.compileAsync()` runs during boot so the first cast never stutters on shader compile.
- Pixel ratio is capped at 1.75; the depth and distortion buffers are half resolution.
- The scene is rendered several times per frame, but the sun's shadow map is built at most once,
by the main pass. The depth and distortion passes deliberately hold the flag back: three picks
shadow casters by testing them against the layers of the camera the frame is being *rendered*
with, and both of those passes pin the camera to a single layer.
- The actual render pipeline is warmed during boot to compile ability shaders before the first cast.
- MSAA is off. Everything is drawn into the composer's (non-multisampled) targets, so `antialias`
on the canvas buys nothing and costs a multisampled back buffer plus a resolve per swap.

**Not paying for an empty stage.** Standing still is the state the sandbox spends most of its
time in, and it used to cost the same as a four-cast fight:

- The loop drops to `idleFps` (30) whenever nothing is cast, armed, decaying or under the cursor,
and snaps back to `maxFps` (60) on the first input or spawn. It also suspends entirely in a
hidden tab.
- The depth prepass and the distortion pass are skipped when no ability, particle or burst is
alive β€” those are the only things that read either buffer.
- Particle systems hide themselves once their last particle has died. Without this a system keeps
issuing a full-capacity instanced draw forever after its one cast, and since the boot warm-up
builds every ability, that is 37 draws and ~111k instances on a stage with nothing on it.
- The sun shadow map and the contact shadow refresh at `shadowFps` (30) rather than every frame.
- Pixel ratio is capped at 1.25; the depth and distortion buffers are half resolution.
- Ambient dust stops drawing at zero amount instead of transforming 2,600 points to discard them.

**Paying less during a cast, too.** Idle skipping does nothing for the frames you are actually
playing, so two knobs work on both:

- `bloomScale` runs the bloom chain at a fraction of the frame size. Bloom is a dozen full-screen
HDR passes and the largest single item in the GPU frame β€” measured at 2.3 of 5.8 ms β€” and its
output is blurred by design, so half resolution costs detail nobody can see.
- `lightCount` is the size of the shared point-light pool. Parked lights sit at zero intensity
rather than being added and removed, which avoids a recompile storm, but a parked light is
still evaluated by every lit fragment. Read once at boot; a new value applies on reload.

**Correcting the guess.** The pixel-ratio cap is chosen before the app has seen the device.
With `dynamicResolution` on, sustained overruns walk a render scale down through 0.85 / 0.7 / 0.6
and back up once the frame budget clears. Only active frames count β€” idle frames are throttled on
purpose β€” and the budget is measured against at most 60 FPS, so a 120 FPS cap on a 60 Hz panel is
not mistaken for a device in trouble. Scale reductions also require sustained CPU or GPU
work above budget, so a lightly loaded 30 Hz display keeps its resolution. Without GPU timing
support, only measured CPU work can establish an overrun. A device with no stored preference starts on **Economy** if
it reports a coarse pointer, ≀4 GB of memory or ≀4 cores, so a phone is not handed the desktop
defaults by someone who never opens the panel.

The editor's **Performance** folder drives all of it live, as does the panel's
**Graphics β†’ Quality mode**. **Economy** is 30 FPS / 15 FPS idle, pixel ratio 1, 1024Β² shadows at
15 Hz, half-resolution bloom, adaptive resolution on and four dynamic lights; **Balanced** restores
the shipped quality settings. `idleBloom` can drop bloom entirely while nothing is happening β€” a
larger saving still, at the cost of the look changing every time the pointer moves, which is why
Economy halves the chain instead. Bloom returns during aiming/effects and while paused for editing.

Graphics preferences are stored separately on this device. Artistic preset save/export/import,
load and reset preserve these preferences; old presets' `performance` blocks are ignored.
Imports are validated before any mutation: only known fields and matching types are accepted,
with finite numeric values (editor ranges where registered, otherwise a Β±10,000 hard bound),
valid hex colors and supported cast animations. Reserved prototype keys, arrays and deep trees
are rejected. **Export all presets** includes quarantined entries unchanged as JSON values.
**Download unreadable backup** saves a wholly unreadable collection verbatim, including after
reload or a failed storage backup. These recovery files preserve data for repair; unsupported
entries must be repaired before importing them into this build. Files are limited to 2 MB and collections to 100 presets.

Four concurrent casts β€” the pool's ceiling, whichever slots they came from β€” is what the budget is
set against, and `MAX_CONCURRENT` in `AbilityManager` retires the oldest one past that whichever
element it came from. Arming a far-cast circle costs two draw calls.

Live counters (FPS, live particles, instances, draw calls) are in the top-right of the HUD.
The top-center FPS pill expands into a compact panel with **Metrics**, **Graphics** and
**Compare** tabs. Metrics include frame interval, CPU work, GPU render time when the browser
supports asynchronous timer queries, draw calls and canvas resolution. The panel refreshes twice
per second; it does not force the scene out of idle mode.

Use **Compare β†’ Record 10 seconds**, label the scenario, then **Copy report** to save JSON with
settings, device context and the sample. Keep viewport and scenario consistent between runs.
Reports include an effective-state timeline with frame/time offsets, adaptive scale, canvas size
and actual light-pool size. Mixed states and a light budget awaiting reload are explicitly flagged;
treat these samples as variable conditions when comparing runs.
Changing performance settings or hiding the tab cancels a sample. CPU timings are browser work,
not GPU utilization; GPU timings sample rendering passes, not temperature or power consumption.

Run `npm test` for regression checks covering 15 FPS timing, particle lifetime editing and shadow
refresh cadence. `npm run build` produces the browser build.

---

Expand Down Expand Up @@ -761,3 +831,5 @@ piece of it.

Code is provided as-is for the purposes of this project. The bundled HDR probe and the character
FBX retain their original licences.

Measured idle samples and regression checks: [performance validation](docs/performance-validation.md).
93 changes: 93 additions & 0 deletions docs/performance-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Rendering validation β€” 2026-09-09

[Recorded test/build output](validation/latest-checks.txt) includes the timestamp, tested code
commit and exit codes. Browser measurements below were captured through the diagnostics panel
and browser evaluation tools; they are separate from the Node test output.

Compared production builds of baseline `8c377c8` and the performance branch with the
new diagnostics panel and timing/lifetime fixes. Both used the same installed dependencies,
Chrome 152, a 1440 Γ— 769 CSS-pixel viewport and device pixel ratio 2 on Mac16,5
(macOS 26.5.2). The other test scene was stopped during each sample.

## Idle comparison

Two sequential 10-second samples per version, no casts, using each version's defaults.
The baseline was uncapped, with pixel ratio 1.75 and 4096Β² shadows. The updated version
used 30 FPS idle, pixel ratio 1.25 and 2048Β² shadows. This measures the combined changes,
including lower visual quality; it is not an equal-quality renderer benchmark.

| Sample | FPS | CPU ms/frame | GPU ms/query | Draw calls/frame |
| --- | ---: | ---: | ---: | ---: |
| Before, run 1 | 119.99 | 1.16 | 10.15 | 120 |
| After, run 1 | 29.99 | 2.77 | 4.76 | 58.65 |
| Before, run 2 | 119.98 | 1.11 | 6.69 | 120 |
| After, run 2 | 30.09 | 2.53 | 6.69 | 60.84 |

The comparison harness measured CPU duration around `App.frame()` and sparse asynchronous
GPU elapsed queries around the same call (38–40 completed queries/sample). The updated
panel's own GPU sampler was disabled during this comparison to avoid nesting queries.
The built-in panel normally measures GPU rendering from contact shadows through post-processing.

Draw submissions per frame roughly halved, and the idle frame rate dropped from 120 to 30.
CPU time per frame increased; at the lower frame rate, aggregate measured CPU work per second
was still lower. GPU query durations varied substantially, so these samples do not establish
a stable per-frame GPU speedup. No wattage, battery-life or temperature claim follows from
these numbers. Thermal state and background OS work were not controlled.

Per-frame GPU cost is, however, the wrong quantity for a sustained-load question. What the
same rows say about **duty cycle** is much less ambiguous: before, ~8.4 ms of GPU work landed
in an 8.33 ms frame period, so the GPU was busy essentially all of the time; after, ~5.7 ms
landed in a 33.3 ms period, or roughly 17%. That is about six times less GPU work per second
of wall time, and it is the figure a thermal question is asking about. It still is not a
temperature measurement.

## Regression checks

- `npm test`: 15 FPS preserves wall/simulation time, long stalls remain bounded, the 50 ms
particle minimum remains visible, lifetime edits reveal hidden particles, and shadow
cadence preserves 30 Hz at 30/60/120/144 display rates.
- `npm run build` and `git diff --check` pass.
- Browser: 15 FPS idle advanced simulation by 2.07 seconds over a 2.10-second observation
(observation endpoints fall between rendered frames).
- At an active 60 FPS budget, sun and contact shadows each refreshed 63 times in 2.10 seconds.
- All ten abilities were cast and advanced for one second each without console errors after
removing a duplicate varying declaration in the growth shadow shader. This is a smoke test,
not a visual verification of every full ability lifecycle.
- Panel: one section at a time, graphics tab selection, Escape to close, UI pointer isolation,
10-second sample completion and JSON serialization checked.
- Mobile 390 Γ— 844: panel remains 320 px wide inside the viewport without horizontal overflow.
- Simulated document hiding cancelled animation and recording; simulation remained frozen,
and restoring visibility restarted the loop.

## Remaining measurements

Temperature and power consumption require a separate sustained test with macOS tools.
Repeat both builds under consistent power, brightness, thermal and background-work conditions,
including matched active-cast sequences. The short idle samples above do not replace that test.

## Economy mode and idle bloom follow-up

At Economy settings (30 active / 15 idle FPS, DPR cap 1, 1024Β² shadows at 15 Hz), two
10-second samples in the same idle scene changed only `idleBloom`:

| Bloom while idle | FPS | Draw calls/frame | CPU ms/frame | GPU ms/query |
| --- | ---: | ---: | ---: | ---: |
| On | 15.00 | 61 | 1.06 | 5.80 |
| Off | 15.00 | 48 | 1.14 | 3.53 |

These are short diagnostic samples, not power measurements. Side-by-side inspection of a
frozen idle scene showed no obvious artifact at the default low bloom strength; stronger
artistic bloom settings can make the change more visible. Balanced keeps idle bloom enabled.
Bloom returned when aiming in the browser test. Reset/import preserved Economy preferences.
Security tests cover reserved keys, atomic imports, numeric ranges, invalid types and
independent graphics persistence. The browser console had only a missing favicon request.

## Review fixes β€” September 9, 2026

The reviewed adaptive-rendering changes now initialize the renderer scale before its first pixel-ratio calculation. Preset replacements retire quarantined entries, duplicate names avoid quarantined names, and storage writes commit in-memory changes only after persistence succeeds. If an unreadable collection cannot be backed up, writes leave the original untouched and retry the backup on the next attempt.

Validation: 19 Node tests pass, including five new cases covering quarantine replacement, duplicate collisions, backup failure/retry and failed-write atomicity. Production build and `git diff --check` pass. A browser check using a real WebGLRenderer verifies its initial DPR and canvas dimensions before any settings synchronization, followed by an adaptive resize; no console errors were observed. This does not repeat the full ability lifecycle checks or measure power/temperature.

To repeat the renderer check, start `npm run dev`, open the app, and run `await (await import('/tests/browser/renderer-initialization.js')).checkRendererInitialization()` in the browser console. This browser check is separate from `npm test`.

[Captured command output and browser results](validation/review-fixes-checks.txt).
52 changes: 52 additions & 0 deletions docs/validation/latest-checks.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
Captured at: 2026-09-09T17:21:40.319834+00:00
Code commit: e778e1873ffd2e5f7af9e55b9c71457bc7aafe82

$ npm test

> casting-abilities@1.0.0 test
> node --test tests/*.test.js

βœ” 15 FPS preserves one second of simulation and wall time (0.672083ms)
βœ” particle visibility includes the 50 ms minimum lifetime (0.82925ms)
βœ” hidden particles can reappear after live lifetime editing (0.176916ms)
βœ” shadow cadence preserves 30 refreshes/second at different display rates (0.364042ms)
βœ” reject prototype pollution at every depth without partial mutation (3.470584ms)
βœ” reject types, non-finite numbers, unknown keys, oversized values, arrays and invalid strings (2.21425ms)
βœ” snapshots, old imports, saved collections and resets preserve device preferences (11.269292ms)
βœ” collection imports are atomic and names cannot address inherited properties (7.826291ms)
βœ” profiles persist independently and corrupt device settings are ignored (1.023333ms)
β„Ή tests 9
β„Ή suites 0
β„Ή pass 9
β„Ή fail 0
β„Ή cancelled 0
β„Ή skipped 0
β„Ή todo 0
β„Ή duration_ms 77.302625


Exit code: 0

$ npm run build

> casting-abilities@1.0.0 build
> vite build

vite v8.2.2 building client environment for production...
transforming...
βœ“ 132 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 1.48 kB β”‚ gzip: 0.64 kB
dist/assets/index-dOb6oGZn.css 17.03 kB β”‚ gzip: 4.32 kB
dist/assets/index-Pnh3hOek.js 1,783.70 kB β”‚ gzip: 487.69 kB β”‚ map: 6,337.59 kB

βœ“ built in 207ms


Exit code: 0

$ git diff --check


Exit code: 0
Loading