From 9413642952879144fb8d73adcf562650effedbd0 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 2 Jun 2026 19:33:47 -0700 Subject: [PATCH 1/4] webgl perf --- src/client/ClientGameRunner.ts | 9 +++++- src/client/render/gl/Renderer.ts | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 808ca0ed1f..9bf59575ea 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -380,8 +380,15 @@ function mountWebGLFrameLoop( // TransformHandler, pushes it to WebGL, and synchronously invokes the // renderer's captured frame callback (which draws). One RAF = one // synchronized camera-update + WebGL render. - const driveFrame = (): void => { + let lastRafTime = performance.now(); + const driveFrame = (now: number): void => { + const dt = now - lastRafTime; + lastRafTime = now; + if (dt > 20) console.log(`[rAF gap] ${dt.toFixed(1)}ms`); + const cpuStart = performance.now(); syncCamera(); + const cpuMs = performance.now() - cpuStart; + if (cpuMs > 10) console.log(`[CPU frame] ${cpuMs.toFixed(1)}ms`); requestAnimationFrame(driveFrame); }; requestAnimationFrame(driveFrame); diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 41110b3edd..5cbbd23b1b 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -158,6 +158,16 @@ export class GPURenderer { onFrame: ((ms: number) => void) | null = null; afterRender: ((canvas: HTMLCanvasElement) => void) | null = null; + // Deferred GPU timer + private gpuTimerExt: { + TIME_ELAPSED_EXT: number; + GPU_DISJOINT_EXT: number; + } | null = null; + private gpuQueries: (WebGLQuery | null)[] = []; + private gpuQueryIssued: boolean[] = []; + private gpuQueryIdx = 0; + private static readonly GPU_QUERY_DEPTH = 3; + // Hit-testing references private lastUnits: Map = new Map(); private lastStructures: Map = new Map(); @@ -208,6 +218,18 @@ export class GPURenderer { if (!floatExt) console.warn("EXT_color_buffer_float not available — palette may fail"); + this.gpuTimerExt = gl.getExtension( + "EXT_disjoint_timer_query_webgl2", + ) as typeof this.gpuTimerExt; + if (this.gpuTimerExt) { + for (let i = 0; i < GPURenderer.GPU_QUERY_DEPTH; i++) { + this.gpuQueries.push(gl.createQuery()); + this.gpuQueryIssued.push(false); + } + } else { + console.warn("EXT_disjoint_timer_query_webgl2 not available"); + } + const mapW = header.mapWidth; const mapH = header.mapHeight; this.mapW = mapW; @@ -1166,9 +1188,41 @@ export class GPURenderer { draw(): void { const now = performance.now(); this.trackFps(now); + + // Deferred GPU timer: read result issued GPU_QUERY_DEPTH frames ago + // (by now the GPU has finished it, so the read is non-blocking). + const gl = this.gl; + const ext = this.gpuTimerExt; + const slot = this.gpuQueryIdx; + if (ext) { + const prevQuery = this.gpuQueries[slot]; + if (prevQuery && this.gpuQueryIssued[slot]) { + const available = gl.getQueryParameter( + prevQuery, + gl.QUERY_RESULT_AVAILABLE, + ); + const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT); + if (available && !disjoint) { + const ns = gl.getQueryParameter(prevQuery, gl.QUERY_RESULT) as number; + const ms = ns / 1e6; + if (ms > 12) console.log(`[GPU] ${ms.toFixed(1)}ms`); + } + } + if (prevQuery) { + gl.beginQuery(ext.TIME_ELAPSED_EXT, prevQuery); + this.gpuQueryIssued[slot] = true; + } + } + this.uploadTextures(); this.computeTextures(); this.renderFrame(); + + if (ext && this.gpuQueries[slot]) { + gl.endQuery(ext.TIME_ELAPSED_EXT); + this.gpuQueryIdx = (slot + 1) % GPURenderer.GPU_QUERY_DEPTH; + } + if (this.onFrame) this.onFrame(performance.now() - now); if (this.afterRender) this.afterRender(this.canvas); } From a1eccc8bcfd8e149ae0ad02240145468c8fbbe58 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 2 Jun 2026 19:52:56 -0700 Subject: [PATCH 2/4] dps --- src/client/ClientGameRunner.ts | 3 ++- src/client/render/gl/Camera.ts | 8 +++++--- src/client/render/gl/Renderer.ts | 3 ++- src/client/render/gl/passes/RadialMenuPass.ts | 5 +++-- src/client/utilities/Dpr.ts | 10 ++++++++++ 5 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 src/client/utilities/Dpr.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 9bf59575ea..ba7d4f237c 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -79,6 +79,7 @@ import { import { ALL_UNIT_TYPES, UnitState } from "./render/types"; import { SoundManager } from "./sound/SoundManager"; import { themeProvider } from "./theme/ThemeProvider"; +import { getEffectiveDpr } from "./utilities/Dpr"; export interface LobbyConfig { cosmetics: PlayerCosmeticRefs; @@ -344,7 +345,7 @@ function mountWebGLFrameLoop( const syncCamera = (): void => { const scale = transformHandler.scale; - const dpr = window.devicePixelRatio || 1; + const dpr = getEffectiveDpr(); const centerX = transformHandler.offsetX + mapWidth / 2 + diff --git a/src/client/render/gl/Camera.ts b/src/client/render/gl/Camera.ts index 1bd4cbf8df..28253e60a1 100644 --- a/src/client/render/gl/Camera.ts +++ b/src/client/render/gl/Camera.ts @@ -1,3 +1,5 @@ +import { getEffectiveDpr } from "../../utilities/Dpr"; + /** * 2D camera: pan/zoom → column-major mat3 for WebGL2 vertex shaders. * @@ -44,7 +46,7 @@ export class Camera { /** Update canvas pixel dimensions. Triggers initial fitMap on first call. */ resize(cssWidth: number, cssHeight: number): void { - const dpr = window.devicePixelRatio || 1; + const dpr = getEffectiveDpr(); this.canvasW = Math.round(cssWidth * dpr); this.canvasH = Math.round(cssHeight * dpr); if (this.needsInitialFit) { @@ -163,7 +165,7 @@ export class Camera { /** Convert screen pixel position to world coordinates. */ screenToWorld(screenX: number, screenY: number): { x: number; y: number } { - const dpr = window.devicePixelRatio || 1; + const dpr = getEffectiveDpr(); const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1; const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1); const sx = (this.zoom * 2) / this.canvasW; @@ -176,7 +178,7 @@ export class Camera { /** Convert world coordinates to screen pixel position (CSS pixels). */ worldToScreen(worldX: number, worldY: number): { x: number; y: number } { - const dpr = window.devicePixelRatio || 1; + const dpr = getEffectiveDpr(); return { x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr), y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr), diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 5cbbd23b1b..1e12dd2435 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -10,6 +10,7 @@ */ import type { Config } from "../../../core/configuration/Config"; +import { getEffectiveDpr } from "../../utilities/Dpr"; import type { AttackRingInput, BonusEvent, @@ -544,7 +545,7 @@ export class GPURenderer { // --------------------------------------------------------------------------- resize(cssWidth: number, cssHeight: number): void { - const dpr = window.devicePixelRatio || 1; + const dpr = getEffectiveDpr(); this.canvas.width = Math.round(cssWidth * dpr); this.canvas.height = Math.round(cssHeight * dpr); this.camera.resize(cssWidth, cssHeight); diff --git a/src/client/render/gl/passes/RadialMenuPass.ts b/src/client/render/gl/passes/RadialMenuPass.ts index 2518298ec4..825d70b1fa 100644 --- a/src/client/render/gl/passes/RadialMenuPass.ts +++ b/src/client/render/gl/passes/RadialMenuPass.ts @@ -22,6 +22,7 @@ import iconVertSrc from "../shaders/radial-menu/icon.vert.glsl?raw"; import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json"; import { assetUrl } from "src/core/AssetUrls"; +import { getEffectiveDpr } from "../../../utilities/Dpr"; const emojiAtlasUrl = assetUrl("atlases/emoji-atlas.png"); @@ -437,7 +438,7 @@ export class RadialMenuPass { if (this.items.length === 0 && !this.centerItem) return; const gl = this.gl; - const dpr = window.devicePixelRatio || 1; + const dpr = getEffectiveDpr(); const vw = gl.drawingBufferWidth; const vh = gl.drawingBufferHeight; const ax = this.anchorX * dpr; @@ -491,7 +492,7 @@ export class RadialMenuPass { centerHovered: boolean, ): void { const gl = this.gl; - const dpr = window.devicePixelRatio || 1; + const dpr = getEffectiveDpr(); const n = items.length; const hasCenter = centerItem !== null; const outerR = cfg.outerR * dpr; diff --git a/src/client/utilities/Dpr.ts b/src/client/utilities/Dpr.ts new file mode 100644 index 0000000000..fa473f3f83 --- /dev/null +++ b/src/client/utilities/Dpr.ts @@ -0,0 +1,10 @@ +// Cap the effective devicePixelRatio used for the WebGL drawing buffer and +// any pixel-aware HUD math. Above 1.5×, low-end integrated GPU compositors +// (notably older Chromebooks) can't swap the drawing buffer within a vsync +// interval and frame pacing collapses. All callers must agree on this value +// or camera ↔ screen coordinate math will desync. +const MAX_RENDER_DPR = 1.5; + +export function getEffectiveDpr(): number { + return Math.min(window.devicePixelRatio || 1, MAX_RENDER_DPR); +} From 67312ad59964ff3d790acf30457f45fd0e44650f Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 2 Jun 2026 20:03:25 -0700 Subject: [PATCH 3/4] desynchronized --- src/client/render/gl/Renderer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 1e12dd2435..41b57b47f3 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -210,6 +210,7 @@ export class GPURenderer { alpha: false, antialias: false, powerPreference: "high-performance", + desynchronized: true, }); if (!gl) throw new Error("WebGL2 not supported"); this.gl = gl; From 63c427af3cf8e7310c3f3e548ce19d4c5a365021 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 2 Jun 2026 20:17:04 -0700 Subject: [PATCH 4/4] dpr --- src/client/render/gl/Renderer.ts | 1 - src/client/utilities/Dpr.ts | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 41b57b47f3..1e12dd2435 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -210,7 +210,6 @@ export class GPURenderer { alpha: false, antialias: false, powerPreference: "high-performance", - desynchronized: true, }); if (!gl) throw new Error("WebGL2 not supported"); this.gl = gl; diff --git a/src/client/utilities/Dpr.ts b/src/client/utilities/Dpr.ts index fa473f3f83..e1d9fca8ad 100644 --- a/src/client/utilities/Dpr.ts +++ b/src/client/utilities/Dpr.ts @@ -5,6 +5,10 @@ // or camera ↔ screen coordinate math will desync. const MAX_RENDER_DPR = 1.5; +// BISECT: sub-native render scale to test whether fill rate is the bottleneck +// on low-end Chromebooks. 0.5 = 1/4 the pixels. Visibly softer. +const RENDER_SCALE = 0.5; + export function getEffectiveDpr(): number { - return Math.min(window.devicePixelRatio || 1, MAX_RENDER_DPR); + return Math.min(window.devicePixelRatio || 1, MAX_RENDER_DPR) * RENDER_SCALE; }