tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-3d/src/sky.rs Rust · 406 lines · 15.6 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! `SkyBackdrop` — fondo de cielo **cilíndrico** genérico: un triángulo
2//! fullscreen que muestrea una textura de panorama por el azimut de la cámara,
3//! dibujado detrás de todo (depth `Always`, sin escribir depth) para que la
4//! geometría del mundo lo tape.
5//!
6//! Cosechado del cielo 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
7//! estaba pegado a las constantes de Doom (tileo 4×/360°, estiramiento vertical
8//! 1.8×). Aquí esos números son **parámetros** ([`SkyParams`]), así un panorama
9//! normal usa los defaults y Doom reproduce su look exacto pasando los suyos.
10//!
11//! Se dibuja **primero** en un pase que ya tiene depth attachment (el de
12//! [`Scene3d`](crate::Scene3d) o el de [`PostFx::scene_pass`](crate::PostFx)):
13//!
14//! ```ignore
15//! sky.upload(queue, &SkyParams { yaw, pitch, fov_x, aspect, ..Default::default() });
16//! // dentro del pase, ANTES de la geometría:
17//! sky.draw(&mut pass);
18//! ```
19
20use crate::scene::DEPTH_FORMAT;
21
22/// Cómo se proyecta el panorama sobre la pantalla.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum SkyMapping {
25    /// **Cilíndrico** (estilo Doom): la columna muestrea por el azimut y la fila
26    /// se desplaza linealmente con el `pitch`. Barato y exacto para una cámara
27    /// que casi no cabecea (FPS), pero **degenera** si la cámara mira muy
28    /// arriba/abajo (la textura se sale de pantalla y el borde se estira en
29    /// rayas verticales). Usa `wraps`/`v_scale`/`pitch_scale`/`v_offset`.
30    #[default]
31    Cylindrical,
32    /// **Esférico** (equirectangular): por cada píxel se reconstruye el rayo de
33    /// cámara desde `yaw`/`pitch`/`fov_x`/`aspect` y se mapea a la textura por
34    /// (azimut, elevación). Correcto a cualquier cabeceo y bloqueado al mundo
35    /// como la geometría — el cielo es una esfera de fondo, no una franja. Los
36    /// campos `wraps`/`v_scale`/`pitch_scale`/`v_offset` se ignoran.
37    Spherical,
38}
39
40/// Parámetros del cielo por frame. `yaw`/`pitch`/`fov_x` salen de la cámara;
41/// el resto modela cómo se mapea la textura.
42#[derive(Clone, Copy, Debug)]
43pub struct SkyParams {
44    /// Azimut de la cámara (rad). El centro de pantalla muestrea esta columna.
45    pub yaw: f32,
46    /// Cabeceo de la cámara (rad). Desplaza el cielo en vertical.
47    pub pitch: f32,
48    /// Campo de visión horizontal (rad). Define cuánto panorama abarca el ancho.
49    pub fov_x: f32,
50    /// Aspect ratio del viewport (w/h). Reservado para usos futuros.
51    pub aspect: f32,
52    /// Cuántas veces se repite la textura al dar la vuelta 360°. `1.0` = un
53    /// panorama completo; Doom usa `4.0` (el cielo tilea 4×).
54    pub wraps: f32,
55    /// Escala vertical de la textura sobre la pantalla. `1.0` = la textura ocupa
56    /// toda la altura; Doom usa `1.8` (franja superior + horizonte al medio).
57    pub v_scale: f32,
58    /// Cuánto desplaza el `pitch` la textura en vertical. Doom usa `0.6`.
59    pub pitch_scale: f32,
60    /// Offset vertical fijo añadido a la coordenada de textura.
61    pub v_offset: f32,
62    /// Proyección del panorama. Ver [`SkyMapping`].
63    pub mapping: SkyMapping,
64}
65
66impl Default for SkyParams {
67    fn default() -> Self {
68        Self {
69            yaw: 0.0,
70            pitch: 0.0,
71            fov_x: std::f32::consts::FRAC_PI_2,
72            aspect: 1.0,
73            wraps: 1.0,
74            v_scale: 1.0,
75            pitch_scale: 1.0,
76            v_offset: 0.0,
77            mapping: SkyMapping::Cylindrical,
78        }
79    }
80}
81
82/// Textura de cielo subida + su bind group.
83struct SkyTexture {
84    bind_group: wgpu::BindGroup,
85}
86
87/// Fondo de cielo cilíndrico reutilizable. Sin textura, [`Self::draw`] es no-op.
88pub struct SkyBackdrop {
89    pipeline: wgpu::RenderPipeline,
90    uniform_buf: wgpu::Buffer,
91    uniform_bg: wgpu::BindGroup,
92    tex_layout: wgpu::BindGroupLayout,
93    sampler: wgpu::Sampler,
94    tex: Option<SkyTexture>,
95}
96
97impl SkyBackdrop {
98    /// Crea el backdrop para el `color_format` del target. El pipeline declara
99    /// depth [`DEPTH_FORMAT`] (`Always`, sin escribir) → debe dibujarse en un
100    /// pase con depth attachment, como los de [`Scene3d`](crate::Scene3d)/
101    /// [`PostFx`](crate::PostFx).
102    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
103        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
104            label: Some("llimphi-3d-sky-uniform-layout"),
105            entries: &[wgpu::BindGroupLayoutEntry {
106                binding: 0,
107                visibility: wgpu::ShaderStages::FRAGMENT,
108                ty: wgpu::BindingType::Buffer {
109                    ty: wgpu::BufferBindingType::Uniform,
110                    has_dynamic_offset: false,
111                    min_binding_size: None,
112                },
113                count: None,
114            }],
115        });
116        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
117            label: Some("llimphi-3d-sky-tex-layout"),
118            entries: &[
119                wgpu::BindGroupLayoutEntry {
120                    binding: 0,
121                    visibility: wgpu::ShaderStages::FRAGMENT,
122                    ty: wgpu::BindingType::Texture {
123                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
124                        view_dimension: wgpu::TextureViewDimension::D2,
125                        multisampled: false,
126                    },
127                    count: None,
128                },
129                wgpu::BindGroupLayoutEntry {
130                    binding: 1,
131                    visibility: wgpu::ShaderStages::FRAGMENT,
132                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
133                    count: None,
134                },
135            ],
136        });
137        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
138            label: Some("llimphi-3d-sky-shader"),
139            source: wgpu::ShaderSource::Wgsl(SKY_WGSL.into()),
140        });
141        let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
142            label: Some("llimphi-3d-sky-pl"),
143            bind_group_layouts: &[Some(&uniform_layout), Some(&tex_layout)],
144            immediate_size: 0,
145        });
146        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
147            label: Some("llimphi-3d-sky-pipeline"),
148            layout: Some(&pl),
149            vertex: wgpu::VertexState {
150                module: &shader,
151                entry_point: Some("vs"),
152                compilation_options: Default::default(),
153                buffers: &[],
154            },
155            primitive: wgpu::PrimitiveState {
156                cull_mode: None,
157                ..Default::default()
158            },
159            depth_stencil: Some(wgpu::DepthStencilState {
160                format: DEPTH_FORMAT,
161                depth_write_enabled: Some(false),
162                depth_compare: Some(wgpu::CompareFunction::Always),
163                stencil: Default::default(),
164                bias: Default::default(),
165            }),
166            multisample: Default::default(),
167            fragment: Some(wgpu::FragmentState {
168                module: &shader,
169                entry_point: Some("fs"),
170                compilation_options: Default::default(),
171                targets: &[Some(wgpu::ColorTargetState {
172                    format: color_format,
173                    blend: None,
174                    write_mask: wgpu::ColorWrites::ALL,
175                })],
176            }),
177            multiview_mask: None,
178            cache: None,
179        });
180
181        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
182            label: Some("llimphi-3d-sky-uniform"),
183            size: 48, // 9 × f32 redondeado a múltiplo de 16
184            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
185            mapped_at_creation: false,
186        });
187        let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
188            label: Some("llimphi-3d-sky-uniform-bg"),
189            layout: &uniform_layout,
190            entries: &[wgpu::BindGroupEntry {
191                binding: 0,
192                resource: uniform_buf.as_entire_binding(),
193            }],
194        });
195        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
196            label: Some("llimphi-3d-sky-sampler"),
197            // Horizontal envuelve (panorama 360°); vertical clampea (no repite
198            // el cielo hacia abajo).
199            address_mode_u: wgpu::AddressMode::Repeat,
200            address_mode_v: wgpu::AddressMode::ClampToEdge,
201            address_mode_w: wgpu::AddressMode::ClampToEdge,
202            mag_filter: wgpu::FilterMode::Linear,
203            min_filter: wgpu::FilterMode::Linear,
204            mipmap_filter: wgpu::MipmapFilterMode::Linear,
205            ..Default::default()
206        });
207
208        Self {
209            pipeline,
210            uniform_buf,
211            uniform_bg,
212            tex_layout,
213            sampler,
214            tex: None,
215        }
216    }
217
218    /// ¿Ya tiene textura cargada? Útil para subirla una sola vez.
219    pub fn has_texture(&self) -> bool {
220        self.tex.is_some()
221    }
222
223    /// Sube/reemplaza la textura del cielo (RGBA8, `w×h`, fila contigua sin
224    /// padding). `data.len()` debe ser `w*h*4`.
225    pub fn set_texture(
226        &mut self,
227        device: &wgpu::Device,
228        queue: &wgpu::Queue,
229        w: u32,
230        h: u32,
231        data: &[u8],
232    ) {
233        assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado");
234        let tex = device.create_texture(&wgpu::TextureDescriptor {
235            label: Some("llimphi-3d-sky-tex"),
236            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
237            mip_level_count: 1,
238            sample_count: 1,
239            dimension: wgpu::TextureDimension::D2,
240            format: wgpu::TextureFormat::Rgba8Unorm,
241            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
242            view_formats: &[],
243        });
244        queue.write_texture(
245            wgpu::TexelCopyTextureInfo {
246                texture: &tex,
247                mip_level: 0,
248                origin: wgpu::Origin3d::ZERO,
249                aspect: wgpu::TextureAspect::All,
250            },
251            data,
252            wgpu::TexelCopyBufferLayout {
253                offset: 0,
254                bytes_per_row: Some(w * 4),
255                rows_per_image: Some(h),
256            },
257            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
258        );
259        let view = tex.create_view(&Default::default());
260        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
261            label: Some("llimphi-3d-sky-tex-bg"),
262            layout: &self.tex_layout,
263            entries: &[
264                wgpu::BindGroupEntry {
265                    binding: 0,
266                    resource: wgpu::BindingResource::TextureView(&view),
267                },
268                wgpu::BindGroupEntry {
269                    binding: 1,
270                    resource: wgpu::BindingResource::Sampler(&self.sampler),
271                },
272            ],
273        });
274        self.tex = Some(SkyTexture { bind_group });
275    }
276
277    /// Sube los parámetros del frame. Llamar antes de [`Self::draw`].
278    pub fn upload(&self, queue: &wgpu::Queue, p: &SkyParams) {
279        let mode = match p.mapping {
280            SkyMapping::Cylindrical => 0.0,
281            SkyMapping::Spherical => 1.0,
282        };
283        let mut b = Vec::with_capacity(48);
284        for v in [
285            p.yaw,
286            p.pitch,
287            p.fov_x,
288            p.aspect,
289            p.wraps,
290            p.v_scale,
291            p.pitch_scale,
292            p.v_offset,
293            mode,
294            0.0, // pad a múltiplo de 16
295            0.0,
296            0.0,
297        ] {
298            b.extend_from_slice(&v.to_ne_bytes());
299        }
300        queue.write_buffer(&self.uniform_buf, 0, &b);
301    }
302
303    /// Dibuja el cielo en un pase **ya abierto** (con depth attachment), como
304    /// **primer** draw (el mundo lo tapa después). No-op si no hay textura.
305    pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
306        let Some(tex) = self.tex.as_ref() else {
307            return;
308        };
309        pass.set_pipeline(&self.pipeline);
310        pass.set_bind_group(0, &self.uniform_bg, &[]);
311        pass.set_bind_group(1, &tex.bind_group, &[]);
312        pass.draw(0..3, 0..1);
313    }
314}
315
316/// Fondo cilíndrico: muestreo de la textura por el azimut de cada columna.
317const SKY_WGSL: &str = r#"
318struct SkyU {
319    yaw: f32, pitch: f32, fov_x: f32, aspect: f32,
320    wraps: f32, v_scale: f32, pitch_scale: f32, v_offset: f32,
321    mode: f32, _pad0: f32, _pad1: f32, _pad2: f32,
322};
323@group(0) @binding(0) var<uniform> s: SkyU;
324@group(1) @binding(0) var sky: texture_2d<f32>;
325@group(1) @binding(1) var samp: sampler;
326
327const PI: f32 = 3.14159265;
328
329struct SOut {
330    @builtin(position) clip: vec4<f32>,
331    @location(0) scr: vec2<f32>,   // uv de pantalla, (0,0) = arriba-izquierda
332};
333
334@vertex
335fn vs(@builtin(vertex_index) vi: u32) -> SOut {
336    var p = array<vec2<f32>, 3>(
337        vec2<f32>(-1.0, -1.0),
338        vec2<f32>( 3.0, -1.0),
339        vec2<f32>(-1.0,  3.0),
340    );
341    var o: SOut;
342    let c = p[vi];
343    o.clip = vec4<f32>(c, 0.0, 1.0);
344    o.scr = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
345    return o;
346}
347
348@fragment
349fn fs(in: SOut) -> @location(0) vec4<f32> {
350    if (s.mode > 0.5) {
351        // ---- Esférico (equirectangular): reconstruir el rayo de cámara ----
352        // Base de cámara igual a Camera3d::orbit(target=0): el ojo está en
353        // (cp·sy, sp, cp·cy)·dist y mira al origen, up=+Y.
354        let sy = sin(s.yaw);  let cy = cos(s.yaw);
355        let sp = sin(s.pitch); let cp = cos(s.pitch);
356        let zaxis = normalize(vec3<f32>(cp * sy, sp, cp * cy)); // ojo→origen invertido
357        let xaxis = normalize(cross(vec3<f32>(0.0, 1.0, 0.0), zaxis));
358        let yaxis = cross(zaxis, xaxis);
359        let tan_x = tan(s.fov_x * 0.5);
360        let tan_y = tan_x / s.aspect;
361        let ndc_x = in.scr.x * 2.0 - 1.0;
362        let ndc_y = 1.0 - in.scr.y * 2.0; // arriba = +1
363        // La cámara mira hacia -zaxis (look_at_rh).
364        let dir = normalize(ndc_x * tan_x * xaxis + ndc_y * tan_y * yaxis - zaxis);
365        let az = atan2(dir.x, dir.z);                 // -PI..PI
366        let el = asin(clamp(dir.y, -1.0, 1.0));       // -PI/2..PI/2
367        let u = az / (2.0 * PI) + 0.5;
368        let v = 0.5 - el / PI;                        // arriba(+el) → v=0
369        let col = textureSample(sky, samp, vec2<f32>(u, v));
370        // El equirectangular tiene una singularidad en los polos (cenit/nadir):
371        // todas las columnas colapsan a una fila y la textura se abre en abanico
372        // ("starburst"). Atenuamos el muestreo a oscuro cerca del polo para que
373        // se disuelva en cielo profundo en vez de reventar en rayas radiales.
374        let pole = smoothstep(0.78, 0.995, abs(dir.y));
375        return col * (1.0 - pole);
376    }
377    // ---- Cilíndrico (Doom): azimut por columna, fila por pitch lineal ----
378    let colang = s.yaw - (in.scr.x - 0.5) * s.fov_x;
379    let su = fract(colang / (2.0 * PI) * s.wraps);
380    let sv = clamp(in.scr.y * s.v_scale - s.pitch * s.pitch_scale + s.v_offset, 0.0, 1.0);
381    return textureSample(sky, samp, vec2<f32>(su, sv));
382}
383"#;
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn shader_wgsl_valida() {
391        let module = naga::front::wgsl::parse_str(SKY_WGSL).expect("SKY_WGSL no parsea");
392        naga::valid::Validator::new(
393            naga::valid::ValidationFlags::all(),
394            naga::valid::Capabilities::all(),
395        )
396        .validate(&module)
397        .expect("SKY_WGSL no valida");
398    }
399
400    #[test]
401    fn defaults_panorama_simple() {
402        let p = SkyParams::default();
403        assert_eq!(p.wraps, 1.0);
404        assert_eq!(p.v_scale, 1.0);
405    }
406}