From ee90da8e661f0e010d0000d89ee5e0d68915aaa3 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Tue, 3 Feb 2026 19:37:35 +0100 Subject: [PATCH] Worker rendering: backpressure render_frame + reduce relations rebuilds - Add render_done worker message and tag render_frame with an id. - Gate TerritoryRendererProxy/Canvas2DRendererProxy to one in-flight render (2s safety timeout) to prevent render queue buildup. - Split roster vs palette dirtiness in GameViewAdapter and only force full relations rebuilds on roster/team changes. - Only markRelationsDirty() on roster changes in WorkerTerritoryRenderer to avoid repeated expensive uploadRelations() while paused. --- .../canvas2d/Canvas2DRendererProxy.ts | 37 ++++++++++++ .../graphics/webgpu/TerritoryRendererProxy.ts | 37 ++++++++++++ src/core/worker/GameViewAdapter.ts | 56 +++++++++++++++---- src/core/worker/Worker.worker.ts | 7 +++ src/core/worker/WorkerMessages.ts | 6 ++ src/core/worker/WorkerTerritoryRenderer.ts | 10 +++- 6 files changed, 140 insertions(+), 13 deletions(-) diff --git a/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts index bb07d961f..bae056f55 100644 --- a/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts +++ b/src/client/graphics/canvas2d/Canvas2DRendererProxy.ts @@ -2,6 +2,7 @@ import { createCanvas } from "src/client/Utils"; import { Theme } from "../../../core/configuration/Config"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; +import { generateID } from "../../../core/Util"; import { WorkerClient } from "../../../core/worker/WorkerClient"; import { InitRendererMessage, @@ -38,6 +39,7 @@ export class Canvas2DRendererProxy { private viewTransform: ViewTransform = { scale: 1, offsetX: 0, offsetY: 0 }; private lastSentViewSize: ViewSize | null = null; private lastSentViewTransform: ViewTransform | null = null; + private renderInFlight = false; private constructor( private readonly game: GameView, @@ -82,6 +84,7 @@ export class Canvas2DRendererProxy { if (this.initPromise) return; this.initPromise = this.init().catch((err) => { this.failed = true; + this.renderInFlight = false; this.pendingMessages = []; console.error("Worker canvas2d renderer init failed:", err); throw err; @@ -301,7 +304,18 @@ export class Canvas2DRendererProxy { } render(): void { + if (this.failed) { + return; + } + if (this.renderInFlight) { + return; + } + + this.renderInFlight = true; + const renderId = `render_${generateID()}`; + const message: RenderFrameMessage = { type: "render_frame" }; + message.id = renderId; if ( !this.lastSentViewSize || @@ -322,6 +336,29 @@ export class Canvas2DRendererProxy { this.lastSentViewTransform = this.viewTransform; } + const worker = this.worker; + if (worker) { + const timeout = setTimeout(() => { + if (!this.renderInFlight) { + worker.removeMessageHandler(renderId); + return; + } + this.renderInFlight = false; + worker.removeMessageHandler(renderId); + }, 2000); + + worker.addMessageHandler(renderId, (m: any) => { + if (m?.type !== "render_done") { + return; + } + clearTimeout(timeout); + this.renderInFlight = false; + }); + } else { + this.renderInFlight = false; + return; + } + this.sendToWorker(message); } } diff --git a/src/client/graphics/webgpu/TerritoryRendererProxy.ts b/src/client/graphics/webgpu/TerritoryRendererProxy.ts index 8d5b021b7..27ee30d25 100644 --- a/src/client/graphics/webgpu/TerritoryRendererProxy.ts +++ b/src/client/graphics/webgpu/TerritoryRendererProxy.ts @@ -2,6 +2,7 @@ import { createCanvas } from "src/client/Utils"; import { Theme } from "../../../core/configuration/Config"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; +import { generateID } from "../../../core/Util"; import { WorkerClient } from "../../../core/worker/WorkerClient"; import { InitRendererMessage, @@ -42,6 +43,7 @@ export class TerritoryRendererProxy { private viewTransform: ViewTransform = { scale: 1, offsetX: 0, offsetY: 0 }; private lastSentViewSize: ViewSize | null = null; private lastSentViewTransform: ViewTransform | null = null; + private renderInFlight = false; private constructor( private readonly game: GameView, @@ -92,6 +94,7 @@ export class TerritoryRendererProxy { if (this.initPromise) return; this.initPromise = this.init().catch((err) => { this.failed = true; + this.renderInFlight = false; this.pendingMessages = []; console.error("Worker territory renderer init failed:", err); throw err; @@ -390,7 +393,18 @@ export class TerritoryRendererProxy { } render(): void { + if (this.failed) { + return; + } + if (this.renderInFlight) { + return; + } + + this.renderInFlight = true; + const renderId = `render_${generateID()}`; + const message: RenderFrameMessage = { type: "render_frame" }; + message.id = renderId; if ( !this.lastSentViewSize || @@ -411,6 +425,29 @@ export class TerritoryRendererProxy { this.lastSentViewTransform = this.viewTransform; } + const worker = this.worker; + if (worker) { + const timeout = setTimeout(() => { + if (!this.renderInFlight) { + worker.removeMessageHandler(renderId); + return; + } + this.renderInFlight = false; + worker.removeMessageHandler(renderId); + }, 2000); + + worker.addMessageHandler(renderId, (m: any) => { + if (m?.type !== "render_done") { + return; + } + clearTimeout(timeout); + this.renderInFlight = false; + }); + } else { + this.renderInFlight = false; + return; + } + this.sendToWorker(message); } } diff --git a/src/core/worker/GameViewAdapter.ts b/src/core/worker/GameViewAdapter.ts index 17fae525d..8b5884b96 100644 --- a/src/core/worker/GameViewAdapter.ts +++ b/src/core/worker/GameViewAdapter.ts @@ -181,15 +181,18 @@ export class GameViewAdapter implements Partial { private readonly defensePostsById = new Map(); private readonly defensePosts: DefensePostUnit[] = []; + // "Dirty" here means "palette/relations roster may have changed" (not "any player field updated"). private playersDirty = true; + private rosterDirty = true; private readonly playersBySmallId = new Map(); private playerViewsCache: PlayerLiteView[] = []; - private playersEpoch = 1; + private rosterEpoch = 1; private playerViewsCacheEpoch = 0; private playerColorsEpoch = 1; private readonly playerColorsDirtyEpochBySmallId = new Map(); private readonly embargoPairs = new Set(); private readonly friendlyPairs = new Set(); + private relationsInitialized = false; private readonly emptyCosmetics = {} as PlayerCosmetics; constructor( @@ -302,13 +305,18 @@ export class GameViewAdapter implements Partial { return dirty; } + consumeRosterDirty(): boolean { + const dirty = this.rosterDirty; + this.rosterDirty = false; + return dirty; + } + setPatternsEnabled(enabled: boolean): void { if (this.patternsEnabled === enabled) { return; } this.patternsEnabled = enabled; this.playersDirty = true; - this.playersEpoch++; this.playerColorsEpoch++; } @@ -321,7 +329,8 @@ export class GameViewAdapter implements Partial { const playerUpdates = (gu.updates?.[GameUpdateType.Player] ?? []) as PlayerUpdate[]; - let playersChanged = false; + let rosterChanged = false; + let paletteRelevantChanged = false; for (const p of playerUpdates) { const small = p.smallID; if (small <= 0) { @@ -329,17 +338,42 @@ export class GameViewAdapter implements Partial { } const existing = this.playersBySmallId.get(small); if (existing) { + const prev = existing.data; existing.data = p; - existing.markColorsDirty(); + const teamChanged = (prev.team ?? null) !== (p.team ?? null); + const colorRelevantChanged = + teamChanged || + prev.clientID !== p.clientID || + prev.playerType !== p.playerType || + prev.isAlive !== p.isAlive || + prev.isDisconnected !== p.isDisconnected; + if (colorRelevantChanged) { + existing.markColorsDirty(); + paletteRelevantChanged = true; + } + if (teamChanged) { + // Team changes affect "friendly" relations matrix across many pairs. + // Treat it like a roster change to force a full relations rebuild. + rosterChanged = true; + } } else { this.playersBySmallId.set(small, new PlayerLiteView(this, p)); + rosterChanged = true; + paletteRelevantChanged = true; } - playersChanged = true; } - if (playersChanged) { - this.playersDirty = true; - this.playersEpoch++; + if (rosterChanged) { + this.rosterDirty = true; + this.rosterEpoch++; + } + if (rosterChanged || paletteRelevantChanged) { + this.playersDirty = true; + } + + const shouldRebuildRelationsSnapshot = + rosterChanged || (!this.relationsInitialized && playerUpdates.length > 0); + if (shouldRebuildRelationsSnapshot) { // Rebuild relations snapshot from authoritative PlayerUpdate state. // This ensures correct initial relations without relying on event history. this.embargoPairs.clear(); @@ -367,6 +401,8 @@ export class GameViewAdapter implements Partial { } } } + + this.relationsInitialized = true; } const embargoUpdates = (gu.updates?.[GameUpdateType.EmbargoEvent] ?? @@ -478,9 +514,9 @@ export class GameViewAdapter implements Partial { * otherwise the worker-rendered territory will disagree with UI. */ playerViews(): any[] { - if (this.playerViewsCacheEpoch !== this.playersEpoch) { + if (this.playerViewsCacheEpoch !== this.rosterEpoch) { this.playerViewsCache = [...this.playersBySmallId.values()]; - this.playerViewsCacheEpoch = this.playersEpoch; + this.playerViewsCacheEpoch = this.rosterEpoch; } return this.playerViewsCache; } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 91068866f..85e7b4a12 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -26,6 +26,7 @@ import { PlayerActionsResultMessage, PlayerBorderTilesResultMessage, PlayerProfileResultMessage, + RenderDoneMessage, RendererReadyMessage, TileContextResultMessage, TransportShipSpawnResultMessage, @@ -548,6 +549,12 @@ ctx.addEventListener("message", async (e: MessageEvent) => { ); } renderer.render(); + if (message.id) { + sendMessage({ + type: "render_done", + id: message.id, + } as RenderDoneMessage); + } } break; diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 9bea06356..ebb78b85b 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -41,6 +41,7 @@ export type WorkerMessageType = | "refresh_terrain" | "tick_renderer" | "render_frame" + | "render_done" | "renderer_metrics"; // Base interface for all messages @@ -255,6 +256,10 @@ export interface RenderFrameMessage extends BaseWorkerMessage { } // Renderer messages from worker to main thread +export interface RenderDoneMessage extends BaseWorkerMessage { + type: "render_done"; +} + export interface RendererReadyMessage extends BaseWorkerMessage { type: "renderer_ready"; ok: boolean; @@ -302,5 +307,6 @@ export type WorkerMessage = | PlayerBorderTilesResultMessage | AttackAveragePositionResultMessage | TransportShipSpawnResultMessage + | RenderDoneMessage | RendererReadyMessage | RendererMetricsMessage; diff --git a/src/core/worker/WorkerTerritoryRenderer.ts b/src/core/worker/WorkerTerritoryRenderer.ts index 91038c984..21d6398da 100644 --- a/src/core/worker/WorkerTerritoryRenderer.ts +++ b/src/core/worker/WorkerTerritoryRenderer.ts @@ -179,16 +179,20 @@ export class WorkerTerritoryRenderer { this.gameViewAdapter.update(gu); const defensePostsDirty = this.gameViewAdapter.consumeDefensePostsDirty(); + const rosterDirty = this.gameViewAdapter.consumeRosterDirty(); const playersDirty = this.gameViewAdapter.consumePlayersDirty(); if (defensePostsDirty) { this.resources?.markDefensePostsDirty(); } - if (playersDirty) { - this.resources?.markPaletteDirty(); + if (rosterDirty) { this.resources?.markRelationsDirty(); + this.resources?.markPaletteDirty(); + this.resources?.invalidateHistory(); + } else if (playersDirty) { + this.resources?.markPaletteDirty(); this.resources?.invalidateHistory(); } - return defensePostsDirty || playersDirty; + return defensePostsDirty || rosterDirty || playersDirty; } /**