Skip to main content

ff_render/compositor/
mod.rs

1#[cfg(feature = "wgpu")]
2mod compositor_inner;
3
4use ff_format::VideoFrame;
5
6use crate::nodes::BlendMode;
7
8// ── LayerTransform ────────────────────────────────────────────────────────────
9
10/// 2D affine transform parameters for a compositor layer.
11///
12/// All values use UV-space coordinates where 0.0 is no change. The default
13/// (identity) transform leaves the layer centred and unscaled.
14#[derive(Debug, Clone)]
15pub struct LayerTransform {
16    /// Horizontal UV-space offset (positive = shift right). Default: `0.0`.
17    pub x: f32,
18    /// Vertical UV-space offset (positive = shift down). Default: `0.0`.
19    pub y: f32,
20    /// Horizontal scale factor (`1.0` = no change). Default: `1.0`.
21    pub scale_x: f32,
22    /// Vertical scale factor (`1.0` = no change). Default: `1.0`.
23    pub scale_y: f32,
24    /// Counter-clockwise rotation in radians. Default: `0.0`.
25    pub rotation: f32,
26}
27
28impl Default for LayerTransform {
29    fn default() -> Self {
30        Self {
31            x: 0.0,
32            y: 0.0,
33            scale_x: 1.0,
34            scale_y: 1.0,
35            rotation: 0.0,
36        }
37    }
38}
39
40impl LayerTransform {
41    /// Returns `true` when this transform is the identity (no visual change).
42    #[must_use]
43    pub fn is_identity(&self) -> bool {
44        self.x.abs() < 1e-6
45            && self.y.abs() < 1e-6
46            && (self.scale_x - 1.0).abs() < 1e-6
47            && (self.scale_y - 1.0).abs() < 1e-6
48            && self.rotation.abs() < 1e-6
49    }
50}
51
52// ── FrameLayer ────────────────────────────────────────────────────────────────
53
54/// A single layer in the composition stack.
55pub struct FrameLayer {
56    /// Source video frame (uploaded to GPU by [`Compositor`]).
57    pub frame: VideoFrame,
58    /// 2D affine transform applied before compositing.
59    pub transform: LayerTransform,
60    /// Blend mode used when compositing this layer over layers below.
61    pub blend_mode: BlendMode,
62    /// Layer opacity (`0.0` = transparent, `1.0` = fully opaque).
63    pub opacity: f32,
64    /// Z-order — lower values are further back. Layers are sorted ascending
65    /// by this field before compositing.
66    pub z_order: i32,
67}
68
69// ── Compositor ────────────────────────────────────────────────────────────────
70
71/// Stateful high-level multi-layer GPU compositor.
72///
73/// Accepts a list of [`FrameLayer`]s, sorts them by [`FrameLayer::z_order`],
74/// uploads each frame to the GPU, applies per-layer transforms and blend modes,
75/// and returns the composited [`wgpu::Texture`].
76///
77/// The wgpu render pipeline is built on the first call to
78/// [`composite`](Self::composite) and reused across frames. It is rebuilt only
79/// when the number of layers changes.
80///
81/// # Thread safety
82///
83/// `Compositor` is [`Send`] and can be moved to a background thread. When
84/// multiple threads need to share a compositor, wrap it in
85/// `Arc<Mutex<Compositor>>`.
86///
87/// Requires the `wgpu` feature.
88#[cfg(feature = "wgpu")]
89pub struct Compositor {
90    ctx: std::sync::Arc<crate::context::RenderContext>,
91    width: u32,
92    height: u32,
93    graph: Option<compositor_inner::CompositorGraph>,
94    last_layer_count: usize,
95}
96
97#[cfg(feature = "wgpu")]
98impl Compositor {
99    /// Create a compositor targeting the given output resolution.
100    #[must_use]
101    pub fn new(
102        ctx: std::sync::Arc<crate::context::RenderContext>,
103        width: u32,
104        height: u32,
105    ) -> Self {
106        Self {
107            ctx,
108            width,
109            height,
110            graph: None,
111            last_layer_count: 0,
112        }
113    }
114
115    /// Composite `layers` into a single [`wgpu::Texture`].
116    ///
117    /// Layers are sorted by [`FrameLayer::z_order`] before compositing
118    /// (ascending — lowest `z_order` is the bottom layer).
119    ///
120    /// The wgpu pipeline is built on the first call and cached; it is rebuilt
121    /// only when `layers.len()` changes between calls.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`RenderError`](crate::error::RenderError) on GPU texture
126    /// creation failure, unsupported pixel format, or render failure.
127    pub fn composite(
128        &mut self,
129        layers: &mut [FrameLayer],
130    ) -> Result<wgpu::Texture, crate::error::RenderError> {
131        layers.sort_unstable_by_key(|l| l.z_order);
132
133        let need_rebuild = self.graph.is_none() || self.last_layer_count != layers.len();
134        if need_rebuild {
135            self.graph = Some(compositor_inner::CompositorGraph::build(
136                &self.ctx,
137                layers.len(),
138                self.width,
139                self.height,
140            ));
141            self.last_layer_count = layers.len();
142        }
143
144        let Some(graph) = self.graph.as_mut() else {
145            return Err(crate::error::RenderError::Composite {
146                message: "compositor graph not initialized".to_string(),
147            });
148        };
149        graph.composite(&self.ctx, layers, self.width, self.height)
150    }
151}
152
153// ── Tests ─────────────────────────────────────────────────────────────────────
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use ff_format::{PixelFormat, VideoFrame};
159
160    fn make_frame() -> VideoFrame {
161        VideoFrame::empty(2, 2, PixelFormat::Rgba).expect("test frame")
162    }
163
164    #[test]
165    fn layer_transform_default_should_be_identity() {
166        let t = LayerTransform::default();
167        assert!(
168            t.is_identity(),
169            "default LayerTransform must be the identity"
170        );
171    }
172
173    #[test]
174    fn layer_transform_nonzero_x_should_not_be_identity() {
175        let t = LayerTransform {
176            x: 0.1,
177            ..Default::default()
178        };
179        assert!(
180            !t.is_identity(),
181            "LayerTransform with non-zero x must not be identity"
182        );
183    }
184
185    #[test]
186    fn frame_layer_should_construct_with_defaults() {
187        let layer = FrameLayer {
188            frame: make_frame(),
189            transform: LayerTransform::default(),
190            blend_mode: BlendMode::Normal,
191            opacity: 1.0,
192            z_order: 0,
193        };
194        assert_eq!(layer.z_order, 0);
195        assert!((layer.opacity - 1.0).abs() < 1e-6);
196    }
197
198    #[test]
199    fn compositor_layers_should_sort_by_z_order() {
200        let mut layers = vec![
201            FrameLayer {
202                frame: make_frame(),
203                transform: LayerTransform::default(),
204                blend_mode: BlendMode::Normal,
205                opacity: 1.0,
206                z_order: 3,
207            },
208            FrameLayer {
209                frame: make_frame(),
210                transform: LayerTransform::default(),
211                blend_mode: BlendMode::Normal,
212                opacity: 1.0,
213                z_order: 1,
214            },
215            FrameLayer {
216                frame: make_frame(),
217                transform: LayerTransform::default(),
218                blend_mode: BlendMode::Normal,
219                opacity: 1.0,
220                z_order: 2,
221            },
222        ];
223        layers.sort_unstable_by_key(|l| l.z_order);
224        let z_orders: Vec<i32> = layers.iter().map(|l| l.z_order).collect();
225        assert_eq!(
226            z_orders,
227            vec![1, 2, 3],
228            "layers must sort ascending by z_order"
229        );
230    }
231
232    #[cfg(feature = "wgpu")]
233    #[test]
234    fn compositor_should_be_send() {
235        fn assert_send<T: Send>() {}
236        assert_send::<Compositor>();
237    }
238}