Sand Simulation in WASM
I like those sand simulation things, in games like Noita, and I figured I could give it a go
The Demo
Try it out in the demo before we go any further. Hopefully your browser supports WASM.
The Algorithm
We start with a grid of numbers:
- 0 = empty
- 1 = sand
- 2 = solid
Each frame we loop over the grid and update it following these simple rules. This diagram shows how we reference neighbouring positions.
The first case is easy, if the tile is empty and there's sand above, drop the sand.
We process from the bottom-up, otherwise we'd be chasing the sand all the way to the bottom in one go.
The second case is diagonals. is we go right-to-left on each row, then we're looking for diagonal moves from the left where the space below it is solid (otherwise it would go right down).
On each frame we alternate left-to-right and right-to-left. This has the added benefit that sand dropping straight down moves twice as fast.
Trying it out on Codepen
I like to "sketch" things out on Codepen to see if it will work. Here's an ASCII version
Check it out in Codepen (you can mess around with the algorithm here too)
Trying it out in wasm.
All of this was an exuse to try WASM. All of this looping and updating might be more efficient in c++. The idea is that it should be faster. at the end of each frame we can just write the whole image buffer to the canvas.
Here's the algorithm in c++. getVal
and setVal
set both an array of empty/sand/solid, and the iamge buffer, plust hey perform some bounds checks.
dir = dir == 1 ? -1 : 1; // flip each frame
for (int y = HEIGHT - 1; y >= 0; y--)
{
for (int x2 = 0; x2 < WIDTH; x2++)
{
int x = dir > 0 ? x2 : WIDTH - 1 - x2;
unsigned char a = getVal(x - 1 * dir, y - 1);
unsigned char b = getVal(x, y - 1);
unsigned char c = getVal(x - 1 * dir, y);
unsigned char d = getVal(x, y);
if (d == 0)
{
if (b == 1)
{
// down
setVal(x, y - 1, 0);
setVal(x, y, 1);
}
if (a == 1 && b == 0 && c != 0)
{
// down-right
setVal(x - 1 * dir, y - 1, 0);
setVal(x, y, 1);
}
}
}
}
This funky code here writes our unsigned char imageData[BUFFER_SIZE]
to the canvas. It might be less efficient but its good enough for now.
EM_ASM(
{
var canvas = document.querySelector('canvas');
var ctx = canvas.getContext('2d');
var imageData = ctx.createImageData($1, $2);
imageData.data.set(HEAPU8.subarray($0, $0 + ($1 * $2 * 4)));
ctx.putImageData(imageData, 0, 0);
},
imageData,
WIDTH,
HEIGHT);
You can check out all the code here on GitHub
https://github.com/MadcoreTom/sand-wasm
How I build WASM
I'm using docker so I don't need to install anything globally
docker run --rm -v c:/code/wasm-sand:/src emscripten/emsdk emcc src/index.cpp -o www/index.js -s EXPORTED_FUNCTIONS="['_main','_draw','_reset']" -sSTACK_SIZE=1MB -sENVIRONMENT=web -lm,
-v c:/code/wasm-sand:/src
is the mounted source directoryemcc src/index.cpp -o www/index.js
are the input and outputEXPORTED_FUNCTIONS="['_main','_draw','_reset']"
are the functions i can call from javascript
Calling it from js
My code dynamically adds the WASM code <script>
tag
const wasmElement = document.createElement("script") as HTMLScriptElement;
wasmElement.type = "text/javascript";
wasmElement.async = true;
wasmElement.addEventListener("load", (r) => {
console.log("WASM loaded");
setTimeout(() => this.onWasmLoad(), 100);
});
then on-load it gets references to the "draw" and "reset" methods
private onWasmLoad() {
this.wasmDraw = createExportWrapper('draw');
this.wasmReset = createExportWrapper('reset');
this.requestAnim();
}
Every frame I just call the draw method
private draw(time: number) {
const MAX_FRAME_TIME_MS = 200;
const timeDelta = Math.min(time - this.lastTime, MAX_FRAME_TIME_MS);
this.lastTime = time;
this.wasmDraw(time, timeDelta,
this.mousePos[0], this.mousePos[1], this.mouseDown,
this.bigBrush);
this.requestAnim();
}
Edit 1 - sharing ImageData more efficiently
Querying for the canvas and then creating an ImageData
object every frame is probably not the best, so I dug around for other options. What we can do is share the array between our javascript and C++ code.
On the c++ side, I have a global uint8_t
array
uint8_t *wasmView; // shared pixel array
// ...
extern "C"
{
// EMSCRIPTEN_KEEPALIVE
uint8_t *getArray(int n)
{
wasmView = new uint8_t[n]; // Create
for (int i = 0; i < n; i++) // Initialise
{
wasmView[i] = i % 4 == 2 ? 50 : i;
}
return wasmView;
}
//...
}
On the javascript (typescript) side I have a few snippets
I have a pixel array, and a "view"
private pixelArray: Uint8Array;
private arrayBufferView: Uint8Array;
Get the getArray function on load
const getArray = Module._getArray;
// Link up WASM array with this.myArray
const len = WIDTH * HEIGHT * 4;
const arr = getArray(len);
// This is the magical line:
this.pixelArray = new Uint8Array(Module.HEAP8.buffer, arr, len);
// Create and initialise the image data
this.imageData = this.ctx.createImageData(canvas.width, canvas.height);
It's pretty cool, we can now modify the array from web assembly code or the javascript/typescript code.
And each frame I draw the pixel array to the canvas
if (this.pixelArray) {
this.imageData.data.set(this.pixelArray); // Efficiently copies the contents
this.ctx.putImageData(this.imageData, 0, 0);
}