From f304141338cb6d106e2d13fff2dcef9a39dfacad Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:40:02 +0200 Subject: [PATCH] Fix/refactor/optim(StructureIconsLayer): restore structure icons after context loss, use WebGL/WebGPU/Canvas, and some improvements (#3654) ## Description: StructureIconsLayer and StructureDrawingUtils fixes and improvements. Most notably have it restore structure icons after webGL context loss. Inspired by @Skigim's https://github.com/openfrontio/OpenFrontIO/pull/3339, https://github.com/openfrontio/OpenFrontIO/pull/3480. Fixes his https://github.com/openfrontio/OpenFrontIO/issues/3207, contains only those fixes from the Issue that are actually valid and needed fixes, on top of his earlier merged PR. ### CONTAINS (partly written by AI, excuse the exaggerated language) **1.** * ** AutoDetectRenderer: ** now, if Hardware Acceleration is unavailable or disabled, Structure Icons will be displayed using Canvas renderer. Otherwise it will use either WebGL or WebGPU, depeding on which is available. PixiJS currently prefers WebGL but it will switch this to WebGPU at one point. We can also force it to WebGPU as explained in the comment. * ** Canvas: ** on Canvas, what doesn't work is gracefully skipped. The non-working parts will be fixed, see this issue in their repo, but until then it will work fine for us anyway: https://github.com/pixijs/pixijs/issues/11981 * **WebGPU Context Loss:** PixiJS doesn't restore this context itself. Instead we do it by calling setupRenderer again on device loss. * **WebGL Context Loss:** To know when we need to restore the layer, don't use native event (`webglcontextrestored`) but use PixiJS's internal hook (`this.renderer.runners.contextChange`). This prevents our cache-clearing commands from interrupting Pixi while it's still busy rebuilding its internal GL State Machine buffer. With links severed, we need to clear and rebuild all icons to restore them. * **WebGL Context existance Check (`this.renderer.context?.isLost`):** This prevents a crash in PixiJS. Fixes black map background and all graphics frozen, which has been reported a few times. Issue created in their repo: https://github.com/pixijs/pixijs/issues/12032. * **Redraw:** for Canvas context restore or on Alt-R, a call from GameRenderer now actually restores icons. Also called for WebGPU device loss and after contextChange WebGL restoration. Checks for WebGL context.isLost so a calls from Alt-R etc won't meddle while GL context is lost. * **Orphaned Object Leaks:** In PixiJS v8, `Container.destroy()` does *not* recursively destroy its children. This PR explicitly adds `.destroy({ children: true })` inside icon deletion states. This stops thousands of `PIXI.Sprite` and `PIXI.BitmapText` child nodes from leaking and choking Pixi when it attempts a WebGL restore. * **Texture Lifecycle:** Invalidate caching logic in `SpriteFactory` now correctly executes `.destroy(true)` on `PIXI.Texture` objects. Previously, they were only deleted from the textureCache Map, retaining a phantom grip on GPU memory buffers. * **Don't remove PIXI.Texture.EMPTY from textureCache: `createTexture()` in `SpriteFactory` stores `PIXI.Texture.EMPTY` (a singleton) in `textureCache` when a structure type has no known shape. When not preventing removal of the EMPTY texture, `clearCache()` would call `texture.destroy(true)` on PixiJS's shared global empty texture, breaking all sprites in the renderer that fall back to it. **2. Small Memory/Perf Optimizations** * **The Shared 2D Canvas Optimization:** To prevent allocating endless tiny `` elements every time a structure color is loaded, `SpriteFactory` now utilizes a cleanly shared `colorCanvas` singleton. To keep this safe from hardware acceleration crashes (where the 2D context dies alongside WebGL), it accurately nullifies itself in `clearCache()` and lazily instantiates on the next call (`getImageColored()`). * **Bypassing Inefficient Textures Cache:** Now passing the `skipCache: true` argument implicitly to dynamic UI elements via `PIXI.Texture.from(structureCanvas, true)`. * **Zero-Allocation Filters (No more GC Stutters):** `renderGhost()` previously spawned numerous `new OutlineFilter(...)` WebGL shaders when hovering over invalid tiles, compounding to many leaked Shader Programs. We hoisted these filters to static class properties initialized once, and went a step further: hoisted the wrapping Array structures too (`filterRedArray`, `filterGreenArray`). This eliminates many pointless micro-allocations and GC sweeps entirely. **BEFORE, for webGL:** https://youtu.be/durJxNFNePs **AFTER, for WebGL:** https://youtu.be/VnYEFMx4gfM **AFTER, for Canvas:** https://youtu.be/zT720oKxcaI **AFTER, for WebGPU:** https://youtu.be/J09Wee2qTs8 The performance optimizations weren't well measurable in my tests but there's no downgrade at least. WebGPU should bee better than WebGL when we would force it but again, currently PixiJS prefers WebGL hardcoded so only if we disallow WebGL will it use WebGPU if it is available, otherwise fallback gracefully to Canvas still. Canvas skips parts gracefully, as long as the non-breaking issue exists in PixiJS (as explained above): AFTER Canvas in Firefox skips
non-supported gracefully ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] 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: tryout33 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../graphics/layers/StructureDrawingUtils.ts | 39 +++- .../graphics/layers/StructureIconsLayer.ts | 197 +++++++++++++----- 2 files changed, 173 insertions(+), 63 deletions(-) diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts index 1ccad7c2f..49c9f6734 100644 --- a/src/client/graphics/layers/StructureDrawingUtils.ts +++ b/src/client/graphics/layers/StructureDrawingUtils.ts @@ -56,6 +56,8 @@ export class SpriteFactory { private transformHandler: TransformHandler; private renderSprites: boolean; private readonly textureCache: Map = new Map(); + private colorCanvas: HTMLCanvasElement | null = null; + private colorCtx: CanvasRenderingContext2D | null = null; private readonly structuresInfos: Map< UnitType, @@ -81,6 +83,21 @@ export class SpriteFactory { this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType)); } + public clearCache() { + for (const texture of this.textureCache.values()) { + if (texture && texture !== PIXI.Texture.EMPTY) { + try { + texture.destroy(true); + } catch (e) { + console.error("Error clearing texture cache:", e); + } + } + } + this.textureCache.clear(); + this.colorCanvas = null; + this.colorCtx = null; + } + private loadIcon( unitInfo: { iconPath: string; @@ -108,6 +125,10 @@ export class SpriteFactory { private invalidateTextureCache(unitType: UnitType) { for (const key of Array.from(this.textureCache.keys())) { if (key.includes(`-${unitType}`)) { + const tex = this.textureCache.get(key); + if (tex && tex !== PIXI.Texture.EMPTY) { + tex.destroy(true); + } this.textureCache.delete(key); } } @@ -455,7 +476,7 @@ export class SpriteFactory { context.restore(); } - return PIXI.Texture.from(structureCanvas); + return PIXI.Texture.from(structureCanvas, true); } public createRange( @@ -511,14 +532,18 @@ export class SpriteFactory { image: HTMLImageElement, color: string, ): HTMLCanvasElement { - const imageCanvas = document.createElement("canvas"); - imageCanvas.width = image.width; - imageCanvas.height = image.height; - const ctx = imageCanvas.getContext("2d")!; + if (!this.colorCanvas || !this.colorCtx) { + this.colorCanvas = document.createElement("canvas"); + this.colorCtx = this.colorCanvas.getContext("2d")!; + } + const { colorCanvas, colorCtx: ctx } = this; + if (colorCanvas.width !== image.width) colorCanvas.width = image.width; + if (colorCanvas.height !== image.height) colorCanvas.height = image.height; + ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = color; - ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height); + ctx.fillRect(0, 0, colorCanvas.width, colorCanvas.height); ctx.globalCompositeOperation = "destination-in"; ctx.drawImage(image, 0, 0); - return imageCanvas; + return colorCanvas; } } diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 20281eab8..5d879e740 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -101,7 +101,11 @@ export class StructureIconsLayer implements Layer { private visibilityStateDirty = true; private pendingConfirm: MouseUpEvent | null = null; private hasHiddenStructure = false; + private rebuildPending = false; potentialUpgrade: StructureRenderInfo | undefined; + private filterRedArray: OutlineFilter[] = []; + private filterGreenArray: OutlineFilter[] = []; + private filterWhiteArray: OutlineFilter[] = []; constructor( private game: GameView, @@ -119,16 +123,37 @@ export class StructureIconsLayer implements Layer { } async setupRenderer() { + if (this.renderer) { + this.renderer.destroy(true); + this.rootStage.removeChildren(); + } + try { await PIXI.Assets.load(bitmapFont); } catch (error) { console.error("Failed to load bitmap font:", error); } - const renderer = new PIXI.WebGLRenderer(); + this.pixicanvas = document.createElement("canvas"); this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; + // This will prefer WebGL, eventually WebGPU, and fallback to Canvas + // Restrict using 'preferences: ["WebGPU", "WebGL"]' or + // 'preferences: "WebGPU"' later if needed + const renderer = await PIXI.autoDetectRenderer({ + canvas: this.pixicanvas, + resolution: 1, + width: this.pixicanvas.width, + height: this.pixicanvas.height, + antialias: false, + clearBeforeRender: true, + backgroundAlpha: 0, + backgroundColor: 0x00000000, + }); + + console.info(`Using ${renderer.name} for structure icons layer`); + this.iconsStage = new PIXI.Container(); this.iconsStage.position.set(0, 0); this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); @@ -154,25 +179,87 @@ export class StructureIconsLayer implements Layer { this.rootStage.position.set(0, 0); this.rootStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - await renderer.init({ - canvas: this.pixicanvas, - resolution: 1, - width: this.pixicanvas.width, - height: this.pixicanvas.height, - antialias: false, - clearBeforeRender: true, - backgroundAlpha: 0, - backgroundColor: 0x00000000, - }); + this.filterRedArray = [ + new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), + ]; + this.filterGreenArray = [ + new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), + ]; + this.filterWhiteArray = [ + new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }), + ]; this.renderer = renderer; + + if (this.renderer.name === "webgpu") { + // Listen to device loss as PixiJS doesn't handle WebGPU context loss itself + const gpuRenderer = this.renderer as PIXI.WebGPURenderer; + gpuRenderer.gpu.device.lost.then(() => { + this.redraw(); + }); + } + + if (this.renderer.name === "webgl") { + this.renderer.runners.contextChange.add({ + // Listen to contextChange as PixiJS handles WebGL context loss and restores itself. + // Don't listen to "webglcontextrestored" event directly as it can fire before PixiJS is ready. + contextChange: () => { + requestAnimationFrame(() => { + this.redraw(); + }); + }, + }); + } + this.rendererInitialized = true; } + private rebuildAllIcons() { + this.clearGhostStructure(); + this.factory.clearCache(); + const allUnitIds = Array.from(this.seenUnitIds); + this.seenUnitIds.clear(); + for (const unitId of allUnitIds) { + const render = this.rendersByUnitId.get(unitId); + if (render) { + render.iconContainer?.destroy({ children: true }); + render.dotContainer?.destroy({ children: true }); + render.levelContainer?.destroy({ children: true }); + } + const unitView = this.game.unit(unitId); + if (unitView && unitView.isActive()) { + this.handleActiveUnit(unitView); + } else { + this.rendersByUnitId.delete(unitId); + } + } + } + shouldTransform(): boolean { return false; } + async redraw() { + if (this.rebuildPending) { + return; + } + if (this.rendererOrGLContextLost()) { + return; + } + this.rebuildPending = true; + + try { + if (this.renderer?.name === "webgpu") { + this.rendererInitialized = false; + await this.setupRenderer(); + } + this.resizeCanvas(); + this.rebuildAllIcons(); + } finally { + this.rebuildPending = false; + } + } + async init() { this.eventBus.on(ToggleStructuresEvent, (e) => this.toggleStructures(e.structureTypes), @@ -188,15 +275,27 @@ export class StructureIconsLayer implements Layer { window.addEventListener("resize", () => this.resizeCanvas()); await this.setupRenderer(); - this.redraw(); + this.resizeCanvas(); + } + + private rendererOrGLContextLost(): boolean { + if (!this.renderer || !this.rendererInitialized) return true; + if (this.renderer.name === "webgl") { + // For WebGL, check isLost to prevent ungraceful handling by PixiJS: + // its GL > logPrettyShaderError throws, when getShaderSource returns null + // Needs to be fixed in PixiJS, in meantime prevent it from here + return (this.renderer as PIXI.WebGLRenderer).context?.isLost === true; + } + return false; } resizeCanvas() { - if (this.renderer) { - this.pixicanvas.width = window.innerWidth; - this.pixicanvas.height = window.innerHeight; - this.renderer.resize(innerWidth, innerHeight, 1); + if (this.rendererOrGLContextLost()) { + return; } + this.pixicanvas.width = window.innerWidth; + this.pixicanvas.height = window.innerHeight; + this.renderer?.resize(innerWidth, innerHeight, 1); } tick() { @@ -220,12 +319,8 @@ export class StructureIconsLayer implements Layer { this.game.config().userSettings()?.structureSprites() ?? true; } - redraw() { - this.resizeCanvas(); - } - renderLayer(mainContext: CanvasRenderingContext2D) { - if (!this.renderer || !this.rendererInitialized) { + if (this.rendererOrGLContextLost()) { return; } @@ -254,8 +349,10 @@ export class StructureIconsLayer implements Layer { scale > DOTS_ZOOM_THRESHOLD && (scale <= ZOOM_THRESHOLD || !this.renderSprites); this.levelsStage!.visible = scale > ZOOM_THRESHOLD && this.renderSprites; - this.renderer.render(this.rootStage); - mainContext.drawImage(this.renderer.canvas, 0, 0); + if (this.renderer) { + this.renderer?.render(this.rootStage); + mainContext.drawImage(this.renderer.canvas, 0, 0); + } } renderGhost() { @@ -333,9 +430,7 @@ export class StructureIconsLayer implements Layer { canUpgrade: false, }); this.updateGhostPrice(0, showPrice); - this.ghostUnit.container.filters = [ - new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), - ]; + this.ghostUnit.container.filters = this.filterRedArray; this.pendingConfirm = null; return; } @@ -356,20 +451,14 @@ export class StructureIconsLayer implements Layer { this.potentialUpgrade = undefined; } if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = [ - new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), - ]; - this.potentialUpgrade.dotContainer.filters = [ - new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), - ]; + this.potentialUpgrade.iconContainer.filters = this.filterGreenArray; + this.potentialUpgrade.dotContainer.filters = this.filterGreenArray; } // No overlapping when a structure is upgradable this.uiState.overlappingRailroads = []; this.uiState.ghostRailPaths = []; } else if (unit.canBuild === false) { - this.ghostUnit.container.filters = [ - new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), - ]; + this.ghostUnit.container.filters = this.filterRedArray; this.uiState.overlappingRailroads = []; this.uiState.ghostRailPaths = []; } else { @@ -536,8 +625,8 @@ export class StructureIconsLayer implements Layer { private clearGhostStructure() { this.pendingConfirm = null; if (this.ghostUnit) { - this.ghostUnit.container.destroy(); - this.ghostUnit.range?.destroy(); + this.ghostUnit.container.destroy({ children: true }); + this.ghostUnit.range?.destroy({ children: true }); this.ghostUnit = null; } if (this.potentialUpgrade) { @@ -585,7 +674,7 @@ export class StructureIconsLayer implements Layer { return; } - this.ghostUnit.range?.destroy(); + this.ghostUnit.range?.destroy({ children: true }); this.ghostUnit.range = null; this.ghostUnit.rangeLevel = level; this.ghostUnit.targetingAlly = targetingAlly; @@ -676,12 +765,8 @@ export class StructureIconsLayer implements Layer { render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3; render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3; if (structureInfos.visible && this.hasHiddenStructure) { - render.iconContainer.filters = [ - new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }), - ]; - render.dotContainer.filters = [ - new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }), - ]; + render.iconContainer.filters = this.filterWhiteArray; + render.dotContainer.filters = this.filterWhiteArray; } else { render.iconContainer.filters = []; render.dotContainer.filters = []; @@ -691,8 +776,8 @@ export class StructureIconsLayer implements Layer { private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) { if (unit.markedForDeletion() !== false) { - render.iconContainer?.destroy(); - render.dotContainer?.destroy(); + render.iconContainer?.destroy({ children: true }); + render.dotContainer?.destroy({ children: true }); render.iconContainer = this.createIconSprite(unit); render.dotContainer = this.createDotSprite(unit); this.modifyVisibility(render); @@ -705,8 +790,8 @@ export class StructureIconsLayer implements Layer { ) { if (render.underConstruction && !unit.isUnderConstruction()) { render.underConstruction = false; - render.iconContainer?.destroy(); - render.dotContainer?.destroy(); + render.iconContainer?.destroy({ children: true }); + render.dotContainer?.destroy({ children: true }); render.iconContainer = this.createIconSprite(unit); render.dotContainer = this.createDotSprite(unit); this.modifyVisibility(render); @@ -716,8 +801,8 @@ export class StructureIconsLayer implements Layer { private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) { if (render.owner !== unit.owner().id()) { render.owner = unit.owner().id(); - render.iconContainer?.destroy(); - render.dotContainer?.destroy(); + render.iconContainer?.destroy({ children: true }); + render.dotContainer?.destroy({ children: true }); render.iconContainer = this.createIconSprite(unit); render.dotContainer = this.createDotSprite(unit); this.modifyVisibility(render); @@ -727,9 +812,9 @@ export class StructureIconsLayer implements Layer { private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) { if (render.level !== unit.level()) { render.level = unit.level(); - render.iconContainer?.destroy(); - render.levelContainer?.destroy(); - render.dotContainer?.destroy(); + render.iconContainer?.destroy({ children: true }); + render.levelContainer?.destroy({ children: true }); + render.dotContainer?.destroy({ children: true }); render.iconContainer = this.createIconSprite(unit); render.levelContainer = this.createLevelSprite(unit); render.dotContainer = this.createDotSprite(unit); @@ -834,9 +919,9 @@ export class StructureIconsLayer implements Layer { } private deleteStructure(render: StructureRenderInfo) { - render.iconContainer?.destroy(); - render.levelContainer?.destroy(); - render.dotContainer?.destroy(); + render.iconContainer?.destroy({ children: true }); + render.levelContainer?.destroy({ children: true }); + render.dotContainer?.destroy({ children: true }); const unitId = render.unit.id(); this.rendersByUnitId.delete(unitId); this.seenUnitIds.delete(unitId);