gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-voxel/src/lod.rs Rust · 290 lines · 11.7 KB History Who touched what raw

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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}