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:
binding— The binding slot number, matching the@bindingattribute in the shader.visibility— Which shader stages can access this resource. Both entries are visible to the fragment stage only, since texturing happens there.sampler/texture— The type descriptor for the resource. We declare a filtering sampler at binding 0 and a float texture at binding 1.
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" },
]
};
- The first attribute is the position — 3 floats starting at byte offset 0, bound to shader location 0.
- The second attribute is the UV coordinate — 2 floats starting at byte offset 12 (right after the 3 position floats at 4 bytes each), bound to shader location 1.
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.topologyis set to"triangle-list", meaning every three indices from the index buffer form an independent triangle.frontFacedefines which winding order is considered the front face —"cw"means clockwise.cullModetells the GPU to discard back-facing triangles, saving us from rendering geometry that would never be visible.layout— The pipeline layout we created earlier.
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:
TEXTURE_BINDING— Allows the texture to be bound to a shader via a bind group.COPY_DST— Allows the texture to be the destination of a copy operation, which is required bycopyExternalImageToTexture.RENDER_ATTACHMENT— Required bycopyExternalImageToTextureinternally for certain image sources and formats.
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.
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 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: