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.
This commit is contained in:
scamiv
2026-02-05 21:46:49 +01:00
parent 848697e258
commit 9ef4f18296
+204 -53
View File
@@ -1,4 +1,6 @@
import { Theme } from "../configuration/Config"; import { Theme } from "../configuration/Config";
import { PastelTheme } from "../configuration/PastelTheme";
import { PastelThemeDark } from "../configuration/PastelThemeDark";
import { TileRef } from "../game/GameMap"; import { TileRef } from "../game/GameMap";
import { TerrainMapData } from "../game/TerrainMapLoader"; import { TerrainMapData } from "../game/TerrainMapLoader";
import { GameRunner } from "../GameRunner"; import { GameRunner } from "../GameRunner";
@@ -14,12 +16,15 @@ export class WorkerCanvas2DRenderer {
private rasterCanvas: OffscreenCanvas | null = null; private rasterCanvas: OffscreenCanvas | null = null;
private rasterCtx: Offscreen2D | null = null; private rasterCtx: Offscreen2D | null = null;
private rasterImage: ImageData | null = null; private rasterImage: ImageData | null = null;
private terrainBaseRgba: Uint8Array | null = null;
private gameViewAdapter: GameViewAdapter | null = null; private gameViewAdapter: GameViewAdapter | null = null;
private gameRunner: GameRunner | null = null; private gameRunner: GameRunner | null = null;
private theme: Theme | null = null; private theme: Theme | null = null;
private ready = false; private ready = false;
private mapWidth = 1;
private mapHeight = 1;
private viewScale = 1; private viewScale = 1;
private viewOffsetX = 0; private viewOffsetX = 0;
@@ -58,6 +63,11 @@ export class WorkerCanvas2DRenderer {
this.gameRunner = gameRunner; this.gameRunner = gameRunner;
this.theme = theme; this.theme = theme;
const mapW = gameRunner.game.width();
const mapH = gameRunner.game.height();
this.mapWidth = mapW;
this.mapHeight = mapH;
this.gameViewAdapter = new GameViewAdapter( this.gameViewAdapter = new GameViewAdapter(
gameRunner.game, gameRunner.game,
mapData, mapData,
@@ -66,8 +76,6 @@ export class WorkerCanvas2DRenderer {
cosmeticsByClientID, cosmeticsByClientID,
); );
const mapW = gameRunner.game.width();
const mapH = gameRunner.game.height();
this.rasterCanvas = new OffscreenCanvas(mapW, mapH); this.rasterCanvas = new OffscreenCanvas(mapW, mapH);
this.rasterCtx = this.rasterCanvas.getContext("2d", { this.rasterCtx = this.rasterCanvas.getContext("2d", {
alpha: true, alpha: true,
@@ -94,6 +102,7 @@ export class WorkerCanvas2DRenderer {
// First paint. // First paint.
this.rebuildPaletteFromGame(); this.rebuildPaletteFromGame();
this.rebuildTerrainBase();
this.markAllDirty(); this.markAllDirty();
this.tick(); this.tick();
} }
@@ -105,9 +114,12 @@ export class WorkerCanvas2DRenderer {
this.rasterCanvas = null; this.rasterCanvas = null;
this.rasterCtx = null; this.rasterCtx = null;
this.rasterImage = null; this.rasterImage = null;
this.terrainBaseRgba = null;
this.gameViewAdapter = null; this.gameViewAdapter = null;
this.gameRunner = null; this.gameRunner = null;
this.theme = null; this.theme = null;
this.mapWidth = 1;
this.mapHeight = 1;
this.dirtyChunkFlags = new Uint8Array(0); this.dirtyChunkFlags = new Uint8Array(0);
this.dirtyChunkQueue = new Uint32Array(0); this.dirtyChunkQueue = new Uint32Array(0);
this.dirtyHead = 0; this.dirtyHead = 0;
@@ -117,8 +129,13 @@ export class WorkerCanvas2DRenderer {
setViewSize(width: number, height: number): void { setViewSize(width: number, height: number): void {
if (!this.canvas) return; if (!this.canvas) return;
this.canvas.width = Math.max(1, Math.floor(width)); const nextWidth = Math.max(1, Math.floor(width));
this.canvas.height = Math.max(1, Math.floor(height)); 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 { setViewTransform(scale: number, offsetX: number, offsetY: number): void {
@@ -160,13 +177,15 @@ export class WorkerCanvas2DRenderer {
} }
refreshTerrain(): void { refreshTerrain(): void {
this.rebuildTerrainBase();
this.markAllDirty(); this.markAllDirty();
} }
markTile(tile: TileRef): void { markTile(tile: TileRef): void {
if (!this.ready || !this.gameRunner) return; if (!this.ready) return;
const x = this.gameRunner.game.x(tile); // TileRef is a linear index (y * width + x).
const y = this.gameRunner.game.y(tile); const x = tile % this.mapWidth;
const y = (tile / this.mapWidth) | 0;
this.markChunkAt(x, y); this.markChunkAt(x, y);
} }
@@ -188,14 +207,29 @@ export class WorkerCanvas2DRenderer {
!this.theme || !this.theme ||
!this.gameViewAdapter || !this.gameViewAdapter ||
!this.rasterCtx || !this.rasterCtx ||
!this.rasterImage !this.rasterImage ||
!this.terrainBaseRgba
) { ) {
return; return;
} }
const mapW = this.gameRunner.game.width(); const mapW = this.mapWidth;
const mapH = this.gameRunner.game.height(); const mapH = this.mapHeight;
const data = this.rasterImage.data; 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 budgetMs = 6;
const start = performance.now(); const start = performance.now();
@@ -219,52 +253,59 @@ export class WorkerCanvas2DRenderer {
for (let y = sy; y < ey; y++) { for (let y = sy; y < ey; y++) {
const row = y * mapW; const row = y * mapW;
for (let x = sx; x < ex; x++) { 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, const p = tile * 4;
g = 0, const tr = base[p];
b = 0, const tg = base[p + 1];
a = 255; const tb = base[p + 2];
if (this.gameRunner.game.hasFallout(tile)) { // Fast path: terrain only.
const idx = 0; if (owner === 0 && !hasFallout) {
r = this.paletteRow0[idx] ?? 120; out[p] = tr;
g = this.paletteRow0[idx + 1] ?? 255; out[p + 1] = tg;
b = this.paletteRow0[idx + 2] ?? 71; out[p + 2] = tb;
} else if (this.gameRunner.game.hasOwner(tile)) { out[p + 3] = 255;
const ownerSmallId = this.gameRunner.game.ownerID(tile); continue;
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;
} }
const p = (row + x) * 4; let r = tr;
data[p] = r; let g = tg;
data[p + 1] = g; let b = tb;
data[p + 2] = b;
data[p + 3] = a; 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.paletteRow1 = row1;
this.hasExternalPalette = false; 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;
}
} }