DEV Community

ndesmic
ndesmic

Posted on

WebGPU Engine from Scratch 1: Basic Rendering

After putting this project in storage for a while, WebGPU is now at a place where I feel I can really use it. Since it's essentially the successor to WebGL and more capable there's not a huge point dumping lots of resources into figuring out the ins and outs of WebGL. So, I started converting what I had, with lessons learned. This will be a re-write but leveraging all the previous knowledge.

The first major architectural change I'm making is actually splitting out the engine from the component.  Originally it just got to be a massive file and there was too much digging in it.  So this time the component just handles DOM stuff and pass a canvas reference to an Engine class.  This Engine class holds the draw loop and all the interesting stuff.

The component is a simple web-component with a height and width:

import { CanvasEngine as Engine } from "../engines/canvas-engine.js"; export class WcGeo extends HTMLElement { static observedAttributes = ["height", "width"]; #height = 720; #width = 1280; constructor() { super(); this.bind(this); } bind(element) { element.createShadowDom = element.createShadowDom.bind(element); element.attachEvents = element.attachEvents.bind(element); element.cacheDom = element.cacheDom.bind(element); } createShadowDom() { this.shadow = this.attachShadow({ mode: "open" }); this.shadow.innerHTML = ` <style> :host { display: block; flex-flow: column nowrap; } canvas { display: block; } </style> <canvas width="${this.#width}" height="${this.#height}"></canvas> `; } async connectedCallback() { this.createShadowDom(); this.cacheDom(); this.attachEvents(); this.engine = new Engine({ canvas: this.dom.canvas }); await this.engine.initialize(); this.engine.start(); } cacheDom() { this.dom = {}; this.dom.canvas = this.shadow.querySelector("canvas"); } attachEvents() { } attributeChangedCallback(name, oldValue, newValue) { this[name] = newValue; } renderLoop() { requestAnimationFrame((timestamp) => { this.render(timestamp); this.renderLoop(); }); } } customElements.define("wc-geo", WcGeo); 
Enter fullscreen mode Exit fullscreen mode

All it does is pass the canvas into the Engine and call initialize (because we need a place to do one-time async setup since it's not in the constructor) and then start which kicks off the render loop. For simplicity let's look at the easiest possible canvas renderer:

export class CanvasEngine { #canvas; #context; constructor(options){ this.#canvas = options.canvas; this.#context = options.canvas.getContext("2d"); } start(){ this.renderLoop(); } async intitialize(){} renderLoop(){ requestAnimationFrame((timestamp) => { this.render(timestamp); this.renderLoop(); }); } render(){ this.#context.clearRect(0,0,this.#canvas.width, this.#canvas.height); this.#context.fillStyle = "#ff0000"; this.#context.fillRect(0, 0, this.#canvas.width, this.#canvas.height); } } 
Enter fullscreen mode Exit fullscreen mode

This just draws a full screen red rectangle. But we can already see the power here since we could have a canvas implementation if we really want and it would be nice to explore different styles of engine using the same base component.

But we're really here for WebGPU. So here's a WebGPU version of the exact same thing:

export class GpuEngine { #canvas; #context; #adapter; #device; #meshes = new Map(); #pipelines = new Map(); constructor(options) { this.#canvas = options.canvas; this.#context = options.canvas.getContext("webgpu"); } async initialize(){ this.#adapter = await navigator.gpu.requestAdapter(); this.#device = await this.#adapter.requestDevice(); this.#context.configure({ device: this.#device, format: "bgra8unorm" }); this.initializeMeshes(); this.initializePipelines(); } initializeMeshes(){ //test position + uv const vertices = new Float32Array([ -1.0, -1.0, 0.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 0.0 ]); const vertexBuffer = this.#device.createBuffer({ size: vertices.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); this.#device.queue.writeBuffer(vertexBuffer, 0, vertices); this.#meshes.set("background", { vertices: vertexBuffer, }); } initializePipelines(){ const vertexBufferDescriptor = [{ attributes: [ { shaderLocation: 0, offset: 0, format: "float32x2" }, { shaderLocation: 1, offset: 8, format: "float32x2" } ], arrayStride: 16, stepMode: "vertex" }]; const shaderModule = this.#device.createShaderModule({ code: ` struct VertexOut { @builtin(position) position : vec4<f32>, @location(0) uv : vec2<f32> }; @vertex fn vertex_main(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>) -> VertexOut { var output : VertexOut; output.position = vec4<f32>(position, 0.0, 1.0); output.uv = uv; return output; } @fragment fn fragment_main(fragData: VertexOut) -> @location(0) vec4<f32> { return vec4<f32>(1.0, 0.0, 0.0, 1.0); } ` }); const pipelineDescriptor = { label: "pipeline", vertex: { module: shaderModule, entryPoint: "vertex_main", buffers: vertexBufferDescriptor }, fragment: { module: shaderModule, entryPoint: "fragment_main", targets: [ { format: "rgba8unorm" } ] }, primitive: { topology: "triangle-list" }, layout: "auto" }; this.#pipelines.set("main", this.#device.createRenderPipeline(pipelineDescriptor)); } start() { this.renderLoop(); } renderLoop() { requestAnimationFrame((timestamp) => { this.render(timestamp); this.renderLoop(); }); } render() { const commandEncoder = this.#device.createCommandEncoder(); const clearColor = { r: 0, g: 0, b: 0, a: 1 }; const renderPassDescriptor = { colorAttachments: [ { loadValue: clearColor, storeOp: "store", loadOp: "load", view: this.#context.getCurrentTexture().createView() } ] }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); passEncoder.setPipeline(this.#pipelines.get("main")); passEncoder.setVertexBuffer(0, this.#meshes.get("background").vertices); passEncoder.draw(6); //TODO need index buffer passEncoder.end(); this.#device.queue.submit([commandEncoder.finish()]); } } 
Enter fullscreen mode Exit fullscreen mode

Let's break this down:

 constructor(options) { this.#canvas = options.canvas; this.#context = options.canvas.getContext("webgpu"); } 
Enter fullscreen mode Exit fullscreen mode

Hopefully doesn't need explaining. We just get the context.

async initialize(){ this.#adapter = await navigator.gpu.requestAdapter(); this.#device = await this.#adapter.requestDevice(); this.#context.configure({ device: this.#device, format: "bgra8unorm" }); this.initializeMeshes(); this.initializePipelines(); } 
Enter fullscreen mode Exit fullscreen mode

This is why we needed an async initialize and couldn't just use the constructor even though the canvas renderer could. We need to asynchronously get the adapter and device (the important part). We then need to setup the canvas to use the device so its output can be routed there. Then we call two methods to setup the meshes and pipelines. This seemed like the most obvious way to split the work but it'll probably change in future iterations because each mesh should probably be associated to a particular pipeline (once we use different shader passes for different objects) so we'll keep association but loosely.

initializeMeshes(){ //test position + uv const vertices = new Float32Array([ -1.0, -1.0, 0.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 0.0 ]); const vertexBuffer = this.#device.createBuffer({ size: vertices.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); this.#device.queue.writeBuffer(vertexBuffer, 0, vertices); this.#meshes.set("background", { vertices: vertexBuffer, }); } 
Enter fullscreen mode Exit fullscreen mode

This should look somewhat intuitive. We create our list of vertices which are 2D coordinate pairs along with 2D UV coordinates (not currently used). Then we create a buffer object on the GPU and upload the data. Lastly for book-keeping we register the mesh with the map of meshes so if we have multiple ones we can keep track of them.

Now the more interesting part, pipelines.

initializePipelines(){ const vertexBufferDescriptor = [{ attributes: [ { shaderLocation: 0, offset: 0, format: "float32x2" }, { shaderLocation: 1, offset: 8, format: "float32x2" } ], arrayStride: 16, stepMode: "vertex" }]; const shaderModule = this.#device.createShaderModule({ code: ` struct VertexOut { @builtin(position) position : vec4<f32>, @location(0) uv : vec2<f32> }; @vertex fn vertex_main(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>) -> VertexOut { var output : VertexOut; output.position = vec4<f32>(position, 0.0, 1.0); output.uv = uv; return output; } @fragment fn fragment_main(fragData: VertexOut) -> @location(0) vec4<f32> { return vec4<f32>(1.0, 0.0, 0.0, 1.0); } ` }); const pipelineDescriptor = { label: "pipeline", vertex: { module: shaderModule, entryPoint: "vertex_main", buffers: vertexBufferDescriptor }, fragment: { module: shaderModule, entryPoint: "fragment_main", targets: [ { format: "rgba8unorm" } ] }, primitive: { topology: "triangle-list" }, layout: "auto" }; this.#pipelines.set("main", this.#device.createRenderPipeline(pipelineDescriptor)); } 
Enter fullscreen mode Exit fullscreen mode

First is the vertex buffer descriptor. It basically tells the shader program how the vertices are laid out in memory. Basically for each vertex we give it a location index (this determines how it's referenced in the shader), the offset from the element pointer where the data is, and what type it describes. Since we have a float32x2 it's 8 bytes and then after that, the float32x2 for the UVs, but that one has offset 8 because it comes 8 bytes after the element start. The strideis how many bytes advance between each element, so because each element is 16 bytes we advance 16. The stepMode can be either vertex or instance. We haven't used instances yet but they are basically copies of meshes (collections of vertices). We are just using vertices so vertex is what we use here. One thing I will note but not discuss is alignment and padding. Not all offset values are allowed because computers like to read bytes in 4s so you'll get errors if things are unaligned, and you might have empty space to compensate. Sometimes you need re-arrange data to be more efficient within these limits. But we're good here.

Next is the shader module code. This is mostly similar to WebGL. We reference the vertex buffers by location and instead of magic globals they are parameters to a function. The function can be called whatever but it's annotated @vertex for a vertex shader and @fragment for a fragment shader. Another difference is that instead of varying variables we can (must) define a struct to output to collect up all the properties. These will still be computed based on derivatives, they are just a little more tidy. The fragment shader just shades them red in WGSL syntax. The return value of the fragment shader is just the pixel color, however we need to add the @location attribute to tell it which color attachment it renders to. In this case it's 0, which is outputting to the canvas texture.

Next is the pipeline descriptor. Unlike WebGL we do all of it because it's all configurable. We give it a label (for debugging), a vertex shader step with the module, name of the function and the buffer format. Then the fragment with the shader module, name of the function and the output format for the color per target (rgba8unorm is basic 8-bit rgba, but others are allowed). The primitiveis how the geometry is constructed, triangle-list is a list of tringles similar to the WebGL triangle list but there are others that can be a little more efficient. The layout is how we define bind groups. Bind groups are how you pass in data similar to globals in WebGL. They have their own layout and you have to manually pass the data in as buffers. An auto layout is one that's given based on the shader. It's the easiest default but you might have a better one if you do it manually.

Lastly, we name the pipeline and keep track of it.

start() { this.renderLoop(); } renderLoop() { requestAnimationFrame((timestamp) => { this.render(timestamp); this.renderLoop(); }); } 
Enter fullscreen mode Exit fullscreen mode

Should be pretty obvious? Starts the loop with requestAnimationFrame.

render() { const commandEncoder = this.#device.createCommandEncoder(); const clearColor = { r: 0, g: 0, b: 0, a: 1 }; const renderPassDescriptor = { colorAttachments: [ { loadValue: clearColor, storeOp: "store", loadOp: "load", view: this.#context.getCurrentTexture().createView() } ] }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); passEncoder.setPipeline(this.#pipelines.get("main")); passEncoder.setVertexBuffer(0, this.#meshes.get("background").vertices); passEncoder.draw(6); //TODO need index buffer passEncoder.end(); this.#device.queue.submit([commandEncoder.finish()]); } 
Enter fullscreen mode Exit fullscreen mode

To start a render we need a command encoder which just encodes the commands to send to the GPU. We need a render pass which has a few properties. The attachments are basically what comes out of the pass, colors are obvious but there might be other things like stencil or depth buffers. Here it's just color. The storeOp and loadOp are what happens when the value is loaded or saved. Since we want a blank canvas we clear it out on load but if it was a second pass we might want to preserve it. On store we just save the value. The view is where it's output to which is the canvas's view.

We then set the pipeline, load the vertex data and then draw some number of primitives. Since this was just a quad created from 2 triangles it's 6 total vertices. Then we tell it we're done, and then encode those command and pas them over the GPU to render.

There's a lot of extra stuff but in some ways it's a lot simpler because you are more in control and there's less magic.

This will render a red screen. We can now easily swap out implementations if we want.

Code

https://github.com/ndesmic/geo/releases/tag/v0.1

Top comments (0)