graph-explorer-render 0.1.0

WebGL2/wgpu renderer for graph-explorer — nodes, edges, halos and labels.
Documentation
mod camera;
mod edges;
mod halo;
mod hit;
mod labels;
mod nodes;
pub use camera::{Camera, CameraUniform};
pub use hit::{contains, pick, HitTarget};
pub use labels::LabelDraw;

use edges::EdgePipeline;
use halo::HaloPipeline;
use labels::Labels;
use nodes::NodePipeline;
use wgpu::util::DeviceExt;

/// One node to draw.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NodeInstanceData {
    pub pos: [f32; 2],
    pub radius: f32,
    pub opacity: f32,
    pub shape: u32,      // 0 circle, 1 square, 2 diamond
    pub color: [f32; 4],
}

/// One edge to draw (world-space endpoints).
#[derive(Clone)]
pub struct EdgeLineData {
    pub a: [f32; 2],
    pub b: [f32; 2],
    pub color: [f32; 4],
    pub width: f32,
    pub opacity: f32,
}

/// One halo to draw (world-space, around a node).
#[derive(Clone)]
pub struct HaloInstanceData {
    pub pos: [f32; 2],
    pub radius: f32,
    pub color: [f32; 4],
    pub softness: f32,
    /// Animation phase in `[0,1)`, advancing over time. Drives the rolling
    /// wavefront in the halo shader (rings emanate outward as this increases).
    pub phase: f32,
}

pub struct Renderer {
    device: wgpu::Device,
    queue: wgpu::Queue,
    camera: Camera,
    camera_buf: wgpu::Buffer,
    camera_bind_group: wgpu::BindGroup,
    nodes: NodePipeline,
    /// World AABB of the current node set (`pos ± radius`), folded during
    /// `set_nodes` so `fit_view` is O(1) and no per-frame copy of the
    /// instance data is retained. `None` when the node set is empty.
    node_bounds: Option<([f32; 2], [f32; 2])>,
    edges: EdgePipeline,
    halos: HaloPipeline,
    labels: Labels,
}

impl Renderer {
    pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat, width: u32, height: u32) -> Self {
        let camera = Camera::new(width as f32, height as f32);
        let camera_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("camera"),
            contents: bytemuck::bytes_of(&camera.uniform()),
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
        });
        let camera_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("camera-bgl"),
            entries: &[wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            }],
        });
        let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("camera-bg"),
            layout: &camera_bgl,
            entries: &[wgpu::BindGroupEntry { binding: 0, resource: camera_buf.as_entire_binding() }],
        });

        let nodes = NodePipeline::new(&device, format, &camera_bgl);
        let edges = EdgePipeline::new(&device, format, &camera_bgl);
        let halos = HaloPipeline::new(&device, format, &camera_bgl);
        let labels = Labels::new(&device, &queue, format);

        Self {
            device,
            queue,
            camera,
            camera_buf,
            camera_bind_group,
            nodes,
            node_bounds: None,
            edges,
            halos,
            labels,
        }
    }

    pub fn resize(&mut self, width: u32, height: u32) {
        self.camera.viewport = [width as f32, height as f32];
    }

    pub fn camera_mut(&mut self) -> &mut Camera { &mut self.camera }
    pub fn camera_ref(&self) -> &Camera { &self.camera }

    pub fn set_fade(&mut self, fade: f32) {
        self.camera.fade = fade.clamp(0.0, 1.0);
    }

    /// Reconfigure a surface owned by the caller (e.g. `GraphView`) using this
    /// renderer's device. `GraphView` owns `surface`/`config` but not the wgpu
    /// `Device` (that lives inside `Renderer`), so surface reconfiguration on
    /// resize is routed through here to keep device ownership in one place.
    pub fn configure_surface(&self, surface: &wgpu::Surface, config: &wgpu::SurfaceConfiguration) {
        surface.configure(&self.device, config);
    }

    pub fn set_nodes(&mut self, nodes: &[NodeInstanceData]) {
        self.nodes.upload(&self.device, &self.queue, nodes);
        // Cheap min/max fold over data the upload just walked (still in
        // cache) — replaces retaining a full per-frame copy for fit_view.
        self.node_bounds = if nodes.is_empty() {
            None
        } else {
            let mut min = [f32::MAX, f32::MAX];
            let mut max = [f32::MIN, f32::MIN];
            for n in nodes {
                min[0] = min[0].min(n.pos[0] - n.radius);
                min[1] = min[1].min(n.pos[1] - n.radius);
                max[0] = max[0].max(n.pos[0] + n.radius);
                max[1] = max[1].max(n.pos[1] + n.radius);
            }
            Some((min, max))
        };
    }

    /// Record this frame's label survivors; shapes only labels not already
    /// in the shaped-buffer cache.
    pub fn set_labels(&mut self, labels: &[LabelDraw]) {
        self.labels.set_labels(labels);
    }

    pub fn set_edges(&mut self, edges: &[EdgeLineData]) {
        self.edges.upload(&self.device, &self.queue, edges);
    }

    pub fn set_halos(&mut self, halos: &[HaloInstanceData]) {
        self.halos.upload(&self.device, &self.queue, halos);
    }

    /// Advance the camera glide by `dt_ms` (paced by `dur_ms`).
    pub fn tick(&mut self, dt_ms: f32, dur_ms: f32) {
        self.camera.tick(dt_ms, dur_ms);
    }

    /// Glide the camera to frame world-AABB `[min,max]`.
    pub fn glide_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
        self.camera.glide_bounds(min, max);
    }

    /// Fit the camera to the current node set (including radii). No-op if empty.
    pub fn fit_view(&mut self) {
        if let Some((min, max)) = self.node_bounds {
            self.camera.fit_bounds(min, max);
        }
    }

    fn upload_camera(&self) {
        self.queue.write_buffer(&self.camera_buf, 0, bytemuck::bytes_of(&self.camera.uniform()));
    }

    /// Clear the target, then draw edges beneath nodes, with labels layered on top.
    pub fn render(&mut self, view: &wgpu::TextureView) -> Result<(), String> {
        self.upload_camera();
        self.labels.prepare(&self.device, &self.queue, &self.camera)?;
        let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("frame") });
        let label_res;
        {
            let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("main-pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.05, g: 0.06, b: 0.09, a: 1.0 }),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
            });
            self.edges.draw(&mut rpass, &self.camera_bind_group);
            self.halos.draw(&mut rpass, &self.camera_bind_group);
            self.nodes.draw(&mut rpass, &self.camera_bind_group);
            label_res = self.labels.draw(&mut rpass);
        }
        label_res?;
        self.queue.submit(Some(encoder.finish()));
        Ok(())
    }
}