Skip to main content

astrelis_render/
line_renderer.rs

1//! Fast instanced line renderer with GPU-based coordinate transformation.
2//!
3//! Renders thousands of line segments efficiently using GPU instancing.
4//! Line segments are stored in data coordinates, and the GPU transforms
5//! them to screen coordinates using a transformation matrix.
6//!
7//! This means pan/zoom only updates a small uniform buffer, not all line data.
8
9use crate::capability::{GpuRequirements, RenderCapability};
10use crate::transform::{DataTransform, TransformUniform};
11use crate::{Color, GraphicsContext, Viewport};
12use astrelis_core::profiling::profile_scope;
13use bytemuck::{Pod, Zeroable};
14use glam::Vec2;
15use std::sync::Arc;
16use wgpu::util::DeviceExt;
17
18/// A line segment for batch rendering.
19///
20/// Coordinates can be in any space - use `set_data_transform()` to map
21/// them to screen coordinates.
22#[derive(Debug, Clone, Copy)]
23pub struct LineSegment {
24    pub start: Vec2,
25    pub end: Vec2,
26    pub width: f32,
27    pub color: Color,
28}
29
30impl LineSegment {
31    pub fn new(start: Vec2, end: Vec2, width: f32, color: Color) -> Self {
32        Self {
33            start,
34            end,
35            width,
36            color,
37        }
38    }
39}
40
41/// GPU instance data for a line segment.
42#[repr(C)]
43#[derive(Debug, Clone, Copy, Pod, Zeroable)]
44struct LineInstance {
45    start: [f32; 2],
46    end: [f32; 2],
47    width: f32,
48    color: [f32; 4],
49    _padding: [f32; 1],
50}
51
52impl LineInstance {
53    fn new(segment: &LineSegment) -> Self {
54        Self {
55            start: [segment.start.x, segment.start.y],
56            end: [segment.end.x, segment.end.y],
57            width: segment.width,
58            color: [
59                segment.color.r,
60                segment.color.g,
61                segment.color.b,
62                segment.color.a,
63            ],
64            _padding: [0.0],
65        }
66    }
67}
68
69impl RenderCapability for LineRenderer {
70    fn requirements() -> GpuRequirements {
71        GpuRequirements::none()
72    }
73
74    fn name() -> &'static str {
75        "LineRenderer"
76    }
77}
78
79/// Fast batched line renderer using GPU instancing.
80///
81/// Optimized for charts with large datasets. Key features:
82/// - Line segments stored in data coordinates
83/// - GPU transforms data → screen (pan/zoom is cheap)
84/// - Only rebuild instance buffer when data actually changes
85pub struct LineRenderer {
86    context: Arc<GraphicsContext>,
87    pipeline: wgpu::RenderPipeline,
88    vertex_buffer: wgpu::Buffer,
89    transform_buffer: wgpu::Buffer,
90    transform_bind_group: wgpu::BindGroup,
91    instance_buffer: Option<wgpu::Buffer>,
92    instance_count: u32,
93    /// Pending line segments
94    pending_segments: Vec<LineSegment>,
95    /// Whether segments need to be re-uploaded
96    data_dirty: bool,
97}
98
99impl LineRenderer {
100    /// Create a new line renderer with the given target texture format.
101    ///
102    /// The `target_format` must match the render target this renderer will draw into.
103    /// For window surfaces, use the format from `WindowContext::format()`.
104    pub fn new(context: Arc<GraphicsContext>, target_format: wgpu::TextureFormat) -> Self {
105        // Create transform uniform buffer
106        let transform_buffer = context.device().create_buffer(&wgpu::BufferDescriptor {
107            label: Some("Line Renderer Transform Buffer"),
108            size: std::mem::size_of::<TransformUniform>() as u64,
109            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
110            mapped_at_creation: false,
111        });
112
113        // Bind group layout
114        let bind_group_layout =
115            context
116                .device()
117                .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
118                    label: Some("Line Renderer Bind Group Layout"),
119                    entries: &[wgpu::BindGroupLayoutEntry {
120                        binding: 0,
121                        visibility: wgpu::ShaderStages::VERTEX,
122                        ty: wgpu::BindingType::Buffer {
123                            ty: wgpu::BufferBindingType::Uniform,
124                            has_dynamic_offset: false,
125                            min_binding_size: None,
126                        },
127                        count: None,
128                    }],
129                });
130
131        let transform_bind_group = context
132            .device()
133            .create_bind_group(&wgpu::BindGroupDescriptor {
134                label: Some("Line Renderer Transform Bind Group"),
135                layout: &bind_group_layout,
136                entries: &[wgpu::BindGroupEntry {
137                    binding: 0,
138                    resource: transform_buffer.as_entire_binding(),
139                }],
140            });
141
142        // Shader
143        let shader = context
144            .device()
145            .create_shader_module(wgpu::ShaderModuleDescriptor {
146                label: Some("Line Renderer Shader"),
147                source: wgpu::ShaderSource::Wgsl(LINE_SHADER.into()),
148            });
149
150        // Pipeline
151        let pipeline_layout =
152            context
153                .device()
154                .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
155                    label: Some("Line Renderer Pipeline Layout"),
156                    bind_group_layouts: &[&bind_group_layout],
157                    push_constant_ranges: &[],
158                });
159
160        let pipeline = context
161            .device()
162            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
163                label: Some("Line Renderer Pipeline"),
164                layout: Some(&pipeline_layout),
165                vertex: wgpu::VertexState {
166                    module: &shader,
167                    entry_point: Some("vs_main"),
168                    buffers: &[
169                        // Unit quad vertices
170                        wgpu::VertexBufferLayout {
171                            array_stride: 8,
172                            step_mode: wgpu::VertexStepMode::Vertex,
173                            attributes: &[wgpu::VertexAttribute {
174                                format: wgpu::VertexFormat::Float32x2,
175                                offset: 0,
176                                shader_location: 0,
177                            }],
178                        },
179                        // Line instances
180                        wgpu::VertexBufferLayout {
181                            array_stride: std::mem::size_of::<LineInstance>() as u64,
182                            step_mode: wgpu::VertexStepMode::Instance,
183                            attributes: &[
184                                wgpu::VertexAttribute {
185                                    format: wgpu::VertexFormat::Float32x2,
186                                    offset: 0,
187                                    shader_location: 1,
188                                },
189                                wgpu::VertexAttribute {
190                                    format: wgpu::VertexFormat::Float32x2,
191                                    offset: 8,
192                                    shader_location: 2,
193                                },
194                                wgpu::VertexAttribute {
195                                    format: wgpu::VertexFormat::Float32,
196                                    offset: 16,
197                                    shader_location: 3,
198                                },
199                                wgpu::VertexAttribute {
200                                    format: wgpu::VertexFormat::Float32x4,
201                                    offset: 20,
202                                    shader_location: 4,
203                                },
204                            ],
205                        },
206                    ],
207                    compilation_options: wgpu::PipelineCompilationOptions::default(),
208                },
209                fragment: Some(wgpu::FragmentState {
210                    module: &shader,
211                    entry_point: Some("fs_main"),
212                    targets: &[Some(wgpu::ColorTargetState {
213                        format: target_format,
214                        blend: Some(wgpu::BlendState::ALPHA_BLENDING),
215                        write_mask: wgpu::ColorWrites::ALL,
216                    })],
217                    compilation_options: wgpu::PipelineCompilationOptions::default(),
218                }),
219                primitive: wgpu::PrimitiveState {
220                    topology: wgpu::PrimitiveTopology::TriangleStrip,
221                    cull_mode: None,
222                    ..Default::default()
223                },
224                depth_stencil: None,
225                multisample: wgpu::MultisampleState::default(),
226                multiview: None,
227                cache: None,
228            });
229
230        // Unit quad
231        let quad_vertices: [[f32; 2]; 4] = [[-0.5, -0.5], [0.5, -0.5], [-0.5, 0.5], [0.5, 0.5]];
232
233        let vertex_buffer =
234            context
235                .device()
236                .create_buffer_init(&wgpu::util::BufferInitDescriptor {
237                    label: Some("Line Renderer Vertex Buffer"),
238                    contents: bytemuck::cast_slice(&quad_vertices),
239                    usage: wgpu::BufferUsages::VERTEX,
240                });
241
242        Self {
243            context,
244            pipeline,
245            vertex_buffer,
246            transform_buffer,
247            transform_bind_group,
248            instance_buffer: None,
249            instance_count: 0,
250            pending_segments: Vec::with_capacity(1024),
251            data_dirty: false,
252        }
253    }
254
255    /// Clear all line segments. Call this when data changes.
256    pub fn clear(&mut self) {
257        self.pending_segments.clear();
258        self.data_dirty = true;
259    }
260
261    /// Add a line segment.
262    #[inline]
263    pub fn add_line(&mut self, start: Vec2, end: Vec2, width: f32, color: Color) {
264        self.pending_segments
265            .push(LineSegment::new(start, end, width, color));
266        self.data_dirty = true;
267    }
268
269    /// Add a line segment.
270    #[inline]
271    pub fn add_segment(&mut self, segment: LineSegment) {
272        self.pending_segments.push(segment);
273        self.data_dirty = true;
274    }
275
276    /// Get the number of line segments.
277    pub fn segment_count(&self) -> usize {
278        self.pending_segments.len()
279    }
280
281    /// Prepare GPU buffers. Only uploads data if it changed.
282    pub fn prepare(&mut self) {
283        profile_scope!("line_renderer_prepare");
284
285        if !self.data_dirty {
286            return; // No data change, skip upload
287        }
288
289        if self.pending_segments.is_empty() {
290            self.instance_buffer = None;
291            self.instance_count = 0;
292            self.data_dirty = false;
293            return;
294        }
295
296        tracing::trace!(
297            "Uploading {} line segments to GPU",
298            self.pending_segments.len()
299        );
300
301        // Convert to GPU format
302        let instances: Vec<LineInstance> = {
303            profile_scope!("convert_instances");
304            self.pending_segments
305                .iter()
306                .map(LineInstance::new)
307                .collect()
308        };
309
310        // Create buffer
311        {
312            profile_scope!("create_instance_buffer");
313            self.instance_buffer = Some(self.context.device().create_buffer_init(
314                &wgpu::util::BufferInitDescriptor {
315                    label: Some("Line Renderer Instance Buffer"),
316                    contents: bytemuck::cast_slice(&instances),
317                    usage: wgpu::BufferUsages::VERTEX,
318                },
319            ));
320        }
321
322        self.instance_count = self.pending_segments.len() as u32;
323        self.data_dirty = false;
324    }
325
326    /// Render lines with identity transform (data coords = screen coords).
327    pub fn render(&self, pass: &mut wgpu::RenderPass, viewport: Viewport) {
328        let transform = DataTransform::identity(viewport);
329        self.render_transformed(pass, &transform);
330    }
331
332    /// Render lines with a [`DataTransform`].
333    ///
334    /// This is the preferred method for rendering with data-to-screen mapping.
335    /// The transform is cheap to update (32 bytes), so pan/zoom only updates
336    /// the transform, not the line data.
337    ///
338    /// # Example
339    ///
340    /// ```ignore
341    /// let transform = DataTransform::from_data_range(viewport, DataRangeParams {
342    ///     plot_x: 80.0, plot_y: 20.0,
343    ///     plot_width: 600.0, plot_height: 400.0,
344    ///     data_x_min: 0.0, data_x_max: 100.0,
345    ///     data_y_min: 0.0, data_y_max: 50.0,
346    /// });
347    /// line_renderer.render_transformed(pass, &transform);
348    /// ```
349    pub fn render_transformed(&self, pass: &mut wgpu::RenderPass, transform: &DataTransform) {
350        self.render_with_uniform(pass, transform.uniform());
351    }
352
353    /// Render with a specific transform uniform.
354    fn render_with_uniform(&self, pass: &mut wgpu::RenderPass, transform: &TransformUniform) {
355        profile_scope!("line_renderer_render");
356
357        if self.instance_count == 0 {
358            return;
359        }
360
361        let Some(instance_buffer) = &self.instance_buffer else {
362            return;
363        };
364
365        // Upload transform
366        self.context.queue().write_buffer(
367            &self.transform_buffer,
368            0,
369            bytemuck::cast_slice(&[*transform]),
370        );
371
372        // Draw
373        pass.push_debug_group("LineRenderer::render");
374        pass.set_pipeline(&self.pipeline);
375        pass.set_bind_group(0, &self.transform_bind_group, &[]);
376        pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
377        pass.set_vertex_buffer(1, instance_buffer.slice(..));
378        pass.draw(0..4, 0..self.instance_count);
379        pass.pop_debug_group();
380    }
381}
382
383/// WGSL shader with data coordinate transformation.
384const LINE_SHADER: &str = r#"
385struct Transform {
386    projection: mat4x4<f32>,
387    scale: vec2<f32>,
388    offset: vec2<f32>,
389}
390
391@group(0) @binding(0)
392var<uniform> transform: Transform;
393
394struct VertexInput {
395    @location(0) quad_pos: vec2<f32>,
396    @location(1) line_start: vec2<f32>,
397    @location(2) line_end: vec2<f32>,
398    @location(3) line_width: f32,
399    @location(4) color: vec4<f32>,
400}
401
402struct VertexOutput {
403    @builtin(position) position: vec4<f32>,
404    @location(0) color: vec4<f32>,
405}
406
407@vertex
408fn vs_main(input: VertexInput) -> VertexOutput {
409    var output: VertexOutput;
410
411    // Transform data coordinates to screen coordinates
412    let screen_start = input.line_start * transform.scale + transform.offset;
413    let screen_end = input.line_end * transform.scale + transform.offset;
414
415    // Compute line direction and perpendicular
416    let delta = screen_end - screen_start;
417    let length = length(delta);
418
419    var dir: vec2<f32>;
420    var perp: vec2<f32>;
421    if length < 0.0001 {
422        dir = vec2<f32>(1.0, 0.0);
423        perp = vec2<f32>(0.0, 1.0);
424    } else {
425        dir = delta / length;
426        perp = vec2<f32>(-dir.y, dir.x);
427    }
428
429    // Transform quad to line segment
430    let center = (screen_start + screen_end) * 0.5;
431    let local_x = input.quad_pos.x * length;
432    let local_y = input.quad_pos.y * input.line_width;
433    let world_pos = center + dir * local_x + perp * local_y;
434
435    output.position = transform.projection * vec4<f32>(world_pos, 0.0, 1.0);
436    output.color = input.color;
437
438    return output;
439}
440
441@fragment
442fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
443    return input.color;
444}
445"#;