Time to step up our game and draw some triangles. We'll start simple and skip indexing for now.
First, we need a model to store the vertices we load from JSON:
export interface Vertex {
x: number;
y: number;
z: number;
}
We also need a class that will serve as both an asset and a renderable object. It stores the vertex array and
a reference to the GPU buffer the vertices will be uploaded to. The isUploaded flag lets the
renderer know whether the mesh has already been uploaded to the GPU:
export class Mesh {
vertices: Vertex[];
vertexBuffer!: GPUBuffer;
isUploaded: boolean = false;
constructor(vertices: Vertex[]) {
this.vertices = vertices;
}
}
To render the mesh we will use the following shaders:
@vertex
fn vs_main(@location(0) position: vec3) -> @builtin(position) vec4
{
return vec4(position, 1);
}
@fragment
fn fs_main() -> @location(0) vec4
{
return vec4(1, 1, 1, 1);
}
The vertex shader accepts positions already in NDC space and passes them through unchanged. The fragment shader paints every pixel white.
To load assets we'll use the AssetLoader class:
export class AssetLoader {
static async loadMesh(url: string): Promise {
const vertices: Vertex[] = await this.loadVertices(url);
return new Mesh(vertices);
}
private static async loadVertices(url: string): Promise {
const response = await fetch(AssetLoader.resolvePath(url));
const vertices: Vertex[] = await response.json();
return vertices;
}
static async loadShader(url: string): Promise {
const file = await fetch(AssetLoader.resolvePath(url));
const text = await file.text();
return text;
}
private static resolvePath(url: string) {
const result = new URL(url, import.meta.url).href;
return result;
}
}
loadMesh delegates the actual fetching to the private loadVertices method and wraps
the result in a Mesh instance. loadShader fetches the shader source as plain text.
The private helper resolvePath ensures assets can be located via relative paths regardless of
where the script is loaded from.
Now let's implement the renderer:
export class Renderer {
private static readonly NUM_COORDS_PER_VERTEX = 3;
private static readonly VERTEX_SIZE = Renderer.NUM_COORDS_PER_VERTEX * Float32Array.BYTES_PER_ELEMENT;
private shaderCode: string;
private canvas: HTMLCanvasElement;
private context!: GPUCanvasContext;
private device!: GPUDevice;
private textureFormat!: GPUTextureFormat;
private pipeline!: GPURenderPipeline;
private onInitSuccessful: () => void;
private onDeviceLost: (info: GPUDeviceLostInfo) => void;
static async create(
canvas: HTMLCanvasElement,
onInitSuccessful: () => void,
onDeviceLost: (info: GPUDeviceLostInfo) => void) {
const shaderCode = await AssetLoader.loadShader("shaders/shaders.wgsl");
const renderer = new Renderer(canvas, shaderCode, onInitSuccessful, onDeviceLost);
await renderer.init();
return renderer;
}
...
}
Since initialization involves asynchronous operations, we can't do it in the constructor. Instead, we expose a
static create method that asynchronously loads the shader code, passes it along with the other
parameters to a newly created renderer instance, and then calls the asynchronous init method.
Besides the shader code, we pass a canvas to extract the rendering context from and to monitor its dimensions for resizing, plus two callbacks — one for successful initialization and one for device loss.
async init() {
if (!navigator.gpu) {
throw new Error("WebGPU is not supported.");
}
const context = this.canvas.getContext('webgpu');
if (!context) {
throw new Error("Failed to acquire WebGPU context.");
}
this.context = context;
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("Failed to request GPU adapter.")
}
this.device = await adapter.requestDevice();
this.device.lost.then(async (info) => {
this.onDeviceLost(info);
try {
await this.init();
} catch (error) {
console.error("Failed to reinitialize the renderer after device loss:", error);
}
});
const observer = new ResizeObserver(() => this.resize());
observer.observe(this.canvas);
this.textureFormat = navigator.gpu.getPreferredCanvasFormat();
this.context.configure({
device: this.device,
format: this.textureFormat
});
const shaderModule = this.createShaderModule(this.shaderCode);
this.pipeline = this.createPipeline(shaderModule, this.textureFormat);
this.onInitSuccessful();
}
We start initialization by checking the navigator.gpu property — if it exists, WebGPU is
supported.
if (!navigator.gpu) {
throw new Error("WebGPU is not supported.");
}
Next we acquire a WebGPU context from the canvas:
const context = this.canvas.getContext('webgpu');
if (!context) {
throw new Error("Failed to acquire WebGPU context.");
}
Once we have the context, we request a GPUAdapter from navigator.gpu. A
GPUAdapter represents a physical device — think of it as your graphics card.
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("Failed to request GPU adapter.")
}
From the GPUAdapter we request a logical device. Since a logical device can be lost unexpectedly,
we attach a handler to the device.lost promise. The handler notifies the outside world via the
onDeviceLost callback and attempts to reinitialize the renderer.
this.device = await adapter.requestDevice();
this.device.lost.then(async (info) => {
this.onDeviceLost(info);
try {
await this.init();
} catch (error) {
console.error("Failed to reinitialize the renderer after device loss:", error);
}
});
We need to monitor the canvas dimensions, so we create a ResizeObserver and pass it a callback
that invokes our resize method.
const observer = new ResizeObserver(() => this.resize());
observer.observe(this.canvas);
Let's take a closer look at what the resize method does:
private resize() {
const dpr = window.devicePixelRatio || 1;
const width = Math.floor(this.canvas.clientWidth * dpr);
const height = Math.floor(this.canvas.clientHeight * dpr);
if (this.canvas.width === width && this.canvas.height === height) {
return;
}
this.canvas.width = width;
this.canvas.height = height;
this.context.configure({
device: this.device,
format: this.textureFormat
});
}
DPR (device pixel ratio) is the ratio between the physical pixels on your monitor and the logical pixels
(CSS pixels) used to define the dimensions of HTML elements. In other words:
DPR = PhysicalPixels / LogicalPixels.
The width and height properties define canvas dimensions in physical pixels. To set
them correctly, we multiply the canvas's logical dimensions (clientWidth,
clientHeight) by the DPR and floor the result:
const dpr = window.devicePixelRatio || 1;
const width = Math.floor(this.canvas.clientWidth * dpr);
const height = Math.floor(this.canvas.clientHeight * dpr);
We only apply new dimensions if they actually changed, because resizing a canvas is a relatively expensive operation:
if (this.canvas.width === width && this.canvas.height === height) {
return;
}
this.canvas.width = width;
this.canvas.height = height;
We need to reconfigure the context because resizing the canvas invalidates its swap chain:
this.context.configure({
device: this.device,
format: this.textureFormat
});
Back in init, we configure the canvas rendering context by providing it with the logical device
and the optimal texture format obtained from navigator.gpu.getPreferredCanvasFormat():
this.textureFormat = navigator.gpu.getPreferredCanvasFormat();
this.context.configure({
device: this.device,
format: this.textureFormat
});
Then we compile the shader and create the pipeline:
const shaderModule = this.createShaderModule(this.shaderCode);
this.pipeline = this.createPipeline(shaderModule, this.textureFormat);
createShaderModule simply calls the corresponding method on the logical device:
private createShaderModule(code: string): GPUShaderModule {
return this.device.createShaderModule({ code: code });
}
Let's dissect the createPipeline method:
private createPipeline(shaderModule: GPUShaderModule, textureFormat: GPUTextureFormat): GPURenderPipeline {
const pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [] });
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: Renderer.VERTEX_SIZE,
stepMode: "vertex",
attributes: [
{
shaderLocation: 0,
offset: 0,
format: "float32x3"
}
]
};
return this.device.createRenderPipeline({
vertex: {
module: shaderModule,
entryPoint: "vs_main",
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: "fs_main",
targets: [{ format: textureFormat }],
},
primitive: {
topology: "triangle-list",
frontFace: "cw",
cullMode: "back"
},
layout: pipelineLayout,
});
}
To create a pipeline, we first need a pipeline layout:
const pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [] });
We also need a layout for our vertex buffer:
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: Renderer.VERTEX_SIZE,
stepMode: "vertex",
attributes: [
{
shaderLocation: 0,
offset: 0,
format: "float32x3"
}
]
};
The layout consists of the following properties:
arrayStride— The number of bytes to advance per step. Our buffer stores 3 coordinates per vertex, so for each vertex we read all three.stepMode— Defines whether we advance to the next chunk of data per vertex or per instance. We're not using instancing, but even if we were, we'd still read 3 coordinates per vertex withstepModeset to"vertex".attributes— Describes the specific vertex attributes contained in the buffer.
We have a single attribute. Let's go over its properties:
shaderLocation— The attribute's binding location inside the vertex shader.offset— The offset in bytes from the start of a stride.format— The vertex attribute format. We have 3 32-bit floating-point numbers per vertex, so we set it tofloat32x3, which corresponds to thevec3type used in our shader.
With everything in place, we can now create the pipeline:
return this.device.createRenderPipeline({
vertex: {
module: shaderModule,
entryPoint: "vs_main",
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: "fs_main",
targets: [{ format: textureFormat }],
},
primitive: {
topology: "triangle-list",
frontFace: "cw",
cullMode: "back"
},
layout: pipelineLayout,
});
Let's go over each property of the pipeline descriptor:
vertex— Configures the vertex stage.moduleholds our shader module,entryPointis the name of the shader's entry function, andbuffersis where we provide the vertex buffer layouts.fragment— Configures the fragment stage. It also hasmoduleandentryPointfields. We assign the same shader module to both stages because our module contains both shaders. Thetargetsfield specifies the color formats of the textures the fragment shader renders into — since we render only to the canvas's underlying frame texture, we put its format there.primitive— Describes the primitive type and how it is rasterized.topologytells the GPU how to assemble primitives from the provided vertices; we use"triangle-list", which means every three consecutive vertices form an independent triangle.frontFacedefines which winding order is considered the front face of a triangle —"cw"means clockwise.cullModetells the GPU to discard back-facing triangles, which saves us from rendering geometry that would never be visible anyway.layout— The pipeline layout we created earlier.
Now let's look at the rendering side. Before submitting a draw call, we check whether the mesh has already
been uploaded to the GPU. If not, we call uploadMesh first:
renderMesh(mesh: Mesh) {
if (!mesh.isUploaded) {
this.uploadMesh(mesh);
}
this.device.queue.submit([this.createCommandBuffer(mesh.vertexBuffer)]);
}
uploadMesh creates the vertex buffer from the mesh's vertex array and sets the
isUploaded flag to prevent redundant uploads on subsequent frames:
uploadMesh(mesh: Mesh) {
mesh.vertexBuffer = this.createVertexBuffer(mesh.vertices);
mesh.isUploaded = true;
}
Inside createVertexBuffer, we allocate the GPU buffer:
private createVertexBuffer(vertices: Vertex[]): GPUBuffer {
const vertexBuffer = this.device.createBuffer({
size: vertices.length * Renderer.VERTEX_SIZE,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
});
const vertexBufferPtr = new Float32Array(vertexBuffer.getMappedRange());
for (let i = 0; i < vertices.length; ++i) {
vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 0] = vertices[i].x;
vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 1] = vertices[i].y;
vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 2] = vertices[i].z;
}
vertexBuffer.unmap();
return vertexBuffer;
}
createBuffer accepts a GPUBufferDescriptor. We're interested in three of its
properties:
size— The size of the buffer in bytes.usage— How the buffer will be used.GPUBufferUsage.VERTEXmarks it as a vertex buffer.mappedAtCreation— Whentrue, the buffer is mapped immediately upon creation, allowing CPU-side writes before it's handed off to the GPU.
Since the buffer is mapped, we obtain a typed view into its memory and fill it with vertex data:
const vertexBufferPtr = new Float32Array(vertexBuffer.getMappedRange());
for (let i = 0; i < vertices.length; ++i) {
vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 0] = vertices[i].x;
vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 1] = vertices[i].y;
vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 2] = vertices[i].z;
}
Once the data is written, we unmap the buffer to hand it back to the GPU:
vertexBuffer.unmap();
With the mesh uploaded, we need to record and submit a command buffer. Let's look at
createCommandBuffer:
private createCommandBuffer(vertexBuffer: GPUBuffer): GPUCommandBuffer {
const commandEncoder = this.device.createCommandEncoder();
const texture = this.context.getCurrentTexture();
const view = texture.createView();
const pass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: view,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: "clear",
storeOp: "store"
}
]
});
pass.setVertexBuffer(0, vertexBuffer);
pass.setPipeline(this.pipeline);
pass.draw(vertexBuffer.size / Renderer.VERTEX_SIZE);
pass.end();
return commandEncoder.finish();
}
We start by creating a command encoder, then get a reference to the canvas's current frame texture and create a view into it:
const commandEncoder = this.device.createCommandEncoder();
const texture = this.context.getCurrentTexture();
const view = texture.createView();
Then we begin the render pass:
const pass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: view,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: "clear",
storeOp: "store"
}
]
});
beginRenderPass accepts a GPURenderPassDescriptor. Right now we only need its
colorAttachments property — a list of textures the render pass will write pixels into. We don't
pass the texture directly; instead we use the view created from it.
view— A view into the target texture.clearValue— The color used by the clear operation.loadOp— The operation performed on the attachment's existing contents at the start of the render pass. We use"clear", which fills it with the color specified byclearValue.storeOp— The operation performed on the resulting pixels at the end of the render pass.
Inside the render pass, we bind the vertex buffer and the pipeline, issue the draw call, and then end the pass:
pass.setVertexBuffer(0, vertexBuffer);
pass.setPipeline(this.pipeline);
pass.draw(vertexBuffer.size / Renderer.VERTEX_SIZE);
pass.end();
Finally we finalize the command buffer and return it:
return commandEncoder.finish();
Once we have the command buffer, we submit it to the device queue for asynchronous execution by the GPU:
this.device.queue.submit([this.createCommandBuffer(mesh.vertexBuffer)]);
Now let's put everything together in the App class:
export class App {
private frameRequestId!: number;
private renderer!: Renderer;
private mesh!: Mesh;
async Run(canvas: HTMLCanvasElement) {
this.mesh = await AssetLoader.loadMesh("assets/models/vertices.json");
try {
this.renderer = await Renderer.create(
canvas,
() => this.start(),
(info) => this.onDeviceLost(info)
);
} catch (error) {
console.error("Failed to create renderer:", error);
}
}
private onDeviceLost(info: GPUDeviceLostInfo) {
console.warn(`Device Lost: ${info.message}`);
this.stop();
this.mesh.isUploaded = false;
}
private frame = (time: number) => {
try {
this.renderer.renderMesh(this.mesh);
} catch (error) {
console.error("Failed to render mesh:", error);
}
this.frameRequestId = requestAnimationFrame(this.frame);
};
private start() {
this.frameRequestId = requestAnimationFrame(this.frame);
}
private stop() {
cancelAnimationFrame(this.frameRequestId);
}
}
The Run method loads the mesh from JSON and instantiates the renderer:
async Run(canvas: HTMLCanvasElement) {
this.mesh = await AssetLoader.loadMesh("assets/models/vertices.json");
try {
this.renderer = await Renderer.create(
canvas,
() => this.start(),
(info) => this.onDeviceLost(info)
);
} catch (error) {
console.error("Failed to create renderer:", error);
}
}
The device loss handler stops the render loop and resets the isUploaded flag so the mesh will be
re-uploaded once the renderer is reinitialized:
private onDeviceLost(info: GPUDeviceLostInfo) {
console.warn(`Device Lost: ${info.message}`);
this.stop();
this.mesh.isUploaded = false;
}
The frame function renders the mesh and schedules its next execution:
private frame = (time: number) => {
try {
this.renderer.renderMesh(this.mesh);
} catch (error) {
console.error("Failed to render mesh:", error);
}
this.frameRequestId = requestAnimationFrame(this.frame);
};
And a pair of methods to start and stop the loop:
private start() {
this.frameRequestId = requestAnimationFrame(this.frame);
}
private stop() {
cancelAnimationFrame(this.frameRequestId);
}
Finally, the entry point:
async function main() {
const canvas = document.querySelector("canvas")!;
const app = new App();
await app.Run(canvas);
}
await main();
And here's what we just built: