tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc
llimphi-3d/src/billboard.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director
1//! `Billboards` — quads **siempre de cara a la cámara**, texturizados desde un
2//! atlas, con test de profundidad y alpha-discard (recorte, no blend). El
3//! ladrillo genérico para sprites, partículas, etiquetas flotantes, íconos en
4//! el mundo.
5//!
6//! Cosechado de los sprites 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
7//! los quads se armaban en CPU y estaban atados al `WadAtlas`/tabla de tintes.
8//! Acá la forma es agnóstica: un **atlas** (una textura) + una lista de
9//! [`Billboard`] con su sub-rect UV, tamaño en mundo y tinte. El quad de cara a
10//! la cámara lo arma el vertex shader con los ejes `right`/`up` de la cámara, así
11//! no hay reconstrucción por CPU cada frame.
12//!
13//! ```ignore
14//! let mut bb = Billboards::new(&device, fmt);
15//! bb.set_atlas(&device, &queue, w, h, &rgba);
16//! bb.set_billboards(&device, &[Billboard { center: [0.0,1.0,0.0], size: [1.0,2.0],
17//! uv_min: [0.0,0.0], uv_max: [1.0,1.0], tint: [1.0,1.0,1.0,1.0] }]);
18//! // en el pase (con depth), después del mundo opaco:
19//! bb.upload(&queue, aspect, &camera);
20//! bb.draw(&mut pass);
21//! ```
22
23use glam::Vec3;
24
25use crate::camera::Camera3d;
26use crate::scene::DEPTH_FORMAT;
27
28/// Un billboard: un quad centrado en `center` (mundo), de `size` (ancho, alto en
29/// unidades de mundo), texturizado con el sub-rect `[uv_min, uv_max]` del atlas y
30/// multiplicado por `tint` (RGBA premultiplica el color; `a` también escala el
31/// alpha del atlas para el discard).
32#[derive(Clone, Copy, Debug)]
33pub struct Billboard {
34 pub center: [f32; 3],
35 pub size: [f32; 2],
36 pub uv_min: [f32; 2],
37 pub uv_max: [f32; 2],
38 pub tint: [f32; 4],
39}
40
41impl Billboard {
42 /// Floats por instancia en el buffer (`center`3 + `size`2 + `uv_min`2 +
43 /// `uv_max`2 + `tint`4).
44 const FLOATS: usize = 3 + 2 + 2 + 2 + 4;
45 const STRIDE: usize = Self::FLOATS * 4;
46
47 fn write_to(&self, out: &mut Vec<u8>) {
48 for v in self.center {
49 out.extend_from_slice(&v.to_ne_bytes());
50 }
51 for v in self.size {
52 out.extend_from_slice(&v.to_ne_bytes());
53 }
54 for v in self.uv_min {
55 out.extend_from_slice(&v.to_ne_bytes());
56 }
57 for v in self.uv_max {
58 out.extend_from_slice(&v.to_ne_bytes());
59 }
60 for v in self.tint {
61 out.extend_from_slice(&v.to_ne_bytes());
62 }
63 }
64}
65
66/// Atlas subido + su bind group.
67struct Atlas {
68 bind_group: wgpu::BindGroup,
69}
70
71/// Renderer de billboards reutilizable. Cachea pipeline/sampler/uniform; el
72/// buffer de instancias se recrea en [`Self::set_billboards`].
73pub struct Billboards {
74 pipeline: wgpu::RenderPipeline,
75 uniform_buf: wgpu::Buffer,
76 uniform_bg: wgpu::BindGroup,
77 tex_layout: wgpu::BindGroupLayout,
78 sampler: wgpu::Sampler,
79 atlas: Option<Atlas>,
80 instances: Option<wgpu::Buffer>,
81 count: u32,
82}
83
84impl Billboards {
85 /// Crea el renderer para el `color_format` del target. El pipeline declara
86 /// depth [`DEPTH_FORMAT`] (test `Less`, escribe) → se usa en un pase con
87 /// depth attachment, después de la geometría opaca.
88 pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
89 let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
90 label: Some("llimphi-3d-bb-uniform-layout"),
91 entries: &[wgpu::BindGroupLayoutEntry {
92 binding: 0,
93 visibility: wgpu::ShaderStages::VERTEX,
94 ty: wgpu::BindingType::Buffer {
95 ty: wgpu::BufferBindingType::Uniform,
96 has_dynamic_offset: false,
97 min_binding_size: None,
98 },
99 count: None,
100 }],
101 });
102 let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
103 label: Some("llimphi-3d-bb-tex-layout"),
104 entries: &[
105 wgpu::BindGroupLayoutEntry {
106 binding: 0,
107 visibility: wgpu::ShaderStages::FRAGMENT,
108 ty: wgpu::BindingType::Texture {
109 sample_type: wgpu::TextureSampleType::Float { filterable: true },
110 view_dimension: wgpu::TextureViewDimension::D2,
111 multisampled: false,
112 },
113 count: None,
114 },
115 wgpu::BindGroupLayoutEntry {
116 binding: 1,
117 visibility: wgpu::ShaderStages::FRAGMENT,
118 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
119 count: None,
120 },
121 ],
122 });
123 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
124 label: Some("llimphi-3d-bb-shader"),
125 source: wgpu::ShaderSource::Wgsl(BILLBOARD_WGSL.into()),
126 });
127 let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
128 label: Some("llimphi-3d-bb-pl"),
129 bind_group_layouts: &[&uniform_layout, &tex_layout],
130 push_constant_ranges: &[],
131 });
132 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
133 label: Some("llimphi-3d-bb-pipeline"),
134 layout: Some(&pl),
135 vertex: wgpu::VertexState {
136 module: &shader,
137 entry_point: Some("vs"),
138 compilation_options: Default::default(),
139 buffers: &[wgpu::VertexBufferLayout {
140 array_stride: Billboard::STRIDE as u64,
141 step_mode: wgpu::VertexStepMode::Instance,
142 attributes: &[
143 wgpu::VertexAttribute {
144 format: wgpu::VertexFormat::Float32x3,
145 offset: 0,
146 shader_location: 0,
147 },
148 wgpu::VertexAttribute {
149 format: wgpu::VertexFormat::Float32x2,
150 offset: 12,
151 shader_location: 1,
152 },
153 wgpu::VertexAttribute {
154 format: wgpu::VertexFormat::Float32x2,
155 offset: 20,
156 shader_location: 2,
157 },
158 wgpu::VertexAttribute {
159 format: wgpu::VertexFormat::Float32x2,
160 offset: 28,
161 shader_location: 3,
162 },
163 wgpu::VertexAttribute {
164 format: wgpu::VertexFormat::Float32x4,
165 offset: 36,
166 shader_location: 4,
167 },
168 ],
169 }],
170 },
171 primitive: wgpu::PrimitiveState {
172 cull_mode: None,
173 ..Default::default()
174 },
175 depth_stencil: Some(wgpu::DepthStencilState {
176 format: DEPTH_FORMAT,
177 depth_write_enabled: true,
178 depth_compare: wgpu::CompareFunction::Less,
179 stencil: Default::default(),
180 bias: Default::default(),
181 }),
182 multisample: Default::default(),
183 fragment: Some(wgpu::FragmentState {
184 module: &shader,
185 entry_point: Some("fs"),
186 compilation_options: Default::default(),
187 targets: &[Some(wgpu::ColorTargetState {
188 format: color_format,
189 blend: None,
190 write_mask: wgpu::ColorWrites::ALL,
191 })],
192 }),
193 multiview: None,
194 cache: None,
195 });
196
197 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
198 label: Some("llimphi-3d-bb-uniform"),
199 size: 96, // view_proj(64) + right(16) + up(16)
200 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
201 mapped_at_creation: false,
202 });
203 let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
204 label: Some("llimphi-3d-bb-uniform-bg"),
205 layout: &uniform_layout,
206 entries: &[wgpu::BindGroupEntry {
207 binding: 0,
208 resource: uniform_buf.as_entire_binding(),
209 }],
210 });
211 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
212 label: Some("llimphi-3d-bb-sampler"),
213 address_mode_u: wgpu::AddressMode::ClampToEdge,
214 address_mode_v: wgpu::AddressMode::ClampToEdge,
215 address_mode_w: wgpu::AddressMode::ClampToEdge,
216 mag_filter: wgpu::FilterMode::Linear,
217 min_filter: wgpu::FilterMode::Linear,
218 mipmap_filter: wgpu::FilterMode::Nearest,
219 ..Default::default()
220 });
221
222 Self {
223 pipeline,
224 uniform_buf,
225 uniform_bg,
226 tex_layout,
227 sampler,
228 atlas: None,
229 instances: None,
230 count: 0,
231 }
232 }
233
234 /// Sube/reemplaza el atlas (RGBA8, `w×h`, sin padding). `data.len()==w*h*4`.
235 pub fn set_atlas(
236 &mut self,
237 device: &wgpu::Device,
238 queue: &wgpu::Queue,
239 w: u32,
240 h: u32,
241 data: &[u8],
242 ) {
243 assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado");
244 let tex = device.create_texture(&wgpu::TextureDescriptor {
245 label: Some("llimphi-3d-bb-atlas"),
246 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
247 mip_level_count: 1,
248 sample_count: 1,
249 dimension: wgpu::TextureDimension::D2,
250 format: wgpu::TextureFormat::Rgba8Unorm,
251 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
252 view_formats: &[],
253 });
254 queue.write_texture(
255 wgpu::TexelCopyTextureInfo {
256 texture: &tex,
257 mip_level: 0,
258 origin: wgpu::Origin3d::ZERO,
259 aspect: wgpu::TextureAspect::All,
260 },
261 data,
262 wgpu::TexelCopyBufferLayout {
263 offset: 0,
264 bytes_per_row: Some(w * 4),
265 rows_per_image: Some(h),
266 },
267 wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
268 );
269 let view = tex.create_view(&Default::default());
270 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
271 label: Some("llimphi-3d-bb-atlas-bg"),
272 layout: &self.tex_layout,
273 entries: &[
274 wgpu::BindGroupEntry {
275 binding: 0,
276 resource: wgpu::BindingResource::TextureView(&view),
277 },
278 wgpu::BindGroupEntry {
279 binding: 1,
280 resource: wgpu::BindingResource::Sampler(&self.sampler),
281 },
282 ],
283 });
284 self.atlas = Some(Atlas { bind_group });
285 }
286
287 /// Reemplaza la lista de billboards (recrea el buffer de instancias). El
288 /// orden importa para el z-fight de alpha-discard: poná los más cercanos
289 /// primero si querés (con depth-write el primero gana). Idealmente el caller
290 /// los ordena back-to-front.
291 pub fn set_billboards(&mut self, device: &wgpu::Device, items: &[Billboard]) {
292 self.count = items.len() as u32;
293 if items.is_empty() {
294 self.instances = None;
295 return;
296 }
297 let mut bytes = Vec::with_capacity(items.len() * Billboard::STRIDE);
298 for b in items {
299 b.write_to(&mut bytes);
300 }
301 let buf = device.create_buffer(&wgpu::BufferDescriptor {
302 label: Some("llimphi-3d-bb-instances"),
303 size: bytes.len() as u64,
304 usage: wgpu::BufferUsages::VERTEX,
305 mapped_at_creation: true,
306 });
307 buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
308 buf.unmap();
309 self.instances = Some(buf);
310 }
311
312 /// Sube el uniform del frame: `view_proj` + los ejes `right`/`up` de la
313 /// cámara (para orientar los quads). `aspect` = w/h.
314 pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
315 let view_proj = camera.view_proj(aspect);
316 let forward = (camera.target - camera.eye).normalize_or_zero();
317 let right = forward.cross(camera.up).normalize_or_zero();
318 let up = right.cross(forward);
319 let mut b = Vec::with_capacity(96);
320 for v in view_proj.to_cols_array() {
321 b.extend_from_slice(&v.to_ne_bytes());
322 }
323 for v in [right.x, right.y, right.z, 0.0] {
324 b.extend_from_slice(&v.to_ne_bytes());
325 }
326 for v in [up.x, up.y, up.z, 0.0] {
327 b.extend_from_slice(&v.to_ne_bytes());
328 }
329 queue.write_buffer(&self.uniform_buf, 0, &b);
330 }
331
332 /// Dibuja los billboards en un pase **ya abierto** (con depth). Requiere
333 /// atlas + instancias + [`Self::upload`] previo. No-op si falta algo.
334 pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
335 let (Some(atlas), Some(inst)) = (self.atlas.as_ref(), self.instances.as_ref()) else {
336 return;
337 };
338 if self.count == 0 {
339 return;
340 }
341 pass.set_pipeline(&self.pipeline);
342 pass.set_bind_group(0, &self.uniform_bg, &[]);
343 pass.set_bind_group(1, &atlas.bind_group, &[]);
344 pass.set_vertex_buffer(0, inst.slice(..));
345 pass.draw(0..6, 0..self.count);
346 }
347
348 /// Conveniencia para ubicar `up`/`right` desde una cámara sin subir nada (p.
349 /// ej. para ordenar los billboards back-to-front por distancia al ojo).
350 pub fn eye(camera: &Camera3d) -> Vec3 {
351 camera.eye
352 }
353}
354
355/// Quad de cara a la cámara por instancia: el vertex shader lo arma con los ejes
356/// `right`/`up`; el fragment muestrea el atlas y descarta el alpha bajo.
357const BILLBOARD_WGSL: &str = r#"
358struct U {
359 view_proj: mat4x4<f32>,
360 right: vec4<f32>,
361 up: vec4<f32>,
362};
363@group(0) @binding(0) var<uniform> u: U;
364@group(1) @binding(0) var tex: texture_2d<f32>;
365@group(1) @binding(1) var samp: sampler;
366
367struct VIn {
368 @location(0) center: vec3<f32>,
369 @location(1) size: vec2<f32>,
370 @location(2) uv_min: vec2<f32>,
371 @location(3) uv_max: vec2<f32>,
372 @location(4) tint: vec4<f32>,
373};
374struct VOut {
375 @builtin(position) clip: vec4<f32>,
376 @location(0) uv: vec2<f32>,
377 @location(1) tint: vec4<f32>,
378};
379
380@vertex
381fn vs(@builtin(vertex_index) vi: u32, in: VIn) -> VOut {
382 // Dos triángulos: esquinas en {0,1}².
383 var cs = array<vec2<f32>, 6>(
384 vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
385 vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
386 );
387 let q = cs[vi];
388 let off = q - vec2<f32>(0.5, 0.5);
389 let world = in.center
390 + u.right.xyz * (off.x * in.size.x)
391 + u.up.xyz * (off.y * in.size.y);
392 var o: VOut;
393 o.clip = u.view_proj * vec4<f32>(world, 1.0);
394 // v invertida: q.y=1 (arriba del quad) ↔ uv_min.y (arriba de la imagen).
395 o.uv = vec2<f32>(
396 mix(in.uv_min.x, in.uv_max.x, q.x),
397 mix(in.uv_min.y, in.uv_max.y, 1.0 - q.y),
398 );
399 o.tint = in.tint;
400 return o;
401}
402
403@fragment
404fn fs(in: VOut) -> @location(0) vec4<f32> {
405 let c = textureSample(tex, samp, in.uv) * in.tint;
406 if (c.a < 0.5) { discard; }
407 return vec4<f32>(c.rgb, 1.0);
408}
409"#;
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn shader_wgsl_valida() {
417 let module =
418 naga::front::wgsl::parse_str(BILLBOARD_WGSL).expect("BILLBOARD_WGSL no parsea");
419 naga::valid::Validator::new(
420 naga::valid::ValidationFlags::all(),
421 naga::valid::Capabilities::all(),
422 )
423 .validate(&module)
424 .expect("BILLBOARD_WGSL no valida");
425 }
426
427 #[test]
428 fn stride_es_13_floats() {
429 assert_eq!(Billboard::STRIDE, 13 * 4);
430 }
431}