FLIGHTSIM-RENDER — sun/PBR/sky/fog + shadows + reflective water (design + guide + for-idiots)
Ratified design:
/home/rickard/.cache/claude-build/flightsim-design.md. Shipped on branchagent/map3d-flightsim. The goal: make the wgpu OSMmap3dread like a modern flight-sim — lighting, shadows, materials, fog, water, sky on simple BLOCK buildings (buildings stay blocks; the quality is light + water, NOT geometry).Framing LAW: vello is 2D only (labels/HUD). Every bit of 3D quality is wgpu pipelines + WGSL feeding the existing
OffscreenColorDepthtarget, then blitted; vello draws 2D chrome on top. See.nornir/render-engine.md§L0/L1.
Design — the pass list (per frame, GPU lane)
[shadow depth pass] → 2048² Depth32 shadow map (sun's view of the mesh)
[reflection pass] → half-res colour (opaque mesh, MIRROR camera) (Ground tier + water)
[sky pass] → fullscreen analytic gradient + HDR sun disc/glow (sun tint
← physical transmittance LUT when one is wired in, Phase 3)
main mesh pass → offscreen colour+depth (PBR + sun + sky + height-fog + PCF shadows)
[water pass] → over the offscreen colour (Gerstner + Fresnel + depth-tint + foam)
blit → egui colour pass (scissored to the widget rect)
Phase 1 — the lighting that sells it (map3d.wgsl, sky.rs)
- Directional sun + ambient + sky tint.
SunSky(sky.rs) is the single model shared by the GPU shader + CPU painter:dir(surface→sun), warmsun_color+intensity, coolsky_colorambient. Presetsnoon()/golden_hour()/from_angles(). - PBR (Cook-Torrance GGX).
map3d.wgsl::fsreplaces the oldabs(dot)with a metallic-roughness GGX BRDF (NDF·G·F / 4·NdotV·NdotL) + an analytic sky-hemisphere ambient. Blocks read matte/realistic, glass-ish where roughness is low. - Cascaded shadow map. A depth-only render of the mesh from the sun (
shadow_vs+SunSky::light_view_proj, one tight ortho cascade fitted to the[-1,1]³box). The mainfsPCF-samples it (3×3, slope-scaled bias) and gates the direct sun term → blocks cast real shadows on the street + each other. The single biggest realism win. - Aerial perspective. The fog
mixgained a height haze term (exp(-k·(0.25-y))·exp(-0.3·eye_alt)) tied to the sun colour + a filmic tonemap so daylight reads bright.
Phase 2 — reflective water (the headline, water.wgsl, extrude.rs)
Surface::Watertag (extrude.rs):natural=water/coastlinepolys route to a tessellated water grid (build_with_water→WaterMesh) aty = WATER_HEIGHT, NOT the opaque ground cover.- Water pass (
water.wgsl): 3 summed Gerstner waves displace the grid by atimeuniform (analytic normal); the fragment = Schlick Fresnel blend of a reflection sample vs a depth-tinted refraction (shallow→deep), sun specular glint, and foam at the shoreline + wave crests. No depth attachment — it SAMPLES the offscreen depth (a manual occlusiondiscardso buildings hide the lake). - Planar reflection pass: the opaque mesh re-rendered with the mirror camera (
OrbitCamera::view_proj_mirrored, reflected abouty = WATER_HEIGHT) into a half-res target the water samples by projective lookup (refl_vp). - Altitude LOD trigger (
lod.rs):METRES_PER_DESIGN_UNIT = world_span_m / DESIGN_FILL(from the extruder's Mercator span) turns the camera's design-unitdistance_to_surface()into metres.< 300 m → LodTier::Ground → reflective water ON(with 300/360 m hysteresis so it never flickers).state_jsonemitsaltitude_m,lod_tier,water_active.
Phase 3 — physical-sky transmittance LUT sampling (sky.wgsl, gpu.rs, FLIGHT-SIM #7)
-
The compute pass it consumes.
facett-map3d-flight's GPU scattering-LUT pass precomputes atmospheric transmittance-to-sun into anRgba32Floattexture, oracle- validated vs the CPUTransmittanceLut. This phase wires that texture into the live sky pass so it draws on screen (the compute pass was landed but unsampled before). -
Sky-pass sampling (
sky.wgsl). The skybox fragment gained atexture_2d<f32>LUT- a non-filtering (nearest) sampler —
Rgba32Floatis not filterable without thefloat32-filterablefeature. When the LUT-on flag is set (S.lut.x > 0.5), the sun disc/glow is multiplied by the transmittance sampled at(u = μ_sun·0.5+0.5, v = alt_norm), so the sun physically reddens as it drops to the horizon. The vertical sky gradient is untouched — only the sun tint is physical.
- a non-filtering (nearest) sampler —
-
The device-neutral seam. A
wgpu::Texturecan't cross devices, so the seam is raw texels:SkyTransmittanceLut::from_rgb(mu, h, &gpu_lut.texels, alt_norm)→Map3D::set_sky_transmittance_lut(...). The renderer re-uploads the texels onto the render device (Map3dRenderer::ensure_sky_lut) and rebinds the sky bind group. -
Additive / no-regression (L2). A 1×1 all-ones (transmittance = 1) default LUT is always bound, so with no LUT set the sky is byte-identical to the pre-wiring analytic pass (proven: the unit-LUT frame changes 0 px). The embeddable / CPU-fallback path is the default; the physical LUT is opt-in.
Guide — how to drive it
use facett_map3d::{Map3D, SunSky};
let m = Map3D::from_ways("city", &ways, 0.3)
.with_gpu(true) // GPU lane (host installs gpu::install_renderer)
.with_sun(SunSky::noon()) // or golden_hour() / from_angles(az, el)
.with_shadows(0.85) // 0 = off (cheap/wasm); 0..1 = cast-shadow darkness
.with_planar_reflection(true); // false = wasm sky-fallback water
// Water switches on by ALTITUDE automatically (< 300 m). Override for tests:
let m = m.with_water_override(Some(true));
Physical-sky transmittance LUT (Phase 3): hand the flight crate's GPU scattering-LUT readback to the sky pass and the sun reddens physically at low elevation.
use facett_map3d::SkyTransmittanceLut;
// gpu_lut = facett_map3d_flight::GpuScatterLut::new(&device).precompute(&device,&queue,32,128,24);
let lut = SkyTransmittanceLut::from_rgb(gpu_lut.mu_size as u32, gpu_lut.h_size as u32,
&gpu_lut.texels, /*alt_norm*/ 0.0);
let m = m.with_sky_transmittance_lut(lut); // or m.set_sky_transmittance_lut(lut)
// m.clear_sky_transmittance_lut(); // → analytic sun tint (the default)
// m.sky_transmittance_lut_active() // state_json-style oracle
- Calibration:
m.metres_per_design_unit(),m.altitude_m(),m.lod_tier(),m.water_active(). All instate_jsontoo. - Determinism:
set_clock(t)feeds the wavetimeso PNG blobs are stable.
Tests (every render feature ships an emitter + a PNG blob oracle you LOOK at)
| Feature | Test | Emitter row | Blob |
| calibration + LOD | src/lod.rs (unit) |
— (inject-assert) | — |
| sun/PBR/sky/fog | tests/flightsim_phase1.rs |
phase1_sun_pbr_sky_fog |
/tmp/map3d_phase1_noon.png |
| shadows | tests/flightsim_shadows.rs |
shadow_cascade_darkens_ground |
/tmp/map3d_shadows_on.png |
| water + 300 m + Fresnel | tests/flightsim_water.rs |
reflective_water_below_300m, water_fresnel_and_wasm_fallback |
/tmp/map3d_water_on.png |
| physical-sky LUT (Phase 3) | tests/sky_lut_proof.rs (on-screen, adapter-gated) + gpu.rs::sky_lut_wiring_… (GPU-less RED) |
sky_lut_onscreen, sky_lut_wiring |
/tmp/map3d_sky_lut_red.png |
Run: CARGO_TARGET_DIR=… cargo test -p facett-map3d --features wgpu,testmatrix
For idiots
- "buildings look cheap" — they're BLOCKS on purpose. Don't add house geometry. Tune
SunSky/with_shadows/ water instead. Lighting sells it. - "the water isn't showing" — it only runs below ~300 m altitude (
LodTier::Ground). Dolly in, orwith_water_override(Some(true)). And the data needs anatural=waterpolygon (checkstate_json["water_triangles"] > 0). - "the sun won't redden / the physical sky does nothing" — the sky pass uses the analytic sun tint UNTIL you wire a LUT:
with_sky_transmittance_lut(...)(Phase 3). With no LUT a 1×1 all-ones default is bound, so the sky is intentionally byte-identical to the analytic pass — that's the CPU-fallback / embeddable default, not a bug. And the sun only reddens where it's in frame (the tint multiplies the sun disc/glow, not the gradient). - "300 m is the wrong zoom" — that's the
METRES_PER_DESIGN_UNITcalibration. It's derived from the data's lon/lat span; the de-risk test islod::tests::altitude_and_tier_cross_at_the_300m_boundary. - "shadow acne / peter-panning" — the slope-scaled bias is in
shadow_factor(shader) + the depth-passDepthBiasState. Nudge those, not the geometry. -
wasm/WebGL2: no compute is used, so the native passes mostly work;
with_shadows(0)with_planar_reflection(false)is the cheap path (water keeps Gerstner+Fresnel+depth+ foam with a sky-gradient reflection). If the GPU lane is unavailable entirely, the CPU painter (lit, depth-sorted) still draws — never a blank pane.