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