Pr fxlayer viewport culling (#3123)

## Description:

Reduce FX layer rendering cost by:
- Updating the offscreen FX buffer only when needed (and clearing it
once when FX ends).
- Drawing only the visible portion of the FX buffer to the main canvas
(viewport culling).
- Reusing `TransformHandler.screenBoundingRect()` as the single source
of truth for viewport bounds.
- 
## Changes
- `FxLayer`:
  - Track buffered frames and skip work when there are no active FX.
  - Use `performance.now()` for refresh timing.
- Draw only the visible map rect (clamp + small pad) instead of blitting
the full map-sized FX canvas.
- Compute the visible rect via `TransformHandler.screenBoundingRect()`.
- `GameRenderer`:
  - Thread `TransformHandler` into `FxLayer` construction.


## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME
This commit is contained in:
scamiv
2026-02-05 05:18:25 +01:00
committed by GitHub
parent c2663944e5
commit ec5fb4fa22
2 changed files with 62 additions and 21 deletions
+1 -1
View File
@@ -269,7 +269,7 @@ export function createRenderer(
structureLayer,
samRadiusLayer,
new UnitLayer(game, eventBus, transformHandler),
new FxLayer(game),
new FxLayer(game, transformHandler),
new UILayer(game, eventBus, transformHandler),
new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
+61 -20
View File
@@ -13,20 +13,25 @@ import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class FxLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private lastRefresh: number = 0;
private lastRefreshMs: number = 0;
private refreshRate: number = 10;
private theme: Theme;
private animatedSpriteLoader: AnimatedSpriteLoader =
new AnimatedSpriteLoader();
private allFx: Fx[] = [];
private hasBufferedFrame = false;
constructor(private game: GameView) {
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {
this.theme = this.game.config().theme();
}
@@ -254,28 +259,64 @@ export class FxLayer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
if (this.game.config().userSettings()?.fxLayer()) {
if (now > this.lastRefresh + this.refreshRate) {
const delta = now - this.lastRefresh;
this.renderAllFx(context, delta);
this.lastRefresh = now;
const nowMs = performance.now();
const hasFx = this.allFx.length > 0;
if (!this.game.config().userSettings()?.fxLayer() || !hasFx) {
if (this.hasBufferedFrame) {
// Clear stale pixels once when fx ends/disabled so re-enabling doesn't
// flash old frames.
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.hasBufferedFrame = false;
}
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
this.lastRefreshMs = nowMs;
return;
}
const needsRefresh =
!this.hasBufferedFrame || nowMs > this.lastRefreshMs + this.refreshRate;
if (needsRefresh) {
const delta = this.hasBufferedFrame ? nowMs - this.lastRefreshMs : 0;
this.renderAllFx(delta);
this.lastRefreshMs = nowMs;
this.hasBufferedFrame = true;
}
this.drawVisibleFx(context);
}
renderAllFx(context: CanvasRenderingContext2D, delta: number) {
if (this.allFx.length > 0) {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.renderContextFx(delta);
}
private drawVisibleFx(context: CanvasRenderingContext2D) {
const mapW = this.game.width();
const mapH = this.game.height();
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
const pad = 2;
const left = Math.max(0, Math.floor(topLeft.x - pad));
const top = Math.max(0, Math.floor(topLeft.y - pad));
const right = Math.min(mapW, Math.ceil(bottomRight.x + pad));
const bottom = Math.min(mapH, Math.ceil(bottomRight.y + pad));
const width = Math.max(0, right - left);
const height = Math.max(0, bottom - top);
if (width === 0 || height === 0) return;
context.drawImage(
this.canvas,
left,
top,
width,
height,
-mapW / 2 + left,
-mapH / 2 + top,
width,
height,
);
}
private renderAllFx(delta: number) {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.renderContextFx(delta);
}
renderContextFx(duration: number) {