Terrain DEM — reasoning (why it's built this way)
Design-only notes live in the module docs (src/terrain.rs) and the roadmap (.nornir/flightsim-engine-roadmap.md §6). This file is the rationale — the choices behind the real-elevation terrain (roadmap §6, the "flat ground today" depth item), and what is deliberately left for the next pass.
Why SRTM .hgt as the "real DEM source"
The task asked for a real DEM format (GeoTIFF / heightmap tile / open Swedish dataset) plus a synthetic fallback. We chose SRTM .hgt because:
- It is a real, ubiquitous, open DEM format (NASA SRTM / the same layout AWS Terrain-Tiles and viewfinderpanoramas ship): raw big-endian
i16metres,N×Nsamples over a 1°×1° cell, SW corner in the filename (N59E017.hgt). - It parses with zero extra dependencies (just
i16::from_be_bytesover the bytes). A GeoTIFF path would drag intiff/geotiff+zune/flatedecoders and CRS handling — weight the lean CPU-foundation crate deliberately avoids (the crate compiles without a graphics adapter and keepseguioptional). Nis inferred from the byte length, so both 1-arc-second (3601²) and 3-arc-second (1201²) tiles load through one path.- SRTM covers Sweden up to 60°N (the SRTM 56°S–60°N band); north of that the synthetic fallback or a future Lantmäteriet GSD-Höjddata 2 m loader (the roadmap's stated Sweden primary) fills in. Adding a second
Dem::from_*constructor later is additive — theDemgrid +elevation_at+ mesh builder are format-agnostic.
Lantmäteriet GSD-Höjddata (the §6 "primary for Sweden") is a licensed/bulk GeoTIFF product; wiring its loader is a follow-up (a new Dem::from_geotiff behind an optional feature) — the grid/sampler/mesh contract here is exactly what it feeds.
Why bilinear (not nearest / bicubic)
Bilinear is the standard heightmap filter: continuous (no stair-steps that would read as terraces when flying low), cheap, and exact for a linear field — which gives a hard, non-fuzzy unit-test oracle (bilinear_is_exact_on_a_linear_ramp). Bicubic would smooth ridgelines slightly better but costs 16 taps + overshoot handling; not worth it before the GPU CDLOD pass exists.
Frame choice — Y-up render vertices (the subtle bit)
The f64 world frame (origin::WorldPos) is (x=easting, y=northing, z=altitude). But the render/camera frame is Y-up: camera_fly::FlightCamera::up() is +Y and roadmap §6 literally says the terrain shader displaces along y. So the mesh builder rebases in the world frame and then maps to render axes [easting, elevation, northing] — elevation is the Y axis. This keeps the terrain consistent with the existing camera basis and the shared facett_map3d::Mesh3 (Y-up), so a TerrainMesh::to_mesh3() drops straight into the reused wgpu pipeline. (The raw origin.rebase output is [easting, northing, elev]; the terrain swaps the last two. Buildings/roads, when they are draped onto terrain in a later pass, must use the same swap — noted for the §3b/§6 drape work.)
Normals from central differences, forced up
Per-vertex normals come from the cross product of the north/east neighbour tangents (central differences on the already-rebased local metres, so the differences are small and precise). We force normal.y ≥ 0 rather than relying on winding, so a lighting bug can't come from an accidental flip; the flat-DEM test pins that a zero- relief field yields straight-up normals (the old flat-plane behaviour as the zero special-case).
Vertical exaggeration knob
build_terrain_mesh(..., vertical_exaggeration) scales elevation. Real-world relief is tiny next to the Mercator horizontal span at Sweden's latitude, so a showcase fly often wants 1.5–3×; the default is 1.0 (true scale). It is a pure multiplier on the sampled height, so it never touches the sampler/DEM correctness.
Determinism / CI (FC-7)
No clock, no RNG, no network. The synthetic ProceduralTerrain is analytic and bounded (base ± amplitude), so CI with no DEM tiles on disk is green and bit-reproducible; TerrainField::load_hgt_or_synthetic is the seam that picks the real tile when present and the synthetic field otherwise (returning bool so a host can log "using synthetic terrain").
What is NOT done here (next pass — stays roadmap §6)
- CDLOD / quadtree subdivision near the camera + skirts to hide LOD seams. This pass builds a single uniform-resolution mesh over a
GeoRegion; the LOD selector and the GPU heightmap-texture displacement (the shader path) sit on top of it. - The drape: offsetting building/road
min_height+heightby the terrain base elevation at their footprint centroid so they don't float/bury once ground ≠ flat.elevation_at(lon, lat)is exactly the query that pass calls; the hook is ready. - Road conforming tessellation (project roads into the terrain eval) vs decals.
- GeoTIFF / Lantmäteriet 2 m loader for the Sweden primary above 60°N.
- Streaming DEM tiles keyed by
SpatialKey(mirror the §5 GeoParquet streamer): aDemper 1° cell in an LRU,elevation_atdispatching to the covering tile. Today oneDem(or the procedural field) covers the flown region.
Bench note
benches/heavy_terrain.rs is HEAVY (full cores, large tile) — parts + rollup: .hgt parse (3601² = 1-arc-second), bilinear + procedural sampling throughput (1 M points), and the full mesh build (default 1024² grid ≈ 1.05 M verts / 2.1 M tris). Grid side overridable via MAP3D_FLIGHT_BENCH_GRID. Numbers + machine (cores/mem) recorded in the crate benchmarks doc / the agent report.