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.
This commit is contained in:
scamiv
2026-02-03 19:37:35 +01:00
parent 6f96cab778
commit ee90da8e66
6 changed files with 140 additions and 13 deletions
@@ -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);
}
}
@@ -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);
}
}
+46 -10
View File
@@ -181,15 +181,18 @@ export class GameViewAdapter implements Partial<GameView> {
private readonly defensePostsById = new Map<number, DefensePostUnit>();
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<number, PlayerLiteView>();
private playerViewsCache: PlayerLiteView[] = [];
private playersEpoch = 1;
private rosterEpoch = 1;
private playerViewsCacheEpoch = 0;
private playerColorsEpoch = 1;
private readonly playerColorsDirtyEpochBySmallId = new Map<number, number>();
private readonly embargoPairs = new Set<bigint>();
private readonly friendlyPairs = new Set<bigint>();
private relationsInitialized = false;
private readonly emptyCosmetics = {} as PlayerCosmetics;
constructor(
@@ -302,13 +305,18 @@ export class GameViewAdapter implements Partial<GameView> {
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<GameView> {
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<GameView> {
}
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<GameView> {
}
}
}
this.relationsInitialized = true;
}
const embargoUpdates = (gu.updates?.[GameUpdateType.EmbargoEvent] ??
@@ -478,9 +514,9 @@ export class GameViewAdapter implements Partial<GameView> {
* 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;
}
+7
View File
@@ -26,6 +26,7 @@ import {
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
PlayerProfileResultMessage,
RenderDoneMessage,
RendererReadyMessage,
TileContextResultMessage,
TransportShipSpawnResultMessage,
@@ -548,6 +549,12 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
);
}
renderer.render();
if (message.id) {
sendMessage({
type: "render_done",
id: message.id,
} as RenderDoneMessage);
}
}
break;
+6
View File
@@ -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;
+7 -3
View File
@@ -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;
}
/**