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//! `Glows` — billboards **aditivos**: como [`Billboards`](crate::Billboards)
2//! (quads de cara a la cámara desde un atlas) pero con **blend aditivo**, sin
3//! escribir profundidad y **sin recorte de alpha** — para halos suaves y
4//! luminosos: estrellas que brillan, atmósferas, auras de cuerpos, glows que el
5//! bloom de [`PostFx`](crate::PostFx) infla. El `Billboards` opaco recorta el
6//! borde (disco nítido); éste suma luz (sin borde, se funde).
7//!
8//! Comparte el tipo de instancia [`Billboard`](crate::Billboard). Se dibuja en
9//! un pase con depth attachment, **después** de la geometría opaca (así el mundo
10//! sólido ocluye los glows que quedan detrás; los de adelante suman luz):
11//!
12//! ```ignore
13//! let mut g = Glows::new(&device, fmt);
14//! g.set_atlas(&device, &queue, w, h, &soft_radial_rgba);
15//! g.set_glows(&device, &[Billboard { .. }]);
16//! g.upload(&queue, aspect, &camera);
17//! g.draw(&mut pass);
18//! ```
19
20use crate::billboard::Billboard;
21use crate::camera::Camera3d;
22use crate::scene::DEPTH_FORMAT;
23
24struct Atlas {
25 bind_group: wgpu::BindGroup,
26}
27
28/// Renderer de glows aditivos. Misma plomería que `Billboards`, distinto blend.
29pub struct Glows {
30 pipeline: wgpu::RenderPipeline,
31 uniform_buf: wgpu::Buffer,
32 uniform_bg: wgpu::BindGroup,
33 tex_layout: wgpu::BindGroupLayout,
34 sampler: wgpu::Sampler,
35 atlas: Option<Atlas>,
36 instances: Option<wgpu::Buffer>,
37 count: u32,
38}
39
40impl Glows {
41 pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
42 let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
43 label: Some("llimphi-3d-glow-uniform-layout"),
44 entries: &[wgpu::BindGroupLayoutEntry {
45 binding: 0,
46 visibility: wgpu::ShaderStages::VERTEX,
47 ty: wgpu::BindingType::Buffer {
48 ty: wgpu::BufferBindingType::Uniform,
49 has_dynamic_offset: false,
50 min_binding_size: None,
51 },
52 count: None,
53 }],
54 });
55 let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
56 label: Some("llimphi-3d-glow-tex-layout"),
57 entries: &[
58 wgpu::BindGroupLayoutEntry {
59 binding: 0,
60 visibility: wgpu::ShaderStages::FRAGMENT,
61 ty: wgpu::BindingType::Texture {
62 sample_type: wgpu::TextureSampleType::Float { filterable: true },
63 view_dimension: wgpu::TextureViewDimension::D2,
64 multisampled: false,
65 },
66 count: None,
67 },
68 wgpu::BindGroupLayoutEntry {
69 binding: 1,
70 visibility: wgpu::ShaderStages::FRAGMENT,
71 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
72 count: None,
73 },
74 ],
75 });
76 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
77 label: Some("llimphi-3d-glow-shader"),
78 source: wgpu::ShaderSource::Wgsl(GLOW_WGSL.into()),
79 });
80 let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
81 label: Some("llimphi-3d-glow-pl"),
82 bind_group_layouts: &[Some(&uniform_layout), Some(&tex_layout)],
83 immediate_size: 0,
84 });
85 // Blend aditivo: la luz se suma al fondo (src*1 + dst*1).
86 let additive = wgpu::BlendState {
87 color: wgpu::BlendComponent {
88 src_factor: wgpu::BlendFactor::One,
89 dst_factor: wgpu::BlendFactor::One,
90 operation: wgpu::BlendOperation::Add,
91 },
92 alpha: wgpu::BlendComponent {
93 src_factor: wgpu::BlendFactor::One,
94 dst_factor: wgpu::BlendFactor::One,
95 operation: wgpu::BlendOperation::Add,
96 },
97 };
98 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
99 label: Some("llimphi-3d-glow-pipeline"),
100 layout: Some(&pl),
101 vertex: wgpu::VertexState {
102 module: &shader,
103 entry_point: Some("vs"),
104 compilation_options: Default::default(),
105 buffers: &[wgpu::VertexBufferLayout {
106 array_stride: Billboard::STRIDE as u64,
107 step_mode: wgpu::VertexStepMode::Instance,
108 attributes: &[
109 wgpu::VertexAttribute {
110 format: wgpu::VertexFormat::Float32x3,
111 offset: 0,
112 shader_location: 0,
113 },
114 wgpu::VertexAttribute {
115 format: wgpu::VertexFormat::Float32x2,
116 offset: 12,
117 shader_location: 1,
118 },
119 wgpu::VertexAttribute {
120 format: wgpu::VertexFormat::Float32x2,
121 offset: 20,
122 shader_location: 2,
123 },
124 wgpu::VertexAttribute {
125 format: wgpu::VertexFormat::Float32x2,
126 offset: 28,
127 shader_location: 3,
128 },
129 wgpu::VertexAttribute {
130 format: wgpu::VertexFormat::Float32x4,
131 offset: 36,
132 shader_location: 4,
133 },
134 ],
135 }],
136 },
137 primitive: wgpu::PrimitiveState {
138 cull_mode: None,
139 ..Default::default()
140 },
141 depth_stencil: Some(wgpu::DepthStencilState {
142 format: DEPTH_FORMAT,
143 depth_write_enabled: Some(false),
144 depth_compare: Some(wgpu::CompareFunction::LessEqual),
145 stencil: Default::default(),
146 bias: Default::default(),
147 }),
148 multisample: Default::default(),
149 fragment: Some(wgpu::FragmentState {
150 module: &shader,
151 entry_point: Some("fs"),
152 compilation_options: Default::default(),
153 targets: &[Some(wgpu::ColorTargetState {
154 format: color_format,
155 blend: Some(additive),
156 write_mask: wgpu::ColorWrites::ALL,
157 })],
158 }),
159 multiview_mask: None,
160 cache: None,
161 });
162 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
163 label: Some("llimphi-3d-glow-uniform"),
164 size: 96, // view_proj(64) + right(16) + up(16)
165 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
166 mapped_at_creation: false,
167 });
168 let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
169 label: Some("llimphi-3d-glow-uniform-bg"),
170 layout: &uniform_layout,
171 entries: &[wgpu::BindGroupEntry {
172 binding: 0,
173 resource: uniform_buf.as_entire_binding(),
174 }],
175 });
176 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
177 label: Some("llimphi-3d-glow-sampler"),
178 address_mode_u: wgpu::AddressMode::ClampToEdge,
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::MipmapFilterMode::Nearest,
184 ..Default::default()
185 });
186 Self {
187 pipeline,
188 uniform_buf,
189 uniform_bg,
190 tex_layout,
191 sampler,
192 atlas: None,
193 instances: None,
194 count: 0,
195 }
196 }
197
198 /// Sube/reemplaza el atlas (RGBA8, `w×h`). Para glows conviene un degradé
199 /// radial suave (gaussiano), sin núcleo duro.
200 pub fn set_atlas(
201 &mut self,
202 device: &wgpu::Device,
203 queue: &wgpu::Queue,
204 w: u32,
205 h: u32,
206 data: &[u8],
207 ) {
208 assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado");
209 let tex = device.create_texture(&wgpu::TextureDescriptor {
210 label: Some("llimphi-3d-glow-atlas"),
211 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
212 mip_level_count: 1,
213 sample_count: 1,
214 dimension: wgpu::TextureDimension::D2,
215 format: wgpu::TextureFormat::Rgba8Unorm,
216 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
217 view_formats: &[],
218 });
219 queue.write_texture(
220 wgpu::TexelCopyTextureInfo {
221 texture: &tex,
222 mip_level: 0,
223 origin: wgpu::Origin3d::ZERO,
224 aspect: wgpu::TextureAspect::All,
225 },
226 data,
227 wgpu::TexelCopyBufferLayout {
228 offset: 0,
229 bytes_per_row: Some(w * 4),
230 rows_per_image: Some(h),
231 },
232 wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
233 );
234 let view = tex.create_view(&Default::default());
235 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
236 label: Some("llimphi-3d-glow-atlas-bg"),
237 layout: &self.tex_layout,
238 entries: &[
239 wgpu::BindGroupEntry {
240 binding: 0,
241 resource: wgpu::BindingResource::TextureView(&view),
242 },
243 wgpu::BindGroupEntry {
244 binding: 1,
245 resource: wgpu::BindingResource::Sampler(&self.sampler),
246 },
247 ],
248 });
249 self.atlas = Some(Atlas { bind_group });
250 }
251
252 /// Reemplaza la lista de glows (recrea el buffer de instancias). Con blend
253 /// aditivo el orden no importa (la suma es conmutativa).
254 pub fn set_glows(&mut self, device: &wgpu::Device, items: &[Billboard]) {
255 self.count = items.len() as u32;
256 if items.is_empty() {
257 self.instances = None;
258 return;
259 }
260 let mut bytes = Vec::with_capacity(items.len() * Billboard::STRIDE);
261 for b in items {
262 b.write_to(&mut bytes);
263 }
264 let buf = device.create_buffer(&wgpu::BufferDescriptor {
265 label: Some("llimphi-3d-glow-instances"),
266 size: bytes.len() as u64,
267 usage: wgpu::BufferUsages::VERTEX,
268 mapped_at_creation: true,
269 });
270 buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
271 buf.unmap();
272 self.instances = Some(buf);
273 }
274
275 /// Sube el uniform del frame: `view_proj` + ejes `right`/`up` de la cámara.
276 pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
277 let view_proj = camera.view_proj(aspect);
278 let forward = (camera.target - camera.eye).normalize_or_zero();
279 let right = forward.cross(camera.up).normalize_or_zero();
280 let up = right.cross(forward);
281 let mut b = Vec::with_capacity(96);
282 for v in view_proj.to_cols_array() {
283 b.extend_from_slice(&v.to_ne_bytes());
284 }
285 for v in [right.x, right.y, right.z, 0.0] {
286 b.extend_from_slice(&v.to_ne_bytes());
287 }
288 for v in [up.x, up.y, up.z, 0.0] {
289 b.extend_from_slice(&v.to_ne_bytes());
290 }
291 queue.write_buffer(&self.uniform_buf, 0, &b);
292 }
293
294 /// Dibuja los glows en un pase ya abierto (con depth). No-op si falta algo.
295 pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
296 let (Some(atlas), Some(inst)) = (self.atlas.as_ref(), self.instances.as_ref()) else {
297 return;
298 };
299 if self.count == 0 {
300 return;
301 }
302 pass.set_pipeline(&self.pipeline);
303 pass.set_bind_group(0, &self.uniform_bg, &[]);
304 pass.set_bind_group(1, &atlas.bind_group, &[]);
305 pass.set_vertex_buffer(0, inst.slice(..));
306 pass.draw(0..6, 0..self.count);
307 }
308}
309
310/// Quad de cara a la cámara; el fragment SUMA luz (sin discard). La salida es
311/// premultiplicada (`rgb*a`) para que el blend aditivo dé un halo suave.
312const GLOW_WGSL: &str = r#"
313struct U {
314 view_proj: mat4x4<f32>,
315 right: vec4<f32>,
316 up: vec4<f32>,
317};
318@group(0) @binding(0) var<uniform> u: U;
319@group(1) @binding(0) var tex: texture_2d<f32>;
320@group(1) @binding(1) var samp: sampler;
321
322struct VIn {
323 @location(0) center: vec3<f32>,
324 @location(1) size: vec2<f32>,
325 @location(2) uv_min: vec2<f32>,
326 @location(3) uv_max: vec2<f32>,
327 @location(4) tint: vec4<f32>,
328};
329struct VOut {
330 @builtin(position) clip: vec4<f32>,
331 @location(0) uv: vec2<f32>,
332 @location(1) tint: vec4<f32>,
333};
334
335@vertex
336fn vs(@builtin(vertex_index) vi: u32, in: VIn) -> VOut {
337 var cs = array<vec2<f32>, 6>(
338 vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
339 vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
340 );
341 let q = cs[vi];
342 let off = q - vec2<f32>(0.5, 0.5);
343 let world = in.center
344 + u.right.xyz * (off.x * in.size.x)
345 + u.up.xyz * (off.y * in.size.y);
346 var o: VOut;
347 o.clip = u.view_proj * vec4<f32>(world, 1.0);
348 o.uv = vec2<f32>(
349 mix(in.uv_min.x, in.uv_max.x, q.x),
350 mix(in.uv_min.y, in.uv_max.y, 1.0 - q.y),
351 );
352 o.tint = in.tint;
353 return o;
354}
355
356@fragment
357fn fs(in: VOut) -> @location(0) vec4<f32> {
358 let c = textureSample(tex, samp, in.uv) * in.tint;
359 let a = c.a;
360 return vec4<f32>(c.rgb * a, a);
361}
362"#;
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn glow_wgsl_valida() {
370 let module = naga::front::wgsl::parse_str(GLOW_WGSL).expect("GLOW_WGSL no parsea");
371 naga::valid::Validator::new(
372 naga::valid::ValidationFlags::all(),
373 naga::valid::Capabilities::all(),
374 )
375 .validate(&module)
376 .expect("GLOW_WGSL no valida");
377 }
378}