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}