We will build a cellular automaton that simulates the game of life. The game of life is a zero-player game, meaning that its evolution is determined by its initial state, requiring no further input. One interacts with the Game of Life by creating an initial configuration and observing how it evolves.
In the last chapter, we learnt about WebGPU basics, and now, we will use that knowledge to create a simple game of life simulation. We will use the GPU to perform the calculations and render the game of life.
The Game of Life
The Game of Life is a cellular automaton devised by the British mathematician John Horton Conway in 1970.
Basically, there is a gird of any size, and each node in the grid can either be empty or alive. The game evolves in steps, and at each step, the following rules are applied to each node:
- If the cell is alive, then it stays alive if it has either 2 or 3 live neighbors, otherwise it dies.
- If the cell is empty, then it springs to life only in the case that it has 3 live neighbors, otherwise it remains empty.
By neighboring, we mean the 8 cells surrounding the cell.
Clean Up the Code
Before we start, let's clean up the code from the last chapter. It's just a bit refactoring.
const requestDevice = async (): Promise<[GPUAdapter, GPUDevice] | null> => {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
console.error('WebGPU not supported');
return null;
}
const device = await adapter.requestDevice();
console.log(device);
return [adapter, device];
}
const getContext = async (device: GPUDevice): Promise<[GPUCanvasContext, GPUTextureFormat]> => {
const canvas = document.getElementById('app') as HTMLCanvasElement;
const context = canvas.getContext("webgpu")!;
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
return [context, canvasFormat];
}
const getShaderModule = async (device: GPUDevice): Promise<GPUShaderModule> => {
return device.createShaderModule({
label: "shader",
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 1.0, 0.0, 1.0);
}
`
});
}
const getBuffer = async (device: GPUDevice): Promise<[GPUBuffer, GPUVertexBufferLayout]> => {
const vertices = [
0.0, 0.0,
0.0, 1.0,
1.0, 0.0,
1.0, 1.0,
0.0, 1.0,
1.0, 0.0,
]
const vertexBuffer = device.createBuffer({
size: vertices.length * 4,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Float32Array(vertexBuffer.getMappedRange()).set(vertices);
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0,
}],
};
vertexBuffer.unmap();
return [vertexBuffer, vertexBufferLayout];
}
const main = async () => {
const [_, device] = (await requestDevice())!;
const [context, canvasFormat] = await getContext(device);
const shaderModule = await getShaderModule(device);
const [vertexBuffer, vertexBufferLayout] = await getBuffer(device);
const pipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0.1, g: 0.3, b: 0.8, a: 1.0 },
storeOp: "store",
}]
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(6);
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
main()
Draw the Grid
We will start by drawing all the cells on the screen, which forms a grid.
First we generate all the points for each grid cell. A grid is a 2D array of cells, and each cell is a square. We will draw the grid as a set of triangles. Each cell will be represented by two triangles.
const grid_length = 64;
const getBuffer = async (device: GPUDevice): Promise<[GPUBuffer, GPUVertexBufferLayout]> => {
const vertexBuffer = device.createBuffer({
size: grid_length * grid_length * 4 * 12,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
const vertices = new Float32Array(grid_length * grid_length * 12);
// starts from -1 to 1
const step = 2 / grid_length;
const padding = step / 4;
for(let i = 0; i < grid_length; i++) {
for(let j = 0; j < grid_length; j++) {
const top_left_x = -1 + i * step;
const top_left_y = 1 - j * step;
const bottom_right_x = top_left_x + step;
const bottom_right_y = top_left_y - step;
const index = (i * grid_length + j) * 12;
vertices[index] = top_left_x + padding;
vertices[index + 1] = top_left_y - padding;
vertices[index + 2] = bottom_right_x - padding;
vertices[index + 3] = top_left_y - padding;
vertices[index + 4] = top_left_x + padding;
vertices[index + 5] = bottom_right_y + padding;
vertices[index + 6] = top_left_x + padding;
vertices[index + 7] = bottom_right_y + padding;
vertices[index + 8] = bottom_right_x - padding;
vertices[index + 9] = top_left_y - padding;
vertices[index + 10] = bottom_right_x - padding;
vertices[index + 11] = bottom_right_y + padding;
}
}
new Float32Array(vertexBuffer.getMappedRange()).set(vertices);
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0,
}],
};
vertexBuffer.unmap();
return [vertexBuffer, vertexBufferLayout];
}
Then change the draw pass to the correct number,
pass.draw(grid_length * grid_length * 6);
Now, you should see a grid of cells on the screen.
Handle the States
Now we need to deal with the states of each cell. Here is our first issue- how can we pass the states of each cell to the GPU? Previously, we learnt about passing data to GPU as shader parameters. But in this case, we can't do that since every cell needs access to the state of all other cells. We can't pass the state of all cells as a parameter to the shader.
The solution is a bind group. A bind group is a collection of resources that are bound to the pipeline. We can directly create an array and bind it to a buffer, then pass it to the shader.
We can simply use u32
to represent the state of each cell. We will use 0 to represent an empty cell and 1 to represent a live cell, please note that this is definitely not the most economical way to represent the state of a cell, but it's the simplest way for now.
To create a bind group, first we initialize the data for it. Here, we will use a random pattern, where for each cell, there is a 25% chance that it will be alive.
const states = new Uint32Array(grid_length * grid_length);
for(let i = 0; i < grid_length * grid_length; i++) {
states[i] = Math.random() < 0.25 ? 1 : 0;
}
Then we create a buffer and bind group for it. The buffer for vertex shader input is vertex buffer, and for now, we just need a buffer that stores the states of each cell, so we use a storage buffer.
const statesStorageBuffer = device.createBuffer({
size: grid_length * grid_length * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
Previously, we used the map function to get the mapped range of the buffer, but this time, we can use device.queue.writeBuffer
, which is another way to write data to the buffer. For mapped range, it is a complete map and thus suitable for constantly updating, small data, while writeBuffer
is suitable for writing data once.
device.queue.writeBuffer(statesStorageBuffer, 0, states.buffer);
The second parameter is an offset, obviously, it should be a zero here.
To access the buffer in the shader, we need to create a bind group layout. A bind group layout is a description of the resources that a bind group will contain.
const bindGroup = device.createBindGroup({
label: "bind group",
layout: pipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: statesStorageBuffer },
}],
});
Here we create a bind group with a single entry, which is a buffer that stores the states of each cell.
Typically, you need to manually create a layout. But we created pipeline with layout: "auto"
, so we can just use pipeline.getBindGroupLayout(0)
to get the layout. It will look for the shader and create the layout for you.
Then we need to update the render pass to bind the bind group.
pass.setBindGroup(0, bindGroup);
So we create a bind group, which contains a buffer that stores the states of each cell, and bind it to the pipeline.
To access the buffer in the shader, we need to update the shader code.
const getShaderModule = async (device: GPUDevice): Promise<GPUShaderModule> => {
return device.createShaderModule({
label: "shader",
code: `
@group(0) @binding(0) var<storage> states: array<u32>;
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 1.0, 0.0, 1.0);
}
`
});
}
Here, the var<storage>
is used to declare a storage buffer, and array<u32>
is used to declare an array of u32
. The @group(0) @binding(0)
is used to bind the buffer to the bind group.
Now we can access the buffer in the shader. However, there is another issue. How can we knew the index of the cell to be rendered?
We just can just pass the index of the cell to the vertex shader. To do so, we can add a new parameter to the vertex shader.
@group(0) @binding(0) var<storage> states: array<u32>;
@vertex
fn vertexMain(@location(0) pos: vec2f, @location(1) cell: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
Then we need to update the vertex buffer layout.
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: 12,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0,
}, {
format: "float32x2",
offset: 8,
shaderLocation: 1,
}],
};
However, a simpler way would be using vertex index. The vertex index is the index of the vertex in the vertex buffer. We can use the vertex index to calculate the cell index.
@group(0) @binding(0) var<storage> states: array<u32>;
@vertex
fn vertexMain(@location(0) pos: vec2f, @builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4f {
let cell_index = vertexIndex / 6;
return vec4f(pos, 0, 1);
}
But the problem is, vertex shader can only take parameters and return position, so we can not discard certain vertices in vertex shader. We need to use the fragment shader to do that. But how we can pass the state to the fragment shader?
Actually, you can just use a struct. So long that the returned value of the vertex shader is a struct with a field of type @builtin(position) vec4f
, the fragment shader can access the struct.
struct VertexOutput {
@builtin(position) pos: vec4<f32>,
@location(0) @interpolate(flat) cell: u32,
};
@group(0) @binding(0) var<storage> states: array<u32>;
@vertex
fn vertexMain(@location(0) pos: vec2f, @builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
let cell_index = vertexIndex / 6;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell;
return output;
}
Please note that, fragment shader is responsible for rendering every pixel, but vertex shader output only the vertex. When the vertices are passed, they are first ensemble into a primitive, then the fragment shader is called for each pixel in the primitive. This is why we need a @interpolate(flat)
for the cell. This tells the pipeline that when dealing with the value of pixels that are not vertices, it should use the flatten value of the primitive- that is, the value of the vertex.
Previously, we passed no parameters to the fragment shader. Actually, it just accepts the output from the vertex shader, so if you want the position, if vertex only return position, then you can just use pos
in the fragment shader.
@fragment
fn fragmentMain(@builtin(position) pos: vec4f) -> vec4<f32> {
return vec4<f32>(1.0, 1.0, 0.0, 1.0);
}
But now, since we have a struct, we can access the field of the struct in the fragment shader.
In the fragment, you can discard some nodes based on the state. The discarded nodes will not be rendered. However, for example, if in a triangle, one vertex is discarded, the whole triangle will be discarded.
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
if(states[input.cell] == 0u) {
discard;
}
return vec4<f32>(1.0, 1.0, 0.0, 1.0);
}
Now, you should only see part of the grid, where the cells are alive initially.
Update the States
However, our cells are not alive- we have to update the states of each cell.
To run computation on GPU, instead of vertex shader and fragment shader, we can use compute shader. A compute shader is a shader stage that is used to perform general-purpose computation on the GPU. It is used to perform calculations that are not necessarily related to rendering.
For computation shader, we need to create a new shader module.
const getComputationShaderModule = async (device: GPUDevice): Promise<GPUShaderModule> => {
return device.createShaderModule({
label: "computation shader",
code: `
@group(0) @binding(0) var<storage> states: array<u32>;
@compute
@workgroup_size(${workgroup_size}, ${workgroup_size})
fn computeMain() {
}
`
});
}
We have something unique for the compute shader- @workgroup_size
. The workgroup size is the number of threads in a workgroup. The workgroup size is a 3D vector, and the typical maximum size is 256x256x64, may vary depending on the device, in my Mac, it's only capable of up to 16
.
If you want to do, for example, computation over grid_length
by grid_length
cells, if it exceeds the workgroup size, you can make the workgroup size smaller. When the actual computation is done, it's done by a workgroup_size
times workgroup_size
every time. It slides over the grid. global_invocation_id
is still the actual index of the cell over the whole grid.
By omitting the parameters, it is the same as setting that dimension to 1.
Then the same pipeline and pass,
const computationShaderModule = await getComputationShaderModule(device);
const computationPipeline = device.createComputePipeline({
label: "computation pipeline",
layout: "auto",
compute: {
module: computationShaderModule,
entryPoint: "computeMain",
}
});
const computationEncoder = device.createCommandEncoder();
const computationPass = computationEncoder.beginComputePass();
Before going further, we need to introduce a two-buffer system for updating states. Basically, since rendering is a long-lasting process, we can't update the states while rendering. So we need to use two buffers, one for the current state and one for the next state. After each frame, we swap the buffers.
Now we extract our code that is responsible for rendering, we isolate them and pass an extra parameter, the step.
const render = async (step: number) => {
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0.1, g: 0.3, b: 0.6, a: 1.0 },
storeOp: "store",
}]
});
const bindGroup = device.createBindGroup({
label: "bind group",
layout: pipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: statesStorageBuffer },
}],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(grid_length * grid_length * 6);
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
render(0);
Instead of one buffer, we use two buffer to allow switching between two.
const statesStorageBuffer = [
device.createBuffer({
size: grid_length * grid_length * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
}),
device.createBuffer({
size: grid_length * grid_length * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
})
]
device.queue.writeBuffer(statesStorageBuffer[0], 0, states.buffer);
Then, when rendering, we need to bind the correct buffer. Since we will be swapping the buffers, we need to use the modulo operator to get the correct buffer.
const bindGroup = device.createBindGroup({
label: "bind group",
layout: pipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: statesStorageBuffer[step % 2] },
}],
});
And lastly,
let step = 0;
setInterval(() => {
render(step);
step++;
step = step % 2;
}, 1000);
We will see the grid of cells updating every second. The second state is all the cells are dead, since we didn't update the states.
Now let's write the actual computation shader. Now we have two buffers and will be switching between them. So after every rendering, we just use one as the previous state, and write the states to the other buffer, so that the next rendering will use the updated states.
Previously, we could only read from the buffer, in order to write to the buffer in the wsgl, we need to specify read_write
in the var
declaration.
@group(0) @binding(1) var<storage, read_write> states: array<u32>;
Note that we don't need to change GPU usage, since that is how CPU will interact with the buffer, where as now, buffers are handled completely by GPU except for the first write.
Then we can write the computation shader.
@group(0) @binding(0) var<storage> states: array<u32>;
@group(0) @binding(1) var<storage, read_write> next_states: array<u32>;
@compute
@workgroup_size(${grid_length}, ${grid_length})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
The compute shader is executed in parallel, and the global_invocation_id
is the global index of the thread. It is a vec3u
. For example, for the first task executaed, it is 0, 0, 0
, then increase for the next task- though please note that they are computed parallelly.
We can just use cell.x
, cell.y
as the index of the cell. That is, for task x, y, z
, we deal with the state of the cell at x, y
.
We can just use cell.x
, cell.y
as the index of the cell.
const getComputationShaderModule = async (device: GPUDevice): Promise<GPUShaderModule> => {
return device.createShaderModule({
label: "computation shader",
code: `
@group(0) @binding(0) var<storage> states: array<u32>;
@group(0) @binding(1) var<storage, read_write> next_states: array<u32>;
@compute
@workgroup_size(${workgroup_size}, ${workgroup_size})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
let index = cell.x * ${grid_length} + cell.y;
var count = 0u;
for(var i: i32 = -1; i < 2; i++) {
for(var j: i32 = -1; j < 2; j++) {
if(i == 0 && j == 0) {
continue;
}
let x = i32(cell.x) + i;
let y = i32(cell.y) + j;
if(x >= 0 && x < ${grid_length} && y >= 0 && y < ${grid_length}) {
count += states[x * ${grid_length} + y];
}
}
}
if(states[index] == 1u) {
if(count < 2u || count > 3u) {
next_states[index] = 0u;
} else {
next_states[index] = 1u;
}
} else {
if(count == 3u) {
next_states[index] = 1u;
} else {
next_states[index] = 0u;
}
}
}
`
});
}
Then the rest of the code, they are the same.
const computationShaderModule = await getComputationShaderModule(device);
const computationEncoder = device.createCommandEncoder();
const computationPass = computationEncoder.beginComputePass();
However, we now want two buffers be shared for the two pipelines, since they will create their respective bind group layout. We need to make sure that the two bind groups are compatible.
const bindGroupLayout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.COMPUTE | GPUShaderStage.VERTEX,
buffer: {
type: "read-only-storage"
}
}, {
binding: 1,
visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.COMPUTE,
buffer: {
type: "storage"
}
}]
});
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [bindGroupLayout]
});
const pipeline = device.createRenderPipeline({
label: "pipeline",
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
const computeShaderModule = await getComputationShaderModule(device);
const computePipeline = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: computeShaderModule,
entryPoint: "computeMain"
}
});
Then we can create the bind group.
const bindGroup = device.createBindGroup({
label: "bind group",
layout: bindGroupLayout,
entries: [{
binding: 0,
resource: { buffer: statesStorageBuffer[step % 2] },
}, {
binding: 1,
resource: { buffer: statesStorageBuffer[(step + 1) % 2] },
}]
});
And lastly, before the encoder finishes, we need to dispatch the computation.
const computePass = encoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, bindGroup);
const factor = Math.floor(grid_length / workgroup_size);
computePass.dispatchWorkgroups(factor, factor)
computePass.end();
Here for the workgroup, if the total computation is x
by y
by z
, and the workgroup size is a
by b
by c
, then the workgroup size is Math.ceil(x / a)
, Math.ceil(y / b)
, Math.ceil(z / c)
. Every time, x
by y
by z
cells are computed.
Now, the complete code is as follows.
const grid_length = 64;
const workgroup_size = 8;
const requestDevice = async (): Promise<[GPUAdapter, GPUDevice] | null> => {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
console.error('WebGPU not supported');
return null;
}
const device = await adapter.requestDevice();
console.log(device);
return [adapter, device];
}
const getContext = async (device: GPUDevice): Promise<[GPUCanvasContext, GPUTextureFormat]> => {
const canvas = document.getElementById('app') as HTMLCanvasElement;
const context = canvas.getContext("webgpu")!;
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
return [context, canvasFormat];
}
const getShaderModule = async (device: GPUDevice): Promise<GPUShaderModule> => {
return device.createShaderModule({
label: "shader",
code: `
@group(0) @binding(0) var<storage> states: array<u32>;
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) cell: u32,
};
@vertex
fn vertexMain(@location(0) pos: vec2f, @builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
var output: VertexOutput;
output.pos = vec4<f32>(pos, 0.0, 1.0);
output.cell = vertexIndex / 6;
return output;
}
@fragment
fn fragmentMain(input: VertexOutput) -> vec4<f32> {
if(states[input.cell] == 0u) {
discard;
}
return vec4<f32>(1.0, 1.0, 0.0, 1.0);
}
`
});
}
const getVertexBuffer = async (device: GPUDevice): Promise<[GPUBuffer, GPUVertexBufferLayout]> => {
const vertexBuffer = device.createBuffer({
size: grid_length * grid_length * 4 * 12,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
const vertices = new Float32Array(grid_length * grid_length * 12);
// starts from -1 to 1
const step = 2 / grid_length;
const padding = step / 6;
for(let i = 0; i < grid_length; i++) {
for(let j = 0; j < grid_length; j++) {
const top_left_x = -1 + i * step;
const top_left_y = 1 - j * step;
const bottom_right_x = top_left_x + step;
const bottom_right_y = top_left_y - step;
const index = (i * grid_length + j) * 12;
vertices[index] = top_left_x + padding;
vertices[index + 1] = top_left_y - padding;
vertices[index + 2] = bottom_right_x - padding;
vertices[index + 3] = top_left_y - padding;
vertices[index + 4] = top_left_x + padding;
vertices[index + 5] = bottom_right_y + padding;
vertices[index + 6] = top_left_x + padding;
vertices[index + 7] = bottom_right_y + padding;
vertices[index + 8] = bottom_right_x - padding;
vertices[index + 9] = top_left_y - padding;
vertices[index + 10] = bottom_right_x - padding;
vertices[index + 11] = bottom_right_y + padding;
}
}
new Float32Array(vertexBuffer.getMappedRange()).set(vertices);
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0,
}],
};
vertexBuffer.unmap();
return [vertexBuffer, vertexBufferLayout];
}
const getComputationShaderModule = async (device: GPUDevice): Promise<GPUShaderModule> => {
return device.createShaderModule({
label: "computation shader",
code: `
@group(0) @binding(0) var<storage> states: array<u32>;
@group(0) @binding(1) var<storage, read_write> next_states: array<u32>;
@compute
@workgroup_size(${workgroup_size}, ${workgroup_size})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
let index = cell.x * ${grid_length} + cell.y;
var count = 0u;
for(var i: i32 = -1; i < 2; i++) {
for(var j: i32 = -1; j < 2; j++) {
if(i == 0 && j == 0) {
continue;
}
let x = i32(cell.x) + i;
let y = i32(cell.y) + j;
if(x >= 0 && x < ${grid_length} && y >= 0 && y < ${grid_length}) {
count += states[x * ${grid_length} + y];
}
}
}
if(states[index] == 1u) {
if(count < 2u || count > 3u) {
next_states[index] = 0u;
} else {
next_states[index] = 1u;
}
} else {
if(count == 3u) {
next_states[index] = 1u;
} else {
next_states[index] = 0u;
}
}
}
`
});
}
const main = async () => {
const [_, device] = (await requestDevice())!;
const [context, canvasFormat] = await getContext(device);
const shaderModule = await getShaderModule(device);
const [vertexBuffer, vertexBufferLayout] = await getVertexBuffer(device);
const states = new Uint32Array(grid_length * grid_length);
for(let i = 0; i < grid_length * grid_length; i++) {
states[i] = Math.random() > 0.25 ? 0 : 1;
}
const statesStorageBuffer = [
device.createBuffer({
size: grid_length * grid_length * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
}),
device.createBuffer({
size: grid_length * grid_length * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
})
]
device.queue.writeBuffer(statesStorageBuffer[0], 0, states.buffer);
const bindGroupLayout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.COMPUTE | GPUShaderStage.VERTEX,
buffer: {
type: "read-only-storage"
}
}, {
binding: 1,
visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.COMPUTE,
buffer: {
type: "storage"
}
}]
});
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [bindGroupLayout]
});
const pipeline = device.createRenderPipeline({
label: "pipeline",
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
const computeShaderModule = await getComputationShaderModule(device);
const computePipeline = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: computeShaderModule,
entryPoint: "computeMain"
}
});
const render = async (step: number) => {
const encoder = device.createCommandEncoder();
const bindGroup = device.createBindGroup({
label: "bind group",
layout: bindGroupLayout,
entries: [{
binding: 0,
resource: { buffer: statesStorageBuffer[step % 2] },
}, {
binding: 1,
resource: { buffer: statesStorageBuffer[(step + 1) % 2] },
}]
});
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0.1, g: 0.3, b: 0.6, a: 1.0 },
storeOp: "store",
}]
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(grid_length * grid_length * 6);
pass.end();
const computePass = encoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, bindGroup);
const factor = Math.floor(grid_length / workgroup_size);
computePass.dispatchWorkgroups(factor, factor)
computePass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
let step = 0;
setInterval(() => {
render(step);
step++;
step = step % 2;
}, 500);
}
main()
Now you should see the game of life simulation running on the screen.
Congratulations! You have successfully created a game of life simulation using WebGPU.
If you are willing to observe the game of life simulation in a more detailed way, you can increase the grid length and workgroup size. However, keep in mind that the larger the grid length and workgroup size, the more computation is required, and it may slow down the simulation. However, GPU is powerful and can handle a large amount of computation. You can build a pure CPU version and see the great difference between the two.
In the next chapter, we will learn about 3D models and how to render them using WebGPU.
Top comments (0)