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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
"#;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
"#;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user