From 1ce2d672e68cd124d0cf353cd5af01ee11d0d6b2 Mon Sep 17 00:00:00 2001 From: velrino Date: Wed, 9 Sep 2026 12:57:34 -0300 Subject: [PATCH 1/7] perf: reduce rendering load and add performance controls --- README.md | 7 +++++- src/config/settings.js | 5 +++++ src/core/App.js | 32 ++++++++++++++++++++++------ src/core/Renderer.js | 3 ++- src/postprocessing/PostProcessing.js | 12 +++++++++-- src/ui/Editor.js | 8 +++++++ src/world/Environment.js | 12 +++++++++-- 7 files changed, 67 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fd7b459..c32312b 100644 --- a/README.md +++ b/README.md @@ -717,7 +717,12 @@ Knobs worth knowing about, because they reshape their ability the most: 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. +- Rendering defaults to 60 FPS, pauses in hidden tabs, and resets the clock on return. +- Pixel ratio defaults to a cap of 1.25; the depth and distortion buffers are half resolution. +- Sun shadows default to 2048², and empty distortion passes are skipped. +- The editor's **Performance** folder adjusts the FPS limit, pixel ratio and shadow resolution live. + For lower power use, choose 30 FPS, pixel ratio 1 and 1024² shadows; these trade smoothness + and sharpness for less rendering work. Performance settings are included in 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 diff --git a/src/config/settings.js b/src/config/settings.js index 3a67e8e..d47d59e 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -40,6 +40,11 @@ export const CAST_ANIMATIONS = ['cast1', 'cast2', 'cast3']; export const settings = { + performance: { + maxFps: 60, + pixelRatio: 1.25, + shadowResolution: 2048 + }, /* ------------------------------------------------------------------ */ /* Global multipliers */ /* ------------------------------------------------------------------ */ diff --git a/src/core/App.js b/src/core/App.js index c74c78f..cac2d54 100644 --- a/src/core/App.js +++ b/src/core/App.js @@ -422,16 +422,36 @@ export class App { } start() { - this.time.reset(); - const loop = () => { - this._raf = requestAnimationFrame(loop); - this.frame(); - }; - this._raf = requestAnimationFrame(loop); + if (this._running) return; + this._running = true; + document.addEventListener('visibilitychange', this._onVisibilityChange); + this._onVisibilityChange(); } + _onVisibilityChange = () => { + cancelAnimationFrame(this._raf); + this._raf = 0; + this.time.reset(); + 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 / Math.max(1, settings.performance.maxFps); + 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); } /* ------------------------------------------------------------------ */ diff --git a/src/core/Renderer.js b/src/core/Renderer.js index dc633e8..5e3dbac 100644 --- a/src/core/Renderer.js +++ b/src/core/Renderer.js @@ -44,7 +44,7 @@ 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); + return Math.min(window.devicePixelRatio || 1, Math.max(0.5, settings.performance.pixelRatio)); } get domElement() { @@ -71,6 +71,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/postprocessing/PostProcessing.js b/src/postprocessing/PostProcessing.js index 2d00c01..a66ccbd 100644 --- a/src/postprocessing/PostProcessing.js +++ b/src/postprocessing/PostProcessing.js @@ -165,12 +165,20 @@ export class PostProcessing { u.uFlashColor.value.copy(flash.color); this.distortionPass.uniforms.uScale.value = post.enabled ? post.distortion : 0; - this.distortionPass.enabled = post.enabled; + this.distortionPass.enabled = post.enabled && post.distortion !== 0; } render() { this._renderDepth(); - this._renderDistortion(); + // Invisible ability pools must not keep an empty distortion pass running. + let hasDistortion = false; + if (settings.post.enabled && settings.post.distortion !== 0) { + this.scene.traverseVisible((node) => { + if (node.isMesh && (node.layers.mask & (1 << LAYER.DISTORTION))) hasDistortion = true; + }); + } + 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(); diff --git a/src/ui/Editor.js b/src/ui/Editor.js index 200cef3..173c7b0 100644 --- a/src/ui/Editor.js +++ b/src/ui/Editor.js @@ -31,6 +31,7 @@ export class Editor { this._presetState = { name: 'My preset', selected: this.presets.names[0] ?? '' }; this._buildPresets(); + this._buildPerformance(); this._buildGlobal(); this._buildAim(); this._buildZone(); @@ -106,6 +107,13 @@ export class Editor { /* folders */ /* ------------------------------------------------------------------ */ + _buildPerformance() { + const folder = this.gui.addFolder('Performance'); + folder.add(settings.performance, 'maxFps', { '30 FPS': 30, '60 FPS': 60, '120 FPS': 120 }).name('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'); + } + _buildPresets() { const folder = this.gui.addFolder('Presets'); const state = this._presetState; 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); From 6db0438d972bc2bb98cf02d951c82b54d48e0740 Mon Sep 17 00:00:00 2001 From: velrino Date: Wed, 9 Sep 2026 13:21:57 -0300 Subject: [PATCH 2/7] perf: reduce idle rendering and throttle shadow updates --- README.md | 32 +++++++-- src/config/settings.js | 16 ++++- src/core/App.js | 97 +++++++++++++++++++++++++--- src/core/Renderer.js | 15 ++++- src/particles/ParticleEngine.js | 21 +++++- src/particles/ParticleSystem.js | 82 ++++++++++++++++++++++- src/postprocessing/PostProcessing.js | 77 ++++++++++++++++++---- src/ui/Editor.js | 6 ++ src/world/ContactShadows.js | 26 +++++++- 9 files changed, 336 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index c32312b..d8a0a46 100644 --- a/README.md +++ b/README.md @@ -715,14 +715,32 @@ 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. +- The scene is rendered several times per frame, but the sun's shadow map is built exactly 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. - `renderer.compileAsync()` runs during boot so the first cast never stutters on shader compile. -- Rendering defaults to 60 FPS, pauses in hidden tabs, and resets the clock on return. -- Pixel ratio defaults to a cap of 1.25; the depth and distortion buffers are half resolution. -- Sun shadows default to 2048², and empty distortion passes are skipped. -- The editor's **Performance** folder adjusts the FPS limit, pixel ratio and shadow resolution live. - For lower power use, choose 30 FPS, pixel ratio 1 and 1024² shadows; these trade smoothness - and sharpness for less rendering work. Performance settings are included in presets. +- 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. + +The editor's **Performance** folder drives all of it live: frame limit, idle frame limit, pixel +ratio, shadow resolution and shadow refresh. For lower power use, choose 30 FPS / 15 FPS idle, +pixel ratio 1, 1024² shadows and a 15 FPS shadow refresh. Note that these values travel inside +saved presets, which is worth knowing before importing a preset onto a phone. 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 diff --git a/src/config/settings.js b/src/config/settings.js index d47d59e..f234111 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -40,10 +40,24 @@ 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 + shadowResolution: 2048, + /** Refresh rate of the sun shadow map *and* the contact shadow. */ + shadowFps: 30 }, /* ------------------------------------------------------------------ */ /* Global multipliers */ diff --git a/src/core/App.js b/src/core/App.js index cac2d54..0f1ff1d 100644 --- a/src/core/App.js +++ b/src/core/App.js @@ -37,6 +37,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())); @@ -70,6 +78,10 @@ export class App { 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._shadowAccumulator = Infinity; /** * Seconds left before each ability can be armed again. Per element, so @@ -172,12 +184,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 +254,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 +266,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)); @@ -412,6 +433,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,9 +444,52 @@ 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() { 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(); } @@ -439,7 +505,7 @@ export class App { _loop = (timestamp) => { if (!this._running || document.hidden) return; this._raf = requestAnimationFrame(this._loop); - const interval = 1000 / Math.max(1, settings.performance.maxFps); + 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; @@ -501,7 +567,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); @@ -514,14 +580,29 @@ 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(); + 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. + this._shadowAccumulator += raw; + if (this._shadowAccumulator >= 1 / Math.max(1, settings.performance.shadowFps)) { + this._shadowAccumulator = 0; + gl.shadowMap.needsUpdate = true; + } + this.post.sync(this.elapsed, this.flash); - this.post.render(); + this.post.render(live); /* ---- readouts ---- */ for (const element of ELEMENTS) { diff --git a/src/core/Renderer.js b/src/core/Renderer.js index 5e3dbac..5d62c03 100644 --- a/src/core/Renderer.js +++ b/src/core/Renderer.js @@ -1,6 +1,6 @@ import { WebGLRenderer, - PCFSoftShadowMap, + PCFShadowMap, ACESFilmicToneMapping, SRGBColorSpace } from 'three'; @@ -14,7 +14,14 @@ export class Renderer { constructor(canvas) { 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 +31,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 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..1cf6feb 100644 --- a/src/particles/ParticleSystem.js +++ b/src/particles/ParticleSystem.js @@ -165,6 +165,33 @@ export class ParticleSystem { this._ranges = []; this._dirty = false; + + /* + * Liveness bookkeeping. + * + * `instanceCount` is fixed at the pool's capacity and the mesh is never + * frustum culled, so without this the system would keep issuing a + * full-capacity instanced draw for the rest of the session after its one + * and only cast — and `App#_precompile` builds every ability at boot, so + * that is the state the app *starts* in. The vertex shader throws dead + * particles out of the clip volume, which costs no fill, but the vertex + * invocations, the draw call and three's per-object setup are all still + * paid. + * + * Two numbers are enough to know when the last particle has died without + * walking the pool: the newest spawn stamp and the longest life handed to + * `emit` since the system was last empty. Both are upper bounds, so the + * deadline they produce is conservative — the system can linger a frame, + * never vanish early. + * + * The mesh is left *visible* here on purpose: `App#_precompile` warms the + * pipeline by drawing the scene once per ability, and a system hidden + * before that frame would hand its shader compile back to the first cast, + * which is exactly what the warm-up exists to avoid. The first real frame + * hides it (see `sync`). + */ + this._lastSpawn = -Infinity; + this._maxLife = 0; } get object3D() { @@ -223,6 +250,11 @@ 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); + this._maxLife = Math.max(this._maxLife, life * (1 + Math.abs(lifeVariance))); + for (let n = 0; n < count; n++) { const i = this.cursor; this.cursor = (this.cursor + 1) % this.capacity; @@ -293,6 +325,44 @@ 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; + if (!live) { + // Drop the bounds so the next cast is measured on its own lifetimes + // rather than on the longest one this system has ever emitted. + this._lastSpawn = -Infinity; + this._maxLife = 0; + } + return live; + } + /** * Exact number of particles still alive. * @@ -323,9 +393,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 +410,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 +429,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 a66ccbd..5f329cc 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) { @@ -102,6 +130,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 +147,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 +161,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 +177,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. */ @@ -164,21 +202,38 @@ export class PostProcessing { u.uFlashStrength.value = flash.strength; u.uFlashColor.value.copy(flash.color); + // `enabled` is not set here: `render` owns it, because whether the pass has + // anything to composite is only known once the scene has been walked. this.distortionPass.uniforms.uScale.value = post.enabled ? post.distortion : 0; - this.distortionPass.enabled = post.enabled && post.distortion !== 0; } - render() { - this._renderDepth(); - // Invisible ability pools must not keep an empty distortion pass running. - let hasDistortion = false; - if (settings.post.enabled && settings.post.distortion !== 0) { - this.scene.traverseVisible((node) => { - if (node.isMesh && (node.layers.mask & (1 << LAYER.DISTORTION))) hasDistortion = true; - }); - } + /** + * 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) { + 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(); diff --git a/src/ui/Editor.js b/src/ui/Editor.js index 173c7b0..c698ebe 100644 --- a/src/ui/Editor.js +++ b/src/ui/Editor.js @@ -110,8 +110,14 @@ export class Editor { _buildPerformance() { const folder = this.gui.addFolder('Performance'); 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'); } _buildPresets() { diff --git a/src/world/ContactShadows.js b/src/world/ContactShadows.js index db80bd2..6a17d26 100644 --- a/src/world/ContactShadows.js +++ b/src/world/ContactShadows.js @@ -93,6 +93,7 @@ export class ContactShadows { this.verticalBlur.depthTest = false; this._clearColor = new Color(); + this._accumulator = Infinity; } /** Keep the shadow catcher under the character. */ @@ -101,12 +102,34 @@ 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; + this._accumulator += dt; + if (this._accumulator < 1 / Math.max(1, settings.performance.shadowFps)) return; + this._accumulator = 0; + + // 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 +158,7 @@ export class ContactShadows { gl.autoClear = previousAutoClear; scene.background = previousBackground; this.plane.visible = true; + gl.shadowMap.needsUpdate = shadowsPending; } _blur(amount) { From 99a1fa76258d8562ebf3094ae9391f16514bd429 Mon Sep 17 00:00:00 2001 From: velrino Date: Wed, 9 Sep 2026 13:44:37 -0300 Subject: [PATCH 3/7] fix: preserve idle timing and add compact performance diagnostics --- README.md | 19 +- docs/performance-validation.md | 55 ++++++ package.json | 1 + src/core/App.js | 31 ++-- src/core/Cadence.js | 20 ++ src/core/GpuTimer.js | 50 +++++ src/core/Time.js | 13 +- src/materials/GrowthVineMaterial.js | 1 - src/particles/ParticleSystem.js | 36 +--- src/ui/HUD.js | 38 +--- src/ui/PerformancePanel.js | 272 ++++++++++++++++++++++++++++ src/ui/styles.css | 78 ++++++++ src/world/ContactShadows.js | 7 +- tests/performance.test.js | 64 +++++++ 14 files changed, 592 insertions(+), 93 deletions(-) create mode 100644 docs/performance-validation.md create mode 100644 src/core/Cadence.js create mode 100644 src/core/GpuTimer.js create mode 100644 src/ui/PerformancePanel.js create mode 100644 tests/performance.test.js diff --git a/README.md b/README.md index d8a0a46..a991bb0 100644 --- a/README.md +++ b/README.md @@ -715,11 +715,11 @@ 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. -- The scene is rendered several times per frame, but the sun's shadow map is built exactly once, +- 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. -- `renderer.compileAsync()` runs during boot so the first cast never stutters on shader compile. +- 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. @@ -746,7 +746,18 @@ Four concurrent casts — the pool's ceiling, whichever slots they came from — 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. +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. --- @@ -784,3 +795,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..57d391c --- /dev/null +++ b/docs/performance-validation.md @@ -0,0 +1,55 @@ +# Rendering validation — 2026-09-09 + +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. + +## 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. 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/core/App.js b/src/core/App.js index 0f1ff1d..721a2d3 100644 --- a/src/core/App.js +++ b/src/core/App.js @@ -1,6 +1,8 @@ import { Vector3, MathUtils } from 'three'; import { Renderer } from './Renderer.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'; @@ -81,7 +83,7 @@ export class App { /** 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._shadowAccumulator = Infinity; + this._shadowCadence = new Cadence(); /** * Seconds left before each ability can be armed again. Per element, so @@ -164,6 +166,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 }); @@ -498,6 +503,7 @@ export class App { 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); }; @@ -523,11 +529,13 @@ export class App { /* ------------------------------------------------------------------ */ 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 ---- */ @@ -587,6 +595,7 @@ export class App { const live = this._liveEffects; if (live || this.decals.active.length > 0 || this.aim.isArmed) this._markActive(); + this.performancePanel.beginGpu(); this.contactShadows.setPosition(this.character.position.x, this.character.position.z); this.contactShadows.render(this.scene, raw); @@ -595,26 +604,23 @@ export class App { // 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. - this._shadowAccumulator += raw; - if (this._shadowAccumulator >= 1 / Math.max(1, settings.performance.shadowFps)) { - this._shadowAccumulator = 0; + if (this._shadowCadence.due(raw, settings.performance.shadowFps)) { gl.shadowMap.needsUpdate = true; } this.post.sync(this.elapsed, this.flash); this.post.render(live); + 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 - })); + this.performancePanel.record(raw, performance.now() - cpuStart, { + targetFps: this._targetFps(), + mode: this.paused ? 'Paused' : performance.now() < this._activeUntil ? 'Active' : 'Idle' + }); } /* ------------------------------------------------------------------ */ @@ -635,6 +641,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/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/Time.js b/src/core/Time.js index fa8896e..e02204c 100644 --- a/src/core/Time.js +++ b/src/core/Time.js @@ -1,22 +1,24 @@ /** * 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. + * Wall time for UI/cooldowns and a bounded simulation delta. The 100 ms + * simulation budget accommodates the supported 15 FPS idle mode. Visibility + * changes reset both clocks; long stalls still cannot create huge steps. */ 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 +28,6 @@ export class Time { reset() { this._last = performance.now() / 1000; this.delta = 0; + this.rawDelta = 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/ParticleSystem.js b/src/particles/ParticleSystem.js index 1cf6feb..629356e 100644 --- a/src/particles/ParticleSystem.js +++ b/src/particles/ParticleSystem.js @@ -166,30 +166,10 @@ export class ParticleSystem { this._ranges = []; this._dirty = false; - /* - * Liveness bookkeeping. - * - * `instanceCount` is fixed at the pool's capacity and the mesh is never - * frustum culled, so without this the system would keep issuing a - * full-capacity instanced draw for the rest of the session after its one - * and only cast — and `App#_precompile` builds every ability at boot, so - * that is the state the app *starts* in. The vertex shader throws dead - * particles out of the clip volume, which costs no fill, but the vertex - * invocations, the draw call and three's per-object setup are all still - * paid. - * - * Two numbers are enough to know when the last particle has died without - * walking the pool: the newest spawn stamp and the longest life handed to - * `emit` since the system was last empty. Both are upper bounds, so the - * deadline they produce is conservative — the system can linger a frame, - * never vanish early. - * - * The mesh is left *visible* here on purpose: `App#_precompile` warms the - * pipeline by drawing the scene once per ability, and a system hidden - * before that frame would hand its shader compile back to the first cast, - * which is exactly what the warm-up exists to avoid. The first real frame - * hides it (see `sync`). - */ + // 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; } @@ -253,7 +233,6 @@ export class ParticleSystem { // 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); - this._maxLife = Math.max(this._maxLife, life * (1 + Math.abs(lifeVariance))); for (let n = 0; n < count; n++) { const i = this.cursor; @@ -307,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; @@ -354,12 +334,6 @@ export class ParticleSystem { const live = this.hasLive(time); this.mesh.visible = live; - if (!live) { - // Drop the bounds so the next cast is measured on its own lifetimes - // rather than on the longest one this system has ever emitted. - this._lastSpawn = -Infinity; - this._maxLife = 0; - } return live; } 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..5c1595e --- /dev/null +++ b/src/ui/PerformancePanel.js @@ -0,0 +1,272 @@ +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.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]]] + ]; + 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); + this.cancelRecording('Settings changed; start a new sample.'); + onSettingsChange?.(); + }); + 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() { + // The collapsed pill only needs FPS; do not poll the GPU for hidden details. + if (this.element.open || this.recording) 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), + 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 }) { + 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 { + 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}`, + mode, targetFps + }; + 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]; }); + 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, + 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 = '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/styles.css b/src/ui/styles.css index 7996059..58d46fd 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -973,3 +973,81 @@ 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); } } diff --git a/src/world/ContactShadows.js b/src/world/ContactShadows.js index 6a17d26..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,7 +94,7 @@ export class ContactShadows { this.verticalBlur.depthTest = false; this._clearColor = new Color(); - this._accumulator = Infinity; + this._cadence = new Cadence(); } /** Keep the shadow catcher under the character. */ @@ -120,9 +121,7 @@ export class ContactShadows { this.plane.material.opacity = strength; if (strength <= 0.001) return; - this._accumulator += dt; - if (this._accumulator < 1 / Math.max(1, settings.performance.shadowFps)) return; - this._accumulator = 0; + 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 diff --git a/tests/performance.test.js b/tests/performance.test.js new file mode 100644 index 0000000..99f1ef0 --- /dev/null +++ b/tests/performance.test.js @@ -0,0 +1,64 @@ +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 { 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}`); + } +}); From e778e1873ffd2e5f7af9e55b9c71457bc7aafe82 Mon Sep 17 00:00:00 2001 From: velrino Date: Wed, 9 Sep 2026 14:20:27 -0300 Subject: [PATCH 4/7] feat: add economy mode and secure artistic preset imports --- README.md | 14 ++++- docs/performance-validation.md | 17 ++++++ src/config/PerformancePreferences.js | 45 ++++++++++++++ src/config/SettingsValidation.js | 70 ++++++++++++++++++++++ src/config/settings.js | 30 +++++----- src/core/App.js | 4 +- src/postprocessing/PostProcessing.js | 4 +- src/ui/Editor.js | 20 ++++++- src/ui/PerformancePanel.js | 27 ++++++++- src/ui/PresetManager.js | 89 ++++++++++++++++++---------- src/ui/styles.css | 4 ++ tests/settings.test.js | 78 ++++++++++++++++++++++++ 12 files changed, 346 insertions(+), 56 deletions(-) create mode 100644 src/config/PerformancePreferences.js create mode 100644 src/config/SettingsValidation.js create mode 100644 tests/settings.test.js diff --git a/README.md b/README.md index a991bb0..b002a15 100644 --- a/README.md +++ b/README.md @@ -738,9 +738,17 @@ time in, and it used to cost the same as a four-cast fight: - Pixel ratio is capped at 1.25; the depth and distortion buffers are half resolution. The editor's **Performance** folder drives all of it live: frame limit, idle frame limit, pixel -ratio, shadow resolution and shadow refresh. For lower power use, choose 30 FPS / 15 FPS idle, -pixel ratio 1, 1024² shadows and a 15 FPS shadow refresh. Note that these values travel inside -saved presets, which is worth knowing before importing a preset onto a phone. +ratio, shadow resolution, shadow refresh and bloom while idle. Choose **Economy** in the +editor or the panel's **Graphics → Quality mode** for 30 FPS / 15 FPS idle, pixel ratio 1, +1024² shadows, a 15 Hz shadow refresh and bloom disabled while idle. **Balanced** restores +the shipped quality settings. 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. 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 diff --git a/docs/performance-validation.md b/docs/performance-validation.md index 57d391c..f8bbb35 100644 --- a/docs/performance-validation.md +++ b/docs/performance-validation.md @@ -53,3 +53,20 @@ these numbers. Thermal state and background OS work were not controlled. 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. diff --git a/src/config/PerformancePreferences.js b/src/config/PerformancePreferences.js new file mode 100644 index 0000000..815ab00 --- /dev/null +++ b/src/config/PerformancePreferences.js @@ -0,0 +1,45 @@ +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] +}; +export const PERFORMANCE_PROFILES = { + Balanced: { ...DEFAULT_SETTINGS.performance }, + Economy: { maxFps: 30, idleFps: 15, pixelRatio: 1, shadowResolution: 1024, shadowFps: 15, idleBloom: false } +}; + +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 }; +} + +export function savePerformancePreferences() { + const valid = validatePerformance(settings.performance); + try { localStorage.setItem(KEY, JSON.stringify(valid)); } catch { /* Storage is optional. */ } +} + +export function loadPerformancePreferences() { + try { + const raw = localStorage.getItem(KEY); + if (raw && raw.length <= 2048) Object.assign(settings.performance, validatePerformance(JSON.parse(raw))); + } catch { /* Ignore corrupt preferences; keep the shipped defaults. */ } +} + +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 f234111..d73f92d 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. * @@ -57,7 +59,8 @@ export const settings = { pixelRatio: 1.25, shadowResolution: 2048, /** Refresh rate of the sun shadow map *and* the contact shadow. */ - shadowFps: 30 + shadowFps: 30, + idleBloom: true }, /* ------------------------------------------------------------------ */ /* Global multipliers */ @@ -4491,24 +4494,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/App.js b/src/core/App.js index 721a2d3..355f834 100644 --- a/src/core/App.js +++ b/src/core/App.js @@ -1,3 +1,4 @@ +import { loadPerformancePreferences } from '../config/PerformancePreferences.js'; import { Vector3, MathUtils } from 'three'; import { Renderer } from './Renderer.js'; @@ -75,6 +76,7 @@ async function waitFor(test, timeout) { */ export class App { constructor(canvas) { + loadPerformancePreferences(); this.canvas = canvas; this.time = new Time(); this.elapsed = 0; @@ -609,7 +611,7 @@ export class App { } this.post.sync(this.elapsed, this.flash); - this.post.render(live); + this.post.render(live, this.paused || performance.now() < this._activeUntil); this.performancePanel.endGpu(); /* ---- readouts ---- */ diff --git a/src/postprocessing/PostProcessing.js b/src/postprocessing/PostProcessing.js index 5f329cc..d7e5524 100644 --- a/src/postprocessing/PostProcessing.js +++ b/src/postprocessing/PostProcessing.js @@ -224,7 +224,9 @@ export class PostProcessing { * see `App#_liveEffects`. Defaults to true so the boot-time warm-up draws * the complete pipeline. */ - render(live = true) { + render(live = true, active = true) { + this.bloomPass.enabled = settings.post.enabled && settings.post.bloomStrength > 0.001 + && (active || settings.performance.idleBloom); if (live) this._renderDepth(); const post = settings.post; diff --git a/src/ui/Editor.js b/src/ui/Editor.js index c698ebe..c5b9d4c 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'; @@ -62,6 +64,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); } @@ -95,6 +98,7 @@ export class Editor { } refresh() { + if (this._performanceState) this._performanceState.profile = performanceProfile(); this.gui.controllersRecursive().forEach((controller) => controller.updateDisplay()); } @@ -109,6 +113,12 @@ export class Editor { _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 }) @@ -118,6 +128,7 @@ export class Editor { folder .add(settings.performance, 'shadowFps', { '15 FPS': 15, '30 FPS': 30, 'Every frame': 240 }) .name('Shadow refresh'); + folder.add(settings.performance, 'idleBloom').name('Bloom while idle'); } _buildPresets() { @@ -142,7 +153,10 @@ export class Editor { .add( { save: () => { - this.presets.save(state.name); + if (!this.presets.save(state.name)) { + this.hooks.onToast?.('Invalid name or preset limit reached'); + return; + } state.selected = state.name; refreshOptions(); this.hooks.onToast?.(`Saved preset "${state.name}"`); @@ -207,11 +221,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/PerformancePanel.js b/src/ui/PerformancePanel.js index 5c1595e..bbb94e3 100644 --- a/src/ui/PerformancePanel.js +++ b/src/ui/PerformancePanel.js @@ -1,3 +1,4 @@ +import { performanceProfile, setPerformanceProfile, savePerformancePreferences } from '../config/PerformancePreferences.js'; import { settings } from '../config/settings.js'; import { GpuTimer } from '../core/GpuTimer.js'; @@ -29,8 +30,10 @@ export class PerformancePanel {

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