//! `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, /// **God rays** (luz volumétrica / *in-scattering*): `0.0` = off; `~1.0` = /// haces de sol marcados cruzando la niebla. El shader marcha unas pocas /// muestras a lo largo del rayo de cámara, prueba la visibilidad del sol en /// cada una y acumula luz dispersada hacia el ojo (más fuerte mirando hacia el /// sol, fase tipo Henyey-Greenstein). Sólo actúa con `fog_density > 0` (la /// niebla es el medio que dispersa). Es el sello anti-Minecraft: la malla de /// cubos no puede hacer esto; el ray-march sí. Coste: shadow rays extra por /// píxel — subilo con criterio. pub god_rays: f32, } impl Default for Atmosphere { fn default() -> Self { Self { sky_zenith: [70, 120, 200], sky_horizon: [188, 208, 230], fog_density: 0.0, god_rays: 0.0, } } } /// **Cielo de noche**: estrellas, luna y luz de luna. Va aparte de /// [`Atmosphere`] porque ésa se arma con literal de struct en muchos lados, y /// porque es otra decisión: la atmósfera dice el color del aire, esto dice si /// es de noche. /// /// `amount = 0.0` (default) = de día, byte-idéntico a antes. Con `> 0` la /// dirección del sol pasa a ser la de **la luna**: el disco se vuelve pálido y /// nítido, la luz directa se enfría y se apaga, el ambiente toma el azul del /// cenit, y los rayos que no pegan nada pintan cielo propio aunque no haya /// niebla (sin eso, una noche sin bruma dejaba ver el fondo vello). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct NightSky { /// Cuánto de noche, `0..1`. Mezcla la luz de día con la de luna. pub amount: f32, /// Densidad de estrellas, `0..1`. Sólo sobre el horizonte, y no tiñen la /// niebla: una estrella no se ve a través de un cerro lejano. pub stars: f32, /// Radio angular de la luna (radianes); `0` = sin luna. La real mide /// ~0,0045; un dibujo la quiere bastante más grande (~0,05). pub moon: f32, } /// Renderer de voxels por ray-march de dos niveles sobre un brick pool sparse. pub struct VoxelRenderer { pool: wgpu::Texture, /// Atlas **paralelo** al `pool` (mismos slots/dimensiones): por voxel guarda el /// **detalle sub-voxel** autorado (`Rg8Uint` = `[rugosidad, bisel]` con offset /// +1; `[0,0]` = sin autor → look global). Se hornea al armar/rearmar el pool y /// crece con él (`grow_layers`); `sync`/`scroll_to` NO lo re-suben (el detalle es /// propiedad de construcción del material, no un canal editado en vivo). Su /// isosuperficie/indirección son las mismas del pool → el shader lo direcciona /// igual. Todo `[0,0]` (caso común) = retrocompat byte a byte. dpool: 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, ubuf_ser: 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, /// **Seres SDF** — personajes orgánicos de primitivas analíticas fundidas por /// `smin`, sphere-traceados en el mismo pase (ver [`crate::ser`]). Coordenadas /// de voxel `[0, dim]`. Se empacan y suben en cada `render`; lista vacía = /// costo ~cero (retrocompat). pub sers: 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, /// **Rugosidad sub-voxel** (bump mapping procedural). Perturba la normal de /// sombreado del voxel golpeado con el gradiente tangencial de un ruido de /// valor 3D → la superficie deja de leerse rectangular a corta distancia /// (roca/tierra granulada) sin tocar geometría ni memoria. `0.0` = off /// (comportamiento clásico, cara plana axial). La perturbación se atenúa /// sola en voxels lustrosos (vidrio/metal, `alpha` bajo) → sólo lo mate se /// rugosiza. Rango útil ≈ `0.0..1.5`. pub rugosidad_amp: f32, /// Frecuencia del ruido de rugosidad (ciclos por voxel). Más alto = grano /// más fino/apretado. Default `1.6`. Sólo importa si `rugosidad_amp > 0`. pub rugosidad_freq: f32, /// **Bisel / suavizado sub-voxel** (pendientes diagonales). Refina el hit del /// ray-march contra la **isosuperficie trilineal de la ocupación** (derivada de /// la forma, sin storage extra): el terreno escalonado se lee como rampa /// diagonal y las esquinas convexas quedan biseladas, sin dejar de ser "bloque /// tallado". `0.0` = off (voxel duro clásico, byte-idéntico); `1.0` = superficie /// suave plena; intermedio = bisel parcial. Las zonas planas quedan planas (la /// isosuperficie sólo se aparta de la cara en bordes/esquinas). Cuesta ~varias /// muestras del campo por píxel golpeado → sólo se paga con `bisel > 0`. pub bisel: f32, /// Modo diagnóstico: pinta la **normal** de sombreado como color (`n·0.5+0.5`) /// en vez del sombreado. Lo usa la certificación (`example bisel_demo`) para /// medir qué fracción de la superficie dejó de ser axial. `false` en producción. pub debug_normals: bool, /// **Silueta sub-voxel POR MATERIAL** (`false` = off, retrocompat byte-idéntico). /// Con `bisel` global prendido, `trace_smooth` traza la isosuperficie GLOBAL: la /// silueta sale igual para todos (el `bisel` por voxel sólo cambiaba la NORMAL de /// sombreado, no el contorno). Con este flag, el CAMPO mismo mezcla escalón↔trilineal /// según el `bisel` POR VOXEL del material sólido cercano → un material duro (bisel /// bajo) conserva la silueta axial/blocky mientras otro liso (bisel alto) la redondea, /// en el MISMO frame. Cuesta: samplea el detalle de las 8 esquinas por evaluación del /// campo → sólo pagalo cuando de veras quieres siluetas distintas por material. Sólo /// tiene efecto con `bisel > 0` (que enciende `trace_smooth`). pub silueta_por_voxel: bool, /// **Cantidad de oclusión ambiental** `[0,1]` (`1.0` = clásica, retrocompat /// byte-idéntico; `0.0` = sin AO). La AO se computa **axialmente** (`axis_of` de la /// normal indexa voxels enteros); sobre una superficie REDONDEADA (bisel) el eje /// dominante salta a lo largo de la esfera y la AO pinta **anillos concéntricos** — /// correcto para bloques, artefacto para un ser orgánico. Bajala (≈0,2–0,4) al rendir /// seres/blobs voxel suaves para que el cuerpo lea liso. Viaja en `n_lights.w`. pub ao_amount: f32, /// **Cielo de noche** (ver [`NightSky`]). Default = de día. pub night: NightSky, } 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()); // Atlas de detalle sub-voxel, espejo del pool (mismos slots). Rg8Uint = dos // bytes por voxel: `[rugosidad, bisel]` (con offset +1; `[0,0]` = sin autor). let dpool = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-3d-voxel-dpool"), size: extent([ax * BRICK, ay * BRICK, az * BRICK]), mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D3, format: wgpu::TextureFormat::Rg8Uint, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let dpool_view = dpool.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(shader_source().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), uniform_entry(5), // Atlas de detalle sub-voxel (Rg8Uint, direccionado como el pool). wgpu::BindGroupLayoutEntry { binding: 4, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Uint, view_dimension: wgpu::TextureViewDimension::D3, multisampled: false, }, count: None, }, ], }); 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(8×16=128)+noche(16) size: 416, 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 ubuf_ser = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-3d-voxel-ubuf-ser"), // count(16) + hdrs(N×48) + prims(M×64) — layout de `ser::pack_sers`. size: (16 + crate::ser::VOXEL_MAX_SERS * 48 + crate::ser::VOXEL_MAX_SER_PRIMS * 64) 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(), }, wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&dpool_view), }, wgpu::BindGroupEntry { binding: 5, resource: ubuf_ser.as_entire_binding(), }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("llimphi-3d-voxel-pl"), bind_group_layouts: &[Some(&bgl)], immediate_size: 0, }); 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: Some(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: Some(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_mask: None, cache: None, }); let mut r = Self { pool, dpool, indir, bind_group, bgl, pipeline, ubuf, ubuf_ent, ubuf_ser, 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(), sers: Vec::new(), lights: Vec::new(), point_shadows: true, rugosidad_amp: 0.0, rugosidad_freq: 1.6, bisel: 0.0, debug_normals: false, silueta_por_voxel: false, ao_amount: 1.0, night: NightSky::default(), }; // 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.upload_brick_detail(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, ); } /// Sube el brick de **detalle sub-voxel** al `dpool` (espeja `upload_brick`, /// mismo slot/origen, 2 bytes/voxel). Se llama junto a `upload_brick` al armar /// el pool y al hornear bricks nuevos. fn upload_brick_detail(&self, queue: &wgpu::Queue, slot: u32, grid: &VoxelGrid, cx: u32, cy: u32, cz: u32) { let data = grid.extract_brick_detail(BRICK, cx, cy, cz); let o = self.slot_origin(slot); write_3d( queue, &self.dpool, [o[0] * BRICK, o[1] * BRICK, o[2] * BRICK], [BRICK, BRICK, BRICK], 2, &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: &[], }); // El dpool crece en paralelo (mismos slots): la franja nueva queda en `[0,0]` // (sin autor → look global), que es lo que corresponde al contenido streameado. let new_dpool = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-3d-voxel-dpool"), size: extent([ax * BRICK, ay * BRICK, new_az * BRICK]), mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D3, format: wgpu::TextureFormat::Rg8Uint, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); // Copia ambos atlas viejos (mismas dimensiones x/y, az capas) al nuevo. let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("llimphi-3d-voxel-pool-grow"), }); let copy = |enc: &mut wgpu::CommandEncoder, src: &wgpu::Texture, dst: &wgpu::Texture| { enc.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: src, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyTextureInfo { texture: dst, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, extent([ax * BRICK, ay * BRICK, az * BRICK]), ); }; copy(&mut enc, &self.pool, &new_pool); copy(&mut enc, &self.dpool, &new_dpool); queue.submit(std::iter::once(enc.finish())); // Re-armar el bind group con las views nuevas (indir/ubufs intactos). let pool_view = new_pool.create_view(&wgpu::TextureViewDescriptor::default()); let dpool_view = new_dpool.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() }, wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&dpool_view) }, wgpu::BindGroupEntry { binding: 5, resource: self.ubuf_ser.as_entire_binding() }, ], }); let old_cap = ax * ay * az; let new_cap = ax * ay * new_az; self.pool = new_pool; self.dpool = new_dpool; 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(416); 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); // sun_dir.w lleva la fuerza de god rays (in-scattering volumétrico). for v in [s[0], s[1], s[2], self.atmosphere.god_rays.max(0.0)] { u.extend_from_slice(&v.to_ne_bytes()); } // cdim.w piggyback = amplitud de rugosidad sub-voxel (0 = off); atlas.w = su // frecuencia. Reusan lanes que estaban en 0 → retrocompatible. for v in [ self.cdim[0] as f32, self.cdim[1] as f32, self.cdim[2] as f32, self.rugosidad_amp.max(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, self.rugosidad_freq.max(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()); } // sky_horizon.w piggyback = bisel/suavizado sub-voxel (0 = off, retrocompat). 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, self.bisel.clamp(0.0, 1.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. // scroll.w piggyback = flag de diagnóstico de normales (0 = sombreado normal). 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, if self.debug_normals { 1.0 } else { 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 }; // n_lights.z piggyback = flag de silueta sub-voxel por material (0 = off, // retrocompat: el campo queda trilineal puro). let silueta_flag = if self.silueta_por_voxel { 1.0 } else { 0.0 }; // n_lights.w piggyback = cantidad de AO [0,1] (1 = clásico byte-idéntico). for v in [nl as f32, shadow_flag, silueta_flag, self.ao_amount.clamp(0.0, 1.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()); } } // Noche: x = cuánto, y = estrellas, z = radio de la luna. let nt = &self.night; for v in [nt.amount.clamp(0.0, 1.0), nt.stars.clamp(0.0, 1.0), nt.moon.max(0.0), 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); // Seres SDF: header + primitivas aplanadas (layout de `ser::pack_sers`). queue.write_buffer(&self.ubuf_ser, 0, &crate::ser::pack_sers(&self.sers)); } /// 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, multiview_mask: 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), w = fuerza de god rays 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 = luces puntuales, y = sombras on/off, z = silueta sub-voxel por material, w = cantidad de AO [0,1] lights: array, 8>, // por luz: [pos.xyz, range], [color.rgb, radio_area] noche: vec4, // x = cuánto de noche (0 = día), y = estrellas, z = radio de la luna }; // 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). // Disco de muestreo para la penumbra: 16 taps en dos anillos (Poisson-ish) → // borde de sombra suave sin bandeo. Más taps = penumbra más limpia (a más costo). const SOFT_TAPS: i32 = 16; const SOFT_DISK = array, 16>( // anillo interno (r≈0.4) vec2( 0.40, 0.00), vec2( 0.28, 0.28), vec2( 0.00, 0.40), vec2(-0.28, 0.28), vec2(-0.40, 0.00), vec2(-0.28, -0.28), vec2( 0.00, -0.40), vec2( 0.28, -0.28), // anillo externo (r≈0.85, rotado 22.5°) vec2( 0.785, 0.325), vec2( 0.325, 0.785), vec2(-0.325, 0.785), vec2(-0.785, 0.325), vec2(-0.785, -0.325), vec2(-0.325, -0.785), vec2( 0.325, -0.785), vec2( 0.785, -0.325), ); 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; // Detalle sub-voxel por voxel (Rg8Uint = [rugosidad, bisel] con offset +1; [0,0] = // sin autor → look global). Direccionado como el pool (mismos slots/indirección). @group(0) @binding(4) var dpool: texture_3d; 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); } // Detalle sub-voxel crudo del voxel `voxel` = `[rug_byte, bisel_byte]` (offset +1; // `[0,0]` = sin autor). Direccionamiento idéntico a `voxel_at`, sobre el `dpool`. fn detail_raw(voxel: vec3) -> vec2 { let vi = vec3(voxel); if (any(vi < vec3(0)) || any(vec3(vi) >= u.grid_dim.xyz)) { return vec2(0u); } let bu = i32(u.grid_dim.w); let cc = vi / bu; let s = slot_at(cc); if (s == 0u) { return vec2(0u); } 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(dpool, acell * bu + local, 0).xy; } // Detalle DECODIFICADO = `(has, rug_amp, bisel)`. `has` = 1 si el voxel tiene detalle // autorado (sobrescribe el look global), 0 = usar el global. `rug_amp` en `[0,1.5]`, // `bisel` en `[0,1]` (espeja el offset +1 de CPU: `(byte-1)/254`). fn pv_detail(voxel: vec3) -> vec3 { let d = detail_raw(voxel); if ((d.x | d.y) == 0u) { return vec3(0.0, 0.0, 0.0); } let rug = (f32(d.x) - 1.0) / 254.0 * 1.5; let bis = (f32(d.y) - 1.0) / 254.0; return vec3(1.0, max(rug, 0.0), clamp(bis, 0.0, 1.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. // DDA por el grid. `pass_glass` = los rayos de SOMBRA atraviesan el vidrio (voxels // muy lustrosos, alpha < GLASS_A) para que las ventanas dejen entrar la luz; los // rayos primarios (pass_glass=false) sí lo dibujan. fn trace(ro: vec3, rd_in: vec3, dim: vec3, B: f32, pass_glass: bool) -> 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); // Vidrio (muy lustroso): transparente a los rayos de sombra → la // ventana no proyecta sombra y deja pasar la luz del día. let es_vidrio = c.a < 0.62; if (c.a > 0.5 && !(pass_glass && es_vidrio)) { 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); let nx = u.noche.x; if (nx <= 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; } // De noche el sol se apaga y en su dirección está la luna: un disco pálido // de borde nítido (se dibuja, no se deslumbra) con un halo frío corto. c = c + (vec3(1.0, 0.96, 0.84) * pow(s, 260.0) * 1.6 + vec3(1.0, 0.90, 0.72) * pow(s, 9.0) * 0.16) * (1.0 - nx); let r = u.noche.z; if (r > 0.0) { let ang = acos(clamp(dot(rd, u.sun_dir.xyz), -1.0, 1.0)); let disco = 1.0 - smoothstep(r * 0.9, r, ang); let halo = exp(-max(ang - r, 0.0) / (r * 2.5)) * 0.22; c = c + vec3(0.55, 0.65, 0.9) * halo * nx; c = mix(c, vec3(0.97, 0.95, 0.86), disco * nx); } return c; } // Estrellas: el rayo cae en una celda de una grilla sobre la esfera; una // fracción de las celdas (la densidad) lleva una estrella en un punto fijo de la // celda, con brillo propio. Determinista: bloqueadas al mundo y estables en el // reel. Van aparte de `sky_color` porque ésa también tiñe la niebla, y una // estrella no se ve a través de un cerro lejano. fn estrellas(rd: vec3) -> vec3 { let nx = u.noche.x; let dens = u.noche.y; if (nx <= 0.0 || dens <= 0.0 || rd.y <= 0.0) { return vec3(0.0); } let q = rd * 170.0; let cel = floor(q); if (hash13(cel) > dens * 0.14) { return vec3(0.0); } let jit = vec3(hash13(cel + 7.1), hash13(cel + 3.7), hash13(cel + 1.3)) - 0.5; let d = length(q - (cel + 0.5 + jit * 0.6)); let brillo = (1.0 - smoothstep(0.06, 0.24, d)) * (0.45 + 0.55 * hash13(cel + 11.0)); // Se apagan contra el horizonte (ahí el cielo es más claro y hay bruma), y // detrás de la luna no hay ninguna. var tapa = smoothstep(0.0, 0.18, rd.y); let r = u.noche.z; if (r > 0.0) { let ang = acos(clamp(dot(rd, u.sun_dir.xyz), -1.0, 1.0)); tapa = tapa * smoothstep(r, r * 1.6, ang); } return vec3(0.92, 0.94, 1.0) * brillo * tapa * nx; } // El color de los haces de luz: cálido de día; frío y mucho más tenue bajo la // luna (con la fuerza de día, el halo se tragaba medio cielo y sus estrellas). fn luz_haces() -> vec3 { return mix(vec3(1.0, 0.95, 0.82), vec3(0.62, 0.70, 0.92) * 0.25, u.noche.x); } // 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; } // --- Rugosidad sub-voxel (bump procedural) --------------------------------- // Hash 3D determinista → [0,1) (estilo iq). Sin RNG por-píxel: estable en el reel. fn hash13(p3: vec3) -> f32 { var p = fract(p3 * 0.1031); p = p + dot(p, p.zyx + vec3(31.32)); return fract((p.x + p.y) * p.z); } // Ruido de valor 3D (trilineal sobre hashes de lattice, con suavizado smoothstep). fn vnoise(x: vec3) -> f32 { let i = floor(x); let f = fract(x); let w = f * f * (3.0 - 2.0 * f); let n000 = hash13(i + vec3(0.0, 0.0, 0.0)); let n100 = hash13(i + vec3(1.0, 0.0, 0.0)); let n010 = hash13(i + vec3(0.0, 1.0, 0.0)); let n110 = hash13(i + vec3(1.0, 1.0, 0.0)); let n001 = hash13(i + vec3(0.0, 0.0, 1.0)); let n101 = hash13(i + vec3(1.0, 0.0, 1.0)); let n011 = hash13(i + vec3(0.0, 1.0, 1.0)); let n111 = hash13(i + vec3(1.0, 1.0, 1.0)); let x00 = mix(n000, n100, w.x); let x10 = mix(n010, n110, w.x); let x01 = mix(n001, n101, w.x); let x11 = mix(n011, n111, w.x); return mix(mix(x00, x10, w.y), mix(x01, x11, w.y), w.z); } // Inclina la normal geométrica `geo_n` por el gradiente TANGENCIAL de una altura // de ruido en `p` (espacio de voxel). `amp` = fuerza, `freq` = grano. Proyecta el // gradiente al plano tangente → la normal se queda en el hemisferio exterior (sin // auto-sombra) para amplitudes moderadas. Diferencias hacia-adelante (4 taps). fn perturb_normal(geo_n: vec3, p: vec3, freq: f32, amp: f32) -> vec3 { let sp = p * freq; let e = 0.5; let h0 = vnoise(sp); let grad = vec3( vnoise(sp + vec3(e, 0.0, 0.0)) - h0, vnoise(sp + vec3(0.0, e, 0.0)) - h0, vnoise(sp + vec3(0.0, 0.0, e)) - h0, ); let tang = grad - geo_n * dot(grad, geo_n); // sólo la componente tangente return normalize(geo_n - tang * amp); } // --- Bisel / suavizado sub-voxel (pendientes diagonales) ------------------- // Ocupación de la celda que contiene `v` (1 sólido / 0 vacío). Fuera de grilla → 0. fn occ_f(v: vec3) -> f32 { return select(0.0, 1.0, voxel_at(v).a > 0.5); } // Campo escalar TRILINEAL = ocupación sobre los 8 centros de celda vecinos. Su // isosuperficie (`field = 0.5`) es una superficie suave: plana donde la forma es // plana, diagonal donde escalona, redondeada en las esquinas → el "voxel suave". fn field_trilinear(p: vec3) -> f32 { let g = p - vec3(0.5); let i = floor(g); let f = fract(g); let c000 = occ_f(i + vec3(0.0, 0.0, 0.0)); let c100 = occ_f(i + vec3(1.0, 0.0, 0.0)); let c010 = occ_f(i + vec3(0.0, 1.0, 0.0)); let c110 = occ_f(i + vec3(1.0, 1.0, 0.0)); let c001 = occ_f(i + vec3(0.0, 0.0, 1.0)); let c101 = occ_f(i + vec3(1.0, 0.0, 1.0)); let c011 = occ_f(i + vec3(0.0, 1.0, 1.0)); let c111 = occ_f(i + vec3(1.0, 1.0, 1.0)); let x00 = mix(c000, c100, f.x); let x10 = mix(c010, c110, f.x); let x01 = mix(c001, c101, f.x); let x11 = mix(c011, c111, f.x); return mix(mix(x00, x10, f.y), mix(x01, x11, f.y), f.z); } // **Suavidad local del material** en `p` para la silueta POR VOXEL: media, ponderada // por peso trilineal Y ocupación, del `bisel` autorado de las 8 celdas vecinas SÓLIDAS // (celda sin autor = 1.0 = suave). Un material duro (bisel bajo) tira su vecindario a // 0 → el campo se vuelve escalón (silueta axial); uno liso lo deja trilineal (silueta // redondeada). El ratio num/den ignora la magnitud del peso → aun en la celda vacía // diagonal de una esquina convexa, si la única esquina sólida es dura, la suavidad ahí // es baja y la esquina se conserva afilada. Sólo se llama con el flag prendido. fn smoothness_local(p: vec3) -> f32 { let g = p - vec3(0.5); let i = floor(g); let f = fract(g); var num = 0.0; var den = 0.0; for (var dx = 0; dx < 2; dx = dx + 1) { for (var dy = 0; dy < 2; dy = dy + 1) { for (var dz = 0; dz < 2; dz = dz + 1) { let corner = i + vec3(f32(dx), f32(dy), f32(dz)); if (occ_f(corner) > 0.5) { let wx = select(1.0 - f.x, f.x, dx == 1); let wy = select(1.0 - f.y, f.y, dy == 1); let wz = select(1.0 - f.z, f.z, dz == 1); let w = wx * wy * wz; let pv = pv_detail(corner); let s = select(1.0, pv.z, pv.x > 0.5); // sin autor = suave num = num + w * s; den = den + w; } } } } if (den < 1e-5) { return 1.0; } return num / den; } // Campo escalar del ray-march suave. Por defecto = trilineal (voxel suave). Con la // silueta POR VOXEL prendida (`n_lights.z`), mezcla escalón↔trilineal según la suavidad // del material sólido cercano: `mix(occ_step, trilineal, s)` — `s=1` deja el cruce en la // isosuperficie (redondeado), `s=0` lo empuja a la cara del voxel (silueta dura axial), // porque el escalón `occ_f` salta 0→1 justo ahí. Así dos materiales comparten un frame // con siluetas distintas. Fast path byte-idéntico cuando el flag está off o todo suave. fn field(p: vec3) -> f32 { let suave = field_trilinear(p); if (u.n_lights.z < 0.5) { return suave; } let s = smoothness_local(p); if (s >= 0.999) { return suave; } let hard = occ_f(p); // escalón 0/1: su iso 0.5 cae en la cara del voxel (axial) return mix(hard, suave, s); } // Normal exterior de la isosuperficie en `p` = opuesta del gradiente del campo // (que apunta hacia el sólido). Diferencias hacia-adelante. Gradiente nulo (plano // saturado) → arriba por defecto. **Usa el campo TRILINEAL** (no el mixto): con la // silueta por voxel, el campo duro es un escalón `occ_f` cuyo gradiente por diferencias // es degenerado (salta 0/1) y haría titilar `axis_of` en las caras planas. La geometría // (silueta) sale del campo mixto; el NORMAL del trilineal → `axis_of(sn)` elige la cara // axial limpia para el material duro, y `sn` queda redondeado para el liso. fn smooth_normal(p: vec3) -> vec3 { // Gradiente de campo DIFUMINADO por diferencias centrales ANCHAS. El trilineal // binario con una diferencia hacia-adelante corta (e=0.5) sólo toma direcciones // cuantizadas por el patrón de ocupación 2×2×2 local → la normal salta en // escalones a lo largo de una superficie curva grande → **anillos de cebolla** // en el sombreado (el artefacto que hacía "monstruo" a un ser redondo). Promediar // el gradiente sobre un radio mayor (diferencias centrales a dos radios, 0.9 y 1.8) // integra suficiente vecindario para que la normal varíe continua → esfera lisa. // Sólo se llama desde `trace_smooth` (bisel>0) → el terreno duro (bisel=0, `trace`) // queda byte-idéntico. Radios < que el semieje de un miembro fino (≈6 vox) para no // cancelar el gradiente cruzando la pieza. let e0 = 1.0; let e1 = 2.2; let gx = (field_trilinear(p + vec3(e0, 0.0, 0.0)) - field_trilinear(p - vec3(e0, 0.0, 0.0))) + (field_trilinear(p + vec3(e1, 0.0, 0.0)) - field_trilinear(p - vec3(e1, 0.0, 0.0))); let gy = (field_trilinear(p + vec3(0.0, e0, 0.0)) - field_trilinear(p - vec3(0.0, e0, 0.0))) + (field_trilinear(p + vec3(0.0, e1, 0.0)) - field_trilinear(p - vec3(0.0, e1, 0.0))); let gz = (field_trilinear(p + vec3(0.0, 0.0, e0)) - field_trilinear(p - vec3(0.0, 0.0, e0))) + (field_trilinear(p + vec3(0.0, 0.0, e1)) - field_trilinear(p - vec3(0.0, 0.0, e1))); let g = vec3(gx, gy, gz); let gl = length(g); if (gl < 1e-5) { return vec3(0.0, 1.0, 0.0); } return -g / gl; } // Eje dominante de una normal (para AO/facetado): la cara axial más cercana. fn axis_of(n: vec3) -> vec3 { let a = abs(n); if (a.x >= a.y && a.x >= a.z) { return vec3(sign(n.x), 0.0, 0.0); } if (a.y >= a.z) { return vec3(0.0, sign(n.y), 0.0); } return vec3(0.0, 0.0, sign(n.z)); } // **Capa 2b — traza la ISOSUPERFICIE suave del campo** (no el voxel duro): el hit, // la silueta Y la normal salen del cruce `field = iso`. Mantiene el DDA GRUESO para // saltar bricks vacíos (aceleración), pero dentro de un brick con contenido marcha el // campo a sub-pasos y biseca el primer cruce de fuera(, rd_in: vec3, dim: vec3, B: f32, bisel: 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 iso = 0.5; 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; let dt = 0.5; var f_prev = field(ro + safe_rd * t_enter) - iso; 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) { let t_exit = min(min(t_max_c.x, t_max_c.y), t_max_c.z); var t = t_cell; // Cota de sub-pasos: un brick en diagonal mide hasta B·√3 de recorrido; // a dt=0.5 hacen falta ~3.5·B pasos para cruzarlo (menos → pinholes en // rayos diagonales, que se saltan el cruce en la parte lejana del brick). let max_sub = i32(B) * 4 + 4; for (var si = 0; si < max_sub; si = si + 1) { let t_next = min(t + dt, t_exit); let f_next = field(ro + safe_rd * t_next) - iso; if (f_prev < 0.0 && f_next >= 0.0) { // Bisección del cruce en [t, t_next]. var a = t; var b = t_next; var fa = field(ro + safe_rd * a) - iso; for (var k = 0; k < 6; k = k + 1) { let tm = 0.5 * (a + b); let fm = field(ro + safe_rd * tm) - iso; if (fa * fm <= 0.0) { b = tm; } else { a = tm; fa = fm; } } let th = 0.5 * (a + b); let ph = ro + safe_rd * th; let sn = smooth_normal(ph); // Color/brillo: la celda SÓLIDA más cercana entre las 8 esquinas del // punto (en la isosuperficie hay ≥1 sólida). Muestrear la celda por // proximidad al punto de hit y no por `floor(ph - n·k)` evita las // motas negras (una celda con ocupación 0 aunque el campo ahí sea >½). let bc = floor(ph - vec3(0.5)); var best_c = clamp(floor(ph - sn * 0.5), vec3(0.0), dim - 1.0); var best_d = 1e9; for (var dx = 0; dx < 2; dx = dx + 1) { for (var dy = 0; dy < 2; dy = dy + 1) { for (var dz = 0; dz < 2; dz = dz + 1) { let cell = bc + vec3(f32(dx), f32(dy), f32(dz)); if (voxel_at(cell).a > 0.5) { let d = distance(cell + vec3(0.5), ph); if (d < best_d) { best_d = d; best_c = cell; } } } } } // Bisel por voxel: si la celda golpeada trae detalle autorado, su // bisel manda la mezcla facetado↔suave de la normal (dos materiales // en la misma escena mezclan distinto); si no, el bisel global. La // geometría/silueta ya salió de la isosuperficie global (barato). let vox = clamp(best_c, vec3(0.0), dim - 1.0); let pv = pv_detail(vox); let bmix = select(bisel, pv.z, pv.x > 0.5); h.hit = true; h.t = th; h.vox = vox; h.normal = normalize(mix(axis_of(sn), sn, bmix)); return h; } f_prev = f_next; t = t_next; if (t >= t_exit) { break; } } } 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; } 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; } 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; } if (any(cc < vec3(0.0)) || any(cc >= cdim)) { return h; } } return h; } struct FOut { @location(0) color: vec4, @builtin(frag_depth) depth: f32, }; // **God rays** (in-scattering volumétrico): marcha el rayo de cámara `ro+rd·t` // hasta `max_t` tomando muestras; en cada una tira un shadow ray hacia el sol y // acumula "cuánta luz solar llega a ese punto de la niebla". El resultado, pesado // por una fase hacia-adelante (brilla mirando al sol) y por la densidad de niebla, // es la luz dispersada hacia el ojo → haces visibles cruzando el aire. Devuelve un // escalar [0,1+] que se multiplica por el color cálido del sol. Coste: N shadow // rays por píxel — sólo se llama si `u.sun_dir.w > 0` y hay niebla. fn god_rays(ro: vec3, rd: vec3, max_t: f32, dim: vec3, B: f32) -> f32 { let strength = u.sun_dir.w; let fogd = u.sky_zenith.w; if (strength <= 0.0 || fogd <= 0.0) { return 0.0; } // Fase hacia-adelante PRIMERO: los haces sólo se ven mirando hacia el sol. Los // píxeles que miran lejos aportan ~0 → cortamos ANTES de la marcha (la marcha es // lo caro: N shadow rays/píxel). God rays queda casi gratis salvo en la cuña que // enfrenta al sol, que es justo donde el efecto importa. let phase = pow(max(dot(rd, u.sun_dir.xyz), 0.0), 6.0); if (phase < 0.003) { return 0.0; } let march_len = min(max_t, 220.0); let steps = 8; // pocas muestras: el efecto es atmosférico, no necesita más (y cuesta) let dt = march_len / f32(steps); // Dither del paso inicial para romper el bandeo de pocas muestras. let jitter = fract(sin(dot(rd.xy, vec2(12.9898, 78.233))) * 43758.5453); var acc = 0.0; for (var i = 0; i < steps; i = i + 1) { let tt = (f32(i) + jitter) * dt; if (tt >= max_t) { break; } let sp = ro + rd * tt; let sh = trace(sp, u.sun_dir.xyz, dim, B, true); acc = acc + select(1.0, 0.0, sh.hit); // 1 si el sol llega, 0 si está en sombra } acc = acc / f32(steps); let depth_fac = 1.0 - exp(-march_len * fogd); // más aire, más dispersión return acc * strength * phase * depth_fac; } @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; // Bisel/suavizado sub-voxel: con `bisel > 0` la primaria TRAZA la isosuperficie // suave del campo (silueta + normal diagonales, no sólo sombreado); `bisel = 0` = // hit duro clásico byte-idéntico. Las sombras siempre usan el trace duro (barato). let bisel = u.sky_horizon.w; var h: Hit; if (bisel > 0.0) { h = trace_smooth(ro, rd, dim, B, bisel); } else { h = trace(ro, rd, dim, B, false); } let t_vox = select(1e30, h.t, h.hit); let eh = trace_entities(ro, rd, t_vox); // Seres SDF: compiten por t contra voxels Y entidades (max_t = lo más cercano // hasta acá) → si hay hit de ser, ES el más cercano. Gateado por count. let t_ent = select(t_vox, eh.t, eh.hit); var shs: SerHit; shs.hit = false; if (seru.count.x > 0.5) { shs = trace_sers(ro, rd, t_ent); } let fog_density = u.sky_zenith.w; var albedo: vec3; var normal: vec3; var p: vec3; var ao: f32; var t_hit: f32; // Brillo/reflectividad por voxel: viaja en el alpha (matte = 1.0 → 0 brillo; // valores más bajos = más lustre). Sólo lo llevan los minerales lustrosos. var shine_a: f32 = 1.0; // Sombreado "de personaje" (rampa toon + rim) sólo para los seres SDF. var is_ser: f32 = 0.0; if (shs.hit) { albedo = ser_color(shs.si, shs.p); normal = ser_normal(shs.si, shs.p); p = shs.p; ao = 1.0; t_hit = shs.t; is_ser = 1.0; } else 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) { let vtex = voxel_at(h.vox); albedo = vtex.rgb; shine_a = vtex.a; // `h.normal` ya es la normal (suave si bisel>0, axial si no). La AO se calcula // con el eje dominante (indexa voxels enteros); para el hit duro es idéntica. normal = h.normal; p = ro + rd * h.t; ao = compute_ao(h.vox, axis_of(h.normal), p, dim); t_hit = h.t; // Rugosidad sub-voxel: inclina la normal de SOMBREADO (ya biselada si aplica) // por bump procedural. Sólo lo mate (roca/tierra) se rugosiza; el vidrio/metal // lustroso (alpha bajo) queda liso. Si el voxel golpeado trae detalle autorado, // su rugosidad manda; si no, la amplitud global (`u.cdim.w`). let pvd = pv_detail(h.vox); let rug_amp = select(u.cdim.w, pvd.y, pvd.x > 0.5); if (rug_amp > 0.0) { let matte = clamp((shine_a - 0.62) / 0.38, 0.0, 1.0); if (matte > 0.0) { normal = perturb_normal(normal, p, max(u.atlas.w, 1e-3), rug_amp * matte); } } } 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). // De noche hay cielo propio siempre: si no, sin bruma no habría estrellas. if (fog_density > 0.0 || u.noche.x > 0.0) { // Cielo + god rays: los haces se ven sobre todo contra el fondo abierto. let gr = god_rays(ro, rd, 1e30, dim, B); let glow = luz_haces() * gr; var sky: FOut; sky.color = vec4(sky_color(rd) + estrellas(rd) + glow, 1.0); sky.depth = 1.0; return sky; } discard; } let ldir = u.sun_dir.xyz; let diff = max(dot(normal, ldir), 0.0); // Bias del origen de sombra: con bisel, la superficie suave está ~1 voxel DENTRO // de la cáscara dura (que es la que traza la sombra) → hay que salir más para no // nacer enterrado y auto-sombrearse (motas negras). Sin bisel, el 0.5 clásico. // La superficie SDF del ser es exacta → bias chico (no está enterrada en cáscara). let so_bias = select(select(0.5, 1.75, bisel > 0.0), 0.25, is_ser > 0.5); let so = p + normal * so_bias + ldir * 0.01; let sh_v = trace(so, ldir, dim, B, true); let sh_e = trace_entities(so, ldir, 1e30); var sun_blocked = sh_v.hit || sh_e.hit; // Los seres proyectan sombra sobre el mundo/entidades, pero NO sobre sí mismos // (regla cartoon: el domo del ojo sombreaba la mejilla = mancha en la cara; la // cara toon se lee plana). El mundo voxel SÍ los sombrea (sh_v arriba). if (!sun_blocked && seru.count.x > 0.5 && is_ser < 0.5) { sun_blocked = ser_shadow(so, ldir, 1e30); } let shadow = select(1.0, 0.25, sun_blocked); // 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. // AO escalada por `n_lights.w` (1 = clásica `0.35 + 0.65*ao` byte-idéntica; 0 = sin AO). // La AO axial pinta anillos sobre superficies redondeadas (bisel) → bajarla para seres // orgánicos, subirla para bloques. let ao_term = 1.0 - u.n_lights.w * 0.65 * (1.0 - ao); let sun_h = clamp(u.sun_dir.y, 0.0, 1.0); var 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). var amb_col = mix(vec3(0.45), u.sky_zenith.xyz, 0.45) * 0.70; // De noche la luz directa es la de la luna (fría y tenue) y el ambiente toma // el azul del cenit. Tenue, no negra: es una noche de cuento, y las caras // se tienen que seguir leyendo. if (u.noche.x > 0.0) { sun_col = mix(sun_col, vec3(0.46, 0.54, 0.78), u.noche.x); amb_col = mix(amb_col, mix(vec3(0.20, 0.24, 0.38), u.sky_zenith.xyz, 0.35), u.noche.x); } var light = amb_col + sun_col * (0.78 * diff * shadow); if (is_ser > 0.5) { // Rampa TOON del ser: el diffuse (ya con sombra) se cuantiza en dos bandas // con borde AA (smoothstep angosto) → luz "de personaje", no de material. // + RIM light frío del cielo en el contorno (despega al ser del fondo). // HALF-LAMBERT (wrap): el Lambert crudo cruzaba su terminador en diagonal por // la CARA (leía como veta gris); envolver la luz (d·0.5+0.5)² corre el // terminador al borde de la silueta → la cara queda iluminada pareja, el // volumen lo dan las bandas suaves + el rim. La sombra del mundo sí baja la banda. let wrap = diff * 0.5 + 0.5; let dsh = clamp(wrap * wrap * shadow, 0.0, 1.0); let band = 0.46 + 0.24 * smoothstep(0.30, 0.40, dsh) + 0.28 * smoothstep(0.62, 0.72, dsh); light = amb_col * 0.85 + sun_col * (0.95 * band); let rimv = pow(1.0 - max(dot(normal, -rd), 0.0), 3.5); light = light + mix(u.sky_zenith.xyz, vec3(1.0), 0.35) * (rimv * 0.45); } // 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, true); 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, true); 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); } // Oclusión por seres SDF: un rayo al centro de la luz (MVP: sombra dura // del ser aun bajo luces de área — los 16 taps con sphere-trace serían caros). if (vis > 0.0 && seru.count.x > 0.5) { if (ser_shadow_salvo(lso, ldir2, d - bias, lp.xyz)) { vis = 0.0; } } } light = light + lc.rgb * (att * ndl * vis); } var color = albedo * light * ao_term; // Luz propia: una pieza de ser con algún canal de color > 1,5 brilla sola — // no la apaga la sombra ni la noche (la estrella que se lleva en la mano). // El umbral está lejos de 1 para que un tinte que se pasó un poco no se // encienda por accidente. Se ve con su tono, normalizado al canal más alto. let maxc = max(albedo.r, max(albedo.g, albedo.b)); if (is_ser > 0.5 && maxc > 1.5) { color = max(color, albedo / maxc); } // Brillo especular + glaseado por voxel (algo que la luz plana de Minecraft no // hace): reflejo dependiente de la vista sobre los minerales lustrosos. `shine` // sale del alpha del voxel (matte 1.0 → 0). Nuestro ray-march ya tiene la // normal de cara y el rayo de cámara, así que el destello es real por-píxel. let shine = clamp((1.0 - shine_a) * 2.04, 0.0, 1.0); if (shine > 0.0) { let vdir = -rd; // hacia la cámara let hvec = normalize(u.sun_dir.xyz + vdir); // half-vector Blinn-Phong let ndh = max(dot(normal, hvec), 0.0); // Lóbulo moderado: las normales del voxel son axiales, un exponente alto // no engancharía ninguna cara visible. La dureza sube un poco el foco. let power = mix(2.0, 9.0, shine); let spec = pow(ndh, power) * shine * shadow * 3.0; // Glaseado (fresnel): lustre vidrioso a ángulo rasante, tinte frío del cielo. let fres = pow(1.0 - max(dot(normal, vdir), 0.0), 4.0) * shine * 0.45; let gloss = sun_col * spec + mix(sun_col, u.sky_zenith.xyz, 0.6) * fres; color = color + gloss; } // 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); // God rays: haz volumétrico en el aire DELANTE de la superficie golpeada. let gr = god_rays(ro, rd, t_hit, dim, B); color = color + luz_haces() * gr; } // Diagnóstico: pinta la normal de sombreado como color (n·0.5+0.5). Lo usa la // certificación para medir qué fracción de la superficie dejó de ser axial. if (u.scroll.w > 0.5) { var dbg: FOut; dbg.color = vec4(normal * 0.5 + 0.5, 1.0); dbg.depth = frag_depth(p, dim); return dbg; } var out: FOut; out.color = vec4(color, 1.0); out.depth = frag_depth(p, dim); return out; } "#; /// WGSL completo del pipeline: el fragmento de seres SDF (uniform + campo + trace) /// antepuesto al ray-march voxel. El orden de declaración no importa en WGSL. fn shader_source() -> String { format!("{}\n{}", crate::ser::wgsl_ser(), WGSL) } #[cfg(test)] mod tests { use super::*; /// El WGSL del ray-march parsea y valida sin GPU (naga). Ataja errores que de /// otro modo sólo saldrían al crear el pipeline — incluye las funciones de /// rugosidad sub-voxel (`hash13`/`vnoise`/`perturb_normal`) y el fragmento de /// seres SDF (`ser_map`/`trace_sers`/`ser_shadow`). #[test] fn wgsl_valida() { let module = naga::front::wgsl::parse_str(&shader_source()) .unwrap_or_else(|e| panic!("WGSL del voxel-renderer no parsea: {e:?}")); naga::valid::Validator::new( naga::valid::ValidationFlags::all(), naga::valid::Capabilities::all(), ) .validate(&module) .unwrap_or_else(|e| panic!("WGSL del voxel-renderer no valida: {e:?}")); } }