Now that you're here, we're going to learn how to render a bunch of points in WebGPU. To do that, we need to build a simple app.
First, we're going to create an interface to load our vertices from JSON. Why JSON? Honestly, I'm not sure — everything is JSON nowadays. Here it is:
export interface Vertex {
x: number;
y: number;
z: number;
}
Next, let's create a model class that will hold a reference to the vertex array we're going to load from
JSON. We'll name it Geometry. The vertices will be uploaded to a GPU buffer the first time
our model is exposed to a renderer, so let's add a field for the vertex buffer as well:
export class Geometry {
vertices: Vertex[];
vertexBuffer: GPUBuffer | null = null;
constructor(vertices: Vertex[]) {
this.vertices = vertices;
}
}
Now that we have the models for storing vertex data, let's write some 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 shader code is pretty much self-explanatory. Our vertices are already in NDC space, so the vertex shader does nothing but accept and pass them through. The fragment shader paints them white.
We also need a couple of functions to load geometry and shader code. Since we're going with OOP, let's
put them in a dedicated class called AssetLoader:
export class AssetLoader {
static async loadGeometry(url: string): Promise {
const response = await fetch(AssetLoader.resolvePath(url));
const vertices: Vertex[] = await response.json();
return new Geometry(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): string {
const result = new URL(url, import.meta.url).href;
return result;
}
}
The private helper method resolvePath ensures that JavaScript can locate assets via relative
paths, even when the script is loaded from an HTML file in a different directory.
Now it's time to implement the most important component of our app — 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 we need to call asynchronous methods during setup, we can't initialize the renderer in its
constructor. Instead, we expose a static create method:
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;
}
Inside the method we asynchronously load the shader code, pass it along with the other parameters to a
newly created renderer instance, and then call 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.
private 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.");
}
The next step is to acquire a WebGPU context from the canvas, the same way we used to do it with WebGL.
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);
Now let's take a closer look at what the resize method does:
private resize() {
const dpr = window.devicePixelRatio || 1;
const width = Math.round(this.canvas.clientWidth * dpr);
const height = Math.round(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 round to the nearest integer:
const dpr = window.devicePixelRatio || 1;
const width = Math.round(this.canvas.clientWidth * dpr);
const height = Math.round(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 that will be used for rendering and the optimal texture format obtained from
navigator.gpu.getPreferredCanvasFormat():
this.textureFormat = navigator.gpu.getPreferredCanvasFormat();
this.context.configure({
device: this.device,
format: this.textureFormat,
});
Now it's time to compile our shader:
const shaderModule = this.createShaderModule(this.shaderCode);
Here is the method itself. It simply calls createShaderModule 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: "point-list" },
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: "point-list" },
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 what we're drawing: points, lines, or triangles. Thetopologyfield tells the GPU how to assemble primitives from the provided vertices. We set it to"point-list", which means each vertex is rendered as an individual point with no assembly step.layout— The pipeline layout we created earlier.
Now let's take a closer look at the rendering method:
renderGeometry(geometry: Geometry) {
if(geometry.vertexBuffer === null) {
this.uploadGeometry(geometry);
}
this.device.queue.submit([this.createCommandBuffer(geometry.vertexBuffer!)]);
}
Before rendering we check whether vertexBuffer is null. A null
value means this is the first call and the vertices haven't been uploaded to the GPU yet. In that case
we call uploadGeometry:
uploadGeometry(geometry: Geometry) {
geometry.vertexBuffer = this.createVertexBuffer(geometry.vertices);
}
uploadGeometry does one thing: it calls createVertexBuffer with the vertices
from the geometry instance and assigns the result to geometry.vertexBuffer.
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;
}
Inside createVertexBuffer, we allocate the GPU buffer:
const vertexBuffer = this.device.createBuffer({
size: vertices.length * Renderer.VERTEX_SIZE,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true,
});
createBuffer accepts a GPUBufferDescriptor object. 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 newly created buffer is mapped, we obtain a pointer to its memory:
const vertexBufferPtr = new Float32Array(vertexBuffer.getMappedRange());
With the pointer in hand, we fill the buffer with vertex data:
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:
vertexBuffer.unmap();
With the vertices uploaded to the GPU buffer, we need to create a command buffer:
private createCommandBuffer(vertexBuffer: GPUBuffer): GPUCommandBuffer {
const commandEncoder = this.device.createCommandEncoder();
const texture = this.context.getCurrentTexture();
const view = texture.createView();
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: view,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: "clear",
storeOp: "store",
}
],
});
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.setPipeline(this.pipeline);
renderPass.draw(vertexBuffer.size / Renderer.VERTEX_SIZE);
renderPass.end();
return commandEncoder.finish();
}
We start by creating a command encoder:
const commandEncoder = this.device.createCommandEncoder();
Then we get a reference to the canvas texture (the frame texture):
const texture = this.context.getCurrentTexture();
And create a view into it:
const view = texture.createView();
Then we begin the render pass:
const renderPass = 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:
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.setPipeline(this.pipeline);
And issue the draw call:
renderPass.draw(vertexBuffer.size / Renderer.VERTEX_SIZE);
Then we end the render pass:
renderPass.end();
And finalize the command buffer:
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(geometry.vertexBuffer!)]);
We have all the components. Let's put them together in the App class:
export class App {
private frameRequestId!: number;;
private renderer!: Renderer;
private geometry!: Geometry;
async Run(canvas: HTMLCanvasElement) {
this.geometry = await AssetLoader.loadGeometry("assets/models/points.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.geometry.vertexBuffer = null;
}
private frame = (time: number) => {
try {
this.renderer.renderGeometry(this.geometry);
} catch (error) {
console.error("Failed to render geometry:", error);
}
this.frameRequestId = requestAnimationFrame(this.frame);
};
private start() {
this.frameRequestId = requestAnimationFrame(this.frame);
}
private stop() {
cancelAnimationFrame(this.frameRequestId);
}
}
The Run method loads geometry from JSON and instantiates the renderer:
async Run(canvas: HTMLCanvasElement) {
this.geometry = await AssetLoader.loadGeometry("assets/models/points.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 invalidated vertex buffer to
null:
private onDeviceLost(info: GPUDeviceLostInfo) {
console.warn(`Device Lost: ${info.message}`);
this.stop();
this.geometry.vertexBuffer = null;
}
The frame function renders the geometry and schedules its next execution:
private frame = (time: number) => {
try {
this.renderer.renderGeometry(this.geometry);
} catch (error) {
console.error("Failed to render geometry:", 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: