Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 +
Expand Down Expand Up @@ -380,8 +381,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);
Expand Down
8 changes: 5 additions & 3 deletions src/client/render/gl/Camera.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getEffectiveDpr } from "../../utilities/Dpr";

/**
* 2D camera: pan/zoom → column-major mat3 for WebGL2 vertex shaders.
*
Expand Down Expand Up @@ -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);
Comment on lines 48 to 50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the rounded backing-store scale for camera math.

resize() rounds canvasW/canvasH, but the conversion methods recompute a fresh DPR. When cssSize * dpr lands on a half-pixel, those values no longer match, so screenToWorld() and worldToScreen() drift from each other and clicks stop lining up exactly with what was rendered. Cache the actual scale from resize() and reuse it here. As per coding guidelines, Camera.ts should handle world↔screen math.

♻️ One simple way to keep the math exact
 export class Camera {
@@
   private canvasW = 1;
   private canvasH = 1;
+  private scaleX = 1;
+  private scaleY = 1;
@@
   resize(cssWidth: number, cssHeight: number): void {
     const dpr = getEffectiveDpr();
     this.canvasW = Math.round(cssWidth * dpr);
     this.canvasH = Math.round(cssHeight * dpr);
+    this.scaleX = cssWidth > 0 ? this.canvasW / cssWidth : 1;
+    this.scaleY = cssHeight > 0 ? this.canvasH / cssHeight : 1;
     if (this.needsInitialFit) {
       this.fitMap();
     }
@@
   screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
-    const dpr = getEffectiveDpr();
-    const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1;
-    const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1);
+    const ndcX = ((screenX * this.scaleX) / this.canvasW) * 2 - 1;
+    const ndcY = -(((screenY * this.scaleY) / this.canvasH) * 2 - 1);
@@
   worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
-    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),
+      x:
+        (this.zoom * (worldX - this.offsetX)) / this.scaleX +
+        this.canvasW / (2 * this.scaleX),
+      y:
+        (this.zoom * (worldY - this.offsetY)) / this.scaleY +
+        this.canvasH / (2 * this.scaleY),
     };
   }

Also applies to: 167-184

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/Camera.ts` around lines 48 - 50, The camera currently
rounds canvasW/canvasH in resize() but recomputes DPR in screenToWorld() and
worldToScreen(), causing half-pixel drift; modify Camera to store the actual
backing-store scale computed in resize() (e.g., a new property like backingScale
or devicePixelRatioUsed) when you set canvasW/canvasH, and have screenToWorld()
and worldToScreen() read and use that cached scale instead of calling
getEffectiveDpr() again (also update the same logic in the conversion code
around the other conversion block at lines ~167-184 so all world↔screen math
uses the identical cached scale).

this.canvasH = Math.round(cssHeight * dpr);
if (this.needsInitialFit) {
Expand Down Expand Up @@ -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;
Expand All @@ -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),
Expand Down
57 changes: 56 additions & 1 deletion src/client/render/gl/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import type { Config } from "../../../core/configuration/Config";
import { getEffectiveDpr } from "../../utilities/Dpr";
import type {
AttackRingInput,
BonusEvent,
Expand Down Expand Up @@ -158,6 +159,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;
Comment on lines +162 to +170
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

GPU query resources need cleanup in dispose().

The query objects allocated at line 226 should be deleted when the renderer is disposed. Currently, dispose() (line 1370) doesn't call gl.deleteQuery() for the timer queries, creating a small resource leak.

♻️ Add cleanup for GPU timer queries

In the dispose() method around line 1410, add:

     this.gl.deleteFramebuffer(this.sceneTarget.fbo);
     this.gl.deleteTexture(this.sceneTarget.tex);
+    for (const q of this.gpuQueries) {
+      if (q) this.gl.deleteQuery(q);
+    }
     this.lastUnits = new Map();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/Renderer.ts` around lines 161 - 169, dispose() currently
doesn't free the WebGLQuery objects stored for GPU timing (gpuQueries /
gpuQueryIssued / gpuQueryIdx / gpuTimerExt), causing a resource leak; update the
Renderer.dispose() method to loop over this.gpuQueries (up to
Renderer.GPU_QUERY_DEPTH), call this.gl.deleteQuery(query) for each non-null
entry, clear/reset the arrays (gpuQueries = [], gpuQueryIssued = [], gpuQueryIdx
= 0) and set gpuTimerExt = null so all GPU timer resources are released.


// Hit-testing references
private lastUnits: Map<number, UnitState> = new Map();
private lastStructures: Map<number, UnitState> = new Map();
Expand Down Expand Up @@ -208,6 +219,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;
Expand Down Expand Up @@ -522,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);
Expand Down Expand Up @@ -1166,9 +1189,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);
}
Expand Down
5 changes: 3 additions & 2 deletions src/client/render/gl/passes/RadialMenuPass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions src/client/utilities/Dpr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 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;

// 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) * RENDER_SCALE;
}
Loading