ccab39f140
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>
1321 lines
53 KiB
Rust
1321 lines
53 KiB
Rust
//! `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;
|
||
}
|
||
"#;
|