gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc llimphi-3d/src/renderer.rs Rust · 434 lines · 16.8 KB History Who touched what raw

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

1//! `Renderer3d` — pipeline wgpu mínimo que dibuja geometría 3D indexada con
2//! test de profundidad sobre la textura intermedia del frame de Llimphi.
3//!
4//! La firma de [`Renderer3d::render`] es la que pide la closure de
5//! `View::gpu_paint_with` (`device, queue, encoder, target_view, (w, h)`), más
6//! la cámara — así un nodo 3D entra en el árbol `View<Msg>` sin tocar el
7//! runtime. Mantiene su **propio depth buffer** (recreado al cambiar de
8//! tamaño); el color se compone con `LoadOp::Load` para preservar la UI vello
9//! que ya está debajo.
10
11use glam::Mat4;
12
13use crate::camera::Camera3d;
14use crate::mesh::{cube, Vertex3d};
15use crate::scene::{ensure_depth, DepthBuffer, DEPTH_FORMAT};
16use crate::voxel_renderer::PointLight;
17
18/// Tope de luces puntuales por frame en el forward-mesh (caben en el uniform).
19/// El caller puede agregar más a [`Renderer3d::lights`]; las que excedan se
20/// ignoran (las primeras `MESH_MAX_LIGHTS`).
21pub const MESH_MAX_LIGHTS: usize = 8;
22
23/// Tamaño del uniform en bytes: `view_proj`(64) + `model`(64) + `eye_nlights`(16)
24/// + `ambient`(16) + `lpos`(MAX·16) + `lcol`(MAX·16).
25const UNIFORM_SIZE: usize = 64 + 64 + 16 + 16 + MESH_MAX_LIGHTS * 16 * 2;
26
27/// Renderer de **mallas** indexadas (por defecto un cubo) visto desde una
28/// [`Camera3d`]. Cachea pipeline, buffers de geometría, uniform y (para el
29/// camino standalone) un depth propio. En [`Scene3d`](crate::Scene3d) comparte
30/// el depth con el pase de voxels para ocluirse mutuamente.
31///
32/// `model` ubica la malla en el mundo (default identidad): `mvp = view_proj ·
33/// model`, así una misma malla se instancia/posiciona sin reconstruir buffers.
34pub struct Renderer3d {
35    pipeline: wgpu::RenderPipeline,
36    vbuf: wgpu::Buffer,
37    ibuf: wgpu::Buffer,
38    index_count: u32,
39    ubuf: wgpu::Buffer,
40    bind_group: wgpu::BindGroup,
41    model: Mat4,
42    depth: Option<DepthBuffer>,
43    /// Luces puntuales coloreadas que iluminan la malla (≤ [`MESH_MAX_LIGHTS`];
44    /// las que sobren se ignoran). `pos`/`range` en **coordenadas de mundo** (no
45    /// de voxel: ojo con el doc de [`PointLight`], que está redactado para el
46    /// ray-marcher); `range` = radio de caída, `radius` se ignora (no hay
47    /// sombras en el forward-mesh). Editable antes de `upload`/`render`. Vacío =
48    /// sin luces → sólo el término ambiente.
49    pub lights: Vec<PointLight>,
50    /// Luz ambiente RGB que multiplica el color base donde no llega ninguna luz.
51    /// Default `[1, 1, 1]`: sin luces, el render es **idéntico** al plano de
52    /// antes (color base tal cual). Para que las luces se noten, bajalo
53    /// (p.ej. `[0.12, 0.12, 0.14]`).
54    pub ambient: [f32; 3],
55}
56
57impl Renderer3d {
58    /// Crea el renderer para un `color_format` dado (el de la textura
59    /// intermedia del frame — `Rgba8Unorm` en headless, el de la surface en
60    /// vivo). Arranca con el cubo de prueba cargado.
61    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
62        let (verts, indices) = cube();
63        Self::with_mesh(device, color_format, &verts, &indices)
64    }
65
66    /// Igual que [`Self::new`] pero con una malla arbitraria.
67    pub fn with_mesh(
68        device: &wgpu::Device,
69        color_format: wgpu::TextureFormat,
70        verts: &[Vertex3d],
71        indices: &[u16],
72    ) -> Self {
73        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
74            label: Some("llimphi-3d-shader"),
75            source: wgpu::ShaderSource::Wgsl(WGSL.into()),
76        });
77
78        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
79            label: Some("llimphi-3d-bgl"),
80            entries: &[wgpu::BindGroupLayoutEntry {
81                binding: 0,
82                // El fragment también lee el uniform (luces + ambiente + eye).
83                visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
84                ty: wgpu::BindingType::Buffer {
85                    ty: wgpu::BufferBindingType::Uniform,
86                    has_dynamic_offset: false,
87                    min_binding_size: None,
88                },
89                count: None,
90            }],
91        });
92
93        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
94            label: Some("llimphi-3d-pl"),
95            bind_group_layouts: &[&bind_layout],
96            push_constant_ranges: &[],
97        });
98
99        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
100            label: Some("llimphi-3d-pipeline"),
101            layout: Some(&pipeline_layout),
102            vertex: wgpu::VertexState {
103                module: &shader,
104                entry_point: Some("vs"),
105                compilation_options: Default::default(),
106                buffers: &[wgpu::VertexBufferLayout {
107                    array_stride: Vertex3d::SIZE as u64,
108                    step_mode: wgpu::VertexStepMode::Vertex,
109                    attributes: &[
110                        wgpu::VertexAttribute {
111                            format: wgpu::VertexFormat::Float32x3,
112                            offset: 0,
113                            shader_location: 0,
114                        },
115                        wgpu::VertexAttribute {
116                            format: wgpu::VertexFormat::Float32x3,
117                            offset: 12,
118                            shader_location: 1,
119                        },
120                    ],
121                }],
122            },
123            primitive: wgpu::PrimitiveState {
124                topology: wgpu::PrimitiveTopology::TriangleList,
125                strip_index_format: None,
126                front_face: wgpu::FrontFace::Ccw,
127                // M0 sin cull: el depth test ya resuelve la oclusión y nos
128                // ahorra bugs de winding al sumar mallas. El cull entra en M1+.
129                cull_mode: None,
130                unclipped_depth: false,
131                polygon_mode: wgpu::PolygonMode::Fill,
132                conservative: false,
133            },
134            depth_stencil: Some(wgpu::DepthStencilState {
135                format: DEPTH_FORMAT,
136                depth_write_enabled: true,
137                depth_compare: wgpu::CompareFunction::Less,
138                stencil: wgpu::StencilState::default(),
139                bias: wgpu::DepthBiasState::default(),
140            }),
141            multisample: wgpu::MultisampleState::default(),
142            fragment: Some(wgpu::FragmentState {
143                module: &shader,
144                entry_point: Some("fs"),
145                compilation_options: Default::default(),
146                targets: &[Some(wgpu::ColorTargetState {
147                    format: color_format,
148                    // Opaco: el cubo reemplaza el fondo vello donde lo cubre.
149                    blend: None,
150                    write_mask: wgpu::ColorWrites::ALL,
151                })],
152            }),
153            multiview: None,
154            cache: None,
155        });
156
157        // Geometría → buffers (idiom `to_ne_bytes`, sin bytemuck).
158        let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
159        for v in verts {
160            v.write_to(&mut vbytes);
161        }
162        let vbuf = create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
163
164        let mut ibytes = Vec::with_capacity(indices.len() * 2);
165        for &i in indices {
166            ibytes.extend_from_slice(&i.to_ne_bytes());
167        }
168        let ibuf = create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
169
170        let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
171            label: Some("llimphi-3d-ubuf"),
172            size: UNIFORM_SIZE as u64,
173            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
174            mapped_at_creation: false,
175        });
176
177        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
178            label: Some("llimphi-3d-bg"),
179            layout: &bind_layout,
180            entries: &[wgpu::BindGroupEntry {
181                binding: 0,
182                resource: ubuf.as_entire_binding(),
183            }],
184        });
185
186        Self {
187            pipeline,
188            vbuf,
189            ibuf,
190            index_count: indices.len() as u32,
191            ubuf,
192            bind_group,
193            model: Mat4::IDENTITY,
194            depth: None,
195            lights: Vec::new(),
196            ambient: [1.0, 1.0, 1.0],
197        }
198    }
199
200    /// Ubica la malla en el mundo (`mvp = view_proj · model`). Default identidad.
201    pub fn set_model(&mut self, model: Mat4) {
202        self.model = model;
203    }
204
205    /// Reemplaza la geometría (recrea los buffers de vértices/índices). Pensado
206    /// para mallas que cambian cada frame — p.ej. un **muñeco articulado** cuya
207    /// pose se rehornea en CPU (limbos rotados) y se vuelve a subir. Las mallas
208    /// son chicas (decenas-cientos de vértices), así que recrear los buffers por
209    /// frame es despreciable; el pipeline/uniform/bind-group se conservan.
210    pub fn set_geometry(&mut self, device: &wgpu::Device, verts: &[Vertex3d], indices: &[u16]) {
211        let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
212        for v in verts {
213            v.write_to(&mut vbytes);
214        }
215        self.vbuf =
216            create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
217
218        let mut ibytes = Vec::with_capacity(indices.len() * 2);
219        for &i in indices {
220            ibytes.extend_from_slice(&i.to_ne_bytes());
221        }
222        self.ibuf =
223            create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
224        self.index_count = indices.len() as u32;
225    }
226
227    /// Sube el uniform del frame: `view_proj` y `model` por separado (el shader
228    /// necesita la posición de mundo `model · pos` para iluminar), `eye`, número
229    /// de luces, ambiente y los arrays de luces. Lo llama [`Self::render`] y
230    /// [`Scene3d`](crate::Scene3d). `aspect` = w/h. glam es column-major, igual
231    /// que WGSL → se sube tal cual.
232    pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
233        let view_proj = camera.view_proj(aspect);
234        let mut b = Vec::with_capacity(UNIFORM_SIZE);
235        for v in view_proj.to_cols_array() {
236            b.extend_from_slice(&v.to_ne_bytes());
237        }
238        for v in self.model.to_cols_array() {
239            b.extend_from_slice(&v.to_ne_bytes());
240        }
241        let n = self.lights.len().min(MESH_MAX_LIGHTS);
242        for v in [camera.eye.x, camera.eye.y, camera.eye.z, n as f32] {
243            b.extend_from_slice(&v.to_ne_bytes());
244        }
245        for v in [self.ambient[0], self.ambient[1], self.ambient[2], 0.0] {
246            b.extend_from_slice(&v.to_ne_bytes());
247        }
248        // Posiciones (xyz) + range (w).
249        for i in 0..MESH_MAX_LIGHTS {
250            let l = self.lights.get(i).copied();
251            let (p, r) = l.map_or(([0.0; 3], 1.0), |l| (l.pos, l.range));
252            for v in [p[0], p[1], p[2], r] {
253                b.extend_from_slice(&v.to_ne_bytes());
254            }
255        }
256        // Colores (rgb) + relleno.
257        for i in 0..MESH_MAX_LIGHTS {
258            let c = self.lights.get(i).copied().map_or([0.0; 3], |l| l.color);
259            for v in [c[0], c[1], c[2], 0.0] {
260                b.extend_from_slice(&v.to_ne_bytes());
261            }
262        }
263        debug_assert_eq!(b.len(), UNIFORM_SIZE);
264        queue.write_buffer(&self.ubuf, 0, &b);
265    }
266
267    /// Dibuja la malla indexada en un pase **ya abierto** (color + depth). Lo usa
268    /// [`Scene3d`](crate::Scene3d) para compartir el pase con los voxels.
269    /// Requiere `upload` previo en el mismo frame.
270    pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
271        pass.set_pipeline(&self.pipeline);
272        pass.set_bind_group(0, &self.bind_group, &[]);
273        pass.set_vertex_buffer(0, self.vbuf.slice(..));
274        pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint16);
275        pass.draw_indexed(0..self.index_count, 0, 0..1);
276    }
277
278    /// Dibuja la malla sola sobre `target` (camino standalone, depth propio).
279    /// Firma compatible con `View::gpu_paint_with`; color preservado
280    /// (`LoadOp::Load`), depth propio limpiado cada frame.
281    pub fn render(
282        &mut self,
283        device: &wgpu::Device,
284        queue: &wgpu::Queue,
285        encoder: &mut wgpu::CommandEncoder,
286        target: &wgpu::TextureView,
287        (w, h): (u32, u32),
288        camera: &Camera3d,
289    ) {
290        if w == 0 || h == 0 {
291            return;
292        }
293        self.upload(queue, w as f32 / h as f32, camera);
294        ensure_depth(&mut self.depth, device, w, h);
295        let depth_view = &self.depth.as_ref().unwrap().view;
296
297        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
298            label: Some("llimphi-3d-pass"),
299            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
300                view: target,
301                resolve_target: None,
302                depth_slice: None,
303                ops: wgpu::Operations {
304                    load: wgpu::LoadOp::Load,
305                    store: wgpu::StoreOp::Store,
306                },
307            })],
308            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
309                view: depth_view,
310                depth_ops: Some(wgpu::Operations {
311                    load: wgpu::LoadOp::Clear(1.0),
312                    store: wgpu::StoreOp::Store,
313                }),
314                stencil_ops: None,
315            }),
316            timestamp_writes: None,
317            occlusion_query_set: None,
318        });
319        self.draw(&mut pass);
320    }
321}
322
323/// Crea un buffer ya inicializado con `data` (sin `wgpu::util::DeviceExt`, para
324/// no arrastrar la feature `util`): `mapped_at_creation` + copia + `unmap`.
325fn create_buffer_init(
326    device: &wgpu::Device,
327    label: &str,
328    usage: wgpu::BufferUsages,
329    data: &[u8],
330) -> wgpu::Buffer {
331    let buf = device.create_buffer(&wgpu::BufferDescriptor {
332        label: Some(label),
333        size: data.len() as u64,
334        usage,
335        mapped_at_creation: true,
336    });
337    buf.slice(..).get_mapped_range_mut().copy_from_slice(data);
338    buf.unmap();
339    buf
340}
341
342const WGSL: &str = r#"
343const MAXL: u32 = 8u;
344struct Uniforms {
345    view_proj: mat4x4<f32>,
346    model: mat4x4<f32>,
347    eye_n: vec4<f32>,      // xyz = ojo en mundo, w = nº de luces
348    ambient: vec4<f32>,    // rgb = luz ambiente
349    lpos: array<vec4<f32>, MAXL>,  // xyz = posición mundo, w = range (radio caída)
350    lcol: array<vec4<f32>, MAXL>,  // rgb = color de la luz
351};
352@group(0) @binding(0) var<uniform> u: Uniforms;
353
354struct VIn {
355    @location(0) pos: vec3<f32>,
356    @location(1) color: vec3<f32>,
357};
358struct VOut {
359    @builtin(position) clip: vec4<f32>,
360    @location(0) color: vec3<f32>,
361    @location(1) world: vec3<f32>,
362};
363
364@vertex
365fn vs(in: VIn) -> VOut {
366    var out: VOut;
367    let world = (u.model * vec4<f32>(in.pos, 1.0)).xyz;
368    out.clip = u.view_proj * vec4<f32>(world, 1.0);
369    out.color = in.color;
370    out.world = world;
371    return out;
372}
373
374@fragment
375fn fs(in: VOut) -> @location(0) vec4<f32> {
376    // Normal geométrica plana (faceteada) por derivadas screen-space: no exige
377    // normales por vértice y sirve para cualquier malla (cubos, muñecos…).
378    let dx = dpdx(in.world);
379    let dy = dpdy(in.world);
380    let ncross = cross(dx, dy);
381    let nlen = length(ncross);
382    if (nlen < 1e-8) {
383        // Triángulo degenerado / visto de canto: sólo ambiente.
384        return vec4<f32>(in.color * u.ambient.rgb, 1.0);
385    }
386    var n = ncross / nlen;
387    // Orientar la normal hacia la cámara (independiente del winding/signo de las
388    // derivadas) para que la cara visible siempre se ilumine bien.
389    let view_dir = u.eye_n.xyz - in.world;
390    if (dot(n, view_dir) < 0.0) {
391        n = -n;
392    }
393
394    var lit = u.ambient.rgb;
395    let count = u32(u.eye_n.w);
396    for (var i: u32 = 0u; i < count; i = i + 1u) {
397        let lp = u.lpos[i];
398        let d = lp.xyz - in.world;
399        let dist = length(d);
400        let range = max(lp.w, 1e-3);
401        // Caída suave: (1 - d/range)^2, recortada a 0 fuera del radio.
402        let a = clamp(1.0 - dist / range, 0.0, 1.0);
403        let atten = a * a;
404        let ndotl = max(dot(n, d / max(dist, 1e-4)), 0.0);
405        lit = lit + u.lcol[i].rgb * (ndotl * atten);
406    }
407    return vec4<f32>(in.color * lit, 1.0);
408}
409"#;
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    /// El shader del forward-mesh (con luces + derivadas) parsea y valida sin
416    /// GPU. Ataja errores que sólo saldrían al crear el pipeline en runtime.
417    #[test]
418    fn shader_wgsl_valida() {
419        let module = naga::front::wgsl::parse_str(WGSL).expect("WGSL no parsea");
420        naga::valid::Validator::new(
421            naga::valid::ValidationFlags::all(),
422            naga::valid::Capabilities::all(),
423        )
424        .validate(&module)
425        .expect("WGSL no valida");
426    }
427
428    /// El uniform empaquetado mide exactamente lo que declara el struct WGSL.
429    #[test]
430    fn uniform_size_coincide() {
431        assert_eq!(UNIFORM_SIZE, 64 + 64 + 16 + 16 + 8 * 16 * 2);
432        assert_eq!(UNIFORM_SIZE % 16, 0);
433    }
434}