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

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

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

187 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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);
}
}
}