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

WebGPU notes   /   04

WebGPU Cubemaps in Rust: Skyboxes and Reflections

A cubemap stores an environment in six square images. This Rust and wgpu example samples one cubemap to draw both a skybox and a reflective sphere. It brings together cube texture views, WGSL reflection vectors, and the depth settings that keep the background behind the scene.

Cubemap faces
6
Sphere vertices
2,145
Draw calls
2

One environment, two uses

In the 2D texture example, UV coordinates selected a point in an image. Here I use a 3D direction to select a point in an environment. The cubemap lookup chooses the appropriate face and the position within it.

The demo draws a skybox around the camera, then a sphere that reflects the same bridge and river scene. Both are visible together and share one cubemap. The camera is fixed at (0, 0, 3.6); there are no camera controls or automatic animation. Rendering runs through sib::render.

A reflective sphere mirrors a suspension bridge, river, and blue evening sky, surrounded by the same cubemap skybox.
The background and the sphere sample the same cubemap with different directions. The sphere’s curved normals bend the reflected view of the bridge.

The skybox uses 36 vertices that each store only a position. The sphere is generated with 64 segments and 32 rings, producing 2,145 vertices and 12,288 indices. Its vertices contain position and normal, with a stride of 24 bytes. No UV attribute is needed for this reflection lookup.

Six faces and their loading order

A cubemap covers the positive and negative directions of the X, Y, and Z axes. The current skybox loader reads six bundled Bridge2 textures from assets/textures/skybox/bridge2. Each is a 1024 × 1024 image stored in an uncompressed RGBA8 KTX1 file.

The order is part of the data contract. Each image becomes the corresponding array layer of the GPU texture:

Scroll sideways to see all table columns.

Cubemap face order in the texture array
Array layerDirectionAsset
0Positive X (+X)posx.ktx
1Negative X (−X)negx.ktx
2Positive Y (+Y)posy.ktx
3Negative Y (−Y)negy.ktx
4Positive Z (+Z)posz.ktx
5Negative Z (−Z)negz.ktx

The native example reads the files from the checkout. The browser fetches them from the site’s shared assets directory. All six faces are loaded and decoded before the renderer starts. The KTX decoder extracts the base image into RGBA8 pixels; this path is not a general compressed KTX2 loader.

When replacing the environment, use six square faces with matching dimensions and compatible orientations. A swapped or rotated face creates a discontinuity in the environment even when the texture upload succeeds.

Create and bind a cube texture view

Initialization in the Rust example passes the decoded faces to the cubemap helper:

let cubemap_texture = texture::Texture::from_rgba8_cube(
    &context.device,
    &context.queue,
    Some("runtime skybox cubemap"),
    &cubemap_images,
)?;

The helper used by this checkout creates a TextureDimension::D2 texture with six array layers in Rgba8UnormSrgb format. It uploads each face to a different layer, then exposes those layers through a TextureViewDimension::Cube view.

The cube view is what lets the shader sample by direction. The underlying storage is six 2D layers, not a volume texture. This helper creates one mip level and uses linear minification and magnification filters with ClampToEdge sampler addressing. It does not generate a mip chain or prefilter the environment for rough reflections.

Both pipelines share bind group 0: binding 0 holds the uniforms, binding 1 the cube view, and binding 2 the sampler. The corresponding texture declarations in WGSL are:

@group(0) @binding(1)
var cubemap_texture: texture_cube<f32>;

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

The uniform buffer is visible to both vertex and fragment stages. Besides the transformation matrices used by the vertex shaders, it contains the camera position used by the sphere’s fragment shader.

Keep the skybox at the far plane

A skybox should feel distant as the camera moves. The example removes translation from the skybox view matrix while retaining its rotation. This keeps the environment centered on the camera, with no translation parallax.

The skybox vertex shader then places its visible geometry at the far depth value:

let clip = uniforms.skybox_view_projection
    * vec4<f32>(input.position, 1.0);

output.clip_position = clip.xyww;
output.direction = input.position;

Writing clip.xyww sets clip space Z equal to W. After perspective division, depth is 1.0. The pass clears depth to 1.0, and the skybox uses LessEqual with depth writes disabled. It can therefore fill the background without blocking nearer geometry.

The skybox fragment shader samples the cubemap using normalize(input.direction). The renderer draws it first, then switches to the sphere pipeline, which also uses LessEqual but enables depth writes. Both draws happen in one render pass.

Reflect the environment in WGSL

For the sphere, the vertex shader passes world position and world normal to the fragment shader. The fragment shader normalizes the interpolated normal and computes a direction from the camera toward the surface:

let normal = normalize(input.world_normal);
let view_direction = normalize(
    input.world_position - uniforms.camera_position.xyz
);
let reflection_direction = reflect(view_direction, normal);
let environment = textureSample(
    cubemap_texture, cubemap_sampler, reflection_direction
).rgb;

reflect redirects that incident vector around the surface normal. The resulting vector selects a color from the environment. Keeping position, camera position, and normal in world space makes the calculation consistent.

The shader also brightens the reflection near grazing angles. It cubes a term derived from the view angle, blending a darker, tinted sample toward a brighter sample. This approximates the angle dependence of Fresnel reflection without a full physically based material model. The output alpha is opaque.

The reflection comes from a fixed set of images. It cannot include newly added scene objects or reproduce the parallax of nearby reflected geometry. Those effects need additional techniques such as updated reflection captures or ray tracing.

Run and modify the example

From the repository root with Rust installed, run the native version:

cargo run --example texturecubemap

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 texturecubemap
cargo run --bin serve

Open http://127.0.0.1:8080/texturecubemap/. Keep the bundled skybox assets in place; the build copies them into the web output. Live rendering needs WebGPU support, while the article and screenshot remain readable without it.

These changes are useful for exploring the example:

  • Change camera_position in Uniforms::new to compare the skybox view with the sphere’s reflection from another viewpoint.
  • Return vec4<f32>(environment, 1.0) after sampling to isolate the reflection from the tint and edge brightening.
  • Reduce SPHERE_SEGMENTS and SPHERE_RINGS to see how mesh resolution changes the silhouette.

The current model uses uniform scale. If you introduce nonuniform scale, transform normals with an appropriate normal matrix so the reflection directions remain correct.