tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ 333dca1d5fb473825c9128d402d4cc4e2a5176fc llimphi-3d/src/sky.rs Rust · 349 líneas · 12.6 KB Historia Quién tocó qué crudo

feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director 333dca1d5 · 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×). Acá 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/// Parámetros del cielo por frame. `yaw`/`pitch`/`fov_x` salen de la cámara;
23/// el resto modela cómo se mapea la textura.
24#[derive(Clone, Copy, Debug)]
25pub struct SkyParams {
26    /// Azimut de la cámara (rad). El centro de pantalla muestrea esta columna.
27    pub yaw: f32,
28    /// Cabeceo de la cámara (rad). Desplaza el cielo en vertical.
29    pub pitch: f32,
30    /// Campo de visión horizontal (rad). Define cuánto panorama abarca el ancho.
31    pub fov_x: f32,
32    /// Aspect ratio del viewport (w/h). Reservado para usos futuros.
33    pub aspect: f32,
34    /// Cuántas veces se repite la textura al dar la vuelta 360°. `1.0` = un
35    /// panorama completo; Doom usa `4.0` (el cielo tilea 4×).
36    pub wraps: f32,
37    /// Escala vertical de la textura sobre la pantalla. `1.0` = la textura ocupa
38    /// toda la altura; Doom usa `1.8` (franja superior + horizonte al medio).
39    pub v_scale: f32,
40    /// Cuánto desplaza el `pitch` la textura en vertical. Doom usa `0.6`.
41    pub pitch_scale: f32,
42    /// Offset vertical fijo añadido a la coordenada de textura.
43    pub v_offset: f32,
44}
45
46impl Default for SkyParams {
47    fn default() -> Self {
48        Self {
49            yaw: 0.0,
50            pitch: 0.0,
51            fov_x: std::f32::consts::FRAC_PI_2,
52            aspect: 1.0,
53            wraps: 1.0,
54            v_scale: 1.0,
55            pitch_scale: 1.0,
56            v_offset: 0.0,
57        }
58    }
59}
60
61/// Textura de cielo subida + su bind group.
62struct SkyTexture {
63    bind_group: wgpu::BindGroup,
64}
65
66/// Fondo de cielo cilíndrico reutilizable. Sin textura, [`Self::draw`] es no-op.
67pub struct SkyBackdrop {
68    pipeline: wgpu::RenderPipeline,
69    uniform_buf: wgpu::Buffer,
70    uniform_bg: wgpu::BindGroup,
71    tex_layout: wgpu::BindGroupLayout,
72    sampler: wgpu::Sampler,
73    tex: Option<SkyTexture>,
74}
75
76impl SkyBackdrop {
77    /// Crea el backdrop para el `color_format` del target. El pipeline declara
78    /// depth [`DEPTH_FORMAT`] (`Always`, sin escribir) → debe dibujarse en un
79    /// pase con depth attachment, como los de [`Scene3d`](crate::Scene3d)/
80    /// [`PostFx`](crate::PostFx).
81    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
82        let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
83            label: Some("llimphi-3d-sky-uniform-layout"),
84            entries: &[wgpu::BindGroupLayoutEntry {
85                binding: 0,
86                visibility: wgpu::ShaderStages::FRAGMENT,
87                ty: wgpu::BindingType::Buffer {
88                    ty: wgpu::BufferBindingType::Uniform,
89                    has_dynamic_offset: false,
90                    min_binding_size: None,
91                },
92                count: None,
93            }],
94        });
95        let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
96            label: Some("llimphi-3d-sky-tex-layout"),
97            entries: &[
98                wgpu::BindGroupLayoutEntry {
99                    binding: 0,
100                    visibility: wgpu::ShaderStages::FRAGMENT,
101                    ty: wgpu::BindingType::Texture {
102                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
103                        view_dimension: wgpu::TextureViewDimension::D2,
104                        multisampled: false,
105                    },
106                    count: None,
107                },
108                wgpu::BindGroupLayoutEntry {
109                    binding: 1,
110                    visibility: wgpu::ShaderStages::FRAGMENT,
111                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
112                    count: None,
113                },
114            ],
115        });
116        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
117            label: Some("llimphi-3d-sky-shader"),
118            source: wgpu::ShaderSource::Wgsl(SKY_WGSL.into()),
119        });
120        let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
121            label: Some("llimphi-3d-sky-pl"),
122            bind_group_layouts: &[&uniform_layout, &tex_layout],
123            push_constant_ranges: &[],
124        });
125        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
126            label: Some("llimphi-3d-sky-pipeline"),
127            layout: Some(&pl),
128            vertex: wgpu::VertexState {
129                module: &shader,
130                entry_point: Some("vs"),
131                compilation_options: Default::default(),
132                buffers: &[],
133            },
134            primitive: wgpu::PrimitiveState {
135                cull_mode: None,
136                ..Default::default()
137            },
138            depth_stencil: Some(wgpu::DepthStencilState {
139                format: DEPTH_FORMAT,
140                depth_write_enabled: false,
141                depth_compare: wgpu::CompareFunction::Always,
142                stencil: Default::default(),
143                bias: Default::default(),
144            }),
145            multisample: Default::default(),
146            fragment: Some(wgpu::FragmentState {
147                module: &shader,
148                entry_point: Some("fs"),
149                compilation_options: Default::default(),
150                targets: &[Some(wgpu::ColorTargetState {
151                    format: color_format,
152                    blend: None,
153                    write_mask: wgpu::ColorWrites::ALL,
154                })],
155            }),
156            multiview: None,
157            cache: None,
158        });
159
160        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
161            label: Some("llimphi-3d-sky-uniform"),
162            size: 32, // 8 × f32
163            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
164            mapped_at_creation: false,
165        });
166        let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
167            label: Some("llimphi-3d-sky-uniform-bg"),
168            layout: &uniform_layout,
169            entries: &[wgpu::BindGroupEntry {
170                binding: 0,
171                resource: uniform_buf.as_entire_binding(),
172            }],
173        });
174        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
175            label: Some("llimphi-3d-sky-sampler"),
176            // Horizontal envuelve (panorama 360°); vertical clampea (no repite
177            // el cielo hacia abajo).
178            address_mode_u: wgpu::AddressMode::Repeat,
179            address_mode_v: wgpu::AddressMode::ClampToEdge,
180            address_mode_w: wgpu::AddressMode::ClampToEdge,
181            mag_filter: wgpu::FilterMode::Linear,
182            min_filter: wgpu::FilterMode::Linear,
183            mipmap_filter: wgpu::FilterMode::Linear,
184            ..Default::default()
185        });
186
187        Self {
188            pipeline,
189            uniform_buf,
190            uniform_bg,
191            tex_layout,
192            sampler,
193            tex: None,
194        }
195    }
196
197    /// ¿Ya tiene textura cargada? Útil para subirla una sola vez.
198    pub fn has_texture(&self) -> bool {
199        self.tex.is_some()
200    }
201
202    /// Sube/reemplaza la textura del cielo (RGBA8, `w×h`, fila contigua sin
203    /// padding). `data.len()` debe ser `w*h*4`.
204    pub fn set_texture(
205        &mut self,
206        device: &wgpu::Device,
207        queue: &wgpu::Queue,
208        w: u32,
209        h: u32,
210        data: &[u8],
211    ) {
212        assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado");
213        let tex = device.create_texture(&wgpu::TextureDescriptor {
214            label: Some("llimphi-3d-sky-tex"),
215            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
216            mip_level_count: 1,
217            sample_count: 1,
218            dimension: wgpu::TextureDimension::D2,
219            format: wgpu::TextureFormat::Rgba8Unorm,
220            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
221            view_formats: &[],
222        });
223        queue.write_texture(
224            wgpu::TexelCopyTextureInfo {
225                texture: &tex,
226                mip_level: 0,
227                origin: wgpu::Origin3d::ZERO,
228                aspect: wgpu::TextureAspect::All,
229            },
230            data,
231            wgpu::TexelCopyBufferLayout {
232                offset: 0,
233                bytes_per_row: Some(w * 4),
234                rows_per_image: Some(h),
235            },
236            wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
237        );
238        let view = tex.create_view(&Default::default());
239        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
240            label: Some("llimphi-3d-sky-tex-bg"),
241            layout: &self.tex_layout,
242            entries: &[
243                wgpu::BindGroupEntry {
244                    binding: 0,
245                    resource: wgpu::BindingResource::TextureView(&view),
246                },
247                wgpu::BindGroupEntry {
248                    binding: 1,
249                    resource: wgpu::BindingResource::Sampler(&self.sampler),
250                },
251            ],
252        });
253        self.tex = Some(SkyTexture { bind_group });
254    }
255
256    /// Sube los parámetros del frame. Llamar antes de [`Self::draw`].
257    pub fn upload(&self, queue: &wgpu::Queue, p: &SkyParams) {
258        let mut b = Vec::with_capacity(32);
259        for v in [
260            p.yaw,
261            p.pitch,
262            p.fov_x,
263            p.aspect,
264            p.wraps,
265            p.v_scale,
266            p.pitch_scale,
267            p.v_offset,
268        ] {
269            b.extend_from_slice(&v.to_ne_bytes());
270        }
271        queue.write_buffer(&self.uniform_buf, 0, &b);
272    }
273
274    /// Dibuja el cielo en un pase **ya abierto** (con depth attachment), como
275    /// **primer** draw (el mundo lo tapa después). No-op si no hay textura.
276    pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
277        let Some(tex) = self.tex.as_ref() else {
278            return;
279        };
280        pass.set_pipeline(&self.pipeline);
281        pass.set_bind_group(0, &self.uniform_bg, &[]);
282        pass.set_bind_group(1, &tex.bind_group, &[]);
283        pass.draw(0..3, 0..1);
284    }
285}
286
287/// Fondo cilíndrico: muestreo de la textura por el azimut de cada columna.
288const SKY_WGSL: &str = r#"
289struct SkyU {
290    yaw: f32, pitch: f32, fov_x: f32, aspect: f32,
291    wraps: f32, v_scale: f32, pitch_scale: f32, v_offset: f32,
292};
293@group(0) @binding(0) var<uniform> s: SkyU;
294@group(1) @binding(0) var sky: texture_2d<f32>;
295@group(1) @binding(1) var samp: sampler;
296
297const PI: f32 = 3.14159265;
298
299struct SOut {
300    @builtin(position) clip: vec4<f32>,
301    @location(0) scr: vec2<f32>,   // uv de pantalla, (0,0) = arriba-izquierda
302};
303
304@vertex
305fn vs(@builtin(vertex_index) vi: u32) -> SOut {
306    var p = array<vec2<f32>, 3>(
307        vec2<f32>(-1.0, -1.0),
308        vec2<f32>( 3.0, -1.0),
309        vec2<f32>(-1.0,  3.0),
310    );
311    var o: SOut;
312    let c = p[vi];
313    o.clip = vec4<f32>(c, 0.0, 1.0);
314    o.scr = vec2<f32>(c.x * 0.5 + 0.5, 1.0 - (c.y * 0.5 + 0.5));
315    return o;
316}
317
318@fragment
319fn fs(in: SOut) -> @location(0) vec4<f32> {
320    // Azimut de la columna: centro = yaw, bordes ± fov/2.
321    let colang = s.yaw - (in.scr.x - 0.5) * s.fov_x;
322    let su = fract(colang / (2.0 * PI) * s.wraps);
323    let sv = clamp(in.scr.y * s.v_scale - s.pitch * s.pitch_scale + s.v_offset, 0.0, 1.0);
324    return textureSample(sky, samp, vec2<f32>(su, sv));
325}
326"#;
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn shader_wgsl_valida() {
334        let module = naga::front::wgsl::parse_str(SKY_WGSL).expect("SKY_WGSL no parsea");
335        naga::valid::Validator::new(
336            naga::valid::ValidationFlags::all(),
337            naga::valid::Capabilities::all(),
338        )
339        .validate(&module)
340        .expect("SKY_WGSL no valida");
341    }
342
343    #[test]
344    fn defaults_panorama_simple() {
345        let p = SkyParams::default();
346        assert_eq!(p.wraps, 1.0);
347        assert_eq!(p.v_scale, 1.0);
348    }
349}