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.
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.
| Request | Asset | File bytes | Encoded contents | Decoded or uploaded bytes | Shader role |
|---|---|---|---|---|---|
| 1 | plane.gltf | 2,652 | JSON with one 204-byte Base64 buffer | 216-byte GPU mesh | Four tangent-space vertices and six indices |
| 2 | rocks_color_rgba.ktx | 5,592,544 | 1024×1024 KTX1 RGBA8; 11 mips | 5,592,404 texel bytes | Rock color with derivative-selected mip filtering |
| 3 | rocks_normal_height_rgba.ktx | 4,194,404 | 1024×1024 KTX1 RGBA8; one mip | 4,194,304 texel bytes | Tangent 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.
| Buffer | Items | Stride or size | GPU payload | Update cadence | Purpose |
|---|---|---|---|---|---|
| Plane vertices | 4 | 48 bytes | 192 bytes | Initialization only | Position, UV, normal, and four-component tangent |
| Plane indices | 6 | 4-byte u32 | 24 bytes | Initialization only | Two triangle-list primitives |
| Vertex uniforms | 1 block | 160 bytes | 160 bytes | Every update and resize | View-projection, model, light, and camera |
| Fragment uniforms | 1 block | 16 bytes | 16 bytes | Initialization only | Height 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.
| Mode | Name | UV operation | Maximum height-search reads | Crossing refinement | Used by demo |
|---|---|---|---|---|---|
| 0 | Color only | Returns the original color sample immediately | 0 | None | No |
| 1 | Normal mapping | Keeps the original UV for normal and color | 0 | None | No |
| 2 | Basic parallax | Applies one height-scaled view offset | 1 | None | No |
| 3 | Steep parallax | Marches equal-depth layers until the ray enters the height field | 49 | Uses the first crossed UV | No |
| 4 | Parallax occlusion | Runs the same march and blends the two UVs around the crossing | 50 | Linear interpolation | Yes |
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.
| Pass | Attachments | Pipeline and draw | Submitted work | Depth behavior | Result |
|---|---|---|---|---|---|
| Parallax scene | Surface color + surface-size depth | One indexed plane draw | 6 indices; 2 triangles | Depth32Float, write enabled, LessEqual | Lit rocky surface on near-black clear color |
| Text overlay | Load surface color; no depth | Framework glyph draw | Glyph count varies with device and FPS text | No depth attachment | Title, 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.