tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director
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}