I had some time on a flight to Boston the other week, and I decided I wanted to mess around a bit more with Löve2d's pixel shaders. I had the idea of creating some sort of GPU accelerated game of life. I figured the "canvas" feature of Löve would be a good fit. I could create a shader that takes the current iteration as a texture, and the output of the shader would be the pixels that should be "alive" in the next iteration. Then all I would have to do is constantly take this output and feed it back into the shader. It turned out to be simpler than I thought.
Here's the shader.
extern float grid_width;
extern float grid_height;
int cellOccupied(Image cells, vec2 cell_coord) {
vec2 coord = mod(cell_coord, 1.0);
int x = int(coord * grid_width);
int y = int(coord * grid_height);
if(Texel(cells, coord) == vec4(1.0, 1.0, 1.0, 1.0)) {
return 1;
} else {
return 0;
}
}
int neighborCount(Image cells, vec2 c) {
int neighborCount = 0;
//we need to index the texture by coordinates [0, 1]
float x = c.x;
float y = c.y;
float xInc = 1.0 / float(grid_width);
float yInc = 1.0 / float(grid_height);
//checks right 3 cells
neighborCount += cellOccupied(cells, vec2(c.x - xInc, c.y + yInc));
neighborCount += cellOccupied(cells, vec2(c.x - xInc, c.y));
neighborCount += cellOccupied(cells, vec2(c.x - xInc, c.y - yInc));
//checks left 3 cells
neighborCount += cellOccupied(cells, vec2(c.x + xInc, c.y + yInc));
neighborCount += cellOccupied(cells, vec2(c.x + xInc, c.y));
neighborCount += cellOccupied(cells, vec2(c.x + xInc, c.y - yInc));
//checks middle 2 cells
neighborCount += cellOccupied(cells, vec2(c.x, c.y + yInc));
neighborCount += cellOccupied(cells, vec2(c.x, c.y - yInc));
return neighborCount;
}
vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
bool alive = cellOccupied(texture, texture_coords) == 1;
int neighborCount = neighborCount(texture, texture_coords);
bool willBeAlive = false;
// if alive, lives if 2 or 3 surround it
// if dead, becomes alive is exactly 3 neighbors
if ((alive && (neighborCount == 2 || neighborCount == 3)) || (!alive && neighborCount == 3)) {
willBeAlive = true;
}
if (willBeAlive) {
return vec4(1.0, 1.0, 1.0, 1.0);
} else {
return vec4(0.0, 0.0, 0.0, 1.0);
}
}
Pixels (cells) that are alive are colored white by definition, and dead cells are black. I wrote a cellOccupied
method to return a boolean if the pixel was white or black. This is complicated only by the fact that the texture coordinates are normalized on [0, 1]. I also made the grid wrap around the edges of the buffer. For each pixel, the fragment shader uses the neighborCount
method in combination with the pixel's current state to return white or black for the next iteration, in accordance with the rules of the game of life.
The Lua script is very simplistic as well. The love.load()
method creates a canvas to render the next iteration into using the shader. It also creates an image buffer to store the current iteration. The shader itself is also initalized.
function love.load(a)
-- store these for later use (must be updated on window resize)
windowWidth = lg.getWidth()
windowHeight = lg.getHeight()
-- how large the data buffers are (we are storing data in an image)
dataWidth = math.ceil(windowWidth / sample)
dataHeight = math.ceil(windowHeight / sample)
-- create a canvas that an image can be drawn on, and then read from
calculationCanvas = lg.newCanvas(dataWidth, dataHeight)
-- create the shader, send the grid width and height
calculationShader = lg.newShader("calculationShader.glsl")
calculationShader:send("grid_width", dataWidth)
calculationShader:send("grid_height", dataHeight)
-- create an image to store the current iteration
currentIteration = li.newImageData(windowWidth / sample, windowHeight / sample)
currentIterationImage = lg.newImage(currentIteration)
currentIterationImage:setFilter("linear", "nearest")
end
The love.draw()
method simply draws the current iteration image into the window.
function love.draw()
lg.draw(currentIterationImage, 0, 0, 0, sample)
end
Most of the interesting stuff happens inside the iterate()
method. The current iteration is drawn into the offscreen "calculation" canvas, then the image data is taken out of the offscreen canvsas and stored to be drawn on the next call to love.draw()
. I had some issues here with memory issues, Löve didn't seem to garbage collect frequently enough and the script would crash soon after launching after consuming a large amount of memory. It's bad practice, but calling the garabge collection method manually seems to fix the issue.
function iterate()
-- draw the current iteration into a canvas, and let the pixel shader
-- calculate the next iteration of the grid
lg.setCanvas(calculationCanvas)
lg.setShader(calculationShader)
lg.draw(currentIterationImage)
lg.setShader()
lg.setCanvas()
-- now, get the next iteration as image data out of the calculationCanvas
currentIteration = calculationCanvas:newImageData()
currentIterationImage = lg.newImage(currentIteration)
currentIterationImage:setFilter("linear", "nearest")
collectgarbage()
end
The love.update()
method lets the user make pixels in the current iteration alive or dead using the two mouse buttons. If the "g" key is pressed, the script spawns a glider at the mouse location. The function also handles calling iterate()
at the right time, since the script also allows the user to control down the iteration speed. A simple method handles creating the gliders. Ugly, but it works.
function love.update(dt)
if love.mouse.isDown(1, 2) then
local x = love.mouse.getX()
local y = love.mouse.getY()
local imageX = math.floor(x / sample);
local imageY = math.floor(y / sample);
if love.mouse.isDown(1) then
if love.keyboard.isDown('g') then
makeGlider(imageX, imageY)
else
currentIteration:setPixel(imageX, imageY, 255, 255, 255, 255)
end
else
currentIteration:setPixel(imageX, imageY, 0, 0, 0, 255)
end
currentIterationImage:refresh()
end
if running then
time = time + dt
if time > (1 / iterationsPerSecond) then
iterate()
time = 0
end
else
time = 0
end
end
function makeGlider(x, y)
currentIteration:setPixel(x, y - 1, 255, 255, 255, 255)
currentIteration:setPixel(x + 1, y, 255, 255, 255, 255)
currentIteration:setPixel(x - 1, y + 1, 255, 255, 255, 255)
currentIteration:setPixel(x, y + 1, 255, 255, 255, 255)
currentIteration:setPixel(x + 1, y + 1, 255, 255, 255, 255)
end
The last bit of the user interface was some keyboard shortcuts. The spacebar pauses or resumes the simulation. The "s" key allows single-stepping the iterations. The "up" and "down" arrow keys let the user adjust the simulation rate. The "r" key fills the entire window with random pixels and "c" clears the current window.
The most fun easter egg I created lets the user hit the "h" key to fill the entire screen with a grid of gliders. Extra fun is letting the simulation run and then disturbing the grid of gliders and watching the disturbance travel throughout the grid.
function love.keypressed(key, scancode, isrepeat)
if key == 'space' then
running = not running
if running then
lw.setTitle("game of life - running")
else
lw.setTitle("game of life - paused")
end
end
if key == 's' then
iterate()
end
if key == 'up' then
iterationsPerSecond = iterationsPerSecond + 5
end
if key == 'down' then
iterationsPerSecond = iterationsPerSecond - 5
end
if key == 'r' then
for x = 0, (dataWidth - 1) do
for y = 0, (dataHeight - 1) do
local alive = love.math.random(0, 4)
if alive < 1 then
currentIteration:setPixel(x, y, 255, 255, 255, 255)
else
currentIteration:setPixel(x, y, 0, 0, 0, 255)
end
end
end
currentIterationImage:refresh()
end
if key == 'c' then
clear()
end
if key == 'h' then
for x = 1, dataWidth - 4, 5 do
for y = 1, dataHeight - 4, 5 do
makeGlider(x, y)
end
end
currentIterationImage:refresh()
end
end
That's about it! The speed is insane, at least other CPU-based game of life impelmentations I've written in the past. At a grid size that's native to my screen (1680x1050) I get over 200 iterations per second. That's 1,764,000 pixels updated at 200 times a second, which is something like 3 nanoseconds per-pixel. Pretty nuts.
This video doesn't really do it justice with all of the compression (watch out for aliasing if you watch it really small).