diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 9bf59575e..ba7d4f237 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 1bd4cbf8d..28253e60a 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 5cbbd23b1..1e12dd243 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 2518298ec..825d70b1f 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 000000000..fa473f3f8 --- /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); +}