tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director
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}