Skip to main content

oximedia_gpu/
viewport.rs

1#![allow(dead_code)]
2//! Viewport and scissor-rectangle management for GPU render passes.
3
4/// The origin convention for viewport coordinates.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
6pub enum ViewportOrigin {
7    /// Y-axis points downward (Direct3D / Vulkan surface convention).
8    #[default]
9    TopLeft,
10    /// Y-axis points upward (OpenGL convention).
11    BottomLeft,
12}
13
14impl ViewportOrigin {
15    /// Returns `true` if this is the top-left (D3D/Vulkan) convention.
16    #[must_use]
17    pub fn is_top_left(&self) -> bool {
18        matches!(self, Self::TopLeft)
19    }
20}
21
22/// A rectangular viewport region mapping NDC to window coordinates.
23#[derive(Debug, Clone, PartialEq)]
24pub struct Viewport {
25    /// X offset in pixels from the window origin.
26    pub x: f32,
27    /// Y offset in pixels from the window origin.
28    pub y: f32,
29    /// Width in pixels.
30    pub width: f32,
31    /// Height in pixels.
32    pub height: f32,
33    /// Near depth range (typically 0.0).
34    pub min_depth: f32,
35    /// Far depth range (typically 1.0).
36    pub max_depth: f32,
37    /// Coordinate origin convention.
38    pub origin: ViewportOrigin,
39}
40
41impl Viewport {
42    /// Create a simple full-window viewport with standard depth range.
43    #[must_use]
44    pub fn new(width: f32, height: f32) -> Self {
45        Self {
46            x: 0.0,
47            y: 0.0,
48            width,
49            height,
50            min_depth: 0.0,
51            max_depth: 1.0,
52            origin: ViewportOrigin::TopLeft,
53        }
54    }
55
56    /// Create a viewport with an explicit offset and depth range.
57    #[must_use]
58    pub fn with_offset(
59        x: f32,
60        y: f32,
61        width: f32,
62        height: f32,
63        min_depth: f32,
64        max_depth: f32,
65    ) -> Self {
66        Self {
67            x,
68            y,
69            width,
70            height,
71            min_depth,
72            max_depth,
73            origin: ViewportOrigin::TopLeft,
74        }
75    }
76
77    /// Aspect ratio (width / height). Returns `f32::INFINITY` if height is zero.
78    #[allow(clippy::cast_precision_loss)]
79    #[must_use]
80    pub fn aspect_ratio(&self) -> f32 {
81        if self.height == 0.0 {
82            return f32::INFINITY;
83        }
84        self.width / self.height
85    }
86
87    /// Returns `true` if the viewport is taller than it is wide.
88    #[must_use]
89    pub fn is_portrait(&self) -> bool {
90        self.height > self.width
91    }
92
93    /// Returns `true` if the viewport is wider than it is tall.
94    #[must_use]
95    pub fn is_landscape(&self) -> bool {
96        self.width > self.height
97    }
98
99    /// Returns `true` if the depth range is valid (min < max, both in 0..=1).
100    #[must_use]
101    pub fn has_valid_depth_range(&self) -> bool {
102        self.min_depth >= 0.0 && self.max_depth <= 1.0 && self.min_depth < self.max_depth
103    }
104}
105
106/// A scissor rectangle that clips rasterisation to a pixel-aligned region.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
108pub struct ViewportScissor {
109    /// Left edge in pixels.
110    pub left: i32,
111    /// Top edge in pixels.
112    pub top: i32,
113    /// Width in pixels.
114    pub width: u32,
115    /// Height in pixels.
116    pub height: u32,
117}
118
119impl ViewportScissor {
120    /// Create a scissor from pixel coordinates.
121    #[must_use]
122    pub fn new(left: i32, top: i32, width: u32, height: u32) -> Self {
123        Self {
124            left,
125            top,
126            width,
127            height,
128        }
129    }
130
131    /// Returns `true` if the given pixel coordinate is inside this scissor region.
132    #[must_use]
133    pub fn clips(&self, px: i32, py: i32) -> bool {
134        px >= self.left
135            && py >= self.top
136            && px < self.left + self.width as i32
137            && py < self.top + self.height as i32
138    }
139
140    /// Area of the scissor rectangle in pixels.
141    #[must_use]
142    pub fn area(&self) -> u64 {
143        u64::from(self.width) * u64::from(self.height)
144    }
145
146    /// Returns `true` if the scissor has non-zero area.
147    #[must_use]
148    pub fn is_non_empty(&self) -> bool {
149        self.width > 0 && self.height > 0
150    }
151}
152
153/// A stack of viewports for managing nested render passes.
154#[derive(Debug, Default)]
155pub struct ViewportStack {
156    stack: Vec<Viewport>,
157}
158
159impl ViewportStack {
160    /// Create an empty stack.
161    #[must_use]
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Push a viewport onto the stack.
167    pub fn push(&mut self, viewport: Viewport) {
168        self.stack.push(viewport);
169    }
170
171    /// Pop the top viewport from the stack. Returns `None` if the stack is empty.
172    pub fn pop(&mut self) -> Option<Viewport> {
173        self.stack.pop()
174    }
175
176    /// Borrow the current (top-of-stack) viewport without removing it.
177    #[must_use]
178    pub fn current(&self) -> Option<&Viewport> {
179        self.stack.last()
180    }
181
182    /// Stack depth.
183    #[must_use]
184    pub fn depth(&self) -> usize {
185        self.stack.len()
186    }
187
188    /// Returns `true` when there are no viewports on the stack.
189    #[must_use]
190    pub fn is_empty(&self) -> bool {
191        self.stack.is_empty()
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_viewport_origin_is_top_left() {
201        assert!(ViewportOrigin::TopLeft.is_top_left());
202        assert!(!ViewportOrigin::BottomLeft.is_top_left());
203    }
204
205    #[test]
206    fn test_viewport_aspect_ratio() {
207        let vp = Viewport::new(1920.0, 1080.0);
208        let ar = vp.aspect_ratio();
209        assert!((ar - 16.0 / 9.0).abs() < 1e-4);
210    }
211
212    #[test]
213    fn test_viewport_aspect_ratio_zero_height() {
214        let vp = Viewport::new(100.0, 0.0);
215        assert_eq!(vp.aspect_ratio(), f32::INFINITY);
216    }
217
218    #[test]
219    fn test_viewport_is_portrait() {
220        let vp = Viewport::new(720.0, 1280.0);
221        assert!(vp.is_portrait());
222        assert!(!vp.is_landscape());
223    }
224
225    #[test]
226    fn test_viewport_is_landscape() {
227        let vp = Viewport::new(1920.0, 1080.0);
228        assert!(vp.is_landscape());
229        assert!(!vp.is_portrait());
230    }
231
232    #[test]
233    fn test_viewport_valid_depth_range() {
234        let vp = Viewport::new(100.0, 100.0);
235        assert!(vp.has_valid_depth_range());
236    }
237
238    #[test]
239    fn test_viewport_invalid_depth_range() {
240        let mut vp = Viewport::new(100.0, 100.0);
241        vp.min_depth = 0.9;
242        vp.max_depth = 0.5;
243        assert!(!vp.has_valid_depth_range());
244    }
245
246    #[test]
247    fn test_scissor_clips_inside() {
248        let s = ViewportScissor::new(10, 10, 100, 100);
249        assert!(s.clips(50, 50));
250    }
251
252    #[test]
253    fn test_scissor_clips_outside() {
254        let s = ViewportScissor::new(10, 10, 100, 100);
255        assert!(!s.clips(5, 50));
256        assert!(!s.clips(50, 5));
257        assert!(!s.clips(110, 50));
258    }
259
260    #[test]
261    fn test_scissor_area() {
262        let s = ViewportScissor::new(0, 0, 1920, 1080);
263        assert_eq!(s.area(), 1920 * 1080);
264    }
265
266    #[test]
267    fn test_scissor_is_non_empty() {
268        let s = ViewportScissor::new(0, 0, 10, 10);
269        assert!(s.is_non_empty());
270        let empty = ViewportScissor::new(0, 0, 0, 0);
271        assert!(!empty.is_non_empty());
272    }
273
274    #[test]
275    fn test_viewport_stack_push_pop() {
276        let mut stack = ViewportStack::new();
277        assert!(stack.is_empty());
278        stack.push(Viewport::new(800.0, 600.0));
279        stack.push(Viewport::new(400.0, 300.0));
280        assert_eq!(stack.depth(), 2);
281        let top = stack.pop().expect("stack pop should return a value");
282        assert!((top.width - 400.0).abs() < 1e-6);
283        assert_eq!(stack.depth(), 1);
284    }
285
286    #[test]
287    fn test_viewport_stack_current() {
288        let mut stack = ViewportStack::new();
289        assert!(stack.current().is_none());
290        stack.push(Viewport::new(1280.0, 720.0));
291        let cur = stack.current().expect("current should return a value");
292        assert!((cur.width - 1280.0).abs() < 1e-6);
293    }
294
295    #[test]
296    fn test_viewport_stack_pop_empty() {
297        let mut stack = ViewportStack::new();
298        assert!(stack.pop().is_none());
299    }
300}