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