From 394b3642a0a708aee1a8c232e99d69aadfed8430 Mon Sep 17 00:00:00 2001 From: Ashelyn Rose Date: Fri, 25 Aug 2023 14:36:56 -0600 Subject: [PATCH] Initial copy from life 1 post Just committing so I can check diffs and describe it later --- posts/2023-08-25_wasm-game-of-life-2.md | 30 +++ scripts/wasm-life-2/controller.js | 146 ++++++++++ scripts/wasm-life-2/game.wat | 337 ++++++++++++++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 posts/2023-08-25_wasm-game-of-life-2.md create mode 100644 scripts/wasm-life-2/controller.js create mode 100644 scripts/wasm-life-2/game.wat diff --git a/posts/2023-08-25_wasm-game-of-life-2.md b/posts/2023-08-25_wasm-game-of-life-2.md new file mode 100644 index 0000000..7ef9f8b --- /dev/null +++ b/posts/2023-08-25_wasm-game-of-life-2.md @@ -0,0 +1,30 @@ +--- +title: "Pure Wasm Life 2: Optimizing Webassembly and Canvas" +subtitle: You know I couldn't leave it alone +resources: +- wasm-life-2/controller.js +- wasm-life-2/game.wat +--- + +This post is part 2 of my Webassembly Game of Life series, read part 1 +[here](/wasm-game-of-life-1). + + + +

+ + +

+ + + diff --git a/scripts/wasm-life-2/controller.js b/scripts/wasm-life-2/controller.js new file mode 100644 index 0000000..7c310c2 --- /dev/null +++ b/scripts/wasm-life-2/controller.js @@ -0,0 +1,146 @@ +let gameState = { + running: false, + pixelSize: 0, + lastReported: null, + frameTimes: [], + frames: 0, + width: 0, + height: 0, + canvas: null, + ctx: null, + gameExports: null +} + +const initialMessage = "Click the board above to start simulation" + +export async function setup(params, wasmModule) { + const { canvas: canvasSelector } = params; + gameState.gameExports = wasmModule.exports + + const canvas = gameState.canvas = document.querySelector(canvasSelector) + gameState.ctx = gameState.canvas.getContext("2d") + gameState.ctx.fillStyle = getComputedStyle(gameState.canvas).getPropertyValue('--foreground') + + const pixelSize = gameState.pixelSize = parseInt(canvas.getAttribute('data-pixelsize') || '2') + gameState.width = Math.floor(parseInt(canvas.width) / pixelSize) + gameState.height = Math.floor(parseInt(canvas.height) / pixelSize) + + initialize() + drawBoard() + + const frameTimesElem = document.getElementById('frameTimes') + const resetButton = document.getElementById('reset') + + frameTimesElem.innerText = initialMessage + + gameState.canvas.addEventListener('click', () => { + if (!gameState.running) { + if (frameTimesElem.innerText === initialMessage) + frameTimesElem.innerText = 'Starting...' + + gameState.running = true + frameLoop() + } else { + gameState.running = false; + gameState.frameTimes = [] + gameState.lastReported = null + } + }) + + resetButton.addEventListener('click', () => { + gameState.running = false + gameState.frameTimes = [] + gameState.lastReported = null + frameTimesElem.innerText = initialMessage + initialize() + drawBoard() + }) +} + +export async function onThemeChange() { + drawBoard() + gameState.ctx.fillStyle = getComputedStyle(gameState.canvas).getPropertyValue('--foreground') +} + +export async function cleanup() { + gameState.running = false +} + +function initialize() { + const { gameExports, width, height } = gameState + + gameExports.initializeBoard(width, height) + + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const filled = Math.random() > .5; + gameExports.setValueAtPosition(row, column, filled ? 1 : 0) + } + } +} + +function frameLoop() { + const { gameExports, running, frameTimes } = gameState + + if (!gameState.lastReported) + gameState.lastReported = performance.now() + + if (!running) return + + const beforeTick = performance.now() + gameExports.tick() + const afterTick = performance.now() + drawBoard() + const afterDraw = performance.now() + + // Push raw frame time + frameTimes.push(afterDraw - beforeTick) + + if (frameTimes.length >= 10) { + const averageFrame = (frameTimes.reduce((a, b) => a + b) / frameTimes.length).toFixed(2); + + const lastReported = gameState.lastReported + const current = gameState.lastReported = performance.now() + const tenFrameTime = (current - lastReported) / 1000 + const fps = Math.floor(10 / tenFrameTime).toFixed(0) + gameState.frameTimes = [] + + const pagesUsed = gameExports.getPagesUsed() + + document.getElementById('frameTimes').innerHTML = + `Frames per second: ${fps}
+ Milliseconds per frame: ${averageFrame}
+ Memory allocated: ${pagesUsed} pages (${pagesUsed * 64} KiB)` + } + + const tickMS = (afterTick - beforeTick).toFixed(3).padStart(7, ' ') + const drawMS = (afterDraw - afterTick).toFixed(3).padStart(7, ' ') + + console.log(`tick took ${tickMS}ms, draw took ${drawMS}ms`) + + requestAnimationFrame(frameLoop) +} + +function drawBoard() { + const { gameExports, width, height, pixelSize, ctx, canvas } = gameState + + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.beginPath() + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const alive = gameExports.getValueAtPosition(row, column) + + if (!alive) continue + + const x = column * pixelSize + const y = row * pixelSize + + ctx.moveTo(x, y) + ctx.lineTo(x + pixelSize, y) + ctx.lineTo(x + pixelSize, y + pixelSize) + ctx.lineTo(x, y + pixelSize) + ctx.lineTo(x, y) + } + } + ctx.fill() +} diff --git a/scripts/wasm-life-2/game.wat b/scripts/wasm-life-2/game.wat new file mode 100644 index 0000000..1c895a8 --- /dev/null +++ b/scripts/wasm-life-2/game.wat @@ -0,0 +1,337 @@ +(module + (memory (export "shared_memory") 1) + + (global $boardWidth (mut i32) (i32.const 0)) + (global $boardHeight (mut i32) (i32.const 0)) + (global $boardBufferLength (mut i32) (i32.const 0)) + (global $buffer0ptr (mut i32) (i32.const -1)) + (global $buffer1ptr (mut i32) (i32.const -1)) + (global $currentBuffer (mut i32) (i32.const -1)) + + (func (export "initializeBoard") (param $width i32) (param $height i32) + ;; Store width and height for later + (global.set $boardWidth (local.get $width)) + (global.set $boardHeight (local.get $height)) + + ;; Compute total cells per board + local.get $width + local.get $height + i32.mul + global.set $boardBufferLength + + ;; Request enough memory for both boards + global.get $boardBufferLength + i32.const 2 + i32.mul + call $growMemoryForBoards + + ;; Set pointer locations for our two boards + (global.set $buffer0ptr (i32.const 0)) + (global.set $buffer1ptr (global.get $boardBufferLength)) + + ;; Set current board + (global.set $currentBuffer (i32.const 0)) + ) + + (func (export "getPagesUsed") (result i32) + memory.size + ) + + (func $growMemoryForBoards (param $totalBytes i32) + (local $targetPages i32) + + ;; figure out target page size + local.get $totalBytes + i32.const 1 + i32.sub + i32.const 65536 ;; size of a page + i32.div_u + i32.const 1 + i32.add + + ;; get difference + memory.size + i32.sub + + ;; grow + memory.grow + drop ;; ignore result, we're gonna crash anyways + ;; perhaps we should have a way to report errors back to JS next time + ) + + (func $getBoardPtr (result i32) + global.get $currentBuffer + i32.eqz + if (result i32) + global.get $buffer0ptr + else + global.get $buffer1ptr + end + ) + + (func $swapBoards + global.get $currentBuffer + i32.eqz + if (result i32) + i32.const 1 + else + i32.const 0 + end + global.set $currentBuffer + ) + + (func $positionInRange (param $position i32) (param $min i32) (param $max i32) (result i32) + local.get $position + local.get $min + i32.lt_s + + if + i32.const 0 + return + end + + local.get $position + local.get $max + i32.ge_s + + if + i32.const 0 + return + end + + i32.const 1 + return + ) + + (func $getIndexForPosition (param $row i32) (param $column i32) (result i32) + local.get $row + i32.const 0 + global.get $boardHeight + call $positionInRange + + local.get $column + i32.const 0 + global.get $boardWidth + call $positionInRange + + i32.and + i32.eqz + + if + i32.const -1 + return + end + + global.get $boardWidth + local.get $row + i32.mul + local.get $column + i32.add + ) + + (func $getValueAtPosition (export "getValueAtPosition") (param $row i32) (param $column i32) (result i32) + (local $position i32) + local.get $row + local.get $column + call $getIndexForPosition + + local.tee $position + i32.const 0 + i32.lt_s + + if + i32.const 0 + return + end + + local.get $position + + call $getBoardPtr + i32.add + i32.load8_u + ) + + (func $setValueAtPosition (export "setValueAtPosition") (param $row i32) (param $column i32) (param $value i32) + (local $position i32) + local.get $row + local.get $column + call $getIndexForPosition + + local.tee $position + i32.const 0 + i32.lt_s + + if + return + end + + local.get $position + + call $getBoardPtr + i32.add + local.get $value + i32.store8 + ) + + (func $getNewValueAtPosition (param $row i32) (param $column i32) (result i32) + (local $count i32) + + local.get $row + i32.const 1 + i32.sub + local.get $column + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + call $getValueAtPosition + + local.get $row + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.sub + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.sub + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + i32.add + i32.add + i32.add + i32.add + i32.add + i32.add + i32.add + + ;; Exactly 3 neighbors + local.tee $count + i32.const 3 + i32.eq + + if + ;; becomes or stays alive + i32.const 1 + return + end + + ;; If currently dead + local.get $row + local.get $column + call $getValueAtPosition + i32.eqz + if + ;; Stay dead + i32.const 0 + return + end + + ;; 2 neighbors + local.get $count + i32.const 2 + i32.eq + if + i32.const 1 + return + end + + i32.const 0 + return + ) + + (func $tick (export "tick") + (local $row i32) + (local $column i32) + (local $value i32) + + i32.const 0 + local.set $row + + loop $rows + + ;; start at the beginning of a row + i32.const 0 + local.set $column + + ;; for every column in the row + loop $columns + + ;; compute new value + local.get $row + local.get $column + call $getNewValueAtPosition + local.set $value + + ;; place in next board + call $swapBoards + local.get $row + local.get $column + local.get $value + call $setValueAtPosition + call $swapBoards + + ;; increment column + local.get $column + i32.const 1 + i32.add + local.tee $column + + ;; loop back if less than width + global.get $boardWidth + i32.lt_s + br_if $columns + end + + ;;increment row + local.get $row + i32.const 1 + i32.add + local.tee $row + + ;; loop back if less than height + global.get $boardHeight + i32.lt_s + br_if $rows + end + + ;; swap to the new board + call $swapBoards + ) +) +