Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/bench.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: benchmark

on:
push:
branches: [master]
paths:
- "frontend/benchmarks/**"
- "frontend/lib/scterrain.ts"
- "frontend/lib/scimage.ts"
- "frontend/vitest.bench.config.ts"
pull_request:
branches: [master]
paths:
- "frontend/benchmarks/**"
- "frontend/lib/scterrain.ts"
- "frontend/lib/scimage.ts"
- "frontend/vitest.bench.config.ts"
workflow_dispatch:

jobs:
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: latest

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml

- name: Install dependencies
run: pnpm install
working-directory: frontend

- name: Install Playwright browsers
run: pnpm exec playwright install chromium
working-directory: frontend

- name: Run benchmarks
run: pnpm bench --outputJson=bench-result.json
working-directory: frontend

- name: Upload benchmark results
uses: actions/upload-artifact@v4
with:
name: bench-result
path: frontend/bench-result.json
retention-days: 30
141 changes: 141 additions & 0 deletions frontend/benchmarks/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { RawTerrain } from "@/types/schemas/terrain/RawTerrain";
import { Tile } from "@/types/schemas/entities/Tile";
import { Unit } from "@/types/schemas/entities/Unit";
import { Sprite } from "@/types/schemas/entities/Sprite";
import { Location } from "@/types/schemas/entities/Location";
import { SCImageBundle } from "@/types/SCImage";
import { Viewport } from "@/types/viewport";

export interface RendererBenchmark {
/** Prepare terrain layer from tile data */
updateTerrain(
terrain: RawTerrain,
tiles: Tile[],
tileGroup: number[][],
tilesetData: Uint8Array,
): void;

/** Prepare unit layer */
updateUnits(
terrain: RawTerrain,
units: Unit[],
images: Map<number, SCImageBundle>,
): Promise<void>;

/** Prepare sprite layer */
updateSprites(
terrain: RawTerrain,
sprites: Sprite[],
images: Map<number, SCImageBundle>,
): Promise<void>;

/** Prepare location overlay layer */
updateLocations(terrain: RawTerrain, locations: Location[]): void;

/** Compose all layers into a single image */
compose(terrain: RawTerrain): void;

/** Paint the viewport region to screen */
paint(viewport: Viewport): void;

/** Dispose resources */
dispose(): void;
}

// -- Canvas2D implementation --------------------------------------------------

import { getTerrainImage } from "@/lib/scterrain";
import { TILE_SIZE } from "@/lib/scterrain";
import {
getPlacedUnitImage,
getPlacedSpriteImages,
getLocationImage,
} from "@/lib/scimage";

export class Canvas2DBenchmark implements RendererBenchmark {
private terrainBitmap?: ImageBitmap;
private unitBitmap?: ImageBitmap;
private spriteBitmap?: ImageBitmap;
private locationBitmap?: ImageBitmap;
private composedBitmap?: ImageBitmap;

private canvas: OffscreenCanvas;
private ctx: OffscreenCanvasRenderingContext2D;

constructor(width = 4096, height = 4096) {
this.canvas = new OffscreenCanvas(width, height);
this.ctx = this.canvas.getContext("2d")!;
}

updateTerrain(
terrain: RawTerrain,
tiles: Tile[],
tileGroup: number[][],
tilesetData: Uint8Array,
): void {
this.terrainBitmap = getTerrainImage(
terrain,
tiles,
tileGroup,
tilesetData,
);
}

async updateUnits(
terrain: RawTerrain,
units: Unit[],
images: Map<number, SCImageBundle>,
): Promise<void> {
this.unitBitmap = await getPlacedUnitImage(terrain, units, images);
}

async updateSprites(
terrain: RawTerrain,
sprites: Sprite[],
images: Map<number, SCImageBundle>,
): Promise<void> {
this.spriteBitmap = await getPlacedSpriteImages(terrain, sprites, images);
}

updateLocations(terrain: RawTerrain, locations: Location[]): void {
this.locationBitmap = getLocationImage(terrain, locations);
}

compose(terrain: RawTerrain): void {
const w = terrain.size.width * TILE_SIZE;
const h = terrain.size.height * TILE_SIZE;

this.canvas = new OffscreenCanvas(w, h);
this.ctx = this.canvas.getContext("2d")!;
this.ctx.clearRect(0, 0, w, h);

if (this.terrainBitmap) this.ctx.drawImage(this.terrainBitmap, 0, 0);
if (this.unitBitmap) this.ctx.drawImage(this.unitBitmap, 0, 0);
if (this.spriteBitmap) this.ctx.drawImage(this.spriteBitmap, 0, 0);
if (this.locationBitmap) this.ctx.drawImage(this.locationBitmap, 0, 0);

this.composedBitmap = this.canvas.transferToImageBitmap();
}

paint(viewport: Viewport): void {
if (!this.composedBitmap) return;

const w = viewport.tileWidth * TILE_SIZE;
const h = viewport.tileHeight * TILE_SIZE;
const sx = viewport.startX * TILE_SIZE;
const sy = viewport.startY * TILE_SIZE;

const viewCanvas = new OffscreenCanvas(w, h);
const viewCtx = viewCanvas.getContext("2d")!;
viewCtx.clearRect(0, 0, w, h);
viewCtx.drawImage(this.composedBitmap, sx, sy, w, h, 0, 0, w, h);
}

dispose(): void {
this.terrainBitmap?.close();
this.unitBitmap?.close();
this.spriteBitmap?.close();
this.locationBitmap?.close();
this.composedBitmap?.close();
}
}
3 changes: 3 additions & 0 deletions frontend/benchmarks/fixtures/location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createLocations } from "./util";

export const location = createLocations(64);
19 changes: 19 additions & 0 deletions frontend/benchmarks/fixtures/terrain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createTerrain, createTiles, createTilesetData } from "./util";

export const terrain = {
verySmall: createTerrain(64, 64),
small: createTerrain(128, 128),
medium: createTerrain(192, 192),
large: createTerrain(256, 256),
};

export const tiles = {
verySmall: createTiles(64, 64),
small: createTiles(128, 128),
medium: createTiles(192, 192),
large: createTiles(256, 256),
};

export const tilesetData = createTilesetData(16);

export const tileGroup = [Array.from({ length: 16 }, (_, i) => i)];
54 changes: 54 additions & 0 deletions frontend/benchmarks/fixtures/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Location } from "@/types/schemas/entities/Location";
import { Tile } from "@/types/schemas/entities/Tile";
import { RawTerrain } from "@/types/schemas/terrain/RawTerrain";

export function createLocations(count: number): Location[] {
return Array.from({ length: count }, (_, i) => ({
id: i,
name: `Location ${i}`,
transform: {
id: i,
name: `Location ${i}`,
position: { x: (i % 10) * 320, y: Math.floor(i / 10) * 320 },
size: { left: 0, top: 0, right: 160, bottom: 160 },
},
kind: "Location" as const,
elevation_flags: 0,
}));
}

export function createTiles(width: number, height: number): Tile[] {
const tiles: Tile[] = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
tiles.push({
id: i,
name: `Tile ${i}`,
transform: {
id: i,
name: `Tile ${i}`,
position: { x, y },
size: { left: 16, top: 16, right: 16, bottom: 16 },
},
kind: "Tile" as const,
group: 0,
tile_id: i % 16,
});
}
}
return tiles;
}

// Each megatile = 3072 bytes (32*32*3 RGB)
export function createTilesetData(megatileCount: number): Uint8Array {
const data = new Uint8Array(megatileCount * 3072);
for (let i = 0; i < data.length; i++) {
data[i] = (i * 7 + 13) & 0xff;
}
return data;
}

export function createTerrain(width: number, height: number): RawTerrain {
return { size: { width, height }, tileset: "Badlands" };
}
51 changes: 51 additions & 0 deletions frontend/benchmarks/viewport.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { bench, describe } from "vitest";
import { Canvas2DBenchmark } from "./adapter";
import { terrain, tileGroup, tilesetData, tiles } from "./fixtures/terrain";
import type { Viewport } from "@/types/viewport";
import { location } from "./fixtures/location";

// -- Test fixtures ------------------------------------------------------------

// -- Benchmarks ---------------------------------------------------------------

const viewport: Viewport = {
startX: 0,
startY: 0,
tileWidth: 40,
tileHeight: 30,
};

describe("Canvas2D Renderer", () => {
const renderer = new Canvas2DBenchmark();

bench("updateTerrain 64x64", () => {
renderer.updateTerrain(terrain.small, tiles.small, tileGroup, tilesetData);
});

bench("updateLocations 50", () => {
renderer.updateLocations(terrain.small, location);
});

bench("compose 64x64", () => {
renderer.updateTerrain(terrain.verySmall, tiles.verySmall, tileGroup, tilesetData);
renderer.updateLocations(terrain.verySmall, location);
renderer.compose(terrain.verySmall);
});

bench("compose 256x256", () => {
renderer.updateTerrain(terrain.large, tiles.large, tileGroup, tilesetData);
renderer.compose(terrain.large);
});

bench("paint viewport 40x30", () => {
renderer.updateTerrain(terrain.small, tiles.small, tileGroup, tilesetData);
renderer.compose(terrain.small);
renderer.paint(viewport);
});

bench("paint viewport offset", () => {
renderer.updateTerrain(terrain.small, tiles.small, tileGroup, tilesetData);
renderer.compose(terrain.small);
renderer.paint({ startX: 20, startY: 20, tileWidth: 40, tileHeight: 30 });
});
});
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"start": "next start",
"lint": "next lint",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"bench": "vitest bench --config vitest.bench.config.ts --run"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
Expand Down
15 changes: 15 additions & 0 deletions frontend/vitest.bench.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
plugins: [tsconfigPaths()],
test: {
include: ["benchmarks/**/*.bench.ts"],
browser: {
enabled: true,
headless: true,
provider: "playwright",
instances: [{ browser: "chromium" }],
},
},
});
Loading