Drawing a shape with a texture in WebGPU

January 11, 2025

In this tutorial we're going to apply a texture to a shape. Texturing is the process of mapping an image onto geometry — each vertex carries UV coordinates that tell the GPU which part of the image to sample when shading a given pixel. We'll cover loading the image, uploading it to the GPU, setting up a sampler and a bind group, and wiring everything together through the shader.

Our vertex now carries UV coordinates in addition to position, so let's update the interface to reflect that:

export interface Vertex {
    x: number;
    y: number;
    z: number;
    u: number;
    v: number;
}

The Face interface stays the same — a triangle defined by three vertex indices:

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

The Texture class holds both the CPU-side ImageBitmap we load from disk and the GPU-side GPUTexture the renderer will upload it into:

export class Texture {
    imageBitmap: ImageBitmap;
    texture!: GPUTexture;

    constructor(imageBitmap: ImageBitmap) {
        this.imageBitmap = imageBitmap;
    }
}

The Mesh class now holds a Texture alongside the vertex and face arrays:

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

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

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

To render the mesh we will use the following shaders:

struct VertexOutput {
    @builtin(position) position: vec4<f32>,
    @location(0) uv: vec2<f32>,
};

@group(0) @binding(0)
var diffuseSampler: sampler;

@group(0) @binding(1)
var diffuseTexture: texture_2d<f32>;

@vertex
fn vs_main(
    @location(0) position: vec3<f32>,
    @location(1) uv: vec2<f32>
) -> VertexOutput {
    var out: VertexOutput;
    out.position = vec4(position, 1.0);
    out.uv = uv;
    return out;
}

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    return textureSample(diffuseTexture, diffuseSampler, in.uv);
}

The vertex shader now accepts two attributes — position and UV coordinates — and passes both to the fragment stage via a VertexOutput struct. The fragment shader samples the texture at the interpolated UV coordinate using the provided sampler, and outputs the resulting color.

The sampler and texture are declared at the top of the shader and bound at group 0, bindings 0 and 1 respectively. We'll mirror this layout on the CPU side when we set up the bind group.

The AssetLoader class now loads vertices, faces, and a texture, combining them into a Mesh:

export class AssetLoader {
    static async loadMesh(
        verticesUrl: string,
        facesUrl: string,
        textureUrl: string): Promise<Mesh> {

        const vertices: Vertex[] = await this.loadVertices(verticesUrl);
        const faces: Face[] = await this.loadFaces(facesUrl);
        const texture: Texture = await this.loadTexture(textureUrl);
        return new Mesh(vertices, faces, texture);
    }

    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 response = await fetch(AssetLoader.resolvePath(url));
        const text = await response.text();
        return text;
    }

    static async loadTexture(url: string): Promise<Texture> {
        const response = await fetch(AssetLoader.resolvePath(url));
        const blob = await response.blob();
        const imageBitmap = await createImageBitmap(blob);
        return new Texture(imageBitmap);
    }

    private static resolvePath(url: string) {
        const result = new URL(url, import.meta.url).href;
        return result;
    }
}

The new loadTexture method fetches the image file, decodes it into a Blob, and converts it to an ImageBitmap using the browser's built-in createImageBitmap function. An ImageBitmap is a decoded, GPU-ready image that can be uploaded to a GPUTexture directly without any further processing on our part.

Now let's implement the renderer. The most notable addition is a bindGroup field, which will hold the sampler and texture bound for the fragment shader:

export class Renderer {
    private static readonly NUM_COORDS_PER_VERTEX = 5;
    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 bindGroup!: GPUBindGroup;

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

Notice that NUM_COORDS_PER_VERTEX is now 5 instead of 3, since each vertex stores a position (x, y, z) and UV coordinates (u, v).

The init method follows the same structure as before, with one addition — after compiling the shader module, we check it for compilation warnings or errors:

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);
    shaderModule.getCompilationInfo().then((info) => {
        if (info.messages.length > 0) {
            console.warn("Shader compilation info:");
            info.messages.forEach((msg) => console.warn(`${msg.lineNum}:${msg.linePos} - ${msg.message}`));
        }
    });

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

We configure the canvas rendering context with the logical device and the optimal texture format:

this.textureFormat = navigator.gpu.getPreferredCanvasFormat();
this.context.configure({
    device: this.device,
    format: this.textureFormat,
});

After compiling the shader module, we asynchronously query it for any compilation messages and log them to the console. This is useful for catching WGSL errors or warnings that the API would otherwise silently swallow:

const shaderModule = this.createShaderModule(this.shaderCode);
shaderModule.getCompilationInfo().then((info) => {
    if (info.messages.length > 0) {
        console.warn("Shader compilation info:");
        info.messages.forEach((msg) => console.warn(`${msg.lineNum}:${msg.linePos} - ${msg.message}`));
    }
});

The resize method works the same way as before — it scales the canvas to the physical pixel size and reconfigures the context:

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

Let's dissect the createPipeline method. The most significant change here is the introduction of a bind group layout, which describes the resources the shader expects to receive:

private createPipeline(
    shaderModule: GPUShaderModule,
    textureFormat: GPUTextureFormat): GPURenderPipeline {

    const bindGroupLayout = this.device.createBindGroupLayout({
        entries: [
            {
                binding: 0,
                visibility: GPUShaderStage.FRAGMENT,
                sampler: { type: "filtering" },
            },
            {
                binding: 1,
                visibility: GPUShaderStage.FRAGMENT,
                texture: { sampleType: "float" },
            },
        ],
    });

    const pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });
    const vertexBufferLayout: GPUVertexBufferLayout = {
        arrayStride: Renderer.VERTEX_SIZE,
        stepMode: "vertex",
        attributes: [
            { shaderLocation: 0, offset: 0, format: "float32x3" },
            { shaderLocation: 1, offset: 12, format: "float32x2" },
        ]
    };

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

We start by creating a bind group layout that declares the resources our fragment shader expects:

const bindGroupLayout = this.device.createBindGroupLayout({
    entries: [
        {
            binding: 0,
            visibility: GPUShaderStage.FRAGMENT,
            sampler: { type: "filtering" },
        },
        {
            binding: 1,
            visibility: GPUShaderStage.FRAGMENT,
            texture: { sampleType: "float" },
        },
    ],
});

Each entry in the layout corresponds to a binding declared in the shader. Let's go over the entry properties:

We pass the bind group layout to the pipeline layout so the GPU knows what resources to expect at draw time:

const pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] });

The vertex buffer layout now declares two attributes instead of one:

const vertexBufferLayout: GPUVertexBufferLayout = {
    arrayStride: Renderer.VERTEX_SIZE,
    stepMode: "vertex",
    attributes: [
        { shaderLocation: 0, offset: 0, format: "float32x3" },
        { shaderLocation: 1, offset: 12, format: "float32x2" },
    ]
};

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 upload side. uploadMesh now creates the GPU texture and the bind group in addition to the vertex and index buffers:

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

The vertex buffer is filled with 5 floats per vertex now — position followed by UV 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;
        vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 3] = vertices[i].u;
        vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 4] = vertices[i].v;
    }

    vertexBuffer.unmap();
    return vertexBuffer;
}

The index buffer is unchanged — three uint16 indices 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;
}

Let's look at createTexture. It allocates a GPUTexture on the device and copies the ImageBitmap into it:

private createTexture(bitmap: ImageBitmap): GPUTexture {
    const lTexture = this.device.createTexture({
        size: [bitmap.width, bitmap.height, 1],
        format: "rgba8unorm",
        usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
    });
    this.device.queue.copyExternalImageToTexture(
        { source: bitmap },
        { texture: lTexture },
        [bitmap.width, bitmap.height]
    );
    return lTexture;
}

We create the texture with three usage flags:

copyExternalImageToTexture handles the upload from the ImageBitmap to the GPU texture in a single call, taking care of any format conversion needed along the way.

Now let's look at createBindGroup. This is where we create the sampler and bind both it and the texture to the pipeline so the shader can access them:

private createBindGroup(texture: Texture): GPUBindGroup {
    const sampler = this.device.createSampler({
        magFilter: "linear",
        minFilter: "linear",
    });
    return this.device.createBindGroup({
        layout: this.pipeline.getBindGroupLayout(0),
        entries: [
            { binding: 0, resource: sampler },
            { binding: 1, resource: texture.texture.createView() },
        ],
    });
}

We create a sampler with linear filtering for both magnification and minification. Filtering determines how the GPU blends texels when the texture is rendered at a size different from its native resolution — "linear" interpolates between neighbouring texels for a smooth result.

The bind group ties together the concrete resources — sampler and texture view — and maps them to the binding slots declared in the bind group layout. The layout is retrieved directly from the pipeline via getBindGroupLayout(0), ensuring the bind group is always compatible with the pipeline it will be used with.

With the mesh uploaded, we record and submit a command buffer. Note that we now call setBindGroup to bind the sampler and texture before issuing the draw call:

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.setPipeline(this.pipeline);
    pass.setBindGroup(0, this.bindGroup);
    pass.setVertexBuffer(0, vertexBuffer);
    pass.setIndexBuffer(indexBuffer, "uint16");
    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 set the pipeline, bind group, vertex buffer, and index buffer, then issue the indexed draw call:

pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.setVertexBuffer(0, vertexBuffer);
pass.setIndexBuffer(indexBuffer, "uint16");
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",
            "assets/textures/texture.png");

        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 vertices, faces, and a texture before instantiating the renderer:

async Run(canvas: HTMLCanvasElement) {
    this.mesh = await AssetLoader.loadMesh(
        "assets/models/vertices.json",
        "assets/models/faces.json",
        "assets/textures/texture.png");

    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.