refac: split code into classes & add reset button

This commit is contained in:
Florian Sylvain
2025-04-06 05:23:20 +02:00
parent 9d1327e9c5
commit 296ea76d6b
2 changed files with 270 additions and 275 deletions
+1
View File
@@ -24,6 +24,7 @@
/> />
</div> </div>
</div> </div>
<button id="reset-button">Reset Grid</button>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+269 -275
View File
@@ -3,18 +3,24 @@ import "./style.css"
const GRID_SIZE = 64 const GRID_SIZE = 64
let COMPUTE_MS_INTERVAL = 100 let COMPUTE_MS_INTERVAL = 100
function setupSliders() { class UIController {
const computeIntervalSlider = document.getElementById( static onReset(callback: () => void) {
"compute-interval-slider", document.getElementById("reset-button")?.addEventListener("click", callback)
) as HTMLInputElement }
const computeIntervalValue = document.getElementById(
"compute-interval-value",
) as HTMLElement
computeIntervalSlider.addEventListener("input", () => { static setup() {
COMPUTE_MS_INTERVAL = parseInt(computeIntervalSlider.value) const slider = document.getElementById(
computeIntervalValue.textContent = computeIntervalSlider.value "compute-interval-slider",
}) ) as HTMLInputElement
const valueLabel = document.getElementById(
"compute-interval-value",
) as HTMLElement
slider.addEventListener("input", () => {
COMPUTE_MS_INTERVAL = parseInt(slider.value)
valueLabel.textContent = slider.value
})
}
} }
async function getCanvas(): Promise<HTMLCanvasElement> { async function getCanvas(): Promise<HTMLCanvasElement> {
@@ -23,319 +29,307 @@ async function getCanvas(): Promise<HTMLCanvasElement> {
return canvas return canvas
} }
async function getAdapter(): Promise<GPUAdapter> { async function getDevice(): Promise<GPUDevice> {
if (!navigator.gpu) throw new Error("WebGPU not supported on this browser.") if (!navigator.gpu) throw new Error("WebGPU not supported")
const adapter = await navigator.gpu.requestAdapter() const adapter = await navigator.gpu.requestAdapter()
if (!adapter) throw new Error("No appropriate GPUAdapter found.") if (!adapter) throw new Error("No GPUAdapter found")
return adapter return adapter.requestDevice()
} }
async function getDevice(adapter: GPUAdapter): Promise<GPUDevice> { class Grid {
return await adapter.requestDevice() readonly uniformBuffer: GPUBuffer
}
function configureContext( constructor(
canvas: HTMLCanvasElement, readonly size: number,
device: GPUDevice, device: GPUDevice,
): GPUCanvasContext { ) {
const context = canvas.getContext("webgpu") const data = new Float32Array([size, size])
if (!context) throw new Error("Failed to get WebGPU context.") this.uniformBuffer = device.createBuffer({
const canvasFormat = navigator.gpu.getPreferredCanvasFormat() label: "Grid Uniform",
context.configure({ device, format: canvasFormat }) size: data.byteLength,
return context usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
} })
device.queue.writeBuffer(this.uniformBuffer, 0, data)
function createVertexBufferLayout(): GPUVertexBufferLayout {
return {
arrayStride: 8,
attributes: [
{ format: "float32x2" as GPUVertexFormat, offset: 0, shaderLocation: 0 },
],
} }
} }
function createPipelineLayout( class Buffers {
device: GPUDevice, static createTimeBuffer(device: GPUDevice): GPUBuffer {
bindGroupLayout: GPUBindGroupLayout, return device.createBuffer({
): GPUPipelineLayout { label: "Time Uniform",
return device.createPipelineLayout({ size: 4,
label: "Cell Pipeline Layout", usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
bindGroupLayouts: [bindGroupLayout], })
}) }
static createVertexBuffer(device: GPUDevice): [GPUBuffer, Float32Array] {
const data = new Float32Array([
-0.8, -0.8, 0.8, -0.8, 0.8, 0.8, -0.8, -0.8, 0.8, 0.8, -0.8, 0.8,
])
const buffer = device.createBuffer({
label: "Cell vertices",
size: data.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
})
device.queue.writeBuffer(buffer, 0, data)
return [buffer, data]
}
static createStateBuffers(device: GPUDevice, size: number): GPUBuffer[] {
const data = new Uint32Array(size * size).map(() =>
Math.random() > 0.6 ? 1 : 0,
)
const usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
const buffers = [
device.createBuffer({ label: "State A", size: data.byteLength, usage }),
device.createBuffer({ label: "State B", size: data.byteLength, usage }),
]
device.queue.writeBuffer(buffers[0], 0, data)
device.queue.writeBuffer(buffers[1], 0, data)
return buffers
}
} }
async function createComputePipeline( class PipelineFactory {
device: GPUDevice, static createBindGroupLayout(device: GPUDevice): GPUBindGroupLayout {
pipelineLayout: GPUPipelineLayout, return device.createBindGroupLayout({
): Promise<GPUComputePipeline> { label: "BindGroupLayout",
return device.createComputePipeline({ entries: [
label: "Simulation pipeline", { binding: 0, visibility: 7, buffer: { type: "uniform" } },
layout: pipelineLayout, { binding: 1, visibility: 2, buffer: { type: "uniform" } },
compute: { { binding: 2, visibility: 5, buffer: { type: "read-only-storage" } },
module: device.createShaderModule({ { binding: 3, visibility: 4, buffer: { type: "storage" } },
label: "Game of Life simulation shader", ],
code: (await import("./shaders/simulation.wgsl?raw")).default, })
}
static createPipelineLayout(device: GPUDevice, layout: GPUBindGroupLayout) {
return device.createPipelineLayout({ bindGroupLayouts: [layout] })
}
static async createCompute(
device: GPUDevice,
layout: GPUPipelineLayout,
): Promise<GPUComputePipeline> {
const module = device.createShaderModule({
label: "Compute shader",
code: (await import("./shaders/simulation.wgsl?raw")).default,
})
return device.createComputePipeline({
label: "Simulation pipeline",
layout,
compute: { module, entryPoint: "computeMain" },
})
}
static async createRender(
device: GPUDevice,
format: GPUTextureFormat,
layout: GPUPipelineLayout,
): Promise<GPURenderPipeline> {
const module = device.createShaderModule({
label: "Cell shader",
code: (await import("./shaders/cell.wgsl?raw")).default,
})
return device.createRenderPipeline({
label: "Cell pipeline",
layout,
vertex: {
module,
entryPoint: "vertexMain",
buffers: [
{
arrayStride: 8,
attributes: [{ format: "float32x2", offset: 0, shaderLocation: 0 }],
},
],
},
fragment: {
module,
entryPoint: "fragmentMain",
targets: [{ format }],
},
})
}
}
class Simulation {
public bindGroups: GPUBindGroup[]
private stateBuffers: GPUBuffer[]
constructor(
private device: GPUDevice,
grid: Grid,
private time: GPUBuffer,
state: GPUBuffer[],
layout: GPUBindGroupLayout,
) {
this.stateBuffers = state
this.bindGroups = this.createBindGroups(grid, layout)
}
reset(size: number) {
const data = new Uint32Array(size * size).map(() =>
Math.random() > 0.6 ? 1 : 0,
)
for (const buffer of this.stateBuffers) {
this.device.queue.writeBuffer(buffer, 0, data)
}
}
private createBindGroups(
grid: Grid,
layout: GPUBindGroupLayout,
): GPUBindGroup[] {
return [
this.device.createBindGroup({
layout,
entries: [
{ binding: 0, resource: { buffer: grid.uniformBuffer } },
{ binding: 1, resource: { buffer: this.time } },
{ binding: 2, resource: { buffer: this.stateBuffers[0] } },
{ binding: 3, resource: { buffer: this.stateBuffers[1] } },
],
}), }),
entryPoint: "computeMain", this.device.createBindGroup({
}, layout,
}) entries: [
} { binding: 0, resource: { buffer: grid.uniformBuffer } },
{ binding: 1, resource: { buffer: this.time } },
async function createCellPipeline( { binding: 2, resource: { buffer: this.stateBuffers[1] } },
device: GPUDevice, { binding: 3, resource: { buffer: this.stateBuffers[0] } },
canvasFormat: GPUTextureFormat, ],
pipelineLayout: GPUPipelineLayout, }),
): Promise<GPURenderPipeline> { ]
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: (await import("./shaders/cell.wgsl?raw")).default,
})
return device.createRenderPipeline({
label: "Cell pipeline",
layout: pipelineLayout,
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [createVertexBufferLayout()],
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{ format: canvasFormat }],
},
})
}
function createGridUniformBuffer(device: GPUDevice): GPUBuffer {
const gridUniformArray = new Float32Array([GRID_SIZE, GRID_SIZE])
const gridUniformBuffer = device.createBuffer({
label: "Grid Uniforms",
size: gridUniformArray.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
device.queue.writeBuffer(gridUniformBuffer, 0, gridUniformArray)
return gridUniformBuffer
}
function createTimeUniformBuffer(device: GPUDevice): GPUBuffer {
return device.createBuffer({
label: "Time Uniform",
size: 4,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
}
function createStateStorageBuffers(device: GPUDevice): GPUBuffer[] {
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE)
const size = cellStateArray.byteLength
const usage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
const cellStateStorage = [
device.createBuffer({ label: "Cell State A", size, usage }),
device.createBuffer({ label: "Cell State B", size, usage }),
]
for (let i = 0; i < cellStateArray.length; ++i) {
cellStateArray[i] = Math.random() > 0.6 ? 1 : 0
} }
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray)
return cellStateStorage
}
function createBindGroupLayout(device: GPUDevice): GPUBindGroupLayout {
return device.createBindGroupLayout({
label: "Cell Bind Group Layout",
entries: [
{
binding: 0,
visibility:
GPUShaderStage.FRAGMENT |
GPUShaderStage.VERTEX |
GPUShaderStage.COMPUTE,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
buffer: { type: "uniform" },
},
{
binding: 2,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: { type: "read-only-storage" },
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage" },
},
],
})
}
function createBindGroups(
device: GPUDevice,
gridUniformBuffer: GPUBuffer,
timeUniformBuffer: GPUBuffer,
cellStateStorage: GPUBuffer[],
bindGroupLayout: GPUBindGroupLayout,
): GPUBindGroup[] {
return [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: gridUniformBuffer } },
{ binding: 1, resource: { buffer: timeUniformBuffer } },
{ binding: 2, resource: { buffer: cellStateStorage[0] } },
{ binding: 3, resource: { buffer: cellStateStorage[1] } },
],
}),
device.createBindGroup({
label: "Cell updater bind group B",
layout: bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: gridUniformBuffer } },
{ binding: 1, resource: { buffer: timeUniformBuffer } },
{ binding: 2, resource: { buffer: cellStateStorage[1] } },
{ binding: 3, resource: { buffer: cellStateStorage[0] } },
],
}),
]
}
function createVertexBuffer(device: GPUDevice): GPUBuffer {
const vertices = new Float32Array([
-0.8, -0.8, 0.8, -0.8, 0.8, 0.8, -0.8, -0.8, 0.8, 0.8, -0.8, 0.8,
])
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
})
device.queue.writeBuffer(vertexBuffer, 0, vertices)
return vertexBuffer
} }
class Renderer { class Renderer {
private lastFrameTime: number = 0 private lastFrame = 0
private frameCount: number = 0 private frameCount = 0
private fps: number = 0 private fps = 0
private bindGroupIndex: number = 0 private bindGroupIndex = 0
private lastBindGroupSwitchTime: number = 0 private lastSwitch = 0
constructor( constructor(
private device: GPUDevice, private device: GPUDevice,
private context: GPUCanvasContext, private context: GPUCanvasContext,
private cellPipeline: GPURenderPipeline, private renderPipeline: GPURenderPipeline,
private simulationPipeline: GPUComputePipeline, private computePipeline: GPUComputePipeline,
private vertexBuffer: GPUBuffer, private vertexBuffer: GPUBuffer,
private bindGroups: GPUBindGroup[],
private timeBuffer: GPUBuffer,
private vertices: Float32Array, private vertices: Float32Array,
private simulation: Simulation,
private timeBuffer: GPUBuffer,
) {} ) {}
private updateFpsCount(timeMs: number): void { render(timeMs: number) {
if (this.lastFrameTime === 0) this.lastFrameTime = timeMs this.updateFPS(timeMs)
const encoder = this.device.createCommandEncoder()
this.runComputePass(encoder, timeMs)
this.runRenderPass(encoder)
this.updateTimeBuffer(timeMs)
this.device.queue.submit([encoder.finish()])
requestAnimationFrame(this.render.bind(this))
}
private updateFPS(timeMs: number) {
if (!this.lastFrame) this.lastFrame = timeMs
this.frameCount++ this.frameCount++
if (timeMs - this.lastFrameTime >= 1000) {
if (timeMs - this.lastFrame >= 1000) {
this.fps = this.frameCount this.fps = this.frameCount
this.frameCount = 0 this.frameCount = 0
this.lastFrameTime = timeMs this.lastFrame = timeMs
document.querySelector("#fps")!.textContent = `FPS: ${this.fps}` document.querySelector("#fps")!.textContent = `FPS: ${this.fps}`
} }
} }
private updateBindGroupIndex(timeMs: number): void { private updateTimeBuffer(timeMs: number) {
if (timeMs - this.lastBindGroupSwitchTime >= COMPUTE_MS_INTERVAL) { const seconds = timeMs / 1000
this.bindGroupIndex = (this.bindGroupIndex + 1) % this.bindGroups.length this.device.queue.writeBuffer(
this.lastBindGroupSwitchTime = timeMs this.timeBuffer,
} 0,
new Float32Array([seconds]),
)
} }
public async render(timeMs: number): Promise<void> { private runComputePass(encoder: GPUCommandEncoder, timeMs: number) {
const encoder = this.device.createCommandEncoder() if (timeMs - this.lastSwitch >= COMPUTE_MS_INTERVAL) {
this.bindGroupIndex = (this.bindGroupIndex + 1) % 2
const computePass = encoder.beginComputePass() this.lastSwitch = timeMs
computePass.setPipeline(this.simulationPipeline) }
computePass.setBindGroup(0, this.bindGroups[this.bindGroupIndex]) const pass = encoder.beginComputePass()
pass.setPipeline(this.computePipeline)
const workgroupCount = Math.ceil(GRID_SIZE / 8) pass.setBindGroup(0, this.simulation.bindGroups[this.bindGroupIndex])
computePass.dispatchWorkgroups(workgroupCount, workgroupCount) pass.dispatchWorkgroups(Math.ceil(GRID_SIZE / 8), Math.ceil(GRID_SIZE / 8))
pass.end()
computePass.end() }
const time = timeMs / 1000
this.updateFpsCount(timeMs)
this.device.queue.writeBuffer(this.timeBuffer, 0, new Float32Array([time]))
this.updateBindGroupIndex(timeMs)
private runRenderPass(encoder: GPUCommandEncoder) {
const pass = encoder.beginRenderPass({ const pass = encoder.beginRenderPass({
colorAttachments: [ colorAttachments: [
{ {
view: this.context.getCurrentTexture().createView(), view: this.context.getCurrentTexture().createView(),
loadOp: "clear", loadOp: "clear",
clearValue: [0.0, 0.0, 0.4, 1.0], clearValue: [0.0, 0.0, 0.4, 1],
storeOp: "store", storeOp: "store",
}, },
], ],
}) })
pass.setPipeline(this.renderPipeline)
pass.setPipeline(this.cellPipeline)
pass.setVertexBuffer(0, this.vertexBuffer) pass.setVertexBuffer(0, this.vertexBuffer)
pass.setBindGroup(0, this.simulation.bindGroups[this.bindGroupIndex])
pass.setBindGroup(0, this.bindGroups[this.bindGroupIndex])
pass.draw(this.vertices.length / 2, GRID_SIZE * GRID_SIZE) pass.draw(this.vertices.length / 2, GRID_SIZE * GRID_SIZE)
pass.end() pass.end()
this.device.queue.submit([encoder.finish()])
requestAnimationFrame((time) => this.render(time))
} }
} }
async function init() { class Engine {
setupSliders() async start() {
UIController.setup()
const device = await getDevice(await getAdapter()) const device = await getDevice()
const context = configureContext(await getCanvas(), device) const canvas = await getCanvas()
const canvasFormat = navigator.gpu.getPreferredCanvasFormat() const context = canvas.getContext("webgpu")!
const format = navigator.gpu.getPreferredCanvasFormat()
context.configure({ device, format })
const gridUniformBuffer = createGridUniformBuffer(device) const grid = new Grid(GRID_SIZE, device)
const timeBuffer = createTimeUniformBuffer(device) const timeBuffer = Buffers.createTimeBuffer(device)
const cellStateStorage = createStateStorageBuffers(device) const [vertexBuffer, vertices] = Buffers.createVertexBuffer(device)
const bindGroupLayout = createBindGroupLayout(device) const stateBuffers = Buffers.createStateBuffers(device, GRID_SIZE)
const bindGroups = createBindGroups( const layout = PipelineFactory.createBindGroupLayout(device)
device, const pipelineLayout = PipelineFactory.createPipelineLayout(device, layout)
gridUniformBuffer,
timeBuffer,
cellStateStorage,
bindGroupLayout,
)
const pipelineLayout = createPipelineLayout(device, bindGroupLayout)
const cellPipeline = await createCellPipeline(
device,
canvasFormat,
pipelineLayout,
)
const simulationPipeline = await createComputePipeline(device, pipelineLayout)
const vertexBuffer = createVertexBuffer(device)
const vertices = new Float32Array([
-0.8, -0.8, 0.8, -0.8, 0.8, 0.8, -0.8, -0.8, 0.8, 0.8, -0.8, 0.8,
])
const renderer = new Renderer( const [renderPipeline, computePipeline] = await Promise.all([
device, PipelineFactory.createRender(device, format, pipelineLayout),
context, PipelineFactory.createCompute(device, pipelineLayout),
cellPipeline, ])
simulationPipeline,
vertexBuffer, const simulation = new Simulation(
bindGroups, device,
timeBuffer, grid,
vertices, timeBuffer,
) stateBuffers,
requestAnimationFrame((time) => renderer.render(time)) layout,
)
const renderer = new Renderer(
device,
context,
renderPipeline,
computePipeline,
vertexBuffer,
vertices,
simulation,
timeBuffer,
)
UIController.onReset(() => simulation.reset(GRID_SIZE))
requestAnimationFrame(renderer.render.bind(renderer))
}
} }
init().catch((error) => { new Engine().start().catch(console.error)
console.error("Error initializing WebGPU:", error)
})