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#[cfg(test)]
154mod tests {
155    use super::*;
156    use ff_format::{PixelFormat, VideoFrame};
157
158    fn make_frame() -> VideoFrame {
159        VideoFrame::empty(2, 2, PixelFormat::Rgba).expect("test frame")
160    }
161
162    #[test]
163    fn layer_transform_default_should_be_identity() {
164        let t = LayerTransform::default();
165        assert!(
166            t.is_identity(),
167            "default LayerTransform must be the identity"
168        );
169    }
170
171    #[test]
172    fn layer_transform_nonzero_x_should_not_be_identity() {
173        let t = LayerTransform {
174            x: 0.1,
175            ..Default::default()
176        };
177        assert!(
178            !t.is_identity(),
179            "LayerTransform with non-zero x must not be identity"
180        );
181    }
182
183    #[test]
184    fn frame_layer_should_construct_with_defaults() {
185        let layer = FrameLayer {
186            frame: make_frame(),
187            transform: LayerTransform::default(),
188            blend_mode: BlendMode::Normal,
189            opacity: 1.0,
190            z_order: 0,
191        };
192        assert_eq!(layer.z_order, 0);
193        assert!((layer.opacity - 1.0).abs() < 1e-6);
194    }
195
196    #[test]
197    fn compositor_layers_should_sort_by_z_order() {
198        let mut layers = vec![
199            FrameLayer {
200                frame: make_frame(),
201                transform: LayerTransform::default(),
202                blend_mode: BlendMode::Normal,
203                opacity: 1.0,
204                z_order: 3,
205            },
206            FrameLayer {
207                frame: make_frame(),
208                transform: LayerTransform::default(),
209                blend_mode: BlendMode::Normal,
210                opacity: 1.0,
211                z_order: 1,
212            },
213            FrameLayer {
214                frame: make_frame(),
215                transform: LayerTransform::default(),
216                blend_mode: BlendMode::Normal,
217                opacity: 1.0,
218                z_order: 2,
219            },
220        ];
221        layers.sort_unstable_by_key(|l| l.z_order);
222        let z_orders: Vec<i32> = layers.iter().map(|l| l.z_order).collect();
223        assert_eq!(
224            z_orders,
225            vec![1, 2, 3],
226            "layers must sort ascending by z_order"
227        );
228    }
229
230    #[cfg(feature = "wgpu")]
231    #[test]
232    fn compositor_should_be_send() {
233        fn assert_send<T: Send>() {}
234        assert_send::<Compositor>();
235    }
236}