Skip to content

webgl perf#4138

Draft
evanpelle wants to merge 4 commits into
mainfrom
webgl-perf
Draft

webgl perf#4138
evanpelle wants to merge 4 commits into
mainfrom
webgl-perf

Conversation

@evanpelle
Copy link
Copy Markdown
Collaborator

Before opening a PR: discuss new features on Discord first, and file bugs or small improvements as issues. You must be assigned to an approved issue — unsolicited PRs will be auto-closed.

Add approved & assigned issue number here:

Resolves #(issue number)

Description:

Describe the PR.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory

Please put your Discord username so you can be contacted if a bug or regression is found:

DISCORD_USERNAME

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e818d8e1-07c5-4c88-9387-2ee01fd98f27

📥 Commits

Reviewing files that changed from the base of the PR and between 67312ad and 63c427a.

📒 Files selected for processing (2)
  • src/client/render/gl/Renderer.ts
  • src/client/utilities/Dpr.ts
💤 Files with no reviewable changes (1)
  • src/client/render/gl/Renderer.ts

Walkthrough

Adds getEffectiveDpr() (clamped DPR) and replaces direct devicePixelRatio uses across camera, renderer, radial menu, and client runner. Instruments CPU frame timing in the RAF loop and adds deferred GPU timing via EXT_disjoint_timer_query_webgl2 with a fixed-depth query ring.

Changes

Performance timing & DPR changes

Layer / File(s) Summary
DPR utility and integration
src/client/utilities/Dpr.ts, src/client/ClientGameRunner.ts, src/client/render/gl/Renderer.ts, src/client/render/gl/Camera.ts, src/client/render/gl/passes/RadialMenuPass.ts
Adds getEffectiveDpr() and replaces direct window.devicePixelRatio uses with it when sizing canvases, camera math, HUD geometry, and when calling view.setCameraState.
CPU Frame Timing in RAF Loop
src/client/ClientGameRunner.ts
Replaces the RAF callback with a timestamped handler that tracks lastRafTime, logs [rAF gap] when gaps exceed 20ms, times syncCamera(), and logs [CPU frame] when CPU work exceeds 10ms.
GPU Frame Timing via WebGL Timer Queries
src/client/render/gl/Renderer.ts
GPURenderer requests EXT_disjoint_timer_query_webgl2, pre-allocates a fixed-depth ring of queries, reads results from a slot when available (checks disjoint), logs GPU ms when >12ms, and wraps the frame with beginQuery/endQuery when supported.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A clamped DPR to ease the strain,
rAF counts gaps and times the brain,
queries circle, deferred and neat,
GPU whispers milliseconds complete,
frames keep cadence, steady, plain.

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description only contains the template with no substantive details filled in; the issue reference, Discord username, and all checklist items are incomplete or placeholder text. Fill in the issue reference, provide a clear description of the changes (DPR clamping, frame timing, GPU query support), add Discord username, and indicate which checklist items apply.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'webgl perf' is vague and generic, using a non-descriptive abbreviation that doesn't convey meaningful information about the specific changes made. Expand the title to be more descriptive, such as 'Add DPR clamping and WebGL performance monitoring' or 'Implement effective DPR helper and frame timing diagnostics'.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/client/render/gl/Renderer.ts`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eef21adc-8b38-4365-91fd-95be92332c86

📥 Commits

Reviewing files that changed from the base of the PR and between 48609fa and 9413642.

📒 Files selected for processing (2)
  • src/client/ClientGameRunner.ts
  • src/client/render/gl/Renderer.ts

Comment on lines +161 to +169
// 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;
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.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Jun 3, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/render/gl/passes/RadialMenuPass.ts (1)

441-445: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Derive radial-menu scale from the actual drawing buffer.

This pass mixes gl.drawingBufferWidth/Height with a fresh getEffectiveDpr() for ax/ay, outerR, and iconHalf. Because the canvas size is rounded, those numbers can disagree even when DPR is stable, so the wheel can render slightly offset from its hit-test anchor. Compute the scale from the real buffer-to-CSS ratio instead of rereading DPR here.

♻️ Keep the menu aligned with the real buffer size
   draw(): void {
@@
     const gl = this.gl;
-    const dpr = getEffectiveDpr();
     const vw = gl.drawingBufferWidth;
     const vh = gl.drawingBufferHeight;
-    const ax = this.anchorX * dpr;
-    const ay = this.anchorY * dpr;
+    const canvas = gl.canvas as HTMLCanvasElement;
+    const scaleX = canvas.clientWidth > 0 ? vw / canvas.clientWidth : 1;
+    const scaleY = canvas.clientHeight > 0 ? vh / canvas.clientHeight : 1;
+    const scale = Math.min(scaleX, scaleY);
+    const ax = this.anchorX * scaleX;
+    const ay = this.anchorY * scaleY;
@@
       this.drawRing(
         ax,
         ay,
         vw,
         vh,
+        scale,
         p,
         this.savedItems,
         -1,
@@
     this.drawRing(
       ax,
       ay,
       vw,
       vh,
+      scale,
       active,
       this.items,
       this.hoveredIndex >= 0 ? this.hoveredIndex : -1,
@@
   private drawRing(
     ax: number,
     ay: number,
     vw: number,
     vh: number,
+    scale: number,
     cfg: RingConfig,
@@
-    const dpr = getEffectiveDpr();
     const n = items.length;
@@
-    const outerR = cfg.outerR * dpr;
+    const outerR = cfg.outerR * scale;
@@
-    const iconHalf = ih * dpr;
+    const iconHalf = ih * scale;

Also applies to: 495-503

🤖 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/passes/RadialMenuPass.ts` around lines 441 - 445, The
pass mixes getEffectiveDpr() with gl.drawingBufferWidth/height causing
mismatched scale; instead compute a single buffer-to-CSS scale from the real
drawing buffer and use it for all geometry and hit-test math. Replace uses of
getEffectiveDpr() for ax/ay, outerR, iconHalf (and the same block later at the
other occurrence) with a computed scale = gl.drawingBufferWidth /
(gl.canvas?.clientWidth || expectedCssWidth) (or use height analog if vertical),
then compute ax = anchorX * scale, ay = anchorY * scale and multiply outerR and
iconHalf by that same scale so the rendered wheel and hit tests align with the
actual buffer. Ensure the same scale is reused in both the initial block (where
ax/ay are set) and the subsequent block that computes outerR/iconHalf.
♻️ Duplicate comments (1)
src/client/render/gl/Renderer.ts (1)

1371-1411: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Delete the timer queries in dispose().

The constructor allocates GPU_QUERY_DEPTH WebGLQuery objects, but dispose() never frees them. Recreating GPURenderer will leak GL objects across sessions.

♻️ Minimal fix
   dispose(): void {
     this.stopLoop();
@@
     this.skinAtlas.dispose();
     this.gl.deleteFramebuffer(this.sceneTarget.fbo);
     this.gl.deleteTexture(this.sceneTarget.tex);
+    for (const query of this.gpuQueries) {
+      if (query) this.gl.deleteQuery(query);
+    }
+    this.gpuQueries = [];
+    this.gpuQueryIssued = [];
+    this.gpuQueryIdx = 0;
+    this.gpuTimerExt = null;
     this.lastUnits = new Map();
     this.lastStructures = new Map();
   }
#!/bin/bash
set -euo pipefail

printf '%s\n' '--- query allocation ---'
sed -n '222,231p' src/client/render/gl/Renderer.ts

printf '\n%s\n' '--- dispose() ---'
sed -n '1371,1414p' src/client/render/gl/Renderer.ts

printf '\n%s\n' '--- query lifecycle call sites ---'
rg -n 'createQuery|deleteQuery' src/client/render/gl/Renderer.ts
🤖 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 1371 - 1411, The dispose()
method currently omits deleting the WebGLQuery objects allocated with
GPU_QUERY_DEPTH in the constructor; update dispose() to iterate over the queries
allocated at construction and call this.gl.deleteQuery(...) for each (and then
clear/null the array) so the GPU_QUERY_DEPTH WebGLQuery objects are freed and
not leaked when GPURenderer is recreated. Ensure you reference the same query
storage used in the constructor (the array/object holding the allocated queries)
and remove/clear those references after calling deleteQuery to avoid dangling
pointers.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/client/render/gl/Camera.ts`:
- Around line 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).

---

Outside diff comments:
In `@src/client/render/gl/passes/RadialMenuPass.ts`:
- Around line 441-445: The pass mixes getEffectiveDpr() with
gl.drawingBufferWidth/height causing mismatched scale; instead compute a single
buffer-to-CSS scale from the real drawing buffer and use it for all geometry and
hit-test math. Replace uses of getEffectiveDpr() for ax/ay, outerR, iconHalf
(and the same block later at the other occurrence) with a computed scale =
gl.drawingBufferWidth / (gl.canvas?.clientWidth || expectedCssWidth) (or use
height analog if vertical), then compute ax = anchorX * scale, ay = anchorY *
scale and multiply outerR and iconHalf by that same scale so the rendered wheel
and hit tests align with the actual buffer. Ensure the same scale is reused in
both the initial block (where ax/ay are set) and the subsequent block that
computes outerR/iconHalf.

---

Duplicate comments:
In `@src/client/render/gl/Renderer.ts`:
- Around line 1371-1411: The dispose() method currently omits deleting the
WebGLQuery objects allocated with GPU_QUERY_DEPTH in the constructor; update
dispose() to iterate over the queries allocated at construction and call
this.gl.deleteQuery(...) for each (and then clear/null the array) so the
GPU_QUERY_DEPTH WebGLQuery objects are freed and not leaked when GPURenderer is
recreated. Ensure you reference the same query storage used in the constructor
(the array/object holding the allocated queries) and remove/clear those
references after calling deleteQuery to avoid dangling pointers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: edb239fe-b3ea-411b-b622-225e4a09227e

📥 Commits

Reviewing files that changed from the base of the PR and between 9413642 and a1eccc8.

📒 Files selected for processing (5)
  • src/client/ClientGameRunner.ts
  • src/client/render/gl/Camera.ts
  • src/client/render/gl/Renderer.ts
  • src/client/render/gl/passes/RadialMenuPass.ts
  • src/client/utilities/Dpr.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/client/ClientGameRunner.ts

Comment on lines 48 to 50
resize(cssWidth: number, cssHeight: number): void {
const dpr = window.devicePixelRatio || 1;
const dpr = getEffectiveDpr();
this.canvasW = Math.round(cssWidth * dpr);
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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

1 participant