1use super::RenderNodeCpu;
9use super::blur::separable_blur_f32;
10
11#[cfg(feature = "wgpu")]
12use super::GaussianBlurNode;
13#[cfg(feature = "wgpu")]
14use super::blur::{create_uniform, fullscreen_pipeline, run_fullscreen, texture_entry};
15
16pub struct GlowNode {
24 pub threshold: f32,
27 pub radius: f32,
30 pub intensity: f32,
32 #[cfg(feature = "wgpu")]
34 blur: GaussianBlurNode,
35 #[cfg(feature = "wgpu")]
36 pipeline: std::sync::OnceLock<GlowPipeline>,
37}
38
39impl GlowNode {
40 #[must_use]
42 pub fn new(threshold: f32, radius: f32, intensity: f32) -> Self {
43 Self {
44 threshold,
45 radius,
46 intensity,
47 #[cfg(feature = "wgpu")]
48 blur: GaussianBlurNode::new(radius),
49 #[cfg(feature = "wgpu")]
50 pipeline: std::sync::OnceLock::new(),
51 }
52 }
53}
54
55impl Default for GlowNode {
56 fn default() -> Self {
58 Self::new(0.8, 10.0, 0.0)
59 }
60}
61
62impl RenderNodeCpu for GlowNode {
65 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
66 fn process_cpu(&self, rgba: &mut [u8], w: u32, h: u32) {
67 let threshold = self.threshold.clamp(0.0, 1.0);
68
69 let mut highlights = vec![0u8; rgba.len()];
71 for (dst, px) in highlights
72 .as_chunks_mut::<4>()
73 .0
74 .iter_mut()
75 .zip(rgba.as_chunks::<4>().0)
76 {
77 let luma =
78 (0.299 * f32::from(px[0]) + 0.587 * f32::from(px[1]) + 0.114 * f32::from(px[2]))
79 / 255.0;
80 if luma >= threshold {
81 dst[0..3].copy_from_slice(&px[0..3]);
82 }
83 dst[3] = 255;
84 }
85
86 let Some(blurred) = separable_blur_f32(&highlights, w, h, self.radius) else {
88 return;
89 };
90
91 for (px, glow) in rgba
93 .as_chunks_mut::<4>()
94 .0
95 .iter_mut()
96 .zip(blurred.as_chunks::<4>().0)
97 {
98 for c in 0..3 {
99 let base = f32::from(px[c]) / 255.0;
100 let out = (base + glow[c] * self.intensity).clamp(0.0, 1.0);
101 px[c] = (out * 255.0 + 0.5) as u8;
102 }
103 }
105 }
106}
107
108#[cfg(feature = "wgpu")]
111struct GlowPipeline {
112 extract: EffectPipeline,
113 blend: EffectPipeline,
114}
115
116#[cfg(feature = "wgpu")]
117struct EffectPipeline {
118 render_pipeline: wgpu::RenderPipeline,
119 bind_group_layout: wgpu::BindGroupLayout,
120 uniform_buf: wgpu::Buffer,
121}
122
123#[cfg(feature = "wgpu")]
124impl GlowNode {
125 fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &GlowPipeline {
126 self.pipeline.get_or_init(|| GlowPipeline {
127 extract: create_extract_pipeline(ctx, self.threshold.clamp(0.0, 1.0)),
128 blend: create_blend_pipeline(ctx, self.intensity),
129 })
130 }
131}
132
133#[cfg(feature = "wgpu")]
134impl super::RenderNode for GlowNode {
135 fn pass_count(&self) -> usize {
136 3
137 }
138
139 fn process(
140 &self,
141 inputs: &[&wgpu::Texture],
142 outputs: &[&wgpu::Texture],
143 ctx: &crate::context::RenderContext,
144 ) {
145 let Some(input) = inputs.first() else {
146 log::warn!("GlowNode::process called with no inputs");
147 return;
148 };
149 if outputs.len() < 3 {
150 log::warn!("GlowNode::process needs 3 output targets");
151 return;
152 }
153 let pd = self.get_or_create_pipeline(ctx);
154
155 encode_uniform_texture_pass(ctx, &pd.extract, &[input], outputs[0], "Glow extract");
157
158 self.blur
163 .process(&[outputs[0]], &[outputs[1], outputs[0]], ctx);
164
165 encode_uniform_texture_pass(
168 ctx,
169 &pd.blend,
170 &[input, outputs[0]],
171 outputs[2],
172 "Glow blend",
173 );
174 }
175}
176
177#[cfg(feature = "wgpu")]
179fn create_extract_pipeline(ctx: &crate::context::RenderContext, threshold: f32) -> EffectPipeline {
180 let device = &ctx.device;
181 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
182 label: Some("Glow extract shader"),
183 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/glow_extract.wgsl").into()),
184 });
185 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
186 label: Some("Glow extract BGL"),
187 entries: &[texture_entry(0), uniform_entry(1)],
188 });
189 let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Glow extract");
190 let uniform_buf = create_uniform(device, "Glow extract uniforms", 16);
191 ctx.queue
192 .write_buffer(&uniform_buf, 0, &pack_scalar(threshold));
193 EffectPipeline {
194 render_pipeline,
195 bind_group_layout: bgl,
196 uniform_buf,
197 }
198}
199
200#[cfg(feature = "wgpu")]
202fn create_blend_pipeline(ctx: &crate::context::RenderContext, intensity: f32) -> EffectPipeline {
203 let device = &ctx.device;
204 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
205 label: Some("Glow blend shader"),
206 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/glow_blend.wgsl").into()),
207 });
208 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
209 label: Some("Glow blend BGL"),
210 entries: &[texture_entry(0), texture_entry(1), uniform_entry(2)],
211 });
212 let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Glow blend");
213 let uniform_buf = create_uniform(device, "Glow blend uniforms", 16);
214 ctx.queue
215 .write_buffer(&uniform_buf, 0, &pack_scalar(intensity));
216 EffectPipeline {
217 render_pipeline,
218 bind_group_layout: bgl,
219 uniform_buf,
220 }
221}
222
223#[cfg(feature = "wgpu")]
224fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
225 wgpu::BindGroupLayoutEntry {
226 binding,
227 visibility: wgpu::ShaderStages::FRAGMENT,
228 ty: wgpu::BindingType::Buffer {
229 ty: wgpu::BufferBindingType::Uniform,
230 has_dynamic_offset: false,
231 min_binding_size: None,
232 },
233 count: None,
234 }
235}
236
237#[cfg(feature = "wgpu")]
239fn pack_scalar(v: f32) -> [u8; 16] {
240 let mut b = [0u8; 16];
241 b[0..4].copy_from_slice(&v.to_le_bytes());
242 b
243}
244
245#[cfg(feature = "wgpu")]
249#[allow(clippy::cast_possible_truncation)]
250fn encode_uniform_texture_pass(
251 ctx: &crate::context::RenderContext,
252 pd: &EffectPipeline,
253 textures: &[&wgpu::Texture],
254 output: &wgpu::Texture,
255 label: &str,
256) {
257 let views: Vec<wgpu::TextureView> = textures
258 .iter()
259 .map(|t| t.create_view(&wgpu::TextureViewDescriptor::default()))
260 .collect();
261 let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
262
263 let mut entries: Vec<wgpu::BindGroupEntry> = views
264 .iter()
265 .enumerate()
266 .map(|(i, view)| wgpu::BindGroupEntry {
267 binding: i as u32,
268 resource: wgpu::BindingResource::TextureView(view),
269 })
270 .collect();
271 entries.push(wgpu::BindGroupEntry {
272 binding: views.len() as u32,
273 resource: pd.uniform_buf.as_entire_binding(),
274 });
275
276 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
277 label: Some(label),
278 layout: &pd.bind_group_layout,
279 entries: &entries,
280 });
281 run_fullscreen(ctx, &pd.render_pipeline, &bind_group, &output_view, label);
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn white_rect(
291 w: usize,
292 h: usize,
293 x0: usize,
294 x1: usize,
295 y0: usize,
296 y1: usize,
297 v: u8,
298 ) -> Vec<u8> {
299 let mut buf = vec![0u8; w * h * 4];
300 for (i, px) in buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
301 let x = i % w;
302 let y = i / w;
303 px[3] = 255;
304 if x >= x0 && x < x1 && y >= y0 && y < y1 {
305 px[0] = v;
306 px[1] = v;
307 px[2] = v;
308 }
309 }
310 buf
311 }
312
313 #[test]
314 fn glow_should_produce_halo_beyond_edge() {
315 let (w, h) = (48usize, 48usize);
317 let mut frame = white_rect(w, h, 16, 32, 16, 32, 255);
318 GlowNode::new(0.8, 10.0, 1.0).process_cpu(&mut frame, w as u32, h as u32);
319 let p = (24 * w + 37) * 4;
321 assert!(
322 frame[p] > 0,
323 "glow halo must extend >= 5 px beyond the edge; got {}",
324 frame[p]
325 );
326 }
327
328 #[test]
329 fn glow_intensity_zero_should_be_noop() {
330 let (w, h) = (32usize, 32usize);
331 let original = white_rect(w, h, 8, 24, 8, 24, 255);
332 let mut frame = original.clone();
333 GlowNode::new(0.8, 10.0, 0.0).process_cpu(&mut frame, w as u32, h as u32);
334 for (a, b) in frame.iter().zip(original.iter()) {
335 assert!(
336 (i32::from(*a) - i32::from(*b)).abs() <= 1,
337 "intensity 0 must be a no-op (within rounding); got {a} vs {b}"
338 );
339 }
340 }
341
342 #[test]
343 fn glow_high_threshold_should_suppress() {
344 let (w, h) = (32usize, 32usize);
347 let original = white_rect(w, h, 8, 24, 8, 24, 200);
348 let mut frame = original.clone();
349 GlowNode::new(1.1, 10.0, 1.0).process_cpu(&mut frame, w as u32, h as u32);
350 for (a, b) in frame.iter().zip(original.iter()) {
351 assert!(
352 (i32::from(*a) - i32::from(*b)).abs() <= 1,
353 "threshold above the brightest luma must suppress all glow; got {a} vs {b}"
354 );
355 }
356 }
357}
358
359#[cfg(all(test, feature = "wgpu"))]
360mod gpu_tests {
361 use super::*;
362 use crate::context::RenderContext;
363 use crate::graph::RenderGraph;
364 use std::sync::Arc;
365
366 fn ctx() -> Option<Arc<RenderContext>> {
367 match futures::executor::block_on(RenderContext::init()) {
368 Ok(ctx) => Some(Arc::new(ctx)),
369 Err(_) => None,
370 }
371 }
372
373 fn white_rect(
374 w: usize,
375 h: usize,
376 x0: usize,
377 x1: usize,
378 y0: usize,
379 y1: usize,
380 v: u8,
381 ) -> Vec<u8> {
382 let mut buf = vec![0u8; w * h * 4];
383 for (i, px) in buf.as_chunks_mut::<4>().0.iter_mut().enumerate() {
384 let x = i % w;
385 let y = i / w;
386 px[3] = 255;
387 if x >= x0 && x < x1 && y >= y0 && y < y1 {
388 px[0] = v;
389 px[1] = v;
390 px[2] = v;
391 }
392 }
393 buf
394 }
395
396 #[test]
397 fn glow_gpu_should_produce_halo_beyond_edge() {
398 let Some(ctx) = ctx() else {
399 return;
400 };
401 let (w, h) = (48u32, 48u32);
402 let frame = white_rect(48, 48, 16, 32, 16, 32, 255);
403 let gpu = RenderGraph::new(Arc::clone(&ctx))
404 .push(GlowNode::new(0.8, 10.0, 1.0))
405 .process_gpu(&frame, w, h)
406 .expect("gpu glow");
407 let p = (24 * 48 + 37) * 4;
408 assert!(
409 gpu[p] > 0,
410 "GPU glow halo must extend >= 5 px beyond the edge; got {}",
411 gpu[p]
412 );
413 }
414
415 #[test]
416 fn glow_gpu_intensity_zero_should_preserve_input() {
417 let Some(ctx) = ctx() else {
418 return;
419 };
420 let (w, h) = (32u32, 32u32);
421 let frame = white_rect(32, 32, 8, 24, 8, 24, 255);
422 let gpu = RenderGraph::new(Arc::clone(&ctx))
423 .push(GlowNode::new(0.8, 10.0, 0.0))
424 .process_gpu(&frame, w, h)
425 .expect("gpu glow");
426 for &idx in &[(16 * 32 + 16) * 4, (2 * 32 + 2) * 4] {
428 assert!(
429 (i32::from(gpu[idx]) - i32::from(frame[idx])).abs() <= 1,
430 "intensity 0 must preserve the input on the GPU path"
431 );
432 }
433 }
434}