tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! LOD del horizonte: una **malla gruesa** del terreno circundante como telón de
2//! fondo, para que más allá de la ventana voxel streameada se vean colinas
3//! lejanas en vez de un muro de niebla. Es el híbrido clásico **voxel cerca /
4//! malla-LOD lejos**: se compone con los voxels finos por el **depth compartido**
5//! de [`Scene3d`](llimphi_3d::Scene3d) (los voxels ocluyen la malla donde se
6//! solapan; afuera, la malla muestra el relieve distante).
7//!
8//! La malla se genera muestreando [`column_height`](crate::column_height) a paso
9//! grueso (sin deps de render: el `Renderer3d` es flat-color, así que la luz y la
10//! **niebla por distancia** se hornean en el color de cada vértice en CPU,
11//! imitando la atmósfera del pase voxel para que el horizonte funda sin costura).
12
13use llimphi_3d::Vertex3d;
14
15use crate::terrain::column_height;
16
17/// Mezcla lineal de dos colores RGB `[f32;3]`.
18#[inline]
19fn mix3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
20 let t = t.clamp(0.0, 1.0);
21 [
22 a[0] + (b[0] - a[0]) * t,
23 a[1] + (b[1] - a[1]) * t,
24 a[2] + (b[2] - a[2]) * t,
25 ]
26}
27
28#[inline]
29fn srgb(c: [u8; 3]) -> [f32; 3] {
30 [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0]
31}
32
33/// Color del terreno (banda por altura) ya **sombreado** por `shade` (difuso) —
34/// imita las bandas de `terrain.rs` para que la costura con los voxels finos no
35/// salte. `fh` = altura normalizada; `is_water` pinta la superficie del mar.
36fn band(fh: f32, is_water: bool) -> [f32; 3] {
37 if is_water {
38 return srgb([44, 96, 140]);
39 }
40 let rock = srgb([88, 86, 92]);
41 let snow = srgb([236, 240, 250]);
42 let grass_lo = srgb([54, 110, 52]);
43 let grass_hi = srgb([96, 150, 70]);
44 let sand = srgb([196, 182, 130]);
45 if fh < 0.33 {
46 sand
47 } else if fh < 0.55 {
48 mix3(grass_lo, grass_hi, (fh - 0.33) / 0.22)
49 } else if fh < 0.72 {
50 mix3(grass_hi, rock, (fh - 0.55) / 0.17)
51 } else if fh < 0.82 {
52 rock
53 } else {
54 mix3(rock, snow, (fh - 0.82) / 0.10)
55 }
56}
57
58/// Parámetros de la falda LOD.
59pub struct LodParams {
60 /// Centro de la ventana en **mundo** `[wx, wz]` (la malla se centra ahí, igual
61 /// que los voxels: posición renderizada = `mundo − centro`).
62 pub center_xz: [i32; 2],
63 /// Lado de la ventana voxel fina (voxels) — se deja un **hueco** ahí para que
64 /// los voxels la llenen (la malla gruesa sólo rodea).
65 pub window_xz: u32,
66 /// Medio-alcance de la falda más allá del centro (voxels).
67 pub span: i32,
68 /// Paso de muestreo grueso (voxels). Mayor = más barato / más facetado.
69 pub stride: i32,
70 /// Color del horizonte (hacia el que funde la niebla) + densidad (espeja la
71 /// `Atmosphere` del pase voxel).
72 pub sky_horizon: [u8; 3],
73 pub fog_density: f32,
74 /// Dirección hacia el sol (para el sombreado difuso horneado).
75 pub sun_dir: [f32; 3],
76}
77
78/// Un **anillo** de la falda LOD piramidal: a qué paso se muestrea y hasta dónde
79/// llega. Un anillo más lejano usa `stride` mayor (más barato, más facetado) — así el
80/// horizonte se extiende mucho sin reventar el límite de 65 535 vértices de un `u16`.
81#[derive(Clone, Copy, Debug)]
82pub struct LodRing {
83 /// Paso de muestreo de este anillo (voxels). Mayor = más grueso/barato.
84 pub stride: i32,
85 /// Medio-alcance de este anillo desde el centro (voxels).
86 pub span: i32,
87}
88
89/// Genera la malla de la falda LOD para una ventana (un solo nivel). Devuelve
90/// `(vértices, índices u16)` listos para
91/// [`Renderer3d::set_geometry`](llimphi_3d::Renderer3d). `dim`/`seed` definen el mismo
92/// terreno procedural que los voxels. Para horizontes enormes usa
93/// [`lod_skirt_pyramid`] (varios anillos de paso creciente).
94pub fn lod_skirt(p: &LodParams, dim: [u32; 3], seed: u32) -> (Vec<Vertex3d>, Vec<u16>) {
95 let stride = p.stride.max(1);
96 let half = p.window_xz as i32 / 2;
97 // Margen: solapar un poco el hueco con la ventana para que no quede una rendija
98 // entre la malla y los voxels (el depth resuelve la oclusión del solape).
99 let hole = (half - stride).max(0);
100 ring_mesh(p, dim, seed, hole, p.span.max(stride), stride)
101}
102
103/// Genera una **falda LOD piramidal**: varios anillos concéntricos de paso creciente,
104/// cada uno como su **propia malla** (así ninguno pasa el límite `u16`, y el conjunto
105/// cubre un horizonte mucho mayor que un nivel único). Los anillos deben venir
106/// ordenados de **adentro hacia afuera** (`span` creciente). El anillo interno deja el
107/// hueco de la ventana voxel fina; cada anillo siguiente arranca solapando un paso con
108/// el borde del anterior (sin rendija; el depth compartido resuelve el solape).
109/// Devuelve **una malla por anillo** — la app sube cada una a un `Renderer3d`.
110pub fn lod_skirt_pyramid(
111 p: &LodParams,
112 dim: [u32; 3],
113 seed: u32,
114 rings: &[LodRing],
115) -> Vec<(Vec<Vertex3d>, Vec<u16>)> {
116 let half = p.window_xz as i32 / 2;
117 let mut out = Vec::with_capacity(rings.len());
118 // El primer anillo arranca en el borde de la ventana voxel (con un paso de solape).
119 let mut inner = (half - rings.first().map(|r| r.stride.max(1)).unwrap_or(1)).max(0);
120 for (k, r) in rings.iter().enumerate() {
121 let stride = r.stride.max(1);
122 let span = r.span.max(stride);
123 out.push(ring_mesh(p, dim, seed, inner, span, stride));
124 // El próximo anillo solapa un paso (suyo) con el borde de éste.
125 let next_stride = rings.get(k + 1).map(|n| n.stride.max(1)).unwrap_or(0);
126 inner = (span - next_stride).max(0);
127 }
128 out
129}
130
131/// Núcleo compartido: malla de un anillo `[-span, span]²` a paso `stride`, dejando un
132/// **hueco cuadrado** central de medio-lado `inner` (lo llena el nivel más fino o la
133/// ventana voxel). La luz difusa + la niebla por distancia se hornean en el color.
134fn ring_mesh(
135 p: &LodParams,
136 dim: [u32; 3],
137 seed: u32,
138 inner: i32,
139 span: i32,
140 stride: i32,
141) -> (Vec<Vertex3d>, Vec<u16>) {
142 let [cx, cz] = p.center_xz;
143 let dy = dim[1] as f32;
144 let sea = (dim[1] as f32 * 0.30) as i32;
145 let hole = inner.max(0);
146
147 // Altura renderizada de la columna de mundo (tierra o superficie del mar).
148 let surf = |wx: i32, wz: i32| -> (i32, bool) {
149 let h = column_height(wx, wz, dim, seed) as i32;
150 if h < sea {
151 (sea, true)
152 } else {
153 (h, false)
154 }
155 };
156
157 let sun = {
158 let s = p.sun_dir;
159 let l = (s[0] * s[0] + s[1] * s[1] + s[2] * s[2]).sqrt().max(1e-6);
160 [s[0] / l, s[1] / l, s[2] / l]
161 };
162 let sky = srgb(p.sky_horizon);
163
164 // Grilla de vértices [cx-span, cx+span] × [cz-span, cz+span] a paso `stride`.
165 let n = ((2 * span) / stride) as usize + 1;
166 let mut verts: Vec<Vertex3d> = Vec::with_capacity(n * n);
167 let coord = |i: usize| -> i32 { -span + i as i32 * stride };
168
169 for iz in 0..n {
170 let wz = cz + coord(iz);
171 for ix in 0..n {
172 let wx = cx + coord(ix);
173 let (h, water) = surf(wx, wz);
174 // Normal por diferencias centrales del relieve (para el sombreado).
175 let (hl, _) = surf(wx - stride, wz);
176 let (hr, _) = surf(wx + stride, wz);
177 let (hd, _) = surf(wx, wz - stride);
178 let (hu, _) = surf(wx, wz + stride);
179 let nx = (hl - hr) as f32;
180 let nz = (hd - hu) as f32;
181 let ny = 2.0 * stride as f32;
182 let nl = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
183 let ndl = ((nx * sun[0] + ny * sun[1] + nz * sun[2]) / nl).max(0.0);
184 let shade = 0.45 + 0.6 * ndl; // ambiente + difuso (≈ pase voxel)
185
186 let fh = h as f32 / dy;
187 let mut color = band(fh, water);
188 color = [color[0] * shade, color[1] * shade, color[2] * shade];
189
190 // Niebla por distancia al centro (≈ cámara): funde al horizonte.
191 let (dxf, dzf) = ((wx - cx) as f32, (wz - cz) as f32);
192 let dist = (dxf * dxf + dzf * dzf).sqrt();
193 // Cap < 1: el horizonte conserva algo de silueta/relieve (no se lava a
194 // cielo puro), que es lo que hace legible que "el mundo sigue".
195 let fog = (1.0 - (-dist * p.fog_density).exp()).min(0.9);
196 color = mix3(color, sky, fog);
197
198 verts.push(Vertex3d {
199 pos: [(wx - cx) as f32, h as f32 - dy * 0.5, (wz - cz) as f32],
200 color,
201 });
202 }
203 }
204
205 // Índices: dos triángulos por celda, salvo las celdas dentro del hueco central
206 // (las llena la ventana voxel fina).
207 let mut indices: Vec<u16> = Vec::new();
208 for iz in 0..n - 1 {
209 for ix in 0..n - 1 {
210 // Centro de la celda en mundo, para el test del hueco.
211 let mwx = coord(ix) + stride / 2;
212 let mwz = coord(iz) + stride / 2;
213 if mwx.abs() < hole && mwz.abs() < hole {
214 continue; // dentro de la ventana fina → la llenan los voxels
215 }
216 let a = (iz * n + ix) as u16;
217 let b = (iz * n + ix + 1) as u16;
218 let c = ((iz + 1) * n + ix) as u16;
219 let d = ((iz + 1) * n + ix + 1) as u16;
220 // CCW vista desde arriba.
221 indices.extend_from_slice(&[a, c, b, b, c, d]);
222 }
223 }
224
225 (verts, indices)
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn skirt_genera_geometria_con_hueco() {
234 let dim = [128, 56, 128];
235 let p = LodParams {
236 center_xz: [0, 0],
237 window_xz: 128,
238 span: 256,
239 stride: 8,
240 sky_horizon: [200, 216, 234],
241 fog_density: 0.01,
242 sun_dir: [0.5, 0.6, 0.3],
243 };
244 let (verts, indices) = lod_skirt(&p, dim, 1);
245 assert!(!verts.is_empty() && !indices.is_empty());
246 assert_eq!(indices.len() % 3, 0, "triángulos completos");
247 assert!(*indices.iter().max().unwrap() < verts.len() as u16, "índices en rango");
248 // Debe haber un hueco: menos triángulos que la grilla completa.
249 let n = ((2 * p.span) / p.stride) as usize + 1;
250 let full = (n - 1) * (n - 1) * 2;
251 assert!(indices.len() / 3 < full, "el hueco recortó triángulos");
252 }
253
254 #[test]
255 fn piramide_apila_anillos_crecientes() {
256 let dim = [128, 56, 128];
257 let p = LodParams {
258 center_xz: [1000, -500], // lejos del origen: el streaming de verdad
259 window_xz: 128,
260 span: 0, // ignorado por la pirámide (cada anillo trae el suyo)
261 stride: 0,
262 sky_horizon: [200, 216, 234],
263 fog_density: 0.004,
264 sun_dir: [0.5, 0.6, 0.3],
265 };
266 // Tres niveles: fino cerca, grueso lejos — el horizonte llega a 1536 voxels.
267 let rings = [
268 LodRing { stride: 6, span: 256 },
269 LodRing { stride: 16, span: 640 },
270 LodRing { stride: 40, span: 1536 },
271 ];
272 let meshes = lod_skirt_pyramid(&p, dim, 1, &rings);
273 assert_eq!(meshes.len(), 3, "una malla por anillo");
274 for (k, (verts, indices)) in meshes.iter().enumerate() {
275 assert!(!verts.is_empty() && !indices.is_empty(), "anillo {k} no vacío");
276 assert_eq!(indices.len() % 3, 0, "triángulos completos en anillo {k}");
277 assert!(*indices.iter().max().unwrap() < verts.len() as u16, "índices en rango anillo {k}");
278 // Cada anillo respeta el límite u16 (es su propia malla).
279 assert!(verts.len() <= u16::MAX as usize, "anillo {k} bajo el tope u16: {}", verts.len());
280 }
281 // El anillo externo, pese a cubrir un área ~6× mayor que el interno, usa menos
282 // vértices (paso mucho más grueso) — el punto de la pirámide.
283 assert!(
284 meshes[2].0.len() < meshes[0].0.len(),
285 "el anillo lejano es más barato: ext {} vs int {}",
286 meshes[2].0.len(),
287 meshes[0].0.len()
288 );
289 }
290}