Skip to main content

ff_render/nodes/composite/
transform.rs

1//! `TransformNode` — UV-space translate / rotate / scale (GPU; CPU passthrough).
2
3#[cfg(feature = "wgpu")]
4use super::helpers::{
5    fullscreen_pipeline, linear_sampler, one_tex_sampler_uniform_bgl, pack_f32, submit_render_pass,
6};
7use crate::nodes::RenderNodeCpu;
8
9// ── TransformNode ─────────────────────────────────────────────────────────────
10
11#[cfg(feature = "wgpu")]
12struct TransformPipeline {
13    render_pipeline: wgpu::RenderPipeline,
14    bind_group_layout: wgpu::BindGroupLayout,
15    sampler: wgpu::Sampler,
16    uniform_buf: wgpu::Buffer,
17}
18
19/// Apply a 2D affine transform (translate, rotate, scale) to a texture.
20///
21/// Pixels that fall outside the [0, 1] UV range after the inverse transform
22/// are rendered as fully transparent.
23///
24/// The CPU path is a no-op (passthrough); use the GPU path for actual
25/// transformation.
26pub struct TransformNode {
27    /// UV-space translation (positive = shift right/down).
28    pub translate: [f32; 2],
29    /// Counter-clockwise rotation in radians.
30    pub rotate: f32,
31    /// Scale factors (1.0 = no change; > 1.0 = zoom in).
32    pub scale: [f32; 2],
33    #[cfg(feature = "wgpu")]
34    pipeline: std::sync::OnceLock<TransformPipeline>,
35}
36
37impl TransformNode {
38    #[must_use]
39    pub fn new(translate: [f32; 2], rotate: f32, scale: [f32; 2]) -> Self {
40        Self {
41            translate,
42            rotate,
43            scale,
44            #[cfg(feature = "wgpu")]
45            pipeline: std::sync::OnceLock::new(),
46        }
47    }
48}
49
50impl Default for TransformNode {
51    fn default() -> Self {
52        Self::new([0.0, 0.0], 0.0, [1.0, 1.0])
53    }
54}
55
56impl RenderNodeCpu for TransformNode {
57    fn process_cpu(&self, _rgba: &mut [u8], _w: u32, _h: u32) {
58        // Affine transform is not implemented in the CPU fallback path.
59    }
60}
61
62#[cfg(feature = "wgpu")]
63impl TransformNode {
64    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &TransformPipeline {
65        self.pipeline.get_or_init(|| {
66            let device = &ctx.device;
67            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
68                label: Some("Transform shader"),
69                source: wgpu::ShaderSource::Wgsl(
70                    include_str!("../../shaders/transform.wgsl").into(),
71                ),
72            });
73            let bgl = one_tex_sampler_uniform_bgl(device, "Transform");
74            let render_pipeline = fullscreen_pipeline(device, &shader, "Transform", &bgl);
75            let sampler = linear_sampler(device, "Transform");
76            // Uniform: translate[2], rotate, _pad, scale[2], _pad, _pad = 32 bytes.
77            let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
78                label: Some("Transform uniforms"),
79                size: 32,
80                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
81                mapped_at_creation: false,
82            });
83            TransformPipeline {
84                render_pipeline,
85                bind_group_layout: bgl,
86                sampler,
87                uniform_buf,
88            }
89        })
90    }
91}
92
93#[cfg(feature = "wgpu")]
94impl crate::nodes::RenderNode for TransformNode {
95    fn process(
96        &self,
97        inputs: &[&wgpu::Texture],
98        outputs: &[&wgpu::Texture],
99        ctx: &crate::context::RenderContext,
100    ) {
101        let Some(input) = inputs.first() else {
102            log::warn!("TransformNode::process called with no inputs");
103            return;
104        };
105        let Some(output) = outputs.first() else {
106            log::warn!("TransformNode::process called with no outputs");
107            return;
108        };
109        let pd = self.get_or_create_pipeline(ctx);
110
111        // Pack uniforms: translate(2), rotate(1), pad(1), scale(2), pad(2) → 8×f32 = 32 bytes.
112        let uniforms = pack_f32(&[
113            self.translate[0],
114            self.translate[1],
115            self.rotate,
116            0.0,
117            self.scale[0],
118            self.scale[1],
119            0.0,
120            0.0,
121        ]);
122        ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniforms);
123
124        let in_view = input.create_view(&wgpu::TextureViewDescriptor::default());
125        let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
126
127        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
128            label: Some("Transform BG"),
129            layout: &pd.bind_group_layout,
130            entries: &[
131                wgpu::BindGroupEntry {
132                    binding: 0,
133                    resource: wgpu::BindingResource::TextureView(&in_view),
134                },
135                wgpu::BindGroupEntry {
136                    binding: 1,
137                    resource: wgpu::BindingResource::Sampler(&pd.sampler),
138                },
139                wgpu::BindGroupEntry {
140                    binding: 2,
141                    resource: pd.uniform_buf.as_entire_binding(),
142                },
143            ],
144        });
145        submit_render_pass(
146            ctx,
147            &pd.render_pipeline,
148            &bind_group,
149            &out_view,
150            "Transform",
151        );
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::nodes::RenderNodeCpu;
159
160    #[test]
161    fn transform_node_cpu_path_should_be_passthrough() {
162        let node = TransformNode::new([0.1, 0.0], 0.0, [2.0, 2.0]);
163        let original = vec![10u8, 20, 30, 255];
164        let mut rgba = original.clone();
165        node.process_cpu(&mut rgba, 1, 1);
166        assert_eq!(rgba, original, "TransformNode CPU must be a no-op");
167    }
168
169    #[test]
170    fn transform_node_default_should_be_identity() {
171        let node = TransformNode::default();
172        assert_eq!(node.translate, [0.0, 0.0]);
173        assert_eq!(node.rotate, 0.0);
174        assert_eq!(node.scale, [1.0, 1.0]);
175    }
176}