WASM/WebGPU vs native parity — the brown cast + the clipping report
Rickard's bug: the OSM-3D GPU render (Map3D with with_gpu(true), the facett-map3d HDR pipeline in src/gpu.rs) looks perfect on desktop native (wgpu / nvidia) but on the browser WASM/WebGPU build it is (1) a brownish / muddy colour cast and (2) shows clipping / z-fighting on the ground terrain and on buildings. This note records the root-cause work, the landed fix, and what still needs a live-browser eyeball.
(1) BROWN — ROOT-CAUSED and FIXED
Root cause: a missing final sRGB encode on the non-sRGB WebGPU surface.
The HDR post stack is map3d.wgsl/water.wgsl/sky.wgsl (write linear radiance to the Rgba16Float offscreen) → bloom.wgsl composite (exposure + ACES tonemap + a pow(1/1.15) lift → an LDR target) → fxaa.wgsl present (the final blit into egui's pass). The composite deliberately writes display-referred colour and relies on the surface carrying the sRGB gamma encode (see the bloom.wgsl composite comment "on top of the LDR target's own sRGB encode").
Both the composite→LDR write and the FXAA present→egui write use render_state.target_format (gpu.rs::PostStack::new, and Map3dRenderer::new(..., render_state.target_format) at the bottom of gpu.rs):
- Native desktop: eframe negotiates a
Bgra8UnormSrgbwindow surface. The hardware sRGB-encodes on write → the display-referred colour is correct. ✅ - Browser WebGPU: the WebGPU canvas preferred format is the non-sRGB
Bgra8Unorm. Nothing encodes → the identical display-referred bytes are shown un-encoded, i.e. too dark, midtones crushed → reads muddy / brownish. ❌
The other BROWN hypothesis — a sky-LUT / ground-albedo texture failing to load on wasm and falling back to a brown default — was ruled out: the sky transmittance LUT is CPU-computed and uploaded (SkyTransmittanceLut::from_rgb), the ground/building albedo is procedural in map3d.wgsl, and there are no runtime image/texture fetches in the GPU path (std::fs in terrain.rs is native-only DEM height loading with a synthetic fallback on wasm — not colour).
The fix (landed, green)
fxaa.wgsl — the final present pass — now sRGB-encodes its output when the target is a non-sRGB surface, gated on a uniform flag (U.texel.z):
gpu.rs::present_encode_srgb(target_format) -> f32— the pure predicate:1.0when the target is not*-srgb(e.g. the browserBgra8Unorm),0.0when it is (nativeBgra8UnormSrgb, hardware encodes). Unit-tested (present_encode_srgb_matches_surface_and_shader_gates_it), red-when-broken.gpu.rs::PostStack.present_encode_srgb— carried into the present uniform'stexel.zinwrite_uniforms(the composite pass ignorestexel, so the slot is free).fxaa.wgsl::linear_to_srgb/present_encode— applies the IEC-61966 encode on every present return path when the flag is set.
Native is kept byte-for-byte identical. The value is set to the format predicate only on wasm (cfg!(target_arch = "wasm32")); on native it is forced 0.0. Rationale: the real native window is always *-srgb (already correct), and the ONLY native non-sRGB consumer is the offscreen egui_kittest test harness (Rgba8Unorm), whose luma goldens are calibrated to the raw un-encoded path. Un-gating the encode on native flips those goldens (verified: e.g. flightsim_shadows's ground-pixel classifier drops from >5000 to ~920 px because every colour shifts). Scoping the workaround to wasm gives the browser parity while the native app AND every headless proof stay stable and green.
Note / future cleanup: because the harness renders to a non-sRGB offscreen, it currently does NOT exercise the encode. If the harness is ever switched to an
*-srgboffscreen (a more faithful proxy of the real desktop surface), un-gate the encode (drop thecfg!(wasm), usepresent_encode_srgb(target_format)directly) and recalibrate the luma-threshold goldens to the encoded output.
(2) CLIPPING / z-fighting — investigated, NOT a depth-format divergence
Checked every suspect the report named; the depth pipeline is portable and identical native↔WebGPU, so it is not the root cause:
- Depth format:
facett_core::render::gpu::offscreen::DEPTH_FORMAT = **Depth32Float**— a WebGPU core (mandatory) format. Same on both. - No reverse-Z:
camera.rs::view_projbuilds a standard forward-Z perspective with clip-z in wgpu's[0,1](Lessdepth compare,depth_write_enabled), andview_proj_matches_project_and_orders_depthasserts the[0,1]range. No native depth-range assumption. - Near/far:
NEAR_PLANE = 0.02,far_plane = distance + 4— plain values, same on both targets. - Depth is sampled correctly for WebGPU: the water/SSR bind uses
TextureSampleType::Depth+Comparison/NonFilteringsamplers; the SSAO gbuffer and theRgba32FloatLUT usefilterable: false+NonFiltering. No illegal filterable-float-depth sampling (which a strict browser would reject). - No divergent GPU features: every
create_devicein the map/flight/core GPU code requestsFeatures::empty()— noDEPTH_CLIP_CONTROL/unclipped_depththat native could have and WebGPU lack.
Most likely explanation (needs a browser eyeball to confirm): the perceived "clipping / z-fighting" is largely a secondary effect of the (now-fixed) sRGB bug. The missing encode crushed the tonal range and fed FXAA the wrong (un-encoded) luma, so coplanar seams (building base ↔ ground, terrain LOD joins) and aliased silhouettes flickered far more visibly than on native, reading as "clipping". The sRGB fix restores FXAA's luma and the tonal range and should markedly reduce it. Any residual true z-fighting is a depth-precision tuning question (large far/near ratio on Depth32Float) that is identical on native and web and would be tuned globally, not a browser-only fix.
Build / test status
- Native:
cargo test -p facett-map3d --features wgpu— green (100 lib + all integration tests, incl. the new sRGB parity test). - WASM:
cargo build --target wasm32-unknown-unknown -p facett-demo --no-default-features --features map-wgpu,map3d-wgpu— builds clean (the fix compiles for wasm; thecfg!(wasm)branch activates the encode there).
Needs a live-browser eyeball (follow-up)
- Confirm the colour cast is gone in a real WebGPU browser and the perceived clipping is resolved (or isolate any residual true z-fighting).
- Enabling the GPU OSM-3D on the web is a separate, browser-gated step: on
mainthe deployed wasm demo runs eframe's glow/WebGL + CPU painters (the mapwith_gpuopt-in andinstall_rendererare native-only). Rickard's WebGPU scenario runs the fixer'sbench_fixenablement (e45dc3f: eframe wgpu backend on wasm +map-wgpu,map3d-wgpu), whichc6f3edfthen paired with a WebGL2 fallback (wgpuwebglfeature) after hitting a black canvas on WebGPU-less browsers. That backend switch is the risky, eyeball-required part and is deliberately NOT flipped here; this fix lands in the engine so the WebGPU render is colour-correct whenever that path is turned on.