tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-3d/src/renderer.rs Rust · 435 siq'ikuna · 16.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · 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: &[Some(&bind_layout)],
96            immediate_size: 0,
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: Some(true),
137                depth_compare: Some(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_mask: 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            multiview_mask: None,
319        });
320        self.draw(&mut pass);
321    }
322}
323
324/// Crea un buffer ya inicializado con `data` (sin `wgpu::util::DeviceExt`, para
325/// no arrastrar la feature `util`): `mapped_at_creation` + copia + `unmap`.
326fn create_buffer_init(
327    device: &wgpu::Device,
328    label: &str,
329    usage: wgpu::BufferUsages,
330    data: &[u8],
331) -> wgpu::Buffer {
332    let buf = device.create_buffer(&wgpu::BufferDescriptor {
333        label: Some(label),
334        size: data.len() as u64,
335        usage,
336        mapped_at_creation: true,
337    });
338    buf.slice(..).get_mapped_range_mut().copy_from_slice(data);
339    buf.unmap();
340    buf
341}
342
343const WGSL: &str = r#"
344const MAXL: u32 = 8u;
345struct Uniforms {
346    view_proj: mat4x4<f32>,
347    model: mat4x4<f32>,
348    eye_n: vec4<f32>,      // xyz = ojo en mundo, w = nº de luces
349    ambient: vec4<f32>,    // rgb = luz ambiente
350    lpos: array<vec4<f32>, MAXL>,  // xyz = posición mundo, w = range (radio caída)
351    lcol: array<vec4<f32>, MAXL>,  // rgb = color de la luz
352};
353@group(0) @binding(0) var<uniform> u: Uniforms;
354
355struct VIn {
356    @location(0) pos: vec3<f32>,
357    @location(1) color: vec3<f32>,
358};
359struct VOut {
360    @builtin(position) clip: vec4<f32>,
361    @location(0) color: vec3<f32>,
362    @location(1) world: vec3<f32>,
363};
364
365@vertex
366fn vs(in: VIn) -> VOut {
367    var out: VOut;
368    let world = (u.model * vec4<f32>(in.pos, 1.0)).xyz;
369    out.clip = u.view_proj * vec4<f32>(world, 1.0);
370    out.color = in.color;
371    out.world = world;
372    return out;
373}
374
375@fragment
376fn fs(in: VOut) -> @location(0) vec4<f32> {
377    // Normal geométrica plana (faceteada) por derivadas screen-space: no exige
378    // normales por vértice y sirve para cualquier malla (cubos, muñecos…).
379    let dx = dpdx(in.world);
380    let dy = dpdy(in.world);
381    let ncross = cross(dx, dy);
382    let nlen = length(ncross);
383    if (nlen < 1e-8) {
384        // Triángulo degenerado / visto de canto: sólo ambiente.
385        return vec4<f32>(in.color * u.ambient.rgb, 1.0);
386    }
387    var n = ncross / nlen;
388    // Orientar la normal hacia la cámara (independiente del winding/signo de las
389    // derivadas) para que la cara visible siempre se ilumine bien.
390    let view_dir = u.eye_n.xyz - in.world;
391    if (dot(n, view_dir) < 0.0) {
392        n = -n;
393    }
394
395    var lit = u.ambient.rgb;
396    let count = u32(u.eye_n.w);
397    for (var i: u32 = 0u; i < count; i = i + 1u) {
398        let lp = u.lpos[i];
399        let d = lp.xyz - in.world;
400        let dist = length(d);
401        let range = max(lp.w, 1e-3);
402        // Caída suave: (1 - d/range)^2, recortada a 0 fuera del radio.
403        let a = clamp(1.0 - dist / range, 0.0, 1.0);
404        let atten = a * a;
405        let ndotl = max(dot(n, d / max(dist, 1e-4)), 0.0);
406        lit = lit + u.lcol[i].rgb * (ndotl * atten);
407    }
408    return vec4<f32>(in.color * lit, 1.0);
409}
410"#;
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    /// El shader del forward-mesh (con luces + derivadas) parsea y valida sin
417    /// GPU. Ataja errores que sólo saldrían al crear el pipeline en runtime.
418    #[test]
419    fn shader_wgsl_valida() {
420        let module = naga::front::wgsl::parse_str(WGSL).expect("WGSL no parsea");
421        naga::valid::Validator::new(
422            naga::valid::ValidationFlags::all(),
423            naga::valid::Capabilities::all(),
424        )
425        .validate(&module)
426        .expect("WGSL no valida");
427    }
428
429    /// El uniform empaquetado mide exactamente lo que declara el struct WGSL.
430    #[test]
431    fn uniform_size_coincide() {
432        assert_eq!(UNIFORM_SIZE, 64 + 64 + 16 + 16 + 8 * 16 * 2);
433        assert_eq!(UNIFORM_SIZE % 16, 0);
434    }
435}