gioser

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Sutichaykuna

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc llimphi-3d/src/reflection.rs Rust · 509 siq'ikuna · 19.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

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}