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//! `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}