tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc
llimphi-3d/src/reflection.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director
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. Acá:
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::FilterMode::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: &[&uniform_layout, &tex_layout],
178 push_constant_ranges: &[],
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: true,
204 depth_compare: 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: 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 })
331 }
332
333 /// Define el quad de la superficie reflectante por sus 4 esquinas en mundo
334 /// (orden CCW: por ejemplo `[bl, br, tr, tl]`). Se dibuja como 2 triángulos.
335 pub fn set_surface_quad(&mut self, device: &wgpu::Device, corners: [[f32; 3]; 4]) {
336 let idx = [0usize, 1, 2, 0, 2, 3];
337 let mut bytes = Vec::with_capacity(6 * 12);
338 for &i in &idx {
339 for v in corners[i] {
340 bytes.extend_from_slice(&v.to_ne_bytes());
341 }
342 }
343 let buf = device.create_buffer(&wgpu::BufferDescriptor {
344 label: Some("llimphi-3d-refl-surface-quad"),
345 size: bytes.len() as u64,
346 usage: wgpu::BufferUsages::VERTEX,
347 mapped_at_creation: true,
348 });
349 buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
350 buf.unmap();
351 self.surface_quad = Some(buf);
352 }
353
354 /// Sube el uniform de la superficie. `view_proj` es la cámara real (no la
355 /// reflejada), `eye` la posición del ojo, `time` para animar las ondas,
356 /// `screen` el tamaño del target final en px.
357 #[allow(clippy::too_many_arguments)]
358 pub fn upload_surface(
359 &self,
360 queue: &wgpu::Queue,
361 view_proj: &Mat4,
362 plane: &ReflectionPlane,
363 eye: &[f32; 3],
364 params: &SurfaceParams,
365 screen: (u32, u32),
366 time: f32,
367 ) {
368 let n = Vec3::from_array(plane.normal).normalize_or_zero();
369 let len = Vec3::from_array(plane.normal).length().max(1e-8);
370 let d = plane.d / len;
371 let mut b = Vec::with_capacity(SURFACE_UNIFORM_SIZE);
372 for v in view_proj.to_cols_array() {
373 b.extend_from_slice(&v.to_ne_bytes());
374 }
375 for v in [eye[0], eye[1], eye[2], time] {
376 b.extend_from_slice(&v.to_ne_bytes());
377 }
378 for v in [n.x, n.y, n.z, d] {
379 b.extend_from_slice(&v.to_ne_bytes());
380 }
381 for v in [
382 params.ripple_strength,
383 params.ripple_scale,
384 params.fresnel_power,
385 params.reflectivity,
386 ] {
387 b.extend_from_slice(&v.to_ne_bytes());
388 }
389 for v in params.tint {
390 b.extend_from_slice(&v.to_ne_bytes());
391 }
392 for v in [screen.0 as f32, screen.1 as f32, params.base_reflect, 0.0] {
393 b.extend_from_slice(&v.to_ne_bytes());
394 }
395 debug_assert_eq!(b.len(), SURFACE_UNIFORM_SIZE);
396 queue.write_buffer(&self.surface_uniform, 0, &b);
397 }
398
399 /// Dibuja la superficie reflectante en un pase **ya abierto** (con depth),
400 /// muestreando la textura de reflexión. Requiere `prepare` + `set_surface_quad`
401 /// + `upload_surface`. No-op si falta algo.
402 pub fn draw_surface<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
403 let (Some(t), Some(quad)) = (self.target.as_ref(), self.surface_quad.as_ref()) else {
404 return;
405 };
406 pass.set_pipeline(&self.surface_pipeline);
407 pass.set_bind_group(0, &self.surface_uniform_bg, &[]);
408 pass.set_bind_group(1, &t.tex_bg, &[]);
409 pass.set_vertex_buffer(0, quad.slice(..));
410 pass.draw(0..6, 0..1);
411 }
412}
413
414/// Uniform de la superficie: `view_proj`(64) + `eye_time`(16) + `plane`(16) +
415/// `params`(16) + `tint`(16) + `screen_basereflect`(16).
416const SURFACE_UNIFORM_SIZE: usize = 64 + 16 * 5;
417
418/// Superficie reflectante: muestrea la textura de reflexión en screen-space,
419/// con ondas procedurales + Fresnel + tinte.
420const SURFACE_WGSL: &str = r#"
421struct U {
422 view_proj: mat4x4<f32>,
423 eye_time: vec4<f32>, // xyz=ojo, w=time
424 plane: vec4<f32>, // xyz=normal, w=d
425 params: vec4<f32>, // ripple_strength, ripple_scale, fresnel_power, reflectivity
426 tint: vec4<f32>, // rgb=color base, a=opacidad
427 screen: vec4<f32>, // w, h, base_reflect, _
428};
429@group(0) @binding(0) var<uniform> u: U;
430@group(1) @binding(0) var refl: texture_2d<f32>;
431@group(1) @binding(1) var samp: sampler;
432
433struct VOut {
434 @builtin(position) clip: vec4<f32>,
435 @location(0) world: vec3<f32>,
436};
437
438@vertex
439fn vs(@location(0) pos: vec3<f32>) -> VOut {
440 var o: VOut;
441 o.clip = u.view_proj * vec4<f32>(pos, 1.0);
442 o.world = pos;
443 return o;
444}
445
446@fragment
447fn fs(in: VOut) -> @location(0) vec4<f32> {
448 // uv de pantalla a partir del builtin position (px, origen arriba-izq).
449 var suv = in.clip.xy / vec2<f32>(u.screen.x, u.screen.y);
450 // Ondas: distorsión sinusoidal cruzada en función de la posición de mundo.
451 let t = u.eye_time.w;
452 let sc = u.params.y;
453 let ripple = vec2<f32>(
454 sin(in.world.x * sc + t) + cos(in.world.z * sc * 1.3 - t * 0.7),
455 cos(in.world.z * sc + t * 1.1) + sin(in.world.x * sc * 0.9 + t),
456 ) * u.params.x;
457 suv = clamp(suv + ripple, vec2<f32>(0.0), vec2<f32>(1.0));
458 let reflected = textureSample(refl, samp, suv).rgb;
459
460 // Fresnel: más reflexión a incidencia rasante.
461 let n = normalize(u.plane.xyz);
462 let view_dir = normalize(u.eye_time.xyz - in.world);
463 let f = pow(1.0 - clamp(dot(view_dir, n), 0.0, 1.0), u.params.z);
464 let amount = clamp(u.screen.z + f * u.params.w, 0.0, 1.0);
465
466 let col = mix(u.tint.rgb, reflected, amount);
467 return vec4<f32>(col, 1.0);
468}
469"#;
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn shader_wgsl_valida() {
477 let module = naga::front::wgsl::parse_str(SURFACE_WGSL).expect("SURFACE_WGSL no parsea");
478 naga::valid::Validator::new(
479 naga::valid::ValidationFlags::all(),
480 naga::valid::Capabilities::all(),
481 )
482 .validate(&module)
483 .expect("SURFACE_WGSL no valida");
484 }
485
486 #[test]
487 fn mirror_espeja_punto_sobre_plano() {
488 // Plano horizontal y=2: (0,5,0) → (0,-1,0).
489 let m = ReflectionPlane::horizontal_y(2.0).mirror();
490 let p = m.transform_point3(Vec3::new(0.0, 5.0, 0.0));
491 assert!((p - Vec3::new(0.0, -1.0, 0.0)).length() < 1e-4, "fue {p:?}");
492 // Un punto en el plano queda fijo.
493 let q = m.transform_point3(Vec3::new(3.0, 2.0, -1.0));
494 assert!((q - Vec3::new(3.0, 2.0, -1.0)).length() < 1e-4, "fue {q:?}");
495 }
496
497 #[test]
498 fn mirror_z_equivale_a_doom() {
499 // El reflect_across_z de Doom: T(z)·S(-1z)·T(-z). Comparamos en z=3.
500 let m = ReflectionPlane::horizontal_z(3.0).mirror();
501 let p = m.transform_point3(Vec3::new(1.0, 2.0, 7.0));
502 assert!((p - Vec3::new(1.0, 2.0, -1.0)).length() < 1e-4, "fue {p:?}");
503 }
504
505 #[test]
506 fn uniform_size_alineado() {
507 assert_eq!(SURFACE_UNIFORM_SIZE % 16, 0);
508 }
509}