Loading WebGPU example...
More info
Back to the demo ↑

WebGPU notes   /   03

WebGPU Textures in Rust: Loading Images with wgpu

UV coordinates tell the GPU where to read an image. This Rust and wgpu example loads a PNG at runtime, uploads its pixels to a WebGPU texture, and samples it in WGSL to draw a lit quad. The geometry is simple enough to follow the whole path from image bytes to pixels on screen.

Vertices
4
Triangles
2
Color textures
1

A PNG on a 3D quad

The vertex attributes example showed how positions, normals, and UVs reach a shader. Here I focus on what happens to an image: loading it, creating the GPU resources, and connecting those resources to the fragment shader.

The demo maps the a picture of my old fog, Sib, in PNG format onto a square made from two triangles. The model has a fixed 15° rotation around the Y axis, viewed through a perspective camera. It does not rotate automatically and has no input controls. The application runs through sib::render.

A black and white husky illustration with a blue eye mapped onto a lit quad, angled against a dark background.
The Sib image covers a single quad. Perspective gives it the angled outline, while diffuse and specular lighting change the sampled image’s brightness.

UV coordinates and vertex data

Each vertex in the Rust source contains a position, a pair of texture coordinates, and a normal:

#[repr(C)]
struct Vertex {
    position: [f32; 3],
    uv: [f32; 2],
    normal: [f32; 3],
}

This interleaved layout uses 32 bytes per vertex. Position, UV, and normal start at byte offsets 0, 12, and 20, and feed shader locations 0, 1, and 2. Notice that this attribute order differs from the previous example; the vertex buffer layout and WGSL inputs must agree.

UVs are normalized image coordinates: u runs horizontally and v vertically. In this quad, the top-left corner uses (0, 0), the top-right uses (1, 0), the bottom-left uses (0, 1), and the bottom-right uses (1, 1). The vertex shader passes these values onward, and the rasterizer interpolates them across each triangle for the fragment shader.

The index list [0, 1, 2, 2, 3, 0] reuses the four vertices to form two triangles. All four normals point along positive Z before the model transform. One draw_indexed call draws the quad each frame.

From PNG bytes to a GPU texture

Loading has two stages. First, AssetLoader::fetch_image_rgba8 downloads the PNG and decodes it into RGBA8 pixels in CPU memory. The browser uses an asynchronous fetch; the native example uses a synchronous HTTP request. Both finish loading the image before starting the renderer.

Once a device and queue are available, initialization uploads those decoded pixels:

let sampled_texture = texture::Texture::from_rgba8_2d(
    &context.device,
    &context.queue,
    Some("runtime sib texture"),
    &texture_image,
)?;

The texture helper used by this checkout creates a 2D Rgba8UnormSrgb texture with TEXTURE_BINDING | COPY_DST usage. Those flags allow shader sampling and copying pixels into the texture. It uploads the image with queue.write_texture, preserving the decoded width and height.

The texture has one mip level: the original image. This loading path does not generate smaller versions of the image. Both native and browser runs need access to the image URL; in the browser, its host must also allow the cross-origin fetch.

Texture views, samplers, and bind groups

A texture stores the image data. A texture view selects the part of that texture exposed to a shader. A sampler controls how sampling handles coordinates and filtering. The helper returns all three, and the bind group connects the view and sampler to WGSL alongside a uniform buffer.

Scroll sideways to see all table columns.

Resources in bind group 0
Binding Resource Shader stage
0 Uniform buffer: transforms, camera position, LOD bias Vertex
1 2D color texture view Fragment
2 Filtering sampler Fragment

The sampler uses ClampToEdge addressing and linear minification and magnification filters. UVs beyond the image boundary therefore sample its edge instead of repeating it. Linear filtering blends neighboring texels, which are the pixels stored in the texture. The wgpu::SamplerDescriptor reference describes these settings.

@group and @binding identify resources; @location identifies shader inputs and outputs. The vertex shader reads the uniforms and passes the needed values to the fragment shader, so the uniform binding only needs vertex-stage visibility here.

Texture sampling and lighting in WGSL

The WGSL shader declares the texture and sampler at the bindings from the table:

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

@group(0) @binding(2)
var sampler_color: sampler;

Inside fs_main, one sample retrieves the color at the interpolated UV:

let color = textureSampleBias(
    texture_color, sampler_color, input.uv, input.lod_bias
);

textureSampleBias adjusts the automatically calculated level of detail before sampling. Here the bias is 0.0, and only the original mip level exists. Changing the bias alone cannot create a mip chain; the separate texture mipmap generation demo covers mip chain generation.

The shader then combines the sampled RGB with ambient and diffuse lighting, plus a specular highlight. The light is at (0, 0, 2.5), the same position as the camera. Unlike the previous demo, this one uses the quad’s normal directly and has no normal map.

One detail matters when replacing the image: sampled alpha scales the specular highlight, but the fragment shader returns alpha 1.0. This example does not demonstrate transparent PNG blending. A transparent material would also need an appropriate shader output and blend state.

Run and modify the example

From a local checkout with Rust installed, run the native version:

cargo run --example texture

For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve:

scripts/build-wasm.sh --release texture
cargo run --bin serve

Open http://127.0.0.1:8080/texture/. Live rendering needs WebGPU support and a successful image download; this article and its screenshot remain readable without WebGPU.

Try these small changes in the source:

  • Replace TEXTURE_URL with another reachable PNG to follow the same loading path with a different image.
  • Flip the V coordinates with 1.0 - v to see how UVs control image orientation.
  • Return vec4<f32>(color.rgb, 1.0) after sampling to view the texture without the lighting calculation.

To experiment with tiling, use from_rgba8_2d_with_sampler and set U and V addressing to Repeat before extending the UV range. Extending UVs with the current clamp sampler only stretches the image edges.