Files
Sergio ccab39f140 refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:40:00 +00:00

1321 lines
53 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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<u32>,
/// Slots libres del pool (free list para allocar bricks nuevos).
free: Vec<u32>,
/// 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<crate::scene::DepthBuffer>,
/// Entidades vivas — se empacan y suben en cada `render`.
pub entities: Vec<Entity3d>,
/// Luces puntuales coloreadas (≤ [`MAX_LIGHTS`]) — antorchas/lámparas que
/// iluminan voxels y entidades cercanos. Se empacan y suben en cada `render`.
pub lights: Vec<PointLight>,
/// 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<f32>,
cam_eye: vec4<f32>,
grid_dim: vec4<f32>, // xyz = dim fino, w = brick size
sun_dir: vec4<f32>, // xyz = dirección hacia el sol (normalizada)
cdim: vec4<f32>, // xyz = dim grueso (celdas de brick)
atlas: vec4<f32>, // xyz = slots por eje en el atlas del pool
sky_zenith: vec4<f32>, // xyz = color cenit, w = densidad de niebla (0 = off)
sky_horizon: vec4<f32>,// xyz = color horizonte / hacia el que niebla desvanece
vp: mat4x4<f32>, // world→clip (forward) para escribir frag_depth
scroll: vec4<f32>, // xyz = origen de brick (streaming toroidal); 0 = sin scroll
n_lights: vec4<f32>, // x = cantidad de luces puntuales, y = sombras on/off
lights: array<vec4<f32>, 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<vec2<f32>, 8>(
vec2<f32>( 0.35, 0.0), vec2<f32>(-0.35, 0.0),
vec2<f32>( 0.0, 0.35), vec2<f32>( 0.0, -0.35),
vec2<f32>( 0.7, 0.7), vec2<f32>(-0.7, 0.7),
vec2<f32>( 0.7, -0.7), vec2<f32>(-0.7, -0.7),
);
struct Entity {
pos: vec4<f32>,
half: vec4<f32>,
color: vec4<f32>,
};
struct EntU {
count: vec4<f32>,
ents: array<Entity, 64>,
};
@group(0) @binding(0) var pool: texture_3d<f32>;
@group(0) @binding(1) var indir: texture_3d<u32>;
@group(0) @binding(2) var<uniform> u: U;
@group(0) @binding(3) var<uniform> ent: EntU;
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) ndc: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VOut {
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
var out: VOut;
out.clip = vec4<f32>(p[vi], 0.0, 1.0);
out.ndc = p[vi];
return out;
}
fn ray_box(ro: vec3<f32>, inv_rd: vec3<f32>, bmin: vec3<f32>, bmax: vec3<f32>) -> vec2<f32> {
let t0 = (bmin - ro) * inv_rd;
let t1 = (bmax - ro) * inv_rd;
let tmin = min(t0, t1);
let tmax = max(t0, t1);
return vec2<f32>(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<i32>) -> u32 {
if (any(cc < vec3<i32>(0)) || any(vec3<f32>(cc) >= u.cdim.xyz)) { return 0u; }
let cd = vec3<i32>(u.cdim.xyz);
let bo = vec3<i32>(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<f32>) -> vec4<f32> {
let vi = vec3<i32>(voxel);
if (any(vi < vec3<i32>(0)) || any(vec3<f32>(vi) >= u.grid_dim.xyz)) { return vec4<f32>(0.0); }
let bu = i32(u.grid_dim.w);
let cc = vi / bu;
let s = slot_at(cc);
if (s == 0u) { return vec4<f32>(0.0); }
let slot = i32(s - 1u);
let ax = i32(u.atlas.x);
let ay = i32(u.atlas.y);
let acell = vec3<i32>(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<f32>,
normal: vec3<f32>,
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<f32>, rd_in: vec3<f32>, dim: vec3<f32>, B: f32) -> Hit {
var h: Hit;
h.hit = false;
let safe_rd = vec3<f32>(
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<f32>(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<f32>(0.0) - ro) * inv_rd;
let te1 = (dim - ro) * inv_rd;
let temin = min(te0, te1);
var box_n = vec3<f32>(0.0, 0.0, -step.z);
if (tb.x == temin.x) { box_n = vec3<f32>(-step.x, 0.0, 0.0); }
else if (tb.x == temin.y) { box_n = vec3<f32>(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<f32>(0.0), cdim - 1.0);
let t_delta_c = abs(B * inv_rd);
var t_max_c = ((cc + max(step, vec3<f32>(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<i32>(cc)) != 0u) {
var voxel = clamp(floor(ro + safe_rd * (t_cell + 1e-4)), vec3<f32>(0.0), dim - 1.0);
var t_max_f = ((voxel + max(step, vec3<f32>(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<f32>(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<f32>(-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<f32>(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<f32>(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<f32>(-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<f32>(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<f32>(0.0, 0.0, -step.z);
}
if (any(cc < vec3<f32>(0.0)) || any(cc >= cdim)) { return h; }
}
return h;
}
fn occ_at(p: vec3<i32>, dim: vec3<f32>) -> f32 {
return select(0.0, 1.0, voxel_at(vec3<f32>(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<f32>, normal: vec3<f32>, p: vec3<f32>, dim: vec3<f32>) -> f32 {
var t1 = vec3<i32>(0, 1, 0);
var t2 = vec3<i32>(0, 0, 1);
if (abs(normal.y) > 0.5) { t1 = vec3<i32>(1, 0, 0); t2 = vec3<i32>(0, 0, 1); }
else if (abs(normal.z) > 0.5) { t1 = vec3<i32>(1, 0, 0); t2 = vec3<i32>(0, 1, 0); }
let base = vec3<i32>(voxel) + vec3<i32>(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<f32>(t1), fp);
let vv = dot(vec3<f32>(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<f32>,
color: vec3<f32>,
};
fn trace_entities(ro: vec3<f32>, rd: vec3<f32>, max_t: f32) -> EHit {
var best: EHit;
best.hit = false;
best.t = max_t;
let safe_rd = vec3<f32>(
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<f32>(1e-4));
let q = (p - c) / d;
let aq = abs(q);
if (aq.x >= aq.y && aq.x >= aq.z) { best.normal = vec3<f32>(sign(q.x), 0.0, 0.0); }
else if (aq.y >= aq.z) { best.normal = vec3<f32>(0.0, sign(q.y), 0.0); }
else { best.normal = vec3<f32>(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<f32>) -> vec3<f32> {
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<f32>(1.0, 0.96, 0.84) * pow(s, 260.0) * 1.6; // disco
c = c + vec3<f32>(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<f32>, dim: vec3<f32>) -> f32 {
let clip = u.vp * vec4<f32>(p - dim * 0.5, 1.0);
return clip.z / clip.w;
}
struct FOut {
@location(0) color: vec4<f32>,
@builtin(frag_depth) depth: f32,
};
@fragment
fn fs(in: VOut) -> FOut {
let p_near = u.inv_vp * vec4<f32>(in.ndc, 0.0, 1.0);
let p_far = u.inv_vp * vec4<f32>(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<f32>;
var normal: vec3<f32>;
var p: vec3<f32>;
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<f32>(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<f32>(1.0, 0.52, 0.24), vec3<f32>(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<f32>(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<f32>(0.0, 1.0, 0.0);
if (abs(ldir2.y) > 0.9) { up = vec3<f32>(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<f32>(color, 1.0);
out.depth = frag_depth(p, dim);
return out;
}
"#;