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}