Part 5 - Textures and Image Loading Image

#Part 5 - Textures and Image Loading

Elliot Forbes Elliot Forbes · May 29, 2026 · 4 min read

A triangle is a great proof-of-concept, but most real graphics involve images. In this part we load a PNG, upload it to the GPU as a texture, and sample it onto a textured quad.

Add the image crate to Cargo.toml:

Cargo.toml
[dependencies]
wgpu = "0.20"
winit = "0.29"
pollster = "0.3"
env_logger = "0.11"
log = "0.4"
bytemuck = { version = "1.14", features = ["derive"] }
image = "0.25"

Place any PNG image at assets/happy-tree.png in your project root. Any image will work — we’ll use it as the texture.

From Triangle to Quad

A quad is just two triangles forming a rectangle. We could define six vertices (two sets of three), but that wastes memory — the two triangles share two vertices. An index buffer solves this: we define four unique vertices and an index list that tells the GPU which vertices to connect for each triangle.

const VERTICES: &[Vertex] = &[
    Vertex { position: [-0.5,  0.5, 0.0], tex_coords: [0.0, 0.0] },
    Vertex { position: [-0.5, -0.5, 0.0], tex_coords: [0.0, 1.0] },
    Vertex { position: [ 0.5, -0.5, 0.0], tex_coords: [1.0, 1.0] },
    Vertex { position: [ 0.5,  0.5, 0.0], tex_coords: [1.0, 0.0] },
];

const INDICES: &[u16] = &[0, 1, 2, 0, 2, 3];

Two triangles: [0, 1, 2] and [0, 2, 3]. The index buffer lets us reuse vertices 0 and 2.

UV Coordinates

We replaced the color field with tex_coords — a pair of floats in the 0–1 range that map each vertex to a point on the texture. (0, 0) is the top-left of the image, (1, 1) is the bottom-right.

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
struct Vertex {
    position: [f32; 3],
    tex_coords: [f32; 2],
}

Loading the Image

The image crate handles decoding. to_rgba8() converts whatever format the PNG uses into a consistent 4-channel 8-bit format the GPU understands:

let img = image::open("assets/happy-tree.png")
    .unwrap()
    .to_rgba8();
let (img_width, img_height) = img.dimensions();

Creating the Texture

let texture_size = wgpu::Extent3d {
    width: img_width,
    height: img_height,
    depth_or_array_layers: 1,
};

let diffuse_texture = device.create_texture(&wgpu::TextureDescriptor {
    label: Some("Diffuse Texture"),
    size: texture_size,
    mip_level_count: 1,
    sample_count: 1,
    dimension: wgpu::TextureDimension::D2,
    format: wgpu::TextureFormat::Rgba8UnormSrgb,
    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
    view_formats: &[],
});

Rgba8UnormSrgb matches the output of to_rgba8() and tells the GPU to treat the values as sRGB colour, which gives correct linear-light rendering.

Uploading Pixel Data

queue.write_texture(
    wgpu::ImageCopyTexture {
        texture: &diffuse_texture,
        mip_level: 0,
        origin: wgpu::Origin3d::ZERO,
        aspect: wgpu::TextureAspect::All,
    },
    &img,
    wgpu::ImageDataLayout {
        offset: 0,
        bytes_per_row: Some(4 * img_width),
        rows_per_image: Some(img_height),
    },
    texture_size,
);

bytes_per_row is 4 * width because each pixel is four bytes (R, G, B, A). This layout tells wgpu how to interpret the flat byte slice as a 2D image.

The Sampler

A sampler defines how the GPU reads the texture when a UV coordinate falls between pixels or outside the 0–1 range:

let diffuse_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
    address_mode_u: wgpu::AddressMode::ClampToEdge,
    address_mode_v: wgpu::AddressMode::ClampToEdge,
    address_mode_w: wgpu::AddressMode::ClampToEdge,
    mag_filter: wgpu::FilterMode::Linear,
    min_filter: wgpu::FilterMode::Nearest,
    mipmap_filter: wgpu::FilterMode::Nearest,
    ..Default::default()
});

ClampToEdge repeats the edge pixel when UVs go outside 0–1. Linear magnification blends neighbouring pixels when the texture is scaled up, producing a smoother result than Nearest.

Updating the Shader

@group(0) @binding(0)
var t_diffuse: texture_2d<f32>;
@group(0) @binding(1)
var s_diffuse: sampler;

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    return textureSample(t_diffuse, s_diffuse, in.tex_coords);
}

textureSample takes a texture, a sampler, and UV coordinates, and returns the interpolated colour at that position.

Drawing with an Index Buffer

The draw call changes slightly — we bind the index buffer and use draw_indexed instead of draw:

render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
render_pass.draw_indexed(0..self.num_indices, 0, 0..1);

Running It

cargo run

You should see the image displayed on a quad in the centre of the window. Try replacing the PNG with a different image to see it update.

What’s Next

In Part 6 we add a transformation matrix so we can rotate and scale the quad every frame.

Further reading:

FAQ

What is a UV coordinate? A UV coordinate is a 2D position in texture space, ranging from (0, 0) at the top-left to (1, 1) at the bottom-right. Each vertex gets a UV, and the GPU interpolates between them to sample the correct part of the texture for each pixel.

What is a texture sampler? A sampler defines the rules for reading a texture — what happens at the edges (clamping, wrapping, mirroring) and how neighbouring texels are blended when the texture is scaled (nearest-neighbour vs linear filtering).

What is an index buffer? An index buffer is a list of integers that tells the GPU which vertices to use for each triangle. It avoids duplicating shared vertices, reducing memory usage and the amount of data the vertex shader processes.

What does to_rgba8 do? It converts a decoded image into a consistent 8-bit-per-channel RGBA format, regardless of the source format (RGB, greyscale, PNG palette, etc.). This gives a predictable byte layout to upload to the GPU.