Drawing a shape with indices in WebGPU

January 11, 2025

In this tutorial we're going to draw a shape using indexed rendering. Instead of repeating vertex data for every triangle, we define each unique vertex once and then describe the triangles as sets of indices pointing into that vertex array. This lets us reuse vertices across multiple triangles, which reduces memory usage and is the standard approach for rendering any non-trivial geometry.

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 model to represent a face. A face is a triangle defined by three indices — each one pointing to a vertex in the vertex array:

export interface Face {
    a: number;
    b: number;
    c: number;
}

The Mesh class holds both the vertex array and the face array, along with references to their respective GPU buffers. As before, the isUploaded flag lets the renderer know whether the mesh has already been uploaded to the GPU:

export class Mesh {
    vertices: Vertex[];
    faces: Face[];

    vertexBuffer!: GPUBuffer;
    indexBuffer!: GPUBuffer;
    isUploaded: boolean = false;

    constructor(vertices: Vertex[], faces: Face[]) {
        this.vertices = vertices;
        this.faces = faces;
    }
}

To render the mesh we will use the following shaders:

@vertex
fn vs_main(@location(0) position: vec3<f32>) -> @builtin(position) vec4<f32>
{
    return vec4(position, 1);
}

@fragment
fn fs_main() -> @location(0) vec4<f32>
{
    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.

The AssetLoader class now loads both vertices and faces from separate JSON files and combines them into a Mesh:

export class AssetLoader {
    static async loadMesh(verticesUrl: string, facesUrl: string): Promise<Mesh> {
        const vertices: Vertex[] = await this.loadVertices(verticesUrl);
        const faces: Face[] = await this.loadFaces(facesUrl);
        return new Mesh(vertices, faces);
    }

    private static async loadVertices(url: string): Promise<Vertex[]> {
        const response = await fetch(AssetLoader.resolvePath(url));
        const vertices: Vertex[] = await response.json();
        return vertices;
    }

    private static async loadFaces(url: string): Promise<Face[]> {
        const response = await fetch(AssetLoader.resolvePath(url));
        const faces: Face[] = await response.json();
        return faces;
    }

    static async loadShader(url: string): Promise<string> {
        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 fetching to the private loadVertices and loadFaces methods and combines their results into 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 static readonly NUM_INDICES_PER_FACE = 3;
    private static readonly FACE_SIZE = Renderer.NUM_INDICES_PER_FACE * Uint16Array.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;
    }
    ...
}

The renderer gains two new constants alongside the existing vertex ones — NUM_INDICES_PER_FACE and FACE_SIZE — which we'll use when allocating and filling the index buffer. Everything else about initialization follows the same pattern: since setup involves asynchronous operations, we can't do it in the constructor, so we expose a static create method that loads the shader code and calls the asynchronous init method.

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:

We have a single attribute. Let's go over its properties:

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:

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, mesh.indexBuffer)]);
}

uploadMesh creates both the vertex buffer and the index buffer, then sets the isUploaded flag to prevent redundant uploads on subsequent frames:

uploadMesh(mesh: Mesh) {
    mesh.vertexBuffer = this.createVertexBuffer(mesh.vertices);
    mesh.indexBuffer = this.createIndexBuffer(mesh.faces);
    mesh.isUploaded = true;
}

The vertex buffer is created the same way as before — we allocate a mapped GPU buffer and fill it with the vertex coordinates:

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;
}

The index buffer follows the same pattern, but stores unsigned 16-bit integers instead of floats — one per index, three per face:

private createIndexBuffer(faces: Face[]): GPUBuffer {
    const indexBuffer = this.device.createBuffer({
        size: faces.length * Renderer.FACE_SIZE,
        usage: GPUBufferUsage.INDEX,
        mappedAtCreation: true
    });

    const indexBufferPtr = new Uint16Array(indexBuffer.getMappedRange());
    for (let i = 0; i < faces.length; ++i) {
        const face = faces[i];
        indexBufferPtr[i * Renderer.NUM_INDICES_PER_FACE + 0] = face.a;
        indexBufferPtr[i * Renderer.NUM_INDICES_PER_FACE + 1] = face.b;
        indexBufferPtr[i * Renderer.NUM_INDICES_PER_FACE + 2] = face.c;
    }

    indexBuffer.unmap();
    return indexBuffer;
}

createBuffer accepts a GPUBufferDescriptor. The properties are the same as for the vertex buffer, with two differences:

With the mesh uploaded, we need to record and submit a command buffer. Let's look at createCommandBuffer:

private createCommandBuffer(vertexBuffer: GPUBuffer, indexBuffer: 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.setIndexBuffer(indexBuffer, "uint16");
    pass.setPipeline(this.pipeline);
    pass.drawIndexed(indexBuffer.size / Uint16Array.BYTES_PER_ELEMENT);
    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.

Inside the render pass, we bind both buffers and the pipeline. Note that we now also call setIndexBuffer, passing the index buffer and "uint16" to tell the GPU what integer format our indices use:

pass.setVertexBuffer(0, vertexBuffer);
pass.setIndexBuffer(indexBuffer, "uint16");
pass.setPipeline(this.pipeline);

Instead of draw, we now call drawIndexed. The argument is the total number of indices, which we derive by dividing the index buffer size by the size of a single uint16 element:

pass.drawIndexed(indexBuffer.size / Uint16Array.BYTES_PER_ELEMENT);

Then we end the render pass and finalize the command buffer:

pass.end();
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, mesh.indexBuffer)]);

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",
            "assets/models/faces.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 now loads both the vertex and face data from their respective JSON files before instantiating the renderer:

async Run(canvas: HTMLCanvasElement) {
    this.mesh = await AssetLoader.loadMesh(
        "assets/models/vertices.json",
        "assets/models/faces.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:

The source code is available here.