//! `VoxelRenderer` — render por **ray-marching** de un [`VoxelGrid`], con //! almacenamiento **sparse de verdad** (brick pool). //! //! No mesha (ruta elegida en `MOTOR-VOXEL.md` §11.1): el rayo se marcha por la //! estructura y el color sale del voxel golpeado. //! //! Evolución de la memoria: //! - **M1** denso (toda la grilla, incluido el aire, en una textura 3D). //! - **M2** traversal sparse (DDA de dos niveles sobre un mapa grueso). //! - **Ahora (brick pool, prereq de M5/M6)**: la memoria también es sparse. //! Sólo los *bricks* (`BRICK³` voxels) que contienen algo se guardan, en un //! **atlas 3D** (el *pool*); una textura de **indirección** del tamaño grueso //! mapea cada celda → slot del pool (`0` = brick vacío, no ocupa memoria). El //! shader resuelve cada voxel: celda gruesa → slot → texel del atlas. //! //! El DDA de dos niveles usa la indirección como mapa de ocupación (skip de //! aire) Y como tabla de slots (lookup fino). Mutar voxels (M3) sigue siendo //! incremental: un slot map + free list permiten allocar/liberar bricks y subir //! sólo los bricks tocados. //! //! Entidades (M4) y la firma de [`VoxelRenderer::render`] (compatible con //! `View::gpu_paint_with`) intactas. use crate::camera::Camera3d; use crate::voxel::VoxelGrid; /// Tamaño de brick (voxels por lado). const BRICK: u32 = 8; /// Lado de brick (voxels) expuesto: el streaming toroidal exige orígenes de /// ventana alineados a este múltiplo (ver [`VoxelRenderer::scroll_to`]). pub const VOXEL_BRICK: u32 = BRICK; /// Tope de luces puntuales por frame, expuesto para que el caller no exceda. pub const VOXEL_MAX_LIGHTS: usize = MAX_LIGHTS; /// Máximo de entidades vivas por frame (cabe holgado en un uniform). const MAX_ENTITIES: usize = 64; /// Máximo de luces puntuales por frame (cabe en el uniform principal). const MAX_LIGHTS: usize = 4; /// Luz puntual coloreada (antorcha/lámpara): ilumina los voxels/entidades /// cercanos con caída suave por distancia. Posición en coordenadas de voxel /// `[0, dim]` (igual que las entidades), color RGB lineal (puede pasar de 1.0 /// para un brillo intenso), `range` = radio de alcance en voxels. #[derive(Clone, Copy)] pub struct PointLight { pub pos: [f32; 3], pub color: [f32; 3], pub range: f32, /// Radio de la luz como **fuente de área** (en voxels). `0.0` = puntual exacta /// → sombra dura (1 shadow ray). `> 0.0` = el shadow ray se reparte en varios /// taps sobre un disco de este radio perpendicular a la dirección a la luz → /// **penumbra** (sombra blanda): cuanto más lejos el ocluyente de la superficie, /// más se abre el borde. Sólo aplica si `point_shadows` está activo. pub radius: f32, } /// Una entidad (agente) — una caja analítica ray-marcheada en el mismo pase que /// los voxels (M4). Posición en coordenadas de voxel `[0, dim]` (sub-voxel, así /// se mueve suave), `half` = medio-tamaño por eje, color RGB. #[derive(Clone, Copy)] pub struct Entity3d { pub pos: [f32; 3], pub half: [f32; 3], pub color: [u8; 3], } /// Atmósfera del mundo (primera rebanada de M6): cielo gradiente + niebla por /// distancia ("aerial perspective"). Editable antes de `render`. /// /// `fog_density` controla todo el efecto: con `0.0` (default) el renderer se /// comporta como antes — los rayos que no pegan nada hacen `discard` (deja ver /// el fondo vello) y no hay niebla. Con `> 0.0` el motor pinta su **propio /// cielo** en los misses y desvanece lo lejano hacia el color del horizonte, que /// es lo que hace legible un mundo grande (sin esto, el borde lejano del terreno /// se ve como un muro recortado). #[derive(Clone, Copy)] pub struct Atmosphere { /// Color del cielo en el cenit (mirando hacia arriba). pub sky_zenith: [u8; 3], /// Color del cielo en el horizonte — también el color hacia el que /// desvanece la niebla. pub sky_horizon: [u8; 3], /// Densidad de niebla por unidad de voxel. `0.0` = desactivada (miss → /// `discard`, sin niebla); valores típicos `0.002..0.02`. pub fog_density: f32, } impl Default for Atmosphere { fn default() -> Self { Self { sky_zenith: [70, 120, 200], sky_horizon: [188, 208, 230], fog_density: 0.0, } } } /// Renderer de voxels por ray-march de dos niveles sobre un brick pool sparse. pub struct VoxelRenderer { pool: wgpu::Texture, indir: wgpu::Texture, bind_group: wgpu::BindGroup, /// Layout del bind group, guardado para re-armar el bind group cuando el pool /// crece (la textura del atlas cambia → su view también). bgl: wgpu::BindGroupLayout, pipeline: wgpu::RenderPipeline, ubuf: wgpu::Buffer, ubuf_ent: wgpu::Buffer, dim: [u32; 3], cdim: [u32; 3], /// Slots del atlas por eje (cuántos bricks entran en cada dimensión). atlas: [u32; 3], /// Por celda gruesa: `slot + 1`, o `0` si el brick está vacío. Espeja la /// textura de indirección en CPU (para el camino incremental). slots: Vec, /// Slots libres del pool (free list para allocar bricks nuevos). free: Vec, /// Origen de brick de la ventana (streaming toroidal): `slots`/indirección se /// indexan por celda **física** = `(celda_lógica + brick_origin) mod cdim`. /// `[0,0,0]` = sin scroll (lógica = física, camino clásico). brick_origin: [i32; 3], /// Dirección hacia el sol (normalizada). Editable antes de `render`. pub sun_dir: [f32; 3], /// Atmósfera (cielo + niebla). `fog_density = 0` → comportamiento clásico. pub atmosphere: Atmosphere, /// Depth buffer propio para el camino *standalone* ([`Self::render`]); en /// `Scene3d` se usa el depth compartido y este queda sin tocar. depth: Option, /// Entidades vivas — se empacan y suben en cada `render`. pub entities: Vec, /// Luces puntuales coloreadas (≤ [`MAX_LIGHTS`]) — antorchas/lámparas que /// iluminan voxels y entidades cercanos. Se empacan y suben en cada `render`. pub lights: Vec, /// Si las luces puntuales proyectan sombra (un shadow ray por luz hacia su /// posición, acotado a la distancia a la luz). `true` por defecto. Apagarlo /// recupera el MVP plano (más barato) — útil para comparar off/on. pub point_shadows: bool, } impl VoxelRenderer { /// Crea el renderer y construye el brick pool a partir de `grid`. pub fn new( device: &wgpu::Device, queue: &wgpu::Queue, color_format: wgpu::TextureFormat, grid: &VoxelGrid, ) -> Self { let dim = grid.dim(); let cdim = [ dim[0].div_ceil(BRICK), dim[1].div_ceil(BRICK), dim[2].div_ceil(BRICK), ]; let n_cells = (cdim[0] * cdim[1] * cdim[2]) as usize; // Bricks ocupados → capacidad con holgura para crecer (M3). let occupied: u32 = (0..cdim[2]) .flat_map(|cz| (0..cdim[1]).flat_map(move |cy| (0..cdim[0]).map(move |cx| (cx, cy, cz)))) .filter(|&(cx, cy, cz)| grid.brick_occupied(BRICK, cx, cy, cz) != 0) .count() as u32; let want = occupied + occupied / 2 + 64; // Atlas cúbico-ish: ax·ay·az ≥ want. let ax = ((want as f64).cbrt().ceil() as u32).max(1); let ay = ax; let az = want.div_ceil(ax * ay).max(1); let capacity = ax * ay * az; let atlas = [ax, ay, az]; let pool = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-3d-voxel-pool"), size: extent([ax * BRICK, ay * BRICK, az * BRICK]), mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D3, format: wgpu::TextureFormat::Rgba8Unorm, // COPY_SRC para poder copiar el atlas a uno más grande al crecer. usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let pool_view = pool.create_view(&wgpu::TextureViewDescriptor::default()); let indir = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-3d-voxel-indir"), size: extent(cdim), mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D3, format: wgpu::TextureFormat::R32Uint, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); let indir_view = indir.create_view(&wgpu::TextureViewDescriptor::default()); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("llimphi-3d-voxel-shader"), source: wgpu::ShaderSource::Wgsl(WGSL.into()), }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-3d-voxel-bgl"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Uint, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, }, count: None, }, uniform_entry(2), uniform_entry(3), ], }); let ubuf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-3d-voxel-ubuf"), // inv_vp(64)+cam_eye(16)+grid_dim/brick(16)+sun(16)+cdim(16)+atlas(16) // +sky_zenith/fog(16)+sky_horizon(16)+vp(64)+scroll(16) // +n_lights(16)+lights(4×32=128) size: 400, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let ubuf_ent = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-3d-voxel-ubuf-ent"), size: (16 + MAX_ENTITIES * 48) as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-3d-voxel-bg"), layout: &bgl, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&pool_view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&indir_view), }, wgpu::BindGroupEntry { binding: 2, resource: ubuf.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 3, resource: ubuf_ent.as_entire_binding(), }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("llimphi-3d-voxel-pl"), bind_group_layouts: &[&bgl], push_constant_ranges: &[], }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-3d-voxel-pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[], }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, // Escribe profundidad (del voxel golpeado) → convive con mallas en // un depth buffer compartido (`Scene3d`): el render volumétrico y el // de triángulos se ocluyen correctamente entre sí. depth_stencil: Some(wgpu::DepthStencilState { format: crate::scene::DEPTH_FORMAT, depth_write_enabled: true, // LessEqual (no Less): el cielo en los misses escribe profundidad // lejana (1.0) y debe pasar contra el clear 1.0; un `Less` lo // rechazaría y dejaría ver el fondo negro. Sólo hay un fragmento // de voxel por píxel (el del rayo), así que no hay z-fighting. depth_compare: wgpu::CompareFunction::LessEqual, stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: color_format, blend: None, write_mask: wgpu::ColorWrites::ALL, })], }), multiview: None, cache: None, }); let mut r = Self { pool, indir, bind_group, bgl, pipeline, ubuf, ubuf_ent, dim, cdim, atlas, slots: vec![0u32; n_cells], free: Vec::new(), brick_origin: [0, 0, 0], sun_dir: normalize3([0.5, 1.0, 0.35]), atmosphere: Atmosphere::default(), depth: None, entities: Vec::new(), lights: Vec::new(), point_shadows: true, }; // Poblar el pool: cada brick ocupado toma un slot incremental. let mut next: u32 = 0; for cz in 0..cdim[2] { for cy in 0..cdim[1] { for cx in 0..cdim[0] { if grid.brick_occupied(BRICK, cx, cy, cz) != 0 { let slot = next; next += 1; let idx = r.cell_idx(cx, cy, cz); r.slots[idx] = slot + 1; r.upload_brick(queue, slot, grid, cx, cy, cz); } } } } r.free = (next..capacity).rev().collect(); r.upload_indirection_full(queue); r } /// Bricks ocupados (slots usados) y total de celdas gruesas — para reportar /// el ahorro de memoria del pool frente al denso. pub fn brick_usage(&self) -> (u32, u32) { let used = self.slots.iter().filter(|&&s| s != 0).count() as u32; (used, (self.cdim[0] * self.cdim[1] * self.cdim[2])) } /// Bytes del pool (atlas) vs. lo que costaría el grid denso completo. pub fn memory_bytes(&self) -> (u64, u64) { let (used, _) = self.brick_usage(); let per_brick = (BRICK * BRICK * BRICK * 4) as u64; let pool = used as u64 * per_brick; let dense = (self.dim[0] * self.dim[1] * self.dim[2] * 4) as u64; (pool, dense) } #[inline] fn cell_idx(&self, cx: u32, cy: u32, cz: u32) -> usize { (cx + cy * self.cdim[0] + cz * self.cdim[0] * self.cdim[1]) as usize } /// Índice **físico** (en `slots`/indirección) de la celda gruesa **lógica** /// `c` (relativa a la ventana): `(c + brick_origin) mod cdim`. Espeja el /// `slot_at` del shader. Con `brick_origin = 0` es `cell_idx(c)` directo. #[inline] fn phys_cell(&self, c: [i32; 3]) -> usize { let p = [ floormod(c[0] + self.brick_origin[0], self.cdim[0] as i32) as u32, floormod(c[1] + self.brick_origin[1], self.cdim[1] as i32) as u32, floormod(c[2] + self.brick_origin[2], self.cdim[2] as i32) as u32, ]; self.cell_idx(p[0], p[1], p[2]) } /// Origen del slot en el atlas (en celdas de brick). fn slot_origin(&self, slot: u32) -> [u32; 3] { let ax = self.atlas[0]; let ay = self.atlas[1]; [slot % ax, (slot / ax) % ay, slot / (ax * ay)] } fn upload_brick(&self, queue: &wgpu::Queue, slot: u32, grid: &VoxelGrid, cx: u32, cy: u32, cz: u32) { let data = grid.extract_brick(BRICK, cx, cy, cz); let o = self.slot_origin(slot); write_3d( queue, &self.pool, [o[0] * BRICK, o[1] * BRICK, o[2] * BRICK], [BRICK, BRICK, BRICK], 4, &data, ); } fn upload_indirection_full(&self, queue: &wgpu::Queue) { let mut bytes = Vec::with_capacity(self.slots.len() * 4); for &s in &self.slots { bytes.extend_from_slice(&s.to_ne_bytes()); } write_3d(queue, &self.indir, [0; 3], self.cdim, 4, &bytes); } /// **Actualización incremental (M3).** Sube sólo los bricks tocados por la /// región mutada: re-sube cada brick afectado a su slot (allocando slots /// nuevos para bricks que pasan de vacío→ocupado, liberándolos al revés) y /// re-sube la sub-región de indirección. Devuelve los bytes subidos. pub fn sync(&mut self, queue: &wgpu::Queue, grid: &mut VoxelGrid) -> u32 { let Some(r) = grid.take_dirty() else { return 0; }; let cmin = [r[0] / BRICK, r[1] / BRICK, r[2] / BRICK]; let cmax = [r[3] / BRICK, r[4] / BRICK, r[5] / BRICK]; let mut uploaded = 0u32; let per_brick = BRICK * BRICK * BRICK * 4; for cz in cmin[2]..=cmax[2] { for cy in cmin[1]..=cmax[1] { for cx in cmin[0]..=cmax[0] { // Celda FÍSICA (espeja el `slot_at` toroidal del shader); con // `brick_origin = 0` es la celda lógica directa. let idx = self.phys_cell([cx as i32, cy as i32, cz as i32]); let occ = grid.brick_occupied(BRICK, cx, cy, cz) != 0; let cur = self.slots[idx]; if occ { let slot = if cur != 0 { cur - 1 } else { match self.free.pop() { Some(s) => { self.slots[idx] = s + 1; s } None => { // Pool lleno: el brick no entra (raro con la // holgura inicial). Lo saltamos sin romper. continue; } } }; self.upload_brick(queue, slot, grid, cx, cy, cz); uploaded += per_brick; } else if cur != 0 { // Brick vaciado: liberar el slot (el atlas queda // huérfano pero la indirección en 0 lo hace invisible). self.free.push(cur - 1); self.slots[idx] = 0; } } } } // Re-subir la indirección tocada. Sin scroll (caso común de edición), la // física = la lógica → sub-región contigua (barato). Con scroll, las // celdas físicas están envueltas (no contiguas) → re-subimos la // indirección entera (es chica: cdim³ u32). if self.brick_origin == [0, 0, 0] { let cext = [ cmax[0] - cmin[0] + 1, cmax[1] - cmin[1] + 1, cmax[2] - cmin[2] + 1, ]; let mut ind = Vec::with_capacity((cext[0] * cext[1] * cext[2] * 4) as usize); for cz in cmin[2]..=cmax[2] { for cy in cmin[1]..=cmax[1] { for cx in cmin[0]..=cmax[0] { ind.extend_from_slice(&self.slots[self.cell_idx(cx, cy, cz)].to_ne_bytes()); } } } write_3d(queue, &self.indir, cmin, cext, 4, &ind); uploaded + ind.len() as u32 } else { self.upload_indirection_full(queue); uploaded + (self.slots.len() * 4) as u32 } } /// **Streaming toroidal (M6).** Desliza la ventana a `origin_voxel` (esquina /// local `(0,0,0)` en coordenadas de mundo, alineada a brick) re-subiendo /// **sólo los bricks que entran** — la franja nueva — sin reconstruir el /// renderer ni re-subir la ventana entera. `grid` es la ventana ya generada /// en ese origen (local `[0,dim)`), de la que se extraen los bricks de la /// franja. Los bricks que salen se reemplazan en su misma celda física (la /// textura es un ring buffer: `world_brick mod cdim`). Devuelve los bytes /// subidos (≈ tamaño de la franja, no de la ventana). Llamar con /// `origin_voxel` múltiplo de [`VOXEL_BRICK`]. pub fn scroll_to( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, origin_voxel: [i32; 3], grid: &VoxelGrid, ) -> u32 { let b = BRICK as i32; debug_assert!( origin_voxel.iter().all(|v| v % b == 0), "scroll_to: origin_voxel debe estar alineado a VOXEL_BRICK" ); let new = [origin_voxel[0] / b, origin_voxel[1] / b, origin_voxel[2] / b]; let old = self.brick_origin; if new == old { return 0; } // El uniform de scroll se actualiza ANTES de poblar para que `phys_cell` // calcule las celdas físicas del nuevo origen. self.brick_origin = new; let cd = [self.cdim[0] as i32, self.cdim[1] as i32, self.cdim[2] as i32]; let mut uploaded = 0u32; let per_brick = BRICK * BRICK * BRICK * 4; let entered = |ccx: i32, ccy: i32, ccz: i32| -> bool { let wb = [ccx + new[0], ccy + new[1], ccz + new[2]]; !((0..cd[0]).contains(&(wb[0] - old[0])) && (0..cd[1]).contains(&(wb[1] - old[1])) && (0..cd[2]).contains(&(wb[2] - old[2]))) }; // **Pre-crecer el pool si hace falta, ANTES de subir nada** (la copia del // atlas viejo es estable, sin carrera con write_texture en vuelo). Cota // segura del pico de slots: los usados ahora + los bricks que entran // ocupados (los que salen aún no se liberaron). Crecer una vez al inicio. let mut entered_occ = 0u32; for ccz in 0..cd[2] { for ccy in 0..cd[1] { for ccx in 0..cd[0] { if entered(ccx, ccy, ccz) && grid.brick_occupied(BRICK, ccx as u32, ccy as u32, ccz as u32) != 0 { entered_occ += 1; } } } } let used_now = self.slots.iter().filter(|&&s| s != 0).count() as u32; let need = used_now + entered_occ; while self.pool_capacity() < need { self.grow_layers(device, queue); } // Recorre las celdas LÓGICAS de la ventana nueva; procesa sólo las que // ENTRARON (su brick de mundo no estaba en la ventana vieja). El bulk // (presente en ambas) conserva su contenido físico intacto. for ccz in 0..cd[2] { for ccy in 0..cd[1] { for ccx in 0..cd[0] { if !entered(ccx, ccy, ccz) { continue; } // Celda física (= celda vieja que sale, por el ring buffer). let idx = self.phys_cell([ccx, ccy, ccz]); let (lx, ly, lz) = (ccx as u32, ccy as u32, ccz as u32); let occ = grid.brick_occupied(BRICK, lx, ly, lz) != 0; let cur = self.slots[idx]; if occ { let slot = if cur != 0 { cur - 1 } else { let s = self.free.pop().expect("capacidad pre-crecida"); self.slots[idx] = s + 1; s }; self.upload_brick(queue, slot, grid, lx, ly, lz); uploaded += per_brick; } else if cur != 0 { self.free.push(cur - 1); self.slots[idx] = 0; } } } } self.upload_indirection_full(queue); uploaded + (self.slots.len() * 4) as u32 } /// Capacidad del pool en slots de brick (`atlas.x·y·z`). Crece con /// [`grow_layers`](Self::grow_layers). pub fn pool_capacity(&self) -> u32 { self.atlas[0] * self.atlas[1] * self.atlas[2] } /// **Crece el brick pool** agregando capas `z` al atlas (×1.5, mín +8 slots). /// Sólo crece `atlas.z` para no remapear los slots existentes (`slot_origin` /// depende de `atlas.x/y`, que quedan fijos): copia el atlas viejo al nuevo, /// re-arma el bind group (la view del pool cambió) y agrega los slots nuevos a /// la free list. Lo dispara `scroll_to` cuando la franja que entra no tiene /// slots libres (ventana más densa que la inicial). fn grow_layers(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) { let [ax, ay, az] = self.atlas; let new_az = az + (az / 2).max(8); let new_pool = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-3d-voxel-pool"), size: extent([ax * BRICK, ay * BRICK, new_az * BRICK]), mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D3, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); // Copia el atlas viejo (mismas dimensiones x/y, az capas) al nuevo. let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("llimphi-3d-voxel-pool-grow"), }); enc.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: &self.pool, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyTextureInfo { texture: &new_pool, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, extent([ax * BRICK, ay * BRICK, az * BRICK]), ); queue.submit(std::iter::once(enc.finish())); // Re-armar el bind group con la view del pool nuevo (indir/ubufs intactos). let pool_view = new_pool.create_view(&wgpu::TextureViewDescriptor::default()); let indir_view = self.indir.create_view(&wgpu::TextureViewDescriptor::default()); self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-3d-voxel-bg"), layout: &self.bgl, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&pool_view) }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&indir_view) }, wgpu::BindGroupEntry { binding: 2, resource: self.ubuf.as_entire_binding() }, wgpu::BindGroupEntry { binding: 3, resource: self.ubuf_ent.as_entire_binding() }, ], }); let old_cap = ax * ay * az; let new_cap = ax * ay * new_az; self.pool = new_pool; self.atlas[2] = new_az; self.free.extend((old_cap..new_cap).rev()); } /// Sube los uniforms (cámara/atmósfera/entidades) del frame. Lo llama tanto /// [`Self::render`] (standalone) como [`Scene3d`](crate::Scene3d) antes de /// abrir el pase compartido. `aspect` = w/h del viewport. pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) { let vp = camera.view_proj(aspect); let inv_vp = vp.inverse(); let mut u = Vec::with_capacity(400); for v in inv_vp.to_cols_array() { u.extend_from_slice(&v.to_ne_bytes()); } for v in [camera.eye.x, camera.eye.y, camera.eye.z, 0.0] { u.extend_from_slice(&v.to_ne_bytes()); } for v in [self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32, BRICK as f32] { u.extend_from_slice(&v.to_ne_bytes()); } let s = normalize3(self.sun_dir); for v in [s[0], s[1], s[2], 0.0] { u.extend_from_slice(&v.to_ne_bytes()); } for v in [self.cdim[0] as f32, self.cdim[1] as f32, self.cdim[2] as f32, 0.0] { u.extend_from_slice(&v.to_ne_bytes()); } for v in [self.atlas[0] as f32, self.atlas[1] as f32, self.atlas[2] as f32, 0.0] { u.extend_from_slice(&v.to_ne_bytes()); } let a = &self.atmosphere; for v in [ a.sky_zenith[0] as f32 / 255.0, a.sky_zenith[1] as f32 / 255.0, a.sky_zenith[2] as f32 / 255.0, a.fog_density.max(0.0), ] { u.extend_from_slice(&v.to_ne_bytes()); } for v in [ a.sky_horizon[0] as f32 / 255.0, a.sky_horizon[1] as f32 / 255.0, a.sky_horizon[2] as f32 / 255.0, 0.0, ] { u.extend_from_slice(&v.to_ne_bytes()); } // Matriz forward (world→clip) para escribir frag_depth del voxel golpeado. for v in vp.to_cols_array() { u.extend_from_slice(&v.to_ne_bytes()); } // Origen de brick (streaming toroidal): el shader envuelve la celda lógica. // Se sube YA REDUCIDO a `[0, cdim)` (floormod) para que el `%` del shader // nunca opere sobre un negativo — el `%` de WGSL sobre enteros con signo es // ambiguo entre plataformas y rompía el wrap con orígenes negativos. for v in [ floormod(self.brick_origin[0], self.cdim[0] as i32) as f32, floormod(self.brick_origin[1], self.cdim[1] as i32) as f32, floormod(self.brick_origin[2], self.cdim[2] as i32) as f32, 0.0, ] { u.extend_from_slice(&v.to_ne_bytes()); } // Luces puntuales: count (vec4) + MAX_LIGHTS × [pos+range, color]. let nl = self.lights.len().min(MAX_LIGHTS); let shadow_flag = if self.point_shadows { 1.0 } else { 0.0 }; for v in [nl as f32, shadow_flag, 0.0, 0.0] { u.extend_from_slice(&v.to_ne_bytes()); } for i in 0..MAX_LIGHTS { let l = self.lights.get(i).copied().unwrap_or(PointLight { pos: [0.0; 3], color: [0.0; 3], range: 1.0, radius: 0.0, }); for v in [l.pos[0], l.pos[1], l.pos[2], l.range] { u.extend_from_slice(&v.to_ne_bytes()); } // color.w lleva el radio de área (penumbra); 0 = sombra dura. for v in [l.color[0], l.color[1], l.color[2], l.radius.max(0.0)] { u.extend_from_slice(&v.to_ne_bytes()); } } queue.write_buffer(&self.ubuf, 0, &u); // Entidades: count (vec4) + array de [pos, half, color] (3×vec4 c/u). let n = self.entities.len().min(MAX_ENTITIES); let mut e = Vec::with_capacity(16 + MAX_ENTITIES * 48); for v in [n as f32, 0.0, 0.0, 0.0] { e.extend_from_slice(&v.to_ne_bytes()); } for i in 0..MAX_ENTITIES { let ent = self.entities.get(i).copied().unwrap_or(Entity3d { pos: [0.0; 3], half: [0.0; 3], color: [0, 0, 0], }); for v in [ent.pos[0], ent.pos[1], ent.pos[2], 0.0] { e.extend_from_slice(&v.to_ne_bytes()); } for v in [ent.half[0], ent.half[1], ent.half[2], 0.0] { e.extend_from_slice(&v.to_ne_bytes()); } for v in [ ent.color[0] as f32 / 255.0, ent.color[1] as f32 / 255.0, ent.color[2] as f32 / 255.0, 0.0, ] { e.extend_from_slice(&v.to_ne_bytes()); } } queue.write_buffer(&self.ubuf_ent, 0, &e); } /// Dibuja el fullscreen-triangle del ray-march en un pase **ya abierto** (con /// color + depth). Lo usa [`Scene3d`](crate::Scene3d) para compartir el pase /// con las mallas. Requiere `upload` previo en el mismo frame. pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) { pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.bind_group, &[]); pass.draw(0..3, 0..1); } /// Ray-marchea la grilla vista desde `camera` sobre `target` (camino /// *standalone*, con depth propio). Color `LoadOp::Load`; misses por /// `discard` (o cielo, con niebla). Grilla centrada en el origen. pub fn render( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, (w, h): (u32, u32), camera: &Camera3d, ) { if w == 0 || h == 0 { return; } self.upload(queue, w as f32 / h as f32, camera); crate::scene::ensure_depth(&mut self.depth, device, w, h); let depth_view = &self.depth.as_ref().unwrap().view; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-3d-voxel-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: depth_view, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), timestamp_writes: None, occlusion_query_set: None, }); self.draw(&mut pass); } } fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, } } fn extent(dim: [u32; 3]) -> wgpu::Extent3d { wgpu::Extent3d { width: dim[0], height: dim[1], depth_or_array_layers: dim[2], } } fn write_3d( queue: &wgpu::Queue, tex: &wgpu::Texture, origin: [u32; 3], ext: [u32; 3], bpp: u32, data: &[u8], ) { queue.write_texture( wgpu::TexelCopyTextureInfo { texture: tex, mip_level: 0, origin: wgpu::Origin3d { x: origin[0], y: origin[1], z: origin[2], }, aspect: wgpu::TextureAspect::All, }, data, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(ext[0] * bpp), rows_per_image: Some(ext[1]), }, extent(ext), ); } fn normalize3(v: [f32; 3]) -> [f32; 3] { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6); [v[0] / l, v[1] / l, v[2] / l] } /// Módulo con resultado siempre en `[0, m)` (maneja `a` negativo). Espeja el /// `((x % m) + m) % m` del shader para el direccionamiento toroidal. #[inline] fn floormod(a: i32, m: i32) -> i32 { ((a % m) + m) % m } const WGSL: &str = r#" struct U { inv_vp: mat4x4, cam_eye: vec4, grid_dim: vec4, // xyz = dim fino, w = brick size sun_dir: vec4, // xyz = dirección hacia el sol (normalizada) cdim: vec4, // xyz = dim grueso (celdas de brick) atlas: vec4, // xyz = slots por eje en el atlas del pool sky_zenith: vec4, // xyz = color cenit, w = densidad de niebla (0 = off) sky_horizon: vec4,// xyz = color horizonte / hacia el que niebla desvanece vp: mat4x4, // world→clip (forward) para escribir frag_depth scroll: vec4, // xyz = origen de brick (streaming toroidal); 0 = sin scroll n_lights: vec4, // x = cantidad de luces puntuales, y = sombras on/off lights: array, 8>, // por luz: [pos.xyz, range], [color.rgb, radio_area] }; // Disco de muestreo para sombras blandas (penumbra): 8 taps en un patrón anular // fijo (determinista, sin RNG por píxel — evita ruido temporal en el reel). const SOFT_TAPS: i32 = 8; const SOFT_DISK = array, 8>( vec2( 0.35, 0.0), vec2(-0.35, 0.0), vec2( 0.0, 0.35), vec2( 0.0, -0.35), vec2( 0.7, 0.7), vec2(-0.7, 0.7), vec2( 0.7, -0.7), vec2(-0.7, -0.7), ); struct Entity { pos: vec4, half: vec4, color: vec4, }; struct EntU { count: vec4, ents: array, }; @group(0) @binding(0) var pool: texture_3d; @group(0) @binding(1) var indir: texture_3d; @group(0) @binding(2) var u: U; @group(0) @binding(3) var ent: EntU; struct VOut { @builtin(position) clip: vec4, @location(0) ndc: vec2, }; @vertex fn vs(@builtin(vertex_index) vi: u32) -> VOut { var p = array, 3>( vec2(-1.0, -1.0), vec2( 3.0, -1.0), vec2(-1.0, 3.0), ); var out: VOut; out.clip = vec4(p[vi], 0.0, 1.0); out.ndc = p[vi]; return out; } fn ray_box(ro: vec3, inv_rd: vec3, bmin: vec3, bmax: vec3) -> vec2 { let t0 = (bmin - ro) * inv_rd; let t1 = (bmax - ro) * inv_rd; let tmin = min(t0, t1); let tmax = max(t0, t1); return vec2(max(max(tmin.x, tmin.y), tmin.z), min(min(tmax.x, tmax.y), tmax.z)); } // Slot del brick que contiene la celda gruesa LÓGICA `cc` (0 = vacío). // Streaming toroidal: la textura de indirección es un ring buffer indexado por // `world_brick mod cdim`. La celda lógica `cc` (relativa a la ventana, [0,cdim)) // se traduce a su celda FÍSICA sumando el origen de brick y envolviendo. Sin // scroll (`scroll = 0`) la física = la lógica (camino clásico, sin cambios). fn slot_at(cc: vec3) -> u32 { if (any(cc < vec3(0)) || any(vec3(cc) >= u.cdim.xyz)) { return 0u; } let cd = vec3(u.cdim.xyz); let bo = vec3(u.scroll.xyz); let phys = ((cc + bo) % cd + cd) % cd; // floormod (maneja origen negativo) return textureLoad(indir, phys, 0).r; } // Voxel fino vía indirección → pool. `.a > 0.5` = sólido. fn voxel_at(voxel: vec3) -> vec4 { let vi = vec3(voxel); if (any(vi < vec3(0)) || any(vec3(vi) >= u.grid_dim.xyz)) { return vec4(0.0); } let bu = i32(u.grid_dim.w); let cc = vi / bu; let s = slot_at(cc); if (s == 0u) { return vec4(0.0); } let slot = i32(s - 1u); let ax = i32(u.atlas.x); let ay = i32(u.atlas.y); let acell = vec3(slot % ax, (slot / ax) % ay, slot / (ax * ay)); let local = vi - cc * bu; return textureLoad(pool, acell * bu + local, 0); } struct Hit { hit: bool, vox: vec3, normal: vec3, t: f32, }; // DDA de dos niveles sobre el brick pool: marcha la grilla gruesa (indirección), // baja a la fina sólo en bricks con slot. fn trace(ro: vec3, rd_in: vec3, dim: vec3, B: f32) -> Hit { var h: Hit; h.hit = false; let safe_rd = vec3( select(rd_in.x, 1e-6, abs(rd_in.x) < 1e-6), select(rd_in.y, 1e-6, abs(rd_in.y) < 1e-6), select(rd_in.z, 1e-6, abs(rd_in.z) < 1e-6), ); let inv_rd = 1.0 / safe_rd; let step = sign(safe_rd); let tb = ray_box(ro, inv_rd, vec3(0.0), dim); if (tb.x > tb.y || tb.y < 0.0) { return h; } let t_enter = max(tb.x, 0.0); let te0 = (vec3(0.0) - ro) * inv_rd; let te1 = (dim - ro) * inv_rd; let temin = min(te0, te1); var box_n = vec3(0.0, 0.0, -step.z); if (tb.x == temin.x) { box_n = vec3(-step.x, 0.0, 0.0); } else if (tb.x == temin.y) { box_n = vec3(0.0, -step.y, 0.0); } let cdim = ceil(dim / B); let p_enter = ro + safe_rd * t_enter; var cc = clamp(floor(p_enter / B), vec3(0.0), cdim - 1.0); let t_delta_c = abs(B * inv_rd); var t_max_c = ((cc + max(step, vec3(0.0))) * B - ro) * inv_rd; var t_cell = t_enter; var cnorm = box_n; let max_coarse = i32(cdim.x + cdim.y + cdim.z) + 3; for (var ci = 0; ci < max_coarse; ci = ci + 1) { if (slot_at(vec3(cc)) != 0u) { var voxel = clamp(floor(ro + safe_rd * (t_cell + 1e-4)), vec3(0.0), dim - 1.0); var t_max_f = ((voxel + max(step, vec3(0.0))) - ro) * inv_rd; let t_delta_f = abs(inv_rd); var fnorm = cnorm; var t_vox = t_cell; let max_fine = i32(B) * 3 + 3; for (var fi = 0; fi < max_fine; fi = fi + 1) { if (any(voxel < vec3(0.0)) || any(voxel >= dim)) { return h; } if (any(floor(voxel / B) != cc)) { break; } let c = voxel_at(voxel); if (c.a > 0.5) { h.hit = true; h.vox = voxel; h.normal = fnorm; h.t = t_vox; return h; } if (t_max_f.x < t_max_f.y && t_max_f.x < t_max_f.z) { voxel.x = voxel.x + step.x; t_vox = t_max_f.x; t_max_f.x = t_max_f.x + t_delta_f.x; fnorm = vec3(-step.x, 0.0, 0.0); } else if (t_max_f.y < t_max_f.z) { voxel.y = voxel.y + step.y; t_vox = t_max_f.y; t_max_f.y = t_max_f.y + t_delta_f.y; fnorm = vec3(0.0, -step.y, 0.0); } else { voxel.z = voxel.z + step.z; t_vox = t_max_f.z; t_max_f.z = t_max_f.z + t_delta_f.z; fnorm = vec3(0.0, 0.0, -step.z); } } } if (t_max_c.x < t_max_c.y && t_max_c.x < t_max_c.z) { cc.x = cc.x + step.x; t_cell = t_max_c.x; t_max_c.x = t_max_c.x + t_delta_c.x; cnorm = vec3(-step.x, 0.0, 0.0); } else if (t_max_c.y < t_max_c.z) { cc.y = cc.y + step.y; t_cell = t_max_c.y; t_max_c.y = t_max_c.y + t_delta_c.y; cnorm = vec3(0.0, -step.y, 0.0); } else { cc.z = cc.z + step.z; t_cell = t_max_c.z; t_max_c.z = t_max_c.z + t_delta_c.z; cnorm = vec3(0.0, 0.0, -step.z); } if (any(cc < vec3(0.0)) || any(cc >= cdim)) { return h; } } return h; } fn occ_at(p: vec3, dim: vec3) -> f32 { return select(0.0, 1.0, voxel_at(vec3(p)).a > 0.5); } fn vertex_ao(s1: f32, s2: f32, c: f32) -> f32 { if (s1 > 0.5 && s2 > 0.5) { return 0.0; } return (3.0 - (s1 + s2 + c)) / 3.0; } fn compute_ao(voxel: vec3, normal: vec3, p: vec3, dim: vec3) -> f32 { var t1 = vec3(0, 1, 0); var t2 = vec3(0, 0, 1); if (abs(normal.y) > 0.5) { t1 = vec3(1, 0, 0); t2 = vec3(0, 0, 1); } else if (abs(normal.z) > 0.5) { t1 = vec3(1, 0, 0); t2 = vec3(0, 1, 0); } let base = vec3(voxel) + vec3(normal); let s1m = occ_at(base - t1, dim); let s1p = occ_at(base + t1, dim); let s2m = occ_at(base - t2, dim); let s2p = occ_at(base + t2, dim); let ao_mm = vertex_ao(s1m, s2m, occ_at(base - t1 - t2, dim)); let ao_pm = vertex_ao(s1p, s2m, occ_at(base + t1 - t2, dim)); let ao_mp = vertex_ao(s1m, s2p, occ_at(base - t1 + t2, dim)); let ao_pp = vertex_ao(s1p, s2p, occ_at(base + t1 + t2, dim)); let fp = fract(p); let uu = dot(vec3(t1), fp); let vv = dot(vec3(t2), fp); return mix(mix(ao_mm, ao_pm, uu), mix(ao_mp, ao_pp, uu), vv); } struct EHit { hit: bool, t: f32, normal: vec3, color: vec3, }; fn trace_entities(ro: vec3, rd: vec3, max_t: f32) -> EHit { var best: EHit; best.hit = false; best.t = max_t; let safe_rd = vec3( select(rd.x, 1e-6, abs(rd.x) < 1e-6), select(rd.y, 1e-6, abs(rd.y) < 1e-6), select(rd.z, 1e-6, abs(rd.z) < 1e-6), ); let inv_rd = 1.0 / safe_rd; let n = i32(ent.count.x); for (var i = 0; i < n; i = i + 1) { let e = ent.ents[i]; let bmin = e.pos.xyz - e.half.xyz; let bmax = e.pos.xyz + e.half.xyz; let tb = ray_box(ro, inv_rd, bmin, bmax); if (tb.x <= tb.y && tb.x > 1e-3 && tb.x < best.t) { best.hit = true; best.t = tb.x; best.color = e.color.rgb; let p = ro + rd * tb.x; let c = (bmin + bmax) * 0.5; let d = max((bmax - bmin) * 0.5, vec3(1e-4)); let q = (p - c) / d; let aq = abs(q); if (aq.x >= aq.y && aq.x >= aq.z) { best.normal = vec3(sign(q.x), 0.0, 0.0); } else if (aq.y >= aq.z) { best.normal = vec3(0.0, sign(q.y), 0.0); } else { best.normal = vec3(0.0, 0.0, sign(q.z)); } } } return best; } // Cielo procedural: gradiente horizonte→cenit por la altura del rayo, con un // disco solar y su halo. Es también el color al que desvanece la niebla. fn sky_color(rd: vec3) -> vec3 { let t = clamp(rd.y * 0.5 + 0.5, 0.0, 1.0); var c = mix(u.sky_horizon.xyz, u.sky_zenith.xyz, pow(t, 0.55)); let s = max(dot(rd, u.sun_dir.xyz), 0.0); c = c + vec3(1.0, 0.96, 0.84) * pow(s, 260.0) * 1.6; // disco c = c + vec3(1.0, 0.90, 0.72) * pow(s, 9.0) * 0.16; // halo return c; } // Profundidad NDC (0..1, wgpu) del punto golpeado `p` (en espacio de grilla): // se lleva a mundo (la grilla está centrada en el origen → world = p - dim/2) y // se proyecta con la matriz forward. Permite que las mallas se ocluyan con los // voxels en el depth buffer compartido de `Scene3d`. fn frag_depth(p: vec3, dim: vec3) -> f32 { let clip = u.vp * vec4(p - dim * 0.5, 1.0); return clip.z / clip.w; } struct FOut { @location(0) color: vec4, @builtin(frag_depth) depth: f32, }; @fragment fn fs(in: VOut) -> FOut { let p_near = u.inv_vp * vec4(in.ndc, 0.0, 1.0); let p_far = u.inv_vp * vec4(in.ndc, 1.0, 1.0); let ro_world = u.cam_eye.xyz; let rd = normalize(p_far.xyz / p_far.w - p_near.xyz / p_near.w); let dim = u.grid_dim.xyz; let B = u.grid_dim.w; let ro = ro_world + dim * 0.5; let h = trace(ro, rd, dim, B); let t_vox = select(1e30, h.t, h.hit); let eh = trace_entities(ro, rd, t_vox); let fog_density = u.sky_zenith.w; var albedo: vec3; var normal: vec3; var p: vec3; var ao: f32; var t_hit: f32; if (eh.hit) { albedo = eh.color; normal = eh.normal; p = ro + rd * eh.t; ao = 1.0; t_hit = eh.t; } else if (h.hit) { albedo = voxel_at(h.vox).rgb; normal = h.normal; p = ro + rd * h.t; ao = compute_ao(h.vox, h.normal, p, dim); t_hit = h.t; } else { // Sin impacto: con niebla activa pintamos cielo propio (a profundidad // lejana, así una malla por delante igual se dibuja); sin niebla, // descartamos para dejar ver el fondo vello (comportamiento clásico). if (fog_density > 0.0) { var sky: FOut; sky.color = vec4(sky_color(rd), 1.0); sky.depth = 1.0; return sky; } discard; } let ldir = u.sun_dir.xyz; let diff = max(dot(normal, ldir), 0.0); let so = p + normal * 0.5 + ldir * 0.01; let sh_v = trace(so, ldir, dim, B); let sh_e = trace_entities(so, ldir, 1e30); let shadow = select(1.0, 0.25, sh_v.hit || sh_e.hit); // Luz CON COLOR (look cinematográfico) sin uniforms nuevos: el color del sol // sale de su elevación (cálido al ras del horizonte → blanco en lo alto) y el // ambiente del color del cielo (rebote frío del cenit). El mood se controla // moviendo `sun_dir` y la paleta de cielo, que ya viajan en el uniform. let ao_term = 0.35 + 0.65 * ao; let sun_h = clamp(u.sun_dir.y, 0.0, 1.0); let sun_col = mix(vec3(1.0, 0.52, 0.24), vec3(1.0, 0.97, 0.9), sun_h); // Ambiente tintado por el cielo pero con ~la misma luminancia que el flat 0.32 // de antes (no oscurece las caras que no ven el sol). let amb_col = mix(vec3(0.45), u.sky_zenith.xyz, 0.45) * 0.70; var light = amb_col + sun_col * (0.78 * diff * shadow); // Luces puntuales coloreadas (antorchas/lámparas): caída suave por distancia // + sombra dura opcional (un shadow ray hacia la luz, acotado a la distancia // a la luz: si un voxel/entidad intercepta *antes* de llegar, la luz no llega). // `p` está en espacio de voxel, igual que `light.pos`. let nlights = i32(u.n_lights.x); let pt_shadows = u.n_lights.y > 0.5; for (var li = 0; li < nlights; li = li + 1) { let lp = u.lights[2 * li]; let lc = u.lights[2 * li + 1]; let to = lp.xyz - p; let d = length(to); let range = max(lp.w, 1e-3); var att = clamp(1.0 - d / range, 0.0, 1.0); att = att * att; // caída cuadrática suave let ldir2 = to / max(d, 1e-3); let ndl = max(dot(normal, ldir2), 0.0); var vis = 1.0; if (pt_shadows && att > 0.0 && ndl > 0.0) { // Sale de la superficie un pelo hacia la luz para no auto-sombrearse. let lso = p + normal * 0.5 + ldir2 * 0.01; let bias = 0.75; // tolerancia: no contar el propio voxel ni la luz. let lrad = lc.w; // radio de área (penumbra); 0 = sombra dura. if (lrad <= 0.0) { // Sombra dura: un solo shadow ray hacia el centro de la luz. let hv = trace(lso, ldir2, dim, B); let blocked_v = hv.hit && hv.t < d - bias; let he = trace_entities(lso, ldir2, d - bias); vis = select(1.0, 0.0, blocked_v || he.hit); } else { // Sombra blanda: la luz es un disco de radio `lrad`. Se reparten // varios shadow rays hacia puntos del disco (perpendicular a la // dirección a la luz) y se promedia la visibilidad → penumbra. // Base ortonormal del plano del disco. var up = vec3(0.0, 1.0, 0.0); if (abs(ldir2.y) > 0.9) { up = vec3(1.0, 0.0, 0.0); } let tx = normalize(cross(up, ldir2)); let ty = cross(ldir2, tx); var occ = 0.0; for (var s = 0; s < SOFT_TAPS; s = s + 1) { let o = SOFT_DISK[s] * lrad; let lp2 = lp.xyz + tx * o.x + ty * o.y; let to2 = lp2 - p; let d2 = length(to2); let dir2 = to2 / max(d2, 1e-3); let hv = trace(lso, dir2, dim, B); let bv = hv.hit && hv.t < d2 - bias; let he = trace_entities(lso, dir2, d2 - bias); if (bv || he.hit) { occ = occ + 1.0; } } vis = 1.0 - occ / f32(SOFT_TAPS); } } light = light + lc.rgb * (att * ndl * vis); } var color = albedo * light * ao_term; // Niebla / perspectiva aérea: lo lejano desvanece hacia el cielo en esa // dirección, lo que hace legible el borde de un mundo grande. if (fog_density > 0.0) { let f = 1.0 - exp(-t_hit * fog_density); color = mix(color, sky_color(rd), f); } var out: FOut; out.color = vec4(color, 1.0); out.depth = frag_depth(p, dim); return out; } "#;