Skip to main content

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}