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).