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

WebGPU notes   /   27

WebGPU Parallax Occlusion Mapping in Rust with wgpu

Load a four-vertex glTF plane and two uncompressed KTX textures, march through up to 48 uniform depth layers against an alpha-derived height field, refine the surface crossing, and light the shifted color and normal samples without changing the mesh.

Height layers
48
Plane triangles
2
Asset requests
3

Simulate rocky depth on a flat plane

The previous WebGPU PBR image-based-lighting example changes a sphere's reflections with environment data. Parallax Mapping instead asks how much apparent surface detail a fragment shader can recover from one flat rectangle. The mesh still contains two triangles. Its rocky relief comes from moving texture coordinates along the tangent-space view direction before the color and normal maps are sampled.

The introducing parallax mapping commit added the example, plane, textures, shader, screenshots, and web registration. The current Rust source and WGSL shader include several later corrections: an obsolete clip-space adjustment was removed, glTF UVs now pass through without a V flip, and the tangent-frame and intersection fix honors tangent.w while preserving signed crossing depths. Rendering runs through sib::render.

Oblique WebGPU view of rocky cobblestones with simulated depth from parallax occlusion mapping.
An oblique camera makes the UV offset visible: a flat two-triangle plane appears to contain rows of deep rocks. The base mesh stays planar; discarded UVs can trim its rectangular outline inward but cannot extend it or create side walls.

Load three runtime assets

The example submits one batch to the shared asset loader. Native code reads the three local files on separate worker threads. WebAssembly starts an asset Worker, resolves the browser-relative URLs, fetches them concurrently with Promise.all, and transfers the buffers back before starting the renderer.

Scroll sideways to see all table columns.

Parallax Mapping runtime asset requests in batch order
RequestAssetFile bytesEncoded contentsDecoded or uploaded bytesShader role
1plane.gltf2,652JSON with one 204-byte Base64 buffer216-byte GPU meshFour tangent-space vertices and six indices
2rocks_color_rgba.ktx5,592,5441024×1024 KTX1 RGBA8; 11 mips5,592,404 texel bytesRock color with derivative-selected mip filtering
3rocks_normal_height_rgba.ktx4,194,4041024×1024 KTX1 RGBA8; one mip4,194,304 texel bytesTangent normal in RGB and inverse height in alpha

The three requests transfer 9,789,600 bytes, about 9.336 MiB. The 5,690-byte shader and 122,752-byte Vazirmatn font are embedded into the executable rather than requested at runtime. The model, textures, and 960×540 screenshots are byte-identical to the versions in the introducing commit.

The example's focused KTX1 parser accepts little-endian, uncompressed GL_RGBA8 input and uploads it as Rgba8Unorm. The color image includes the complete 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, and 1-pixel mip chain. The combined normal-height image contains only its 1024×1024 base. This is not a compressed KTX2 or general-purpose texture pipeline.

Decode and upload the plane glTF

The glTF has one default scene, one node, one mesh, and one indexed triangle primitive. Its four positions form a ten-unit square in the XZ plane, from −5 through 5 with Y = 0. Rust traverses the scene, bakes node transforms into each attribute, and scales the complete model by 0.2, producing a world-space square from −1 through 1.

The custom loader requires positions, normals, tangents, and texture coordinate set zero. It accepts triangle primitives and embedded Base64 data-URI buffers, converts the source six u16 indices to u32, and rejects external buffers and GLB binary chunks.

Scroll sideways to see all table columns.

Parallax Mapping geometry and explicit uniform buffers
BufferItemsStride or sizeGPU payloadUpdate cadencePurpose
Plane vertices448 bytes192 bytesInitialization onlyPosition, UV, normal, and four-component tangent
Plane indices64-byte u3224 bytesInitialization onlyTwo triangle-list primitives
Vertex uniforms1 block160 bytes160 bytesEvery update and resizeView-projection, model, light, and camera
Fragment uniforms1 block16 bytes16 bytesInitialization onlyHeight scale, bias, layers, and mapping mode

These example-owned buffers total only 392 bytes. The two sampled textures contribute 9,786,708 logical texel bytes, while the surface-size Depth32Float image adds four bytes per output pixel. CPU copies of the parsed KTX mips and glTF data are dropped after initialization.

Build the tangent-space coordinate frame

Parallax movement must follow the texture surface rather than world axes. The vertex shader transforms the supplied normal and three-component tangent by the model matrix, then reconstructs the bitangent with the glTF handedness sign:

let normal = normalize(model3 * input.normal);
let tangent = normalize(model3 * input.tangent.xyz);
let bitangent = normalize(cross(normal, tangent))
    * input.tangent.w;
let tbn = transpose(mat3x3<f32>(
    tangent, bitangent, normal,
));

The transposed basis transforms light position, camera position, and fragment position into tangent space. The fragment stage can then subtract those positions to construct view and light directions in the same coordinate system as the normal-height texture.

The current implementation deliberately preserves the glTF UV values. A former 1 - V conversion reversed the tangent-space V direction and made the parallax march travel incorrectly. A later shader fix multiplied the reconstructed bitangent by tangent.w, preserving handedness for mirrored UV islands. This plane uses a uniform model scale, so multiplying its normal by the model's upper-left 3×3 block gives the expected direction; a nonuniformly scaled model would need an inverse-transpose normal matrix.

Trace 48 height layers in WGSL

The fragment uniform defaults to height scale 0.1, parallax bias −0.02, 48 layers, and mapping mode 4. The bias belongs only to mode 2's basic parallax offset and does not affect the visible mode 4 path. The value is uploaded once and the demo exposes no input or user interface, so every visible frame uses parallax occlusion mapping. Other branches remain in WGSL for code comparison.

Scroll sideways to see all table columns.

Mapping modes implemented by the Parallax Mapping shader
ModeNameUV operationMaximum height-search readsCrossing refinementUsed by demo
0Color onlyReturns the original color sample immediately0NoneNo
1Normal mappingKeeps the original UV for normal and color0NoneNo
2Basic parallaxApplies one height-scaled view offset1NoneNo
3Steep parallaxMarches equal-depth layers until the ray enters the height field49Uses the first crossed UVNo
4Parallax occlusionRuns the same march and blends the two UVs around the crossing50Linear interpolationYes

height_at returns 1 - alpha from the combined normal-height texture at LOD 0. The 48-layer search gives each layer depth 1 / 48. It divides the view-direction XY offset by max(view.z, 0.001), so a grazing view produces a larger march across the texture.

let layer_depth = 1.0 / f32(layer_count);
let delta_uv = view_dir.xy * uniforms.height_scale /
    (max(view_dir.z, 0.001) * f32(layer_count));

for (var i = 0; i < 128; i = i + 1) {
    if i >= layer_count { break; }
    curr_layer_depth = curr_layer_depth + layer_depth;
    curr_uv = curr_uv - delta_uv;
    height = height_at(curr_uv);
    if height < curr_layer_depth { break; }
}

The loop has a compile-time ceiling of 128 iterations, but the fixed uniform stops it at 48. Parallax occlusion then samples the previous UV, compares the signed depths on each side of the crossing, and mixes the two coordinates. The denominator remains negative and no closer to zero than −0.0001 because the next point is below the surface and the previous point is above it. That signed bound avoids division by a nearly zero value while preserving the crossing interpolation.

Shade the shifted color and normal samples

After mode 4 returns its refined UV, WGSL reads normal RGB from the one-level combined texture and color from the mipmapped color texture. Coordinates outside zero through one are discarded, cutting away fragments whose simulated view ray leaves the authored surface. The repeat-addressed sampler performs those reads before the bounds test, but discarded values do not reach the color target.

The sampled normal is remapped from zero-to-one RGB into −1-to-1 tangent space. Lighting then adds three compact terms: 20% ambient color, Lambert-like diffuse color multiplied by max(N dot L, 0), and a gray Blinn-style highlight with exponent 32 and strength 0.15. A single positional light moves around the plane at radius 1.5 and Y = 2.

Animation time advances by delta_seconds * 0.5 and feeds a full-turn sine and cosine, producing a two-second light orbit. The camera remains at (0, 1.25, -1.5), looks at the origin, and uses a 60° right-handed perspective with near and far planes of 0.1 and 256. There are no camera, light, height, layer, or mode controls.

This lighting is intentionally small and readable. It has no physically based BRDF, light color, intensity, distance attenuation, light-ray self-shadowing, geometric occlusion, HDR intermediate, exposure, or tone mapping. The color KTX is uploaded as linear Rgba8Unorm and the shader applies no source gamma decode, so its color handling is approximate.

Render one plane and a text overlay

One five-entry bind group holds the 160-byte vertex uniform, color texture, combined normal-height texture, filtering sampler, and 16-byte fragment uniform. The color texture's sampler repeats U and V and uses linear magnification, minification, and mip interpolation. A second sampler is created with the normal-height texture wrapper but remains unbound.

Scroll sideways to see all table columns.

Parallax Mapping render passes and submitted draw work
PassAttachmentsPipeline and drawSubmitted workDepth behaviorResult
Parallax sceneSurface color + surface-size depthOne indexed plane draw6 indices; 2 trianglesDepth32Float, write enabled, LessEqualLit rocky surface on near-black clear color
Text overlayLoad surface color; no depthFramework glyph drawGlyph count varies with device and FPS textNo depth attachmentTitle, GPU, FPS, and active mapping mode

The scene pipeline uses triangle-list topology, one sample, no face culling, no blending, and no post-processing. It clears color to (0.02, 0.02, 0.024, 1) and depth to 1 before drawing the six indices once. Apparent rock depth therefore changes fragment sampling cost, not vertex count or submitted triangle count.

The second pass loads the scene color and renders a 22 px Vazirmatn overlay. It shows “Parallax mapping,” GPU adapter information, CPU frame cadence, and “mode: Parallax occlusion mapping.” The displayed FPS refreshes on the framework's roughly 500 ms statistics cadence, while overlay preparation and the scene draw still occur every frame. Resizing recreates depth, updates the view-projection uniform, and rebuilds text placement.

Run and extend the example

From a local checkout with Rust installed, run the native parallax occlusion mapping example:

cargo run --example parallaxmapping

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

Open http://127.0.0.1:8080/parallaxmapping/ in a browser with WebGPU support. Native startup joins its three loader threads before calling sib::render. WebAssembly awaits the Worker batch and starts the same renderer asynchronously. Both targets parse the same source assets, upload the same mesh buffers and decoded texel payloads, and execute the same WGSL and two-pass frame with no rendering fallback.

The demo fixes the search at 48 layers instead of adapting work to view angle. Its normal-height texture has no lower-resolution mips, and the search explicitly samples LOD zero even when the plane recedes. Parallax does not change geometry, extend the base silhouette, or create real side walls; out-of-range UV discard can only clip the rectangle inward. It also provides no light-ray self-shadowing. Near grazing angles, dividing by a view Z clamped to 0.001 can create long UV marches and discarded edges.

The loaders are deliberately narrow: the glTF path expects embedded data and mandatory tangent-space attributes, while the texture path supports uncompressed RGBA8 KTX1 rather than compressed KTX2. Combined with approximate color transfer, fixed Blinn-style lighting, an unattenuated moving light, no MSAA, and no controls, this keeps the algorithm inspectable rather than production-complete.

Useful changes to try:

  • Choose the layer count from view angle, then compare fixed stepping, binary refinement, cone-step mapping, and relief mapping.
  • Add parallax self-shadowing from the light direction and compare its cost with a real displaced or tessellated mesh.
  • Store color in an sRGB-capable format, build filtered normal-height mips with normal renormalization, and inspect distant-surface aliasing.
  • Add orbit-camera and live controls for mapping mode, layer count, height scale, and bias so every implemented branch can be compared.