Skip to main content

astrelis_render/
transform.rs

1//! Shared data-to-screen coordinate transformation for instanced renderers.
2//!
3//! This module provides [`DataTransform`] used by
4//! [`LineRenderer`](crate::LineRenderer), [`PointRenderer`](crate::PointRenderer),
5//! and [`QuadRenderer`](crate::QuadRenderer) to map data coordinates to screen
6//! pixels on the GPU.
7//!
8//! # How it works
9//!
10//! Data points are stored in their original coordinate space. The GPU applies:
11//! ```text
12//! screen_pos = data_pos * scale + offset
13//! clip_pos   = projection * screen_pos
14//! ```
15//!
16//! This means pan/zoom only updates a small uniform buffer (32 bytes), not
17//! all the vertex/instance data. For charts with thousands of data points,
18//! this is the key to smooth interaction.
19
20use crate::Viewport;
21use bytemuck::{Pod, Zeroable};
22
23/// Parameters describing a data range and its target plot area.
24///
25/// Used to construct a [`DataTransform`] that maps data coordinates
26/// to screen coordinates within the plot area.
27#[derive(Debug, Clone, Copy)]
28pub struct DataRangeParams {
29    /// Plot area X offset in screen pixels (from left edge of viewport).
30    pub plot_x: f32,
31    /// Plot area Y offset in screen pixels (from top edge of viewport).
32    pub plot_y: f32,
33    /// Plot area width in screen pixels.
34    pub plot_width: f32,
35    /// Plot area height in screen pixels.
36    pub plot_height: f32,
37    /// Minimum data X value.
38    pub data_x_min: f64,
39    /// Maximum data X value.
40    pub data_x_max: f64,
41    /// Minimum data Y value.
42    pub data_y_min: f64,
43    /// Maximum data Y value.
44    pub data_y_max: f64,
45}
46
47impl DataRangeParams {
48    /// Create new data range parameters.
49    pub fn new(
50        plot_x: f32,
51        plot_y: f32,
52        plot_width: f32,
53        plot_height: f32,
54        data_x_min: f64,
55        data_x_max: f64,
56        data_y_min: f64,
57        data_y_max: f64,
58    ) -> Self {
59        Self {
60            plot_x,
61            plot_y,
62            plot_width,
63            plot_height,
64            data_x_min,
65            data_x_max,
66            data_y_min,
67            data_y_max,
68        }
69    }
70}
71
72/// High-level data-to-screen transform.
73///
74/// Combines a viewport (for the projection matrix) with an optional data range
75/// mapping. When no data range is set, data coordinates equal screen coordinates
76/// (identity transform).
77///
78/// # Example
79///
80/// ```ignore
81/// // Identity transform: data coords = screen pixels
82/// let transform = DataTransform::identity(viewport);
83///
84/// // Data range transform: maps data [0..100, 0..50] to a 400x300 plot area
85/// let transform = DataTransform::from_data_range(viewport, DataRangeParams {
86///     plot_x: 80.0, plot_y: 20.0,
87///     plot_width: 400.0, plot_height: 300.0,
88///     data_x_min: 0.0, data_x_max: 100.0,
89///     data_y_min: 0.0, data_y_max: 50.0,
90/// });
91///
92/// renderer.render_transformed(pass, &transform);
93/// ```
94#[derive(Debug, Clone, Copy)]
95pub struct DataTransform {
96    uniform: TransformUniform,
97}
98
99impl DataTransform {
100    /// Create an identity transform (data coordinates = screen coordinates).
101    pub fn identity(viewport: Viewport) -> Self {
102        let logical = viewport.to_logical();
103        Self {
104            uniform: TransformUniform::identity(logical.width, logical.height),
105        }
106    }
107
108    /// Create a transform that maps data coordinates to a plot area on screen.
109    ///
110    /// Data point `(data_x, data_y)` maps to screen position:
111    /// - `screen_x = plot_x + (data_x - data_x_min) / (data_x_max - data_x_min) * plot_width`
112    /// - `screen_y = plot_y + plot_height - (data_y - data_y_min) / (data_y_max - data_y_min) * plot_height`
113    ///
114    /// Y is flipped because screen Y goes downward but data Y typically goes upward.
115    pub fn from_data_range(viewport: Viewport, params: DataRangeParams) -> Self {
116        let logical = viewport.to_logical();
117        Self {
118            uniform: TransformUniform::for_data_range(
119                logical.width,
120                logical.height,
121                params.plot_x,
122                params.plot_y,
123                params.plot_width,
124                params.plot_height,
125                params.data_x_min as f32,
126                params.data_x_max as f32,
127                params.data_y_min as f32,
128                params.data_y_max as f32,
129            ),
130        }
131    }
132
133    /// Get the GPU-ready uniform data.
134    pub(crate) fn uniform(&self) -> &TransformUniform {
135        &self.uniform
136    }
137}
138
139/// GPU uniform buffer for data-to-screen coordinate transformation.
140///
141/// Contains an orthographic projection matrix and a scale+offset transform
142/// for mapping data coordinates to screen pixels.
143///
144/// Layout (80 bytes, 16-byte aligned):
145/// ```text
146/// offset 0:  mat4x4<f32> projection  (64 bytes)
147/// offset 64: vec2<f32>   scale        (8 bytes)
148/// offset 72: vec2<f32>   offset       (8 bytes)
149/// ```
150#[repr(C)]
151#[derive(Debug, Clone, Copy, Pod, Zeroable, PartialEq)]
152pub(crate) struct TransformUniform {
153    /// Orthographic projection matrix.
154    pub(crate) projection: [[f32; 4]; 4],
155    /// Scale: `screen_pos = data_pos * scale + offset`.
156    pub(crate) scale: [f32; 2],
157    /// Offset: `screen_pos = data_pos * scale + offset`.
158    pub(crate) offset: [f32; 2],
159}
160
161impl TransformUniform {
162    /// Identity data transform (data coords = screen coords).
163    pub(crate) fn identity(viewport_width: f32, viewport_height: f32) -> Self {
164        Self {
165            projection: Self::ortho_matrix(viewport_width, viewport_height),
166            scale: [1.0, 1.0],
167            offset: [0.0, 0.0],
168        }
169    }
170
171    /// Create transform for mapping data coordinates to a plot area.
172    ///
173    /// Data point (data_x, data_y) maps to screen position:
174    /// - screen_x = plot_x + (data_x - data_x_min) / (data_x_max - data_x_min) * plot_width
175    /// - screen_y = plot_y + plot_height - (data_y - data_y_min) / (data_y_max - data_y_min) * plot_height
176    #[allow(clippy::too_many_arguments)]
177    pub(crate) fn for_data_range(
178        viewport_width: f32,
179        viewport_height: f32,
180        plot_x: f32,
181        plot_y: f32,
182        plot_width: f32,
183        plot_height: f32,
184        data_x_min: f32,
185        data_x_max: f32,
186        data_y_min: f32,
187        data_y_max: f32,
188    ) -> Self {
189        // screen = data * scale + offset
190        let scale_x = plot_width / (data_x_max - data_x_min);
191        let scale_y = -plot_height / (data_y_max - data_y_min); // Negative for Y flip
192
193        let offset_x = plot_x - data_x_min * scale_x;
194        let offset_y = plot_y + plot_height - data_y_min * scale_y;
195
196        Self {
197            projection: Self::ortho_matrix(viewport_width, viewport_height),
198            scale: [scale_x, scale_y],
199            offset: [offset_x, offset_y],
200        }
201    }
202
203    /// Create an orthographic projection matrix for the given viewport size.
204    ///
205    /// Maps (0,0) to top-left, (width, height) to bottom-right.
206    pub(crate) fn ortho_matrix(width: f32, height: f32) -> [[f32; 4]; 4] {
207        [
208            [2.0 / width, 0.0, 0.0, 0.0],
209            [0.0, -2.0 / height, 0.0, 0.0],
210            [0.0, 0.0, 1.0, 0.0],
211            [-1.0, 1.0, 0.0, 1.0],
212        ]
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use astrelis_core::geometry::{PhysicalPosition, PhysicalSize, ScaleFactor};
220
221    fn test_viewport() -> Viewport {
222        Viewport {
223            position: PhysicalPosition::new(0.0, 0.0),
224            size: PhysicalSize::new(800.0, 600.0),
225            scale_factor: ScaleFactor(1.0),
226        }
227    }
228
229    #[test]
230    fn test_identity_transform() {
231        let transform = DataTransform::identity(test_viewport());
232        let u = transform.uniform();
233        assert_eq!(u.scale, [1.0, 1.0]);
234        assert_eq!(u.offset, [0.0, 0.0]);
235    }
236
237    #[test]
238    fn test_data_range_transform() {
239        let params = DataRangeParams::new(
240            100.0, 50.0, // plot origin
241            600.0, 400.0, // plot size
242            0.0, 10.0, // data x range
243            0.0, 100.0, // data y range
244        );
245        let transform = DataTransform::from_data_range(test_viewport(), params);
246        let u = transform.uniform();
247
248        // scale_x = 600 / (10 - 0) = 60
249        assert!((u.scale[0] - 60.0).abs() < 0.001);
250        // scale_y = -400 / (100 - 0) = -4
251        assert!((u.scale[1] - (-4.0)).abs() < 0.001);
252    }
253
254    #[test]
255    fn test_ortho_matrix_dimensions() {
256        let matrix = TransformUniform::ortho_matrix(800.0, 600.0);
257        // Check that the matrix has the right scale factors
258        assert!((matrix[0][0] - 2.0 / 800.0).abs() < 0.0001);
259        assert!((matrix[1][1] - (-2.0 / 600.0)).abs() < 0.0001);
260        assert!((matrix[2][2] - 1.0).abs() < 0.0001);
261    }
262
263    #[test]
264    fn test_transform_uniform_size() {
265        // Ensure the uniform matches the expected GPU layout (80 bytes)
266        assert_eq!(std::mem::size_of::<TransformUniform>(), 80);
267    }
268}