surtgis_relief_3d/lib.rs
1//! # `surtgis-relief-3d`
2//!
3//! Native wgpu 3D viewer for SurtGis shaded relief. Renders a DEM as a
4//! displaced, textured mesh in a native window (winit) or a browser
5//! canvas (WebGPU / WebGL2 fallback).
6//!
7//! Status: **M1 spike** — skeleton only. Builds a wgpu pipeline and
8//! renders a 1024×1024 textured plane (1M vertices) to validate
9//! production-workload performance. M2 wires real DEM heights and the
10//! `surtgis-relief` texture; M3 adds lighting; M4 ships WASM/browser;
11//! M5 adds headless screenshots. See `SPEC_SURTGIS_RELIEF_3D.md`.
12
13use thiserror::Error;
14
15pub mod camera;
16pub mod lod;
17pub mod martini;
18pub mod mesh;
19pub mod pipeline;
20
21#[cfg(not(target_arch = "wasm32"))]
22pub mod headless;
23
24#[cfg(not(target_arch = "wasm32"))]
25pub mod native;
26
27#[cfg(target_arch = "wasm32")]
28pub mod web;
29
30#[derive(Debug, Error)]
31pub enum ReliefError {
32 #[error("wgpu adapter request failed: no compatible GPU found")]
33 NoAdapter,
34 #[error("wgpu device request failed: {0}")]
35 Device(String),
36 #[error("surface creation failed: {0}")]
37 Surface(String),
38 #[error("window event loop error: {0}")]
39 EventLoop(String),
40}
41
42pub type Result<T> = std::result::Result<T, ReliefError>;
43
44/// Vertex layout. Position + UV + normal — 8 floats per vertex.
45/// Normals are computed at "baseline" vertical exaggeration; the shader
46/// re-orients them per-frame when the user changes the runtime
47/// `vertical_scale` uniform.
48#[repr(C)]
49#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
50pub struct Vertex {
51 pub position: [f32; 3],
52 pub uv: [f32; 2],
53 pub normal: [f32; 3],
54}
55
56impl Vertex {
57 pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
58 array_stride: std::mem::size_of::<Self>() as wgpu::BufferAddress,
59 step_mode: wgpu::VertexStepMode::Vertex,
60 attributes: &[
61 wgpu::VertexAttribute {
62 offset: 0,
63 shader_location: 0,
64 format: wgpu::VertexFormat::Float32x3,
65 },
66 wgpu::VertexAttribute {
67 offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
68 shader_location: 1,
69 format: wgpu::VertexFormat::Float32x2,
70 },
71 wgpu::VertexAttribute {
72 offset: (std::mem::size_of::<[f32; 3]>() + std::mem::size_of::<[f32; 2]>())
73 as wgpu::BufferAddress,
74 shader_location: 2,
75 format: wgpu::VertexFormat::Float32x3,
76 },
77 ],
78 };
79}
80
81/// P4-M3b vertex compression. 16 bytes per vertex (half the f32 layout).
82/// Used as the GPU-side storage format by `pipeline::build_pipeline`,
83/// which converts `&[Vertex]` → `Vec<VertexC>` once at upload time.
84///
85/// Memory budget impact on the M2 spike (4 K × 4 K DEM with skirts,
86/// 18.81 M vertices):
87///
88/// uncompressed: 18.81 M × 32 B = **602 MB**
89/// compressed : 18.81 M × 16 B = **301 MB**
90///
91/// 50 % reduction lets DEMs up to ~3 K side fit the typical 256 MB
92/// WebGL2 single-buffer cap. 4 K still needs M3c lazy upload to fit.
93///
94/// Encoding:
95/// - `pos` in `[-1, 1]` as snorm16 — the mesh builders normalise
96/// positions to longer-side = 2 scene units so XZ always fit.
97/// Y is clamped at ±1 in scene units (typical zex defaults give
98/// y in `[0, 0.45]`).
99/// - `uv` as unorm16 in `[0, 1]`.
100/// - `normal` as snorm8 — ~1° angular precision, well below the
101/// Lambertian shading threshold for visible artefacts.
102#[repr(C, align(2))]
103#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
104pub struct VertexC {
105 pub pos: [i16; 3],
106 pub _pad0: u16,
107 pub uv: [u16; 2],
108 pub normal: [i8; 3],
109 pub _pad1: u8,
110}
111
112impl VertexC {
113 pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
114 array_stride: std::mem::size_of::<Self>() as wgpu::BufferAddress,
115 step_mode: wgpu::VertexStepMode::Vertex,
116 attributes: &[
117 // pos+pad consumed as a vec4<f32> in [-1, 1]; the shader
118 // takes .xyz.
119 wgpu::VertexAttribute {
120 offset: 0,
121 shader_location: 0,
122 format: wgpu::VertexFormat::Snorm16x4,
123 },
124 wgpu::VertexAttribute {
125 offset: 8,
126 shader_location: 1,
127 format: wgpu::VertexFormat::Unorm16x2,
128 },
129 // normal+pad consumed as a vec4<f32> in [-1, 1]; shader
130 // takes .xyz.
131 wgpu::VertexAttribute {
132 offset: 12,
133 shader_location: 2,
134 format: wgpu::VertexFormat::Snorm8x4,
135 },
136 ],
137 };
138
139 /// Convert from a full-precision Vertex. Clamping is conservative
140 /// — the encoder won't panic on out-of-range input, just saturates.
141 pub fn from_vertex(v: &Vertex) -> Self {
142 #[inline]
143 fn s16(x: f32) -> i16 {
144 (x.clamp(-1.0, 1.0) * 32767.0).round() as i16
145 }
146 #[inline]
147 fn u16f(x: f32) -> u16 {
148 (x.clamp(0.0, 1.0) * 65535.0).round() as u16
149 }
150 #[inline]
151 fn s8(x: f32) -> i8 {
152 (x.clamp(-1.0, 1.0) * 127.0).round() as i8
153 }
154 Self {
155 pos: [s16(v.position[0]), s16(v.position[1]), s16(v.position[2])],
156 _pad0: 0,
157 uv: [u16f(v.uv[0]), u16f(v.uv[1])],
158 normal: [s8(v.normal[0]), s8(v.normal[1]), s8(v.normal[2])],
159 _pad1: 0,
160 }
161 }
162}
163
164/// Per-frame uniforms — 144 bytes, every field a vec4 slot to keep the
165/// std140-equivalent layout obvious on both sides of the FFI:
166/// view_proj : mat4x4<f32> (offset 0, 64 B)
167/// light_dir.xyz : vec3 in vec4 (offset 64, 16 B) // direction TOWARDS light
168/// light_color.xyz / .w : colour + amb. (offset 80, 16 B)
169/// vertical_scale.x : f32 in vec4 (offset 96, 16 B)
170/// fog_color.xyz / .w : colour + density [0,1] (offset 112, 16 B)
171/// fog_range.x / .y : near / far stops (offset 128, 16 B)
172#[repr(C)]
173#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
174pub struct Uniforms {
175 pub view_proj: [[f32; 4]; 4],
176 pub light_dir: [f32; 4],
177 pub light_color: [f32; 4],
178 pub vertical_scale: [f32; 4],
179 pub fog_color: [f32; 4],
180 pub fog_range: [f32; 4],
181}
182
183impl Uniforms {
184 /// Identity / neutral defaults. Light at azimuth 315°, altitude 45°
185 /// (matching the rayshader recipe so the 3D light direction lines up
186 /// with whatever was baked into the 2D texture). Fog density is 0
187 /// so the M3 output is bit-equivalent to pre-P3 by default.
188 pub fn identity() -> Self {
189 let dir = sun_dir(315.0, 45.0);
190 Self {
191 view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
192 light_dir: [dir.x, dir.y, dir.z, 0.0],
193 light_color: [1.0, 1.0, 1.0, 0.4], // .w = ambient
194 vertical_scale: [1.0, 0.0, 0.0, 0.0],
195 // Neutral fog: light grey-blue colour that reads as sky,
196 // density 0 so it does not affect the output unless the
197 // viewer / CLI sets it. fog_range is in world-space units
198 // — the mesh is normalised to longer-side = 2, so a
199 // near/far of (1.5, 6.0) gives haze that ramps up from
200 // "near the camera" to "the far horizon".
201 fog_color: [0.78, 0.83, 0.88, 0.0],
202 fog_range: [1.5, 6.0, 0.0, 0.0],
203 }
204 }
205}
206
207/// Unit vector pointing *from the surface toward the sun*. Convention
208/// matches `RayShadeParams::with_soft_shadow_altitude` (azimuth 0=N,
209/// clockwise; altitude 0=horizon, 90=zenith).
210pub fn sun_dir(azimuth_deg: f32, altitude_deg: f32) -> glam::Vec3 {
211 let az = azimuth_deg.to_radians();
212 let alt = altitude_deg.to_radians();
213 let cos_alt = alt.cos();
214 glam::Vec3::new(cos_alt * az.sin(), alt.sin(), -cos_alt * az.cos()).normalize()
215}