gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc llimphi-3d/src/billboard.rs Rust · 431 líneas · 15.8 KB Historia Quién tocó qué crudo

feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director 333dca1d5 · Sergio ·

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}