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>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+84
View File
@@ -0,0 +1,84 @@
//! Cámara 3D — produce la matriz `view_proj` que el shader aplica a cada
//! vértice. Convención de mano derecha y profundidad `0..1` (la de wgpu/
//! Vulkan/Metal/DX12, **no** la `-1..1` de OpenGL).
use glam::{Mat4, Vec3};
/// Cámara en perspectiva. `eye` mira a `target` con `up` como vertical.
#[derive(Debug, Clone, Copy)]
pub struct Camera3d {
/// Posición del ojo en mundo.
pub eye: Vec3,
/// Punto al que mira.
pub target: Vec3,
/// Vector "arriba" (normalmente `Vec3::Y`).
pub up: Vec3,
/// Campo de visión vertical, en radianes.
pub fovy_rad: f32,
/// Plano cercano (`> 0`).
pub znear: f32,
/// Plano lejano.
pub zfar: f32,
}
impl Default for Camera3d {
fn default() -> Self {
Self {
eye: Vec3::new(2.5, 2.0, 3.5),
target: Vec3::ZERO,
up: Vec3::Y,
fovy_rad: 60_f32.to_radians(),
znear: 0.1,
// Generoso: cubre mundos voxel de cientos de unidades. Importa desde
// que el pase de voxels escribe profundidad (`Scene3d`): un hit más
// allá de `zfar` se clamparía a 1.0 y fallaría el depth test. Float32
// de depth mantiene precisión de sobra en este rango para oclusión.
zfar: 5000.0,
}
}
}
impl Camera3d {
/// Cámara orbitando `target` a `dist`, con `yaw`/`pitch` en radianes.
/// `yaw` gira alrededor del eje Y; `pitch` sube/baja (clamp suave para no
/// cruzar los polos y degenerar el `up`).
pub fn orbit(target: Vec3, yaw: f32, pitch: f32, dist: f32) -> Self {
let lim = std::f32::consts::FRAC_PI_2 - 0.01;
let pitch = pitch.clamp(-lim, lim);
let (sy, cy) = yaw.sin_cos();
let (sp, cp) = pitch.sin_cos();
let offset = Vec3::new(cp * sy, sp, cp * cy) * dist;
Self {
eye: target + offset,
target,
..Self::default()
}
}
/// Cámara **libre / primera persona**: parada en `eye`, mirando según
/// `yaw` (giro alrededor de Y) y `pitch` (cabeceo, clamped para no cruzar el
/// cenit). Complementa a [`orbit`](Self::orbit): `orbit` mira un punto desde
/// afuera (vista de paisaje), `fly` te pone *adentro* del mundo (vuelo / FPS).
/// `yaw=0` mira hacia `+Z`.
pub fn fly(eye: Vec3, yaw: f32, pitch: f32) -> Self {
let lim = std::f32::consts::FRAC_PI_2 - 0.01;
let pitch = pitch.clamp(-lim, lim);
let (sy, cy) = yaw.sin_cos();
let (sp, cp) = pitch.sin_cos();
let dir = Vec3::new(cp * sy, sp, cp * cy);
Self {
eye,
target: eye + dir,
..Self::default()
}
}
/// Matriz `proj * view` lista para `mvp * vec4(pos, 1.0)` en el shader.
/// `aspect` = ancho/alto del viewport en pixels.
pub fn view_proj(&self, aspect: f32) -> Mat4 {
let view = Mat4::look_at_rh(self.eye, self.target, self.up);
// `perspective_rh` (no `_gl`): profundidad 0..1, la que espera wgpu.
let proj = Mat4::perspective_rh(self.fovy_rad, aspect.max(1e-4), self.znear, self.zfar);
proj * view
}
}
+139
View File
@@ -0,0 +1,139 @@
//! `CameraTrack` — interpolación de cámara por **keyframes** en el tiempo, el
//! ingrediente "cine" del motor: en vez de una `Camera3d` fija o atada a input,
//! una secuencia de poses `(t, eye, target, fov)` que se interpolan suave para
//! producir un **movimiento de cámara guionado** (travelling, grúa, dolly,
//! corte). Determinista por construcción → ideal para *filmar* frame a frame.
//!
//! Es genérico del motor 3D (no sabe de voxels ni de juegos): cualquier app que
//! quiera una cámara animada lo usa. La *dirección* de actores/eventos vive en
//! la capa de contenido (la app), no acá.
use glam::Vec3;
use crate::camera::Camera3d;
/// Una pose de cámara anclada a un instante `t` (segundos). Entre keys
/// consecutivas, [`CameraTrack::sample`] interpola `eye`/`target`/`fovy_rad`.
#[derive(Debug, Clone, Copy)]
pub struct CamKey {
/// Instante de la pose, en segundos desde el inicio.
pub t: f32,
/// Posición del ojo.
pub eye: Vec3,
/// Punto al que mira.
pub target: Vec3,
/// Campo de visión vertical (radianes) en esta pose.
pub fovy_rad: f32,
}
impl CamKey {
/// Atajo: una pose mirando de `eye` a `target` con FOV en **grados**.
pub fn look(t: f32, eye: Vec3, target: Vec3, fov_deg: f32) -> Self {
Self { t, eye, target, fovy_rad: fov_deg.to_radians() }
}
}
/// Secuencia de [`CamKey`] ordenada en el tiempo. `sample(t)` devuelve la
/// `Camera3d` interpolada; fuera de rango hace *clamp* a la primera/última pose.
#[derive(Debug, Clone, Default)]
pub struct CameraTrack {
keys: Vec<CamKey>,
}
impl CameraTrack {
/// Crea el track a partir de las keys (se ordenan por `t`). Un track vacío
/// o de una sola key es válido (devuelve siempre esa pose).
pub fn new(mut keys: Vec<CamKey>) -> Self {
keys.sort_by(|a, b| a.t.total_cmp(&b.t));
Self { keys }
}
/// Duración total (el `t` de la última key), o `0.0` si está vacío.
pub fn duration(&self) -> f32 {
self.keys.last().map(|k| k.t).unwrap_or(0.0)
}
/// La cámara interpolada en el instante `t` (segundos). Entre dos keys usa
/// **smoothstep** (acelera/desacelera suave, sin tirones) sobre la fracción
/// del segmento; antes de la primera / después de la última, clampa.
pub fn sample(&self, t: f32) -> Camera3d {
match self.keys.as_slice() {
[] => Camera3d::default(),
[only] => cam_of(only),
keys => {
// Clamp a los extremos.
if t <= keys[0].t {
return cam_of(&keys[0]);
}
if t >= keys[keys.len() - 1].t {
return cam_of(&keys[keys.len() - 1]);
}
// Segmento que contiene a `t`: última key con `t_key <= t`
// (existe y no es la última, por el clamp de arriba).
let i = keys.iter().rposition(|k| k.t <= t).unwrap_or(0).min(keys.len() - 2);
let (a, b) = (&keys[i], &keys[i + 1]);
let span = (b.t - a.t).max(1e-6);
let f = smoothstep((t - a.t) / span);
Camera3d {
eye: a.eye.lerp(b.eye, f),
target: a.target.lerp(b.target, f),
fovy_rad: a.fovy_rad + (b.fovy_rad - a.fovy_rad) * f,
..Camera3d::default()
}
}
}
}
}
/// Construye una `Camera3d` (con `up`/planos por defecto) desde una key.
fn cam_of(k: &CamKey) -> Camera3d {
Camera3d {
eye: k.eye,
target: k.target,
fovy_rad: k.fovy_rad,
..Camera3d::default()
}
}
/// Suavizado Hermite clásico `3t²−2t³` en `[0,1]` (deriva nula en los extremos).
fn smoothstep(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
#[cfg(test)]
mod tests {
use super::*;
fn track() -> CameraTrack {
CameraTrack::new(vec![
CamKey::look(0.0, Vec3::new(0.0, 0.0, 0.0), Vec3::Z, 60.0),
CamKey::look(2.0, Vec3::new(10.0, 0.0, 0.0), Vec3::Z, 40.0),
])
}
#[test]
fn clamp_en_los_extremos() {
let tr = track();
assert_eq!(tr.sample(-1.0).eye, Vec3::ZERO);
assert_eq!(tr.sample(5.0).eye.x, 10.0);
assert_eq!(tr.duration(), 2.0);
}
#[test]
fn interpola_la_mitad_con_smoothstep() {
let tr = track();
// En la mitad temporal, smoothstep(0.5)=0.5 → punto medio exacto.
let c = tr.sample(1.0);
assert!((c.eye.x - 5.0).abs() < 1e-4, "x={}", c.eye.x);
assert!((c.fovy_rad - 50_f32.to_radians()).abs() < 1e-4);
}
#[test]
fn smoothstep_acelera_suave() {
let tr = track();
// A 1/4 del tiempo, smoothstep(0.25)=0.15625 < 0.25 (arranca lento).
let c = tr.sample(0.5);
assert!(c.eye.x < 2.5, "debería ir más lento al principio: x={}", c.eye.x);
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Dimensiones / mundos paralelos (M5) — `MOTOR-VOXEL.md` §3.8.
//!
//! Una **dimensión = un mundo voxel independiente** con su propio grid, su sol,
//! su cielo (color de fondo) y sus entidades. "Viajar" = cambiar qué dimensión
//! renderiza la cámara (un portal = un `switch`). No agrega complejidad de motor
//! (cada dimensión reusa el `VoxelRenderer` sparse tal cual): es contenido.
//!
//! El [`Multiverse`] mantiene N dimensiones y la activa; cada una materializa su
//! `VoxelRenderer` (su brick pool) perezosamente la primera vez que se la pinta,
//! y queda "tibia" en memoria para que el switch sea instantáneo.
use crate::camera::Camera3d;
use crate::voxel::VoxelGrid;
use crate::voxel_renderer::{Atmosphere, Entity3d, VoxelRenderer};
/// Formato de la textura intermedia de Llimphi (target de `gpu_paint_with`).
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Un mundo voxel independiente.
pub struct Dimension {
pub name: String,
pub grid: VoxelGrid,
/// Color de fondo (cielo) sugerido para la pasada vello base.
pub sky: [u8; 3],
/// Dirección hacia el sol de esta dimensión.
pub sun_dir: [f32; 3],
/// Atmósfera (cielo + niebla) de esta dimensión. Default = niebla off, así
/// una dimensión sin configurar se comporta como en M5 (miss → discard).
pub atmosphere: Atmosphere,
/// Entidades (agentes) de esta dimensión; se copian al renderer por frame.
pub entities: Vec<Entity3d>,
renderer: Option<VoxelRenderer>,
}
impl Dimension {
/// Dimensión nueva con cielo/sol por defecto y sin entidades.
pub fn new(name: impl Into<String>, grid: VoxelGrid) -> Self {
Self {
name: name.into(),
grid,
sky: [18, 22, 32],
sun_dir: [0.5, 1.0, 0.35],
atmosphere: Atmosphere::default(),
entities: Vec::new(),
renderer: None,
}
}
pub fn with_sky(mut self, sky: [u8; 3]) -> Self {
self.sky = sky;
self
}
pub fn with_sun(mut self, sun_dir: [f32; 3]) -> Self {
self.sun_dir = sun_dir;
self
}
/// Activa cielo + niebla propios para esta dimensión (el `render` los aplica
/// al renderer). Con `fog_density > 0`, el motor pinta su propio cielo en los
/// misses (ya no se ve el fondo vello).
pub fn with_atmosphere(mut self, atmosphere: Atmosphere) -> Self {
self.atmosphere = atmosphere;
self
}
pub fn with_entities(mut self, entities: Vec<Entity3d>) -> Self {
self.entities = entities;
self
}
}
/// Conjunto de dimensiones con una activa. La cámara siempre ve la activa.
pub struct Multiverse {
dims: Vec<Dimension>,
active: usize,
format: wgpu::TextureFormat,
}
impl Multiverse {
pub fn new(dims: Vec<Dimension>) -> Self {
Self {
dims,
active: 0,
format: FMT,
}
}
/// Cambia el formato de color del target (default `Rgba8Unorm`, la
/// intermedia de Llimphi). Sólo afecta a renderers aún no materializados.
pub fn with_format(mut self, format: wgpu::TextureFormat) -> Self {
self.format = format;
self
}
pub fn count(&self) -> usize {
self.dims.len()
}
pub fn active(&self) -> usize {
self.active
}
pub fn active_name(&self) -> &str {
&self.dims[self.active].name
}
pub fn names(&self) -> Vec<String> {
self.dims.iter().map(|d| d.name.clone()).collect()
}
pub fn skies(&self) -> Vec<[u8; 3]> {
self.dims.iter().map(|d| d.sky).collect()
}
/// Viaja a la dimensión `i` (no-op si fuera de rango).
pub fn switch(&mut self, i: usize) {
if i < self.dims.len() {
self.active = i;
}
}
pub fn next(&mut self) {
self.active = (self.active + 1) % self.dims.len();
}
pub fn prev(&mut self) {
self.active = (self.active + self.dims.len() - 1) % self.dims.len();
}
pub fn active_dim(&self) -> &Dimension {
&self.dims[self.active]
}
pub fn active_dim_mut(&mut self) -> &mut Dimension {
&mut self.dims[self.active]
}
/// Ray-marchea la dimensión activa sobre `target`. Materializa su brick pool
/// la primera vez. Firma compatible con la closure de `gpu_paint_with`.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
viewport: (u32, u32),
camera: &Camera3d,
) {
let fmt = self.format;
let d = &mut self.dims[self.active];
let r = d
.renderer
.get_or_insert_with(|| VoxelRenderer::new(device, queue, fmt, &d.grid));
r.sun_dir = d.sun_dir;
r.atmosphere = d.atmosphere;
r.entities = d.entities.clone();
r.render(device, queue, encoder, target, viewport, camera);
}
/// Acceso al renderer ya materializado de la dimensión activa (para `sync`
/// incremental de mutaciones, stats, etc.). `None` si aún no se pintó.
pub fn active_renderer_mut(&mut self) -> Option<&mut VoxelRenderer> {
self.dims[self.active].renderer.as_mut()
}
}
+306
View File
@@ -0,0 +1,306 @@
//! `Hud` — un pase **screen-space** mínimo: dibuja rectángulos de color plano
//! (con alpha) directamente en NDC, *después* del pase 3D, sobre el mismo
//! target. Es la pieza que faltaba para un **HUD / mira (crosshair)** en primera
//! persona: el contenido vello del árbol Llimphi queda **debajo** del canvas GPU
//! full-screen, así que cualquier overlay que deba ir *encima* del ray-march
//! tiene que pintarse en GPU en la misma closure `gpu_paint_with`, y eso es
//! justo lo que hace [`Hud::render`].
//!
//! Deliberadamente tonto: sin texturas, sin bind groups, sin depth. Geometría
//! en CPU → un vertex buffer dinámico → un draw. Suficiente para miras, barras,
//! marcos y **texto** ([`HudQuad::text`], fuente bitmap 5×7 = un quad por píxel
//! encendido, sin salir del pipeline de quads).
/// Un rectángulo de HUD en **pixels** (origen arriba-izquierda, como la
/// pantalla), color RGBA lineal `0..1`.
#[derive(Debug, Clone, Copy)]
pub struct HudQuad {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub color: [f32; 4],
}
impl HudQuad {
/// Una **mira (crosshair)** centrada en un viewport `(w, h)`: dos barras
/// (horizontal + vertical) de brazo `arm` y grosor `th` pixels.
pub fn crosshair(viewport: (u32, u32), arm: f32, th: f32, color: [f32; 4]) -> [HudQuad; 2] {
let cx = viewport.0 as f32 * 0.5;
let cy = viewport.1 as f32 * 0.5;
[
HudQuad { x: cx - arm, y: cy - th * 0.5, w: arm * 2.0, h: th, color },
HudQuad { x: cx - th * 0.5, y: cy - arm, w: th, h: arm * 2.0, color },
]
}
/// Emite los quads de una cadena con la **fuente bitmap 5×7** embebida
/// ([`glyph`]): origen arriba-izquierda en `(x, y)` pixels, cada píxel de
/// glifo mide `px` pixels de lado y los caracteres avanzan `6·px` (5 de ancho
/// + 1 de espacio). Sólo ASCII; las minúsculas se dibujan en mayúscula y los
/// caracteres desconocidos quedan en blanco. Se mantiene dentro del pipeline
/// tonto del HUD (un quad por píxel encendido, sin texturas).
pub fn text(s: &str, x: f32, y: f32, px: f32, color: [f32; 4]) -> Vec<HudQuad> {
let mut out = Vec::new();
let mut cx = x;
for ch in s.chars() {
if ch != ' ' {
let g = glyph(ch);
for (r, row) in g.iter().enumerate() {
for c in 0..5u32 {
if row & (1 << (4 - c)) != 0 {
out.push(HudQuad {
x: cx + c as f32 * px,
y: y + r as f32 * px,
w: px,
h: px,
color,
});
}
}
}
}
cx += 6.0 * px;
}
out
}
/// Ancho en pixels que ocuparía `s` con [`text`](Self::text) a tamaño `px`
/// (útil para dimensionar un panel de fondo antes de dibujar el texto).
pub fn text_width(s: &str, px: f32) -> f32 {
s.chars().count() as f32 * 6.0 * px
}
}
/// Mapa de un carácter a su bitmap **5×7**: 7 filas, cada `u8` con los 5 bits
/// bajos = columnas de izquierda (bit 4) a derecha (bit 0). Cubre `0-9`, `A-Z`
/// y puntuación común; lo desconocido devuelve un glifo en blanco. Las filas se
/// escriben en binario para que la forma sea legible en el código.
fn glyph(c: char) -> [u8; 7] {
match c.to_ascii_uppercase() {
'0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
'1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
'3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
'4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
'5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
'6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
'7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
'8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
'9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
'C' => [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
'D' => [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
'F' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
'G' => [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'I' => [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'J' => [0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100],
'K' => [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
'M' => [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
'N' => [0b10001, 0b11001, 0b10101, 0b10101, 0b10011, 0b10001, 0b10001],
'O' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
'Q' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
'S' => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
'T' => [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
'U' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'V' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
'W' => [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
'X' => [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
'Z' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000],
'.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100],
',' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100, 0b01000],
'-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
'+' => [0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000],
'/' => [0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000],
'(' => [0b00110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b00110],
')' => [0b01100, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01100],
'%' => [0b11001, 0b11001, 0b00010, 0b00100, 0b01000, 0b10011, 0b10011],
_ => [0; 7],
}
}
/// Tamaño de un vértice del HUD: `pos: vec2<f32>` + `color: vec4<f32>`.
const VSIZE: usize = 2 * 4 + 4 * 4;
/// Renderer de overlay screen-space. Cachea pipeline + un vertex buffer
/// dinámico que crece según haga falta.
pub struct Hud {
pipeline: wgpu::RenderPipeline,
vbuf: wgpu::Buffer,
cap: u64,
}
impl Hud {
/// Crea el HUD para el `color_format` del target (el de la intermedia del
/// frame). No toca depth: dibuja siempre encima.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-hud-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-hud-pl"),
bind_group_layouts: &[],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-hud-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: VSIZE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 8,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
// Sin depth: el HUD va siempre encima del 3D.
depth_stencil: None,
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: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let cap = (64 * 6 * VSIZE) as u64; // ~64 quads sin recrear
let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-hud-vbuf"),
size: cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self { pipeline, vbuf, cap }
}
/// Dibuja `quads` sobre `target` (color `LoadOp::Load`, sin depth). Firma
/// compatible con la closure `gpu_paint_with`: llamar *después* del pase 3D.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
quads: &[HudQuad],
) {
if w == 0 || h == 0 || quads.is_empty() {
return;
}
// Geometría en CPU: 2 triángulos (6 vértices) por quad, en NDC. El eje Y
// de pantalla va hacia abajo; NDC hacia arriba → `1 - 2·y/h`.
let (fw, fh) = (w as f32, h as f32);
let mut bytes = Vec::with_capacity(quads.len() * 6 * VSIZE);
let mut vert = |x_px: f32, y_px: f32, c: [f32; 4]| {
let ndc_x = x_px / fw * 2.0 - 1.0;
let ndc_y = 1.0 - y_px / fh * 2.0;
bytes.extend_from_slice(&ndc_x.to_ne_bytes());
bytes.extend_from_slice(&ndc_y.to_ne_bytes());
for ch in c {
bytes.extend_from_slice(&ch.to_ne_bytes());
}
};
for q in quads {
let (x0, y0, x1, y1) = (q.x, q.y, q.x + q.w, q.y + q.h);
vert(x0, y0, q.color);
vert(x1, y0, q.color);
vert(x1, y1, q.color);
vert(x0, y0, q.color);
vert(x1, y1, q.color);
vert(x0, y1, q.color);
}
// Crecer el buffer si hiciera falta (raro: la mira son 2 quads).
if bytes.len() as u64 > self.cap {
self.cap = (bytes.len() as u64).next_power_of_two();
self.vbuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-hud-vbuf"),
size: self.cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
queue.write_buffer(&self.vbuf, 0, &bytes);
let count = (quads.len() * 6) as u32;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-3d-hud-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: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_vertex_buffer(0, self.vbuf.slice(..bytes.len() as u64));
pass.draw(0..count, 0..1);
}
}
const WGSL: &str = r#"
struct VIn {
@location(0) pos: vec2<f32>,
@location(1) color: vec4<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs(in: VIn) -> VOut {
var out: VOut;
out.clip = vec4<f32>(in.pos, 0.0, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
return in.color;
}
"#;
+54
View File
@@ -0,0 +1,54 @@
//! # llimphi-3d — pase 3D base de Llimphi (M0 del motor 3D)
//!
//! Lo mínimo para tener **3D real dentro de un `View` de Llimphi**: una
//! [`Camera3d`] (matrices view/proj con `glam`), un depth buffer propio y un
//! [`Renderer3d`] que dibuja geometría indexada con test de profundidad sobre
//! la textura intermedia del frame.
//!
//! ## Cómo encaja con el bucle Elm + vello + wgpu
//!
//! Llimphi ya rasteriza la UI con vello sobre una textura intermedia y expone
//! [`View::gpu_paint_with`] para inyectar una pasada GPU directa *después* de
//! vello (con `LoadOp::Load`, preservando la UI). [`Renderer3d::render`] tiene
//! **exactamente** la firma que esa closure necesita
//! (`device, queue, encoder, target_view, (w, h), &camera`), así que un nodo 3D
//! es:
//!
//! ```ignore
//! let r3d = Arc::new(Mutex::new(Renderer3d::new(&device, fmt)));
//! View::empty().gpu_paint_with(move |dev, q, enc, view, rect, vp| {
//! r3d.lock().unwrap().render(dev, q, enc, view, vp, &camera);
//! })
//! ```
//!
//! No es un segundo motor: corre sobre el **mismo wgpu** que ya usa Llimphi,
//! que a su vez traduce a Vulkan/Metal/DX12/GL/WebGPU. Ver
//! `01_yachay/dominium/MOTOR-VOXEL.md` §11 para la ruta completa (M0..M4,
//! ray-march de voxels sparse en los hitos siguientes).
//!
//! [`View::gpu_paint_with`]: https://docs/llimphi-compositor
pub use glam;
pub use wgpu;
mod camera;
mod cinema;
mod dimensions;
mod hud;
mod mesh;
mod renderer;
mod scene;
mod voxel;
mod voxel_renderer;
pub use camera::Camera3d;
pub use cinema::{CamKey, CameraTrack};
pub use dimensions::{Dimension, Multiverse};
pub use hud::{Hud, HudQuad};
pub use mesh::{cube, push_cube, Vertex3d, CUBE_INDICES};
pub use renderer::Renderer3d;
pub use scene::Scene3d;
pub use voxel::{DirtyBox, VoxelGrid};
pub use voxel_renderer::{
Atmosphere, Entity3d, PointLight, VoxelRenderer, VOXEL_BRICK, VOXEL_MAX_LIGHTS,
};
+85
View File
@@ -0,0 +1,85 @@
//! Geometría de mallas: el vértice 3D ([`Vertex3d`]), un cubo de prueba
//! ([`cube`]) y un compositor de cajas transformadas ([`push_cube`]) para armar
//! mallas multi-caja en CPU — p.ej. un **muñeco articulado** (cabeza/torso/
//! miembros como cajas rotadas en sus articulaciones).
//!
//! Sigue el idiom de `llimphi-raster::gpu` (subir a GPU vía `to_ne_bytes`, sin
//! `bytemuck`) para no agregar una dependencia nueva al workspace.
use glam::{Mat4, Vec3};
/// Vértice 3D: posición en mundo + color RGB lineal.
#[derive(Debug, Clone, Copy)]
pub struct Vertex3d {
pub pos: [f32; 3],
pub color: [f32; 3],
}
impl Vertex3d {
/// Tamaño en bytes de un vértice empaquetado (`6 × f32`).
pub const SIZE: usize = 6 * 4;
/// Vuelca este vértice al buffer en orden `pos.xyz, color.rgb` (native
/// endian, como hace `GpuBatch`).
pub fn write_to(&self, out: &mut Vec<u8>) {
for v in self.pos {
out.extend_from_slice(&v.to_ne_bytes());
}
for v in self.color {
out.extend_from_slice(&v.to_ne_bytes());
}
}
}
/// Las 8 esquinas del cubo unitario centrado en el origen (lado 1, `-0.5..0.5`).
const CUBE_CORNERS: [[f32; 3]; 8] = [
[-0.5, -0.5, -0.5],
[0.5, -0.5, -0.5],
[0.5, 0.5, -0.5],
[-0.5, 0.5, -0.5],
[-0.5, -0.5, 0.5],
[0.5, -0.5, 0.5],
[0.5, 0.5, 0.5],
[-0.5, 0.5, 0.5],
];
/// Los 36 índices (12 triángulos) del cubo, winding CCW visto desde afuera.
#[rustfmt::skip]
pub const CUBE_INDICES: [u16; 36] = [
0, 2, 1, 0, 3, 2, // -Z (atrás)
4, 5, 6, 4, 6, 7, // +Z (frente)
0, 4, 7, 0, 7, 3, // -X (izquierda)
1, 2, 6, 1, 6, 5, // +X (derecha)
0, 1, 5, 0, 5, 4, // -Y (abajo)
3, 7, 6, 3, 6, 2, // +Y (arriba)
];
/// Cubo unitario centrado en el origen (lado 1, de `-0.5` a `0.5`). 8 vértices
/// coloreados por su posición (`color = pos + 0.5`) → un degradé que deja ver
/// las tres caras visibles distintas. 36 índices (12 triángulos), winding CCW.
pub fn cube() -> (Vec<Vertex3d>, Vec<u16>) {
let verts = CUBE_CORNERS
.iter()
.map(|&[x, y, z]| Vertex3d {
pos: [x, y, z],
color: [x + 0.5, y + 0.5, z + 0.5],
})
.collect();
(verts, CUBE_INDICES.to_vec())
}
/// Apila un cubo transformado por `m` (mapea el cubo unitario `[-0.5,0.5]³` a su
/// caja en mundo) con color plano `color`, en `verts`/`indices`. Es el ladrillo
/// para componer mallas multi-caja en CPU: cada llamada agrega 8 vértices + 36
/// índices con la base reubicada. Para un miembro articulado, `m` suele ser
/// `T(articulación) · R(ángulo) · T(0,-largo/2,0) · S(tamaño)`.
pub fn push_cube(verts: &mut Vec<Vertex3d>, indices: &mut Vec<u16>, m: Mat4, color: [f32; 3]) {
let base = verts.len() as u16;
for c in CUBE_CORNERS {
let p = m.transform_point3(Vec3::from_array(c));
verts.push(Vertex3d { pos: p.to_array(), color });
}
for i in CUBE_INDICES {
indices.push(base + i);
}
}
+314
View File
@@ -0,0 +1,314 @@
//! `Renderer3d` — pipeline wgpu mínimo que dibuja geometría 3D indexada con
//! test de profundidad sobre la textura intermedia del frame de Llimphi.
//!
//! La firma de [`Renderer3d::render`] es la que pide la closure de
//! `View::gpu_paint_with` (`device, queue, encoder, target_view, (w, h)`), más
//! la cámara — así un nodo 3D entra en el árbol `View<Msg>` sin tocar el
//! runtime. Mantiene su **propio depth buffer** (recreado al cambiar de
//! tamaño); el color se compone con `LoadOp::Load` para preservar la UI vello
//! que ya está debajo.
use glam::Mat4;
use crate::camera::Camera3d;
use crate::mesh::{cube, Vertex3d};
use crate::scene::{ensure_depth, DepthBuffer, DEPTH_FORMAT};
/// Renderer de **mallas** indexadas (por defecto un cubo) visto desde una
/// [`Camera3d`]. Cachea pipeline, buffers de geometría, uniform y (para el
/// camino standalone) un depth propio. En [`Scene3d`](crate::Scene3d) comparte
/// el depth con el pase de voxels para ocluirse mutuamente.
///
/// `model` ubica la malla en el mundo (default identidad): `mvp = view_proj ·
/// model`, así una misma malla se instancia/posiciona sin reconstruir buffers.
pub struct Renderer3d {
pipeline: wgpu::RenderPipeline,
vbuf: wgpu::Buffer,
ibuf: wgpu::Buffer,
index_count: u32,
ubuf: wgpu::Buffer,
bind_group: wgpu::BindGroup,
model: Mat4,
depth: Option<DepthBuffer>,
}
impl Renderer3d {
/// Crea el renderer para un `color_format` dado (el de la textura
/// intermedia del frame — `Rgba8Unorm` en headless, el de la surface en
/// vivo). Arranca con el cubo de prueba cargado.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let (verts, indices) = cube();
Self::with_mesh(device, color_format, &verts, &indices)
}
/// Igual que [`Self::new`] pero con una malla arbitraria.
pub fn with_mesh(
device: &wgpu::Device,
color_format: wgpu::TextureFormat,
verts: &[Vertex3d],
indices: &[u16],
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-3d-bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: Vertex3d::SIZE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 12,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
// M0 sin cull: el depth test ya resuelve la oclusión y nos
// ahorra bugs de winding al sumar mallas. El cull entra en M1+.
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
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,
// Opaco: el cubo reemplaza el fondo vello donde lo cubre.
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
// Geometría → buffers (idiom `to_ne_bytes`, sin bytemuck).
let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
for v in verts {
v.write_to(&mut vbytes);
}
let vbuf = create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
let mut ibytes = Vec::with_capacity(indices.len() * 2);
for &i in indices {
ibytes.extend_from_slice(&i.to_ne_bytes());
}
let ibuf = create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-ubuf"),
size: 64, // una mat4x4<f32>
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-bg"),
layout: &bind_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: ubuf.as_entire_binding(),
}],
});
Self {
pipeline,
vbuf,
ibuf,
index_count: indices.len() as u32,
ubuf,
bind_group,
model: Mat4::IDENTITY,
depth: None,
}
}
/// Ubica la malla en el mundo (`mvp = view_proj · model`). Default identidad.
pub fn set_model(&mut self, model: Mat4) {
self.model = model;
}
/// Reemplaza la geometría (recrea los buffers de vértices/índices). Pensado
/// para mallas que cambian cada frame — p.ej. un **muñeco articulado** cuya
/// pose se rehornea en CPU (limbos rotados) y se vuelve a subir. Las mallas
/// son chicas (decenas-cientos de vértices), así que recrear los buffers por
/// frame es despreciable; el pipeline/uniform/bind-group se conservan.
pub fn set_geometry(&mut self, device: &wgpu::Device, verts: &[Vertex3d], indices: &[u16]) {
let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
for v in verts {
v.write_to(&mut vbytes);
}
self.vbuf =
create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
let mut ibytes = Vec::with_capacity(indices.len() * 2);
for &i in indices {
ibytes.extend_from_slice(&i.to_ne_bytes());
}
self.ibuf =
create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
self.index_count = indices.len() as u32;
}
/// Sube el uniform del frame (`mvp = view_proj · model`). Lo llama
/// [`Self::render`] y [`Scene3d`](crate::Scene3d). `aspect` = w/h.
pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
let mvp = camera.view_proj(aspect) * self.model;
// glam es column-major; el shader WGSL espera column-major → upload tal cual.
let mut ubytes = Vec::with_capacity(64);
for v in mvp.to_cols_array() {
ubytes.extend_from_slice(&v.to_ne_bytes());
}
queue.write_buffer(&self.ubuf, 0, &ubytes);
}
/// Dibuja la malla indexada en un pase **ya abierto** (color + depth). Lo usa
/// [`Scene3d`](crate::Scene3d) para compartir el pase con los voxels.
/// 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.set_vertex_buffer(0, self.vbuf.slice(..));
pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint16);
pass.draw_indexed(0..self.index_count, 0, 0..1);
}
/// Dibuja la malla sola sobre `target` (camino standalone, depth propio).
/// Firma compatible con `View::gpu_paint_with`; color preservado
/// (`LoadOp::Load`), depth propio limpiado cada frame.
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);
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-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);
}
}
/// Crea un buffer ya inicializado con `data` (sin `wgpu::util::DeviceExt`, para
/// no arrastrar la feature `util`): `mapped_at_creation` + copia + `unmap`.
fn create_buffer_init(
device: &wgpu::Device,
label: &str,
usage: wgpu::BufferUsages,
data: &[u8],
) -> wgpu::Buffer {
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: data.len() as u64,
usage,
mapped_at_creation: true,
});
buf.slice(..).get_mapped_range_mut().copy_from_slice(data);
buf.unmap();
buf
}
const WGSL: &str = r#"
struct Uniforms { mvp: mat4x4<f32> };
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VIn {
@location(0) pos: vec3<f32>,
@location(1) color: vec3<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec3<f32>,
};
@vertex
fn vs(in: VIn) -> VOut {
var out: VOut;
out.clip = u.mvp * vec4<f32>(in.pos, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
"#;
+186
View File
@@ -0,0 +1,186 @@
//! `Scene3d` — orquestador de una escena 3D **general**: compone, en un único
//! pase con **depth buffer compartido**, el render volumétrico de voxels
//! ([`VoxelRenderer`](crate::VoxelRenderer)) y mallas de triángulos
//! ([`Renderer3d`](crate::Renderer3d)). Es el keystone que vuelve a `llimphi-3d`
//! un motor 3D general y no "sólo voxels": voxels y mallas se **ocluyen
//! correctamente entre sí** porque ambos escriben/testean el mismo depth.
//!
//! La firma de [`Scene3d::render`] es compatible con la closure de
//! `View::gpu_paint_with` (más los renderers a componer): el `Scene3d` posee el
//! depth y abre el pase; cada renderer aporta su `upload` (uniforms) + `draw`
//! (en el pase ya abierto).
use crate::camera::Camera3d;
use crate::renderer::Renderer3d;
use crate::voxel_renderer::VoxelRenderer;
/// Formato del depth buffer de toda la escena 3D (debe coincidir entre el
/// pipeline de voxels, el de mallas y la textura de depth).
pub(crate) const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Depth attachment cacheado, recreado cuando cambia el tamaño del viewport.
pub(crate) struct DepthBuffer {
pub view: wgpu::TextureView,
w: u32,
h: u32,
}
/// Asegura que `slot` tenga un depth buffer de `w×h` (lo recrea si cambió).
pub(crate) fn ensure_depth(
slot: &mut Option<DepthBuffer>,
device: &wgpu::Device,
w: u32,
h: u32,
) {
if matches!(slot, Some(d) if d.w == w && d.h == h) {
return;
}
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-3d-depth"),
size: wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
*slot = Some(DepthBuffer { view, w, h });
}
/// Escena 3D que comparte un depth buffer entre el pase de voxels y el de
/// mallas. Sólo posee el depth; los renderers los aporta el llamador por
/// referencia en cada frame (así la app conserva la propiedad y los muta).
#[derive(Default)]
pub struct Scene3d {
depth: Option<DepthBuffer>,
}
impl Scene3d {
pub fn new() -> Self {
Self::default()
}
/// Compone la escena sobre `target` (textura intermedia del frame). Primero
/// ray-marchea los voxels (escriben color + profundidad), luego dibuja las
/// mallas (testean contra esa profundidad) — todo en un pase con el depth
/// compartido, limpiado a lejano (`1.0`) al abrirlo. El color se preserva
/// (`LoadOp::Load`) para no pisar la UI vello de abajo.
///
/// Firma compatible con `View::gpu_paint_with` más los renderers a componer.
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
camera: &Camera3d,
voxel: Option<&VoxelRenderer>,
meshes: &[&Renderer3d],
) {
// El caso por defecto: la escena ocupa todo el target.
self.render_in(
device,
queue,
encoder,
target,
(w, h),
(0.0, 0.0, w as f32, h as f32),
camera,
voxel,
meshes,
);
}
/// Como [`render`](Self::render) pero **confina** la escena a la sub-región
/// `rect = (x, y, w, h)` (en px del target, esquina sup-izq), vía
/// `set_viewport` + `set_scissor_rect`. Es lo que permite montar el 3D en un
/// **panel** de una UI (un canvas que no ocupa toda la ventana) sin pisar el
/// chrome alrededor: la pasada de ray-march/mallas pinta sólo dentro del rect,
/// con el aspect del rect (no el de la ventana). `target`/`viewport` siguen
/// siendo el frame completo (load-preserve del chrome ya rasterizado).
#[allow(clippy::too_many_arguments)]
pub fn render_in(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
rect: (f32, f32, f32, f32),
camera: &Camera3d,
voxel: Option<&VoxelRenderer>,
meshes: &[&Renderer3d],
) {
if w == 0 || h == 0 {
return;
}
let (rx, ry, rw, rh) = rect;
if rw < 1.0 || rh < 1.0 {
return;
}
// El aspect es el del rect (el viewport mapea NDC a esa sub-región).
let aspect = rw / rh;
// Subir uniforms antes de abrir el pase (queue.write_buffer se ordena
// antes del submit).
if let Some(v) = voxel {
v.upload(queue, aspect, camera);
}
for m in meshes {
m.upload(queue, aspect, camera);
}
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-scene-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,
});
// Viewport (mapeo NDC→rect) + scissor (recorte físico al rect, clampeado a
// los límites del attachment).
pass.set_viewport(rx, ry, rw, rh, 0.0, 1.0);
let sx = rx.max(0.0);
let sy = ry.max(0.0);
let sw = (rw.min(w as f32 - sx)).max(0.0) as u32;
let sh = (rh.min(h as f32 - sy)).max(0.0) as u32;
if sw == 0 || sh == 0 {
return;
}
pass.set_scissor_rect(sx as u32, sy as u32, sw, sh);
if let Some(v) = voxel {
v.draw(&mut pass);
}
for m in meshes {
m.draw(&mut pass);
}
}
}
+296
View File
@@ -0,0 +1,296 @@
//! `VoxelGrid` — grid de voxels denso y acotado (CPU side). Cada voxel es
//! RGBA8: `rgb` = color, `a` = ocupación (`0` vacío, `>0` sólido). Se sube a
//! una textura 3D de GPU que el shader ray-march recorre por DDA.
//!
//! M1 es **denso** a propósito (lo más simple que funciona). El salto a sparse
//! (SVO/brickmap, saltar el aire) es M2 — ver `MOTOR-VOXEL.md` §11.2.
//!
//! M3 agrega **dirty tracking**: cada `set`/`clear` expande una caja AABB de la
//! región cambiada. `VoxelRenderer::sync` sube sólo esa sub-caja (fina + bricks
//! gruesos afectados) — la actualización incremental que reemplaza al re-mesh.
/// Caja AABB de voxels cambiados desde el último `take_dirty`: `[xmin, ymin,
/// zmin, xmax, ymax, zmax]` inclusiva.
pub type DirtyBox = [u32; 6];
/// Grid denso de voxels RGBA8. Índice lineal `x + y*dx + z*dx*dy` (x contiguo),
/// que es justo el layout que espera `queue.write_texture` (filas en x, luego y,
/// luego capas en z).
#[derive(Clone)]
pub struct VoxelGrid {
dim: [u32; 3],
data: Vec<[u8; 4]>,
/// AABB de voxels mutados desde el último `take_dirty`. `None` = sin cambios.
dirty: Option<DirtyBox>,
}
impl VoxelGrid {
/// Grid vacío de `dim = [dx, dy, dz]` voxels.
pub fn new(dim: [u32; 3]) -> Self {
let n = (dim[0] * dim[1] * dim[2]) as usize;
Self {
dim,
data: vec![[0, 0, 0, 0]; n],
dirty: None,
}
}
/// Dimensiones `[dx, dy, dz]`.
pub fn dim(&self) -> [u32; 3] {
self.dim
}
#[inline]
fn idx(&self, x: u32, y: u32, z: u32) -> usize {
(x + y * self.dim[0] + z * self.dim[0] * self.dim[1]) as usize
}
#[inline]
fn mark_dirty(&mut self, x: u32, y: u32, z: u32) {
match &mut self.dirty {
None => self.dirty = Some([x, y, z, x, y, z]),
Some(d) => {
d[0] = d[0].min(x);
d[1] = d[1].min(y);
d[2] = d[2].min(z);
d[3] = d[3].max(x);
d[4] = d[4].max(y);
d[5] = d[5].max(z);
}
}
}
/// Toma y limpia la caja de cambios pendientes. `VoxelRenderer::sync` la usa
/// para subir sólo lo mutado. `None` si no hubo cambios desde la última toma.
pub fn take_dirty(&mut self) -> Option<DirtyBox> {
self.dirty.take()
}
/// Descarta los cambios pendientes sin subirlos (tras un upload completo, el
/// estado inicial ya está en GPU).
pub fn reset_dirty(&mut self) {
self.dirty = None;
}
/// Marca un voxel sólido con color `rgb` (alpha = 255). Fuera de rango: no-op.
pub fn set(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3]) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [rgb[0], rgb[1], rgb[2], 255];
self.mark_dirty(x, y, z);
}
}
/// Vacía **todos** los voxels y marca el grid entero como dirty (la próxima
/// `VoxelRenderer::sync` re-sube todo). Para regenerar el contenido de una
/// ventana de *streaming* in-place sin reconstruir el renderer.
pub fn clear_all(&mut self) {
for px in &mut self.data {
*px = [0, 0, 0, 0];
}
self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]);
}
/// Vacía un voxel.
pub fn clear(&mut self, x: u32, y: u32, z: u32) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [0, 0, 0, 0];
self.mark_dirty(x, y, z);
}
}
#[inline]
fn solid(&self, x: u32, y: u32, z: u32) -> bool {
self.data[self.idx(x, y, z)][3] > 0
}
/// `true` si el voxel `(x,y,z)` es sólido. Fuera de rango → `false` (el
/// "afuera" del mundo es aire). Lo usa el raycast de `llimphi-voxel` para
/// picking/edición (mirar → bloque).
#[inline]
pub fn is_solid(&self, x: i32, y: i32, z: i32) -> bool {
if x < 0 || y < 0 || z < 0 {
return false;
}
let (x, y, z) = (x as u32, y as u32, z as u32);
x < self.dim[0] && y < self.dim[1] && z < self.dim[2] && self.solid(x, y, z)
}
/// Color RGBA del voxel `(x,y,z)`, o `None` fuera de rango. `a = 0` = aire.
pub fn get(&self, x: u32, y: u32, z: u32) -> Option<[u8; 4]> {
(x < self.dim[0] && y < self.dim[1] && z < self.dim[2]).then(|| self.data[self.idx(x, y, z)])
}
/// Altura del voxel sólido más alto en la columna `(x, z)` (escaneando de
/// arriba hacia abajo), o `None` si la columna está vacía. Útil para posar
/// una cámara/entidad sobre el terreno sin meterla dentro de la roca.
pub fn height_at(&self, x: u32, z: u32) -> Option<u32> {
if x >= self.dim[0] || z >= self.dim[2] {
return None;
}
(0..self.dim[1]).rev().find(|&y| self.solid(x, y, z))
}
/// Mapa de ocupación grueso por *bricks* de `brick³` voxels (M2): un texel
/// por brick, `255` si el brick contiene algún voxel sólido, `0` si está
/// todo vacío. El shader marcha primero esta grilla gruesa y se salta los
/// bricks vacíos enteros en un paso (empty-space skipping). Devuelve
/// `(dim_grueso, bytes R8)` con índice `cx + cy*cdx + cz*cdx*cdy`.
pub fn coarse_occupancy(&self, brick: u32) -> ([u32; 3], Vec<u8>) {
let b = brick.max(1);
let cdim = [
self.dim[0].div_ceil(b),
self.dim[1].div_ceil(b),
self.dim[2].div_ceil(b),
];
let mut out = vec![0u8; (cdim[0] * cdim[1] * cdim[2]) as usize];
for z in 0..self.dim[2] {
for y in 0..self.dim[1] {
for x in 0..self.dim[0] {
if self.solid(x, y, z) {
let (cx, cy, cz) = (x / b, y / b, z / b);
out[(cx + cy * cdim[0] + cz * cdim[0] * cdim[1]) as usize] = 255;
}
}
}
}
(cdim, out)
}
/// `255` si el brick `(cx,cy,cz)` (tamaño `b`) tiene algún voxel sólido,
/// `0` si está todo vacío. Lo usa el brick pool para decidir si un brick
/// necesita slot.
pub fn brick_occupied(&self, b: u32, cx: u32, cy: u32, cz: u32) -> u8 {
let (x0, y0, z0) = (cx * b, cy * b, cz * b);
for z in z0..(z0 + b).min(self.dim[2]) {
for y in y0..(y0 + b).min(self.dim[1]) {
for x in x0..(x0 + b).min(self.dim[0]) {
if self.solid(x, y, z) {
return 255;
}
}
}
}
0
}
/// Extrae los voxels de un brick `(cx,cy,cz)` de lado `brick` como RGBA
/// plano (`brick³` voxels, x contiguo), padeando con vacío los voxels fuera
/// del grid (bricks de borde cuando `dim` no es múltiplo de `brick`). Es la
/// unidad de subida al *pool* sparse (un slot del atlas = un brick).
pub fn extract_brick(&self, brick: u32, cx: u32, cy: u32, cz: u32) -> Vec<u8> {
let b = brick;
let mut out = vec![0u8; (b * b * b * 4) as usize];
for lz in 0..b {
for ly in 0..b {
for lx in 0..b {
let (x, y, z) = (cx * b + lx, cy * b + ly, cz * b + lz);
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let px = self.data[self.idx(x, y, z)];
let o = ((lx + ly * b + lz * b * b) * 4) as usize;
out[o..o + 4].copy_from_slice(&px);
}
}
}
}
out
}
/// Extrae una sub-caja RGBA contigua `[origin, origin+ext)` para subirla con
/// `queue.write_texture` (M3: upload incremental de la región fina mutada).
pub fn extract_fine(&self, origin: [u32; 3], ext: [u32; 3]) -> Vec<u8> {
let mut out = Vec::with_capacity((ext[0] * ext[1] * ext[2] * 4) as usize);
for z in origin[2]..origin[2] + ext[2] {
for y in origin[1]..origin[1] + ext[1] {
let row = self.idx(origin[0], y, z);
for i in 0..ext[0] as usize {
out.extend_from_slice(&self.data[row + i]);
}
}
}
out
}
/// Recalcula la ocupación gruesa de la caja de bricks `[cmin, cmin+cext)` y
/// la devuelve contigua (R8) para subir sólo esos bricks (M3).
pub fn coarse_region(&self, brick: u32, cmin: [u32; 3], cext: [u32; 3]) -> Vec<u8> {
let b = brick.max(1);
let mut out = Vec::with_capacity((cext[0] * cext[1] * cext[2]) as usize);
for cz in cmin[2]..cmin[2] + cext[2] {
for cy in cmin[1]..cmin[1] + cext[1] {
for cx in cmin[0]..cmin[0] + cext[0] {
out.push(self.brick_occupied(b, cx, cy, cz));
}
}
}
out
}
/// Bytes RGBA planos listos para `queue.write_texture`.
pub fn bytes(&self) -> &[u8] {
// `[u8;4]` es contiguo: reinterpretamos el Vec como bytes planos.
// SAFETY: `[u8;4]` no tiene padding; len*4 bytes válidos.
unsafe {
std::slice::from_raw_parts(self.data.as_ptr() as *const u8, self.data.len() * 4)
}
}
/// Escena de prueba para M1: un piso de 2 capas + una esfera coloreada por
/// posición flotando en el centro. Pone a prueba el DDA (atraviesa aire,
/// pega en piso y en esfera) y el sombreado por normal de cara.
pub fn demo_scene(dim: [u32; 3]) -> Self {
let mut g = Self::new(dim);
let [dx, dy, dz] = dim;
// Piso: 2 capas grises abajo, con un leve damero para leer la perspectiva.
for z in 0..dz {
for x in 0..dx {
let chk = ((x / 4 + z / 4) % 2) == 0;
let base = if chk { 70 } else { 95 };
for y in 0..2 {
g.set(x, y, z, [base, base + 8, base + 16]);
}
}
}
// Esfera centrada, color por posición normalizada.
let cx = dx as f32 / 2.0;
let cy = dy as f32 * 0.55;
let cz = dz as f32 / 2.0;
let r = (dx.min(dy).min(dz) as f32) * 0.3;
for z in 0..dz {
for y in 0..dy {
for x in 0..dx {
let (fx, fy, fz) = (x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
let d = ((fx - cx).powi(2) + (fy - cy).powi(2) + (fz - cz).powi(2)).sqrt();
if d <= r {
let rr = (fx / dx as f32 * 255.0) as u8;
let gg = (fy / dy as f32 * 255.0) as u8;
let bb = (fz / dz as f32 * 255.0) as u8;
g.set(x, y, z, [rr, gg, bb]);
}
}
}
}
// Pilares: dan rincones para el AO y proyectan/reciben sombras.
let pillars: [(u32, u32, u32, [u8; 3]); 3] = [
(dx / 5, dz / 4, dy * 7 / 10, [200, 120, 90]),
(dx * 4 / 5, dz / 3, dy / 2, [110, 170, 120]),
(dx / 3, dz * 4 / 5, dy * 3 / 5, [120, 130, 210]),
];
for (px, pz, ph, col) in pillars {
for y in 2..(2 + ph).min(dy) {
for dxx in 0..3u32 {
for dzz in 0..3u32 {
g.set(px + dxx, y, pz + dzz, col);
}
}
}
}
// Estado inicial: el upload completo lo cubre, no es "mutación".
g.reset_dirty();
g
}
}
File diff suppressed because it is too large Load Diff