diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 0000000..ee1f165 --- /dev/null +++ b/.github/workflows/bench.yml @@ -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 diff --git a/frontend/benchmarks/adapter.ts b/frontend/benchmarks/adapter.ts new file mode 100644 index 0000000..8d9c786 --- /dev/null +++ b/frontend/benchmarks/adapter.ts @@ -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, + ): Promise; + + /** Prepare sprite layer */ + updateSprites( + terrain: RawTerrain, + sprites: Sprite[], + images: Map, + ): Promise; + + /** 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, + ): Promise { + this.unitBitmap = await getPlacedUnitImage(terrain, units, images); + } + + async updateSprites( + terrain: RawTerrain, + sprites: Sprite[], + images: Map, + ): Promise { + 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(); + } +} diff --git a/frontend/benchmarks/fixtures/location.ts b/frontend/benchmarks/fixtures/location.ts new file mode 100644 index 0000000..4066ef9 --- /dev/null +++ b/frontend/benchmarks/fixtures/location.ts @@ -0,0 +1,3 @@ +import { createLocations } from "./util"; + +export const location = createLocations(64); diff --git a/frontend/benchmarks/fixtures/terrain.ts b/frontend/benchmarks/fixtures/terrain.ts new file mode 100644 index 0000000..efd52c6 --- /dev/null +++ b/frontend/benchmarks/fixtures/terrain.ts @@ -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)]; diff --git a/frontend/benchmarks/fixtures/util.ts b/frontend/benchmarks/fixtures/util.ts new file mode 100644 index 0000000..f0d572d --- /dev/null +++ b/frontend/benchmarks/fixtures/util.ts @@ -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" }; +} diff --git a/frontend/benchmarks/viewport.bench.ts b/frontend/benchmarks/viewport.bench.ts new file mode 100644 index 0000000..0a83d1b --- /dev/null +++ b/frontend/benchmarks/viewport.bench.ts @@ -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 }); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 22def6f..6a3926f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/vitest.bench.config.ts b/frontend/vitest.bench.config.ts new file mode 100644 index 0000000..1791123 --- /dev/null +++ b/frontend/vitest.bench.config.ts @@ -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" }], + }, + }, +});