1use super::RenderNodeCpu;
2
3#[cfg(feature = "wgpu")]
6struct CrossfadePipeline {
7 render_pipeline: wgpu::RenderPipeline,
8 bind_group_layout: wgpu::BindGroupLayout,
9 sampler: wgpu::Sampler,
10 uniform_buf: wgpu::Buffer,
11}
12
13pub struct CrossfadeNode {
23 pub factor: f32,
25 pub to_rgba: Vec<u8>,
27 pub to_width: u32,
29 pub to_height: u32,
31 #[cfg(feature = "wgpu")]
32 pipeline: std::sync::OnceLock<CrossfadePipeline>,
33}
34
35impl CrossfadeNode {
36 #[must_use]
41 pub fn new(factor: f32, to_rgba: Vec<u8>, to_width: u32, to_height: u32) -> Self {
42 Self {
43 factor,
44 to_rgba,
45 to_width,
46 to_height,
47 #[cfg(feature = "wgpu")]
48 pipeline: std::sync::OnceLock::new(),
49 }
50 }
51}
52
53impl RenderNodeCpu for CrossfadeNode {
56 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
57 fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
58 if self.to_rgba.len() != rgba.len() {
59 log::warn!(
60 "CrossfadeNode::process_cpu skipped: size mismatch from={} to={}",
61 rgba.len(),
62 self.to_rgba.len()
63 );
64 return;
65 }
66 for (src, dst) in rgba.iter_mut().zip(self.to_rgba.iter()) {
67 let blended = (1.0 - self.factor) * f32::from(*src) + self.factor * f32::from(*dst);
68 *src = (blended + 0.5) as u8;
69 }
70 }
71}
72
73#[cfg(feature = "wgpu")]
76impl CrossfadeNode {
77 #[allow(clippy::too_many_lines)]
78 fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &CrossfadePipeline {
79 self.pipeline.get_or_init(|| {
80 let device = &ctx.device;
81
82 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
83 label: Some("Crossfade shader"),
84 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/crossfade.wgsl").into()),
85 });
86
87 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
89 label: Some("Crossfade BGL"),
90 entries: &[
91 wgpu::BindGroupLayoutEntry {
92 binding: 0,
93 visibility: wgpu::ShaderStages::FRAGMENT,
94 ty: wgpu::BindingType::Texture {
95 sample_type: wgpu::TextureSampleType::Float { filterable: true },
96 view_dimension: wgpu::TextureViewDimension::D2,
97 multisampled: false,
98 },
99 count: None,
100 },
101 wgpu::BindGroupLayoutEntry {
102 binding: 1,
103 visibility: wgpu::ShaderStages::FRAGMENT,
104 ty: wgpu::BindingType::Texture {
105 sample_type: wgpu::TextureSampleType::Float { filterable: true },
106 view_dimension: wgpu::TextureViewDimension::D2,
107 multisampled: false,
108 },
109 count: None,
110 },
111 wgpu::BindGroupLayoutEntry {
112 binding: 2,
113 visibility: wgpu::ShaderStages::FRAGMENT,
114 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
115 count: None,
116 },
117 wgpu::BindGroupLayoutEntry {
118 binding: 3,
119 visibility: wgpu::ShaderStages::FRAGMENT,
120 ty: wgpu::BindingType::Buffer {
121 ty: wgpu::BufferBindingType::Uniform,
122 has_dynamic_offset: false,
123 min_binding_size: None,
124 },
125 count: None,
126 },
127 ],
128 });
129
130 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
131 label: Some("Crossfade layout"),
132 bind_group_layouts: &[Some(&bgl)],
133 immediate_size: 0,
134 });
135
136 let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
137 label: Some("Crossfade pipeline"),
138 layout: Some(&pipeline_layout),
139 vertex: wgpu::VertexState {
140 module: &shader,
141 entry_point: Some("vs_main"),
142 buffers: &[],
143 compilation_options: wgpu::PipelineCompilationOptions::default(),
144 },
145 fragment: Some(wgpu::FragmentState {
146 module: &shader,
147 entry_point: Some("fs_main"),
148 targets: &[Some(wgpu::ColorTargetState {
149 format: wgpu::TextureFormat::Rgba8Unorm,
150 blend: None,
151 write_mask: wgpu::ColorWrites::ALL,
152 })],
153 compilation_options: wgpu::PipelineCompilationOptions::default(),
154 }),
155 primitive: wgpu::PrimitiveState::default(),
156 depth_stencil: None,
157 multisample: wgpu::MultisampleState::default(),
158 multiview_mask: None,
159 cache: None,
160 });
161
162 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
163 label: Some("Crossfade sampler"),
164 address_mode_u: wgpu::AddressMode::ClampToEdge,
165 address_mode_v: wgpu::AddressMode::ClampToEdge,
166 mag_filter: wgpu::FilterMode::Linear,
167 min_filter: wgpu::FilterMode::Linear,
168 ..Default::default()
169 });
170
171 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
173 label: Some("Crossfade uniforms"),
174 size: 16,
175 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
176 mapped_at_creation: false,
177 });
178
179 CrossfadePipeline {
180 render_pipeline,
181 bind_group_layout: bgl,
182 sampler,
183 uniform_buf,
184 }
185 })
186 }
187}
188
189#[cfg(feature = "wgpu")]
190impl super::RenderNode for CrossfadeNode {
191 fn input_count(&self) -> usize {
192 2
193 }
194
195 fn process(
196 &self,
197 inputs: &[&wgpu::Texture],
198 outputs: &[&wgpu::Texture],
199 ctx: &crate::context::RenderContext,
200 ) {
201 let Some(tex_from) = inputs.first() else {
202 log::warn!("CrossfadeNode::process called with no inputs");
203 return;
204 };
205 let Some(output) = outputs.first() else {
206 log::warn!("CrossfadeNode::process called with no outputs");
207 return;
208 };
209
210 let pd = self.get_or_create_pipeline(ctx);
211
212 let uniform_bytes: Vec<u8> = [self.factor, 0.0_f32, 0.0_f32, 0.0_f32]
214 .iter()
215 .flat_map(|f| f.to_le_bytes())
216 .collect();
217 ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniform_bytes);
218
219 let to_tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
221 label: Some("Crossfade to_tex"),
222 size: wgpu::Extent3d {
223 width: self.to_width,
224 height: self.to_height,
225 depth_or_array_layers: 1,
226 },
227 mip_level_count: 1,
228 sample_count: 1,
229 dimension: wgpu::TextureDimension::D2,
230 format: wgpu::TextureFormat::Rgba8Unorm,
231 usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
232 view_formats: &[],
233 });
234 ctx.queue.write_texture(
235 wgpu::TexelCopyTextureInfo {
236 texture: &to_tex,
237 mip_level: 0,
238 origin: wgpu::Origin3d::ZERO,
239 aspect: wgpu::TextureAspect::All,
240 },
241 &self.to_rgba,
242 wgpu::TexelCopyBufferLayout {
243 offset: 0,
244 bytes_per_row: Some(self.to_width * 4),
245 rows_per_image: None,
246 },
247 wgpu::Extent3d {
248 width: self.to_width,
249 height: self.to_height,
250 depth_or_array_layers: 1,
251 },
252 );
253
254 let from_view = tex_from.create_view(&wgpu::TextureViewDescriptor::default());
255 let to_view = to_tex.create_view(&wgpu::TextureViewDescriptor::default());
256 let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
257
258 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
259 label: Some("Crossfade BG"),
260 layout: &pd.bind_group_layout,
261 entries: &[
262 wgpu::BindGroupEntry {
263 binding: 0,
264 resource: wgpu::BindingResource::TextureView(&from_view),
265 },
266 wgpu::BindGroupEntry {
267 binding: 1,
268 resource: wgpu::BindingResource::TextureView(&to_view),
269 },
270 wgpu::BindGroupEntry {
271 binding: 2,
272 resource: wgpu::BindingResource::Sampler(&pd.sampler),
273 },
274 wgpu::BindGroupEntry {
275 binding: 3,
276 resource: pd.uniform_buf.as_entire_binding(),
277 },
278 ],
279 });
280
281 let mut encoder = ctx
282 .device
283 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
284 label: Some("Crossfade pass"),
285 });
286 {
287 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
288 label: Some("Crossfade pass"),
289 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
290 view: &out_view,
291 resolve_target: None,
292 depth_slice: None,
293 ops: wgpu::Operations {
294 load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
295 store: wgpu::StoreOp::Store,
296 },
297 })],
298 depth_stencil_attachment: None,
299 timestamp_writes: None,
300 occlusion_query_set: None,
301 multiview_mask: None,
302 });
303 pass.set_pipeline(&pd.render_pipeline);
304 pass.set_bind_group(0, &bind_group, &[]);
305 pass.draw(0..6, 0..1);
306 }
307 ctx.queue.submit(std::iter::once(encoder.finish()));
308 }
309}
310
311#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn crossfade_node_factor_zero_should_return_from_frame() {
319 let to = vec![200u8, 200, 200, 255];
320 let node = CrossfadeNode::new(0.0, to, 1, 1);
321 let mut rgba = vec![50u8, 60, 70, 255];
322 let original = rgba.clone();
323 node.process_cpu(&mut rgba, 1, 1);
324 assert_eq!(rgba[0], original[0], "factor=0 must keep from-frame R");
325 }
326
327 #[test]
328 fn crossfade_node_factor_one_should_return_to_frame() {
329 let to = vec![200u8, 200, 200, 255];
330 let node = CrossfadeNode::new(1.0, to.clone(), 1, 1);
331 let mut rgba = vec![50u8, 50, 50, 255];
332 node.process_cpu(&mut rgba, 1, 1);
333 assert!(
335 (rgba[0] as i32 - 200).abs() <= 1,
336 "factor=1 must return to-frame R; got {}",
337 rgba[0]
338 );
339 }
340
341 #[test]
342 fn crossfade_node_factor_half_should_produce_arithmetic_mean() {
343 let to = vec![200u8, 200, 200, 255];
345 let node = CrossfadeNode::new(0.5, to, 1, 1);
346 let mut rgba = vec![0u8, 0, 0, 255];
347 node.process_cpu(&mut rgba, 1, 1);
348 let diff = (rgba[0] as i32 - 100).abs();
349 assert!(
350 diff <= 1,
351 "factor=0.5 must produce arithmetic mean ~100; got {}",
352 rgba[0]
353 );
354 }
355
356 #[test]
357 fn crossfade_node_size_mismatch_should_leave_rgba_unchanged() {
358 let to = vec![200u8; 8]; let node = CrossfadeNode::new(0.5, to, 2, 1);
360 let original = vec![50u8, 50, 50, 255]; let mut rgba = original.clone();
362 node.process_cpu(&mut rgba, 1, 1); assert_eq!(rgba, original, "size mismatch must leave rgba unchanged");
364 }
365}