gioser

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main llimphi-3d/src/voxel_renderer.rs Rust · 2086 siq'ikuna · 92.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · 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/// **Cielo de noche**: estrellas, luna y luz de luna. Va aparte de
112/// [`Atmosphere`] porque ésa se arma con literal de struct en muchos lados, y
113/// porque es otra decisión: la atmósfera dice el color del aire, esto dice si
114/// es de noche.
115///
116/// `amount = 0.0` (default) = de día, byte-idéntico a antes. Con `> 0` la
117/// dirección del sol pasa a ser la de **la luna**: el disco se vuelve pálido y
118/// nítido, la luz directa se enfría y se apaga, el ambiente toma el azul del
119/// cenit, y los rayos que no pegan nada pintan cielo propio aunque no haya
120/// niebla (sin eso, una noche sin bruma dejaba ver el fondo vello).
121#[derive(Clone, Copy, Debug, Default, PartialEq)]
122pub struct NightSky {
123    /// Cuánto de noche, `0..1`. Mezcla la luz de día con la de luna.
124    pub amount: f32,
125    /// Densidad de estrellas, `0..1`. Sólo sobre el horizonte, y no tiñen la
126    /// niebla: una estrella no se ve a través de un cerro lejano.
127    pub stars: f32,
128    /// Radio angular de la luna (radianes); `0` = sin luna. La real mide
129    /// ~0,0045; un dibujo la quiere bastante más grande (~0,05).
130    pub moon: f32,
131}
132
133/// Renderer de voxels por ray-march de dos niveles sobre un brick pool sparse.
134pub struct VoxelRenderer {
135    pool: wgpu::Texture,
136    /// Atlas **paralelo** al `pool` (mismos slots/dimensiones): por voxel guarda el
137    /// **detalle sub-voxel** autorado (`Rg8Uint` = `[rugosidad, bisel]` con offset
138    /// +1; `[0,0]` = sin autor → look global). Se hornea al armar/rearmar el pool y
139    /// crece con él (`grow_layers`); `sync`/`scroll_to` NO lo re-suben (el detalle es
140    /// propiedad de construcción del material, no un canal editado en vivo). Su
141    /// isosuperficie/indirección son las mismas del pool → el shader lo direcciona
142    /// igual. Todo `[0,0]` (caso común) = retrocompat byte a byte.
143    dpool: wgpu::Texture,
144    indir: wgpu::Texture,
145    bind_group: wgpu::BindGroup,
146    /// Layout del bind group, guardado para re-armar el bind group cuando el pool
147    /// crece (la textura del atlas cambia → su view también).
148    bgl: wgpu::BindGroupLayout,
149    pipeline: wgpu::RenderPipeline,
150    ubuf: wgpu::Buffer,
151    ubuf_ent: wgpu::Buffer,
152    ubuf_ser: wgpu::Buffer,
153    dim: [u32; 3],
154    cdim: [u32; 3],
155    /// Slots del atlas por eje (cuántos bricks entran en cada dimensión).
156    atlas: [u32; 3],
157    /// Por celda gruesa: `slot + 1`, o `0` si el brick está vacío. Espeja la
158    /// textura de indirección en CPU (para el camino incremental).
159    slots: Vec<u32>,
160    /// Slots libres del pool (free list para allocar bricks nuevos).
161    free: Vec<u32>,
162    /// Origen de brick de la ventana (streaming toroidal): `slots`/indirección se
163    /// indexan por celda **física** = `(celda_lógica + brick_origin) mod cdim`.
164    /// `[0,0,0]` = sin scroll (lógica = física, camino clásico).
165    brick_origin: [i32; 3],
166    /// Dirección hacia el sol (normalizada). Editable antes de `render`.
167    pub sun_dir: [f32; 3],
168    /// Atmósfera (cielo + niebla). `fog_density = 0` → comportamiento clásico.
169    pub atmosphere: Atmosphere,
170    /// Depth buffer propio para el camino *standalone* ([`Self::render`]); en
171    /// `Scene3d` se usa el depth compartido y este queda sin tocar.
172    depth: Option<crate::scene::DepthBuffer>,
173    /// Entidades vivas — se empacan y suben en cada `render`.
174    pub entities: Vec<Entity3d>,
175    /// **Seres SDF** — personajes orgánicos de primitivas analíticas fundidas por
176    /// `smin`, sphere-traceados en el mismo pase (ver [`crate::ser`]). Coordenadas
177    /// de voxel `[0, dim]`. Se empacan y suben en cada `render`; lista vacía =
178    /// costo ~cero (retrocompat).
179    pub sers: Vec<crate::ser::SdfSer>,
180    /// Luces puntuales coloreadas (≤ [`MAX_LIGHTS`]) — antorchas/lámparas que
181    /// iluminan voxels y entidades cercanos. Se empacan y suben en cada `render`.
182    pub lights: Vec<PointLight>,
183    /// Si las luces puntuales proyectan sombra (un shadow ray por luz hacia su
184    /// posición, acotado a la distancia a la luz). `true` por defecto. Apagarlo
185    /// recupera el MVP plano (más barato) — útil para comparar off/on.
186    pub point_shadows: bool,
187    /// **Rugosidad sub-voxel** (bump mapping procedural). Perturba la normal de
188    /// sombreado del voxel golpeado con el gradiente tangencial de un ruido de
189    /// valor 3D → la superficie deja de leerse rectangular a corta distancia
190    /// (roca/tierra granulada) sin tocar geometría ni memoria. `0.0` = off
191    /// (comportamiento clásico, cara plana axial). La perturbación se atenúa
192    /// sola en voxels lustrosos (vidrio/metal, `alpha` bajo) → sólo lo mate se
193    /// rugosiza. Rango útil ≈ `0.0..1.5`.
194    pub rugosidad_amp: f32,
195    /// Frecuencia del ruido de rugosidad (ciclos por voxel). Más alto = grano
196    /// más fino/apretado. Default `1.6`. Sólo importa si `rugosidad_amp > 0`.
197    pub rugosidad_freq: f32,
198    /// **Bisel / suavizado sub-voxel** (pendientes diagonales). Refina el hit del
199    /// ray-march contra la **isosuperficie trilineal de la ocupación** (derivada de
200    /// la forma, sin storage extra): el terreno escalonado se lee como rampa
201    /// diagonal y las esquinas convexas quedan biseladas, sin dejar de ser "bloque
202    /// tallado". `0.0` = off (voxel duro clásico, byte-idéntico); `1.0` = superficie
203    /// suave plena; intermedio = bisel parcial. Las zonas planas quedan planas (la
204    /// isosuperficie sólo se aparta de la cara en bordes/esquinas). Cuesta ~varias
205    /// muestras del campo por píxel golpeado → sólo se paga con `bisel > 0`.
206    pub bisel: f32,
207    /// Modo diagnóstico: pinta la **normal** de sombreado como color (`n·0.5+0.5`)
208    /// en vez del sombreado. Lo usa la certificación (`example bisel_demo`) para
209    /// medir qué fracción de la superficie dejó de ser axial. `false` en producción.
210    pub debug_normals: bool,
211    /// **Silueta sub-voxel POR MATERIAL** (`false` = off, retrocompat byte-idéntico).
212    /// Con `bisel` global prendido, `trace_smooth` traza la isosuperficie GLOBAL: la
213    /// silueta sale igual para todos (el `bisel` por voxel sólo cambiaba la NORMAL de
214    /// sombreado, no el contorno). Con este flag, el CAMPO mismo mezcla escalón↔trilineal
215    /// según el `bisel` POR VOXEL del material sólido cercano → un material duro (bisel
216    /// bajo) conserva la silueta axial/blocky mientras otro liso (bisel alto) la redondea,
217    /// en el MISMO frame. Cuesta: samplea el detalle de las 8 esquinas por evaluación del
218    /// campo → sólo pagalo cuando de veras quieres siluetas distintas por material. Sólo
219    /// tiene efecto con `bisel > 0` (que enciende `trace_smooth`).
220    pub silueta_por_voxel: bool,
221    /// **Cantidad de oclusión ambiental** `[0,1]` (`1.0` = clásica, retrocompat
222    /// byte-idéntico; `0.0` = sin AO). La AO se computa **axialmente** (`axis_of` de la
223    /// normal indexa voxels enteros); sobre una superficie REDONDEADA (bisel) el eje
224    /// dominante salta a lo largo de la esfera y la AO pinta **anillos concéntricos** —
225    /// correcto para bloques, artefacto para un ser orgánico. Bajala (≈0,2–0,4) al rendir
226    /// seres/blobs voxel suaves para que el cuerpo lea liso. Viaja en `n_lights.w`.
227    pub ao_amount: f32,
228    /// **Cielo de noche** (ver [`NightSky`]). Default = de día.
229    pub night: NightSky,
230}
231
232impl VoxelRenderer {
233    /// Crea el renderer y construye el brick pool a partir de `grid`.
234    pub fn new(
235        device: &wgpu::Device,
236        queue: &wgpu::Queue,
237        color_format: wgpu::TextureFormat,
238        grid: &VoxelGrid,
239    ) -> Self {
240        let dim = grid.dim();
241        let cdim = [
242            dim[0].div_ceil(BRICK),
243            dim[1].div_ceil(BRICK),
244            dim[2].div_ceil(BRICK),
245        ];
246        let n_cells = (cdim[0] * cdim[1] * cdim[2]) as usize;
247
248        // Bricks ocupados → capacidad con holgura para crecer (M3).
249        let occupied: u32 = (0..cdim[2])
250            .flat_map(|cz| (0..cdim[1]).flat_map(move |cy| (0..cdim[0]).map(move |cx| (cx, cy, cz))))
251            .filter(|&(cx, cy, cz)| grid.brick_occupied(BRICK, cx, cy, cz) != 0)
252            .count() as u32;
253        let want = occupied + occupied / 2 + 64;
254        // Atlas cúbico-ish: ax·ay·az ≥ want.
255        let ax = ((want as f64).cbrt().ceil() as u32).max(1);
256        let ay = ax;
257        let az = want.div_ceil(ax * ay).max(1);
258        let capacity = ax * ay * az;
259        let atlas = [ax, ay, az];
260
261        let pool = device.create_texture(&wgpu::TextureDescriptor {
262            label: Some("llimphi-3d-voxel-pool"),
263            size: extent([ax * BRICK, ay * BRICK, az * BRICK]),
264            mip_level_count: 1,
265            sample_count: 1,
266            dimension: wgpu::TextureDimension::D3,
267            format: wgpu::TextureFormat::Rgba8Unorm,
268            // COPY_SRC para poder copiar el atlas a uno más grande al crecer.
269            usage: wgpu::TextureUsages::TEXTURE_BINDING
270                | wgpu::TextureUsages::COPY_DST
271                | wgpu::TextureUsages::COPY_SRC,
272            view_formats: &[],
273        });
274        let pool_view = pool.create_view(&wgpu::TextureViewDescriptor::default());
275
276        // Atlas de detalle sub-voxel, espejo del pool (mismos slots). Rg8Uint = dos
277        // bytes por voxel: `[rugosidad, bisel]` (con offset +1; `[0,0]` = sin autor).
278        let dpool = device.create_texture(&wgpu::TextureDescriptor {
279            label: Some("llimphi-3d-voxel-dpool"),
280            size: extent([ax * BRICK, ay * BRICK, az * BRICK]),
281            mip_level_count: 1,
282            sample_count: 1,
283            dimension: wgpu::TextureDimension::D3,
284            format: wgpu::TextureFormat::Rg8Uint,
285            usage: wgpu::TextureUsages::TEXTURE_BINDING
286                | wgpu::TextureUsages::COPY_DST
287                | wgpu::TextureUsages::COPY_SRC,
288            view_formats: &[],
289        });
290        let dpool_view = dpool.create_view(&wgpu::TextureViewDescriptor::default());
291
292        let indir = device.create_texture(&wgpu::TextureDescriptor {
293            label: Some("llimphi-3d-voxel-indir"),
294            size: extent(cdim),
295            mip_level_count: 1,
296            sample_count: 1,
297            dimension: wgpu::TextureDimension::D3,
298            format: wgpu::TextureFormat::R32Uint,
299            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
300            view_formats: &[],
301        });
302        let indir_view = indir.create_view(&wgpu::TextureViewDescriptor::default());
303
304        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
305            label: Some("llimphi-3d-voxel-shader"),
306            source: wgpu::ShaderSource::Wgsl(shader_source().into()),
307        });
308
309        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
310            label: Some("llimphi-3d-voxel-bgl"),
311            entries: &[
312                wgpu::BindGroupLayoutEntry {
313                    binding: 0,
314                    visibility: wgpu::ShaderStages::FRAGMENT,
315                    ty: wgpu::BindingType::Texture {
316                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
317                        view_dimension: wgpu::TextureViewDimension::D3,
318                        multisampled: false,
319                    },
320                    count: None,
321                },
322                wgpu::BindGroupLayoutEntry {
323                    binding: 1,
324                    visibility: wgpu::ShaderStages::FRAGMENT,
325                    ty: wgpu::BindingType::Texture {
326                        sample_type: wgpu::TextureSampleType::Uint,
327                        view_dimension: wgpu::TextureViewDimension::D3,
328                        multisampled: false,
329                    },
330                    count: None,
331                },
332                uniform_entry(2),
333                uniform_entry(3),
334                uniform_entry(5),
335                // Atlas de detalle sub-voxel (Rg8Uint, direccionado como el pool).
336                wgpu::BindGroupLayoutEntry {
337                    binding: 4,
338                    visibility: wgpu::ShaderStages::FRAGMENT,
339                    ty: wgpu::BindingType::Texture {
340                        sample_type: wgpu::TextureSampleType::Uint,
341                        view_dimension: wgpu::TextureViewDimension::D3,
342                        multisampled: false,
343                    },
344                    count: None,
345                },
346            ],
347        });
348
349        let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
350            label: Some("llimphi-3d-voxel-ubuf"),
351            // inv_vp(64)+cam_eye(16)+grid_dim/brick(16)+sun(16)+cdim(16)+atlas(16)
352            // +sky_zenith/fog(16)+sky_horizon(16)+vp(64)+scroll(16)
353            // +n_lights(16)+lights(8×16=128)+noche(16)
354            size: 416,
355            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
356            mapped_at_creation: false,
357        });
358        let ubuf_ent = device.create_buffer(&wgpu::BufferDescriptor {
359            label: Some("llimphi-3d-voxel-ubuf-ent"),
360            size: (16 + MAX_ENTITIES * 48) as u64,
361            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
362            mapped_at_creation: false,
363        });
364        let ubuf_ser = device.create_buffer(&wgpu::BufferDescriptor {
365            label: Some("llimphi-3d-voxel-ubuf-ser"),
366            // count(16) + hdrs(N×48) + prims(M×64) — layout de `ser::pack_sers`.
367            size: (16 + crate::ser::VOXEL_MAX_SERS * 48 + crate::ser::VOXEL_MAX_SER_PRIMS * 64)
368                as u64,
369            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
370            mapped_at_creation: false,
371        });
372
373        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
374            label: Some("llimphi-3d-voxel-bg"),
375            layout: &bgl,
376            entries: &[
377                wgpu::BindGroupEntry {
378                    binding: 0,
379                    resource: wgpu::BindingResource::TextureView(&pool_view),
380                },
381                wgpu::BindGroupEntry {
382                    binding: 1,
383                    resource: wgpu::BindingResource::TextureView(&indir_view),
384                },
385                wgpu::BindGroupEntry {
386                    binding: 2,
387                    resource: ubuf.as_entire_binding(),
388                },
389                wgpu::BindGroupEntry {
390                    binding: 3,
391                    resource: ubuf_ent.as_entire_binding(),
392                },
393                wgpu::BindGroupEntry {
394                    binding: 4,
395                    resource: wgpu::BindingResource::TextureView(&dpool_view),
396                },
397                wgpu::BindGroupEntry {
398                    binding: 5,
399                    resource: ubuf_ser.as_entire_binding(),
400                },
401            ],
402        });
403
404        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
405            label: Some("llimphi-3d-voxel-pl"),
406            bind_group_layouts: &[Some(&bgl)],
407            immediate_size: 0,
408        });
409
410        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
411            label: Some("llimphi-3d-voxel-pipeline"),
412            layout: Some(&pipeline_layout),
413            vertex: wgpu::VertexState {
414                module: &shader,
415                entry_point: Some("vs"),
416                compilation_options: Default::default(),
417                buffers: &[],
418            },
419            primitive: wgpu::PrimitiveState {
420                topology: wgpu::PrimitiveTopology::TriangleList,
421                ..Default::default()
422            },
423            // Escribe profundidad (del voxel golpeado) → convive con mallas en
424            // un depth buffer compartido (`Scene3d`): el render volumétrico y el
425            // de triángulos se ocluyen correctamente entre sí.
426            depth_stencil: Some(wgpu::DepthStencilState {
427                format: crate::scene::DEPTH_FORMAT,
428                depth_write_enabled: Some(true),
429                // LessEqual (no Less): el cielo en los misses escribe profundidad
430                // lejana (1.0) y debe pasar contra el clear 1.0; un `Less` lo
431                // rechazaría y dejaría ver el fondo negro. Sólo hay un fragmento
432                // de voxel por píxel (el del rayo), así que no hay z-fighting.
433                depth_compare: Some(wgpu::CompareFunction::LessEqual),
434                stencil: wgpu::StencilState::default(),
435                bias: wgpu::DepthBiasState::default(),
436            }),
437            multisample: wgpu::MultisampleState::default(),
438            fragment: Some(wgpu::FragmentState {
439                module: &shader,
440                entry_point: Some("fs"),
441                compilation_options: Default::default(),
442                targets: &[Some(wgpu::ColorTargetState {
443                    format: color_format,
444                    blend: None,
445                    write_mask: wgpu::ColorWrites::ALL,
446                })],
447            }),
448            multiview_mask: None,
449            cache: None,
450        });
451
452        let mut r = Self {
453            pool,
454            dpool,
455            indir,
456            bind_group,
457            bgl,
458            pipeline,
459            ubuf,
460            ubuf_ent,
461            ubuf_ser,
462            dim,
463            cdim,
464            atlas,
465            slots: vec![0u32; n_cells],
466            free: Vec::new(),
467            brick_origin: [0, 0, 0],
468            sun_dir: normalize3([0.5, 1.0, 0.35]),
469            atmosphere: Atmosphere::default(),
470            depth: None,
471            entities: Vec::new(),
472            sers: Vec::new(),
473            lights: Vec::new(),
474            point_shadows: true,
475            rugosidad_amp: 0.0,
476            rugosidad_freq: 1.6,
477            bisel: 0.0,
478            debug_normals: false,
479            silueta_por_voxel: false,
480            ao_amount: 1.0,
481            night: NightSky::default(),
482        };
483
484        // Poblar el pool: cada brick ocupado toma un slot incremental.
485        let mut next: u32 = 0;
486        for cz in 0..cdim[2] {
487            for cy in 0..cdim[1] {
488                for cx in 0..cdim[0] {
489                    if grid.brick_occupied(BRICK, cx, cy, cz) != 0 {
490                        let slot = next;
491                        next += 1;
492                        let idx = r.cell_idx(cx, cy, cz);
493                        r.slots[idx] = slot + 1;
494                        r.upload_brick(queue, slot, grid, cx, cy, cz);
495                        r.upload_brick_detail(queue, slot, grid, cx, cy, cz);
496                    }
497                }
498            }
499        }
500        r.free = (next..capacity).rev().collect();
501        r.upload_indirection_full(queue);
502        r
503    }
504
505    /// Bricks ocupados (slots usados) y total de celdas gruesas — para reportar
506    /// el ahorro de memoria del pool frente al denso.
507    pub fn brick_usage(&self) -> (u32, u32) {
508        let used = self.slots.iter().filter(|&&s| s != 0).count() as u32;
509        (used, (self.cdim[0] * self.cdim[1] * self.cdim[2]))
510    }
511
512    /// Bytes del pool (atlas) vs. lo que costaría el grid denso completo.
513    pub fn memory_bytes(&self) -> (u64, u64) {
514        let (used, _) = self.brick_usage();
515        let per_brick = (BRICK * BRICK * BRICK * 4) as u64;
516        let pool = used as u64 * per_brick;
517        let dense = (self.dim[0] * self.dim[1] * self.dim[2] * 4) as u64;
518        (pool, dense)
519    }
520
521    #[inline]
522    fn cell_idx(&self, cx: u32, cy: u32, cz: u32) -> usize {
523        (cx + cy * self.cdim[0] + cz * self.cdim[0] * self.cdim[1]) as usize
524    }
525
526    /// Índice **físico** (en `slots`/indirección) de la celda gruesa **lógica**
527    /// `c` (relativa a la ventana): `(c + brick_origin) mod cdim`. Espeja el
528    /// `slot_at` del shader. Con `brick_origin = 0` es `cell_idx(c)` directo.
529    #[inline]
530    fn phys_cell(&self, c: [i32; 3]) -> usize {
531        let p = [
532            floormod(c[0] + self.brick_origin[0], self.cdim[0] as i32) as u32,
533            floormod(c[1] + self.brick_origin[1], self.cdim[1] as i32) as u32,
534            floormod(c[2] + self.brick_origin[2], self.cdim[2] as i32) as u32,
535        ];
536        self.cell_idx(p[0], p[1], p[2])
537    }
538
539    /// Origen del slot en el atlas (en celdas de brick).
540    fn slot_origin(&self, slot: u32) -> [u32; 3] {
541        let ax = self.atlas[0];
542        let ay = self.atlas[1];
543        [slot % ax, (slot / ax) % ay, slot / (ax * ay)]
544    }
545
546    fn upload_brick(&self, queue: &wgpu::Queue, slot: u32, grid: &VoxelGrid, cx: u32, cy: u32, cz: u32) {
547        let data = grid.extract_brick(BRICK, cx, cy, cz);
548        let o = self.slot_origin(slot);
549        write_3d(
550            queue,
551            &self.pool,
552            [o[0] * BRICK, o[1] * BRICK, o[2] * BRICK],
553            [BRICK, BRICK, BRICK],
554            4,
555            &data,
556        );
557    }
558
559    /// Sube el brick de **detalle sub-voxel** al `dpool` (espeja `upload_brick`,
560    /// mismo slot/origen, 2 bytes/voxel). Se llama junto a `upload_brick` al armar
561    /// el pool y al hornear bricks nuevos.
562    fn upload_brick_detail(&self, queue: &wgpu::Queue, slot: u32, grid: &VoxelGrid, cx: u32, cy: u32, cz: u32) {
563        let data = grid.extract_brick_detail(BRICK, cx, cy, cz);
564        let o = self.slot_origin(slot);
565        write_3d(
566            queue,
567            &self.dpool,
568            [o[0] * BRICK, o[1] * BRICK, o[2] * BRICK],
569            [BRICK, BRICK, BRICK],
570            2,
571            &data,
572        );
573    }
574
575    fn upload_indirection_full(&self, queue: &wgpu::Queue) {
576        let mut bytes = Vec::with_capacity(self.slots.len() * 4);
577        for &s in &self.slots {
578            bytes.extend_from_slice(&s.to_ne_bytes());
579        }
580        write_3d(queue, &self.indir, [0; 3], self.cdim, 4, &bytes);
581    }
582
583    /// **Actualización incremental (M3).** Sube sólo los bricks tocados por la
584    /// región mutada: re-sube cada brick afectado a su slot (allocando slots
585    /// nuevos para bricks que pasan de vacío→ocupado, liberándolos al revés) y
586    /// re-sube la sub-región de indirección. Devuelve los bytes subidos.
587    pub fn sync(&mut self, queue: &wgpu::Queue, grid: &mut VoxelGrid) -> u32 {
588        let Some(r) = grid.take_dirty() else {
589            return 0;
590        };
591        let cmin = [r[0] / BRICK, r[1] / BRICK, r[2] / BRICK];
592        let cmax = [r[3] / BRICK, r[4] / BRICK, r[5] / BRICK];
593        let mut uploaded = 0u32;
594        let per_brick = BRICK * BRICK * BRICK * 4;
595
596        for cz in cmin[2]..=cmax[2] {
597            for cy in cmin[1]..=cmax[1] {
598                for cx in cmin[0]..=cmax[0] {
599                    // Celda FÍSICA (espeja el `slot_at` toroidal del shader); con
600                    // `brick_origin = 0` es la celda lógica directa.
601                    let idx = self.phys_cell([cx as i32, cy as i32, cz as i32]);
602                    let occ = grid.brick_occupied(BRICK, cx, cy, cz) != 0;
603                    let cur = self.slots[idx];
604                    if occ {
605                        let slot = if cur != 0 {
606                            cur - 1
607                        } else {
608                            match self.free.pop() {
609                                Some(s) => {
610                                    self.slots[idx] = s + 1;
611                                    s
612                                }
613                                None => {
614                                    // Pool lleno: el brick no entra (raro con la
615                                    // holgura inicial). Lo saltamos sin romper.
616                                    continue;
617                                }
618                            }
619                        };
620                        self.upload_brick(queue, slot, grid, cx, cy, cz);
621                        uploaded += per_brick;
622                    } else if cur != 0 {
623                        // Brick vaciado: liberar el slot (el atlas queda
624                        // huérfano pero la indirección en 0 lo hace invisible).
625                        self.free.push(cur - 1);
626                        self.slots[idx] = 0;
627                    }
628                }
629            }
630        }
631
632        // Re-subir la indirección tocada. Sin scroll (caso común de edición), la
633        // física = la lógica → sub-región contigua (barato). Con scroll, las
634        // celdas físicas están envueltas (no contiguas) → re-subimos la
635        // indirección entera (es chica: cdim³ u32).
636        if self.brick_origin == [0, 0, 0] {
637            let cext = [
638                cmax[0] - cmin[0] + 1,
639                cmax[1] - cmin[1] + 1,
640                cmax[2] - cmin[2] + 1,
641            ];
642            let mut ind = Vec::with_capacity((cext[0] * cext[1] * cext[2] * 4) as usize);
643            for cz in cmin[2]..=cmax[2] {
644                for cy in cmin[1]..=cmax[1] {
645                    for cx in cmin[0]..=cmax[0] {
646                        ind.extend_from_slice(&self.slots[self.cell_idx(cx, cy, cz)].to_ne_bytes());
647                    }
648                }
649            }
650            write_3d(queue, &self.indir, cmin, cext, 4, &ind);
651            uploaded + ind.len() as u32
652        } else {
653            self.upload_indirection_full(queue);
654            uploaded + (self.slots.len() * 4) as u32
655        }
656    }
657
658    /// **Streaming toroidal (M6).** Desliza la ventana a `origin_voxel` (esquina
659    /// local `(0,0,0)` en coordenadas de mundo, alineada a brick) re-subiendo
660    /// **sólo los bricks que entran** — la franja nueva — sin reconstruir el
661    /// renderer ni re-subir la ventana entera. `grid` es la ventana ya generada
662    /// en ese origen (local `[0,dim)`), de la que se extraen los bricks de la
663    /// franja. Los bricks que salen se reemplazan en su misma celda física (la
664    /// textura es un ring buffer: `world_brick mod cdim`). Devuelve los bytes
665    /// subidos (≈ tamaño de la franja, no de la ventana). Llamar con
666    /// `origin_voxel` múltiplo de [`VOXEL_BRICK`].
667    pub fn scroll_to(
668        &mut self,
669        device: &wgpu::Device,
670        queue: &wgpu::Queue,
671        origin_voxel: [i32; 3],
672        grid: &VoxelGrid,
673    ) -> u32 {
674        let b = BRICK as i32;
675        debug_assert!(
676            origin_voxel.iter().all(|v| v % b == 0),
677            "scroll_to: origin_voxel debe estar alineado a VOXEL_BRICK"
678        );
679        let new = [origin_voxel[0] / b, origin_voxel[1] / b, origin_voxel[2] / b];
680        let old = self.brick_origin;
681        if new == old {
682            return 0;
683        }
684        // El uniform de scroll se actualiza ANTES de poblar para que `phys_cell`
685        // calcule las celdas físicas del nuevo origen.
686        self.brick_origin = new;
687
688        let cd = [self.cdim[0] as i32, self.cdim[1] as i32, self.cdim[2] as i32];
689        let mut uploaded = 0u32;
690        let per_brick = BRICK * BRICK * BRICK * 4;
691
692        let entered = |ccx: i32, ccy: i32, ccz: i32| -> bool {
693            let wb = [ccx + new[0], ccy + new[1], ccz + new[2]];
694            !((0..cd[0]).contains(&(wb[0] - old[0]))
695                && (0..cd[1]).contains(&(wb[1] - old[1]))
696                && (0..cd[2]).contains(&(wb[2] - old[2])))
697        };
698
699        // **Pre-crecer el pool si hace falta, ANTES de subir nada** (la copia del
700        // atlas viejo es estable, sin carrera con write_texture en vuelo). Cota
701        // segura del pico de slots: los usados ahora + los bricks que entran
702        // ocupados (los que salen aún no se liberaron). Crecer una vez al inicio.
703        let mut entered_occ = 0u32;
704        for ccz in 0..cd[2] {
705            for ccy in 0..cd[1] {
706                for ccx in 0..cd[0] {
707                    if entered(ccx, ccy, ccz)
708                        && grid.brick_occupied(BRICK, ccx as u32, ccy as u32, ccz as u32) != 0
709                    {
710                        entered_occ += 1;
711                    }
712                }
713            }
714        }
715        let used_now = self.slots.iter().filter(|&&s| s != 0).count() as u32;
716        let need = used_now + entered_occ;
717        while self.pool_capacity() < need {
718            self.grow_layers(device, queue);
719        }
720
721        // Recorre las celdas LÓGICAS de la ventana nueva; procesa sólo las que
722        // ENTRARON (su brick de mundo no estaba en la ventana vieja). El bulk
723        // (presente en ambas) conserva su contenido físico intacto.
724        for ccz in 0..cd[2] {
725            for ccy in 0..cd[1] {
726                for ccx in 0..cd[0] {
727                    if !entered(ccx, ccy, ccz) {
728                        continue;
729                    }
730                    // Celda física (= celda vieja que sale, por el ring buffer).
731                    let idx = self.phys_cell([ccx, ccy, ccz]);
732                    let (lx, ly, lz) = (ccx as u32, ccy as u32, ccz as u32);
733                    let occ = grid.brick_occupied(BRICK, lx, ly, lz) != 0;
734                    let cur = self.slots[idx];
735                    if occ {
736                        let slot = if cur != 0 {
737                            cur - 1
738                        } else {
739                            let s = self.free.pop().expect("capacidad pre-crecida");
740                            self.slots[idx] = s + 1;
741                            s
742                        };
743                        self.upload_brick(queue, slot, grid, lx, ly, lz);
744                        uploaded += per_brick;
745                    } else if cur != 0 {
746                        self.free.push(cur - 1);
747                        self.slots[idx] = 0;
748                    }
749                }
750            }
751        }
752
753        self.upload_indirection_full(queue);
754        uploaded + (self.slots.len() * 4) as u32
755    }
756
757    /// Capacidad del pool en slots de brick (`atlas.x·y·z`). Crece con
758    /// [`grow_layers`](Self::grow_layers).
759    pub fn pool_capacity(&self) -> u32 {
760        self.atlas[0] * self.atlas[1] * self.atlas[2]
761    }
762
763    /// **Crece el brick pool** agregando capas `z` al atlas (×1.5, mín +8 slots).
764    /// Sólo crece `atlas.z` para no remapear los slots existentes (`slot_origin`
765    /// depende de `atlas.x/y`, que quedan fijos): copia el atlas viejo al nuevo,
766    /// re-arma el bind group (la view del pool cambió) y agrega los slots nuevos a
767    /// la free list. Lo dispara `scroll_to` cuando la franja que entra no tiene
768    /// slots libres (ventana más densa que la inicial).
769    fn grow_layers(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
770        let [ax, ay, az] = self.atlas;
771        let new_az = az + (az / 2).max(8);
772        let new_pool = device.create_texture(&wgpu::TextureDescriptor {
773            label: Some("llimphi-3d-voxel-pool"),
774            size: extent([ax * BRICK, ay * BRICK, new_az * BRICK]),
775            mip_level_count: 1,
776            sample_count: 1,
777            dimension: wgpu::TextureDimension::D3,
778            format: wgpu::TextureFormat::Rgba8Unorm,
779            usage: wgpu::TextureUsages::TEXTURE_BINDING
780                | wgpu::TextureUsages::COPY_DST
781                | wgpu::TextureUsages::COPY_SRC,
782            view_formats: &[],
783        });
784        // El dpool crece en paralelo (mismos slots): la franja nueva queda en `[0,0]`
785        // (sin autor → look global), que es lo que corresponde al contenido streameado.
786        let new_dpool = device.create_texture(&wgpu::TextureDescriptor {
787            label: Some("llimphi-3d-voxel-dpool"),
788            size: extent([ax * BRICK, ay * BRICK, new_az * BRICK]),
789            mip_level_count: 1,
790            sample_count: 1,
791            dimension: wgpu::TextureDimension::D3,
792            format: wgpu::TextureFormat::Rg8Uint,
793            usage: wgpu::TextureUsages::TEXTURE_BINDING
794                | wgpu::TextureUsages::COPY_DST
795                | wgpu::TextureUsages::COPY_SRC,
796            view_formats: &[],
797        });
798
799        // Copia ambos atlas viejos (mismas dimensiones x/y, az capas) al nuevo.
800        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
801            label: Some("llimphi-3d-voxel-pool-grow"),
802        });
803        let copy = |enc: &mut wgpu::CommandEncoder, src: &wgpu::Texture, dst: &wgpu::Texture| {
804            enc.copy_texture_to_texture(
805                wgpu::TexelCopyTextureInfo {
806                    texture: src,
807                    mip_level: 0,
808                    origin: wgpu::Origin3d::ZERO,
809                    aspect: wgpu::TextureAspect::All,
810                },
811                wgpu::TexelCopyTextureInfo {
812                    texture: dst,
813                    mip_level: 0,
814                    origin: wgpu::Origin3d::ZERO,
815                    aspect: wgpu::TextureAspect::All,
816                },
817                extent([ax * BRICK, ay * BRICK, az * BRICK]),
818            );
819        };
820        copy(&mut enc, &self.pool, &new_pool);
821        copy(&mut enc, &self.dpool, &new_dpool);
822        queue.submit(std::iter::once(enc.finish()));
823
824        // Re-armar el bind group con las views nuevas (indir/ubufs intactos).
825        let pool_view = new_pool.create_view(&wgpu::TextureViewDescriptor::default());
826        let dpool_view = new_dpool.create_view(&wgpu::TextureViewDescriptor::default());
827        let indir_view = self.indir.create_view(&wgpu::TextureViewDescriptor::default());
828        self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
829            label: Some("llimphi-3d-voxel-bg"),
830            layout: &self.bgl,
831            entries: &[
832                wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&pool_view) },
833                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&indir_view) },
834                wgpu::BindGroupEntry { binding: 2, resource: self.ubuf.as_entire_binding() },
835                wgpu::BindGroupEntry { binding: 3, resource: self.ubuf_ent.as_entire_binding() },
836                wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&dpool_view) },
837                wgpu::BindGroupEntry { binding: 5, resource: self.ubuf_ser.as_entire_binding() },
838            ],
839        });
840
841        let old_cap = ax * ay * az;
842        let new_cap = ax * ay * new_az;
843        self.pool = new_pool;
844        self.dpool = new_dpool;
845        self.atlas[2] = new_az;
846        self.free.extend((old_cap..new_cap).rev());
847    }
848
849    /// Sube los uniforms (cámara/atmósfera/entidades) del frame. Lo llama tanto
850    /// [`Self::render`] (standalone) como [`Scene3d`](crate::Scene3d) antes de
851    /// abrir el pase compartido. `aspect` = w/h del viewport.
852    pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
853        let vp = camera.view_proj(aspect);
854        let inv_vp = vp.inverse();
855        let mut u = Vec::with_capacity(416);
856        for v in inv_vp.to_cols_array() {
857            u.extend_from_slice(&v.to_ne_bytes());
858        }
859        for v in [camera.eye.x, camera.eye.y, camera.eye.z, 0.0] {
860            u.extend_from_slice(&v.to_ne_bytes());
861        }
862        for v in [self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32, BRICK as f32] {
863            u.extend_from_slice(&v.to_ne_bytes());
864        }
865        let s = normalize3(self.sun_dir);
866        // sun_dir.w lleva la fuerza de god rays (in-scattering volumétrico).
867        for v in [s[0], s[1], s[2], self.atmosphere.god_rays.max(0.0)] {
868            u.extend_from_slice(&v.to_ne_bytes());
869        }
870        // cdim.w piggyback = amplitud de rugosidad sub-voxel (0 = off); atlas.w = su
871        // frecuencia. Reusan lanes que estaban en 0 → retrocompatible.
872        for v in [
873            self.cdim[0] as f32,
874            self.cdim[1] as f32,
875            self.cdim[2] as f32,
876            self.rugosidad_amp.max(0.0),
877        ] {
878            u.extend_from_slice(&v.to_ne_bytes());
879        }
880        for v in [
881            self.atlas[0] as f32,
882            self.atlas[1] as f32,
883            self.atlas[2] as f32,
884            self.rugosidad_freq.max(0.0),
885        ] {
886            u.extend_from_slice(&v.to_ne_bytes());
887        }
888        let a = &self.atmosphere;
889        for v in [
890            a.sky_zenith[0] as f32 / 255.0,
891            a.sky_zenith[1] as f32 / 255.0,
892            a.sky_zenith[2] as f32 / 255.0,
893            a.fog_density.max(0.0),
894        ] {
895            u.extend_from_slice(&v.to_ne_bytes());
896        }
897        // sky_horizon.w piggyback = bisel/suavizado sub-voxel (0 = off, retrocompat).
898        for v in [
899            a.sky_horizon[0] as f32 / 255.0,
900            a.sky_horizon[1] as f32 / 255.0,
901            a.sky_horizon[2] as f32 / 255.0,
902            self.bisel.clamp(0.0, 1.0),
903        ] {
904            u.extend_from_slice(&v.to_ne_bytes());
905        }
906        // Matriz forward (world→clip) para escribir frag_depth del voxel golpeado.
907        for v in vp.to_cols_array() {
908            u.extend_from_slice(&v.to_ne_bytes());
909        }
910        // Origen de brick (streaming toroidal): el shader envuelve la celda lógica.
911        // Se sube YA REDUCIDO a `[0, cdim)` (floormod) para que el `%` del shader
912        // nunca opere sobre un negativo — el `%` de WGSL sobre enteros con signo es
913        // ambiguo entre plataformas y rompía el wrap con orígenes negativos.
914        // scroll.w piggyback = flag de diagnóstico de normales (0 = sombreado normal).
915        for v in [
916            floormod(self.brick_origin[0], self.cdim[0] as i32) as f32,
917            floormod(self.brick_origin[1], self.cdim[1] as i32) as f32,
918            floormod(self.brick_origin[2], self.cdim[2] as i32) as f32,
919            if self.debug_normals { 1.0 } else { 0.0 },
920        ] {
921            u.extend_from_slice(&v.to_ne_bytes());
922        }
923        // Luces puntuales: count (vec4) + MAX_LIGHTS × [pos+range, color].
924        let nl = self.lights.len().min(MAX_LIGHTS);
925        let shadow_flag = if self.point_shadows { 1.0 } else { 0.0 };
926        // n_lights.z piggyback = flag de silueta sub-voxel por material (0 = off,
927        // retrocompat: el campo queda trilineal puro).
928        let silueta_flag = if self.silueta_por_voxel { 1.0 } else { 0.0 };
929        // n_lights.w piggyback = cantidad de AO [0,1] (1 = clásico byte-idéntico).
930        for v in [nl as f32, shadow_flag, silueta_flag, self.ao_amount.clamp(0.0, 1.0)] {
931            u.extend_from_slice(&v.to_ne_bytes());
932        }
933        for i in 0..MAX_LIGHTS {
934            let l = self.lights.get(i).copied().unwrap_or(PointLight {
935                pos: [0.0; 3],
936                color: [0.0; 3],
937                range: 1.0,
938                radius: 0.0,
939            });
940            for v in [l.pos[0], l.pos[1], l.pos[2], l.range] {
941                u.extend_from_slice(&v.to_ne_bytes());
942            }
943            // color.w lleva el radio de área (penumbra); 0 = sombra dura.
944            for v in [l.color[0], l.color[1], l.color[2], l.radius.max(0.0)] {
945                u.extend_from_slice(&v.to_ne_bytes());
946            }
947        }
948        // Noche: x = cuánto, y = estrellas, z = radio de la luna.
949        let nt = &self.night;
950        for v in [nt.amount.clamp(0.0, 1.0), nt.stars.clamp(0.0, 1.0), nt.moon.max(0.0), 0.0] {
951            u.extend_from_slice(&v.to_ne_bytes());
952        }
953        queue.write_buffer(&self.ubuf, 0, &u);
954
955        // Entidades: count (vec4) + array de [pos, half, color] (3×vec4 c/u).
956        let n = self.entities.len().min(MAX_ENTITIES);
957        let mut e = Vec::with_capacity(16 + MAX_ENTITIES * 48);
958        for v in [n as f32, 0.0, 0.0, 0.0] {
959            e.extend_from_slice(&v.to_ne_bytes());
960        }
961        for i in 0..MAX_ENTITIES {
962            let ent = self.entities.get(i).copied().unwrap_or(Entity3d {
963                pos: [0.0; 3],
964                half: [0.0; 3],
965                color: [0, 0, 0],
966            });
967            for v in [ent.pos[0], ent.pos[1], ent.pos[2], 0.0] {
968                e.extend_from_slice(&v.to_ne_bytes());
969            }
970            for v in [ent.half[0], ent.half[1], ent.half[2], 0.0] {
971                e.extend_from_slice(&v.to_ne_bytes());
972            }
973            for v in [
974                ent.color[0] as f32 / 255.0,
975                ent.color[1] as f32 / 255.0,
976                ent.color[2] as f32 / 255.0,
977                0.0,
978            ] {
979                e.extend_from_slice(&v.to_ne_bytes());
980            }
981        }
982        queue.write_buffer(&self.ubuf_ent, 0, &e);
983
984        // Seres SDF: header + primitivas aplanadas (layout de `ser::pack_sers`).
985        queue.write_buffer(&self.ubuf_ser, 0, &crate::ser::pack_sers(&self.sers));
986    }
987
988    /// Dibuja el fullscreen-triangle del ray-march en un pase **ya abierto** (con
989    /// color + depth). Lo usa [`Scene3d`](crate::Scene3d) para compartir el pase
990    /// con las mallas. Requiere `upload` previo en el mismo frame.
991    pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
992        pass.set_pipeline(&self.pipeline);
993        pass.set_bind_group(0, &self.bind_group, &[]);
994        pass.draw(0..3, 0..1);
995    }
996
997    /// Ray-marchea la grilla vista desde `camera` sobre `target` (camino
998    /// *standalone*, con depth propio). Color `LoadOp::Load`; misses por
999    /// `discard` (o cielo, con niebla). Grilla centrada en el origen.
1000    pub fn render(
1001        &mut self,
1002        device: &wgpu::Device,
1003        queue: &wgpu::Queue,
1004        encoder: &mut wgpu::CommandEncoder,
1005        target: &wgpu::TextureView,
1006        (w, h): (u32, u32),
1007        camera: &Camera3d,
1008    ) {
1009        if w == 0 || h == 0 {
1010            return;
1011        }
1012        self.upload(queue, w as f32 / h as f32, camera);
1013        crate::scene::ensure_depth(&mut self.depth, device, w, h);
1014        let depth_view = &self.depth.as_ref().unwrap().view;
1015
1016        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1017            label: Some("llimphi-3d-voxel-pass"),
1018            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1019                view: target,
1020                resolve_target: None,
1021                depth_slice: None,
1022                ops: wgpu::Operations {
1023                    load: wgpu::LoadOp::Load,
1024                    store: wgpu::StoreOp::Store,
1025                },
1026            })],
1027            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1028                view: depth_view,
1029                depth_ops: Some(wgpu::Operations {
1030                    load: wgpu::LoadOp::Clear(1.0),
1031                    store: wgpu::StoreOp::Store,
1032                }),
1033                stencil_ops: None,
1034            }),
1035            timestamp_writes: None,
1036            occlusion_query_set: None,
1037            multiview_mask: None,
1038        });
1039        self.draw(&mut pass);
1040    }
1041}
1042
1043fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
1044    wgpu::BindGroupLayoutEntry {
1045        binding,
1046        visibility: wgpu::ShaderStages::FRAGMENT,
1047        ty: wgpu::BindingType::Buffer {
1048            ty: wgpu::BufferBindingType::Uniform,
1049            has_dynamic_offset: false,
1050            min_binding_size: None,
1051        },
1052        count: None,
1053    }
1054}
1055
1056fn extent(dim: [u32; 3]) -> wgpu::Extent3d {
1057    wgpu::Extent3d {
1058        width: dim[0],
1059        height: dim[1],
1060        depth_or_array_layers: dim[2],
1061    }
1062}
1063
1064fn write_3d(
1065    queue: &wgpu::Queue,
1066    tex: &wgpu::Texture,
1067    origin: [u32; 3],
1068    ext: [u32; 3],
1069    bpp: u32,
1070    data: &[u8],
1071) {
1072    queue.write_texture(
1073        wgpu::TexelCopyTextureInfo {
1074            texture: tex,
1075            mip_level: 0,
1076            origin: wgpu::Origin3d {
1077                x: origin[0],
1078                y: origin[1],
1079                z: origin[2],
1080            },
1081            aspect: wgpu::TextureAspect::All,
1082        },
1083        data,
1084        wgpu::TexelCopyBufferLayout {
1085            offset: 0,
1086            bytes_per_row: Some(ext[0] * bpp),
1087            rows_per_image: Some(ext[1]),
1088        },
1089        extent(ext),
1090    );
1091}
1092
1093fn normalize3(v: [f32; 3]) -> [f32; 3] {
1094    let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
1095    [v[0] / l, v[1] / l, v[2] / l]
1096}
1097
1098/// Módulo con resultado siempre en `[0, m)` (maneja `a` negativo). Espeja el
1099/// `((x % m) + m) % m` del shader para el direccionamiento toroidal.
1100#[inline]
1101fn floormod(a: i32, m: i32) -> i32 {
1102    ((a % m) + m) % m
1103}
1104
1105const WGSL: &str = r#"
1106struct U {
1107    inv_vp: mat4x4<f32>,
1108    cam_eye: vec4<f32>,
1109    grid_dim: vec4<f32>,   // xyz = dim fino, w = brick size
1110    sun_dir: vec4<f32>,    // xyz = dirección hacia el sol (normalizada), w = fuerza de god rays
1111    cdim: vec4<f32>,       // xyz = dim grueso (celdas de brick)
1112    atlas: vec4<f32>,      // xyz = slots por eje en el atlas del pool
1113    sky_zenith: vec4<f32>, // xyz = color cenit, w = densidad de niebla (0 = off)
1114    sky_horizon: vec4<f32>,// xyz = color horizonte / hacia el que niebla desvanece
1115    vp: mat4x4<f32>,       // world→clip (forward) para escribir frag_depth
1116    scroll: vec4<f32>,     // xyz = origen de brick (streaming toroidal); 0 = sin scroll
1117    n_lights: vec4<f32>,   // x = luces puntuales, y = sombras on/off, z = silueta sub-voxel por material, w = cantidad de AO [0,1]
1118    lights: array<vec4<f32>, 8>, // por luz: [pos.xyz, range], [color.rgb, radio_area]
1119    noche: vec4<f32>,      // x = cuánto de noche (0 = día), y = estrellas, z = radio de la luna
1120};
1121
1122// Disco de muestreo para sombras blandas (penumbra): 8 taps en un patrón anular
1123// fijo (determinista, sin RNG por píxel — evita ruido temporal en el reel).
1124// Disco de muestreo para la penumbra: 16 taps en dos anillos (Poisson-ish) →
1125// borde de sombra suave sin bandeo. Más taps = penumbra más limpia (a más costo).
1126const SOFT_TAPS: i32 = 16;
1127const SOFT_DISK = array<vec2<f32>, 16>(
1128    // anillo interno (r≈0.4)
1129    vec2<f32>( 0.40,  0.00), vec2<f32>( 0.28,  0.28),
1130    vec2<f32>( 0.00,  0.40), vec2<f32>(-0.28,  0.28),
1131    vec2<f32>(-0.40,  0.00), vec2<f32>(-0.28, -0.28),
1132    vec2<f32>( 0.00, -0.40), vec2<f32>( 0.28, -0.28),
1133    // anillo externo (r≈0.85, rotado 22.5°)
1134    vec2<f32>( 0.785,  0.325), vec2<f32>( 0.325,  0.785),
1135    vec2<f32>(-0.325,  0.785), vec2<f32>(-0.785,  0.325),
1136    vec2<f32>(-0.785, -0.325), vec2<f32>(-0.325, -0.785),
1137    vec2<f32>( 0.325, -0.785), vec2<f32>( 0.785, -0.325),
1138);
1139struct Entity {
1140    pos: vec4<f32>,
1141    half: vec4<f32>,
1142    color: vec4<f32>,
1143};
1144struct EntU {
1145    count: vec4<f32>,
1146    ents: array<Entity, 64>,
1147};
1148@group(0) @binding(0) var pool: texture_3d<f32>;
1149@group(0) @binding(1) var indir: texture_3d<u32>;
1150@group(0) @binding(2) var<uniform> u: U;
1151@group(0) @binding(3) var<uniform> ent: EntU;
1152// Detalle sub-voxel por voxel (Rg8Uint = [rugosidad, bisel] con offset +1; [0,0] =
1153// sin autor → look global). Direccionado como el pool (mismos slots/indirección).
1154@group(0) @binding(4) var dpool: texture_3d<u32>;
1155
1156struct VOut {
1157    @builtin(position) clip: vec4<f32>,
1158    @location(0) ndc: vec2<f32>,
1159};
1160
1161@vertex
1162fn vs(@builtin(vertex_index) vi: u32) -> VOut {
1163    var p = array<vec2<f32>, 3>(
1164        vec2<f32>(-1.0, -1.0),
1165        vec2<f32>( 3.0, -1.0),
1166        vec2<f32>(-1.0,  3.0),
1167    );
1168    var out: VOut;
1169    out.clip = vec4<f32>(p[vi], 0.0, 1.0);
1170    out.ndc = p[vi];
1171    return out;
1172}
1173
1174fn ray_box(ro: vec3<f32>, inv_rd: vec3<f32>, bmin: vec3<f32>, bmax: vec3<f32>) -> vec2<f32> {
1175    let t0 = (bmin - ro) * inv_rd;
1176    let t1 = (bmax - ro) * inv_rd;
1177    let tmin = min(t0, t1);
1178    let tmax = max(t0, t1);
1179    return vec2<f32>(max(max(tmin.x, tmin.y), tmin.z), min(min(tmax.x, tmax.y), tmax.z));
1180}
1181
1182// Slot del brick que contiene la celda gruesa LÓGICA `cc` (0 = vacío).
1183// Streaming toroidal: la textura de indirección es un ring buffer indexado por
1184// `world_brick mod cdim`. La celda lógica `cc` (relativa a la ventana, [0,cdim))
1185// se traduce a su celda FÍSICA sumando el origen de brick y envolviendo. Sin
1186// scroll (`scroll = 0`) la física = la lógica (camino clásico, sin cambios).
1187fn slot_at(cc: vec3<i32>) -> u32 {
1188    if (any(cc < vec3<i32>(0)) || any(vec3<f32>(cc) >= u.cdim.xyz)) { return 0u; }
1189    let cd = vec3<i32>(u.cdim.xyz);
1190    let bo = vec3<i32>(u.scroll.xyz);
1191    let phys = ((cc + bo) % cd + cd) % cd; // floormod (maneja origen negativo)
1192    return textureLoad(indir, phys, 0).r;
1193}
1194
1195// Voxel fino vía indirección → pool. `.a > 0.5` = sólido.
1196fn voxel_at(voxel: vec3<f32>) -> vec4<f32> {
1197    let vi = vec3<i32>(voxel);
1198    if (any(vi < vec3<i32>(0)) || any(vec3<f32>(vi) >= u.grid_dim.xyz)) { return vec4<f32>(0.0); }
1199    let bu = i32(u.grid_dim.w);
1200    let cc = vi / bu;
1201    let s = slot_at(cc);
1202    if (s == 0u) { return vec4<f32>(0.0); }
1203    let slot = i32(s - 1u);
1204    let ax = i32(u.atlas.x);
1205    let ay = i32(u.atlas.y);
1206    let acell = vec3<i32>(slot % ax, (slot / ax) % ay, slot / (ax * ay));
1207    let local = vi - cc * bu;
1208    return textureLoad(pool, acell * bu + local, 0);
1209}
1210
1211// Detalle sub-voxel crudo del voxel `voxel` = `[rug_byte, bisel_byte]` (offset +1;
1212// `[0,0]` = sin autor). Direccionamiento idéntico a `voxel_at`, sobre el `dpool`.
1213fn detail_raw(voxel: vec3<f32>) -> vec2<u32> {
1214    let vi = vec3<i32>(voxel);
1215    if (any(vi < vec3<i32>(0)) || any(vec3<f32>(vi) >= u.grid_dim.xyz)) { return vec2<u32>(0u); }
1216    let bu = i32(u.grid_dim.w);
1217    let cc = vi / bu;
1218    let s = slot_at(cc);
1219    if (s == 0u) { return vec2<u32>(0u); }
1220    let slot = i32(s - 1u);
1221    let ax = i32(u.atlas.x);
1222    let ay = i32(u.atlas.y);
1223    let acell = vec3<i32>(slot % ax, (slot / ax) % ay, slot / (ax * ay));
1224    let local = vi - cc * bu;
1225    return textureLoad(dpool, acell * bu + local, 0).xy;
1226}
1227
1228// Detalle DECODIFICADO = `(has, rug_amp, bisel)`. `has` = 1 si el voxel tiene detalle
1229// autorado (sobrescribe el look global), 0 = usar el global. `rug_amp` en `[0,1.5]`,
1230// `bisel` en `[0,1]` (espeja el offset +1 de CPU: `(byte-1)/254`).
1231fn pv_detail(voxel: vec3<f32>) -> vec3<f32> {
1232    let d = detail_raw(voxel);
1233    if ((d.x | d.y) == 0u) { return vec3<f32>(0.0, 0.0, 0.0); }
1234    let rug = (f32(d.x) - 1.0) / 254.0 * 1.5;
1235    let bis = (f32(d.y) - 1.0) / 254.0;
1236    return vec3<f32>(1.0, max(rug, 0.0), clamp(bis, 0.0, 1.0));
1237}
1238
1239struct Hit {
1240    hit: bool,
1241    vox: vec3<f32>,
1242    normal: vec3<f32>,
1243    t: f32,
1244};
1245
1246// DDA de dos niveles sobre el brick pool: marcha la grilla gruesa (indirección),
1247// baja a la fina sólo en bricks con slot.
1248// DDA por el grid. `pass_glass` = los rayos de SOMBRA atraviesan el vidrio (voxels
1249// muy lustrosos, alpha < GLASS_A) para que las ventanas dejen entrar la luz; los
1250// rayos primarios (pass_glass=false) sí lo dibujan.
1251fn trace(ro: vec3<f32>, rd_in: vec3<f32>, dim: vec3<f32>, B: f32, pass_glass: bool) -> Hit {
1252    var h: Hit;
1253    h.hit = false;
1254
1255    let safe_rd = vec3<f32>(
1256        select(rd_in.x, 1e-6, abs(rd_in.x) < 1e-6),
1257        select(rd_in.y, 1e-6, abs(rd_in.y) < 1e-6),
1258        select(rd_in.z, 1e-6, abs(rd_in.z) < 1e-6),
1259    );
1260    let inv_rd = 1.0 / safe_rd;
1261    let step = sign(safe_rd);
1262
1263    let tb = ray_box(ro, inv_rd, vec3<f32>(0.0), dim);
1264    if (tb.x > tb.y || tb.y < 0.0) { return h; }
1265    let t_enter = max(tb.x, 0.0);
1266
1267    let te0 = (vec3<f32>(0.0) - ro) * inv_rd;
1268    let te1 = (dim - ro) * inv_rd;
1269    let temin = min(te0, te1);
1270    var box_n = vec3<f32>(0.0, 0.0, -step.z);
1271    if (tb.x == temin.x) { box_n = vec3<f32>(-step.x, 0.0, 0.0); }
1272    else if (tb.x == temin.y) { box_n = vec3<f32>(0.0, -step.y, 0.0); }
1273
1274    let cdim = ceil(dim / B);
1275    let p_enter = ro + safe_rd * t_enter;
1276    var cc = clamp(floor(p_enter / B), vec3<f32>(0.0), cdim - 1.0);
1277    let t_delta_c = abs(B * inv_rd);
1278    var t_max_c = ((cc + max(step, vec3<f32>(0.0))) * B - ro) * inv_rd;
1279    var t_cell = t_enter;
1280    var cnorm = box_n;
1281
1282    let max_coarse = i32(cdim.x + cdim.y + cdim.z) + 3;
1283    for (var ci = 0; ci < max_coarse; ci = ci + 1) {
1284        if (slot_at(vec3<i32>(cc)) != 0u) {
1285            var voxel = clamp(floor(ro + safe_rd * (t_cell + 1e-4)), vec3<f32>(0.0), dim - 1.0);
1286            var t_max_f = ((voxel + max(step, vec3<f32>(0.0))) - ro) * inv_rd;
1287            let t_delta_f = abs(inv_rd);
1288            var fnorm = cnorm;
1289            var t_vox = t_cell;
1290            let max_fine = i32(B) * 3 + 3;
1291            for (var fi = 0; fi < max_fine; fi = fi + 1) {
1292                if (any(voxel < vec3<f32>(0.0)) || any(voxel >= dim)) { return h; }
1293                if (any(floor(voxel / B) != cc)) { break; }
1294                let c = voxel_at(voxel);
1295                // Vidrio (muy lustroso): transparente a los rayos de sombra → la
1296                // ventana no proyecta sombra y deja pasar la luz del día.
1297                let es_vidrio = c.a < 0.62;
1298                if (c.a > 0.5 && !(pass_glass && es_vidrio)) {
1299                    h.hit = true;
1300                    h.vox = voxel;
1301                    h.normal = fnorm;
1302                    h.t = t_vox;
1303                    return h;
1304                }
1305                if (t_max_f.x < t_max_f.y && t_max_f.x < t_max_f.z) {
1306                    voxel.x = voxel.x + step.x;
1307                    t_vox = t_max_f.x;
1308                    t_max_f.x = t_max_f.x + t_delta_f.x;
1309                    fnorm = vec3<f32>(-step.x, 0.0, 0.0);
1310                } else if (t_max_f.y < t_max_f.z) {
1311                    voxel.y = voxel.y + step.y;
1312                    t_vox = t_max_f.y;
1313                    t_max_f.y = t_max_f.y + t_delta_f.y;
1314                    fnorm = vec3<f32>(0.0, -step.y, 0.0);
1315                } else {
1316                    voxel.z = voxel.z + step.z;
1317                    t_vox = t_max_f.z;
1318                    t_max_f.z = t_max_f.z + t_delta_f.z;
1319                    fnorm = vec3<f32>(0.0, 0.0, -step.z);
1320                }
1321            }
1322        }
1323        if (t_max_c.x < t_max_c.y && t_max_c.x < t_max_c.z) {
1324            cc.x = cc.x + step.x;
1325            t_cell = t_max_c.x;
1326            t_max_c.x = t_max_c.x + t_delta_c.x;
1327            cnorm = vec3<f32>(-step.x, 0.0, 0.0);
1328        } else if (t_max_c.y < t_max_c.z) {
1329            cc.y = cc.y + step.y;
1330            t_cell = t_max_c.y;
1331            t_max_c.y = t_max_c.y + t_delta_c.y;
1332            cnorm = vec3<f32>(0.0, -step.y, 0.0);
1333        } else {
1334            cc.z = cc.z + step.z;
1335            t_cell = t_max_c.z;
1336            t_max_c.z = t_max_c.z + t_delta_c.z;
1337            cnorm = vec3<f32>(0.0, 0.0, -step.z);
1338        }
1339        if (any(cc < vec3<f32>(0.0)) || any(cc >= cdim)) { return h; }
1340    }
1341    return h;
1342}
1343
1344fn occ_at(p: vec3<i32>, dim: vec3<f32>) -> f32 {
1345    return select(0.0, 1.0, voxel_at(vec3<f32>(p)).a > 0.5);
1346}
1347
1348fn vertex_ao(s1: f32, s2: f32, c: f32) -> f32 {
1349    if (s1 > 0.5 && s2 > 0.5) { return 0.0; }
1350    return (3.0 - (s1 + s2 + c)) / 3.0;
1351}
1352
1353fn compute_ao(voxel: vec3<f32>, normal: vec3<f32>, p: vec3<f32>, dim: vec3<f32>) -> f32 {
1354    var t1 = vec3<i32>(0, 1, 0);
1355    var t2 = vec3<i32>(0, 0, 1);
1356    if (abs(normal.y) > 0.5) { t1 = vec3<i32>(1, 0, 0); t2 = vec3<i32>(0, 0, 1); }
1357    else if (abs(normal.z) > 0.5) { t1 = vec3<i32>(1, 0, 0); t2 = vec3<i32>(0, 1, 0); }
1358
1359    let base = vec3<i32>(voxel) + vec3<i32>(normal);
1360    let s1m = occ_at(base - t1, dim);
1361    let s1p = occ_at(base + t1, dim);
1362    let s2m = occ_at(base - t2, dim);
1363    let s2p = occ_at(base + t2, dim);
1364    let ao_mm = vertex_ao(s1m, s2m, occ_at(base - t1 - t2, dim));
1365    let ao_pm = vertex_ao(s1p, s2m, occ_at(base + t1 - t2, dim));
1366    let ao_mp = vertex_ao(s1m, s2p, occ_at(base - t1 + t2, dim));
1367    let ao_pp = vertex_ao(s1p, s2p, occ_at(base + t1 + t2, dim));
1368
1369    let fp = fract(p);
1370    let uu = dot(vec3<f32>(t1), fp);
1371    let vv = dot(vec3<f32>(t2), fp);
1372    return mix(mix(ao_mm, ao_pm, uu), mix(ao_mp, ao_pp, uu), vv);
1373}
1374
1375struct EHit {
1376    hit: bool,
1377    t: f32,
1378    normal: vec3<f32>,
1379    color: vec3<f32>,
1380};
1381
1382fn trace_entities(ro: vec3<f32>, rd: vec3<f32>, max_t: f32) -> EHit {
1383    var best: EHit;
1384    best.hit = false;
1385    best.t = max_t;
1386    let safe_rd = vec3<f32>(
1387        select(rd.x, 1e-6, abs(rd.x) < 1e-6),
1388        select(rd.y, 1e-6, abs(rd.y) < 1e-6),
1389        select(rd.z, 1e-6, abs(rd.z) < 1e-6),
1390    );
1391    let inv_rd = 1.0 / safe_rd;
1392    let n = i32(ent.count.x);
1393    for (var i = 0; i < n; i = i + 1) {
1394        let e = ent.ents[i];
1395        let bmin = e.pos.xyz - e.half.xyz;
1396        let bmax = e.pos.xyz + e.half.xyz;
1397        let tb = ray_box(ro, inv_rd, bmin, bmax);
1398        if (tb.x <= tb.y && tb.x > 1e-3 && tb.x < best.t) {
1399            best.hit = true;
1400            best.t = tb.x;
1401            best.color = e.color.rgb;
1402            let p = ro + rd * tb.x;
1403            let c = (bmin + bmax) * 0.5;
1404            let d = max((bmax - bmin) * 0.5, vec3<f32>(1e-4));
1405            let q = (p - c) / d;
1406            let aq = abs(q);
1407            if (aq.x >= aq.y && aq.x >= aq.z) { best.normal = vec3<f32>(sign(q.x), 0.0, 0.0); }
1408            else if (aq.y >= aq.z) { best.normal = vec3<f32>(0.0, sign(q.y), 0.0); }
1409            else { best.normal = vec3<f32>(0.0, 0.0, sign(q.z)); }
1410        }
1411    }
1412    return best;
1413}
1414
1415// Cielo procedural: gradiente horizonte→cenit por la altura del rayo, con un
1416// disco solar y su halo. Es también el color al que desvanece la niebla.
1417fn sky_color(rd: vec3<f32>) -> vec3<f32> {
1418    let t = clamp(rd.y * 0.5 + 0.5, 0.0, 1.0);
1419    var c = mix(u.sky_horizon.xyz, u.sky_zenith.xyz, pow(t, 0.55));
1420    let s = max(dot(rd, u.sun_dir.xyz), 0.0);
1421    let nx = u.noche.x;
1422    if (nx <= 0.0) {
1423        c = c + vec3<f32>(1.0, 0.96, 0.84) * pow(s, 260.0) * 1.6;   // disco
1424        c = c + vec3<f32>(1.0, 0.90, 0.72) * pow(s, 9.0) * 0.16;    // halo
1425        return c;
1426    }
1427    // De noche el sol se apaga y en su dirección está la luna: un disco pálido
1428    // de borde nítido (se dibuja, no se deslumbra) con un halo frío corto.
1429    c = c + (vec3<f32>(1.0, 0.96, 0.84) * pow(s, 260.0) * 1.6
1430        + vec3<f32>(1.0, 0.90, 0.72) * pow(s, 9.0) * 0.16) * (1.0 - nx);
1431    let r = u.noche.z;
1432    if (r > 0.0) {
1433        let ang = acos(clamp(dot(rd, u.sun_dir.xyz), -1.0, 1.0));
1434        let disco = 1.0 - smoothstep(r * 0.9, r, ang);
1435        let halo = exp(-max(ang - r, 0.0) / (r * 2.5)) * 0.22;
1436        c = c + vec3<f32>(0.55, 0.65, 0.9) * halo * nx;
1437        c = mix(c, vec3<f32>(0.97, 0.95, 0.86), disco * nx);
1438    }
1439    return c;
1440}
1441
1442// Estrellas: el rayo cae en una celda de una grilla sobre la esfera; una
1443// fracción de las celdas (la densidad) lleva una estrella en un punto fijo de la
1444// celda, con brillo propio. Determinista: bloqueadas al mundo y estables en el
1445// reel. Van aparte de `sky_color` porque ésa también tiñe la niebla, y una
1446// estrella no se ve a través de un cerro lejano.
1447fn estrellas(rd: vec3<f32>) -> vec3<f32> {
1448    let nx = u.noche.x;
1449    let dens = u.noche.y;
1450    if (nx <= 0.0 || dens <= 0.0 || rd.y <= 0.0) { return vec3<f32>(0.0); }
1451    let q = rd * 170.0;
1452    let cel = floor(q);
1453    if (hash13(cel) > dens * 0.14) { return vec3<f32>(0.0); }
1454    let jit = vec3<f32>(hash13(cel + 7.1), hash13(cel + 3.7), hash13(cel + 1.3)) - 0.5;
1455    let d = length(q - (cel + 0.5 + jit * 0.6));
1456    let brillo = (1.0 - smoothstep(0.06, 0.24, d)) * (0.45 + 0.55 * hash13(cel + 11.0));
1457    // Se apagan contra el horizonte (ahí el cielo es más claro y hay bruma), y
1458    // detrás de la luna no hay ninguna.
1459    var tapa = smoothstep(0.0, 0.18, rd.y);
1460    let r = u.noche.z;
1461    if (r > 0.0) {
1462        let ang = acos(clamp(dot(rd, u.sun_dir.xyz), -1.0, 1.0));
1463        tapa = tapa * smoothstep(r, r * 1.6, ang);
1464    }
1465    return vec3<f32>(0.92, 0.94, 1.0) * brillo * tapa * nx;
1466}
1467
1468// El color de los haces de luz: cálido de día; frío y mucho más tenue bajo la
1469// luna (con la fuerza de día, el halo se tragaba medio cielo y sus estrellas).
1470fn luz_haces() -> vec3<f32> {
1471    return mix(vec3<f32>(1.0, 0.95, 0.82), vec3<f32>(0.62, 0.70, 0.92) * 0.25, u.noche.x);
1472}
1473
1474// Profundidad NDC (0..1, wgpu) del punto golpeado `p` (en espacio de grilla):
1475// se lleva a mundo (la grilla está centrada en el origen → world = p - dim/2) y
1476// se proyecta con la matriz forward. Permite que las mallas se ocluyan con los
1477// voxels en el depth buffer compartido de `Scene3d`.
1478fn frag_depth(p: vec3<f32>, dim: vec3<f32>) -> f32 {
1479    let clip = u.vp * vec4<f32>(p - dim * 0.5, 1.0);
1480    return clip.z / clip.w;
1481}
1482
1483// --- Rugosidad sub-voxel (bump procedural) ---------------------------------
1484// Hash 3D determinista → [0,1) (estilo iq). Sin RNG por-píxel: estable en el reel.
1485fn hash13(p3: vec3<f32>) -> f32 {
1486    var p = fract(p3 * 0.1031);
1487    p = p + dot(p, p.zyx + vec3<f32>(31.32));
1488    return fract((p.x + p.y) * p.z);
1489}
1490// Ruido de valor 3D (trilineal sobre hashes de lattice, con suavizado smoothstep).
1491fn vnoise(x: vec3<f32>) -> f32 {
1492    let i = floor(x);
1493    let f = fract(x);
1494    let w = f * f * (3.0 - 2.0 * f);
1495    let n000 = hash13(i + vec3<f32>(0.0, 0.0, 0.0));
1496    let n100 = hash13(i + vec3<f32>(1.0, 0.0, 0.0));
1497    let n010 = hash13(i + vec3<f32>(0.0, 1.0, 0.0));
1498    let n110 = hash13(i + vec3<f32>(1.0, 1.0, 0.0));
1499    let n001 = hash13(i + vec3<f32>(0.0, 0.0, 1.0));
1500    let n101 = hash13(i + vec3<f32>(1.0, 0.0, 1.0));
1501    let n011 = hash13(i + vec3<f32>(0.0, 1.0, 1.0));
1502    let n111 = hash13(i + vec3<f32>(1.0, 1.0, 1.0));
1503    let x00 = mix(n000, n100, w.x);
1504    let x10 = mix(n010, n110, w.x);
1505    let x01 = mix(n001, n101, w.x);
1506    let x11 = mix(n011, n111, w.x);
1507    return mix(mix(x00, x10, w.y), mix(x01, x11, w.y), w.z);
1508}
1509// Inclina la normal geométrica `geo_n` por el gradiente TANGENCIAL de una altura
1510// de ruido en `p` (espacio de voxel). `amp` = fuerza, `freq` = grano. Proyecta el
1511// gradiente al plano tangente → la normal se queda en el hemisferio exterior (sin
1512// auto-sombra) para amplitudes moderadas. Diferencias hacia-adelante (4 taps).
1513fn perturb_normal(geo_n: vec3<f32>, p: vec3<f32>, freq: f32, amp: f32) -> vec3<f32> {
1514    let sp = p * freq;
1515    let e = 0.5;
1516    let h0 = vnoise(sp);
1517    let grad = vec3<f32>(
1518        vnoise(sp + vec3<f32>(e, 0.0, 0.0)) - h0,
1519        vnoise(sp + vec3<f32>(0.0, e, 0.0)) - h0,
1520        vnoise(sp + vec3<f32>(0.0, 0.0, e)) - h0,
1521    );
1522    let tang = grad - geo_n * dot(grad, geo_n); // sólo la componente tangente
1523    return normalize(geo_n - tang * amp);
1524}
1525
1526// --- Bisel / suavizado sub-voxel (pendientes diagonales) -------------------
1527// Ocupación de la celda que contiene `v` (1 sólido / 0 vacío). Fuera de grilla → 0.
1528fn occ_f(v: vec3<f32>) -> f32 {
1529    return select(0.0, 1.0, voxel_at(v).a > 0.5);
1530}
1531// Campo escalar TRILINEAL = ocupación sobre los 8 centros de celda vecinos. Su
1532// isosuperficie (`field = 0.5`) es una superficie suave: plana donde la forma es
1533// plana, diagonal donde escalona, redondeada en las esquinas → el "voxel suave".
1534fn field_trilinear(p: vec3<f32>) -> f32 {
1535    let g = p - vec3<f32>(0.5);
1536    let i = floor(g);
1537    let f = fract(g);
1538    let c000 = occ_f(i + vec3<f32>(0.0, 0.0, 0.0));
1539    let c100 = occ_f(i + vec3<f32>(1.0, 0.0, 0.0));
1540    let c010 = occ_f(i + vec3<f32>(0.0, 1.0, 0.0));
1541    let c110 = occ_f(i + vec3<f32>(1.0, 1.0, 0.0));
1542    let c001 = occ_f(i + vec3<f32>(0.0, 0.0, 1.0));
1543    let c101 = occ_f(i + vec3<f32>(1.0, 0.0, 1.0));
1544    let c011 = occ_f(i + vec3<f32>(0.0, 1.0, 1.0));
1545    let c111 = occ_f(i + vec3<f32>(1.0, 1.0, 1.0));
1546    let x00 = mix(c000, c100, f.x);
1547    let x10 = mix(c010, c110, f.x);
1548    let x01 = mix(c001, c101, f.x);
1549    let x11 = mix(c011, c111, f.x);
1550    return mix(mix(x00, x10, f.y), mix(x01, x11, f.y), f.z);
1551}
1552
1553// **Suavidad local del material** en `p` para la silueta POR VOXEL: media, ponderada
1554// por peso trilineal Y ocupación, del `bisel` autorado de las 8 celdas vecinas SÓLIDAS
1555// (celda sin autor = 1.0 = suave). Un material duro (bisel bajo) tira su vecindario a
1556// 0 → el campo se vuelve escalón (silueta axial); uno liso lo deja trilineal (silueta
1557// redondeada). El ratio num/den ignora la magnitud del peso → aun en la celda vacía
1558// diagonal de una esquina convexa, si la única esquina sólida es dura, la suavidad ahí
1559// es baja y la esquina se conserva afilada. Sólo se llama con el flag prendido.
1560fn smoothness_local(p: vec3<f32>) -> f32 {
1561    let g = p - vec3<f32>(0.5);
1562    let i = floor(g);
1563    let f = fract(g);
1564    var num = 0.0;
1565    var den = 0.0;
1566    for (var dx = 0; dx < 2; dx = dx + 1) {
1567        for (var dy = 0; dy < 2; dy = dy + 1) {
1568            for (var dz = 0; dz < 2; dz = dz + 1) {
1569                let corner = i + vec3<f32>(f32(dx), f32(dy), f32(dz));
1570                if (occ_f(corner) > 0.5) {
1571                    let wx = select(1.0 - f.x, f.x, dx == 1);
1572                    let wy = select(1.0 - f.y, f.y, dy == 1);
1573                    let wz = select(1.0 - f.z, f.z, dz == 1);
1574                    let w = wx * wy * wz;
1575                    let pv = pv_detail(corner);
1576                    let s = select(1.0, pv.z, pv.x > 0.5); // sin autor = suave
1577                    num = num + w * s;
1578                    den = den + w;
1579                }
1580            }
1581        }
1582    }
1583    if (den < 1e-5) { return 1.0; }
1584    return num / den;
1585}
1586
1587// Campo escalar del ray-march suave. Por defecto = trilineal (voxel suave). Con la
1588// silueta POR VOXEL prendida (`n_lights.z`), mezcla escalón↔trilineal según la suavidad
1589// del material sólido cercano: `mix(occ_step, trilineal, s)` — `s=1` deja el cruce en la
1590// isosuperficie (redondeado), `s=0` lo empuja a la cara del voxel (silueta dura axial),
1591// porque el escalón `occ_f` salta 0→1 justo ahí. Así dos materiales comparten un frame
1592// con siluetas distintas. Fast path byte-idéntico cuando el flag está off o todo suave.
1593fn field(p: vec3<f32>) -> f32 {
1594    let suave = field_trilinear(p);
1595    if (u.n_lights.z < 0.5) { return suave; }
1596    let s = smoothness_local(p);
1597    if (s >= 0.999) { return suave; }
1598    let hard = occ_f(p); // escalón 0/1: su iso 0.5 cae en la cara del voxel (axial)
1599    return mix(hard, suave, s);
1600}
1601
1602// Normal exterior de la isosuperficie en `p` = opuesta del gradiente del campo
1603// (que apunta hacia el sólido). Diferencias hacia-adelante. Gradiente nulo (plano
1604// saturado) → arriba por defecto. **Usa el campo TRILINEAL** (no el mixto): con la
1605// silueta por voxel, el campo duro es un escalón `occ_f` cuyo gradiente por diferencias
1606// es degenerado (salta 0/1) y haría titilar `axis_of` en las caras planas. La geometría
1607// (silueta) sale del campo mixto; el NORMAL del trilineal → `axis_of(sn)` elige la cara
1608// axial limpia para el material duro, y `sn` queda redondeado para el liso.
1609fn smooth_normal(p: vec3<f32>) -> vec3<f32> {
1610    // Gradiente de campo DIFUMINADO por diferencias centrales ANCHAS. El trilineal
1611    // binario con una diferencia hacia-adelante corta (e=0.5) sólo toma direcciones
1612    // cuantizadas por el patrón de ocupación 2×2×2 local → la normal salta en
1613    // escalones a lo largo de una superficie curva grande → **anillos de cebolla**
1614    // en el sombreado (el artefacto que hacía "monstruo" a un ser redondo). Promediar
1615    // el gradiente sobre un radio mayor (diferencias centrales a dos radios, 0.9 y 1.8)
1616    // integra suficiente vecindario para que la normal varíe continua → esfera lisa.
1617    // Sólo se llama desde `trace_smooth` (bisel>0) → el terreno duro (bisel=0, `trace`)
1618    // queda byte-idéntico. Radios < que el semieje de un miembro fino (≈6 vox) para no
1619    // cancelar el gradiente cruzando la pieza.
1620    let e0 = 1.0;
1621    let e1 = 2.2;
1622    let gx =
1623        (field_trilinear(p + vec3<f32>(e0, 0.0, 0.0)) - field_trilinear(p - vec3<f32>(e0, 0.0, 0.0)))
1624        + (field_trilinear(p + vec3<f32>(e1, 0.0, 0.0)) - field_trilinear(p - vec3<f32>(e1, 0.0, 0.0)));
1625    let gy =
1626        (field_trilinear(p + vec3<f32>(0.0, e0, 0.0)) - field_trilinear(p - vec3<f32>(0.0, e0, 0.0)))
1627        + (field_trilinear(p + vec3<f32>(0.0, e1, 0.0)) - field_trilinear(p - vec3<f32>(0.0, e1, 0.0)));
1628    let gz =
1629        (field_trilinear(p + vec3<f32>(0.0, 0.0, e0)) - field_trilinear(p - vec3<f32>(0.0, 0.0, e0)))
1630        + (field_trilinear(p + vec3<f32>(0.0, 0.0, e1)) - field_trilinear(p - vec3<f32>(0.0, 0.0, e1)));
1631    let g = vec3<f32>(gx, gy, gz);
1632    let gl = length(g);
1633    if (gl < 1e-5) { return vec3<f32>(0.0, 1.0, 0.0); }
1634    return -g / gl;
1635}
1636
1637// Eje dominante de una normal (para AO/facetado): la cara axial más cercana.
1638fn axis_of(n: vec3<f32>) -> vec3<f32> {
1639    let a = abs(n);
1640    if (a.x >= a.y && a.x >= a.z) { return vec3<f32>(sign(n.x), 0.0, 0.0); }
1641    if (a.y >= a.z) { return vec3<f32>(0.0, sign(n.y), 0.0); }
1642    return vec3<f32>(0.0, 0.0, sign(n.z));
1643}
1644
1645// **Capa 2b — traza la ISOSUPERFICIE suave del campo** (no el voxel duro): el hit,
1646// la silueta Y la normal salen del cruce `field = iso`. Mantiene el DDA GRUESO para
1647// saltar bricks vacíos (aceleración), pero dentro de un brick con contenido marcha el
1648// campo a sub-pasos y biseca el primer cruce de fuera(<iso)→dentro(≥iso). `bisel`
1649// mezcla la normal hacia su eje dominante (facetado↔suave); la geometría siempre es
1650// la superficie suave cuando se llama. Sólo la rimaria (las sombras usan `trace`).
1651fn trace_smooth(ro: vec3<f32>, rd_in: vec3<f32>, dim: vec3<f32>, B: f32, bisel: f32) -> Hit {
1652    var h: Hit;
1653    h.hit = false;
1654    let safe_rd = vec3<f32>(
1655        select(rd_in.x, 1e-6, abs(rd_in.x) < 1e-6),
1656        select(rd_in.y, 1e-6, abs(rd_in.y) < 1e-6),
1657        select(rd_in.z, 1e-6, abs(rd_in.z) < 1e-6),
1658    );
1659    let inv_rd = 1.0 / safe_rd;
1660    let step = sign(safe_rd);
1661    let tb = ray_box(ro, inv_rd, vec3<f32>(0.0), dim);
1662    if (tb.x > tb.y || tb.y < 0.0) { return h; }
1663    let t_enter = max(tb.x, 0.0);
1664    let iso = 0.5;
1665    let cdim = ceil(dim / B);
1666    let p_enter = ro + safe_rd * t_enter;
1667    var cc = clamp(floor(p_enter / B), vec3<f32>(0.0), cdim - 1.0);
1668    let t_delta_c = abs(B * inv_rd);
1669    var t_max_c = ((cc + max(step, vec3<f32>(0.0))) * B - ro) * inv_rd;
1670    var t_cell = t_enter;
1671    let dt = 0.5;
1672    var f_prev = field(ro + safe_rd * t_enter) - iso;
1673    let max_coarse = i32(cdim.x + cdim.y + cdim.z) + 3;
1674    for (var ci = 0; ci < max_coarse; ci = ci + 1) {
1675        if (slot_at(vec3<i32>(cc)) != 0u) {
1676            let t_exit = min(min(t_max_c.x, t_max_c.y), t_max_c.z);
1677            var t = t_cell;
1678            // Cota de sub-pasos: un brick en diagonal mide hasta B·√3 de recorrido;
1679            // a dt=0.5 hacen falta ~3.5·B pasos para cruzarlo (menos → pinholes en
1680            // rayos diagonales, que se saltan el cruce en la parte lejana del brick).
1681            let max_sub = i32(B) * 4 + 4;
1682            for (var si = 0; si < max_sub; si = si + 1) {
1683                let t_next = min(t + dt, t_exit);
1684                let f_next = field(ro + safe_rd * t_next) - iso;
1685                if (f_prev < 0.0 && f_next >= 0.0) {
1686                    // Bisección del cruce en [t, t_next].
1687                    var a = t;
1688                    var b = t_next;
1689                    var fa = field(ro + safe_rd * a) - iso;
1690                    for (var k = 0; k < 6; k = k + 1) {
1691                        let tm = 0.5 * (a + b);
1692                        let fm = field(ro + safe_rd * tm) - iso;
1693                        if (fa * fm <= 0.0) { b = tm; } else { a = tm; fa = fm; }
1694                    }
1695                    let th = 0.5 * (a + b);
1696                    let ph = ro + safe_rd * th;
1697                    let sn = smooth_normal(ph);
1698                    // Color/brillo: la celda SÓLIDA más cercana entre las 8 esquinas del
1699                    // punto (en la isosuperficie hay ≥1 sólida). Muestrear la celda por
1700                    // proximidad al punto de hit y no por `floor(ph - n·k)` evita las
1701                    // motas negras (una celda con ocupación 0 aunque el campo ahí sea >½).
1702                    let bc = floor(ph - vec3<f32>(0.5));
1703                    var best_c = clamp(floor(ph - sn * 0.5), vec3<f32>(0.0), dim - 1.0);
1704                    var best_d = 1e9;
1705                    for (var dx = 0; dx < 2; dx = dx + 1) {
1706                        for (var dy = 0; dy < 2; dy = dy + 1) {
1707                            for (var dz = 0; dz < 2; dz = dz + 1) {
1708                                let cell = bc + vec3<f32>(f32(dx), f32(dy), f32(dz));
1709                                if (voxel_at(cell).a > 0.5) {
1710                                    let d = distance(cell + vec3<f32>(0.5), ph);
1711                                    if (d < best_d) { best_d = d; best_c = cell; }
1712                                }
1713                            }
1714                        }
1715                    }
1716                    // Bisel por voxel: si la celda golpeada trae detalle autorado, su
1717                    // bisel manda la mezcla facetado↔suave de la normal (dos materiales
1718                    // en la misma escena mezclan distinto); si no, el bisel global. La
1719                    // geometría/silueta ya salió de la isosuperficie global (barato).
1720                    let vox = clamp(best_c, vec3<f32>(0.0), dim - 1.0);
1721                    let pv = pv_detail(vox);
1722                    let bmix = select(bisel, pv.z, pv.x > 0.5);
1723                    h.hit = true;
1724                    h.t = th;
1725                    h.vox = vox;
1726                    h.normal = normalize(mix(axis_of(sn), sn, bmix));
1727                    return h;
1728                }
1729                f_prev = f_next;
1730                t = t_next;
1731                if (t >= t_exit) { break; }
1732            }
1733        }
1734        if (t_max_c.x < t_max_c.y && t_max_c.x < t_max_c.z) {
1735            cc.x = cc.x + step.x;
1736            t_cell = t_max_c.x;
1737            t_max_c.x = t_max_c.x + t_delta_c.x;
1738        } else if (t_max_c.y < t_max_c.z) {
1739            cc.y = cc.y + step.y;
1740            t_cell = t_max_c.y;
1741            t_max_c.y = t_max_c.y + t_delta_c.y;
1742        } else {
1743            cc.z = cc.z + step.z;
1744            t_cell = t_max_c.z;
1745            t_max_c.z = t_max_c.z + t_delta_c.z;
1746        }
1747        if (any(cc < vec3<f32>(0.0)) || any(cc >= cdim)) { return h; }
1748    }
1749    return h;
1750}
1751
1752struct FOut {
1753    @location(0) color: vec4<f32>,
1754    @builtin(frag_depth) depth: f32,
1755};
1756
1757// **God rays** (in-scattering volumétrico): marcha el rayo de cámara `ro+rd·t`
1758// hasta `max_t` tomando muestras; en cada una tira un shadow ray hacia el sol y
1759// acumula "cuánta luz solar llega a ese punto de la niebla". El resultado, pesado
1760// por una fase hacia-adelante (brilla mirando al sol) y por la densidad de niebla,
1761// es la luz dispersada hacia el ojo → haces visibles cruzando el aire. Devuelve un
1762// escalar [0,1+] que se multiplica por el color cálido del sol. Coste: N shadow
1763// rays por píxel — sólo se llama si `u.sun_dir.w > 0` y hay niebla.
1764fn god_rays(ro: vec3<f32>, rd: vec3<f32>, max_t: f32, dim: vec3<f32>, B: f32) -> f32 {
1765    let strength = u.sun_dir.w;
1766    let fogd = u.sky_zenith.w;
1767    if (strength <= 0.0 || fogd <= 0.0) { return 0.0; }
1768    // Fase hacia-adelante PRIMERO: los haces sólo se ven mirando hacia el sol. Los
1769    // píxeles que miran lejos aportan ~0 → cortamos ANTES de la marcha (la marcha es
1770    // lo caro: N shadow rays/píxel). God rays queda casi gratis salvo en la cuña que
1771    // enfrenta al sol, que es justo donde el efecto importa.
1772    let phase = pow(max(dot(rd, u.sun_dir.xyz), 0.0), 6.0);
1773    if (phase < 0.003) { return 0.0; }
1774    let march_len = min(max_t, 220.0);
1775    let steps = 8; // pocas muestras: el efecto es atmosférico, no necesita más (y cuesta)
1776    let dt = march_len / f32(steps);
1777    // Dither del paso inicial para romper el bandeo de pocas muestras.
1778    let jitter = fract(sin(dot(rd.xy, vec2<f32>(12.9898, 78.233))) * 43758.5453);
1779    var acc = 0.0;
1780    for (var i = 0; i < steps; i = i + 1) {
1781        let tt = (f32(i) + jitter) * dt;
1782        if (tt >= max_t) { break; }
1783        let sp = ro + rd * tt;
1784        let sh = trace(sp, u.sun_dir.xyz, dim, B, true);
1785        acc = acc + select(1.0, 0.0, sh.hit); // 1 si el sol llega, 0 si está en sombra
1786    }
1787    acc = acc / f32(steps);
1788    let depth_fac = 1.0 - exp(-march_len * fogd); // más aire, más dispersión
1789    return acc * strength * phase * depth_fac;
1790}
1791
1792@fragment
1793fn fs(in: VOut) -> FOut {
1794    let p_near = u.inv_vp * vec4<f32>(in.ndc, 0.0, 1.0);
1795    let p_far  = u.inv_vp * vec4<f32>(in.ndc, 1.0, 1.0);
1796    let ro_world = u.cam_eye.xyz;
1797    let rd = normalize(p_far.xyz / p_far.w - p_near.xyz / p_near.w);
1798
1799    let dim = u.grid_dim.xyz;
1800    let B = u.grid_dim.w;
1801    let ro = ro_world + dim * 0.5;
1802
1803    // Bisel/suavizado sub-voxel: con `bisel > 0` la primaria TRAZA la isosuperficie
1804    // suave del campo (silueta + normal diagonales, no sólo sombreado); `bisel = 0` =
1805    // hit duro clásico byte-idéntico. Las sombras siempre usan el trace duro (barato).
1806    let bisel = u.sky_horizon.w;
1807    var h: Hit;
1808    if (bisel > 0.0) {
1809        h = trace_smooth(ro, rd, dim, B, bisel);
1810    } else {
1811        h = trace(ro, rd, dim, B, false);
1812    }
1813    let t_vox = select(1e30, h.t, h.hit);
1814    let eh = trace_entities(ro, rd, t_vox);
1815
1816    // Seres SDF: compiten por t contra voxels Y entidades (max_t = lo más cercano
1817    // hasta acá) → si hay hit de ser, ES el más cercano. Gateado por count.
1818    let t_ent = select(t_vox, eh.t, eh.hit);
1819    var shs: SerHit;
1820    shs.hit = false;
1821    if (seru.count.x > 0.5) {
1822        shs = trace_sers(ro, rd, t_ent);
1823    }
1824
1825    let fog_density = u.sky_zenith.w;
1826
1827    var albedo: vec3<f32>;
1828    var normal: vec3<f32>;
1829    var p: vec3<f32>;
1830    var ao: f32;
1831    var t_hit: f32;
1832    // Brillo/reflectividad por voxel: viaja en el alpha (matte = 1.0 → 0 brillo;
1833    // valores más bajos = más lustre). Sólo lo llevan los minerales lustrosos.
1834    var shine_a: f32 = 1.0;
1835    // Sombreado "de personaje" (rampa toon + rim) sólo para los seres SDF.
1836    var is_ser: f32 = 0.0;
1837    if (shs.hit) {
1838        albedo = ser_color(shs.si, shs.p);
1839        normal = ser_normal(shs.si, shs.p);
1840        p = shs.p;
1841        ao = 1.0;
1842        t_hit = shs.t;
1843        is_ser = 1.0;
1844    } else if (eh.hit) {
1845        albedo = eh.color;
1846        normal = eh.normal;
1847        p = ro + rd * eh.t;
1848        ao = 1.0;
1849        t_hit = eh.t;
1850    } else if (h.hit) {
1851        let vtex = voxel_at(h.vox);
1852        albedo = vtex.rgb;
1853        shine_a = vtex.a;
1854        // `h.normal` ya es la normal (suave si bisel>0, axial si no). La AO se calcula
1855        // con el eje dominante (indexa voxels enteros); para el hit duro es idéntica.
1856        normal = h.normal;
1857        p = ro + rd * h.t;
1858        ao = compute_ao(h.vox, axis_of(h.normal), p, dim);
1859        t_hit = h.t;
1860        // Rugosidad sub-voxel: inclina la normal de SOMBREADO (ya biselada si aplica)
1861        // por bump procedural. Sólo lo mate (roca/tierra) se rugosiza; el vidrio/metal
1862        // lustroso (alpha bajo) queda liso. Si el voxel golpeado trae detalle autorado,
1863        // su rugosidad manda; si no, la amplitud global (`u.cdim.w`).
1864        let pvd = pv_detail(h.vox);
1865        let rug_amp = select(u.cdim.w, pvd.y, pvd.x > 0.5);
1866        if (rug_amp > 0.0) {
1867            let matte = clamp((shine_a - 0.62) / 0.38, 0.0, 1.0);
1868            if (matte > 0.0) {
1869                normal = perturb_normal(normal, p, max(u.atlas.w, 1e-3), rug_amp * matte);
1870            }
1871        }
1872    } else {
1873        // Sin impacto: con niebla activa pintamos cielo propio (a profundidad
1874        // lejana, así una malla por delante igual se dibuja); sin niebla,
1875        // descartamos para dejar ver el fondo vello (comportamiento clásico).
1876        // De noche hay cielo propio siempre: si no, sin bruma no habría estrellas.
1877        if (fog_density > 0.0 || u.noche.x > 0.0) {
1878            // Cielo + god rays: los haces se ven sobre todo contra el fondo abierto.
1879            let gr = god_rays(ro, rd, 1e30, dim, B);
1880            let glow = luz_haces() * gr;
1881            var sky: FOut;
1882            sky.color = vec4<f32>(sky_color(rd) + estrellas(rd) + glow, 1.0);
1883            sky.depth = 1.0;
1884            return sky;
1885        }
1886        discard;
1887    }
1888
1889    let ldir = u.sun_dir.xyz;
1890    let diff = max(dot(normal, ldir), 0.0);
1891
1892    // Bias del origen de sombra: con bisel, la superficie suave está ~1 voxel DENTRO
1893    // de la cáscara dura (que es la que traza la sombra) → hay que salir más para no
1894    // nacer enterrado y auto-sombrearse (motas negras). Sin bisel, el 0.5 clásico.
1895    // La superficie SDF del ser es exacta → bias chico (no está enterrada en cáscara).
1896    let so_bias = select(select(0.5, 1.75, bisel > 0.0), 0.25, is_ser > 0.5);
1897    let so = p + normal * so_bias + ldir * 0.01;
1898    let sh_v = trace(so, ldir, dim, B, true);
1899    let sh_e = trace_entities(so, ldir, 1e30);
1900    var sun_blocked = sh_v.hit || sh_e.hit;
1901    // Los seres proyectan sombra sobre el mundo/entidades, pero NO sobre sí mismos
1902    // (regla cartoon: el domo del ojo sombreaba la mejilla = mancha en la cara; la
1903    // cara toon se lee plana). El mundo voxel SÍ los sombrea (sh_v arriba).
1904    if (!sun_blocked && seru.count.x > 0.5 && is_ser < 0.5) {
1905        sun_blocked = ser_shadow(so, ldir, 1e30);
1906    }
1907    let shadow = select(1.0, 0.25, sun_blocked);
1908
1909    // Luz CON COLOR (look cinematográfico) sin uniforms nuevos: el color del sol
1910    // sale de su elevación (cálido al ras del horizonte → blanco en lo alto) y el
1911    // ambiente del color del cielo (rebote frío del cenit). El mood se controla
1912    // moviendo `sun_dir` y la paleta de cielo, que ya viajan en el uniform.
1913    // AO escalada por `n_lights.w` (1 = clásica `0.35 + 0.65*ao` byte-idéntica; 0 = sin AO).
1914    // La AO axial pinta anillos sobre superficies redondeadas (bisel) → bajarla para seres
1915    // orgánicos, subirla para bloques.
1916    let ao_term = 1.0 - u.n_lights.w * 0.65 * (1.0 - ao);
1917    let sun_h = clamp(u.sun_dir.y, 0.0, 1.0);
1918    var sun_col = mix(vec3<f32>(1.0, 0.52, 0.24), vec3<f32>(1.0, 0.97, 0.9), sun_h);
1919    // Ambiente tintado por el cielo pero con ~la misma luminancia que el flat 0.32
1920    // de antes (no oscurece las caras que no ven el sol).
1921    var amb_col = mix(vec3<f32>(0.45), u.sky_zenith.xyz, 0.45) * 0.70;
1922    // De noche la luz directa es la de la luna (fría y tenue) y el ambiente toma
1923    // el azul del cenit. Tenue, no negra: es una noche de cuento, y las caras
1924    // se tienen que seguir leyendo.
1925    if (u.noche.x > 0.0) {
1926        sun_col = mix(sun_col, vec3<f32>(0.46, 0.54, 0.78), u.noche.x);
1927        amb_col = mix(amb_col, mix(vec3<f32>(0.20, 0.24, 0.38), u.sky_zenith.xyz, 0.35), u.noche.x);
1928    }
1929    var light = amb_col + sun_col * (0.78 * diff * shadow);
1930    if (is_ser > 0.5) {
1931        // Rampa TOON del ser: el diffuse (ya con sombra) se cuantiza en dos bandas
1932        // con borde AA (smoothstep angosto) → luz "de personaje", no de material.
1933        // + RIM light frío del cielo en el contorno (despega al ser del fondo).
1934        // HALF-LAMBERT (wrap): el Lambert crudo cruzaba su terminador en diagonal por
1935        // la CARA (leía como veta gris); envolver la luz (d·0.5+0.5)² corre el
1936        // terminador al borde de la silueta → la cara queda iluminada pareja, el
1937        // volumen lo dan las bandas suaves + el rim. La sombra del mundo sí baja la banda.
1938        let wrap = diff * 0.5 + 0.5;
1939        let dsh = clamp(wrap * wrap * shadow, 0.0, 1.0);
1940        let band = 0.46 + 0.24 * smoothstep(0.30, 0.40, dsh) + 0.28 * smoothstep(0.62, 0.72, dsh);
1941        light = amb_col * 0.85 + sun_col * (0.95 * band);
1942        let rimv = pow(1.0 - max(dot(normal, -rd), 0.0), 3.5);
1943        light = light + mix(u.sky_zenith.xyz, vec3<f32>(1.0), 0.35) * (rimv * 0.45);
1944    }
1945
1946    // Luces puntuales coloreadas (antorchas/lámparas): caída suave por distancia
1947    // + sombra dura opcional (un shadow ray hacia la luz, acotado a la distancia
1948    // a la luz: si un voxel/entidad intercepta *antes* de llegar, la luz no llega).
1949    // `p` está en espacio de voxel, igual que `light.pos`.
1950    let nlights = i32(u.n_lights.x);
1951    let pt_shadows = u.n_lights.y > 0.5;
1952    for (var li = 0; li < nlights; li = li + 1) {
1953        let lp = u.lights[2 * li];
1954        let lc = u.lights[2 * li + 1];
1955        let to = lp.xyz - p;
1956        let d = length(to);
1957        let range = max(lp.w, 1e-3);
1958        var att = clamp(1.0 - d / range, 0.0, 1.0);
1959        att = att * att; // caída cuadrática suave
1960        let ldir2 = to / max(d, 1e-3);
1961        let ndl = max(dot(normal, ldir2), 0.0);
1962        var vis = 1.0;
1963        if (pt_shadows && att > 0.0 && ndl > 0.0) {
1964            // Sale de la superficie un pelo hacia la luz para no auto-sombrearse.
1965            let lso = p + normal * 0.5 + ldir2 * 0.01;
1966            let bias = 0.75; // tolerancia: no contar el propio voxel ni la luz.
1967            let lrad = lc.w; // radio de área (penumbra); 0 = sombra dura.
1968            if (lrad <= 0.0) {
1969                // Sombra dura: un solo shadow ray hacia el centro de la luz.
1970                let hv = trace(lso, ldir2, dim, B, true);
1971                let blocked_v = hv.hit && hv.t < d - bias;
1972                let he = trace_entities(lso, ldir2, d - bias);
1973                vis = select(1.0, 0.0, blocked_v || he.hit);
1974            } else {
1975                // Sombra blanda: la luz es un disco de radio `lrad`. Se reparten
1976                // varios shadow rays hacia puntos del disco (perpendicular a la
1977                // dirección a la luz) y se promedia la visibilidad → penumbra.
1978                // Base ortonormal del plano del disco.
1979                var up = vec3<f32>(0.0, 1.0, 0.0);
1980                if (abs(ldir2.y) > 0.9) { up = vec3<f32>(1.0, 0.0, 0.0); }
1981                let tx = normalize(cross(up, ldir2));
1982                let ty = cross(ldir2, tx);
1983                var occ = 0.0;
1984                for (var s = 0; s < SOFT_TAPS; s = s + 1) {
1985                    let o = SOFT_DISK[s] * lrad;
1986                    let lp2 = lp.xyz + tx * o.x + ty * o.y;
1987                    let to2 = lp2 - p;
1988                    let d2 = length(to2);
1989                    let dir2 = to2 / max(d2, 1e-3);
1990                    let hv = trace(lso, dir2, dim, B, true);
1991                    let bv = hv.hit && hv.t < d2 - bias;
1992                    let he = trace_entities(lso, dir2, d2 - bias);
1993                    if (bv || he.hit) { occ = occ + 1.0; }
1994                }
1995                vis = 1.0 - occ / f32(SOFT_TAPS);
1996            }
1997            // Oclusión por seres SDF: un rayo al centro de la luz (MVP: sombra dura
1998            // del ser aun bajo luces de área — los 16 taps con sphere-trace serían caros).
1999            if (vis > 0.0 && seru.count.x > 0.5) {
2000                if (ser_shadow_salvo(lso, ldir2, d - bias, lp.xyz)) { vis = 0.0; }
2001            }
2002        }
2003        light = light + lc.rgb * (att * ndl * vis);
2004    }
2005
2006    var color = albedo * light * ao_term;
2007    // Luz propia: una pieza de ser con algún canal de color > 1,5 brilla sola —
2008    // no la apaga la sombra ni la noche (la estrella que se lleva en la mano).
2009    // El umbral está lejos de 1 para que un tinte que se pasó un poco no se
2010    // encienda por accidente. Se ve con su tono, normalizado al canal más alto.
2011    let maxc = max(albedo.r, max(albedo.g, albedo.b));
2012    if (is_ser > 0.5 && maxc > 1.5) {
2013        color = max(color, albedo / maxc);
2014    }
2015
2016    // Brillo especular + glaseado por voxel (algo que la luz plana de Minecraft no
2017    // hace): reflejo dependiente de la vista sobre los minerales lustrosos. `shine`
2018    // sale del alpha del voxel (matte 1.0 → 0). Nuestro ray-march ya tiene la
2019    // normal de cara y el rayo de cámara, así que el destello es real por-píxel.
2020    let shine = clamp((1.0 - shine_a) * 2.04, 0.0, 1.0);
2021    if (shine > 0.0) {
2022        let vdir = -rd;                              // hacia la cámara
2023        let hvec = normalize(u.sun_dir.xyz + vdir);  // half-vector Blinn-Phong
2024        let ndh = max(dot(normal, hvec), 0.0);
2025        // Lóbulo moderado: las normales del voxel son axiales, un exponente alto
2026        // no engancharía ninguna cara visible. La dureza sube un poco el foco.
2027        let power = mix(2.0, 9.0, shine);
2028        let spec = pow(ndh, power) * shine * shadow * 3.0;
2029        // Glaseado (fresnel): lustre vidrioso a ángulo rasante, tinte frío del cielo.
2030        let fres = pow(1.0 - max(dot(normal, vdir), 0.0), 4.0) * shine * 0.45;
2031        let gloss = sun_col * spec + mix(sun_col, u.sky_zenith.xyz, 0.6) * fres;
2032        color = color + gloss;
2033    }
2034
2035    // Niebla / perspectiva aérea: lo lejano desvanece hacia el cielo en esa
2036    // dirección, lo que hace legible el borde de un mundo grande.
2037    if (fog_density > 0.0) {
2038        let f = 1.0 - exp(-t_hit * fog_density);
2039        color = mix(color, sky_color(rd), f);
2040        // God rays: haz volumétrico en el aire DELANTE de la superficie golpeada.
2041        let gr = god_rays(ro, rd, t_hit, dim, B);
2042        color = color + luz_haces() * gr;
2043    }
2044
2045    // Diagnóstico: pinta la normal de sombreado como color (n·0.5+0.5). Lo usa la
2046    // certificación para medir qué fracción de la superficie dejó de ser axial.
2047    if (u.scroll.w > 0.5) {
2048        var dbg: FOut;
2049        dbg.color = vec4<f32>(normal * 0.5 + 0.5, 1.0);
2050        dbg.depth = frag_depth(p, dim);
2051        return dbg;
2052    }
2053
2054    var out: FOut;
2055    out.color = vec4<f32>(color, 1.0);
2056    out.depth = frag_depth(p, dim);
2057    return out;
2058}
2059"#;
2060
2061/// WGSL completo del pipeline: el fragmento de seres SDF (uniform + campo + trace)
2062/// antepuesto al ray-march voxel. El orden de declaración no importa en WGSL.
2063fn shader_source() -> String {
2064    format!("{}\n{}", crate::ser::wgsl_ser(), WGSL)
2065}
2066
2067#[cfg(test)]
2068mod tests {
2069    use super::*;
2070
2071    /// El WGSL del ray-march parsea y valida sin GPU (naga). Ataja errores que de
2072    /// otro modo sólo saldrían al crear el pipeline — incluye las funciones de
2073    /// rugosidad sub-voxel (`hash13`/`vnoise`/`perturb_normal`) y el fragmento de
2074    /// seres SDF (`ser_map`/`trace_sers`/`ser_shadow`).
2075    #[test]
2076    fn wgsl_valida() {
2077        let module = naga::front::wgsl::parse_str(&shader_source())
2078            .unwrap_or_else(|e| panic!("WGSL del voxel-renderer no parsea: {e:?}"));
2079        naga::valid::Validator::new(
2080            naga::valid::ValidationFlags::all(),
2081            naga::valid::Capabilities::all(),
2082        )
2083        .validate(&module)
2084        .unwrap_or_else(|e| panic!("WGSL del voxel-renderer no valida: {e:?}"));
2085    }
2086}