Initial copy from life 1 post
Just committing so I can check diffs and describe it laterpost/wasm-gol-2
parent
12d2b39b0a
commit
394b3642a0
@ -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).
|
||||
|
||||
<noscript>[If you enable Javascript, you'll see a game board here]</noscript>
|
||||
<canvas
|
||||
id="game"
|
||||
width="800"
|
||||
height="600"
|
||||
data-pixelsize="1"
|
||||
style="width:100%; aspect-ratio: 4/3; image-rendering: pixelated;"
|
||||
/>
|
||||
<p style="display: flex; align-items: flex-start; margin-top: 4px; height: 64px">
|
||||
<span style="flex: 1" id="frameTimes"/>
|
||||
<button id="reset">Reset</button>
|
||||
</p>
|
||||
<ScriptLoader
|
||||
src="/wasm-life-2/controller.js"
|
||||
wasm="/wasm-life-2/game.wat"
|
||||
canvas="#game"
|
||||
/>
|
||||
|
||||
|
@ -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}<br/>
|
||||
Milliseconds per frame: ${averageFrame}<br/>
|
||||
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()
|
||||
}
|
@ -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
|
||||
)
|
||||
)
|
||||
|
Loading…
Reference in New Issue