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

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc llimphi-3d/src/voxel_renderer.rs Rust · 1372 lines · 55.8 KB History Who touched what raw

feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director 333dca1d5 · Sergio ·

1//! `VoxelRenderer` — render por **ray-marching** de un [`VoxelGrid`], con
2//! almacenamiento **sparse de verdad** (brick pool).
3//!
4//! No mesha (ruta elegida en `MOTOR-VOXEL.md` §11.1): el rayo se marcha por la
5//! estructura y el color sale del voxel golpeado.
6//!
7//! Evolución de la memoria:
8//! - **M1** denso (toda la grilla, incluido el aire, en una textura 3D).
9//! - **M2** traversal sparse (DDA de dos niveles sobre un mapa grueso).
10//! - **Ahora (brick pool, prereq de M5/M6)**: la memoria también es sparse.
11//!   Sólo los *bricks* (`BRICK³` voxels) que contienen algo se guardan, en un
12//!   **atlas 3D** (el *pool*); una textura de **indirección** del tamaño grueso
13//!   mapea cada celda → slot del pool (`0` = brick vacío, no ocupa memoria). El
14//!   shader resuelve cada voxel: celda gruesa → slot → texel del atlas.
15//!
16//! El DDA de dos niveles usa la indirección como mapa de ocupación (skip de
17//! aire) Y como tabla de slots (lookup fino). Mutar voxels (M3) sigue siendo
18//! incremental: un slot map + free list permiten allocar/liberar bricks y subir
19//! sólo los bricks tocados.
20//!
21//! Entidades (M4) y la firma de [`VoxelRenderer::render`] (compatible con
22//! `View::gpu_paint_with`) intactas.
23
24use crate::camera::Camera3d;
25use crate::voxel::VoxelGrid;
26
27/// Tamaño de brick (voxels por lado).
28const BRICK: u32 = 8;
29
30/// Lado de brick (voxels) expuesto: el streaming toroidal exige orígenes de
31/// ventana alineados a este múltiplo (ver [`VoxelRenderer::scroll_to`]).
32pub const VOXEL_BRICK: u32 = BRICK;
33
34/// Tope de luces puntuales por frame, expuesto para que el caller no exceda.
35pub const VOXEL_MAX_LIGHTS: usize = MAX_LIGHTS;
36
37/// Máximo de entidades vivas por frame (cabe holgado en un uniform).
38const MAX_ENTITIES: usize = 64;
39
40/// Máximo de luces puntuales por frame (cabe en el uniform principal).
41const MAX_LIGHTS: usize = 4;
42
43/// Luz puntual coloreada (antorcha/lámpara): ilumina los voxels/entidades
44/// cercanos con caída suave por distancia. Posición en coordenadas de voxel
45/// `[0, dim]` (igual que las entidades), color RGB lineal (puede pasar de 1.0
46/// para un brillo intenso), `range` = radio de alcance en voxels.
47#[derive(Clone, Copy)]
48pub struct PointLight {
49    pub pos: [f32; 3],
50    pub color: [f32; 3],
51    pub range: f32,
52    /// Radio de la luz como **fuente de área** (en voxels). `0.0` = puntual exacta
53    /// → sombra dura (1 shadow ray). `> 0.0` = el shadow ray se reparte en varios
54    /// taps sobre un disco de este radio perpendicular a la dirección a la luz →
55    /// **penumbra** (sombra blanda): cuanto más lejos el ocluyente de la superficie,
56    /// más se abre el borde. Sólo aplica si `point_shadows` está activo.
57    pub radius: f32,
58}
59
60/// Una entidad (agente) — una caja analítica ray-marcheada en el mismo pase que
61/// los voxels (M4). Posición en coordenadas de voxel `[0, dim]` (sub-voxel, así
62/// se mueve suave), `half` = medio-tamaño por eje, color RGB.
63#[derive(Clone, Copy)]
64pub struct Entity3d {
65    pub pos: [f32; 3],
66    pub half: [f32; 3],
67    pub color: [u8; 3],
68}
69
70/// Atmósfera del mundo (primera rebanada de M6): cielo gradiente + niebla por
71/// distancia ("aerial perspective"). Editable antes de `render`.
72///
73/// `fog_density` controla todo el efecto: con `0.0` (default) el renderer se
74/// comporta como antes — los rayos que no pegan nada hacen `discard` (deja ver
75/// el fondo vello) y no hay niebla. Con `> 0.0` el motor pinta su **propio
76/// cielo** en los misses y desvanece lo lejano hacia el color del horizonte, que
77/// es lo que hace legible un mundo grande (sin esto, el borde lejano del terreno
78/// se ve como un muro recortado).
79#[derive(Clone, Copy)]
80pub struct Atmosphere {
81    /// Color del cielo en el cenit (mirando hacia arriba).
82    pub sky_zenith: [u8; 3],
83    /// Color del cielo en el horizonte — también el color hacia el que
84    /// desvanece la niebla.
85    pub sky_horizon: [u8; 3],
86    /// Densidad de niebla por unidad de voxel. `0.0` = desactivada (miss →
87    /// `discard`, sin niebla); valores típicos `0.002..0.02`.
88    pub fog_density: f32,
89    /// **God rays** (luz volumétrica / *in-scattering*): `0.0` = off; `~1.0` =
90    /// haces de sol marcados cruzando la niebla. El shader marcha unas pocas
91    /// muestras a lo largo del rayo de cámara, prueba la visibilidad del sol en
92    /// cada una y acumula luz dispersada hacia el ojo (más fuerte mirando hacia el
93    /// sol, fase tipo Henyey-Greenstein). Sólo actúa con `fog_density > 0` (la
94    /// niebla es el medio que dispersa). Es el sello anti-Minecraft: la malla de
95    /// cubos no puede hacer esto; el ray-march sí. Coste: shadow rays extra por
96    /// píxel — subilo con criterio.
97    pub god_rays: f32,
98}
99
100impl Default for Atmosphere {
101    fn default() -> Self {
102        Self {
103            sky_zenith: [70, 120, 200],
104            sky_horizon: [188, 208, 230],
105            fog_density: 0.0,
106            god_rays: 0.0,
107        }
108    }
109}
110
111/// Renderer de voxels por ray-march de dos niveles sobre un brick pool sparse.
112pub struct VoxelRenderer {
113    pool: wgpu::Texture,
114    indir: wgpu::Texture,
115    bind_group: wgpu::BindGroup,
116    /// Layout del bind group, guardado para re-armar el bind group cuando el pool
117    /// crece (la textura del atlas cambia → su view también).
118    bgl: wgpu::BindGroupLayout,
119    pipeline: wgpu::RenderPipeline,
120    ubuf: wgpu::Buffer,
121    ubuf_ent: wgpu::Buffer,
122    dim: [u32; 3],
123    cdim: [u32; 3],
124    /// Slots del atlas por eje (cuántos bricks entran en cada dimensión).
125    atlas: [u32; 3],
126    /// Por celda gruesa: `slot + 1`, o `0` si el brick está vacío. Espeja la
127    /// textura de indirección en CPU (para el camino incremental).
128    slots: Vec<u32>,
129    /// Slots libres del pool (free list para allocar bricks nuevos).
130    free: Vec<u32>,
131    /// Origen de brick de la ventana (streaming toroidal): `slots`/indirección se
132    /// indexan por celda **física** = `(celda_lógica + brick_origin) mod cdim`.
133    /// `[0,0,0]` = sin scroll (lógica = física, camino clásico).
134    brick_origin: [i32; 3],
135    /// Dirección hacia el sol (normalizada). Editable antes de `render`.
136    pub sun_dir: [f32; 3],
137    /// Atmósfera (cielo + niebla). `fog_density = 0` → comportamiento clásico.
138    pub atmosphere: Atmosphere,
139    /// Depth buffer propio para el camino *standalone* ([`Self::render`]); en
140    /// `Scene3d` se usa el depth compartido y este queda sin tocar.
141    depth: Option<crate::scene::DepthBuffer>,
142    /// Entidades vivas — se empacan y suben en cada `render`.
143    pub entities: Vec<Entity3d>,
144    /// Luces puntuales coloreadas (≤ [`MAX_LIGHTS`]) — antorchas/lámparas que
145    /// iluminan voxels y entidades cercanos. Se empacan y suben en cada `render`.
146    pub lights: Vec<PointLight>,
147    /// Si las luces puntuales proyectan sombra (un shadow ray por luz hacia su
148    /// posición, acotado a la distancia a la luz). `true` por defecto. Apagarlo
149    /// recupera el MVP plano (más barato) — útil para comparar off/on.
150    pub point_shadows: bool,
151}
152
153impl VoxelRenderer {
154    /// Crea el renderer y construye el brick pool a partir de `grid`.
155    pub fn new(
156        device: &wgpu::Device,
157        queue: &wgpu::Queue,
158        color_format: wgpu::TextureFormat,
159        grid: &VoxelGrid,
160    ) -> Self {
161        let dim = grid.dim();
162        let cdim = [
163            dim[0].div_ceil(BRICK),
164            dim[1].div_ceil(BRICK),
165            dim[2].div_ceil(BRICK),
166        ];
167        let n_cells = (cdim[0] * cdim[1] * cdim[2]) as usize;
168
169        // Bricks ocupados → capacidad con holgura para crecer (M3).
170        let occupied: u32 = (0..cdim[2])
171            .flat_map(|cz| (0..cdim[1]).flat_map(move |cy| (0..cdim[0]).map(move |cx| (cx, cy, cz))))
172            .filter(|&(cx, cy, cz)| grid.brick_occupied(BRICK, cx, cy, cz) != 0)
173            .count() as u32;
174        let want = occupied + occupied / 2 + 64;
175        // Atlas cúbico-ish: ax·ay·az ≥ want.
176        let ax = ((want as f64).cbrt().ceil() as u32).max(1);
177        let ay = ax;
178        let az = want.div_ceil(ax * ay).max(1);
179        let capacity = ax * ay * az;
180        let atlas = [ax, ay, az];
181
182        let pool = device.create_texture(&wgpu::TextureDescriptor {
183            label: Some("llimphi-3d-voxel-pool"),
184            size: extent([ax * BRICK, ay * BRICK, az * BRICK]),
185            mip_level_count: 1,
186            sample_count: 1,
187            dimension: wgpu::TextureDimension::D3,
188            format: wgpu::TextureFormat::Rgba8Unorm,
189            // COPY_SRC para poder copiar el atlas a uno más grande al crecer.
190            usage: wgpu::TextureUsages::TEXTURE_BINDING
191                | wgpu::TextureUsages::COPY_DST
192                | wgpu::TextureUsages::COPY_SRC,
193            view_formats: &[],
194        });
195        let pool_view = pool.create_view(&wgpu::TextureViewDescriptor::default());
196
197        let indir = device.create_texture(&wgpu::TextureDescriptor {
198            label: Some("llimphi-3d-voxel-indir"),
199            size: extent(cdim),
200            mip_level_count: 1,
201            sample_count: 1,
202            dimension: wgpu::TextureDimension::D3,
203            format: wgpu::TextureFormat::R32Uint,
204            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
205            view_formats: &[],
206        });
207        let indir_view = indir.create_view(&wgpu::TextureViewDescriptor::default());
208
209        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
210            label: Some("llimphi-3d-voxel-shader"),
211            source: wgpu::ShaderSource::Wgsl(WGSL.into()),
212        });
213
214        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
215            label: Some("llimphi-3d-voxel-bgl"),
216            entries: &[
217                wgpu::BindGroupLayoutEntry {
218                    binding: 0,
219                    visibility: wgpu::ShaderStages::FRAGMENT,
220                    ty: wgpu::BindingType::Texture {
221                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
222                        view_dimension: wgpu::TextureViewDimension::D3,
223                        multisampled: false,
224                    },
225                    count: None,
226                },
227                wgpu::BindGroupLayoutEntry {
228                    binding: 1,
229                    visibility: wgpu::ShaderStages::FRAGMENT,
230                    ty: wgpu::BindingType::Texture {
231                        sample_type: wgpu::TextureSampleType::Uint,
232                        view_dimension: wgpu::TextureViewDimension::D3,
233                        multisampled: false,
234                    },
235                    count: None,
236                },
237                uniform_entry(2),
238                uniform_entry(3),
239            ],
240        });
241
242        let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
243            label: Some("llimphi-3d-voxel-ubuf"),
244            // inv_vp(64)+cam_eye(16)+grid_dim/brick(16)+sun(16)+cdim(16)+atlas(16)
245            // +sky_zenith/fog(16)+sky_horizon(16)+vp(64)+scroll(16)
246            // +n_lights(16)+lights(4×32=128)
247            size: 400,
248            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
249            mapped_at_creation: false,
250        });
251        let ubuf_ent = device.create_buffer(&wgpu::BufferDescriptor {
252            label: Some("llimphi-3d-voxel-ubuf-ent"),
253            size: (16 + MAX_ENTITIES * 48) as u64,
254            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
255            mapped_at_creation: false,
256        });
257
258        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
259            label: Some("llimphi-3d-voxel-bg"),
260            layout: &bgl,
261            entries: &[
262                wgpu::BindGroupEntry {
263                    binding: 0,
264                    resource: wgpu::BindingResource::TextureView(&pool_view),
265                },
266                wgpu::BindGroupEntry {
267                    binding: 1,
268                    resource: wgpu::BindingResource::TextureView(&indir_view),
269                },
270                wgpu::BindGroupEntry {
271                    binding: 2,
272                    resource: ubuf.as_entire_binding(),
273                },
274                wgpu::BindGroupEntry {
275                    binding: 3,
276                    resource: ubuf_ent.as_entire_binding(),
277                },
278            ],
279        });
280
281        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
282            label: Some("llimphi-3d-voxel-pl"),
283            bind_group_layouts: &[&bgl],
284            push_constant_ranges: &[],
285        });
286
287        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
288            label: Some("llimphi-3d-voxel-pipeline"),
289            layout: Some(&pipeline_layout),
290            vertex: wgpu::VertexState {
291                module: &shader,
292                entry_point: Some("vs"),
293                compilation_options: Default::default(),
294                buffers: &[],
295            },
296            primitive: wgpu::PrimitiveState {
297                topology: wgpu::PrimitiveTopology::TriangleList,
298                ..Default::default()
299            },
300            // Escribe profundidad (del voxel golpeado) → convive con mallas en
301            // un depth buffer compartido (`Scene3d`): el render volumétrico y el
302            // de triángulos se ocluyen correctamente entre sí.
303            depth_stencil: Some(wgpu::DepthStencilState {
304                format: crate::scene::DEPTH_FORMAT,
305                depth_write_enabled: true,
306                // LessEqual (no Less): el cielo en los misses escribe profundidad
307                // lejana (1.0) y debe pasar contra el clear 1.0; un `Less` lo
308                // rechazaría y dejaría ver el fondo negro. Sólo hay un fragmento
309                // de voxel por píxel (el del rayo), así que no hay z-fighting.
310                depth_compare: wgpu::CompareFunction::LessEqual,
311                stencil: wgpu::StencilState::default(),
312                bias: wgpu::DepthBiasState::default(),
313            }),
314            multisample: wgpu::MultisampleState::default(),
315            fragment: Some(wgpu::FragmentState {
316                module: &shader,
317                entry_point: Some("fs"),
318                compilation_options: Default::default(),
319                targets: &[Some(wgpu::ColorTargetState {
320                    format: color_format,
321                    blend: None,
322                    write_mask: wgpu::ColorWrites::ALL,
323                })],
324            }),
325            multiview: None,
326            cache: None,
327        });
328
329        let mut r = Self {
330            pool,
331            indir,
332            bind_group,
333            bgl,
334            pipeline,
335            ubuf,
336            ubuf_ent,
337            dim,
338            cdim,
339            atlas,
340            slots: vec![0u32; n_cells],
341            free: Vec::new(),
342            brick_origin: [0, 0, 0],
343            sun_dir: normalize3([0.5, 1.0, 0.35]),
344            atmosphere: Atmosphere::default(),
345            depth: None,
346            entities: Vec::new(),
347            lights: Vec::new(),
348            point_shadows: true,
349        };
350
351        // Poblar el pool: cada brick ocupado toma un slot incremental.
352        let mut next: u32 = 0;
353        for cz in 0..cdim[2] {
354            for cy in 0..cdim[1] {
355                for cx in 0..cdim[0] {
356                    if grid.brick_occupied(BRICK, cx, cy, cz) != 0 {
357                        let slot = next;
358                        next += 1;
359                        let idx = r.cell_idx(cx, cy, cz);
360                        r.slots[idx] = slot + 1;
361                        r.upload_brick(queue, slot, grid, cx, cy, cz);
362                    }
363                }
364            }
365        }
366        r.free = (next..capacity).rev().collect();
367        r.upload_indirection_full(queue);
368        r
369    }
370
371    /// Bricks ocupados (slots usados) y total de celdas gruesas — para reportar
372    /// el ahorro de memoria del pool frente al denso.
373    pub fn brick_usage(&self) -> (u32, u32) {
374        let used = self.slots.iter().filter(|&&s| s != 0).count() as u32;
375        (used, (self.cdim[0] * self.cdim[1] * self.cdim[2]))
376    }
377
378    /// Bytes del pool (atlas) vs. lo que costaría el grid denso completo.
379    pub fn memory_bytes(&self) -> (u64, u64) {
380        let (used, _) = self.brick_usage();
381        let per_brick = (BRICK * BRICK * BRICK * 4) as u64;
382        let pool = used as u64 * per_brick;
383        let dense = (self.dim[0] * self.dim[1] * self.dim[2] * 4) as u64;
384        (pool, dense)
385    }
386
387    #[inline]
388    fn cell_idx(&self, cx: u32, cy: u32, cz: u32) -> usize {
389        (cx + cy * self.cdim[0] + cz * self.cdim[0] * self.cdim[1]) as usize
390    }
391
392    /// Índice **físico** (en `slots`/indirección) de la celda gruesa **lógica**
393    /// `c` (relativa a la ventana): `(c + brick_origin) mod cdim`. Espeja el
394    /// `slot_at` del shader. Con `brick_origin = 0` es `cell_idx(c)` directo.
395    #[inline]
396    fn phys_cell(&self, c: [i32; 3]) -> usize {
397        let p = [
398            floormod(c[0] + self.brick_origin[0], self.cdim[0] as i32) as u32,
399            floormod(c[1] + self.brick_origin[1], self.cdim[1] as i32) as u32,
400            floormod(c[2] + self.brick_origin[2], self.cdim[2] as i32) as u32,
401        ];
402        self.cell_idx(p[0], p[1], p[2])
403    }
404
405    /// Origen del slot en el atlas (en celdas de brick).
406    fn slot_origin(&self, slot: u32) -> [u32; 3] {
407        let ax = self.atlas[0];
408        let ay = self.atlas[1];
409        [slot % ax, (slot / ax) % ay, slot / (ax * ay)]
410    }
411
412    fn upload_brick(&self, queue: &wgpu::Queue, slot: u32, grid: &VoxelGrid, cx: u32, cy: u32, cz: u32) {
413        let data = grid.extract_brick(BRICK, cx, cy, cz);
414        let o = self.slot_origin(slot);
415        write_3d(
416            queue,
417            &self.pool,
418            [o[0] * BRICK, o[1] * BRICK, o[2] * BRICK],
419            [BRICK, BRICK, BRICK],
420            4,
421            &data,
422        );
423    }
424
425    fn upload_indirection_full(&self, queue: &wgpu::Queue) {
426        let mut bytes = Vec::with_capacity(self.slots.len() * 4);
427        for &s in &self.slots {
428            bytes.extend_from_slice(&s.to_ne_bytes());
429        }
430        write_3d(queue, &self.indir, [0; 3], self.cdim, 4, &bytes);
431    }
432
433    /// **Actualización incremental (M3).** Sube sólo los bricks tocados por la
434    /// región mutada: re-sube cada brick afectado a su slot (allocando slots
435    /// nuevos para bricks que pasan de vacío→ocupado, liberándolos al revés) y
436    /// re-sube la sub-región de indirección. Devuelve los bytes subidos.
437    pub fn sync(&mut self, queue: &wgpu::Queue, grid: &mut VoxelGrid) -> u32 {
438        let Some(r) = grid.take_dirty() else {
439            return 0;
440        };
441        let cmin = [r[0] / BRICK, r[1] / BRICK, r[2] / BRICK];
442        let cmax = [r[3] / BRICK, r[4] / BRICK, r[5] / BRICK];
443        let mut uploaded = 0u32;
444        let per_brick = BRICK * BRICK * BRICK * 4;
445
446        for cz in cmin[2]..=cmax[2] {
447            for cy in cmin[1]..=cmax[1] {
448                for cx in cmin[0]..=cmax[0] {
449                    // Celda FÍSICA (espeja el `slot_at` toroidal del shader); con
450                    // `brick_origin = 0` es la celda lógica directa.
451                    let idx = self.phys_cell([cx as i32, cy as i32, cz as i32]);
452                    let occ = grid.brick_occupied(BRICK, cx, cy, cz) != 0;
453                    let cur = self.slots[idx];
454                    if occ {
455                        let slot = if cur != 0 {
456                            cur - 1
457                        } else {
458                            match self.free.pop() {
459                                Some(s) => {
460                                    self.slots[idx] = s + 1;
461                                    s
462                                }
463                                None => {
464                                    // Pool lleno: el brick no entra (raro con la
465                                    // holgura inicial). Lo saltamos sin romper.
466                                    continue;
467                                }
468                            }
469                        };
470                        self.upload_brick(queue, slot, grid, cx, cy, cz);
471                        uploaded += per_brick;
472                    } else if cur != 0 {
473                        // Brick vaciado: liberar el slot (el atlas queda
474                        // huérfano pero la indirección en 0 lo hace invisible).
475                        self.free.push(cur - 1);
476                        self.slots[idx] = 0;
477                    }
478                }
479            }
480        }
481
482        // Re-subir la indirección tocada. Sin scroll (caso común de edición), la
483        // física = la lógica → sub-región contigua (barato). Con scroll, las
484        // celdas físicas están envueltas (no contiguas) → re-subimos la
485        // indirección entera (es chica: cdim³ u32).
486        if self.brick_origin == [0, 0, 0] {
487            let cext = [
488                cmax[0] - cmin[0] + 1,
489                cmax[1] - cmin[1] + 1,
490                cmax[2] - cmin[2] + 1,
491            ];
492            let mut ind = Vec::with_capacity((cext[0] * cext[1] * cext[2] * 4) as usize);
493            for cz in cmin[2]..=cmax[2] {
494                for cy in cmin[1]..=cmax[1] {
495                    for cx in cmin[0]..=cmax[0] {
496                        ind.extend_from_slice(&self.slots[self.cell_idx(cx, cy, cz)].to_ne_bytes());
497                    }
498                }
499            }
500            write_3d(queue, &self.indir, cmin, cext, 4, &ind);
501            uploaded + ind.len() as u32
502        } else {
503            self.upload_indirection_full(queue);
504            uploaded + (self.slots.len() * 4) as u32
505        }
506    }
507
508    /// **Streaming toroidal (M6).** Desliza la ventana a `origin_voxel` (esquina
509    /// local `(0,0,0)` en coordenadas de mundo, alineada a brick) re-subiendo
510    /// **sólo los bricks que entran** — la franja nueva — sin reconstruir el
511    /// renderer ni re-subir la ventana entera. `grid` es la ventana ya generada
512    /// en ese origen (local `[0,dim)`), de la que se extraen los bricks de la
513    /// franja. Los bricks que salen se reemplazan en su misma celda física (la
514    /// textura es un ring buffer: `world_brick mod cdim`). Devuelve los bytes
515    /// subidos (≈ tamaño de la franja, no de la ventana). Llamar con
516    /// `origin_voxel` múltiplo de [`VOXEL_BRICK`].
517    pub fn scroll_to(
518        &mut self,
519        device: &wgpu::Device,
520        queue: &wgpu::Queue,
521        origin_voxel: [i32; 3],
522        grid: &VoxelGrid,
523    ) -> u32 {
524        let b = BRICK as i32;
525        debug_assert!(
526            origin_voxel.iter().all(|v| v % b == 0),
527            "scroll_to: origin_voxel debe estar alineado a VOXEL_BRICK"
528        );
529        let new = [origin_voxel[0] / b, origin_voxel[1] / b, origin_voxel[2] / b];
530        let old = self.brick_origin;
531        if new == old {
532            return 0;
533        }
534        // El uniform de scroll se actualiza ANTES de poblar para que `phys_cell`
535        // calcule las celdas físicas del nuevo origen.
536        self.brick_origin = new;
537
538        let cd = [self.cdim[0] as i32, self.cdim[1] as i32, self.cdim[2] as i32];
539        let mut uploaded = 0u32;
540        let per_brick = BRICK * BRICK * BRICK * 4;
541
542        let entered = |ccx: i32, ccy: i32, ccz: i32| -> bool {
543            let wb = [ccx + new[0], ccy + new[1], ccz + new[2]];
544            !((0..cd[0]).contains(&(wb[0] - old[0]))
545                && (0..cd[1]).contains(&(wb[1] - old[1]))
546                && (0..cd[2]).contains(&(wb[2] - old[2])))
547        };
548
549        // **Pre-crecer el pool si hace falta, ANTES de subir nada** (la copia del
550        // atlas viejo es estable, sin carrera con write_texture en vuelo). Cota
551        // segura del pico de slots: los usados ahora + los bricks que entran
552        // ocupados (los que salen aún no se liberaron). Crecer una vez al inicio.
553        let mut entered_occ = 0u32;
554        for ccz in 0..cd[2] {
555            for ccy in 0..cd[1] {
556                for ccx in 0..cd[0] {
557                    if entered(ccx, ccy, ccz)
558                        && grid.brick_occupied(BRICK, ccx as u32, ccy as u32, ccz as u32) != 0
559                    {
560                        entered_occ += 1;
561                    }
562                }
563            }
564        }
565        let used_now = self.slots.iter().filter(|&&s| s != 0).count() as u32;
566        let need = used_now + entered_occ;
567        while self.pool_capacity() < need {
568            self.grow_layers(device, queue);
569        }
570
571        // Recorre las celdas LÓGICAS de la ventana nueva; procesa sólo las que
572        // ENTRARON (su brick de mundo no estaba en la ventana vieja). El bulk
573        // (presente en ambas) conserva su contenido físico intacto.
574        for ccz in 0..cd[2] {
575            for ccy in 0..cd[1] {
576                for ccx in 0..cd[0] {
577                    if !entered(ccx, ccy, ccz) {
578                        continue;
579                    }
580                    // Celda física (= celda vieja que sale, por el ring buffer).
581                    let idx = self.phys_cell([ccx, ccy, ccz]);
582                    let (lx, ly, lz) = (ccx as u32, ccy as u32, ccz as u32);
583                    let occ = grid.brick_occupied(BRICK, lx, ly, lz) != 0;
584                    let cur = self.slots[idx];
585                    if occ {
586                        let slot = if cur != 0 {
587                            cur - 1
588                        } else {
589                            let s = self.free.pop().expect("capacidad pre-crecida");
590                            self.slots[idx] = s + 1;
591                            s
592                        };
593                        self.upload_brick(queue, slot, grid, lx, ly, lz);
594                        uploaded += per_brick;
595                    } else if cur != 0 {
596                        self.free.push(cur - 1);
597                        self.slots[idx] = 0;
598                    }
599                }
600            }
601        }
602
603        self.upload_indirection_full(queue);
604        uploaded + (self.slots.len() * 4) as u32
605    }
606
607    /// Capacidad del pool en slots de brick (`atlas.x·y·z`). Crece con
608    /// [`grow_layers`](Self::grow_layers).
609    pub fn pool_capacity(&self) -> u32 {
610        self.atlas[0] * self.atlas[1] * self.atlas[2]
611    }
612
613    /// **Crece el brick pool** agregando capas `z` al atlas (×1.5, mín +8 slots).
614    /// Sólo crece `atlas.z` para no remapear los slots existentes (`slot_origin`
615    /// depende de `atlas.x/y`, que quedan fijos): copia el atlas viejo al nuevo,
616    /// re-arma el bind group (la view del pool cambió) y agrega los slots nuevos a
617    /// la free list. Lo dispara `scroll_to` cuando la franja que entra no tiene
618    /// slots libres (ventana más densa que la inicial).
619    fn grow_layers(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
620        let [ax, ay, az] = self.atlas;
621        let new_az = az + (az / 2).max(8);
622        let new_pool = device.create_texture(&wgpu::TextureDescriptor {
623            label: Some("llimphi-3d-voxel-pool"),
624            size: extent([ax * BRICK, ay * BRICK, new_az * BRICK]),
625            mip_level_count: 1,
626            sample_count: 1,
627            dimension: wgpu::TextureDimension::D3,
628            format: wgpu::TextureFormat::Rgba8Unorm,
629            usage: wgpu::TextureUsages::TEXTURE_BINDING
630                | wgpu::TextureUsages::COPY_DST
631                | wgpu::TextureUsages::COPY_SRC,
632            view_formats: &[],
633        });
634
635        // Copia el atlas viejo (mismas dimensiones x/y, az capas) al nuevo.
636        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
637            label: Some("llimphi-3d-voxel-pool-grow"),
638        });
639        enc.copy_texture_to_texture(
640            wgpu::TexelCopyTextureInfo {
641                texture: &self.pool,
642                mip_level: 0,
643                origin: wgpu::Origin3d::ZERO,
644                aspect: wgpu::TextureAspect::All,
645            },
646            wgpu::TexelCopyTextureInfo {
647                texture: &new_pool,
648                mip_level: 0,
649                origin: wgpu::Origin3d::ZERO,
650                aspect: wgpu::TextureAspect::All,
651            },
652            extent([ax * BRICK, ay * BRICK, az * BRICK]),
653        );
654        queue.submit(std::iter::once(enc.finish()));
655
656        // Re-armar el bind group con la view del pool nuevo (indir/ubufs intactos).
657        let pool_view = new_pool.create_view(&wgpu::TextureViewDescriptor::default());
658        let indir_view = self.indir.create_view(&wgpu::TextureViewDescriptor::default());
659        self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
660            label: Some("llimphi-3d-voxel-bg"),
661            layout: &self.bgl,
662            entries: &[
663                wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&pool_view) },
664                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&indir_view) },
665                wgpu::BindGroupEntry { binding: 2, resource: self.ubuf.as_entire_binding() },
666                wgpu::BindGroupEntry { binding: 3, resource: self.ubuf_ent.as_entire_binding() },
667            ],
668        });
669
670        let old_cap = ax * ay * az;
671        let new_cap = ax * ay * new_az;
672        self.pool = new_pool;
673        self.atlas[2] = new_az;
674        self.free.extend((old_cap..new_cap).rev());
675    }
676
677    /// Sube los uniforms (cámara/atmósfera/entidades) del frame. Lo llama tanto
678    /// [`Self::render`] (standalone) como [`Scene3d`](crate::Scene3d) antes de
679    /// abrir el pase compartido. `aspect` = w/h del viewport.
680    pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
681        let vp = camera.view_proj(aspect);
682        let inv_vp = vp.inverse();
683        let mut u = Vec::with_capacity(400);
684        for v in inv_vp.to_cols_array() {
685            u.extend_from_slice(&v.to_ne_bytes());
686        }
687        for v in [camera.eye.x, camera.eye.y, camera.eye.z, 0.0] {
688            u.extend_from_slice(&v.to_ne_bytes());
689        }
690        for v in [self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32, BRICK as f32] {
691            u.extend_from_slice(&v.to_ne_bytes());
692        }
693        let s = normalize3(self.sun_dir);
694        // sun_dir.w lleva la fuerza de god rays (in-scattering volumétrico).
695        for v in [s[0], s[1], s[2], self.atmosphere.god_rays.max(0.0)] {
696            u.extend_from_slice(&v.to_ne_bytes());
697        }
698        for v in [self.cdim[0] as f32, self.cdim[1] as f32, self.cdim[2] as f32, 0.0] {
699            u.extend_from_slice(&v.to_ne_bytes());
700        }
701        for v in [self.atlas[0] as f32, self.atlas[1] as f32, self.atlas[2] as f32, 0.0] {
702            u.extend_from_slice(&v.to_ne_bytes());
703        }
704        let a = &self.atmosphere;
705        for v in [
706            a.sky_zenith[0] as f32 / 255.0,
707            a.sky_zenith[1] as f32 / 255.0,
708            a.sky_zenith[2] as f32 / 255.0,
709            a.fog_density.max(0.0),
710        ] {
711            u.extend_from_slice(&v.to_ne_bytes());
712        }
713        for v in [
714            a.sky_horizon[0] as f32 / 255.0,
715            a.sky_horizon[1] as f32 / 255.0,
716            a.sky_horizon[2] as f32 / 255.0,
717            0.0,
718        ] {
719            u.extend_from_slice(&v.to_ne_bytes());
720        }
721        // Matriz forward (world→clip) para escribir frag_depth del voxel golpeado.
722        for v in vp.to_cols_array() {
723            u.extend_from_slice(&v.to_ne_bytes());
724        }
725        // Origen de brick (streaming toroidal): el shader envuelve la celda lógica.
726        // Se sube YA REDUCIDO a `[0, cdim)` (floormod) para que el `%` del shader
727        // nunca opere sobre un negativo — el `%` de WGSL sobre enteros con signo es
728        // ambiguo entre plataformas y rompía el wrap con orígenes negativos.
729        for v in [
730            floormod(self.brick_origin[0], self.cdim[0] as i32) as f32,
731            floormod(self.brick_origin[1], self.cdim[1] as i32) as f32,
732            floormod(self.brick_origin[2], self.cdim[2] as i32) as f32,
733            0.0,
734        ] {
735            u.extend_from_slice(&v.to_ne_bytes());
736        }
737        // Luces puntuales: count (vec4) + MAX_LIGHTS × [pos+range, color].
738        let nl = self.lights.len().min(MAX_LIGHTS);
739        let shadow_flag = if self.point_shadows { 1.0 } else { 0.0 };
740        for v in [nl as f32, shadow_flag, 0.0, 0.0] {
741            u.extend_from_slice(&v.to_ne_bytes());
742        }
743        for i in 0..MAX_LIGHTS {
744            let l = self.lights.get(i).copied().unwrap_or(PointLight {
745                pos: [0.0; 3],
746                color: [0.0; 3],
747                range: 1.0,
748                radius: 0.0,
749            });
750            for v in [l.pos[0], l.pos[1], l.pos[2], l.range] {
751                u.extend_from_slice(&v.to_ne_bytes());
752            }
753            // color.w lleva el radio de área (penumbra); 0 = sombra dura.
754            for v in [l.color[0], l.color[1], l.color[2], l.radius.max(0.0)] {
755                u.extend_from_slice(&v.to_ne_bytes());
756            }
757        }
758        queue.write_buffer(&self.ubuf, 0, &u);
759
760        // Entidades: count (vec4) + array de [pos, half, color] (3×vec4 c/u).
761        let n = self.entities.len().min(MAX_ENTITIES);
762        let mut e = Vec::with_capacity(16 + MAX_ENTITIES * 48);
763        for v in [n as f32, 0.0, 0.0, 0.0] {
764            e.extend_from_slice(&v.to_ne_bytes());
765        }
766        for i in 0..MAX_ENTITIES {
767            let ent = self.entities.get(i).copied().unwrap_or(Entity3d {
768                pos: [0.0; 3],
769                half: [0.0; 3],
770                color: [0, 0, 0],
771            });
772            for v in [ent.pos[0], ent.pos[1], ent.pos[2], 0.0] {
773                e.extend_from_slice(&v.to_ne_bytes());
774            }
775            for v in [ent.half[0], ent.half[1], ent.half[2], 0.0] {
776                e.extend_from_slice(&v.to_ne_bytes());
777            }
778            for v in [
779                ent.color[0] as f32 / 255.0,
780                ent.color[1] as f32 / 255.0,
781                ent.color[2] as f32 / 255.0,
782                0.0,
783            ] {
784                e.extend_from_slice(&v.to_ne_bytes());
785            }
786        }
787        queue.write_buffer(&self.ubuf_ent, 0, &e);
788    }
789
790    /// Dibuja el fullscreen-triangle del ray-march en un pase **ya abierto** (con
791    /// color + depth). Lo usa [`Scene3d`](crate::Scene3d) para compartir el pase
792    /// con las mallas. Requiere `upload` previo en el mismo frame.
793    pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
794        pass.set_pipeline(&self.pipeline);
795        pass.set_bind_group(0, &self.bind_group, &[]);
796        pass.draw(0..3, 0..1);
797    }
798
799    /// Ray-marchea la grilla vista desde `camera` sobre `target` (camino
800    /// *standalone*, con depth propio). Color `LoadOp::Load`; misses por
801    /// `discard` (o cielo, con niebla). Grilla centrada en el origen.
802    pub fn render(
803        &mut self,
804        device: &wgpu::Device,
805        queue: &wgpu::Queue,
806        encoder: &mut wgpu::CommandEncoder,
807        target: &wgpu::TextureView,
808        (w, h): (u32, u32),
809        camera: &Camera3d,
810    ) {
811        if w == 0 || h == 0 {
812            return;
813        }
814        self.upload(queue, w as f32 / h as f32, camera);
815        crate::scene::ensure_depth(&mut self.depth, device, w, h);
816        let depth_view = &self.depth.as_ref().unwrap().view;
817
818        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
819            label: Some("llimphi-3d-voxel-pass"),
820            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
821                view: target,
822                resolve_target: None,
823                depth_slice: None,
824                ops: wgpu::Operations {
825                    load: wgpu::LoadOp::Load,
826                    store: wgpu::StoreOp::Store,
827                },
828            })],
829            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
830                view: depth_view,
831                depth_ops: Some(wgpu::Operations {
832                    load: wgpu::LoadOp::Clear(1.0),
833                    store: wgpu::StoreOp::Store,
834                }),
835                stencil_ops: None,
836            }),
837            timestamp_writes: None,
838            occlusion_query_set: None,
839        });
840        self.draw(&mut pass);
841    }
842}
843
844fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
845    wgpu::BindGroupLayoutEntry {
846        binding,
847        visibility: wgpu::ShaderStages::FRAGMENT,
848        ty: wgpu::BindingType::Buffer {
849            ty: wgpu::BufferBindingType::Uniform,
850            has_dynamic_offset: false,
851            min_binding_size: None,
852        },
853        count: None,
854    }
855}
856
857fn extent(dim: [u32; 3]) -> wgpu::Extent3d {
858    wgpu::Extent3d {
859        width: dim[0],
860        height: dim[1],
861        depth_or_array_layers: dim[2],
862    }
863}
864
865fn write_3d(
866    queue: &wgpu::Queue,
867    tex: &wgpu::Texture,
868    origin: [u32; 3],
869    ext: [u32; 3],
870    bpp: u32,
871    data: &[u8],
872) {
873    queue.write_texture(
874        wgpu::TexelCopyTextureInfo {
875            texture: tex,
876            mip_level: 0,
877            origin: wgpu::Origin3d {
878                x: origin[0],
879                y: origin[1],
880                z: origin[2],
881            },
882            aspect: wgpu::TextureAspect::All,
883        },
884        data,
885        wgpu::TexelCopyBufferLayout {
886            offset: 0,
887            bytes_per_row: Some(ext[0] * bpp),
888            rows_per_image: Some(ext[1]),
889        },
890        extent(ext),
891    );
892}
893
894fn normalize3(v: [f32; 3]) -> [f32; 3] {
895    let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
896    [v[0] / l, v[1] / l, v[2] / l]
897}
898
899/// Módulo con resultado siempre en `[0, m)` (maneja `a` negativo). Espeja el
900/// `((x % m) + m) % m` del shader para el direccionamiento toroidal.
901#[inline]
902fn floormod(a: i32, m: i32) -> i32 {
903    ((a % m) + m) % m
904}
905
906const WGSL: &str = r#"
907struct U {
908    inv_vp: mat4x4<f32>,
909    cam_eye: vec4<f32>,
910    grid_dim: vec4<f32>,   // xyz = dim fino, w = brick size
911    sun_dir: vec4<f32>,    // xyz = dirección hacia el sol (normalizada), w = fuerza de god rays
912    cdim: vec4<f32>,       // xyz = dim grueso (celdas de brick)
913    atlas: vec4<f32>,      // xyz = slots por eje en el atlas del pool
914    sky_zenith: vec4<f32>, // xyz = color cenit, w = densidad de niebla (0 = off)
915    sky_horizon: vec4<f32>,// xyz = color horizonte / hacia el que niebla desvanece
916    vp: mat4x4<f32>,       // world→clip (forward) para escribir frag_depth
917    scroll: vec4<f32>,     // xyz = origen de brick (streaming toroidal); 0 = sin scroll
918    n_lights: vec4<f32>,   // x = cantidad de luces puntuales, y = sombras on/off
919    lights: array<vec4<f32>, 8>, // por luz: [pos.xyz, range], [color.rgb, radio_area]
920};
921
922// Disco de muestreo para sombras blandas (penumbra): 8 taps en un patrón anular
923// fijo (determinista, sin RNG por píxel — evita ruido temporal en el reel).
924const SOFT_TAPS: i32 = 8;
925const SOFT_DISK = array<vec2<f32>, 8>(
926    vec2<f32>( 0.35,  0.0),  vec2<f32>(-0.35,  0.0),
927    vec2<f32>( 0.0,   0.35), vec2<f32>( 0.0,  -0.35),
928    vec2<f32>( 0.7,   0.7),  vec2<f32>(-0.7,   0.7),
929    vec2<f32>( 0.7,  -0.7),  vec2<f32>(-0.7,  -0.7),
930);
931struct Entity {
932    pos: vec4<f32>,
933    half: vec4<f32>,
934    color: vec4<f32>,
935};
936struct EntU {
937    count: vec4<f32>,
938    ents: array<Entity, 64>,
939};
940@group(0) @binding(0) var pool: texture_3d<f32>;
941@group(0) @binding(1) var indir: texture_3d<u32>;
942@group(0) @binding(2) var<uniform> u: U;
943@group(0) @binding(3) var<uniform> ent: EntU;
944
945struct VOut {
946    @builtin(position) clip: vec4<f32>,
947    @location(0) ndc: vec2<f32>,
948};
949
950@vertex
951fn vs(@builtin(vertex_index) vi: u32) -> VOut {
952    var p = array<vec2<f32>, 3>(
953        vec2<f32>(-1.0, -1.0),
954        vec2<f32>( 3.0, -1.0),
955        vec2<f32>(-1.0,  3.0),
956    );
957    var out: VOut;
958    out.clip = vec4<f32>(p[vi], 0.0, 1.0);
959    out.ndc = p[vi];
960    return out;
961}
962
963fn ray_box(ro: vec3<f32>, inv_rd: vec3<f32>, bmin: vec3<f32>, bmax: vec3<f32>) -> vec2<f32> {
964    let t0 = (bmin - ro) * inv_rd;
965    let t1 = (bmax - ro) * inv_rd;
966    let tmin = min(t0, t1);
967    let tmax = max(t0, t1);
968    return vec2<f32>(max(max(tmin.x, tmin.y), tmin.z), min(min(tmax.x, tmax.y), tmax.z));
969}
970
971// Slot del brick que contiene la celda gruesa LÓGICA `cc` (0 = vacío).
972// Streaming toroidal: la textura de indirección es un ring buffer indexado por
973// `world_brick mod cdim`. La celda lógica `cc` (relativa a la ventana, [0,cdim))
974// se traduce a su celda FÍSICA sumando el origen de brick y envolviendo. Sin
975// scroll (`scroll = 0`) la física = la lógica (camino clásico, sin cambios).
976fn slot_at(cc: vec3<i32>) -> u32 {
977    if (any(cc < vec3<i32>(0)) || any(vec3<f32>(cc) >= u.cdim.xyz)) { return 0u; }
978    let cd = vec3<i32>(u.cdim.xyz);
979    let bo = vec3<i32>(u.scroll.xyz);
980    let phys = ((cc + bo) % cd + cd) % cd; // floormod (maneja origen negativo)
981    return textureLoad(indir, phys, 0).r;
982}
983
984// Voxel fino vía indirección → pool. `.a > 0.5` = sólido.
985fn voxel_at(voxel: vec3<f32>) -> vec4<f32> {
986    let vi = vec3<i32>(voxel);
987    if (any(vi < vec3<i32>(0)) || any(vec3<f32>(vi) >= u.grid_dim.xyz)) { return vec4<f32>(0.0); }
988    let bu = i32(u.grid_dim.w);
989    let cc = vi / bu;
990    let s = slot_at(cc);
991    if (s == 0u) { return vec4<f32>(0.0); }
992    let slot = i32(s - 1u);
993    let ax = i32(u.atlas.x);
994    let ay = i32(u.atlas.y);
995    let acell = vec3<i32>(slot % ax, (slot / ax) % ay, slot / (ax * ay));
996    let local = vi - cc * bu;
997    return textureLoad(pool, acell * bu + local, 0);
998}
999
1000struct Hit {
1001    hit: bool,
1002    vox: vec3<f32>,
1003    normal: vec3<f32>,
1004    t: f32,
1005};
1006
1007// DDA de dos niveles sobre el brick pool: marcha la grilla gruesa (indirección),
1008// baja a la fina sólo en bricks con slot.
1009fn trace(ro: vec3<f32>, rd_in: vec3<f32>, dim: vec3<f32>, B: f32) -> Hit {
1010    var h: Hit;
1011    h.hit = false;
1012
1013    let safe_rd = vec3<f32>(
1014        select(rd_in.x, 1e-6, abs(rd_in.x) < 1e-6),
1015        select(rd_in.y, 1e-6, abs(rd_in.y) < 1e-6),
1016        select(rd_in.z, 1e-6, abs(rd_in.z) < 1e-6),
1017    );
1018    let inv_rd = 1.0 / safe_rd;
1019    let step = sign(safe_rd);
1020
1021    let tb = ray_box(ro, inv_rd, vec3<f32>(0.0), dim);
1022    if (tb.x > tb.y || tb.y < 0.0) { return h; }
1023    let t_enter = max(tb.x, 0.0);
1024
1025    let te0 = (vec3<f32>(0.0) - ro) * inv_rd;
1026    let te1 = (dim - ro) * inv_rd;
1027    let temin = min(te0, te1);
1028    var box_n = vec3<f32>(0.0, 0.0, -step.z);
1029    if (tb.x == temin.x) { box_n = vec3<f32>(-step.x, 0.0, 0.0); }
1030    else if (tb.x == temin.y) { box_n = vec3<f32>(0.0, -step.y, 0.0); }
1031
1032    let cdim = ceil(dim / B);
1033    let p_enter = ro + safe_rd * t_enter;
1034    var cc = clamp(floor(p_enter / B), vec3<f32>(0.0), cdim - 1.0);
1035    let t_delta_c = abs(B * inv_rd);
1036    var t_max_c = ((cc + max(step, vec3<f32>(0.0))) * B - ro) * inv_rd;
1037    var t_cell = t_enter;
1038    var cnorm = box_n;
1039
1040    let max_coarse = i32(cdim.x + cdim.y + cdim.z) + 3;
1041    for (var ci = 0; ci < max_coarse; ci = ci + 1) {
1042        if (slot_at(vec3<i32>(cc)) != 0u) {
1043            var voxel = clamp(floor(ro + safe_rd * (t_cell + 1e-4)), vec3<f32>(0.0), dim - 1.0);
1044            var t_max_f = ((voxel + max(step, vec3<f32>(0.0))) - ro) * inv_rd;
1045            let t_delta_f = abs(inv_rd);
1046            var fnorm = cnorm;
1047            var t_vox = t_cell;
1048            let max_fine = i32(B) * 3 + 3;
1049            for (var fi = 0; fi < max_fine; fi = fi + 1) {
1050                if (any(voxel < vec3<f32>(0.0)) || any(voxel >= dim)) { return h; }
1051                if (any(floor(voxel / B) != cc)) { break; }
1052                let c = voxel_at(voxel);
1053                if (c.a > 0.5) {
1054                    h.hit = true;
1055                    h.vox = voxel;
1056                    h.normal = fnorm;
1057                    h.t = t_vox;
1058                    return h;
1059                }
1060                if (t_max_f.x < t_max_f.y && t_max_f.x < t_max_f.z) {
1061                    voxel.x = voxel.x + step.x;
1062                    t_vox = t_max_f.x;
1063                    t_max_f.x = t_max_f.x + t_delta_f.x;
1064                    fnorm = vec3<f32>(-step.x, 0.0, 0.0);
1065                } else if (t_max_f.y < t_max_f.z) {
1066                    voxel.y = voxel.y + step.y;
1067                    t_vox = t_max_f.y;
1068                    t_max_f.y = t_max_f.y + t_delta_f.y;
1069                    fnorm = vec3<f32>(0.0, -step.y, 0.0);
1070                } else {
1071                    voxel.z = voxel.z + step.z;
1072                    t_vox = t_max_f.z;
1073                    t_max_f.z = t_max_f.z + t_delta_f.z;
1074                    fnorm = vec3<f32>(0.0, 0.0, -step.z);
1075                }
1076            }
1077        }
1078        if (t_max_c.x < t_max_c.y && t_max_c.x < t_max_c.z) {
1079            cc.x = cc.x + step.x;
1080            t_cell = t_max_c.x;
1081            t_max_c.x = t_max_c.x + t_delta_c.x;
1082            cnorm = vec3<f32>(-step.x, 0.0, 0.0);
1083        } else if (t_max_c.y < t_max_c.z) {
1084            cc.y = cc.y + step.y;
1085            t_cell = t_max_c.y;
1086            t_max_c.y = t_max_c.y + t_delta_c.y;
1087            cnorm = vec3<f32>(0.0, -step.y, 0.0);
1088        } else {
1089            cc.z = cc.z + step.z;
1090            t_cell = t_max_c.z;
1091            t_max_c.z = t_max_c.z + t_delta_c.z;
1092            cnorm = vec3<f32>(0.0, 0.0, -step.z);
1093        }
1094        if (any(cc < vec3<f32>(0.0)) || any(cc >= cdim)) { return h; }
1095    }
1096    return h;
1097}
1098
1099fn occ_at(p: vec3<i32>, dim: vec3<f32>) -> f32 {
1100    return select(0.0, 1.0, voxel_at(vec3<f32>(p)).a > 0.5);
1101}
1102
1103fn vertex_ao(s1: f32, s2: f32, c: f32) -> f32 {
1104    if (s1 > 0.5 && s2 > 0.5) { return 0.0; }
1105    return (3.0 - (s1 + s2 + c)) / 3.0;
1106}
1107
1108fn compute_ao(voxel: vec3<f32>, normal: vec3<f32>, p: vec3<f32>, dim: vec3<f32>) -> f32 {
1109    var t1 = vec3<i32>(0, 1, 0);
1110    var t2 = vec3<i32>(0, 0, 1);
1111    if (abs(normal.y) > 0.5) { t1 = vec3<i32>(1, 0, 0); t2 = vec3<i32>(0, 0, 1); }
1112    else if (abs(normal.z) > 0.5) { t1 = vec3<i32>(1, 0, 0); t2 = vec3<i32>(0, 1, 0); }
1113
1114    let base = vec3<i32>(voxel) + vec3<i32>(normal);
1115    let s1m = occ_at(base - t1, dim);
1116    let s1p = occ_at(base + t1, dim);
1117    let s2m = occ_at(base - t2, dim);
1118    let s2p = occ_at(base + t2, dim);
1119    let ao_mm = vertex_ao(s1m, s2m, occ_at(base - t1 - t2, dim));
1120    let ao_pm = vertex_ao(s1p, s2m, occ_at(base + t1 - t2, dim));
1121    let ao_mp = vertex_ao(s1m, s2p, occ_at(base - t1 + t2, dim));
1122    let ao_pp = vertex_ao(s1p, s2p, occ_at(base + t1 + t2, dim));
1123
1124    let fp = fract(p);
1125    let uu = dot(vec3<f32>(t1), fp);
1126    let vv = dot(vec3<f32>(t2), fp);
1127    return mix(mix(ao_mm, ao_pm, uu), mix(ao_mp, ao_pp, uu), vv);
1128}
1129
1130struct EHit {
1131    hit: bool,
1132    t: f32,
1133    normal: vec3<f32>,
1134    color: vec3<f32>,
1135};
1136
1137fn trace_entities(ro: vec3<f32>, rd: vec3<f32>, max_t: f32) -> EHit {
1138    var best: EHit;
1139    best.hit = false;
1140    best.t = max_t;
1141    let safe_rd = vec3<f32>(
1142        select(rd.x, 1e-6, abs(rd.x) < 1e-6),
1143        select(rd.y, 1e-6, abs(rd.y) < 1e-6),
1144        select(rd.z, 1e-6, abs(rd.z) < 1e-6),
1145    );
1146    let inv_rd = 1.0 / safe_rd;
1147    let n = i32(ent.count.x);
1148    for (var i = 0; i < n; i = i + 1) {
1149        let e = ent.ents[i];
1150        let bmin = e.pos.xyz - e.half.xyz;
1151        let bmax = e.pos.xyz + e.half.xyz;
1152        let tb = ray_box(ro, inv_rd, bmin, bmax);
1153        if (tb.x <= tb.y && tb.x > 1e-3 && tb.x < best.t) {
1154            best.hit = true;
1155            best.t = tb.x;
1156            best.color = e.color.rgb;
1157            let p = ro + rd * tb.x;
1158            let c = (bmin + bmax) * 0.5;
1159            let d = max((bmax - bmin) * 0.5, vec3<f32>(1e-4));
1160            let q = (p - c) / d;
1161            let aq = abs(q);
1162            if (aq.x >= aq.y && aq.x >= aq.z) { best.normal = vec3<f32>(sign(q.x), 0.0, 0.0); }
1163            else if (aq.y >= aq.z) { best.normal = vec3<f32>(0.0, sign(q.y), 0.0); }
1164            else { best.normal = vec3<f32>(0.0, 0.0, sign(q.z)); }
1165        }
1166    }
1167    return best;
1168}
1169
1170// Cielo procedural: gradiente horizonte→cenit por la altura del rayo, con un
1171// disco solar y su halo. Es también el color al que desvanece la niebla.
1172fn sky_color(rd: vec3<f32>) -> vec3<f32> {
1173    let t = clamp(rd.y * 0.5 + 0.5, 0.0, 1.0);
1174    var c = mix(u.sky_horizon.xyz, u.sky_zenith.xyz, pow(t, 0.55));
1175    let s = max(dot(rd, u.sun_dir.xyz), 0.0);
1176    c = c + vec3<f32>(1.0, 0.96, 0.84) * pow(s, 260.0) * 1.6;   // disco
1177    c = c + vec3<f32>(1.0, 0.90, 0.72) * pow(s, 9.0) * 0.16;    // halo
1178    return c;
1179}
1180
1181// Profundidad NDC (0..1, wgpu) del punto golpeado `p` (en espacio de grilla):
1182// se lleva a mundo (la grilla está centrada en el origen → world = p - dim/2) y
1183// se proyecta con la matriz forward. Permite que las mallas se ocluyan con los
1184// voxels en el depth buffer compartido de `Scene3d`.
1185fn frag_depth(p: vec3<f32>, dim: vec3<f32>) -> f32 {
1186    let clip = u.vp * vec4<f32>(p - dim * 0.5, 1.0);
1187    return clip.z / clip.w;
1188}
1189
1190struct FOut {
1191    @location(0) color: vec4<f32>,
1192    @builtin(frag_depth) depth: f32,
1193};
1194
1195// **God rays** (in-scattering volumétrico): marcha el rayo de cámara `ro+rd·t`
1196// hasta `max_t` tomando muestras; en cada una tira un shadow ray hacia el sol y
1197// acumula "cuánta luz solar llega a ese punto de la niebla". El resultado, pesado
1198// por una fase hacia-adelante (brilla mirando al sol) y por la densidad de niebla,
1199// es la luz dispersada hacia el ojo → haces visibles cruzando el aire. Devuelve un
1200// escalar [0,1+] que se multiplica por el color cálido del sol. Coste: N shadow
1201// rays por píxel — sólo se llama si `u.sun_dir.w > 0` y hay niebla.
1202fn god_rays(ro: vec3<f32>, rd: vec3<f32>, max_t: f32, dim: vec3<f32>, B: f32) -> f32 {
1203    let strength = u.sun_dir.w;
1204    let fogd = u.sky_zenith.w;
1205    if (strength <= 0.0 || fogd <= 0.0) { return 0.0; }
1206    // Fase hacia-adelante PRIMERO: los haces sólo se ven mirando hacia el sol. Los
1207    // píxeles que miran lejos aportan ~0 → cortamos ANTES de la marcha (la marcha es
1208    // lo caro: N shadow rays/píxel). God rays queda casi gratis salvo en la cuña que
1209    // enfrenta al sol, que es justo donde el efecto importa.
1210    let phase = pow(max(dot(rd, u.sun_dir.xyz), 0.0), 6.0);
1211    if (phase < 0.003) { return 0.0; }
1212    let march_len = min(max_t, 220.0);
1213    let steps = 8; // pocas muestras: el efecto es atmosférico, no necesita más (y cuesta)
1214    let dt = march_len / f32(steps);
1215    // Dither del paso inicial para romper el bandeo de pocas muestras.
1216    let jitter = fract(sin(dot(rd.xy, vec2<f32>(12.9898, 78.233))) * 43758.5453);
1217    var acc = 0.0;
1218    for (var i = 0; i < steps; i = i + 1) {
1219        let tt = (f32(i) + jitter) * dt;
1220        if (tt >= max_t) { break; }
1221        let sp = ro + rd * tt;
1222        let sh = trace(sp, u.sun_dir.xyz, dim, B);
1223        acc = acc + select(1.0, 0.0, sh.hit); // 1 si el sol llega, 0 si está en sombra
1224    }
1225    acc = acc / f32(steps);
1226    let depth_fac = 1.0 - exp(-march_len * fogd); // más aire, más dispersión
1227    return acc * strength * phase * depth_fac;
1228}
1229
1230@fragment
1231fn fs(in: VOut) -> FOut {
1232    let p_near = u.inv_vp * vec4<f32>(in.ndc, 0.0, 1.0);
1233    let p_far  = u.inv_vp * vec4<f32>(in.ndc, 1.0, 1.0);
1234    let ro_world = u.cam_eye.xyz;
1235    let rd = normalize(p_far.xyz / p_far.w - p_near.xyz / p_near.w);
1236
1237    let dim = u.grid_dim.xyz;
1238    let B = u.grid_dim.w;
1239    let ro = ro_world + dim * 0.5;
1240
1241    let h = trace(ro, rd, dim, B);
1242    let t_vox = select(1e30, h.t, h.hit);
1243    let eh = trace_entities(ro, rd, t_vox);
1244
1245    let fog_density = u.sky_zenith.w;
1246
1247    var albedo: vec3<f32>;
1248    var normal: vec3<f32>;
1249    var p: vec3<f32>;
1250    var ao: f32;
1251    var t_hit: f32;
1252    if (eh.hit) {
1253        albedo = eh.color;
1254        normal = eh.normal;
1255        p = ro + rd * eh.t;
1256        ao = 1.0;
1257        t_hit = eh.t;
1258    } else if (h.hit) {
1259        albedo = voxel_at(h.vox).rgb;
1260        normal = h.normal;
1261        p = ro + rd * h.t;
1262        ao = compute_ao(h.vox, h.normal, p, dim);
1263        t_hit = h.t;
1264    } else {
1265        // Sin impacto: con niebla activa pintamos cielo propio (a profundidad
1266        // lejana, así una malla por delante igual se dibuja); sin niebla,
1267        // descartamos para dejar ver el fondo vello (comportamiento clásico).
1268        if (fog_density > 0.0) {
1269            // Cielo + god rays: los haces se ven sobre todo contra el fondo abierto.
1270            let gr = god_rays(ro, rd, 1e30, dim, B);
1271            let glow = vec3<f32>(1.0, 0.95, 0.82) * gr;
1272            var sky: FOut;
1273            sky.color = vec4<f32>(sky_color(rd) + glow, 1.0);
1274            sky.depth = 1.0;
1275            return sky;
1276        }
1277        discard;
1278    }
1279
1280    let ldir = u.sun_dir.xyz;
1281    let diff = max(dot(normal, ldir), 0.0);
1282
1283    let so = p + normal * 0.5 + ldir * 0.01;
1284    let sh_v = trace(so, ldir, dim, B);
1285    let sh_e = trace_entities(so, ldir, 1e30);
1286    let shadow = select(1.0, 0.25, sh_v.hit || sh_e.hit);
1287
1288    // Luz CON COLOR (look cinematográfico) sin uniforms nuevos: el color del sol
1289    // sale de su elevación (cálido al ras del horizonte → blanco en lo alto) y el
1290    // ambiente del color del cielo (rebote frío del cenit). El mood se controla
1291    // moviendo `sun_dir` y la paleta de cielo, que ya viajan en el uniform.
1292    let ao_term = 0.35 + 0.65 * ao;
1293    let sun_h = clamp(u.sun_dir.y, 0.0, 1.0);
1294    let sun_col = mix(vec3<f32>(1.0, 0.52, 0.24), vec3<f32>(1.0, 0.97, 0.9), sun_h);
1295    // Ambiente tintado por el cielo pero con ~la misma luminancia que el flat 0.32
1296    // de antes (no oscurece las caras que no ven el sol).
1297    let amb_col = mix(vec3<f32>(0.45), u.sky_zenith.xyz, 0.45) * 0.70;
1298    var light = amb_col + sun_col * (0.78 * diff * shadow);
1299
1300    // Luces puntuales coloreadas (antorchas/lámparas): caída suave por distancia
1301    // + sombra dura opcional (un shadow ray hacia la luz, acotado a la distancia
1302    // a la luz: si un voxel/entidad intercepta *antes* de llegar, la luz no llega).
1303    // `p` está en espacio de voxel, igual que `light.pos`.
1304    let nlights = i32(u.n_lights.x);
1305    let pt_shadows = u.n_lights.y > 0.5;
1306    for (var li = 0; li < nlights; li = li + 1) {
1307        let lp = u.lights[2 * li];
1308        let lc = u.lights[2 * li + 1];
1309        let to = lp.xyz - p;
1310        let d = length(to);
1311        let range = max(lp.w, 1e-3);
1312        var att = clamp(1.0 - d / range, 0.0, 1.0);
1313        att = att * att; // caída cuadrática suave
1314        let ldir2 = to / max(d, 1e-3);
1315        let ndl = max(dot(normal, ldir2), 0.0);
1316        var vis = 1.0;
1317        if (pt_shadows && att > 0.0 && ndl > 0.0) {
1318            // Sale de la superficie un pelo hacia la luz para no auto-sombrearse.
1319            let lso = p + normal * 0.5 + ldir2 * 0.01;
1320            let bias = 0.75; // tolerancia: no contar el propio voxel ni la luz.
1321            let lrad = lc.w; // radio de área (penumbra); 0 = sombra dura.
1322            if (lrad <= 0.0) {
1323                // Sombra dura: un solo shadow ray hacia el centro de la luz.
1324                let hv = trace(lso, ldir2, dim, B);
1325                let blocked_v = hv.hit && hv.t < d - bias;
1326                let he = trace_entities(lso, ldir2, d - bias);
1327                vis = select(1.0, 0.0, blocked_v || he.hit);
1328            } else {
1329                // Sombra blanda: la luz es un disco de radio `lrad`. Se reparten
1330                // varios shadow rays hacia puntos del disco (perpendicular a la
1331                // dirección a la luz) y se promedia la visibilidad → penumbra.
1332                // Base ortonormal del plano del disco.
1333                var up = vec3<f32>(0.0, 1.0, 0.0);
1334                if (abs(ldir2.y) > 0.9) { up = vec3<f32>(1.0, 0.0, 0.0); }
1335                let tx = normalize(cross(up, ldir2));
1336                let ty = cross(ldir2, tx);
1337                var occ = 0.0;
1338                for (var s = 0; s < SOFT_TAPS; s = s + 1) {
1339                    let o = SOFT_DISK[s] * lrad;
1340                    let lp2 = lp.xyz + tx * o.x + ty * o.y;
1341                    let to2 = lp2 - p;
1342                    let d2 = length(to2);
1343                    let dir2 = to2 / max(d2, 1e-3);
1344                    let hv = trace(lso, dir2, dim, B);
1345                    let bv = hv.hit && hv.t < d2 - bias;
1346                    let he = trace_entities(lso, dir2, d2 - bias);
1347                    if (bv || he.hit) { occ = occ + 1.0; }
1348                }
1349                vis = 1.0 - occ / f32(SOFT_TAPS);
1350            }
1351        }
1352        light = light + lc.rgb * (att * ndl * vis);
1353    }
1354
1355    var color = albedo * light * ao_term;
1356
1357    // Niebla / perspectiva aérea: lo lejano desvanece hacia el cielo en esa
1358    // dirección, lo que hace legible el borde de un mundo grande.
1359    if (fog_density > 0.0) {
1360        let f = 1.0 - exp(-t_hit * fog_density);
1361        color = mix(color, sky_color(rd), f);
1362        // God rays: haz volumétrico en el aire DELANTE de la superficie golpeada.
1363        let gr = god_rays(ro, rd, t_hit, dim, B);
1364        color = color + vec3<f32>(1.0, 0.95, 0.82) * gr;
1365    }
1366
1367    var out: FOut;
1368    out.color = vec4<f32>(color, 1.0);
1369    out.depth = frag_depth(p, dim);
1370    return out;
1371}
1372"#;