Files
llimphi/llimphi-3d/src/billboard.rs
T
Sergio 93253cc227 chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes
Regenerado con scripts/actualizar-standalone.py --llimphi. Vendoriza además
shared/{foreign-lottie,grafo}. Quedan fuera los crates acoplados al workspace
madre (wasm-*, video-plane, voxel-app/studio, allichay, plugin-host, shuma-term).
cargo check --workspace verde (112 miembros).
2026-07-25 20:57:22 +00:00

432 lines
16 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.
//! `Billboards` — quads **siempre de cara a la cámara**, texturizados desde un
//! atlas, con test de profundidad y alpha-discard (recorte, no blend). El
//! ladrillo genérico para sprites, partículas, etiquetas flotantes, íconos en
//! el mundo.
//!
//! Cosechado de los sprites 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
//! los quads se armaban en CPU y estaban atados al `WadAtlas`/tabla de tintes.
//! Aquí la forma es agnóstica: un **atlas** (una textura) + una lista de
//! [`Billboard`] con su sub-rect UV, tamaño en mundo y tinte. El quad de cara a
//! la cámara lo arma el vertex shader con los ejes `right`/`up` de la cámara, así
//! no hay reconstrucción por CPU cada frame.
//!
//! ```ignore
//! let mut bb = Billboards::new(&device, fmt);
//! bb.set_atlas(&device, &queue, w, h, &rgba);
//! bb.set_billboards(&device, &[Billboard { center: [0.0,1.0,0.0], size: [1.0,2.0],
//! uv_min: [0.0,0.0], uv_max: [1.0,1.0], tint: [1.0,1.0,1.0,1.0] }]);
//! // en el pase (con depth), después del mundo opaco:
//! bb.upload(&queue, aspect, &camera);
//! bb.draw(&mut pass);
//! ```
use glam::Vec3;
use crate::camera::Camera3d;
use crate::scene::DEPTH_FORMAT;
/// Un billboard: un quad centrado en `center` (mundo), de `size` (ancho, alto en
/// unidades de mundo), texturizado con el sub-rect `[uv_min, uv_max]` del atlas y
/// multiplicado por `tint` (RGBA premultiplica el color; `a` también escala el
/// alpha del atlas para el discard).
#[derive(Clone, Copy, Debug)]
pub struct Billboard {
pub center: [f32; 3],
pub size: [f32; 2],
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
pub tint: [f32; 4],
}
impl Billboard {
/// Floats por instancia en el buffer (`center`3 + `size`2 + `uv_min`2 +
/// `uv_max`2 + `tint`4).
const FLOATS: usize = 3 + 2 + 2 + 2 + 4;
pub(crate) const STRIDE: usize = Self::FLOATS * 4;
pub(crate) fn write_to(&self, out: &mut Vec<u8>) {
for v in self.center {
out.extend_from_slice(&v.to_ne_bytes());
}
for v in self.size {
out.extend_from_slice(&v.to_ne_bytes());
}
for v in self.uv_min {
out.extend_from_slice(&v.to_ne_bytes());
}
for v in self.uv_max {
out.extend_from_slice(&v.to_ne_bytes());
}
for v in self.tint {
out.extend_from_slice(&v.to_ne_bytes());
}
}
}
/// Atlas subido + su bind group.
struct Atlas {
bind_group: wgpu::BindGroup,
}
/// Renderer de billboards reutilizable. Cachea pipeline/sampler/uniform; el
/// buffer de instancias se recrea en [`Self::set_billboards`].
pub struct Billboards {
pipeline: wgpu::RenderPipeline,
uniform_buf: wgpu::Buffer,
uniform_bg: wgpu::BindGroup,
tex_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
atlas: Option<Atlas>,
instances: Option<wgpu::Buffer>,
count: u32,
}
impl Billboards {
/// Crea el renderer para el `color_format` del target. El pipeline declara
/// depth [`DEPTH_FORMAT`] (test `Less`, escribe) → se usa en un pase con
/// depth attachment, después de la geometría opaca.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-3d-bb-uniform-layout"),
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 tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-3d-bb-tex-layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-bb-shader"),
source: wgpu::ShaderSource::Wgsl(BILLBOARD_WGSL.into()),
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-bb-pl"),
bind_group_layouts: &[&uniform_layout, &tex_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-bb-pipeline"),
layout: Some(&pl),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: Billboard::STRIDE as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 12,
shader_location: 1,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 20,
shader_location: 2,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 28,
shader_location: 3,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 36,
shader_location: 4,
},
],
}],
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-bb-uniform"),
size: 96, // view_proj(64) + right(16) + up(16)
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-bb-uniform-bg"),
layout: &uniform_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buf.as_entire_binding(),
}],
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-3d-bb-sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
Self {
pipeline,
uniform_buf,
uniform_bg,
tex_layout,
sampler,
atlas: None,
instances: None,
count: 0,
}
}
/// Sube/reemplaza el atlas (RGBA8, `w×h`, sin padding). `data.len()==w*h*4`.
pub fn set_atlas(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
w: u32,
h: u32,
data: &[u8],
) {
assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado");
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-3d-bb-atlas"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(w * 4),
rows_per_image: Some(h),
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
let view = tex.create_view(&Default::default());
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-bb-atlas-bg"),
layout: &self.tex_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.atlas = Some(Atlas { bind_group });
}
/// Reemplaza la lista de billboards (recrea el buffer de instancias). El
/// orden importa para el z-fight de alpha-discard: poná los más cercanos
/// primero si quieres (con depth-write el primero gana). Idealmente el caller
/// los ordena back-to-front.
pub fn set_billboards(&mut self, device: &wgpu::Device, items: &[Billboard]) {
self.count = items.len() as u32;
if items.is_empty() {
self.instances = None;
return;
}
let mut bytes = Vec::with_capacity(items.len() * Billboard::STRIDE);
for b in items {
b.write_to(&mut bytes);
}
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-bb-instances"),
size: bytes.len() as u64,
usage: wgpu::BufferUsages::VERTEX,
mapped_at_creation: true,
});
buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
buf.unmap();
self.instances = Some(buf);
}
/// Sube el uniform del frame: `view_proj` + los ejes `right`/`up` de la
/// cámara (para orientar los quads). `aspect` = w/h.
pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
let view_proj = camera.view_proj(aspect);
let forward = (camera.target - camera.eye).normalize_or_zero();
let right = forward.cross(camera.up).normalize_or_zero();
let up = right.cross(forward);
let mut b = Vec::with_capacity(96);
for v in view_proj.to_cols_array() {
b.extend_from_slice(&v.to_ne_bytes());
}
for v in [right.x, right.y, right.z, 0.0] {
b.extend_from_slice(&v.to_ne_bytes());
}
for v in [up.x, up.y, up.z, 0.0] {
b.extend_from_slice(&v.to_ne_bytes());
}
queue.write_buffer(&self.uniform_buf, 0, &b);
}
/// Dibuja los billboards en un pase **ya abierto** (con depth). Requiere
/// atlas + instancias + [`Self::upload`] previo. No-op si falta algo.
pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
let (Some(atlas), Some(inst)) = (self.atlas.as_ref(), self.instances.as_ref()) else {
return;
};
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_bg, &[]);
pass.set_bind_group(1, &atlas.bind_group, &[]);
pass.set_vertex_buffer(0, inst.slice(..));
pass.draw(0..6, 0..self.count);
}
/// Conveniencia para ubicar `up`/`right` desde una cámara sin subir nada (p.
/// ej. para ordenar los billboards back-to-front por distancia al ojo).
pub fn eye(camera: &Camera3d) -> Vec3 {
camera.eye
}
}
/// Quad de cara a la cámara por instancia: el vertex shader lo arma con los ejes
/// `right`/`up`; el fragment muestrea el atlas y descarta el alpha bajo.
const BILLBOARD_WGSL: &str = r#"
struct U {
view_proj: mat4x4<f32>,
right: vec4<f32>,
up: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u: U;
@group(1) @binding(0) var tex: texture_2d<f32>;
@group(1) @binding(1) var samp: sampler;
struct VIn {
@location(0) center: vec3<f32>,
@location(1) size: vec2<f32>,
@location(2) uv_min: vec2<f32>,
@location(3) uv_max: vec2<f32>,
@location(4) tint: vec4<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) tint: vec4<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32, in: VIn) -> VOut {
// Dos triángulos: esquinas en {0,1}².
var cs = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
);
let q = cs[vi];
let off = q - vec2<f32>(0.5, 0.5);
let world = in.center
+ u.right.xyz * (off.x * in.size.x)
+ u.up.xyz * (off.y * in.size.y);
var o: VOut;
o.clip = u.view_proj * vec4<f32>(world, 1.0);
// v invertida: q.y=1 (arriba del quad) ↔ uv_min.y (arriba de la imagen).
o.uv = vec2<f32>(
mix(in.uv_min.x, in.uv_max.x, q.x),
mix(in.uv_min.y, in.uv_max.y, 1.0 - q.y),
);
o.tint = in.tint;
return o;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
let c = textureSample(tex, samp, in.uv) * in.tint;
if (c.a < 0.5) { discard; }
return vec4<f32>(c.rgb, 1.0);
}
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shader_wgsl_valida() {
let module =
naga::front::wgsl::parse_str(BILLBOARD_WGSL).expect("BILLBOARD_WGSL no parsea");
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.expect("BILLBOARD_WGSL no valida");
}
#[test]
fn stride_es_13_floats() {
assert_eq!(Billboard::STRIDE, 13 * 4);
}
}