mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 08:48:10 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user