From fc2ac07fabb6e8842e659737c2b2c89a8cd23ae4 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:25:42 +0100 Subject: [PATCH] Add zoom-tier mover canvas scaling and rescale metrics --- .../graphics/layers/PerformanceOverlay.ts | 10 + src/client/graphics/layers/UnitLayer.ts | 182 ++++++++++++++++-- 2 files changed, 171 insertions(+), 21 deletions(-) diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 6105ef2d6..72111f999 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -1311,6 +1311,16 @@ export class PerformanceOverlay extends LitElement implements Layer { drawn: ${Number(unitLayerCounters.moversDrawn ?? 0)} skipped: ${Number(unitLayerCounters.moversSkipped ?? 0)} +
+ moverCanvasScale: + ${Number(unitLayerCounters.moverCanvasScale ?? 0).toFixed(0)} + rescale(last/avg/count): + ${Number(unitLayerCounters.moverCanvasRescaleLastMs ?? 0).toFixed(2)}ms + / + ${Number(unitLayerCounters.moverCanvasRescaleAvgMs ?? 0).toFixed(2)}ms + / + ${Number(unitLayerCounters.moverCanvasRescaleCount ?? 0).toFixed(0)} +
draw: ${Number(unitLayerCounters.drawTimeMs ?? 0).toFixed(2)}ms / diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 6f5ae1871..0bd708137 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -47,7 +47,11 @@ const ONSCREEN_HYSTERESIS_FRAMES = 2; const OFFSCREEN_VERIFY_MAX_PER_FRAME = 12; const VIEW_PADDING_PX = 12; const MOVER_SPATIAL_HASH_CELL_PX = 24; -const DYNAMIC_MOVER_CANVAS_SCALE = 5; +const DYNAMIC_MOVER_SCALE_STEPS = [1, 2, 3, 4]; +const DYNAMIC_MOVER_ZOOM_THRESHOLDS = [1.2, 2.4, 4.8] as const; +const DYNAMIC_MOVER_ZOOM_HYSTERESIS = 0.2; +const DYNAMIC_MOVER_SCALE_SETTLE_MS = 160; +const DYNAMIC_MOVER_SCALE_COOLDOWN_MS = 300; const DYNAMIC_MOVER_SUBPIXEL_SNAP = false; const SMALL_SHIP_MASK_SIZE = 5; const TRANSPORT_SHIP_MASK = [ @@ -140,6 +144,13 @@ export class UnitLayer implements Layer { private onScreenCursor = 0; private offScreenCursor = 0; private renderFrame = 0; + private dynamicMoverCanvasScale = 1; + private pendingDynamicMoverCanvasScale: number | null = null; + private pendingDynamicMoverCanvasScaleSinceMs = 0; + private lastDynamicMoverCanvasScaleChangeAtMs = -Infinity; + private lastDynamicMoverCanvasRescaleMs = 0; + private totalDynamicMoverCanvasRescaleMs = 0; + private dynamicMoverCanvasRescaleCount = 0; private lastPerfCounters: Record = { moversTrackedTotal: 0, moversSampled: 0, @@ -150,6 +161,10 @@ export class UnitLayer implements Layer { budgetSoftOverrunMs: UNIT_DRAW_SOFT_OVERRUN_MS, avgOnScreenDebt: 0, maxOnScreenDebt: 0, + moverCanvasScale: 1, + moverCanvasRescaleLastMs: 0, + moverCanvasRescaleAvgMs: 0, + moverCanvasRescaleCount: 0, }; private theme: Theme; @@ -460,6 +475,8 @@ export class UnitLayer implements Layer { renderLayer(context: CanvasRenderingContext2D) { this.renderFrame++; + const nowMs = performance.now(); + this.maybeUpdateDynamicMoverCanvasScale(nowMs); const tickAlpha = this.computeTickAlpha(); const tickFloat = this.game.ticks() + tickAlpha; const viewBounds = this.currentViewBounds(); @@ -568,6 +585,14 @@ export class UnitLayer implements Layer { avgOnScreenDebt: onScreenDebtCount > 0 ? totalOnScreenDebt / onScreenDebtCount : 0, maxOnScreenDebt, + moverCanvasScale: this.dynamicMoverCanvasScale, + moverCanvasRescaleLastMs: this.lastDynamicMoverCanvasRescaleMs, + moverCanvasRescaleAvgMs: + this.dynamicMoverCanvasRescaleCount > 0 + ? this.totalDynamicMoverCanvasRescaleMs / + this.dynamicMoverCanvasRescaleCount + : 0, + moverCanvasRescaleCount: this.dynamicMoverCanvasRescaleCount, }; } @@ -1024,12 +1049,12 @@ export class UnitLayer implements Layer { } private snapDynamicMoverCoord(value: number): number { - if (!DYNAMIC_MOVER_SUBPIXEL_SNAP || DYNAMIC_MOVER_CANVAS_SCALE <= 0) { + if (!DYNAMIC_MOVER_SUBPIXEL_SNAP || this.dynamicMoverCanvasScale <= 0) { return value; } return ( - Math.round(value * DYNAMIC_MOVER_CANVAS_SCALE) / - DYNAMIC_MOVER_CANVAS_SCALE + Math.round(value * this.dynamicMoverCanvasScale) / + this.dynamicMoverCanvasScale ); } @@ -1151,12 +1176,17 @@ export class UnitLayer implements Layer { if (context === null) throw new Error("2d context not supported"); this.context = context; - this.dynamicMoverCanvas = document.createElement("canvas"); - const dynamicMoverContext = this.dynamicMoverCanvas.getContext("2d"); - if (dynamicMoverContext === null) - throw new Error("2d context not supported"); - this.dynamicMoverContext = dynamicMoverContext; - this.dynamicMoverContext.imageSmoothingEnabled = false; + const initialDynamicScale = this.baseDynamicMoverCanvasScaleForZoom( + this.transformHandler.scale, + ); + this.dynamicMoverCanvasScale = initialDynamicScale; + this.pendingDynamicMoverCanvasScale = null; + this.pendingDynamicMoverCanvasScaleSinceMs = 0; + this.lastDynamicMoverCanvasScaleChangeAtMs = performance.now(); + this.lastDynamicMoverCanvasRescaleMs = 0; + this.totalDynamicMoverCanvasRescaleMs = 0; + this.dynamicMoverCanvasRescaleCount = 0; + this.initializeDynamicMoverCanvas(initialDynamicScale); this.trailCanvas = document.createElement("canvas"); const trailContext = this.trailCanvas.getContext("2d"); @@ -1165,17 +1195,6 @@ export class UnitLayer implements Layer { this.canvas.width = this.game.width(); this.canvas.height = this.game.height(); - this.dynamicMoverCanvas.width = this.game.width() * DYNAMIC_MOVER_CANVAS_SCALE; - this.dynamicMoverCanvas.height = - this.game.height() * DYNAMIC_MOVER_CANVAS_SCALE; - this.dynamicMoverContext.setTransform( - DYNAMIC_MOVER_CANVAS_SCALE, - 0, - 0, - DYNAMIC_MOVER_CANVAS_SCALE, - 0, - 0, - ); this.trailCanvas.width = this.game.width(); this.trailCanvas.height = this.game.height(); @@ -1190,6 +1209,127 @@ export class UnitLayer implements Layer { this.redrawStaticSprites(); } + private baseDynamicMoverCanvasScaleForZoom(zoom: number): number { + let idx = 0; + while ( + idx < DYNAMIC_MOVER_ZOOM_THRESHOLDS.length && + zoom >= DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx] + ) { + idx++; + } + return DYNAMIC_MOVER_SCALE_STEPS[idx]; + } + + private dynamicMoverCanvasScaleForZoomWithHysteresis(zoom: number): number { + let idx = DYNAMIC_MOVER_SCALE_STEPS.indexOf(this.dynamicMoverCanvasScale); + if (idx < 0) { + idx = 0; + } + + while ( + idx < DYNAMIC_MOVER_ZOOM_THRESHOLDS.length && + zoom >= DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx] + DYNAMIC_MOVER_ZOOM_HYSTERESIS + ) { + idx++; + } + + while ( + idx > 0 && + zoom < DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx - 1] - DYNAMIC_MOVER_ZOOM_HYSTERESIS + ) { + idx--; + } + + return DYNAMIC_MOVER_SCALE_STEPS[idx]; + } + + private maybeUpdateDynamicMoverCanvasScale(nowMs: number): void { + const targetScale = this.dynamicMoverCanvasScaleForZoomWithHysteresis( + this.transformHandler.scale, + ); + if (targetScale === this.dynamicMoverCanvasScale) { + this.pendingDynamicMoverCanvasScale = null; + this.pendingDynamicMoverCanvasScaleSinceMs = 0; + return; + } + + if ( + nowMs - this.lastDynamicMoverCanvasScaleChangeAtMs < + DYNAMIC_MOVER_SCALE_COOLDOWN_MS + ) { + return; + } + + if (this.pendingDynamicMoverCanvasScale !== targetScale) { + this.pendingDynamicMoverCanvasScale = targetScale; + this.pendingDynamicMoverCanvasScaleSinceMs = nowMs; + return; + } + + if ( + nowMs - this.pendingDynamicMoverCanvasScaleSinceMs < + DYNAMIC_MOVER_SCALE_SETTLE_MS + ) { + return; + } + + this.lastDynamicMoverCanvasRescaleMs = + this.rebuildDynamicMoverCanvas(targetScale); + this.totalDynamicMoverCanvasRescaleMs += this.lastDynamicMoverCanvasRescaleMs; + this.dynamicMoverCanvasRescaleCount++; + this.dynamicMoverCanvasScale = targetScale; + this.lastDynamicMoverCanvasScaleChangeAtMs = nowMs; + this.pendingDynamicMoverCanvasScale = null; + this.pendingDynamicMoverCanvasScaleSinceMs = 0; + } + + private initializeDynamicMoverCanvas(scale: number): void { + this.dynamicMoverCanvas = document.createElement("canvas"); + this.dynamicMoverCanvas.width = Math.max(1, this.game.width() * scale); + this.dynamicMoverCanvas.height = Math.max(1, this.game.height() * scale); + const dynamicMoverContext = this.dynamicMoverCanvas.getContext("2d"); + if (dynamicMoverContext === null) { + throw new Error("2d context not supported"); + } + this.dynamicMoverContext = dynamicMoverContext; + this.dynamicMoverContext.imageSmoothingEnabled = false; + this.dynamicMoverContext.setTransform(scale, 0, 0, scale, 0, 0); + } + + private rebuildDynamicMoverCanvas(targetScale: number): number { + const oldCanvas = this.dynamicMoverCanvas; + const oldWidth = oldCanvas.width; + const oldHeight = oldCanvas.height; + + this.dynamicMoverCanvas = document.createElement("canvas"); + this.dynamicMoverCanvas.width = Math.max(1, this.game.width() * targetScale); + this.dynamicMoverCanvas.height = Math.max(1, this.game.height() * targetScale); + const dynamicMoverContext = this.dynamicMoverCanvas.getContext("2d"); + if (dynamicMoverContext === null) { + throw new Error("2d context not supported"); + } + this.dynamicMoverContext = dynamicMoverContext; + this.dynamicMoverContext.imageSmoothingEnabled = false; + + const blitStart = performance.now(); + this.dynamicMoverContext.setTransform(1, 0, 0, 1, 0, 0); + this.dynamicMoverContext.drawImage( + oldCanvas, + 0, + 0, + oldWidth, + oldHeight, + 0, + 0, + this.dynamicMoverCanvas.width, + this.dynamicMoverCanvas.height, + ); + const blitMs = performance.now() - blitStart; + + this.dynamicMoverContext.setTransform(targetScale, 0, 0, targetScale, 0, 0); + return blitMs; + } + private setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) { return false;