tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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}