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
47/// High-level data-to-screen transform.
48///
49/// Combines a viewport (for the projection matrix) with an optional data range
50/// mapping. When no data range is set, data coordinates equal screen coordinates
51/// (identity transform).
52///
53/// # Example
54///
55/// ```ignore
56/// // Identity transform: data coords = screen pixels
57/// let transform = DataTransform::identity(viewport);
58///
59/// // Data range transform: maps data [0..100, 0..50] to a 400x300 plot area
60/// let transform = DataTransform::from_data_range(viewport, DataRangeParams {
61///     plot_x: 80.0, plot_y: 20.0,
62///     plot_width: 400.0, plot_height: 300.0,
63///     data_x_min: 0.0, data_x_max: 100.0,
64///     data_y_min: 0.0, data_y_max: 50.0,
65/// });
66///
67/// renderer.render_transformed(pass, &transform);
68/// ```
69#[derive(Debug, Clone, Copy)]
70pub struct DataTransform {
71    uniform: TransformUniform,
72}
73
74impl DataTransform {
75    /// Create an identity transform (data coordinates = screen coordinates).
76    pub fn identity(viewport: Viewport) -> Self {
77        let logical = viewport.to_logical();
78        Self {
79            uniform: TransformUniform::identity(logical.width, logical.height),
80        }
81    }
82
83    /// Create a transform that maps data coordinates to a plot area on screen.
84    ///
85    /// Data point `(data_x, data_y)` maps to screen position:
86    /// - `screen_x = plot_x + (data_x - data_x_min) / (data_x_max - data_x_min) * plot_width`
87    /// - `screen_y = plot_y + plot_height - (data_y - data_y_min) / (data_y_max - data_y_min) * plot_height`
88    ///
89    /// Y is flipped because screen Y goes downward but data Y typically goes upward.
90    pub fn from_data_range(viewport: Viewport, params: DataRangeParams) -> Self {
91        let logical = viewport.to_logical();
92        Self {
93            uniform: TransformUniform::for_data_range(
94                logical.width,
95                logical.height,
96                params.plot_x,
97                params.plot_y,
98                params.plot_width,
99                params.plot_height,
100                params.data_x_min as f32,
101                params.data_x_max as f32,
102                params.data_y_min as f32,
103                params.data_y_max as f32,
104            ),
105        }
106    }
107
108    /// Get the GPU-ready uniform data.
109    pub(crate) fn uniform(&self) -> &TransformUniform {
110        &self.uniform
111    }
112}
113
114/// GPU uniform buffer for data-to-screen coordinate transformation.
115///
116/// Contains an orthographic projection matrix and a scale+offset transform
117/// for mapping data coordinates to screen pixels.
118///
119/// Layout (80 bytes, 16-byte aligned):
120/// ```text
121/// offset 0:  mat4x4<f32> projection  (64 bytes)
122/// offset 64: vec2<f32>   scale        (8 bytes)
123/// offset 72: vec2<f32>   offset       (8 bytes)
124/// ```
125#[repr(C)]
126#[derive(Debug, Clone, Copy, Pod, Zeroable, PartialEq)]
127pub(crate) struct TransformUniform {
128    /// Orthographic projection matrix.
129    pub(crate) projection: [[f32; 4]; 4],
130    /// Scale: `screen_pos = data_pos * scale + offset`.
131    pub(crate) scale: [f32; 2],
132    /// Offset: `screen_pos = data_pos * scale + offset`.
133    pub(crate) offset: [f32; 2],
134}
135
136impl TransformUniform {
137    /// Identity data transform (data coords = screen coords).
138    pub(crate) fn identity(viewport_width: f32, viewport_height: f32) -> Self {
139        Self {
140            projection: Self::ortho_matrix(viewport_width, viewport_height),
141            scale: [1.0, 1.0],
142            offset: [0.0, 0.0],
143        }
144    }
145
146    /// Create transform for mapping data coordinates to a plot area.
147    ///
148    /// Data point (data_x, data_y) maps to screen position:
149    /// - screen_x = plot_x + (data_x - data_x_min) / (data_x_max - data_x_min) * plot_width
150    /// - screen_y = plot_y + plot_height - (data_y - data_y_min) / (data_y_max - data_y_min) * plot_height
151    #[allow(clippy::too_many_arguments)]
152    pub(crate) fn for_data_range(
153        viewport_width: f32,
154        viewport_height: f32,
155        plot_x: f32,
156        plot_y: f32,
157        plot_width: f32,
158        plot_height: f32,
159        data_x_min: f32,
160        data_x_max: f32,
161        data_y_min: f32,
162        data_y_max: f32,
163    ) -> Self {
164        // screen = data * scale + offset
165        let scale_x = plot_width / (data_x_max - data_x_min);
166        let scale_y = -plot_height / (data_y_max - data_y_min); // Negative for Y flip
167
168        let offset_x = plot_x - data_x_min * scale_x;
169        let offset_y = plot_y + plot_height - data_y_min * scale_y;
170
171        Self {
172            projection: Self::ortho_matrix(viewport_width, viewport_height),
173            scale: [scale_x, scale_y],
174            offset: [offset_x, offset_y],
175        }
176    }
177
178    /// Create an orthographic projection matrix for the given viewport size.
179    ///
180    /// Maps (0,0) to top-left, (width, height) to bottom-right.
181    pub(crate) fn ortho_matrix(width: f32, height: f32) -> [[f32; 4]; 4] {
182        [
183            [2.0 / width, 0.0, 0.0, 0.0],
184            [0.0, -2.0 / height, 0.0, 0.0],
185            [0.0, 0.0, 1.0, 0.0],
186            [-1.0, 1.0, 0.0, 1.0],
187        ]
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use astrelis_core::geometry::{PhysicalPosition, PhysicalSize, ScaleFactor};
195
196    fn test_viewport() -> Viewport {
197        Viewport {
198            position: PhysicalPosition::new(0.0, 0.0),
199            size: PhysicalSize::new(800.0, 600.0),
200            scale_factor: ScaleFactor(1.0),
201        }
202    }
203
204    #[test]
205    fn test_identity_transform() {
206        let transform = DataTransform::identity(test_viewport());
207        let u = transform.uniform();
208        assert_eq!(u.scale, [1.0, 1.0]);
209        assert_eq!(u.offset, [0.0, 0.0]);
210    }
211
212    #[test]
213    fn test_data_range_transform() {
214        let params = DataRangeParams {
215            plot_x: 100.0,
216            plot_y: 50.0,
217            plot_width: 600.0,
218            plot_height: 400.0,
219            data_x_min: 0.0,
220            data_x_max: 10.0,
221            data_y_min: 0.0,
222            data_y_max: 100.0,
223        };
224        let transform = DataTransform::from_data_range(test_viewport(), params);
225        let u = transform.uniform();
226
227        // scale_x = 600 / (10 - 0) = 60
228        assert!((u.scale[0] - 60.0).abs() < 0.001);
229        // scale_y = -400 / (100 - 0) = -4
230        assert!((u.scale[1] - (-4.0)).abs() < 0.001);
231    }
232
233    #[test]
234    fn test_ortho_matrix_dimensions() {
235        let matrix = TransformUniform::ortho_matrix(800.0, 600.0);
236        // Check that the matrix has the right scale factors
237        assert!((matrix[0][0] - 2.0 / 800.0).abs() < 0.0001);
238        assert!((matrix[1][1] - (-2.0 / 600.0)).abs() < 0.0001);
239        assert!((matrix[2][2] - 1.0).abs() < 0.0001);
240    }
241
242    #[test]
243    fn test_transform_uniform_size() {
244        // Ensure the uniform matches the expected GPU layout (80 bytes)
245        assert_eq!(std::mem::size_of::<TransformUniform>(), 80);
246    }
247}