diff --git a/README.md b/README.md index fd7b459..be939a8 100644 --- a/README.md +++ b/README.md @@ -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. --- @@ -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). diff --git a/docs/performance-validation.md b/docs/performance-validation.md new file mode 100644 index 0000000..5e9b439 --- /dev/null +++ b/docs/performance-validation.md @@ -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). diff --git a/docs/validation/latest-checks.txt b/docs/validation/latest-checks.txt new file mode 100644 index 0000000..7346ae0 --- /dev/null +++ b/docs/validation/latest-checks.txt @@ -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 diff --git a/docs/validation/review-fixes-checks.txt b/docs/validation/review-fixes-checks.txt new file mode 100644 index 0000000..805d69d --- /dev/null +++ b/docs/validation/review-fixes-checks.txt @@ -0,0 +1,217 @@ +Captured at: 2026-09-09T18:37:28.809716+00:00 +Scope: working tree included in this commit; review fixes and adaptive-rendering changes. +Node: v25.6.1 +Warnings in negative preset tests are expected (invalid data and simulated storage quota failures). + +$ npm test + +> casting-abilities@1.0.0 test +> node --test tests/*.test.js + +✔ 15 FPS preserves one second of simulation and wall time (0.676417ms) +✔ particle visibility includes the 50 ms minimum lifetime (0.590375ms) +✔ hidden particles can reappear after live lifetime editing (0.147042ms) +✔ shadow cadence preserves 30 refreshes/second at different display rates (0.771542ms) +✔ adaptive resolution steps down under sustained overrun and back up when it clears (0.448625ms) +✔ adaptive resolution ignores idle frames and a display slower than the cap (0.086375ms) +[performance] ignoring stored preferences Error: Invalid performance setting: maxFps + at validatePerformance (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/PerformancePreferences.js:43:78) + at loadPerformancePreferences (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/PerformancePreferences.js:74:67) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:79:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[performance] ignoring stored preferences Error: Invalid performance setting: maxFps + at validatePerformance (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/PerformancePreferences.js:43:78) + at loadPerformancePreferences (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/PerformancePreferences.js:74:67) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:96:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] preset "Stale" is not loadable and was left in storage Error: Unknown setting: global.knobRemovedInALaterBuild + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:35:48) + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:40:23) + at validatePatch (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:61:10) + at validateSettings (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/settings.js:4520:10) + at PresetManager._read (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:88:24) + at new PresetManager (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:41:25) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:111:19) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] unreadable preset collection SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3) + at JSON.parse () + at PresetManager._read (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:71:19) + at new PresetManager (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:41:25) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:124:19) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] preset "Look" is not loadable and was left in storage Error: Unknown setting: global.removedKnob + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:35:48) + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:40:23) + at validatePatch (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:61:10) + at validateSettings (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/settings.js:4520:10) + at PresetManager._read (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:88:24) + at new PresetManager (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:41:25) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:136:21) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] preset "Look" is not loadable and was left in storage Error: Unknown setting: global.removedKnob + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:35:48) + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:40:23) + at validatePatch (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:61:10) + at validateSettings (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/settings.js:4520:10) + at PresetManager._read (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:88:24) + at new PresetManager (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:41:25) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:136:21) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] preset "Look copy" is not loadable and was left in storage Error: Unknown setting: global.removedKnob + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:35:48) + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:40:23) + at validatePatch (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:61:10) + at validateSettings (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/settings.js:4520:10) + at PresetManager._read (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:88:24) + at new PresetManager (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:41:25) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:151:19) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] unreadable preset collection SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3) + at JSON.parse () + at PresetManager._read (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:71:19) + at new PresetManager (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:41:25) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:166:19) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] backup failed; original presets remain untouched Error: QuotaExceededError + at localStorage.setItem (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:163:53) + at PresetManager._backupUnreadable (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:105:20) + at PresetManager._read (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:79:12) + at new PresetManager (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:41:25) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:166:19) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] backup failed; original presets remain untouched Error: QuotaExceededError + at localStorage.setItem (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:163:53) + at PresetManager._backupUnreadable (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:105:20) + at PresetManager._write (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:116:15) + at PresetManager.save (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:142:15) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:167:24) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] backup failed; original presets remain untouched Error: QuotaExceededError + at localStorage.setItem (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:163:53) + at PresetManager._backupUnreadable (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:105:20) + at PresetManager._write (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:116:15) + at PresetManager.importJSON (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:240:15) + at file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:168:31 + at getActual (node:assert:586:5) + at strict.throws (node:assert:734:24) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:168:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) +[PresetManager] preset "Stale" is not loadable and was left in storage Error: Unknown setting: global.removedKnob + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:35:48) + at visit (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:40:23) + at validatePatch (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/SettingsValidation.js:61:10) + at validateSettings (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/config/settings.js:4520:10) + at PresetManager._read (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:88:24) + at new PresetManager (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:41:25) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:183:19) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] could not persist presets Error: QuotaExceededError + at localStorage.setItem (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:184:40) + at PresetManager._write (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:120:20) + at PresetManager.save (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:142:15) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:185:24) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] could not persist presets Error: QuotaExceededError + at localStorage.setItem (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:184:40) + at PresetManager._write (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:120:20) + at PresetManager.remove (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:169:17) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:186:24) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] could not persist presets Error: QuotaExceededError + at localStorage.setItem (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:184:40) + at PresetManager._write (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:120:20) + at PresetManager.duplicate (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:161:15) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:187:24) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +[PresetManager] could not persist presets Error: QuotaExceededError + at localStorage.setItem (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:184:40) + at PresetManager._write (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:120:20) + at PresetManager.importJSON (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/src/ui/PresetManager.js:240:15) + at file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:188:31 + at getActual (node:assert:586:5) + at strict.throws (node:assert:734:24) + at TestContext. (file:///Users/velrino/Documents/Projects/3js/LinearAbiltyCastingExtendedThreeJS/tests/settings.test.js:188:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1118:25) + at async Test.processPendingSubtests (node:internal/test_runner/test:787:7) +✔ reject prototype pollution at every depth without partial mutation (3.695709ms) +✔ reject types, non-finite numbers, unknown keys, oversized values, arrays and invalid strings (1.812917ms) +✔ snapshots, old imports, saved collections and resets preserve device preferences (11.131416ms) +✔ collection imports are atomic and names cannot address inherited properties (7.833ms) +✔ profiles persist independently and corrupt device settings are ignored (1.548959ms) +✔ a device with no stored preference is guessed at, and a stored one is never overridden (1.112833ms) +✔ one unreadable preset does not take the rest of the collection with it (9.338167ms) +✔ a wholesale unreadable collection is moved aside, not overwritten (2.232125ms) +✔ save replaces a quarantined preset permanently (1.31025ms) +✔ import replaces a quarantined preset permanently (3.661083ms) +✔ duplicates do not overwrite quarantined names (4.010083ms) +✔ failed backup blocks writes until the original can be preserved (4.282708ms) +✔ failed persistence leaves saved presets and quarantine unchanged (6.461125ms) +ℹ tests 19 +ℹ suites 0 +ℹ pass 19 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 113.406042 + +Exit code: 0 + +$ npm run build + +> casting-abilities@1.0.0 build +> vite build + +vite v8.2.2 building client environment for production... +transforming... +✓ 133 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-Bg410Ve-.js 1,788.25 kB │ gzip: 489.02 kB │ map: 6,356.66 kB + +✓ built in 220ms + +Exit code: 0 + +$ git diff --check + +Exit code: 0 + +Browser: Chrome, Vite dev server at 127.0.0.1:4181, isolated context. +await (await import('/tests/browser/renderer-initialization.js')).checkRendererInitialization() +{"initial":{"pixelRatio":1.25,"width":1800,"height":891},"adaptiveRatio":0.75,"passed":true} +Console errors: none. +Scope: renderer initialization and resizing; no new full ability lifecycle or power/temperature measurement. +Test page and dev server closed after validation. diff --git a/package.json b/package.json index b5953e8..ba23920 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "dev": "vite", + "test": "node --test tests/*.test.js", "build": "vite build", "preview": "vite preview" }, diff --git a/src/config/PerformancePreferences.js b/src/config/PerformancePreferences.js new file mode 100644 index 0000000..f153016 --- /dev/null +++ b/src/config/PerformancePreferences.js @@ -0,0 +1,95 @@ +import { settings, DEFAULT_SETTINGS } from './settings.js'; +import { assertPlainObject, assertSafeTree } from './SettingsValidation.js'; + +const KEY = 'casting.performance.v1'; +const allowed = { + maxFps: [30, 60, 120], idleFps: [15, 30, 240], + pixelRatio: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], + shadowResolution: [1024, 2048, 4096], shadowFps: [15, 30, 240], idleBloom: [true, false], + bloomScale: [0.5, 0.75, 1], dynamicResolution: [true, false], lightCount: [3, 4, 6] +}; +export const PERFORMANCE_PROFILES = { + Balanced: { ...DEFAULT_SETTINGS.performance }, + // Economy keeps bloom on and halves its resolution instead of dropping it at + // idle: the saving is comparable, it also applies during a cast, and the + // look no longer changes every time the pointer moves. + Economy: { + maxFps: 30, idleFps: 15, pixelRatio: 1, shadowResolution: 1024, shadowFps: 15, + idleBloom: true, bloomScale: 0.5, dynamicResolution: true, lightCount: 4 + } +}; + +/** + * Where a device with no stored preference should start. + * + * A phone that never opens the graphics panel would otherwise boot straight + * into the desktop defaults. None of these signals is precise — `deviceMemory` + * is coarse and absent on Safari, a coarse pointer covers tablets too — but + * each of them is only ever used to pick a *starting* profile that the user + * can change in two clicks, and `dynamicResolution` corrects the rest. + */ +export function suggestedProfile() { + if (typeof navigator === 'undefined' || typeof matchMedia !== 'function') return 'Balanced'; + const coarse = matchMedia('(pointer: coarse)').matches; + return coarse || (navigator.deviceMemory ?? 8) <= 4 || (navigator.hardwareConcurrency ?? 8) <= 4 + ? 'Economy' + : 'Balanced'; +} + +export function validatePerformance(patch) { + assertPlainObject(patch); + assertSafeTree(patch); + for (const [key, value] of Object.entries(patch)) { + if (!Object.hasOwn(allowed, key) || !allowed[key].includes(value)) throw new Error(`Invalid performance setting: ${key}`); + } + return { ...patch }; +} + +/** + * Persist the current device preferences. + * + * Every caller is a UI change handler, and what it validates is local state we + * just wrote ourselves — so a rejected value here is a bug in a control, not + * hostile input, and must not escape into the widget that fired the event. + * `validatePerformance` still throws on the load path, where the data really + * is untrusted. + */ +export function savePerformancePreferences() { + try { + localStorage.setItem(KEY, JSON.stringify(validatePerformance(settings.performance))); + } catch (error) { + console.warn('[performance] preferences not saved', error); + } +} + +export function loadPerformancePreferences() { + let raw = null; + try { raw = localStorage.getItem(KEY); } catch { /* Storage is optional. */ } + + if (raw !== null) { + // Something was stored, so a choice was made on this device. Honour it, or + // keep the live values if it is corrupt — guessing over a decision the + // user already made would be worse than doing nothing. + try { + if (raw.length <= 2048) Object.assign(settings.performance, validatePerformance(JSON.parse(raw))); + } catch (error) { + console.warn('[performance] ignoring stored preferences', error); + } + return; + } + + // First run. The guess is not saved, so it is re-made every boot until the + // user picks something themselves. + Object.assign(settings.performance, PERFORMANCE_PROFILES[suggestedProfile()]); +} + +export function setPerformanceProfile(name) { + if (!Object.hasOwn(PERFORMANCE_PROFILES, name)) return; + Object.assign(settings.performance, PERFORMANCE_PROFILES[name]); + savePerformancePreferences(); +} + +export function performanceProfile() { + return Object.entries(PERFORMANCE_PROFILES).find(([, profile]) => + Object.keys(profile).every(key => profile[key] === settings.performance[key]))?.[0] ?? 'Custom'; +} diff --git a/src/config/SettingsValidation.js b/src/config/SettingsValidation.js new file mode 100644 index 0000000..2c8e813 --- /dev/null +++ b/src/config/SettingsValidation.js @@ -0,0 +1,70 @@ +const forbidden = new Set(['__proto__', 'prototype', 'constructor']); +const ranges = new WeakMap(); + +export function registerSettingRange(object, key, min, max) { + if (!ranges.has(object)) ranges.set(object, new Map()); + ranges.get(object).set(key, [min, max]); +} + +export function assertPlainObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) || + ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { + throw new Error('Expected a settings object.'); + } +} + +/** Inspect ignored keys too, before merging or storing any imported data. */ +export function assertSafeTree(value, depth = 0) { + if (depth > 12) throw new Error('Preset nesting is too deep.'); + if (!value || typeof value !== 'object') return; + assertPlainObject(value); + for (const [key, child] of Object.entries(value)) { + if (forbidden.has(key)) throw new Error(`Reserved preset key: ${key}`); + assertSafeTree(child, depth + 1); + } +} + +/** Build a validated copy first: an invalid field must never partially apply. */ +export function validatePatch(patch, schema, target, { omitPerformance = false } = {}) { + assertPlainObject(patch); + assertSafeTree(patch); + function visit(input, defaults, live, path = '') { + const result = {}; + for (const [key, value] of Object.entries(input)) { + if (omitPerformance && !path && key === 'performance') continue; + if (!Object.hasOwn(defaults, key)) throw new Error(`Unknown setting: ${path}${key}`); + const expected = defaults[key]; + const label = `${path}${key}`; + if (expected && typeof expected === 'object') { + assertPlainObject(value); + result[key] = visit(value, expected, live[key], `${label}.`); + } else { + if (typeof value !== typeof expected) throw new Error(`Invalid type: ${label}`); + if (typeof value === 'number') { + // Editor ranges are reused at runtime; a hard bound protects headless + // consumers and settings that have no editor control. + const [min, max] = ranges.get(live)?.get(key) ?? [-10000, 10000]; + if (!Number.isFinite(value) || (value !== expected && (value < min || value > max))) { + throw new Error(`Out-of-range setting: ${label}`); + } + } + if (typeof value === 'string') { + const valid = key === 'castAnim' ? ['cast1', 'cast2', 'cast3'].includes(value) + : /^#[0-9a-f]{6}$/i.test(value); + if (!valid) throw new Error(`Invalid value: ${label}`); + } + result[key] = value; + } + } + return result; + } + return visit(patch, schema, target); +} + +export function mergeValidated(patch, target) { + for (const [key, value] of Object.entries(patch)) { + if (value && typeof value === 'object') mergeValidated(value, target[key]); + else target[key] = value; + } + return target; +} diff --git a/src/config/settings.js b/src/config/settings.js index 3a67e8e..9b69856 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -1,3 +1,5 @@ +import { validatePatch, mergeValidated } from './SettingsValidation.js'; + /** * settings.js — the single source of truth for every tweakable value in the sandbox. * @@ -40,6 +42,48 @@ export const CAST_ANIMATIONS = ['cast1', 'cast2', 'cast3']; export const settings = { + /* ------------------------------------------------------------------ */ + /* Render budget */ + /* */ + /* Nothing here changes the look of an ability — these are the knobs */ + /* that decide how much work the frame is allowed to cost. `idleFps` */ + /* and `shadowFps` are the two that matter for sustained power draw: */ + /* an empty stage still has to redraw the character's idle loop, but */ + /* it does not have to do it sixty times a second, and the sun's */ + /* shadow map does not have to be rebuilt from scratch every frame. */ + /* ------------------------------------------------------------------ */ + performance: { + maxFps: 60, + /** Frame cap while nothing is cast, armed or still settling. */ + idleFps: 30, + pixelRatio: 1.25, + shadowResolution: 2048, + /** Refresh rate of the sun shadow map *and* the contact shadow. */ + shadowFps: 30, + idleBloom: true, + /** + * Fraction of the frame size the bloom chain runs at. + * + * Bloom is a dozen full-screen HDR passes and, measured on this scene, the + * single largest item in the GPU frame — 2.3 of 5.8 ms at Economy + * settings. Halving its resolution recovers most of that during casts as + * well as at rest, which switching it off at idle cannot do, and without + * the visible pop that switching brings. The result is blurred by design, + * so the lost detail is not detail anyone can see. + */ + bloomScale: 1, + /** Let sustained frame-time overruns walk the render scale down. */ + dynamicResolution: false, + /** + * Dynamic point lights kept in the scene. + * + * Read once at boot: the count is part of the lighting program's cache + * key, so changing it mid-session recompiles every material in the scene. + * Parked lights still cost a per-fragment evaluation, which is why the + * low-power profile carries fewer of them. + */ + lightCount: 6 + }, /* ------------------------------------------------------------------ */ /* Global multipliers */ /* ------------------------------------------------------------------ */ @@ -4472,24 +4516,21 @@ export const DEFAULT_SETTINGS = structuredClone(settings); * Deep-merge a plain object into `settings` in place. * Existing object identity is preserved so every live binding keeps working. */ -export function applySettings(patch, target = settings) { - for (const key of Object.keys(patch)) { - const value = patch[key]; - if (value && typeof value === 'object' && !Array.isArray(value)) { - if (target[key] && typeof target[key] === 'object') applySettings(value, target[key]); - } else if (key in target) { - target[key] = value; - } - } - return target; +export function validateSettings(patch) { + return validatePatch(patch, DEFAULT_SETTINGS, settings, { omitPerformance: true }); +} + +export function applySettings(patch) { + return mergeValidated(validateSettings(patch), settings); } -/** Restore every value to the shipped defaults (in place). */ +/** Reset artistic settings while preserving this device's graphics choices. */ export function resetSettings() { - applySettings(structuredClone(DEFAULT_SETTINGS)); + applySettings(DEFAULT_SETTINGS); } -/** Serialisable clone of the current state. */ +/** Artistic preset: device performance preferences are stored separately. */ export function snapshotSettings() { - return structuredClone(settings); + const { performance, ...artistic } = settings; + return structuredClone(artistic); } diff --git a/src/core/AdaptiveResolution.js b/src/core/AdaptiveResolution.js new file mode 100644 index 0000000..a2291cc --- /dev/null +++ b/src/core/AdaptiveResolution.js @@ -0,0 +1,93 @@ +/** Render scales, coarsest last. Each step is roughly 30% less fill. */ +const STEPS = [1, 0.85, 0.7, 0.6]; +/** Seconds of *active* time behind every decision. */ +const WINDOW = 2; +/** Slower than this multiple of the budget for a whole window → step down. */ +const OVER = 1.35; +/** Faster than this for `CALM` consecutive windows → step back up. */ +const UNDER = 1.1; +const CALM = 3; +// A high user cap is a preference, not evidence of the display refresh rate. +const CEILING = 60; + +/** + * Cadence alone cannot distinguish a slow display from an overloaded renderer. + * Require measured CPU/GPU work above budget before lowering resolution. + * Without timing evidence we conservatively keep the current pixel budget. + */ +export class AdaptiveResolution { + constructor() { + this.index = 0; + this._elapsed = 0; + this._frames = 0; + this._workTotal = 0; + this._gpuTotal = 0; + this._gpuSamples = 0; + this._calm = 0; + } + + get scale() { + return STEPS[this.index]; + } + + /** Back to full resolution, e.g. when the setting is switched off. */ + reset() { + this.index = 0; + this._elapsed = 0; + this._frames = 0; + this._workTotal = 0; + this._gpuTotal = 0; + this._gpuSamples = 0; + this._calm = 0; + } + + /** + * @param {number} dt wall seconds since the previous frame + * @param {number} targetFps the cap the loop is aiming at right now + * @param {boolean} active whether this frame was drawn at the active rate + * @param {number} workMs measured CPU/GPU work, excluding frame-cap waiting + * @param {number|null} gpuMs newly completed GPU timing, if available + * @returns {boolean} whether `scale` changed + */ + sample(dt, targetFps, active, workMs = 0, gpuMs = null) { + // A stall (tab switch, shader compile, GC) is not a resolution problem. + if (!active || !(dt > 0) || dt > 1) return false; + + const budget = 1 / Math.min(CEILING, Math.max(1, targetFps)); + this._workTotal += Number.isFinite(workMs) ? workMs : 0; + if (Number.isFinite(gpuMs)) { + this._gpuTotal += gpuMs; + this._gpuSamples++; + } + this._elapsed += dt; + this._frames++; + if (this._elapsed < WINDOW) return false; + + const busy = Math.max(this._workTotal / this._frames, + this._gpuSamples >= 2 ? this._gpuTotal / this._gpuSamples : 0) > budget * OVER * 1000; + const measured = this._elapsed / this._frames; + this._elapsed = 0; + this._frames = 0; + this._workTotal = 0; + this._gpuTotal = 0; + this._gpuSamples = 0; + + if (measured > budget * OVER && busy) { + this._calm = 0; + if (this.index >= STEPS.length - 1) return false; + this.index++; + return true; + } + + if (measured < budget * UNDER) { + this._calm++; + if (this.index === 0 || this._calm < CALM) return false; + this._calm = 0; + this.index--; + return true; + } + + this._calm = 0; + return false; + } +} diff --git a/src/core/App.js b/src/core/App.js index c74c78f..6513251 100644 --- a/src/core/App.js +++ b/src/core/App.js @@ -1,6 +1,10 @@ +import { loadPerformancePreferences } from '../config/PerformancePreferences.js'; import { Vector3, MathUtils } from 'three'; import { Renderer } from './Renderer.js'; +import { AdaptiveResolution } from './AdaptiveResolution.js'; +import { Cadence } from './Cadence.js'; +import { PerformancePanel } from '../ui/PerformancePanel.js'; import { Time } from './Time.js'; import { CameraRig } from './CameraRig.js'; import { frame } from './FrameUniforms.js'; @@ -37,6 +41,14 @@ import { settings, ELEMENTS, ELEMENT_META } from '../config/settings.js'; const HDR_URL = './hdri/spruit_sunrise.hdr'; const SERPENT_URL = './models/snake.glb'; +/** + * How long the loop keeps running at full rate after the last thing happened. + * + * Long enough to cover what outlives the effect that caused it — the camera + * easing back onto the character, screen shake bleeding off, a flash decaying. + */ +const ACTIVE_GRACE = 1.5; + /** Hand the page back for one frame, so the loading veil can repaint. */ const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); @@ -65,11 +77,18 @@ async function waitFor(test, timeout) { */ export class App { constructor(canvas) { + loadPerformancePreferences(); this.canvas = canvas; this.time = new Time(); this.elapsed = 0; this.paused = false; this._raf = 0; + /** Wall-clock deadline until which the loop runs at `maxFps`. */ + this._activeUntil = 0; + /** Seconds of real time since the sun's shadow map was last rebuilt. */ + this._shadowCadence = new Cadence(); + /** Corrects the pixel-ratio guess once the device has been observed. */ + this._resolution = new AdaptiveResolution(); /** * Seconds left before each ability can be armed again. Per element, so @@ -79,7 +98,7 @@ export class App { /* ---- core ---- */ this.renderer = new Renderer(canvas); - this.rig = new CameraRig(canvas); + this.rig = new CameraRig(canvas, { onInteraction: () => this._markActive() }); this.camera = this.rig.camera; this.environment = new Environment(this.renderer, this.camera); @@ -152,6 +171,9 @@ export class App { onToast: (message) => this.hud.showToast(message) }); + this.performancePanel = new PerformancePanel(this.renderer.gl, { + onSettingsChange: () => this.editor.refresh() + }); this._bindEvents(); this.selectAbility(ELEMENTS[0], { silent: true }); @@ -172,12 +194,19 @@ export class App { this.dust.setPixelRatio(pixelRatio); }); - this.input.on('pointer:move', (pointer) => this.aim.point(pointer)); + this.input.on('pointer:move', (pointer) => { + this._markActive(); + this.aim.point(pointer); + }); this.input.on('pointer:confirm', (pointer) => { + this._markActive(); this.aim.point(pointer); this.aim.confirm(); }); - this.input.on('action', (action, slot) => this._handleAction(action, slot)); + this.input.on('action', (action, slot) => { + this._markActive(); + this._handleAction(action, slot); + }); this.aim.on('cast', (origin, direction, distance) => this._cast(origin, direction, distance)); this.aim.on('reject', () => this.hud.showToast('Too close — aim further out')); @@ -235,6 +264,7 @@ export class App { /** Select an ability and arm it, unless it is still cooling down. */ armAbility(element = this.element) { + this._markActive(); if ((this.cooldowns.get(element) ?? 0) > 0) { this.hud.showToast('Not ready'); return; @@ -246,6 +276,7 @@ export class App { } _cast(origin, direction, distance) { + this._markActive(); const element = this.element; this.abilities.cast(origin, direction, distance, element); this.cooldowns.set(element, Math.max(0, settings[element].cooldown)); @@ -308,6 +339,11 @@ export class App { this.loading.hide(); this.hud.reveal(); + const unreadable = this.editor.unreadablePresets; + if (unreadable) { + this.hud.showToast(`${unreadable} saved preset(s) could not be read — left untouched in storage`, 4000); + } + this.start(); } @@ -412,6 +448,8 @@ export class App { } // Same order as `frame()`, so every pass sees what it will see in flight. + // Nothing here is throttled or skipped: the warm-up has to touch every + // program the pipeline can ask for, which is the whole point of it. this.renderer.gl.shadowMap.needsUpdate = true; this.contactShadows.render(this.scene); this.post.sync(this.elapsed, this.flash); @@ -421,27 +459,93 @@ export class App { for (const node of culled) node.frustumCulled = true; } + /* ------------------------------------------------------------------ */ + /* Frame budget */ + /* ------------------------------------------------------------------ */ + + /** + * Is anything on screen that samples the depth buffer or writes a distortion + * offset? Ability meshes, particles and burst shells are the only three, so + * when all of them are gone both auxiliary passes have nothing to feed and + * `PostProcessing#render` skips them. + */ + get _liveEffects() { + return ( + this.abilities.active.length > 0 || + this.particles.live || + this.bursts.active.length > 0 + ); + } + + /** + * Keep the loop at `maxFps` for the next `seconds`. + * + * An empty stage is not a still image — the character keeps playing its idle + * loop — so the frame cannot simply be skipped. It can be *paid for less + * often*: nothing about a breathing idle needs sixty frames a second, and + * halving the rate halves every pass in the pipeline at once. Input and live + * effects push it straight back up, and the grace period covers the tail + * (camera easing, shake, flash) without every subsystem having to report in. + */ + _markActive(seconds = ACTIVE_GRACE) { + this._activeUntil = Math.max(this._activeUntil, performance.now() + seconds * 1000); + } + + /** Frames per second the loop should be running at right now. */ + _targetFps() { + const perf = settings.performance; + const max = Math.max(1, perf.maxFps); + if (performance.now() < this._activeUntil) return max; + return Math.min(max, Math.max(1, perf.idleFps)); + } + start() { - this.time.reset(); - const loop = () => { - this._raf = requestAnimationFrame(loop); - this.frame(); - }; - this._raf = requestAnimationFrame(loop); + if (this._running) return; + this._running = true; + // The reveal, the first camera settle and any early input deserve the full + // rate whether or not anything has been cast yet. + this._markActive(3); + document.addEventListener('visibilitychange', this._onVisibilityChange); + this._onVisibilityChange(); } + _onVisibilityChange = () => { + cancelAnimationFrame(this._raf); + this._raf = 0; + this.time.reset(); + this.performancePanel.resetWindow(); + this._lastFrame = null; + if (this._running && !document.hidden) this._raf = requestAnimationFrame(this._loop); + }; + + _loop = (timestamp) => { + if (!this._running || document.hidden) return; + this._raf = requestAnimationFrame(this._loop); + const interval = 1000 / this._targetFps(); + const elapsed = this._lastFrame === null ? interval : timestamp - this._lastFrame; + // Allow a small rAF rounding error without accidentally halving the rate. + if (elapsed < interval - 0.5) return; + this._lastFrame = timestamp - (elapsed >= interval ? elapsed % interval : 0); + this.frame(); + }; + stop() { + this._running = false; cancelAnimationFrame(this._raf); + this._raf = 0; + document.removeEventListener('visibilitychange', this._onVisibilityChange); } /* ------------------------------------------------------------------ */ frame() { + const cpuStart = performance.now(); const gl = this.renderer.gl; gl.info.reset(); - const raw = this.time.tick(); - const dt = this.paused ? 0 : raw * settings.global.timeScale; + const simulationDelta = this.time.tick(); + const raw = this.time.rawDelta; + const dt = this.paused ? 0 : simulationDelta * settings.global.timeScale; this.elapsed += dt; /* ---- shared uniforms ---- */ @@ -481,7 +585,7 @@ export class App { // out of it. this.dummies.update(dt, this.character.position); this.dummies.applyHits(this.abilities.active); - this.particles.flush(); + this.particles.flush(this.elapsed); this.decals.update(dt); this.bursts.update(dt); this.lights.update(dt); @@ -494,26 +598,61 @@ export class App { this.flash.update(raw); this.rig.update(raw); + /* ---- frame budget ---- */ + // Anything still running keeps the loop at full rate; `_markActive` is + // called every frame it is true, so the grace period always counts from + // the last busy frame rather than from the cast that started it. + const live = this._liveEffects; + if (live || this.decals.active.length > 0 || this.aim.isArmed) this._markActive(); + + // Only frames the loop was actually trying to deliver at `maxFps` carry a + // usable signal; the scaler ignores the rest. + const active = this.paused || performance.now() < this._activeUntil; + + this.performancePanel.beginGpu(); this.contactShadows.setPosition(this.character.position.x, this.character.position.z); - this.contactShadows.render(this.scene); + this.contactShadows.render(this.scene, raw); /* ---- render ---- */ - // Exactly one cascade shadow update per frame (see Renderer). - gl.shadowMap.needsUpdate = true; + // At most one shadow update per frame (see Renderer), and by default only + // every other one: rebuilding a 2048² map from the whole world is the most + // expensive single thing in the frame, and re-running it for a character + // mid-idle-loop buys nothing you can see through the PCF blur. + if (this._shadowCadence.due(raw, settings.performance.shadowFps)) { + gl.shadowMap.needsUpdate = true; + } + this.post.sync(this.elapsed, this.flash); - this.post.render(); + this.post.render(live, active); + this.performancePanel.endGpu(); /* ---- readouts ---- */ for (const element of ELEMENTS) { this.hud.setCooldown(element, this.cooldowns.get(element) ?? 0, settings[element].cooldown); } this.hud.setArmed(this.aim.isArmed); - this.hud.update(raw, () => ({ - particles: this.particles.countLive(this.elapsed), - calls: gl.info.render.calls, - spikes: this.abilities.active.reduce((total, ability) => total + ability.instanceCount, 0), - abilities: this.abilities.active.length - })); + const cpuMs = performance.now() - cpuStart; + const gpu = this.performancePanel.gpu; + // Only use a newly completed GPU query; old timings must not sustain an overrun. + this._lastWorkMs = cpuMs; + this._lastGpuMs = gpu.completed !== this._lastGpuCompleted ? gpu.latest : null; + this._lastGpuCompleted = gpu.completed; + this.performancePanel.record(raw, cpuMs, { + targetFps: this._targetFps(), + mode: this.paused ? 'Paused' : active ? 'Active' : 'Idle', + scale: this.renderer.resolutionScale, + lightCount: this.lights.lights.length + }); + + // Decide the next frame's scale only after reporting the canvas just rendered. + if (settings.performance.dynamicResolution) { + if (this._resolution.sample(raw, this._targetFps(), active, this._lastWorkMs, this._lastGpuMs)) { + this.renderer.resolutionScale = this._resolution.scale; + } + } else if (this.renderer.resolutionScale !== 1) { + this._resolution.reset(); + this.renderer.resolutionScale = 1; + } } /* ------------------------------------------------------------------ */ @@ -534,6 +673,7 @@ export class App { this.contactShadows.dispose(); this.post.dispose(); this.environment.dispose(); + this.performancePanel.dispose(); this.editor.dispose(); this.rig.dispose(); this.renderer.dispose(); diff --git a/src/core/Cadence.js b/src/core/Cadence.js new file mode 100644 index 0000000..3380eb0 --- /dev/null +++ b/src/core/Cadence.js @@ -0,0 +1,20 @@ +/** Periodic refresh budget that preserves remainder across display frames. */ +export class Cadence { + constructor() { + this.elapsed = 0; + this.first = true; + } + + due(dt, fps) { + const interval = 1 / Math.max(1, fps); + this.elapsed += dt; + if (this.first || !Number.isFinite(dt)) { + this.first = false; + this.elapsed = 0; + return true; + } + if (this.elapsed + 1e-6 < interval) return false; + this.elapsed = Math.max(0, this.elapsed - Math.floor((this.elapsed + 1e-6) / interval) * interval); + return true; + } +} diff --git a/src/core/CameraRig.js b/src/core/CameraRig.js index 3627830..4648a78 100644 --- a/src/core/CameraRig.js +++ b/src/core/CameraRig.js @@ -18,7 +18,8 @@ const _desiredTarget = new Vector3(); * - The rig gently drifts its look-at point toward whatever ability is casting. */ export class CameraRig { - constructor(domElement) { + constructor(domElement, { onInteraction = () => {} } = {}) { + this.onInteraction = onInteraction; this.camera = new PerspectiveCamera( settings.camera.fov, window.innerWidth / window.innerHeight, @@ -50,6 +51,8 @@ export class CameraRig { this.controls.target.set(0, settings.camera.targetHeight, 0); this.controls.update(); + this.controls.addEventListener('start', this.onInteraction); + this.controls.addEventListener('change', this.onInteraction); // Actual distance, eased toward `settings.camera.distance` so a wheel flick // glides instead of snapping. @@ -63,6 +66,7 @@ export class CameraRig { /** Wheel zoom. Multiplicative, so each notch feels the same at any distance. */ _onWheel(event) { event.preventDefault(); + this.onInteraction(); const cam = settings.camera; // Firefox reports lines (deltaMode 1) and pages (2) rather than pixels. @@ -134,6 +138,8 @@ export class CameraRig { dispose() { this.domElement.removeEventListener('wheel', this._onWheel); + this.controls.removeEventListener('start', this.onInteraction); + this.controls.removeEventListener('change', this.onInteraction); this.controls.dispose(); } } diff --git a/src/core/GpuTimer.js b/src/core/GpuTimer.js new file mode 100644 index 0000000..2fc9cd2 --- /dev/null +++ b/src/core/GpuTimer.js @@ -0,0 +1,50 @@ +/** Sparse asynchronous GPU timings. Never wait for a query on the main thread. */ +export class GpuTimer { + constructor(gl) { + this.gl = gl; + this.ext = gl.getExtension('EXT_disjoint_timer_query_webgl2'); + this.pending = []; + this.active = null; + this.nextSample = 0; + this.latest = null; + this.completed = 0; + } + + begin(now) { + const gl = this.gl; + const ext = this.ext; + if (!ext || gl.isContextLost()) return; + if (!this.pending.length && now < this.nextSample) return; + if (gl.getParameter(ext.GPU_DISJOINT_EXT)) { + for (const query of this.pending) gl.deleteQuery(query); + this.pending.length = 0; + this.latest = null; + return; + } + while (this.pending.length && gl.getQueryParameter(this.pending[0], gl.QUERY_RESULT_AVAILABLE)) { + const query = this.pending.shift(); + this.latest = gl.getQueryParameter(query, gl.QUERY_RESULT) / 1e6; + this.completed++; + gl.deleteQuery(query); + } + if (now < this.nextSample || this.pending.length >= 4) return; + const query = gl.createQuery(); + if (!query) return; + gl.beginQuery(ext.TIME_ELAPSED_EXT, query); + this.active = query; + this.nextSample = now + 250; + } + + end() { + if (!this.active) return; + this.gl.endQuery(this.ext.TIME_ELAPSED_EXT); + this.pending.push(this.active); + this.active = null; + } + + dispose() { + this.end(); + for (const query of this.pending) this.gl.deleteQuery(query); + this.pending.length = 0; + } +} diff --git a/src/core/Renderer.js b/src/core/Renderer.js index dc633e8..cf39156 100644 --- a/src/core/Renderer.js +++ b/src/core/Renderer.js @@ -1,6 +1,6 @@ import { WebGLRenderer, - PCFSoftShadowMap, + PCFShadowMap, ACESFilmicToneMapping, SRGBColorSpace } from 'three'; @@ -12,9 +12,22 @@ import { settings } from '../config/settings.js'; */ export class Renderer { constructor(canvas) { + /** + * Multiplier applied on top of the pixel-ratio cap, owned by + * `AdaptiveResolution`. 1 unless the device has proved it cannot keep up. + */ + this.resolutionScale = 1; + this.gl = new WebGLRenderer({ canvas, - antialias: true, + // Deliberately off. Every pixel this app draws lands in one of the + // composer's render targets, which are not multisampled; the only thing + // ever drawn to the default framebuffer is the grade pass' full-screen + // quad, whose single edge is the edge of the screen. Asking for MSAA + // here therefore buys no antialiasing at all and costs a 4x + // multisampled back buffer plus a full resolve on every swap. Edge AA, + // if it is ever wanted, belongs in the composer (SMAA/FXAA). + antialias: false, powerPreference: 'high-performance', stencil: false, alpha: false @@ -24,7 +37,9 @@ export class Renderer { this.gl.setSize(window.innerWidth, window.innerHeight, false); this.gl.shadowMap.enabled = true; - this.gl.shadowMap.type = PCFSoftShadowMap; + // `PCFSoftShadowMap` is deprecated as of r185 — three downgrades it to + // `PCFShadowMap` internally and warns once — so ask for it by name. + this.gl.shadowMap.type = PCFShadowMap; // The frame renders the scene several times (depth prepass, distortion, // contact shadows, main pass). Automatic updates would rebuild the cascade // shadow maps for every one of them, so the app flags a single update per @@ -44,7 +59,10 @@ export class Renderer { /** Cap the pixel ratio: 4K + heavy transparency is not worth the fill rate. */ targetPixelRatio() { - return Math.min(window.devicePixelRatio || 1, 1.75); + const cap = Math.min(window.devicePixelRatio || 1, Math.max(0.5, settings.performance.pixelRatio)); + // `syncSettings` compares this against the live ratio every frame, so a + // change to either the cap or the scale is picked up on the next one. + return Math.max(0.5, cap * this.resolutionScale); } get domElement() { @@ -71,6 +89,7 @@ export class Renderer { /** Called once per frame before rendering so the editor can drive exposure. */ syncSettings() { this.gl.toneMappingExposure = settings.post.exposure; + if (this.gl.getPixelRatio() !== this.targetPixelRatio()) this.handleResize(); } dispose() { diff --git a/src/core/Time.js b/src/core/Time.js index fa8896e..304d91a 100644 --- a/src/core/Time.js +++ b/src/core/Time.js @@ -1,22 +1,27 @@ /** * Frame timer. * - * A three-line replacement for THREE.Clock (deprecated in recent releases) that - * also owns the delta clamp: a background tab or a long shader compile must - * never hand the simulation a multi-second step. + * Two clocks from one tick. `delta` drives the simulation and is bounded at + * 100 ms — enough for the supported 15 FPS idle mode, but never enough for a + * stall to teleport an ability. `rawDelta` is unbounded wall time, on purpose: + * cooldowns, the camera and the aim indicator are all saturating or clamped, + * and they should reflect how long the user actually waited. Visibility + * changes reset both. */ export class Time { - constructor(maxDelta = 1 / 20) { + constructor(maxDelta = 0.1) { this.maxDelta = maxDelta; this.elapsed = 0; this.delta = 0; + this.rawDelta = 0; this._last = performance.now() / 1000; } /** @returns {number} clamped seconds since the previous tick */ tick() { const now = performance.now() / 1000; - this.delta = Math.min(now - this._last, this.maxDelta); + this.rawDelta = Math.max(0, now - this._last); + this.delta = Math.min(this.rawDelta, this.maxDelta); this._last = now; this.elapsed += this.delta; return this.delta; @@ -26,5 +31,6 @@ export class Time { reset() { this._last = performance.now() / 1000; this.delta = 0; + this.rawDelta = 0; } } diff --git a/src/effects/LightPool.js b/src/effects/LightPool.js index 1916f83..3dc9125 100644 --- a/src/effects/LightPool.js +++ b/src/effects/LightPool.js @@ -2,6 +2,7 @@ import { PointLight } from 'three'; import { settings } from '../config/settings.js'; import { damp } from '../utils/math.js'; +/** Ceiling. `settings.performance.lightCount` picks the actual size at boot. */ const POOL_SIZE = 6; /** @@ -11,11 +12,19 @@ const POOL_SIZE = 6; * removing a light changes the lighting program's cache key and forces three to * recompile *every* material, which is the classic cause of a hitch when a VFX * spawns. Unused lights simply sit at zero intensity. + * + * That parking is not free — a light at zero intensity is still evaluated by + * every lit fragment of the floor, the character and the targets — so the size + * is a budget, read once here. Changing it later is what the comment above + * warns about, so a new value applies on the next reload; `acquire` already + * returns null past the end, and an ability without a light simply goes + * unlit rather than failing. */ export class LightPool { constructor(scene) { this.lights = []; - for (let i = 0; i < POOL_SIZE; i++) { + const count = Math.max(1, Math.min(POOL_SIZE, settings.performance.lightCount)); + for (let i = 0; i < count; i++) { const light = new PointLight(0xffffff, 0, 10, 2); light.castShadow = false; light.intensity = 0; diff --git a/src/materials/GrowthVineMaterial.js b/src/materials/GrowthVineMaterial.js index e4251c4..dd2d198 100644 --- a/src/materials/GrowthVineMaterial.js +++ b/src/materials/GrowthVineMaterial.js @@ -590,7 +590,6 @@ export function createVineMaterial(environment, shape) { uniform float uWitherEdge; varying vec3 vGrowthWorld; varying float vGrowthT; - varying float vGrowthT; varying float vGrowthSeed; ${WITHER_GLSL} `, diff --git a/src/particles/ParticleEngine.js b/src/particles/ParticleEngine.js index 8f2b569..cfe4358 100644 --- a/src/particles/ParticleEngine.js +++ b/src/particles/ParticleEngine.js @@ -12,6 +12,8 @@ export class ParticleEngine { constructor(scene) { this.scene = scene; this.systems = new Map(); + /** Set by `flush`: is any system still showing a particle? */ + this.live = false; } /** @@ -30,9 +32,21 @@ export class ParticleEngine { return system; } - /** Upload the frame's spawn data. Called once, after all abilities update. */ - flush() { - for (const system of this.systems.values()) system.flush(); + /** + * Upload the frame's spawn data and hide the systems that have gone empty. + * + * Called once, after all abilities have updated, so a system that spawned on + * this frame is already visible on the frame it spawned. + * + * @param {number} time simulation time, the clock `emit` is given + */ + flush(time) { + let live = false; + for (const system of this.systems.values()) { + const emitted = system.flush(); + if (system.sync(time, emitted)) live = true; + } + this.live = live; } /** @@ -47,6 +61,7 @@ export class ParticleEngine { reset() { for (const system of this.systems.values()) system.reset(); + this.live = false; } dispose() { diff --git a/src/particles/ParticleSystem.js b/src/particles/ParticleSystem.js index 37b2fef..629356e 100644 --- a/src/particles/ParticleSystem.js +++ b/src/particles/ParticleSystem.js @@ -165,6 +165,13 @@ export class ParticleSystem { this._ranges = []; this._dirty = false; + + // Conservative bounds let empty pools skip draws without scanning slots. + // Retain them until reset: increasing uLifeScale can reveal old particles. + // Keep the mesh visible initially so boot warm-up compiles its shaders; + // the first engine update hides unused systems. + this._lastSpawn = -Infinity; + this._maxLife = 0; } get object3D() { @@ -223,6 +230,10 @@ export class ParticleSystem { const d = this.data; + // Upper bound on when this batch can still be on screen (see the + // liveness note in the constructor). + this._lastSpawn = Math.max(this._lastSpawn, time); + for (let n = 0; n < count; n++) { const i = this.cursor; this.cursor = (this.cursor + 1) % this.capacity; @@ -275,6 +286,7 @@ export class ParticleSystem { // --- scalars -------------------------------------------------- d.spawn[i] = time; d.life[i] = Math.max(0.05, life * (1 + (Math.random() - 0.5) * 2 * lifeVariance)); + this._maxLife = Math.max(this._maxLife, d.life[i]); d.size[i] = Math.max(0.001, size * (1 + (Math.random() - 0.5) * 2 * sizeVariance)); d.seed[i] = Math.random(); d.spin[i] = (Math.random() - 0.5) * 2 * spin; @@ -293,6 +305,38 @@ export class ParticleSystem { } + /** + * Could any particle still be on screen at `time`? + * + * O(1), and deliberately pessimistic — see the constructor. + * + * @param {number} time simulation time, the same clock `emit` is given + */ + hasLive(time) { + return time <= this._lastSpawn + this._maxLife * this.uniforms.uLifeScale.value; + } + + /** + * Hide the system while every one of its particles is dead. + * + * Called once a frame by the engine, after the abilities have emitted, so a + * system that spawned this frame is visible on the frame it spawned. + * + * @param {number} time simulation time, the clock `emit` is given + * @param {boolean} emitted whether this frame spawned into the system + * @returns {boolean} whether the system is still live + */ + sync(time, emitted) { + // Anchor a fresh batch to the frame clock rather than to whatever stamp + // the caller put on it: emitters read the time from a shared uniform that + // may be a frame stale, and being early here would blink the system out. + if (emitted) this._lastSpawn = Math.max(this._lastSpawn, time); + + const live = this.hasLive(time); + this.mesh.visible = live; + return live; + } + /** * Exact number of particles still alive. * @@ -323,9 +367,13 @@ export class ParticleSystem { } } - /** Upload only the slots that changed this frame. */ + /** + * Upload only the slots that changed this frame. + * + * @returns {boolean} whether anything was emitted since the last flush + */ flush() { - if (!this._dirty) return; + if (!this._dirty) return false; for (const [key, itemSize] of Object.entries(FLOATS)) { const attribute = this.attributes[key]; attribute.needsUpdate = true; @@ -336,6 +384,7 @@ export class ParticleSystem { } this._ranges.length = 0; this._dirty = false; + return true; } /** Convenience for setting the 4-stop lifetime gradient from hex strings. */ @@ -354,6 +403,9 @@ export class ParticleSystem { this._ranges.length = 0; this._dirty = false; this.cursor = 0; + this._lastSpawn = -Infinity; + this._maxLife = 0; + this.mesh.visible = false; } dispose() { diff --git a/src/postprocessing/PostProcessing.js b/src/postprocessing/PostProcessing.js index 2d00c01..6e9bc95 100644 --- a/src/postprocessing/PostProcessing.js +++ b/src/postprocessing/PostProcessing.js @@ -18,6 +18,25 @@ import { frame } from '../core/FrameUniforms.js'; import { settings } from '../config/settings.js'; const DISTORTION_CLEAR = new Color(0.5, 0.5, 0.0); +const DISTORTION_MASK = 1 << LAYER.DISTORTION; + +/** + * Is there a visible mesh on the distortion layer anywhere under `node`? + * + * `Object3D#traverseVisible` would do this in one line but cannot stop at the + * first hit, and the answer here is a boolean: the pooled abilities put a few + * hundred nodes in the scene and this runs every frame. + */ +function hasVisibleDistortion(node) { + if (node.visible === false) return false; + if (node.isMesh === true && (node.layers.mask & DISTORTION_MASK) !== 0) return true; + + const children = node.children; + for (let i = 0; i < children.length; i++) { + if (hasVisibleDistortion(children[i])) return true; + } + return false; +} /** * The full render pipeline. @@ -29,7 +48,16 @@ const DISTORTION_CLEAR = new Color(0.5, 0.5, 0.0); * 3. composer — scene → refraction → bloom → tone map → grade * * Passes 1 and 2 run at half resolution: both are only ever read as smooth, - * low-frequency data, so full resolution would be wasted fill rate. + * low-frequency data, so full resolution would be wasted fill rate. They are + * also both *conditional* — see `render`. + * + * One subtlety ties the three together: `WebGLShadowMap` picks its casters by + * testing them against the layers of the camera the frame is being *rendered* + * with, not against the shadow camera's. Passes 1 and 2 pin the camera to a + * single layer, so whichever of them happens to run first would decide what + * ends up in the sun's shadow map — dropping every `LAYER.SHAPED` caster in + * the depth prepass' case. Both therefore hold the flag back and let the main + * pass, the only one that still sees the whole scene, build the map. */ export class PostProcessing { constructor(renderer, scene, camera) { @@ -77,6 +105,14 @@ export class PostProcessing { settings.post.bloomThreshold ); this.composer.addPass(this.bloomPass); + // Bloom is sized in CSS pixels rather than device pixels, and scaled again + // by the render budget. Its own composite still writes back into the + // composer's full-resolution buffer, so a smaller chain costs detail the + // blur was going to destroy anyway. + this._width = size.x; + this._height = size.y; + this._bloomScale = null; + this._applyBloomSize(); // Tone mapping + sRGB conversion happen here; everything before is linear HDR. this.outputPass = new OutputPass(); @@ -90,6 +126,16 @@ export class PostProcessing { this._clearColor = new Color(); } + /** Resize the bloom chain to `settings.performance.bloomScale`. */ + _applyBloomSize() { + const scale = Math.min(1, Math.max(0.25, settings.performance.bloomScale)); + this._bloomScale = settings.performance.bloomScale; + this.bloomPass.setSize( + Math.max(2, Math.round(this._width * scale)), + Math.max(2, Math.round(this._height * scale)) + ); + } + /** Opaque depth for soft particles. */ _renderDepth() { const gl = this.gl; @@ -102,6 +148,10 @@ export class PostProcessing { gl.getClearColor(this._clearColor); const previousAlpha = gl.getClearAlpha(); + // Not this pass' job (see the class comment). + const shadowsPending = gl.shadowMap.needsUpdate; + gl.shadowMap.needsUpdate = false; + scene.background = null; scene.overrideMaterial = this.depthMaterial; camera.layers.set(LAYER.WORLD); @@ -115,6 +165,7 @@ export class PostProcessing { scene.overrideMaterial = previousOverride; camera.layers.mask = mask; gl.setClearColor(this._clearColor, previousAlpha); + gl.shadowMap.needsUpdate = shadowsPending; } /** Screen-space refraction offsets. */ @@ -128,6 +179,10 @@ export class PostProcessing { gl.getClearColor(this._clearColor); const previousAlpha = gl.getClearAlpha(); + // Not this pass' job either (see the class comment). + const shadowsPending = gl.shadowMap.needsUpdate; + gl.shadowMap.needsUpdate = false; + scene.background = null; camera.layers.set(LAYER.DISTORTION); @@ -140,6 +195,7 @@ export class PostProcessing { camera.layers.mask = mask; gl.setClearColor(this._clearColor, previousAlpha); gl.setRenderTarget(null); + gl.shadowMap.needsUpdate = shadowsPending; } /** Push editor values into the passes. Called once per frame. */ @@ -149,7 +205,6 @@ export class PostProcessing { this.bloomPass.strength = post.bloomStrength; this.bloomPass.radius = post.bloomRadius; this.bloomPass.threshold = post.bloomThreshold; - this.bloomPass.enabled = post.enabled && post.bloomStrength > 0.001; const u = this.gradePass.uniforms; u.uTime.value = elapsed; @@ -164,13 +219,43 @@ export class PostProcessing { u.uFlashStrength.value = flash.strength; u.uFlashColor.value.copy(flash.color); + // Neither pass' `enabled` is set here: `render` owns both, because whether + // the distortion pass has anything to composite is only known once the + // scene has been walked, and bloom also depends on the frame's activity. this.distortionPass.uniforms.uScale.value = post.enabled ? post.distortion : 0; - this.distortionPass.enabled = post.enabled; } - render() { - this._renderDepth(); - this._renderDistortion(); + /** + * Draw the frame. + * + * Both auxiliary passes are skipped when nothing on screen can consume them, + * which on an idle stage is every frame: + * + * - the depth buffer is sampled only by ability, particle and burst + * materials, so with none of them alive the prepass is a full render of + * the opaque world into a texture nobody reads; + * - the distortion buffer is written only by proxies parented to an ability, + * and the pass that composites it is a full-screen read of an image that + * is uniformly "no offset". + * + * @param {boolean} live whether any depth-sampling effect is on screen — + * see `App#_liveEffects`. Defaults to true so the boot-time warm-up draws + * the complete pipeline. + */ + render(live = true, active = true) { + const perf = settings.performance; + this.bloomPass.enabled = settings.post.enabled && settings.post.bloomStrength > 0.001 + && (active || perf.idleBloom); + if (this._bloomScale !== perf.bloomScale) this._applyBloomSize(); + if (live) this._renderDepth(); + + const post = settings.post; + const hasDistortion = + live && post.enabled && post.distortion !== 0 && hasVisibleDistortion(this.scene); + + this.distortionPass.enabled = hasDistortion; + if (hasDistortion) this._renderDistortion(); + // Tone mapping is applied by OutputPass: three automatically disables the // in-material tone mapping while rendering into the composer's targets. this.composer.render(); @@ -180,7 +265,10 @@ export class PostProcessing { setSize(width, height, pixelRatio) { this.composer.setPixelRatio(pixelRatio); this.composer.setSize(width, height); - this.bloomPass.setSize(width, height); + // After the composer, which resizes every pass to the device resolution. + this._width = width; + this._height = height; + this._applyBloomSize(); const w = Math.floor(width * pixelRatio); const h = Math.floor(height * pixelRatio); diff --git a/src/ui/Editor.js b/src/ui/Editor.js index 200cef3..04c8327 100644 --- a/src/ui/Editor.js +++ b/src/ui/Editor.js @@ -1,3 +1,5 @@ +import { registerSettingRange } from '../config/SettingsValidation.js'; +import { performanceProfile, setPerformanceProfile, savePerformancePreferences } from '../config/PerformancePreferences.js'; import GUI from 'lil-gui'; import { settings, CAST_ANIMATIONS } from '../config/settings.js'; import { PresetManager } from './PresetManager.js'; @@ -23,14 +25,25 @@ export class Editor { */ constructor(hooks = {}) { this.hooks = hooks; - this.presets = new PresetManager(); this.gui = new GUI({ title: 'VFX Editor', width: 330 }); this.gui.domElement.style.setProperty('--title-height', '30px'); - this._presetState = { name: 'My preset', selected: this.presets.names[0] ?? '' }; - - this._buildPresets(); + /* + * Folder order here is not the order they appear in. + * + * `Editor.range` is what teaches `SettingsValidation` the bounds a slider + * was declared with, and `PresetManager` validates the stored collection + * against exactly those bounds the moment it is constructed. Reading the + * presets first — as this used to — meant the registry was still empty at + * boot and every number fell back to the blanket ±10,000, while a later + * import was checked against the real ranges: the same file could load on + * startup and be rejected from the import button. + * + * So the sliders are declared first and the Presets folder is moved back + * to the top of the panel afterwards, where it belongs. + */ + this._buildPerformance(); this._buildGlobal(); this._buildAim(); this._buildZone(); @@ -50,6 +63,11 @@ export class Editor { this._buildCharacter(); this._buildDummies(); + this.presets = new PresetManager(); + this._presetState = { name: 'My preset', selected: this.presets.names[0] ?? '' }; + this._buildPresets(); + this.gui.$children.prepend(this.presetFolder.domElement); + // Everything starts collapsed, top-level folders included. There are enough // controls here that any folder left open pushes the rest off the screen, // so the panel opens as a list of sections and the user picks one. @@ -61,6 +79,7 @@ export class Editor { /* ------------------------------------------------------------------ */ static range(folder, object, key, min, max, step, label) { + registerSettingRange(object, key, min, max); return folder.add(object, key, min, max, step).name(label ?? key); } @@ -93,7 +112,17 @@ export class Editor { return group; } + /** + * Stored presets this build could not read, for a caller that has somewhere + * visible to say so. Reporting it from the constructor would put the message + * behind the loading veil, where nobody would ever see it. + */ + get unreadablePresets() { + return this.presets.quarantined; + } + refresh() { + if (this._performanceState) this._performanceState.profile = performanceProfile(); this.gui.controllersRecursive().forEach((controller) => controller.updateDisplay()); } @@ -106,6 +135,30 @@ export class Editor { /* folders */ /* ------------------------------------------------------------------ */ + _buildPerformance() { + const folder = this.gui.addFolder('Performance'); + this._performanceState = { profile: performanceProfile() }; + folder.add(this._performanceState, 'profile', ['Balanced', 'Economy', 'Custom']).name('Quality mode').onChange(name => { + setPerformanceProfile(name); + this.refresh(); + }); + folder.onChange(() => { savePerformancePreferences(); this.refresh(); }); + folder.add(settings.performance, 'maxFps', { '30 FPS': 30, '60 FPS': 60, '120 FPS': 120 }).name('Frame limit'); + folder + .add(settings.performance, 'idleFps', { '15 FPS': 15, '30 FPS': 30, 'Off (no idle drop)': 240 }) + .name('Idle frame limit'); + Editor.range(folder, settings.performance, 'pixelRatio', 0.5, 2, 0.25, 'Pixel ratio'); + folder.add(settings.performance, 'shadowResolution', { Low: 1024, Balanced: 2048, High: 4096 }).name('Shadow resolution'); + folder + .add(settings.performance, 'shadowFps', { '15 FPS': 15, '30 FPS': 30, 'Every frame': 240 }) + .name('Shadow refresh'); + folder + .add(settings.performance, 'bloomScale', { Full: 1, 'Three quarters': 0.75, Half: 0.5 }) + .name('Bloom resolution'); + folder.add(settings.performance, 'idleBloom').name('Bloom while idle'); + folder.add(settings.performance, 'dynamicResolution').name('Adapt to frame rate'); + } + _buildPresets() { const folder = this.gui.addFolder('Presets'); const state = this._presetState; @@ -128,7 +181,10 @@ export class Editor { .add( { save: () => { - this.presets.save(state.name); + if (!this.presets.save(state.name)) { + this.hooks.onToast?.('Could not save preset. Check the name, preset limit and available storage.'); + return; + } state.selected = state.name; refreshOptions(); this.hooks.onToast?.(`Saved preset "${state.name}"`); @@ -161,6 +217,8 @@ export class Editor { state.selected = copy; refreshOptions(); this.hooks.onToast?.(`Duplicated to "${copy}"`); + } else { + this.hooks.onToast?.('Could not duplicate preset. Check the selection, preset limit and available storage.'); } } }, @@ -175,6 +233,8 @@ export class Editor { if (this.presets.remove(state.selected)) { refreshOptions(); this.hooks.onToast?.('Preset deleted'); + } else { + this.hooks.onToast?.('Could not delete preset. Check the selection and available storage.'); } } }, @@ -183,6 +243,9 @@ export class Editor { .name('Delete'); folder.add({ exportOne: () => this.presets.exportJSON() }, 'exportOne').name('Export current (JSON)'); + folder.add({ recover: () => { + if (!this.presets.exportUnreadable()) this.hooks.onToast?.('No unreadable backup is available.'); + } }, 'recover').name('Download unreadable backup'); folder.add({ exportAll: () => this.presets.exportAll() }, 'exportAll').name('Export all presets'); folder @@ -193,11 +256,11 @@ export class Editor { refreshOptions(); this.refresh(); this.hooks.onToast?.( - result.applied + result.error ?? (result.applied ? 'Settings imported' : result.imported.length ? `Imported ${result.imported.length} preset(s)` - : 'Nothing imported' + : 'Nothing imported') ); } }, diff --git a/src/ui/HUD.js b/src/ui/HUD.js index b509490..05ffa37 100644 --- a/src/ui/HUD.js +++ b/src/ui/HUD.js @@ -17,9 +17,6 @@ export class HUD { this.root = root; this.onAbility = null; this._toastTimer = 0; - this._statsAccumulator = 0; - this._frames = 0; - this._fps = 0; /** Last sweep ratio pushed to the DOM, per element. */ this._cooldownShown = new Map(); this._armedShown = null; @@ -30,13 +27,6 @@ export class HUD { Press Q, E, R, F, V, X, B, Z, N or K, aim, click to cast. -
-
FPS
-
Particles 0
-
Instances 0
-
Draw calls 0
-
-
Q — Volcanic Horror Ward   E — Caustic Bloom
R — Arborist's Growth   F — Cyber Serpent
@@ -89,12 +79,6 @@ export class HUD { }); } - this.stats = { - fps: root.querySelector('[data-stat="fps"]'), - particles: root.querySelector('[data-stat="particles"]'), - spikes: root.querySelector('[data-stat="spikes"]'), - calls: root.querySelector('[data-stat="calls"]') - }; this.help = root.querySelector('.hud__help'); this.toast = root.querySelector('[data-toast]'); this.pausedBadge = root.querySelector('[data-paused]'); @@ -158,27 +142,7 @@ export class HUD { this._toastTimer = setTimeout(() => this.toast.classList.remove('is-visible'), duration); } - /** - * @param {number} dt - * @param {() => {particles:number, spikes:number, calls:number}} collect - * Called only when the readout actually refreshes, so gathering the numbers - * (which means walking the particle pools) stays off the hot path. - */ - update(dt, collect) { - this._frames++; - this._statsAccumulator += dt; - if (this._statsAccumulator < 0.4) return; - - this._fps = Math.round(this._frames / this._statsAccumulator); - this._frames = 0; - this._statsAccumulator = 0; - - const info = collect(); - this.stats.fps.textContent = this._fps; - this.stats.particles.textContent = info.particles; - this.stats.spikes.textContent = info.spikes; - this.stats.calls.textContent = info.calls; - } + } /** Boot screen helper. */ diff --git a/src/ui/PerformancePanel.js b/src/ui/PerformancePanel.js new file mode 100644 index 0000000..0b4cf52 --- /dev/null +++ b/src/ui/PerformancePanel.js @@ -0,0 +1,315 @@ +import { performanceProfile, setPerformanceProfile, savePerformancePreferences } from '../config/PerformancePreferences.js'; +import { settings } from '../config/settings.js'; +import { GpuTimer } from '../core/GpuTimer.js'; + +const ms = (value) => value == null ? 'Unavailable' : `${value.toFixed(2)} ms`; +const average = (values) => values.length ? values.reduce((sum, n) => sum + n, 0) / values.length : null; + +/** Compact HUD and user-triggered samples; DOM refreshes at most twice a second. */ +export class PerformancePanel { + constructor(renderer, { onSettingsChange } = {}) { + this.renderer = renderer; + this.gpu = new GpuTimer(renderer.getContext()); + this.lastSample = null; + this.recording = null; + this.latest = null; + this.resetWindow(); + this.element = document.createElement('details'); + this.element.className = 'performance'; + this.element.innerHTML = ` + — FPSStarting +
+

Performance

+ +
+
+

CPU: browser work. GPU: measured when supported. Frame interval includes the FPS cap.

+
+ + +
`; + document.getElementById('hud').append(this.element); + this.fps = this.element.querySelector('[data-fps]'); + this.mode = this.element.querySelector('[data-mode]'); + this.status = this.element.querySelector('[data-status]'); + this.recordButton = this.element.querySelector('[data-record]'); + this.rows = this.createRows('[data-metrics]', ['Frame interval', 'CPU work', 'GPU render', 'Draw calls', 'Triangles', 'Canvas']); + this.sampleRows = this.createRows('[data-sample]', ['Sample', 'Average FPS', 'CPU avg / p95', 'GPU average', 'Average draw calls']); + const tabs = [...this.element.querySelectorAll('[data-tab]')]; + const selectTab = (tab) => { + for (const button of tabs) { + button.setAttribute('aria-selected', String(button === tab)); + button.tabIndex = button === tab ? 0 : -1; + } + for (const section of this.element.querySelectorAll('[data-section]')) { + section.hidden = section.dataset.section !== tab.dataset.tab; + } + }; + for (const [index, tab] of tabs.entries()) { + tab.addEventListener('click', () => selectTab(tab)); + tab.addEventListener('keydown', (event) => { + let next; + if (event.key === 'ArrowRight') next = tabs[(index + 1) % tabs.length]; + if (event.key === 'ArrowLeft') next = tabs[(index + tabs.length - 1) % tabs.length]; + if (event.key === 'Home') next = tabs[0]; + if (event.key === 'End') next = tabs[tabs.length - 1]; + if (!next) return; + event.preventDefault(); + selectTab(next); + next.focus(); + }); + } + this.profileSelect = this.element.querySelector('[data-profile]'); + this.bloomCheckbox = this.element.querySelector('[data-idle-bloom]'); + this.dynamicCheckbox = this.element.querySelector('[data-dynamic]'); + this.profileSelect.value = performanceProfile(); + this.bloomCheckbox.checked = settings.performance.idleBloom; + this.dynamicCheckbox.checked = settings.performance.dynamicResolution; + const changed = () => { + savePerformancePreferences(); + this.cancelRecording('Settings changed; start a new sample.'); + onSettingsChange?.(); + }; + this.profileSelect.addEventListener('change', () => { + setPerformanceProfile(this.profileSelect.value); + changed(); + }); + this.bloomCheckbox.addEventListener('change', () => { + settings.performance.idleBloom = this.bloomCheckbox.checked; + changed(); + }); + this.dynamicCheckbox.addEventListener('change', () => { + settings.performance.dynamicResolution = this.dynamicCheckbox.checked; + changed(); + }); + this.controls = []; + const options = [ + ['maxFps', 'Active FPS', [30, 60, 120]], + ['idleFps', 'Idle FPS', [15, 30, ['No reduction', 240]]], + ['pixelRatio', 'Pixel ratio', [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]], + ['shadowResolution', 'Shadow size', [1024, 2048, 4096]], + ['shadowFps', 'Shadow refresh', [15, 30, ['Every frame', 240]]], + ['bloomScale', 'Bloom resolution', [['Full', 1], ['Three quarters', 0.75], ['Half', 0.5]]] + ]; + for (const [key, title, values] of options) { + const label = document.createElement('label'); + label.textContent = title; + const select = document.createElement('select'); + for (const value of values) { + const [text, number] = Array.isArray(value) ? value : [value, value]; + select.add(new Option(String(text), String(number))); + } + select.value = String(settings.performance[key]); + select.addEventListener('change', () => { + settings.performance[key] = Number(select.value); + changed(); + }); + label.append(select); + this.element.querySelector('[data-controls]').append(label); + this.controls.push([key, select]); + } + for (const name of ['pointerdown', 'pointermove', 'wheel']) { + this.element.addEventListener(name, (event) => event.stopPropagation()); + } + this.element.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + this.element.open = false; + this.element.querySelector('summary').focus(); + } + event.stopPropagation(); + }); + this.element.querySelector('[data-close]').addEventListener('click', () => { + this.element.open = false; + this.element.querySelector('summary').focus(); + }); + this._onOutside = (event) => { + if (!this.element.contains(event.target)) this.element.open = false; + }; + document.addEventListener('pointerdown', this._onOutside); + this.recordButton.addEventListener('click', () => this.startRecording()); + this.element.querySelector('[data-copy]').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(this.report(), null, 2)); + this.status.textContent = 'Report copied.'; + } catch { + this.status.textContent = 'Clipboard unavailable. Use Download report.'; + this.downloadButton.hidden = false; + } + }); + this.downloadButton = document.createElement('button'); + this.downloadButton.type = 'button'; + this.downloadButton.textContent = 'Download report'; + this.downloadButton.hidden = true; + this.downloadButton.addEventListener('click', () => { + const url = URL.createObjectURL(new Blob([JSON.stringify(this.report(), null, 2)], { type: 'application/json' })); + const link = document.createElement('a'); + link.href = url; + link.download = 'performance.json'; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }); + this.element.querySelector('.performance__actions').append(this.downloadButton); + } + + createRows(selector, labels) { + const rows = {}; + for (const text of labels) { + const row = document.createElement('div'); + const dt = document.createElement('dt'); + const dd = document.createElement('dd'); + dt.textContent = text; + dd.textContent = '—'; + row.append(dt, dd); + this.element.querySelector(selector).append(row); + rows[text] = dd; + } + return rows; + } + + resetWindow() { + this.windowTime = 0; + this.frames = 0; + this.cpuTotal = 0; + if (this.recording) this.cancelRecording('Sample interrupted by a visibility change. Start again.'); + } + + beginGpu() { + // Adaptive resolution also needs sparse GPU evidence when the panel is closed. + if (this.element.open || this.recording || settings.performance.dynamicResolution) this.gpu.begin(performance.now()); + } + endGpu() { this.gpu.end(); } + + startRecording() { + this.recording = { + label: this.element.querySelector('[data-label]').value.trim() || 'Untitled', + settings: structuredClone(settings.performance), + effectiveStates: [], + elapsed: 0, cpu: [], calls: [], gpu: [], lastGpu: this.gpu.completed + }; + this.recordButton.disabled = true; + this.status.textContent = 'Recording… keep the same scenario for 10 seconds.'; + } + + cancelRecording(message) { + if (!this.recording) return; + this.recording = null; + this.recordButton.disabled = false; + this.status.textContent = message; + } + + record(dt, cpuMs, { mode, targetFps, scale = 1, lightCount = null }) { + if (dt <= 0) return; + const info = this.renderer.info; + this.frames++; + this.windowTime += dt; + this.cpuTotal += cpuMs; + const recording = this.recording; + if (recording) { + if (Object.keys(recording.settings).some(key => recording.settings[key] !== settings.performance[key])) { + this.cancelRecording('Settings changed; start a new sample.'); + } else { + const state = { scale, lightCount, + canvas: [this.renderer.domElement.width, this.renderer.domElement.height] }; + const previous = recording.effectiveStates.at(-1); + if (!previous || JSON.stringify(previous.state) !== JSON.stringify(state)) { + recording.effectiveStates.push({ frame: recording.cpu.length, seconds: recording.elapsed, state }); + } + recording.elapsed += dt; + recording.cpu.push(cpuMs); + recording.calls.push(info.render.calls); + if (recording.lastGpu !== this.gpu.completed && this.gpu.latest != null) { + recording.gpu.push(this.gpu.latest); + recording.lastGpu = this.gpu.completed; + } + if (recording.elapsed >= 10) this.finishRecording(); + } + } + if (this.windowTime < 0.5) return; + this.latest = { + fps: this.frames / this.windowTime, + frameMs: this.windowTime * 1000 / this.frames, + cpuMs: this.cpuTotal / this.frames, + gpuMs: this.gpu.latest, + calls: info.render.calls, + triangles: info.render.triangles, + geometries: info.memory.geometries, + textures: info.memory.textures, + canvas: `${this.renderer.domElement.width} × ${this.renderer.domElement.height}` + + (scale < 1 ? ` · ${Math.round(scale * 100)}% adaptive` : ''), + mode, targetFps, scale, lightCount + }; + const value = this.latest; + this.fps.textContent = `${Math.round(value.fps)} FPS`; + this.mode.textContent = mode; + this.element.dataset.slow = String(value.fps < targetFps * 0.8); + if (this.element.open) { + const values = [ms(value.frameMs), ms(value.cpuMs), ms(value.gpuMs), value.calls, + value.triangles.toLocaleString(), value.canvas]; + Object.values(this.rows).forEach((row, i) => { row.textContent = values[i]; }); + this.profileSelect.value = performanceProfile(); + this.bloomCheckbox.checked = settings.performance.idleBloom; + this.dynamicCheckbox.checked = settings.performance.dynamicResolution; + for (const [key, select] of this.controls) select.value = String(settings.performance[key]); + if (this.recording) this.status.textContent = `Recording… ${Math.ceil(10 - this.recording.elapsed)} seconds left.`; + } + this.windowTime = 0; + this.frames = 0; + this.cpuTotal = 0; + } + + finishRecording() { + const sample = this.recording; + const sorted = [...sample.cpu].sort((a, b) => a - b); + this.lastSample = { + label: sample.label, capturedAt: new Date().toISOString(), settings: sample.settings, + effectiveStates: sample.effectiveStates, + mixedRenderingStates: sample.effectiveStates.length > 1, + lightBudgetRequiresReload: sample.effectiveStates.some(({ state }) => state.lightCount !== sample.settings.lightCount), + seconds: sample.elapsed, frames: sample.cpu.length, fps: sample.cpu.length / sample.elapsed, + cpuAverageMs: average(sample.cpu), cpuP95Ms: sorted[Math.ceil(sorted.length * 0.95) - 1], + gpuAverageMs: average(sample.gpu), gpuSamples: sample.gpu.length, + averageDrawCalls: average(sample.calls) + }; + const value = this.lastSample; + const values = [value.label, value.fps.toFixed(1), `${ms(value.cpuAverageMs)} / ${ms(value.cpuP95Ms)}`, + ms(value.gpuAverageMs), value.averageDrawCalls.toFixed(1)]; + Object.values(this.sampleRows).forEach((row, i) => { row.textContent = values[i]; }); + this.recording = null; + this.recordButton.disabled = false; + this.status.textContent = value.mixedRenderingStates || value.lightBudgetRequiresReload + ? 'Sample ready with rendering-state differences. The report includes the state timeline and effective light count.' + : 'Sample ready. Copy the report to compare runs.'; + } + + report() { + return { + capturedAt: new Date().toISOString(), + device: { browser: navigator.userAgent, logicalCpus: navigator.hardwareConcurrency, + viewport: [innerWidth, innerHeight], devicePixelRatio }, + settings: structuredClone(settings.performance), current: this.latest, sample: this.lastSample, + timing: 'CPU = browser frame work; GPU = sparse asynchronous render queries, when supported. FPS includes intentional throttling. No temperature or power readings.' + }; + } + + dispose() { + this.gpu.dispose(); + document.removeEventListener('pointerdown', this._onOutside); + this.element.remove(); + } +} diff --git a/src/ui/PresetManager.js b/src/ui/PresetManager.js index 1c5cefc..df4c9b2 100644 --- a/src/ui/PresetManager.js +++ b/src/ui/PresetManager.js @@ -1,9 +1,32 @@ -import { settings, applySettings, snapshotSettings, DEFAULT_SETTINGS } from '../config/settings.js'; +import { assertPlainObject, assertSafeTree } from '../config/SettingsValidation.js'; +import { settings, applySettings, snapshotSettings, DEFAULT_SETTINGS, validateSettings } from '../config/settings.js'; // Namespaced afresh: the settings tree was rebuilt around the ward ability, so // presets saved against the old elemental blocks would merge into nothing. const STORAGE_KEY = 'frost-sandbox.presets.v1'; const LAST_KEY = 'frost-sandbox.lastPreset'; +const BROKEN_KEY = 'frost-sandbox.presets.v1.unreadable'; +const MAX_BYTES = 2 * 1024 * 1024; +const MAX_PRESETS = 100; +const MAX_STORAGE_BYTES = 8 * 1024 * 1024; + +function validName(name) { + if (typeof name !== 'string' || !name.trim() || name.length > 80 || ['__proto__', 'prototype', 'constructor'].includes(name)) { + throw new Error('Use a preset name of 1–80 characters without reserved keys.'); + } +} + +export function validateCollection(data) { + assertPlainObject(data); + assertSafeTree(data); + if (Object.keys(data).length > MAX_PRESETS) throw new Error('Maximum 100 presets per collection.'); + const result = Object.create(null); + for (const [name, preset] of Object.entries(data)) { + validName(name); + result[name] = validateSettings(preset); + } + return result; +} /** * Preset persistence. @@ -18,21 +41,89 @@ export class PresetManager { this.presets = this._read(); } + /** + * Load the stored collection, preset by preset. + * + * Validation is deliberately *not* all-or-nothing here, unlike an import. A + * collection in storage was written by this app over months; one entry the + * current tree no longer understands — a knob renamed between builds is + * enough — must not take the rest of the user's work with it. Entries that + * fail are quarantined: they stay out of the UI but are written back + * untouched, so nothing is destroyed by the next save. + */ _read() { + this._quarantine = Object.create(null); + this._pendingBackup = null; + let raw = null; + try { + raw = localStorage.getItem(STORAGE_KEY); + } catch (error) { + console.warn('[PresetManager] storage unavailable', error); + return Object.create(null); + } + if (!raw) return Object.create(null); + + let data; + try { + if (new TextEncoder().encode(raw).length > MAX_STORAGE_BYTES) { + throw new Error('Preset storage exceeds 8 MB.'); + } + data = JSON.parse(raw); + assertPlainObject(data); + assertSafeTree(data); + } catch (error) { + // The blob itself is unusable, so nothing can be salvaged from it. Move + // it aside rather than letting the next save overwrite it in place. + console.warn('[PresetManager] unreadable preset collection', error); + this._pendingBackup = raw; + this._backupUnreadable(); + return Object.create(null); + } + + const result = Object.create(null); + for (const [name, preset] of Object.entries(data)) { + try { + if (Object.keys(result).length >= MAX_PRESETS) throw new Error('Maximum 100 presets per collection.'); + validName(name); + result[name] = validateSettings(preset); + } catch (error) { + this._quarantine[name] = preset; + console.warn(`[PresetManager] preset "${name}" is not loadable and was left in storage`, error); + } + } + return result; + } + + /** How many stored presets this build could not read. */ + get quarantined() { + return Object.keys(this._quarantine).length; + } + + _backupUnreadable() { + if (this._pendingBackup === null) return true; try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : {}; + localStorage.setItem(BROKEN_KEY, this._pendingBackup); + this._pendingBackup = null; + return true; } catch (error) { - console.warn('[PresetManager] could not read presets', error); - return {}; + console.warn('[PresetManager] backup failed; original presets remain untouched', error); + return false; } } - _write() { + _write(presets = this.presets) { + // Retry a failed backup before allowing any replacement of the original. + if (!this._backupUnreadable()) return false; + const quarantine = Object.assign(Object.create(null), this._quarantine); + for (const name of Object.keys(presets)) delete quarantine[name]; try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this.presets)); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...quarantine, ...presets })); + this.presets = presets; + this._quarantine = quarantine; + return true; } catch (error) { console.warn('[PresetManager] could not persist presets', error); + return false; } } @@ -45,36 +136,37 @@ export class PresetManager { } save(name) { - if (!name) return false; - this.presets[name] = snapshotSettings(); - this._write(); - localStorage.setItem(LAST_KEY, name); + try { validName(name); } catch { return false; } + if (!this.has(name) && this.names.length >= MAX_PRESETS) return false; + const next = Object.assign(Object.create(null), this.presets, { [name]: snapshotSettings() }); + if (!this._write(next)) return false; + try { localStorage.setItem(LAST_KEY, name); } catch { /* Storage is optional. */ } return true; } load(name) { - const preset = this.presets[name]; - if (!preset) return false; - applySettings(preset); - localStorage.setItem(LAST_KEY, name); + if (!this.has(name)) return false; + try { applySettings(this.presets[name]); } catch { return false; } + try { localStorage.setItem(LAST_KEY, name); } catch { /* Storage is optional. */ } return true; } duplicate(name) { - if (!this.has(name)) return null; - let copy = `${name} copy`; + if (!this.has(name) || this.names.length >= MAX_PRESETS) return null; + const base = name.slice(0, 65); + let copy = `${base} copy`; let index = 2; - while (this.has(copy)) copy = `${name} copy ${index++}`; - this.presets[copy] = structuredClone(this.presets[name]); - this._write(); + while (this.has(copy) || Object.hasOwn(this._quarantine, copy)) copy = `${base} copy ${index++}`; + const next = Object.assign(Object.create(null), this.presets, { [copy]: structuredClone(this.presets[name]) }); + if (!this._write(next)) return null; return copy; } remove(name) { if (!this.has(name)) return false; - delete this.presets[name]; - this._write(); - return true; + const next = Object.assign(Object.create(null), this.presets); + delete next[name]; + return this._write(next); } reset() { @@ -95,7 +187,7 @@ export class PresetManager { /** Export every stored preset in one file. */ exportAll() { - const blob = new Blob([JSON.stringify(this.presets, null, 2)], { type: 'application/json' }); + const blob = new Blob([JSON.stringify({ ...this._quarantine, ...this.presets }, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; @@ -104,6 +196,22 @@ export class PresetManager { URL.revokeObjectURL(url); } + /** Download the original unreadable bytes, including when storage backup failed. */ + exportUnreadable() { + let raw = this._pendingBackup; + if (raw === null) { + try { raw = localStorage.getItem(BROKEN_KEY); } catch { return false; } + } + if (raw === null) return false; + const url = URL.createObjectURL(new Blob([raw], { type: 'application/octet-stream' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'frost-presets-unreadable.txt'; + anchor.click(); + URL.revokeObjectURL(url); + return true; + } + /** * Import from a JSON file chosen by the user. * Accepts either a single settings snapshot or a map of presets. @@ -118,32 +226,37 @@ export class PresetManager { const file = input.files?.[0]; if (!file) return resolve({ imported: [], applied: false }); try { - const data = JSON.parse(await file.text()); - // A settings snapshot always has a `global` block; anything else is - // treated as a preset collection. - if (data && data.global && data.ward) { - applySettings(data); - resolve({ imported: [], applied: true }); - } else { - const names = []; - for (const [name, preset] of Object.entries(data)) { - if (preset && typeof preset === 'object') { - this.presets[name] = preset; - names.push(name); - } - } - this._write(); - resolve({ imported: names, applied: false }); - } + if (file.size > MAX_BYTES) throw new Error('Preset files must be at most 2 MB.'); + resolve(this.importJSON(await file.text())); } catch (error) { console.error('[PresetManager] import failed', error); - resolve({ imported: [], applied: false }); + resolve({ imported: [], applied: false, error: error.message }); } }; + input.addEventListener('cancel', () => resolve({ imported: [], applied: false })); input.click(); }); } + importJSON(text) { + if (new TextEncoder().encode(text).length > MAX_BYTES) throw new Error('Preset files must be at most 2 MB.'); + const data = JSON.parse(text); + assertPlainObject(data); + assertSafeTree(data); + // A collection can itself contain a preset named 'global'. Its value + // has settings blocks, whereas a snapshot's global block has scalars. + if (Object.hasOwn(data, 'global') && data.global && + Object.values(data.global).every(value => value === null || typeof value !== 'object')) { + applySettings(data); + return { imported: [], applied: true }; + } + const imported = validateCollection(data); + const merged = Object.assign(Object.create(null), this.presets, imported); + if (Object.keys(merged).length > MAX_PRESETS) throw new Error('Maximum 100 saved presets.'); + if (!this._write(merged)) throw new Error('Could not persist imported presets. Existing storage was preserved.'); + return { imported: Object.keys(imported), applied: false }; + } + /** Current live settings, for callers that want to inspect them. */ get current() { return settings; diff --git a/src/ui/styles.css b/src/ui/styles.css index 7996059..6551817 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -973,3 +973,85 @@ body { font-size: 15px; } } + +/* Compact performance readout, with an opt-in diagnostics panel. */ +.performance { + position: absolute; + top: 18px; + left: 50%; + transform: translateX(-50%); + pointer-events: auto; + z-index: 30; + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.performance summary { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: max-content; + margin: auto; + min-height: 36px; + padding: 0 14px; + border: 1px solid var(--ui-border); + border-radius: 24px; + background: rgba(12, 16, 22, 0.94); + cursor: pointer; + list-style: none; +} +.performance summary::-webkit-details-marker { display: none; } +.performance summary i { width: 6px; height: 6px; border-radius: 50%; background: #9bd8b4; } +.performance[data-slow='true'] summary i { background: #edc084; } +.performance summary span { color: var(--ui-text-dim); } +.performance[open] .performance__chevron { transform: rotate(180deg); } +.performance__body { + width: min(320px, calc(100vw - 24px)); + max-height: min(420px, calc(100dvh - 86px)); + overflow-y: auto; + overscroll-behavior: contain; + margin-top: 10px; + padding: 14px; + border: 1px solid var(--ui-border); + border-radius: 14px; + background: #101720; + box-shadow: var(--ui-shadow); +} +.performance header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +.performance small { color: var(--ui-text-dim); font-size: 9px; letter-spacing: 0.15em; } +.performance h2 { margin: 0; font-size: 16px; font-weight: 500; } +.performance h3 { font-size: 12px; font-weight: 500; margin: 20px 0 12px; } +.performance dl { margin: 0; } +.performance dl > div { display: flex; justify-content: space-between; gap: 14px; padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.045); } +.performance dt { color: var(--ui-text-dim); } +.performance dd { margin: 0; text-align: right; overflow-wrap: anywhere; min-width: 0; } +.performance__controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.performance label { display: grid; gap: 6px; color: var(--ui-text-dim); font-size: 11px; } +.performance button, .performance select, .performance input { + font: inherit; + color: var(--ui-text); + background: #1b2531; + border: 1px solid var(--ui-border); + border-radius: 7px; + padding: 8px; + min-width: 0; + min-height: 34px; +} +.performance button { cursor: pointer; } +.performance button:disabled { opacity: 0.5; cursor: wait; } +.performance button:hover { border-color: var(--ui-accent); } +.performance :focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 3px; } +.performance__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +.performance p { color: var(--ui-text-dim); font-size: 11px; line-height: 1.6; } +.performance__note { margin-bottom: 0; } +@media (max-width: 1000px) { .hud__title { top: 68px; max-width: calc(100vw - 36px); } } + +.performance__tabs { display: flex; gap: 4px; margin: 0 0 12px; padding: 3px; border-radius: 8px; background: #0a1017; } +.performance__tabs button { flex: 1; padding: 6px 8px; min-height: 30px; border: 0; background: transparent; color: var(--ui-text-dim); } +.performance__tabs button[aria-selected='true'] { background: #24303c; color: var(--ui-text); } +.performance section[hidden] { display: none; } +@media (max-width: 600px) { .performance__body { max-height: min(380px, 55dvh); } } + +.performance__controls { margin-top: 12px; } +.performance .performance__bloom { display: flex; align-items: center; gap: 8px; margin-top: 10px; } +.performance__bloom input { min-height: 0; accent-color: var(--ui-accent); } diff --git a/src/world/ContactShadows.js b/src/world/ContactShadows.js index db80bd2..5e60e30 100644 --- a/src/world/ContactShadows.js +++ b/src/world/ContactShadows.js @@ -12,6 +12,7 @@ import { import { HorizontalBlurShader } from 'three/addons/shaders/HorizontalBlurShader.js'; import { VerticalBlurShader } from 'three/addons/shaders/VerticalBlurShader.js'; import { settings } from '../config/settings.js'; +import { Cadence } from '../core/Cadence.js'; import { LAYER } from '../core/Layers.js'; /** @@ -93,6 +94,7 @@ export class ContactShadows { this.verticalBlur.depthTest = false; this._clearColor = new Color(); + this._cadence = new Cadence(); } /** Keep the shadow catcher under the character. */ @@ -101,12 +103,32 @@ export class ContactShadows { this.group.position.z = z; } - render(scene) { + /** + * Re-project the catcher. + * + * Throttled to `settings.performance.shadowFps`: this is four render-target + * binds and four draws for a blob under a character that is playing an idle + * loop, and the result is blurred twice before anyone sees it, so refreshing + * it at half the frame rate is not a difference you can point at. + * + * @param {THREE.Scene} scene + * @param {number} [dt] seconds since the last frame; omit to force a refresh + */ + render(scene, dt = Infinity) { const gl = this.renderer.gl; const strength = settings.environment.contactShadow; + // Opacity is not throttled — the editor slider has to answer immediately. this.plane.material.opacity = strength; if (strength <= 0.001) return; + if (!this._cadence.due(dt, settings.performance.shadowFps)) return; + + // This pass renders through `shadowCamera`, which is pinned to the contact + // layer; letting it build the sun's shadow map would reduce that map to + // the character alone. See the note in PostProcessing. + const shadowsPending = gl.shadowMap.needsUpdate; + gl.shadowMap.needsUpdate = false; + const previousBackground = scene.background; const previousOverride = scene.overrideMaterial; const previousAutoClear = gl.autoClear; @@ -135,6 +157,7 @@ export class ContactShadows { gl.autoClear = previousAutoClear; scene.background = previousBackground; this.plane.visible = true; + gl.shadowMap.needsUpdate = shadowsPending; } _blur(amount) { diff --git a/src/world/DustMotes.js b/src/world/DustMotes.js index 96ce81d..b08777c 100644 --- a/src/world/DustMotes.js +++ b/src/world/DustMotes.js @@ -110,8 +110,14 @@ export class DustMotes { } update(elapsed, anchor) { + const amount = settings.environment.dustAmount; + // The fragment shader already discards every mote at zero, but 2,600 + // points are transformed, clipped and binned before it gets the chance. + this.points.visible = amount > 0.001; + if (!this.points.visible) return; + this.material.uniforms.uTime.value = elapsed; - this.material.uniforms.uAmount.value = settings.environment.dustAmount; + this.material.uniforms.uAmount.value = amount; // Keep the volume centred on the action without re-uploading positions. if (anchor) this.points.position.set(anchor.x, 0, anchor.z); } diff --git a/src/world/Environment.js b/src/world/Environment.js index 41721e9..764ed80 100644 --- a/src/world/Environment.js +++ b/src/world/Environment.js @@ -31,7 +31,7 @@ const SHADOW_EXTENT = 26; * * Sun shadows use one directional light whose orthographic shadow camera is * re-centred on the character every frame and fitted tightly to the play area. - * At 4096² over a 52 m box that is ~1.3 cm per texel — sharper than a three + * At the default 2048² over a 52 m box that is ~2.5 cm per texel — sharper than a three * cascade split would give here, without the cost or the complexity. * * (An earlier revision used the CSM addon. It replaces three's @@ -77,7 +77,7 @@ export class Environment { settings.environment.sunIntensity ); this.sun.castShadow = true; - this.sun.shadow.mapSize.set(4096, 4096); + this.sun.shadow.mapSize.set(settings.performance.shadowResolution, settings.performance.shadowResolution); this.sun.shadow.bias = settings.environment.shadowBias; this.sun.shadow.normalBias = 0.035; this.sun.shadow.radius = settings.environment.shadowRadius; @@ -178,6 +178,14 @@ export class Environment { } update() { + const shadow = this.sun.shadow; + const resolution = settings.performance.shadowResolution; + if (shadow.mapSize.x !== resolution) { + shadow.mapSize.set(resolution, resolution); + shadow.map?.dispose(); + shadow.map = null; + this.renderer.gl.shadowMap.needsUpdate = true; + } const env = settings.environment; this._computeLightDirection(_sunDir, env.sunAzimuth, env.sunElevation); diff --git a/tests/browser/renderer-initialization.js b/tests/browser/renderer-initialization.js new file mode 100644 index 0000000..b0974ce --- /dev/null +++ b/tests/browser/renderer-initialization.js @@ -0,0 +1,26 @@ +import { Renderer } from '../../src/core/Renderer.js'; +import { settings } from '../../src/config/settings.js'; + +// Run through Vite in a browser: this checks a real WebGLRenderer before the +// app's first syncSettings() can conceal an invalid initial pixel ratio. +export function checkRendererInitialization() { + const canvas = document.createElement('canvas'); + const renderer = new Renderer(canvas); + try { + const expected = Math.max(0.5, Math.min(window.devicePixelRatio || 1, + Math.max(0.5, settings.performance.pixelRatio))); + const initial = { pixelRatio: renderer.gl.getPixelRatio(), width: canvas.width, height: canvas.height }; + if (initial.pixelRatio !== expected || initial.width !== Math.floor(window.innerWidth * expected) || + initial.height !== Math.floor(window.innerHeight * expected)) { + throw new Error(`Invalid renderer initialization: ${JSON.stringify(initial)}`); + } + renderer.resolutionScale = 0.6; + renderer.syncSettings(); + const adaptiveRatio = renderer.gl.getPixelRatio(); + if (adaptiveRatio !== Math.max(0.5, expected * 0.6)) throw new Error('Adaptive resolution did not resize'); + return { initial, adaptiveRatio, passed: true }; + } finally { + renderer.dispose(); + renderer.gl.forceContextLoss(); + } +} diff --git a/tests/performance.test.js b/tests/performance.test.js new file mode 100644 index 0000000..b30f407 --- /dev/null +++ b/tests/performance.test.js @@ -0,0 +1,112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Vector3 } from 'three'; +import { Time } from '../src/core/Time.js'; +import { Cadence } from '../src/core/Cadence.js'; +import { AdaptiveResolution } from '../src/core/AdaptiveResolution.js'; +import { ParticleSystem } from '../src/particles/ParticleSystem.js'; + +function system(life) { + const value = new ParticleSystem({ name: 'test', capacity: 2 }); + value.emit(1, { position: new Vector3(), time: 0, life, lifeVariance: 0 }); + value.sync(0, value.flush()); + return value; +} + +test('15 FPS preserves one second of simulation and wall time', () => { + const original = globalThis.performance; + let now = 0; + globalThis.performance = { now: () => now }; + try { + const time = new Time(); + let wall = 0; + for (let i = 0; i < 15; i++) { + now += 1000 / 15; + time.tick(); + wall += time.rawDelta; + } + assert.ok(Math.abs(time.elapsed - 1) < 1e-9); + assert.ok(Math.abs(wall - 1) < 1e-9); + now += 2000; + assert.equal(time.tick(), 0.1); + assert.ok(Math.abs(time.rawDelta - 2) < 1e-9); + time.reset(); + assert.equal(time.rawDelta, 0); + } finally { globalThis.performance = original; } +}); + +test('particle visibility includes the 50 ms minimum lifetime', () => { + const particle = system(0.01); + assert.equal(particle.countLive(0.03), 1); + assert.equal(particle.sync(0.03, false), true); + assert.equal(particle.sync(0.06, false), false); + particle.dispose(); +}); + +test('hidden particles can reappear after live lifetime editing', () => { + const particle = system(1); + assert.equal(particle.sync(1.1, false), false); + particle.uniforms.uLifeScale.value = 2; + assert.equal(particle.countLive(1.2), 1); + assert.equal(particle.sync(1.2, false), true); + particle.reset(); + assert.equal(particle.sync(1.2, false), false); + particle.dispose(); +}); + +test('shadow cadence preserves 30 refreshes/second at different display rates', () => { + for (const fps of [30, 60, 120, 144]) { + const cadence = new Cadence(); + cadence.due(0, 30); + let updates = 0; + for (let i = 0; i < fps * 10; i++) if (cadence.due(1 / fps, 30)) updates++; + assert.equal(updates, 300, `display rate ${fps}`); + } +}); + +/** Feed `seconds` of frames at `fps` and report every scale the run produced. */ +function run(resolution, { seconds, fps, targetFps, active = true, workMs = 1000 / fps, gpuMs = null }) { + const dt = 1 / fps; + const scales = []; + for (let i = 0; i < Math.round(seconds * fps); i++) { + if (resolution.sample(dt, targetFps, active, workMs, gpuMs)) scales.push(resolution.scale); + } + return scales; +} + +test('adaptive resolution steps down under sustained overrun and back up when it clears', () => { + const resolution = new AdaptiveResolution(); + // Asking for 60 and delivering 30 for six seconds: three windows, three steps. + assert.deepEqual(run(resolution, { seconds: 6, fps: 30, targetFps: 60 }), [0.85, 0.7, 0.6]); + assert.equal(resolution.scale, 0.6); + + // It takes three calm windows to earn one step back, and no more than one. + assert.deepEqual(run(resolution, { seconds: 4, fps: 60, targetFps: 60 }), []); + assert.deepEqual(run(resolution, { seconds: 4, fps: 60, targetFps: 60 }), [0.7]); +}); + +test('adaptive resolution ignores idle frames and a display slower than the cap', () => { + const idle = new AdaptiveResolution(); + // 15 FPS against a 60 FPS cap is the idle throttle doing its job, not a + // device falling behind; nothing about it should touch the resolution. + assert.deepEqual(run(idle, { seconds: 10, fps: 15, targetFps: 60, active: false }), []); + assert.equal(idle.scale, 1); + + const capped = new AdaptiveResolution(); + // A 120 FPS cap on a 60 Hz panel is permanently "late" against the budget. + assert.deepEqual(run(capped, { seconds: 10, fps: 60, targetFps: 120 }), []); + assert.equal(capped.scale, 1); +}); + +test('30 Hz refresh and browser throttling do not reduce resolution without busy work', () => { + for (const fps of [30, 20]) { + const resolution = new AdaptiveResolution(); + assert.deepEqual(run(resolution, { seconds: 10, fps, targetFps: 60, workMs: 2 }), []); + assert.equal(resolution.scale, 1); + } +}); + +test('GPU evidence can identify overload even when CPU submission is fast', () => { + const resolution = new AdaptiveResolution(); + assert.deepEqual(run(resolution, { seconds: 6, fps: 30, targetFps: 60, workMs: 2, gpuMs: 30 }), [0.85, 0.7, 0.6]); +}); diff --git a/tests/review.test.js b/tests/review.test.js new file mode 100644 index 0000000..18a8122 --- /dev/null +++ b/tests/review.test.js @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PerformancePanel } from '../src/ui/PerformancePanel.js'; +import { CameraRig } from '../src/core/CameraRig.js'; +import { settings } from '../src/config/settings.js'; + +function panel() { + const value = Object.create(PerformancePanel.prototype); + Object.assign(value, { + renderer: { info: { render: { calls: 3, triangles: 10 }, memory: {} }, domElement: { width: 800, height: 600 } }, + gpu: { completed: 0, latest: null }, + element: { querySelector: () => ({ value: 'test' }), dataset: {}, open: false }, + fps: {}, mode: {}, status: {}, recordButton: {}, sampleRows: {}, + frames: 0, windowTime: 0, cpuTotal: 0 + }); + return value; +} + +test('completed comparison exposes adaptive trajectory, canvas changes and effective light budget', () => { + const value = panel(); + value.startRecording(); + const configuredLights = settings.performance.lightCount; + for (let i = 0; i < 10; i++) { + if (i === 5) value.renderer.domElement.width = 680; + value.record(1, 2, { mode: 'Active', targetFps: 60, scale: i < 5 ? 1 : 0.85, lightCount: configuredLights + 2 }); + } + assert.equal(value.lastSample.mixedRenderingStates, true); + assert.equal(value.lastSample.lightBudgetRequiresReload, true); + assert.deepEqual(value.lastSample.effectiveStates, [ + { frame: 0, seconds: 0, state: { scale: 1, lightCount: configuredLights + 2, canvas: [800, 600] } }, + { frame: 5, seconds: 5, state: { scale: 0.85, lightCount: configuredLights + 2, canvas: [680, 600] } } + ]); + assert.match(value.status.textContent, /differences/); +}); + +test('constant effective state completes a comparable sample', () => { + const value = panel(); + value.startRecording(); + for (let i = 0; i < 10; i++) value.record(1, 2, { mode: 'Idle', targetFps: 15, lightCount: settings.performance.lightCount }); + assert.equal(value.lastSample.effectiveStates.length, 1); + assert.equal(value.lastSample.mixedRenderingStates, false); + assert.equal(value.lastSample.lightBudgetRequiresReload, false); +}); + +test('wheel zoom signals interaction immediately', () => { + const original = settings.camera.distance; + let active = false; + const rig = Object.create(CameraRig.prototype); + rig.onInteraction = () => { active = true; }; + try { + rig._onWheel({ preventDefault() {}, deltaMode: 0, deltaY: 100 }); + assert.equal(active, true); + assert.notEqual(settings.camera.distance, original); + } finally { settings.camera.distance = original; } +}); diff --git a/tests/settings.test.js b/tests/settings.test.js new file mode 100644 index 0000000..af2b52f --- /dev/null +++ b/tests/settings.test.js @@ -0,0 +1,222 @@ +import test, { beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { settings, DEFAULT_SETTINGS, applySettings, snapshotSettings, resetSettings } from '../src/config/settings.js'; +import { registerSettingRange } from '../src/config/SettingsValidation.js'; +import { PresetManager } from '../src/ui/PresetManager.js'; +import { setPerformanceProfile, loadPerformancePreferences, performanceProfile, validatePerformance, suggestedProfile } from '../src/config/PerformancePreferences.js'; + +beforeEach(() => { + const storage = new Map(); + globalThis.localStorage = { getItem: key => storage.get(key) ?? null, setItem: (k,v) => storage.set(k,v) }; + Object.assign(settings.performance, DEFAULT_SETTINGS.performance); + resetSettings(); +}); + +test('reject prototype pollution at every depth without partial mutation', () => { + const original = Object.prototype.toString; + for (const text of [ + '{"global":{"glow":2},"__proto__":{"toString":null}}', + '{"global":{"constructor":{"prototype":{"toString":null}}}}', + '{"performance":{"__proto__":{"toString":null}}}' + ]) assert.throws(() => applySettings(JSON.parse(text)), /Reserved/); + assert.equal(Object.prototype.toString, original); + assert.equal(settings.global.glow, DEFAULT_SETTINGS.global.glow); +}); + +test('reject types, non-finite numbers, unknown keys, oversized values, arrays and invalid strings', () => { + for (const value of ['1', null, Infinity, NaN, 1e9, []]) { + assert.throws(() => applySettings({ global: { glow: 2, timeScale: value } })); + assert.equal(settings.global.glow, DEFAULT_SETTINGS.global.glow); + } + assert.throws(() => applySettings({ unknown: true })); + assert.throws(() => applySettings({ ward: { castAnim: '