From 3af17511191a4051353d3e389f83c7708001536b Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 16 May 2026 16:42:05 -0700 Subject: [PATCH] fix: eliminate WebGL camera-sync lag and forced-reflow cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues with mounting the WebGL renderer alongside canvas2D: 1. One-frame camera lag. WebGL had its own RAF loop independent of canvas2D's. When the user panned, WebGL's RAF could fire before canvas2D's syncCamera ran, drawing with stale camera state. Fix: pass a capturing raf/caf to the renderer so its loop never actually schedules itself; invoke the captured frame callback synchronously from canvas2D's onPreRender hook, after setCameraState. Both renderers now lock-step on a single RAF. 2. Layout thrashing. syncCamera read glCanvas.clientWidth/Height every frame, forcing a synchronous layout flush — ~11% CPU under Layout. Fix: cache canvas dimensions and update via ResizeObserver. Canvas size changes are rare; the cached values are accurate between resizes. --- src/client/ClientGameRunner.ts | 45 +++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index ea734b250..711fe43d7 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -248,6 +248,21 @@ function mountWebGLDebugRenderer( glCanvas.style.pointerEvents = "none"; document.body.insertBefore(glCanvas, document.body.firstChild); + // Capture the WebGL renderer's animation-frame callback rather than letting + // it run its own RAF loop. Two independent RAF loops race: when the user + // pans, the WebGL renderer can draw with one-frame-stale camera state + // because its RAF fires before canvas2D's RAF (which would have synced the + // camera). Driving WebGL's draw synchronously from canvas2D's onPreRender + // hook locks them to the same frame. + let cachedWebGLFrameCallback: FrameRequestCallback | null = null; + const captureRaf = (cb: FrameRequestCallback): number => { + cachedWebGLFrameCallback = cb; + return 0; + }; + const captureCaf = (_id: number): void => { + cachedWebGLFrameCallback = null; + }; + const palette = new Float32Array(4096 * 2 * 4); const view = new WebGLGameView( glCanvas, @@ -264,6 +279,8 @@ function mountWebGLDebugRenderer( }, terrainBytes, palette, + captureRaf, + captureCaf, ); // Names are rendered by the existing HTML NameLayer; disable the renderer's @@ -277,20 +294,40 @@ function mountWebGLDebugRenderer( } }); + // Cache canvas dimensions to avoid forced reflows every frame. Reading + // clientWidth/clientHeight flushes pending layout — at 60fps that's a + // measurable cost. Only update on resize events from the observer. + let cachedCanvasW = glCanvas.clientWidth; + let cachedCanvasH = glCanvas.clientHeight; + const resizeObs = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + cachedCanvasW = width; + cachedCanvasH = height; + } + } + }); + resizeObs.observe(glCanvas); + const syncCamera = (): void => { const scale = transformHandler.scale; const dpr = window.devicePixelRatio || 1; - const canvasW = glCanvas.clientWidth; - const canvasH = glCanvas.clientHeight; const centerX = transformHandler.offsetX + mapWidth / 2 + - (canvasW - mapWidth) / (2 * scale); + (cachedCanvasW - mapWidth) / (2 * scale); const centerY = transformHandler.offsetY + mapHeight / 2 + - (canvasH - mapHeight) / (2 * scale); + (cachedCanvasH - mapHeight) / (2 * scale); view.setCameraState(centerX, centerY, scale * dpr); + // Invoke the WebGL renderer's frame callback synchronously, with the just- + // updated camera state. The callback re-arms itself via captureRaf, so + // we'll get a fresh callback ready for the next canvas2D frame. + const cb = cachedWebGLFrameCallback; + cachedWebGLFrameCallback = null; + cb?.(performance.now()); }; (window as unknown as { __webglView?: unknown }).__webglView = view;