tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `PlanarReflection` — reflexión planar genérica: una superficie espejada
2//! (agua, piso pulido, vidrio) que refleja el mundo a través de un plano.
3//!
4//! Cosechado del agua 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde la
5//! reflexión estaba cableada al plano horizontal de Doom (`reflect_across_z`) y
6//! al shader del agua. Aquí:
7//!
8//! - [`ReflectionPlane`] modela un plano **arbitrario** (`n·x + d = 0`) y da su
9//! matriz espejo ([`ReflectionPlane::mirror`]) por reflexión de Householder.
10//! - [`PlanarReflection`] administra el **render target de reflexión**
11//! (color+depth) — el caller rinde su mundo con la `view_proj · mirror` ahí —
12//! y trae una **superficie reflectante lista**: un quad sobre el plano que
13//! muestrea esa textura en screen-space, con ondas procedurales + Fresnel +
14//! tinte. El caller puede usar esa superficie o sólo la textura para su
15//! propio shader.
16//!
17//! Flujo:
18//! ```ignore
19//! let m = plane.mirror();
20//! refl.prepare(&device, (w, h));
21//! { let mut rp = refl.reflection_pass(&mut enc, clear);
22//! // rendir el mundo con view_proj * m (clip bajo el plano en tu shader) }
23//! // ... pase principal: mundo normal ...
24//! refl.set_surface_quad(&device, corners);
25//! refl.upload_surface(&queue, &view_proj, &plane, &eye, &SurfaceParams::default(), (w,h), time);
26//! refl.draw_surface(&mut pass); // en el pase principal (con depth)
27//! ```
28
29use glam::{Mat4, Vec3};
30
31use crate::scene::DEPTH_FORMAT;
32
33/// Plano `n·x + d = 0` con `n` unitaria. La reflexión espeja a través de él.
34#[derive(Clone, Copy, Debug)]
35pub struct ReflectionPlane {
36 /// Normal del plano (se normaliza en [`Self::mirror`]).
37 pub normal: [f32; 3],
38 /// Término independiente: distancia con signo del origen, en unidades de `n`.
39 pub d: f32,
40}
41
42impl ReflectionPlane {
43 /// Plano horizontal a altura `y` con normal `+Y` (piso/agua, mundo Y-up).
44 pub fn horizontal_y(y: f32) -> Self {
45 Self { normal: [0.0, 1.0, 0.0], d: -y }
46 }
47
48 /// Plano horizontal a altura `z` con normal `+Z` (mundo Z-up, como Doom).
49 pub fn horizontal_z(z: f32) -> Self {
50 Self { normal: [0.0, 0.0, 1.0], d: -z }
51 }
52
53 /// Matriz de **reflexión de Householder** a través del plano: espeja
54 /// cualquier punto a su imagen del otro lado. `mvp_reflejada = view_proj ·
55 /// mirror`. Equivale, para un plano horizontal, al `T·S(-1)·T⁻¹` de Doom.
56 pub fn mirror(&self) -> Mat4 {
57 let n = Vec3::from_array(self.normal).normalize_or_zero();
58 let (nx, ny, nz) = (n.x, n.y, n.z);
59 // Renormalizamos d al `n` unitario.
60 let len = Vec3::from_array(self.normal).length().max(1e-8);
61 let d = self.d / len;
62 // Column-major (glam): cada `Vec4` es una columna.
63 Mat4::from_cols(
64 [1.0 - 2.0 * nx * nx, -2.0 * nx * ny, -2.0 * nx * nz, 0.0].into(),
65 [-2.0 * nx * ny, 1.0 - 2.0 * ny * ny, -2.0 * ny * nz, 0.0].into(),
66 [-2.0 * nx * nz, -2.0 * ny * nz, 1.0 - 2.0 * nz * nz, 0.0].into(),
67 [-2.0 * nx * d, -2.0 * ny * d, -2.0 * nz * d, 1.0].into(),
68 )
69 }
70}
71
72/// Parámetros de la superficie reflectante.
73#[derive(Clone, Copy, Debug)]
74pub struct SurfaceParams {
75 /// Amplitud de la distorsión por ondas (en uv de pantalla). `0` = espejo liso.
76 pub ripple_strength: f32,
77 /// Frecuencia espacial de las ondas (en unidades de mundo).
78 pub ripple_scale: f32,
79 /// Exponente Fresnel: más alto = la reflexión sólo aparece muy rasante.
80 pub fresnel_power: f32,
81 /// Reflectividad máxima (a incidencia rasante). `1` = espejo; `<1` mezcla tinte.
82 pub reflectivity: f32,
83 /// Reflexión mínima mirando de frente (`0`..`reflectivity`).
84 pub base_reflect: f32,
85 /// Color/tinte de la superficie donde no refleja (rgb) + opacidad (a).
86 pub tint: [f32; 4],
87}
88
89impl Default for SurfaceParams {
90 fn default() -> Self {
91 Self {
92 ripple_strength: 0.012,
93 ripple_scale: 1.6,
94 fresnel_power: 4.0,
95 reflectivity: 0.9,
96 base_reflect: 0.08,
97 tint: [0.04, 0.10, 0.16, 1.0],
98 }
99 }
100}
101
102/// Render target de reflexión (color+depth) + su bind group para muestrearlo.
103struct ReflTarget {
104 w: u32,
105 h: u32,
106 color_view: wgpu::TextureView,
107 depth_view: wgpu::TextureView,
108 tex_bg: wgpu::BindGroup,
109}
110
111/// Reflexión planar reutilizable: target de reflexión + superficie reflectante.
112pub struct PlanarReflection {
113 tex_layout: wgpu::BindGroupLayout,
114 sampler: wgpu::Sampler,
115 target: Option<ReflTarget>,
116 // Superficie:
117 surface_pipeline: wgpu::RenderPipeline,
118 surface_uniform: wgpu::Buffer,
119 surface_uniform_bg: wgpu::BindGroup,
120 surface_quad: Option<wgpu::Buffer>,
121}
122
123impl PlanarReflection {
124 /// Crea el sistema para el `color_format` del target final.
125 pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
126 let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
127 label: Some("llimphi-3d-refl-tex-layout"),
128 entries: &[
129 wgpu::BindGroupLayoutEntry {
130 binding: 0,
131 visibility: wgpu::ShaderStages::FRAGMENT,
132 ty: wgpu::BindingType::Texture {
133 sample_type: wgpu::TextureSampleType::Float { filterable: true },
134 view_dimension: wgpu::TextureViewDimension::D2,
135 multisampled: false,
136 },
137 count: None,
138 },
139 wgpu::BindGroupLayoutEntry {
140 binding: 1,
141 visibility: wgpu::ShaderStages::FRAGMENT,
142 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
143 count: None,
144 },
145 ],
146 });
147 let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
148 label: Some("llimphi-3d-refl-uniform-layout"),
149 entries: &[wgpu::BindGroupLayoutEntry {
150 binding: 0,
151 visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
152 ty: wgpu::BindingType::Buffer {
153 ty: wgpu::BufferBindingType::Uniform,
154 has_dynamic_offset: false,
155 min_binding_size: None,
156 },
157 count: None,
158 }],
159 });
160 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
161 label: Some("llimphi-3d-refl-sampler"),
162 address_mode_u: wgpu::AddressMode::ClampToEdge,
163 address_mode_v: wgpu::AddressMode::ClampToEdge,
164 address_mode_w: wgpu::AddressMode::ClampToEdge,
165 mag_filter: wgpu::FilterMode::Linear,
166 min_filter: wgpu::FilterMode::Linear,
167 mipmap_filter: wgpu::MipmapFilterMode::Linear,
168 ..Default::default()
169 });
170
171 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
172 label: Some("llimphi-3d-refl-surface-shader"),
173 source: wgpu::ShaderSource::Wgsl(SURFACE_WGSL.into()),
174 });
175 let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
176 label: Some("llimphi-3d-refl-surface-pl"),
177 bind_group_layouts: &[Some(&uniform_layout), Some(&tex_layout)],
178 immediate_size: 0,
179 });
180 let surface_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
181 label: Some("llimphi-3d-refl-surface-pipeline"),
182 layout: Some(&pl),
183 vertex: wgpu::VertexState {
184 module: &shader,
185 entry_point: Some("vs"),
186 compilation_options: Default::default(),
187 buffers: &[wgpu::VertexBufferLayout {
188 array_stride: 12,
189 step_mode: wgpu::VertexStepMode::Vertex,
190 attributes: &[wgpu::VertexAttribute {
191 format: wgpu::VertexFormat::Float32x3,
192 offset: 0,
193 shader_location: 0,
194 }],
195 }],
196 },
197 primitive: wgpu::PrimitiveState {
198 cull_mode: None,
199 ..Default::default()
200 },
201 depth_stencil: Some(wgpu::DepthStencilState {
202 format: DEPTH_FORMAT,
203 depth_write_enabled: Some(true),
204 depth_compare: Some(wgpu::CompareFunction::Less),
205 stencil: Default::default(),
206 bias: Default::default(),
207 }),
208 multisample: Default::default(),
209 fragment: Some(wgpu::FragmentState {
210 module: &shader,
211 entry_point: Some("fs"),
212 compilation_options: Default::default(),
213 targets: &[Some(wgpu::ColorTargetState {
214 format: color_format,
215 blend: None,
216 write_mask: wgpu::ColorWrites::ALL,
217 })],
218 }),
219 multiview_mask: None,
220 cache: None,
221 });
222
223 let surface_uniform = device.create_buffer(&wgpu::BufferDescriptor {
224 label: Some("llimphi-3d-refl-surface-uniform"),
225 size: SURFACE_UNIFORM_SIZE as u64,
226 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
227 mapped_at_creation: false,
228 });
229 let surface_uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
230 label: Some("llimphi-3d-refl-surface-uniform-bg"),
231 layout: &uniform_layout,
232 entries: &[wgpu::BindGroupEntry {
233 binding: 0,
234 resource: surface_uniform.as_entire_binding(),
235 }],
236 });
237
238 Self {
239 tex_layout,
240 sampler,
241 target: None,
242 surface_pipeline,
243 surface_uniform,
244 surface_uniform_bg,
245 surface_quad: None,
246 }
247 }
248
249 /// Asegura el render target de reflexión a `(w,h)` (lo recrea al cambiar).
250 pub fn prepare(&mut self, device: &wgpu::Device, (w, h): (u32, u32)) {
251 if w == 0 || h == 0 {
252 return;
253 }
254 if matches!(&self.target, Some(t) if t.w == w && t.h == h) {
255 return;
256 }
257 let color = device.create_texture(&wgpu::TextureDescriptor {
258 label: Some("llimphi-3d-refl-color"),
259 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
260 mip_level_count: 1,
261 sample_count: 1,
262 dimension: wgpu::TextureDimension::D2,
263 format: wgpu::TextureFormat::Rgba8Unorm,
264 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
265 view_formats: &[],
266 });
267 let color_view = color.create_view(&Default::default());
268 let depth = device.create_texture(&wgpu::TextureDescriptor {
269 label: Some("llimphi-3d-refl-depth"),
270 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
271 mip_level_count: 1,
272 sample_count: 1,
273 dimension: wgpu::TextureDimension::D2,
274 format: DEPTH_FORMAT,
275 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
276 view_formats: &[],
277 });
278 let depth_view = depth.create_view(&Default::default());
279 let tex_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
280 label: Some("llimphi-3d-refl-tex-bg"),
281 layout: &self.tex_layout,
282 entries: &[
283 wgpu::BindGroupEntry {
284 binding: 0,
285 resource: wgpu::BindingResource::TextureView(&color_view),
286 },
287 wgpu::BindGroupEntry {
288 binding: 1,
289 resource: wgpu::BindingResource::Sampler(&self.sampler),
290 },
291 ],
292 });
293 self.target = Some(ReflTarget { w, h, color_view, depth_view, tex_bg });
294 }
295
296 /// Abre el pase de reflexión (color limpiado a `clear` + depth limpio) y
297 /// devuelve el [`wgpu::RenderPass`] para que el caller rinda el mundo con la
298 /// `view_proj · plane.mirror()`. Requiere [`Self::prepare`]. Soltar el pase
299 /// antes de muestrear la textura.
300 pub fn reflection_pass<'p>(
301 &'p self,
302 encoder: &'p mut wgpu::CommandEncoder,
303 clear: wgpu::Color,
304 ) -> wgpu::RenderPass<'p> {
305 let t = self
306 .target
307 .as_ref()
308 .expect("PlanarReflection::reflection_pass sin prepare()");
309 encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
310 label: Some("llimphi-3d-refl-pass"),
311 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
312 view: &t.color_view,
313 resolve_target: None,
314 depth_slice: None,
315 ops: wgpu::Operations {
316 load: wgpu::LoadOp::Clear(clear),
317 store: wgpu::StoreOp::Store,
318 },
319 })],
320 depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
321 view: &t.depth_view,
322 depth_ops: Some(wgpu::Operations {
323 load: wgpu::LoadOp::Clear(1.0),
324 store: wgpu::StoreOp::Store,
325 }),
326 stencil_ops: None,
327 }),
328 timestamp_writes: None,
329 occlusion_query_set: None,
330 multiview_mask: None,
331 })
332 }
333
334 /// Define el quad de la superficie reflectante por sus 4 esquinas en mundo
335 /// (orden CCW: por ejemplo `[bl, br, tr, tl]`). Se dibuja como 2 triángulos.
336 pub fn set_surface_quad(&mut self, device: &wgpu::Device, corners: [[f32; 3]; 4]) {
337 let idx = [0usize, 1, 2, 0, 2, 3];
338 let mut bytes = Vec::with_capacity(6 * 12);
339 for &i in &idx {
340 for v in corners[i] {
341 bytes.extend_from_slice(&v.to_ne_bytes());
342 }
343 }
344 let buf = device.create_buffer(&wgpu::BufferDescriptor {
345 label: Some("llimphi-3d-refl-surface-quad"),
346 size: bytes.len() as u64,
347 usage: wgpu::BufferUsages::VERTEX,
348 mapped_at_creation: true,
349 });
350 buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
351 buf.unmap();
352 self.surface_quad = Some(buf);
353 }
354
355 /// Sube el uniform de la superficie. `view_proj` es la cámara real (no la
356 /// reflejada), `eye` la posición del ojo, `time` para animar las ondas,
357 /// `screen` el tamaño del target final en px.
358 #[allow(clippy::too_many_arguments)]
359 pub fn upload_surface(
360 &self,
361 queue: &wgpu::Queue,
362 view_proj: &Mat4,
363 plane: &ReflectionPlane,
364 eye: &[f32; 3],
365 params: &SurfaceParams,
366 screen: (u32, u32),
367 time: f32,
368 ) {
369 let n = Vec3::from_array(plane.normal).normalize_or_zero();
370 let len = Vec3::from_array(plane.normal).length().max(1e-8);
371 let d = plane.d / len;
372 let mut b = Vec::with_capacity(SURFACE_UNIFORM_SIZE);
373 for v in view_proj.to_cols_array() {
374 b.extend_from_slice(&v.to_ne_bytes());
375 }
376 for v in [eye[0], eye[1], eye[2], time] {
377 b.extend_from_slice(&v.to_ne_bytes());
378 }
379 for v in [n.x, n.y, n.z, d] {
380 b.extend_from_slice(&v.to_ne_bytes());
381 }
382 for v in [
383 params.ripple_strength,
384 params.ripple_scale,
385 params.fresnel_power,
386 params.reflectivity,
387 ] {
388 b.extend_from_slice(&v.to_ne_bytes());
389 }
390 for v in params.tint {
391 b.extend_from_slice(&v.to_ne_bytes());
392 }
393 for v in [screen.0 as f32, screen.1 as f32, params.base_reflect, 0.0] {
394 b.extend_from_slice(&v.to_ne_bytes());
395 }
396 debug_assert_eq!(b.len(), SURFACE_UNIFORM_SIZE);
397 queue.write_buffer(&self.surface_uniform, 0, &b);
398 }
399
400 /// Dibuja la superficie reflectante en un pase **ya abierto** (con depth),
401 /// muestreando la textura de reflexión. Requiere `prepare` + `set_surface_quad`
402 /// + `upload_surface`. No-op si falta algo.
403 pub fn draw_surface<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
404 let (Some(t), Some(quad)) = (self.target.as_ref(), self.surface_quad.as_ref()) else {
405 return;
406 };
407 pass.set_pipeline(&self.surface_pipeline);
408 pass.set_bind_group(0, &self.surface_uniform_bg, &[]);
409 pass.set_bind_group(1, &t.tex_bg, &[]);
410 pass.set_vertex_buffer(0, quad.slice(..));
411 pass.draw(0..6, 0..1);
412 }
413}
414
415/// Uniform de la superficie: `view_proj`(64) + `eye_time`(16) + `plane`(16) +
416/// `params`(16) + `tint`(16) + `screen_basereflect`(16).
417const SURFACE_UNIFORM_SIZE: usize = 64 + 16 * 5;
418
419/// Superficie reflectante: muestrea la textura de reflexión en screen-space,
420/// con ondas procedurales + Fresnel + tinte.
421const SURFACE_WGSL: &str = r#"
422struct U {
423 view_proj: mat4x4<f32>,
424 eye_time: vec4<f32>, // xyz=ojo, w=time
425 plane: vec4<f32>, // xyz=normal, w=d
426 params: vec4<f32>, // ripple_strength, ripple_scale, fresnel_power, reflectivity
427 tint: vec4<f32>, // rgb=color base, a=opacidad
428 screen: vec4<f32>, // w, h, base_reflect, _
429};
430@group(0) @binding(0) var<uniform> u: U;
431@group(1) @binding(0) var refl: texture_2d<f32>;
432@group(1) @binding(1) var samp: sampler;
433
434struct VOut {
435 @builtin(position) clip: vec4<f32>,
436 @location(0) world: vec3<f32>,
437};
438
439@vertex
440fn vs(@location(0) pos: vec3<f32>) -> VOut {
441 var o: VOut;
442 o.clip = u.view_proj * vec4<f32>(pos, 1.0);
443 o.world = pos;
444 return o;
445}
446
447@fragment
448fn fs(in: VOut) -> @location(0) vec4<f32> {
449 // uv de pantalla a partir del builtin position (px, origen arriba-izq).
450 var suv = in.clip.xy / vec2<f32>(u.screen.x, u.screen.y);
451 // Ondas: distorsión sinusoidal cruzada en función de la posición de mundo.
452 let t = u.eye_time.w;
453 let sc = u.params.y;
454 let ripple = vec2<f32>(
455 sin(in.world.x * sc + t) + cos(in.world.z * sc * 1.3 - t * 0.7),
456 cos(in.world.z * sc + t * 1.1) + sin(in.world.x * sc * 0.9 + t),
457 ) * u.params.x;
458 suv = clamp(suv + ripple, vec2<f32>(0.0), vec2<f32>(1.0));
459 let reflected = textureSample(refl, samp, suv).rgb;
460
461 // Fresnel: más reflexión a incidencia rasante.
462 let n = normalize(u.plane.xyz);
463 let view_dir = normalize(u.eye_time.xyz - in.world);
464 let f = pow(1.0 - clamp(dot(view_dir, n), 0.0, 1.0), u.params.z);
465 let amount = clamp(u.screen.z + f * u.params.w, 0.0, 1.0);
466
467 let col = mix(u.tint.rgb, reflected, amount);
468 return vec4<f32>(col, 1.0);
469}
470"#;
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn shader_wgsl_valida() {
478 let module = naga::front::wgsl::parse_str(SURFACE_WGSL).expect("SURFACE_WGSL no parsea");
479 naga::valid::Validator::new(
480 naga::valid::ValidationFlags::all(),
481 naga::valid::Capabilities::all(),
482 )
483 .validate(&module)
484 .expect("SURFACE_WGSL no valida");
485 }
486
487 #[test]
488 fn mirror_espeja_punto_sobre_plano() {
489 // Plano horizontal y=2: (0,5,0) → (0,-1,0).
490 let m = ReflectionPlane::horizontal_y(2.0).mirror();
491 let p = m.transform_point3(Vec3::new(0.0, 5.0, 0.0));
492 assert!((p - Vec3::new(0.0, -1.0, 0.0)).length() < 1e-4, "fue {p:?}");
493 // Un punto en el plano queda fijo.
494 let q = m.transform_point3(Vec3::new(3.0, 2.0, -1.0));
495 assert!((q - Vec3::new(3.0, 2.0, -1.0)).length() < 1e-4, "fue {q:?}");
496 }
497
498 #[test]
499 fn mirror_z_equivale_a_doom() {
500 // El reflect_across_z de Doom: T(z)·S(-1z)·T(-z). Comparamos en z=3.
501 let m = ReflectionPlane::horizontal_z(3.0).mirror();
502 let p = m.transform_point3(Vec3::new(1.0, 2.0, 7.0));
503 assert!((p - Vec3::new(1.0, 2.0, -1.0)).length() < 1e-4, "fue {p:?}");
504 }
505
506 #[test]
507 fn uniform_size_alineado() {
508 assert_eq!(SURFACE_UNIFORM_SIZE % 16, 0);
509 }
510}