Skip to main content

oximedia_virtual/led/
render.rs

1//! LED wall content rendering
2//!
3//! Provides real-time rendering of content for LED walls with
4//! perspective correction and multi-panel support.
5//!
6//! Panels are rendered in parallel using Rayon.  The per-panel work
7//! (perspective transform + pixel rasterisation) is fully independent, so
8//! the final frame is deterministic and identical to the serial path.
9
10use super::{perspective::PerspectiveCorrection, LedPanel, LedWall};
11use crate::math::{Matrix4, Point3, Vector3};
12use crate::{tracking::CameraPose, Result, VirtualProductionError};
13use rayon::prelude::*;
14use serde::{Deserialize, Serialize};
15
16/// LED renderer configuration
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct LedRendererConfig {
19    /// Target frame rate
20    pub target_fps: f64,
21    /// Enable perspective correction
22    pub perspective_correction: bool,
23    /// Enable color correction
24    pub color_correction: bool,
25    /// Render quality (0.0 - 1.0)
26    pub quality: f32,
27    /// Enable motion blur
28    pub motion_blur: bool,
29}
30
31impl Default for LedRendererConfig {
32    fn default() -> Self {
33        Self {
34            target_fps: 60.0,
35            perspective_correction: true,
36            color_correction: true,
37            quality: 1.0,
38            motion_blur: false,
39        }
40    }
41}
42
43/// Render output for LED wall
44#[derive(Debug, Clone)]
45pub struct RenderOutput {
46    /// Rendered pixel data (RGB)
47    pub pixels: Vec<u8>,
48    /// Output width
49    pub width: usize,
50    /// Output height
51    pub height: usize,
52    /// Frame number
53    pub frame_number: u64,
54    /// Timestamp in nanoseconds
55    pub timestamp_ns: u64,
56}
57
58impl RenderOutput {
59    /// Create new render output
60    #[must_use]
61    pub fn new(width: usize, height: usize, frame_number: u64, timestamp_ns: u64) -> Self {
62        Self {
63            pixels: vec![0; width * height * 3],
64            width,
65            height,
66            frame_number,
67            timestamp_ns,
68        }
69    }
70
71    /// Get pixel at position
72    #[must_use]
73    pub fn get_pixel(&self, x: usize, y: usize) -> Option<[u8; 3]> {
74        if x >= self.width || y >= self.height {
75            return None;
76        }
77
78        let idx = (y * self.width + x) * 3;
79        Some([self.pixels[idx], self.pixels[idx + 1], self.pixels[idx + 2]])
80    }
81
82    /// Set pixel at position
83    pub fn set_pixel(&mut self, x: usize, y: usize, rgb: [u8; 3]) {
84        if x >= self.width || y >= self.height {
85            return;
86        }
87
88        let idx = (y * self.width + x) * 3;
89        self.pixels[idx] = rgb[0];
90        self.pixels[idx + 1] = rgb[1];
91        self.pixels[idx + 2] = rgb[2];
92    }
93}
94
95/// Per-panel rendered output carrying the panel's pixel buffer and its
96/// horizontal offset within the overall LED wall frame.
97struct PanelBuffer {
98    pixels: Vec<u8>,
99    width: usize,
100    height: usize,
101    /// Horizontal start offset in the combined output frame.
102    x_offset: usize,
103}
104
105/// LED wall renderer
106pub struct LedRenderer {
107    config: LedRendererConfig,
108    led_wall: Option<LedWall>,
109    perspective: PerspectiveCorrection,
110    frame_number: u64,
111}
112
113impl LedRenderer {
114    /// Create new LED renderer
115    pub fn new(config: LedRendererConfig) -> Result<Self> {
116        let perspective = PerspectiveCorrection::new()?;
117
118        Ok(Self {
119            config,
120            led_wall: None,
121            perspective,
122            frame_number: 0,
123        })
124    }
125
126    /// Set LED wall configuration
127    pub fn set_led_wall(&mut self, wall: LedWall) {
128        self.led_wall = Some(wall);
129    }
130
131    /// Render frame for LED wall.
132    ///
133    /// Panels are rendered in parallel using Rayon.  The projection matrix is
134    /// shared across panels and computed once before the parallel section.
135    pub fn render(
136        &mut self,
137        camera_pose: &CameraPose,
138        source_frame: &[u8],
139        source_width: usize,
140        source_height: usize,
141        timestamp_ns: u64,
142    ) -> Result<RenderOutput> {
143        let led_wall = self
144            .led_wall
145            .as_ref()
146            .ok_or_else(|| VirtualProductionError::LedWall("No LED wall configured".to_string()))?;
147
148        let (output_width, output_height) = led_wall.total_resolution();
149
150        // Clone panels for the parallel section (avoids borrow of led_wall).
151        let panels: Vec<LedPanel> = led_wall.panels.clone();
152
153        // Hoist the shared projection matrix computation out of the parallel
154        // loop.  Each panel will still build its own per-panel transform, but
155        // the camera-pose-derived view matrix is computed once.
156        let perspective_enabled = self.config.perspective_correction;
157        let perspective_config = self.perspective.config().clone();
158
159        // Compute per-panel x offsets (prefix sum of panel widths) while still
160        // in the serial section so we keep layout order deterministic.
161        let x_offsets: Vec<usize> = panels
162            .iter()
163            .scan(0usize, |acc, p| {
164                let off = *acc;
165                *acc += p.resolution.0;
166                Some(off)
167            })
168            .collect();
169
170        // Parallel render: each panel produces its own pixel buffer.
171        let panel_buffers: Result<Vec<PanelBuffer>> = panels
172            .par_iter()
173            .zip(x_offsets.par_iter())
174            .map(|(panel, &x_offset)| {
175                render_panel_pure(
176                    panel,
177                    camera_pose,
178                    source_frame,
179                    source_width,
180                    source_height,
181                    perspective_enabled,
182                    &perspective_config,
183                )
184                .map(|(pixels, w, h)| PanelBuffer {
185                    pixels,
186                    width: w,
187                    height: h,
188                    x_offset,
189                })
190            })
191            .collect();
192
193        let panel_buffers = panel_buffers?;
194
195        // Stitch panel buffers into the single output frame.
196        let mut output =
197            RenderOutput::new(output_width, output_height, self.frame_number, timestamp_ns);
198
199        for pb in &panel_buffers {
200            for y in 0..pb.height {
201                for x in 0..pb.width {
202                    let src = (y * pb.width + x) * 3;
203                    if src + 2 < pb.pixels.len() {
204                        let dst_x = pb.x_offset + x;
205                        output.set_pixel(
206                            dst_x,
207                            y,
208                            [pb.pixels[src], pb.pixels[src + 1], pb.pixels[src + 2]],
209                        );
210                    }
211                }
212            }
213        }
214
215        self.frame_number += 1;
216
217        Ok(output)
218    }
219
220    /// Get current frame number
221    #[must_use]
222    pub fn frame_number(&self) -> u64 {
223        self.frame_number
224    }
225
226    /// Reset frame counter
227    pub fn reset_frame_counter(&mut self) {
228        self.frame_number = 0;
229    }
230
231    /// Get configuration
232    #[must_use]
233    pub fn config(&self) -> &LedRendererConfig {
234        &self.config
235    }
236
237    /// Get LED wall
238    #[must_use]
239    pub fn led_wall(&self) -> Option<&LedWall> {
240        self.led_wall.as_ref()
241    }
242}
243
244/// Pure (non-mutating) per-panel render function usable from Rayon closures.
245///
246/// Returns `(pixels, width, height)` for the panel.
247fn render_panel_pure(
248    panel: &LedPanel,
249    camera_pose: &CameraPose,
250    source_frame: &[u8],
251    source_width: usize,
252    source_height: usize,
253    perspective_enabled: bool,
254    perspective_config: &super::perspective::PerspectiveCorrectionConfig,
255) -> Result<(Vec<u8>, usize, usize)> {
256    use super::perspective::PerspectiveCorrection;
257
258    let (panel_width, panel_height) = panel.resolution;
259
260    // Build perspective correction for this panel.  Each panel gets its own
261    // PerspectiveCorrection instance to avoid shared mutable state.
262    let transform = if perspective_enabled {
263        let pc = PerspectiveCorrection::with_config(perspective_config.clone())?;
264        pc.compute_transform(camera_pose, panel)?
265    } else {
266        Matrix4::identity()
267    };
268
269    let mut pixels = vec![0u8; panel_width * panel_height * 3];
270
271    for y in 0..panel_height {
272        for x in 0..panel_width {
273            // Compute world position of this pixel.
274            let pixel_x = (x as f64 / panel_width as f64) * panel.width;
275            let pixel_y = (y as f64 / panel_height as f64) * panel.height;
276
277            let world_pos = panel.position + Vector3::new(pixel_x, pixel_y, 0.0);
278
279            // Apply perspective transform.
280            let transformed = transform * world_pos.to_homogeneous();
281            let screen_pos = Point3::from_homogeneous(transformed).unwrap_or(world_pos);
282
283            // Map to source frame coordinates.
284            let src_x = ((screen_pos.x / panel.width) * source_width as f64) as usize;
285            let src_y = ((screen_pos.y / panel.height) * source_height as f64) as usize;
286
287            if src_x < source_width && src_y < source_height {
288                let src_idx = (src_y * source_width + src_x) * 3;
289                if src_idx + 2 < source_frame.len() {
290                    let dst = (y * panel_width + x) * 3;
291                    pixels[dst] = source_frame[src_idx];
292                    pixels[dst + 1] = source_frame[src_idx + 1];
293                    pixels[dst + 2] = source_frame[src_idx + 2];
294                }
295            }
296        }
297    }
298
299    Ok((pixels, panel_width, panel_height))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_render_output() {
308        let output = RenderOutput::new(1920, 1080, 0, 0);
309        assert_eq!(output.width, 1920);
310        assert_eq!(output.height, 1080);
311        assert_eq!(output.pixels.len(), 1920 * 1080 * 3);
312    }
313
314    #[test]
315    fn test_render_output_pixel() {
316        let mut output = RenderOutput::new(100, 100, 0, 0);
317        output.set_pixel(50, 50, [255, 128, 64]);
318
319        let pixel = output.get_pixel(50, 50);
320        assert_eq!(pixel, Some([255, 128, 64]));
321    }
322
323    #[test]
324    fn test_led_renderer_creation() {
325        let config = LedRendererConfig::default();
326        let renderer = LedRenderer::new(config);
327        assert!(renderer.is_ok());
328    }
329
330    #[test]
331    fn test_led_renderer_frame_counter() {
332        let config = LedRendererConfig::default();
333        let mut renderer = LedRenderer::new(config).expect("should succeed in test");
334
335        assert_eq!(renderer.frame_number(), 0);
336        renderer.reset_frame_counter();
337        assert_eq!(renderer.frame_number(), 0);
338    }
339
340    #[test]
341    fn test_led_renderer_set_wall() {
342        let config = LedRendererConfig::default();
343        let mut renderer = LedRenderer::new(config).expect("should succeed in test");
344
345        let wall = LedWall::new("Test Wall".to_string());
346        renderer.set_led_wall(wall);
347
348        assert!(renderer.led_wall().is_some());
349    }
350
351    #[test]
352    fn test_render_parallel_vs_serial_determinism() {
353        use crate::led::{LedPanel, LedWall};
354        use crate::math::Point3;
355        use crate::tracking::CameraPose;
356
357        let mut config = LedRendererConfig::default();
358        config.perspective_correction = false; // keep test simple
359
360        let mut renderer1 = LedRenderer::new(config.clone()).expect("ok");
361        let mut renderer2 = LedRenderer::new(config).expect("ok");
362
363        let mut wall1 = LedWall::new("W".to_string());
364        let mut wall2 = LedWall::new("W".to_string());
365
366        for i in 0..3 {
367            let panel = LedPanel::new(
368                Point3::new(i as f64 * 2.0, 0.0, 0.0),
369                2.0,
370                1.5,
371                (16, 12),
372                2.5,
373            );
374            wall1.add_panel(panel.clone());
375            wall2.add_panel(panel);
376        }
377
378        renderer1.set_led_wall(wall1);
379        renderer2.set_led_wall(wall2);
380
381        let pose = CameraPose::default();
382        let source = vec![128u8; 64 * 48 * 3];
383
384        let out1 = renderer1.render(&pose, &source, 64, 48, 0).expect("ok");
385        let out2 = renderer2.render(&pose, &source, 64, 48, 0).expect("ok");
386
387        assert_eq!(
388            out1.pixels, out2.pixels,
389            "parallel and serial must produce identical output"
390        );
391    }
392}