From 1ff0b4ddee14ad1a94ccd2abe6fa5ec22e7597ee Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:50:58 +0100 Subject: [PATCH] Enhance WorkerCanvas2DRenderer with terrain handling improvements - Added support for terrain base RGBA values to optimize rendering. - Refactored tile marking logic to utilize map dimensions for improved accuracy. - Updated view size handling to prevent unnecessary canvas resizing. - Introduced methods to rebuild terrain base and refresh terrain for better visual fidelity. - Cleaned up initialization and cleanup processes for better resource management. --- src/core/worker/WorkerCanvas2DRenderer.ts | 257 +++++++++++++++++----- 1 file changed, 204 insertions(+), 53 deletions(-) diff --git a/src/core/worker/WorkerCanvas2DRenderer.ts b/src/core/worker/WorkerCanvas2DRenderer.ts index cb1ae5080..2cfbd74eb 100644 --- a/src/core/worker/WorkerCanvas2DRenderer.ts +++ b/src/core/worker/WorkerCanvas2DRenderer.ts @@ -1,4 +1,6 @@ import { Theme } from "../configuration/Config"; +import { PastelTheme } from "../configuration/PastelTheme"; +import { PastelThemeDark } from "../configuration/PastelThemeDark"; import { TileRef } from "../game/GameMap"; import { TerrainMapData } from "../game/TerrainMapLoader"; import { GameRunner } from "../GameRunner"; @@ -14,12 +16,15 @@ export class WorkerCanvas2DRenderer { private rasterCanvas: OffscreenCanvas | null = null; private rasterCtx: Offscreen2D | null = null; private rasterImage: ImageData | null = null; + private terrainBaseRgba: Uint8Array | null = null; private gameViewAdapter: GameViewAdapter | null = null; private gameRunner: GameRunner | null = null; private theme: Theme | null = null; private ready = false; + private mapWidth = 1; + private mapHeight = 1; private viewScale = 1; private viewOffsetX = 0; @@ -58,6 +63,11 @@ export class WorkerCanvas2DRenderer { this.gameRunner = gameRunner; this.theme = theme; + const mapW = gameRunner.game.width(); + const mapH = gameRunner.game.height(); + this.mapWidth = mapW; + this.mapHeight = mapH; + this.gameViewAdapter = new GameViewAdapter( gameRunner.game, mapData, @@ -66,8 +76,6 @@ export class WorkerCanvas2DRenderer { cosmeticsByClientID, ); - const mapW = gameRunner.game.width(); - const mapH = gameRunner.game.height(); this.rasterCanvas = new OffscreenCanvas(mapW, mapH); this.rasterCtx = this.rasterCanvas.getContext("2d", { alpha: true, @@ -94,6 +102,7 @@ export class WorkerCanvas2DRenderer { // First paint. this.rebuildPaletteFromGame(); + this.rebuildTerrainBase(); this.markAllDirty(); this.tick(); } @@ -105,9 +114,12 @@ export class WorkerCanvas2DRenderer { this.rasterCanvas = null; this.rasterCtx = null; this.rasterImage = null; + this.terrainBaseRgba = null; this.gameViewAdapter = null; this.gameRunner = null; this.theme = null; + this.mapWidth = 1; + this.mapHeight = 1; this.dirtyChunkFlags = new Uint8Array(0); this.dirtyChunkQueue = new Uint32Array(0); this.dirtyHead = 0; @@ -117,8 +129,13 @@ export class WorkerCanvas2DRenderer { setViewSize(width: number, height: number): void { if (!this.canvas) return; - this.canvas.width = Math.max(1, Math.floor(width)); - this.canvas.height = Math.max(1, Math.floor(height)); + const nextWidth = Math.max(1, Math.floor(width)); + const nextHeight = Math.max(1, Math.floor(height)); + if (this.canvas.width === nextWidth && this.canvas.height === nextHeight) { + return; + } + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; } setViewTransform(scale: number, offsetX: number, offsetY: number): void { @@ -160,13 +177,15 @@ export class WorkerCanvas2DRenderer { } refreshTerrain(): void { + this.rebuildTerrainBase(); this.markAllDirty(); } markTile(tile: TileRef): void { - if (!this.ready || !this.gameRunner) return; - const x = this.gameRunner.game.x(tile); - const y = this.gameRunner.game.y(tile); + if (!this.ready) return; + // TileRef is a linear index (y * width + x). + const x = tile % this.mapWidth; + const y = (tile / this.mapWidth) | 0; this.markChunkAt(x, y); } @@ -188,14 +207,29 @@ export class WorkerCanvas2DRenderer { !this.theme || !this.gameViewAdapter || !this.rasterCtx || - !this.rasterImage + !this.rasterImage || + !this.terrainBaseRgba ) { return; } - const mapW = this.gameRunner.game.width(); - const mapH = this.gameRunner.game.height(); - const data = this.rasterImage.data; + const mapW = this.mapWidth; + const mapH = this.mapHeight; + const out = this.rasterImage.data; + const base = this.terrainBaseRgba; + const state = this.gameRunner.game.tileStateView(); + const row0 = this.paletteRow0; + const maxSmallId = this.paletteMaxSmallId; + + const falloutR = row0[0] ?? 120; + const falloutG = row0[1] ?? 255; + const falloutB = row0[2] ?? 71; + const ownerMask = 0xfff; + const falloutBit = 0x2000; + + const mix65 = (a: number, b: number): number => + ((a * 35 + b * 65 + 50) / 100) | 0; + const mix50 = (a: number, b: number): number => (a + b + 1) >> 1; const budgetMs = 6; const start = performance.now(); @@ -219,52 +253,59 @@ export class WorkerCanvas2DRenderer { for (let y = sy; y < ey; y++) { const row = y * mapW; for (let x = sx; x < ex; x++) { - const tile = this.gameRunner.game.ref(x, y); + const tile = row + x; + const s = state[tile]; + const owner = s & ownerMask; + const hasFallout = (s & falloutBit) !== 0; - let r = 0, - g = 0, - b = 0, - a = 255; + const p = tile * 4; + const tr = base[p]; + const tg = base[p + 1]; + const tb = base[p + 2]; - if (this.gameRunner.game.hasFallout(tile)) { - const idx = 0; - r = this.paletteRow0[idx] ?? 120; - g = this.paletteRow0[idx + 1] ?? 255; - b = this.paletteRow0[idx + 2] ?? 71; - } else if (this.gameRunner.game.hasOwner(tile)) { - const ownerSmallId = this.gameRunner.game.ownerID(tile); - const slot = 10 + Math.max(0, ownerSmallId); - const idx = slot * 4; - if (idx + 2 < this.paletteRow0.length) { - r = this.paletteRow0[idx]; - g = this.paletteRow0[idx + 1]; - b = this.paletteRow0[idx + 2]; - } else { - const rgba = this.theme.terrainColor( - this.gameRunner.game, - tile, - ).rgba; - r = rgba.r; - g = rgba.g; - b = rgba.b; - a = rgba.a ?? 255; - } - } else { - const rgba = this.theme.terrainColor( - this.gameRunner.game, - tile, - ).rgba; - r = rgba.r; - g = rgba.g; - b = rgba.b; - a = rgba.a ?? 255; + // Fast path: terrain only. + if (owner === 0 && !hasFallout) { + out[p] = tr; + out[p + 1] = tg; + out[p + 2] = tb; + out[p + 3] = 255; + continue; } - const p = (row + x) * 4; - data[p] = r; - data[p + 1] = g; - data[p + 2] = b; - data[p + 3] = a; + let r = tr; + let g = tg; + let b = tb; + + if (owner !== 0) { + // Player colors start at slot 10. + if (owner <= maxSmallId) { + const idx = (10 + owner) * 4; + if (idx + 2 < row0.length) { + let pr = row0[idx]; + let pg = row0[idx + 1]; + let pb = row0[idx + 2]; + + if (hasFallout) { + pr = mix50(pr, falloutR); + pg = mix50(pg, falloutG); + pb = mix50(pb, falloutB); + } + + r = mix65(tr, pr); + g = mix65(tg, pg); + b = mix65(tb, pb); + } + } + } else if (hasFallout) { + r = mix50(tr, falloutR); + g = mix50(tg, falloutG); + b = mix50(tb, falloutB); + } + + out[p] = r; + out[p + 1] = g; + out[p + 2] = b; + out[p + 3] = 255; } } @@ -378,4 +419,114 @@ export class WorkerCanvas2DRenderer { this.paletteRow1 = row1; this.hasExternalPalette = false; } + + private rebuildTerrainBase(): void { + if (!this.gameRunner || !this.theme || !this.rasterImage) { + return; + } + + const mapW = this.mapWidth; + const mapH = this.mapHeight; + const numTiles = mapW * mapH; + const terrain = this.gameRunner.game.terrainDataView(); + const base = new Uint8Array(numTiles * 4); + + const isDark = this.theme instanceof PastelThemeDark; + const isPastel = + this.theme instanceof PastelTheme || + this.theme instanceof PastelThemeDark; + + if (isPastel) { + // Decode terrain directly from packed terrain bytes (fast, no allocations). + const shoreR = isDark ? 134 : 204; + const shoreG = isDark ? 133 : 203; + const shoreB = isDark ? 88 : 158; + + const shorelineWaterR = isDark ? 50 : 100; + const shorelineWaterG = isDark ? 50 : 143; + const shorelineWaterB = isDark ? 50 : 255; + + const waterBaseR = isDark ? 14 : 70; + const waterBaseG = isDark ? 11 : 132; + const waterBaseB = isDark ? 30 : 180; + + for (let t = 0; t < numTiles; t++) { + const b = terrain[t]; + const isLand = (b & 0x80) !== 0; + const isShoreline = (b & 0x40) !== 0; + const mag = b & 0x1f; + + let r = 0, + g = 0, + bb = 0; + + if (isLand && isShoreline) { + r = shoreR; + g = shoreG; + bb = shoreB; + } else if (!isLand) { + // Water (ocean + lake share the same formula here). + if (isShoreline) { + r = shorelineWaterR; + g = shorelineWaterG; + bb = shorelineWaterB; + } else if (isDark) { + if (mag < 10) { + const adj = 9 - mag; + r = Math.max(waterBaseR + adj, 0); + g = Math.max(waterBaseG + adj, 0); + bb = Math.max(waterBaseB + adj, 0); + } else { + r = waterBaseR; + g = waterBaseG; + bb = waterBaseB; + } + } else { + const m = mag < 10 ? mag : 10; + const adj = 1 - m; + r = Math.max(waterBaseR + adj, 0); + g = Math.max(waterBaseG + adj, 0); + bb = Math.max(waterBaseB + adj, 0); + } + } else { + // Land (non-shore) + if (mag < 10) { + r = isDark ? 140 : 190; + g = (isDark ? 170 : 220) - 2 * mag; + bb = isDark ? 88 : 138; + } else if (mag < 20) { + r = (isDark ? 150 : 200) + 2 * mag; + g = (isDark ? 133 : 183) + 2 * mag; + bb = (isDark ? 88 : 138) + 2 * mag; + } else { + const half = mag >> 1; + r = (isDark ? 180 : 230) + half; + g = (isDark ? 180 : 230) + half; + bb = (isDark ? 180 : 230) + half; + } + } + + const p = t * 4; + base[p] = r; + base[p + 1] = g; + base[p + 2] = bb; + base[p + 3] = 255; + } + } else { + // Fallback for other themes: call the theme once per tile (slow but only on init/theme change). + for (let t = 0; t < numTiles; t++) { + const rgba = this.theme.terrainColor( + this.gameRunner.game, + t as TileRef, + ).rgba; + const p = t * 4; + base[p] = rgba.r; + base[p + 1] = rgba.g; + base[p + 2] = rgba.b; + base[p + 3] = rgba.a ?? 255; + } + } + + this.terrainBaseRgba = base; + } }