From 0e7c33a594ebadcc3617b00bcc6cf55643973120 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 18 Jun 2026 12:34:45 -0700 Subject: [PATCH] Cap renderer device-pixel-ratio at 2 (#4339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Routes every renderer call site that read `window.devicePixelRatio` through a single `renderDpr()` helper that caps the value at **2**. ```ts export function renderDpr(): number { return Math.min(window.devicePixelRatio || 2, 2); } ``` ## Why On very high-DPI displays (DPR 3, common on phones) the WebGL backing store was sized at 3× CSS pixels — ~9× the fragment work of 1× — for a marginal visual gain over 2×. Capping at 2 keeps retina (DPR 2) pixel-perfect while clamping the 3× case. ## How it stays correct DPR isn't just the canvas size — it's one coordinate system shared by: - the canvas backing-store size (`Renderer.resize`) - the camera's screen↔world math (`Camera.resize` / `screenToWorld` / `worldToScreen`) - the camera zoom scale (`ClientGameRunner.syncCamera`) - the constant-CSS-pixel-size world text (`WorldTextPass`) These must all use the same DPR value or pointer hit-testing and text sizing drift. Routing them through one helper guarantees that. The diagnostics reporter (`Diagnostic.ts`) is intentionally left reading the real hardware DPR, since its job is to report the actual device. ## Test - `tsc --noEmit` clean for all touched files (one pre-existing unrelated `marked` types error remains on `main`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- src/client/ClientGameRunner.ts | 3 ++- src/client/render/gl/Camera.ts | 8 +++++--- src/client/render/gl/Renderer.ts | 3 ++- src/client/render/gl/index.ts | 1 + src/client/render/gl/passes/WorldTextPass.ts | 5 +++-- src/client/render/gl/utils/Dpr.ts | 13 +++++++++++++ 6 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 src/client/render/gl/utils/Dpr.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index d6c3f7a32..d561c0e88 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -72,6 +72,7 @@ import { deepAssign, MapRenderer, preloadAtlasData, + renderDpr, type RenderSettings, } from "./render/gl"; import { ALL_UNIT_TYPES, UnitState } from "./render/types"; @@ -352,7 +353,7 @@ function mountWebGLFrameLoop( const syncCamera = (): void => { const scale = transformHandler.scale; - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); const centerX = transformHandler.offsetX + mapWidth / 2 + diff --git a/src/client/render/gl/Camera.ts b/src/client/render/gl/Camera.ts index 1bd4cbf8d..7b2c4abb7 100644 --- a/src/client/render/gl/Camera.ts +++ b/src/client/render/gl/Camera.ts @@ -15,6 +15,8 @@ * ty = -offsetY * sy */ +import { renderDpr } from "./utils/Dpr"; + const MIN_ZOOM = 0.2; const MAX_ZOOM = 20; const DBLCLICK_MIN_ZOOM = 0.7; @@ -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 = renderDpr(); 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 = renderDpr(); 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 = renderDpr(); 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 87a504998..5d01e518e 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -60,6 +60,7 @@ import { WorldTextPass } from "./passes/WorldTextPass"; import type { RenderSettings } from "./RenderSettings"; import { AffiliationPalette } from "./utils/Affiliation"; import { getPaletteSize, hexToRgb } from "./utils/ColorUtils"; +import { renderDpr } from "./utils/Dpr"; import { createTexture2D, toScreen, @@ -569,7 +570,7 @@ export class GPURenderer { // --------------------------------------------------------------------------- resize(cssWidth: number, cssHeight: number): void { - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); 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/index.ts b/src/client/render/gl/index.ts index de14f07ff..f5de04d10 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -11,6 +11,7 @@ export { createRenderSettings, dumpSettings } from "./RenderSettings"; export type { RenderSettings } from "./RenderSettings"; export { deepAssign, deepDiff } from "./SettingsUtils"; export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; +export { renderDpr } from "./utils/Dpr"; export { buildNukeTrajectory, samRange } from "./utils/NukeTrajectory"; export type { SAMInfo } from "./utils/NukeTrajectory"; diff --git a/src/client/render/gl/passes/WorldTextPass.ts b/src/client/render/gl/passes/WorldTextPass.ts index 0a6b43540..6227f69e9 100644 --- a/src/client/render/gl/passes/WorldTextPass.ts +++ b/src/client/render/gl/passes/WorldTextPass.ts @@ -10,6 +10,7 @@ import type { Config } from "../../../../core/configuration/Config"; import type { BonusEvent, ConquestFx } from "../../types"; import type { RenderSettings } from "../RenderSettings"; +import { renderDpr } from "../utils/Dpr"; import { createProgram } from "../utils/GlUtils"; import type { GlyphTables } from "./name-pass/AtlasData"; import { buildGlyphTables, parseAtlasData } from "./name-pass/AtlasData"; @@ -399,7 +400,7 @@ export class WorldTextPass { let count = 0; // canvasW in Camera is cssWidth*dpr, so `zoom` is device-px-per-world-unit. // Multiply screen-relative scales by dpr to keep a constant CSS-pixel size. - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); for (const popup of this.active) { const elapsed = now - popup.startMs; @@ -541,7 +542,7 @@ export class WorldTextPass { gl.useProgram(this.program); gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); gl.uniform1f(this.uZoom, zoom); - const dpr = window.devicePixelRatio || 1; + const dpr = renderDpr(); gl.uniform1f( this.uMinScreenScale, this.settings.bonusPopup.minScreenScale * dpr, diff --git a/src/client/render/gl/utils/Dpr.ts b/src/client/render/gl/utils/Dpr.ts new file mode 100644 index 000000000..ccad4f8f9 --- /dev/null +++ b/src/client/render/gl/utils/Dpr.ts @@ -0,0 +1,13 @@ +/** + * Device-pixel-ratio used by the WebGL renderer for its backing store and all + * screen↔world math. Capped at 2 to avoid rendering at 3x on very high-DPI + * (mobile) displays, which costs ~9x the fragment work of 1x for a marginal + * visual gain over 2x. + * + * Every renderer call site that previously read `window.devicePixelRatio` + * must go through this so the canvas size, camera math, and text scaling stay + * on the same coordinate system. + */ +export function renderDpr(): number { + return Math.min(window.devicePixelRatio || 2, 2); +}