Skip to main content

demo_500_nodes/
lib.rs

1#![doc = include_str!("../README.md")]
2#![doc = r#"<link rel="stylesheet" href="../gallery/pkg/demo.css"><script type="module" src="../gallery/pkg/demo-loader.js"></script>"#]
3
4mod graph;
5mod nodes;
6
7use demo_common::Demo;
8use graph::generate_procedural_graph;
9use iced::{
10    Color, Element, Length, Point, Rectangle, Subscription, Task, Theme, Vector, mouse,
11    widget::{canvas, column, container, opaque, row, stack, text, toggler},
12};
13use iced_nodegraph::{
14    Counts, GraphInfo, GraphStyle, Ids, Minimap, PinInfo, PinRef, PinStatus, PinStyle,
15    default_graph_style, default_pin_style, edge, node,
16};
17use nodes::NodeType;
18use web_time::Instant;
19
20/// The id vocabulary of this demo: indexed nodes and pins, unidentified edges,
21/// and a `TypeId` pin payload carrying the data type of each pin.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23struct TypedIds;
24
25impl Ids for TypedIds {
26    type NodeId = usize;
27    type PinId = usize;
28    type EdgeId = ();
29    type AnchorId = usize;
30    type Payload = std::any::TypeId;
31}
32
33/// Colors a node's pins by their data-type marker.
34fn pin_style(
35    theme: &iced::Theme,
36    pin: &PinInfo<'_, TypedIds>,
37    _other: Option<&PinInfo<'_, TypedIds>>,
38    status: PinStatus,
39) -> PinStyle {
40    use nodes::colors;
41    use std::any::TypeId;
42    let ty = *pin.info();
43    let color = if ty == TypeId::of::<colors::Float>() {
44        colors::PIN_FLOAT
45    } else if ty == TypeId::of::<colors::Vec2>() {
46        colors::PIN_VEC2
47    } else if ty == TypeId::of::<colors::Vec3>() {
48        colors::PIN_VEC3
49    } else if ty == TypeId::of::<colors::Vec4>() {
50        colors::PIN_VEC4
51    } else {
52        colors::PIN_GENERIC_IN
53    };
54    PinStyle {
55        color: color.into(),
56        ..default_pin_style(theme, status)
57    }
58}
59use std::collections::{HashSet, VecDeque};
60
61/// How many recent frames the live timing chart keeps.
62const HIST_CAP: usize = 160;
63
64/// How many recent frame intervals the `NG_REPORT` line summarises.
65const INTERVAL_CAP: usize = 120;
66/// Frames between `NG_REPORT` lines.
67const REPORT_EVERY: u32 = 60;
68
69/// Reads an environment knob, `None` when unset or unparseable. Always `None`
70/// on wasm (`std::env` is empty there), so the browser build keeps the defaults.
71fn env_var<T: std::str::FromStr>(key: &str) -> Option<T> {
72    std::env::var(key).ok()?.parse().ok()
73}
74
75/// True when the knob is set to `1`.
76fn env_flag(key: &str) -> bool {
77    env_var::<u32>(key) == Some(1)
78}
79
80pub fn main() -> iced::Result {
81    let window_settings = iced::window::Settings::default();
82
83    // `NG_SCALE` is the fragment-count axis: physical pixels - and with them
84    // the SDF pipeline's fragment work - scale with `scale^2`. iced's own
85    // default is 1.0, so an unset knob leaves behaviour unchanged.
86    let scale: f32 = env_var("NG_SCALE").unwrap_or(1.0);
87    iced::application(Application::boot, Application::update, Application::view)
88        .subscription(Application::subscription)
89        .title("500 Node Benchmark - iced_nodegraph")
90        .theme(Application::theme)
91        .window(window_settings)
92        .scale_factor(move |_| scale)
93        .run()
94}
95
96#[derive(Debug, Clone)]
97enum ApplicationMessage {
98    EdgeConnected {
99        from: PinRef<TypedIds>,
100        to: PinRef<TypedIds>,
101    },
102    EdgeDisconnected {
103        from: PinRef<TypedIds>,
104        to: PinRef<TypedIds>,
105    },
106    SelectionChanged(Vec<usize>),
107    NodesMoved {
108        delta: Vector,
109        indices: Vec<usize>,
110    },
111    Info(Box<GraphInfo>),
112    /// Stats-panel toggle (top right). Hiding the panel also drops the
113    /// `on_info` subscription, so the demo stops rendering while idle.
114    StatsToggled(bool),
115    /// Camera reported by the widget on pan/zoom release (uncontrolled camera).
116    /// Used only to read off exact coordinates when reproducing the pan/zoom
117    /// float-collapse display bug.
118    CameraReport {
119        pos: Point,
120        zoom: f32,
121    },
122}
123
124struct Application {
125    edges: Vec<(PinRef<TypedIds>, PinRef<TypedIds>)>,
126    nodes: Vec<(Point, NodeType)>,
127    current_theme: Theme,
128    selected_nodes: HashSet<usize>,
129    /// Last camera (world position, zoom) reported by the widget on pan/zoom
130    /// release. Shown in the stats panel to capture float-collapse repro coords.
131    camera: (Point, f32),
132    /// Most recent per-frame diagnostics from the graph widget.
133    latest_info: Option<GraphInfo>,
134    /// Per-op CPU time (microseconds) for the last `HIST_CAP` frames, oldest
135    /// first. Each entry mirrors `GraphInfo::timings` order.
136    history: VecDeque<Vec<f32>>,
137    /// Whether the live stats panel (and with it the `on_info`-driven frame
138    /// stream) is active. Off = the demo only redraws on interaction.
139    stats_visible: bool,
140    /// `NG_NO_GRID=1`: drop the tiling background, removing one full-canvas
141    /// SDF layer. Set once at startup.
142    no_grid: bool,
143    /// `NG_REPORT=1`: force the index probe on and print a report line every
144    /// [`REPORT_EVERY`] frames.
145    report: bool,
146    /// Wall clock of the previous [`ApplicationMessage::Info`], for intervals.
147    last_frame: Option<Instant>,
148    /// Recent frame intervals in milliseconds, oldest first.
149    intervals: VecDeque<f32>,
150    /// Frames since the last printed report line.
151    since_report: u32,
152    /// Report lines printed so far; 0 prints the header.
153    reports: u32,
154}
155
156impl Default for Application {
157    fn default() -> Self {
158        let (mut nodes, mut edges) = generate_procedural_graph();
159        // `NG_NODES` truncates rather than reconfiguring the generator (whose
160        // stage sizes are hardcoded loop bounds): keep the first N nodes and
161        // drop every edge that referenced a dropped one.
162        if let Some(n) = env_var::<usize>("NG_NODES") {
163            nodes.truncate(n);
164            edges.retain(|(from, to)| from.node_id < n && to.node_id < n);
165        }
166        if env_flag("NG_NO_EDGES") {
167            edges.clear();
168        }
169        let report = env_flag("NG_REPORT");
170        let stats_visible = true;
171        // The fine-slot readback costs 4 bytes per fine tile per culled frame,
172        // so it is only armed while something is actually reading it.
173        iced_nodegraph_sdf::set_index_probe(stats_visible || report);
174        Self {
175            edges,
176            nodes,
177            current_theme: Theme::CatppuccinMocha,
178            selected_nodes: HashSet::new(),
179            camera: (Point::ORIGIN, 1.0),
180            latest_info: None,
181            history: VecDeque::with_capacity(HIST_CAP),
182            stats_visible,
183            no_grid: env_flag("NG_NO_GRID"),
184            report,
185            last_frame: None,
186            intervals: VecDeque::with_capacity(INTERVAL_CAP),
187            since_report: 0,
188            reports: 0,
189        }
190    }
191}
192
193impl demo_common::Demo for Application {
194    type Message = ApplicationMessage;
195
196    fn boot() -> (Self, Task<ApplicationMessage>) {
197        (Self::default(), Task::none())
198    }
199
200    fn update(&mut self, message: ApplicationMessage) -> Task<ApplicationMessage> {
201        match message {
202            ApplicationMessage::EdgeConnected { from, to } => {
203                self.edges.push((from, to));
204            }
205            ApplicationMessage::EdgeDisconnected { from, to } => {
206                self.edges.retain(|(f, t)| !(f == &from && t == &to));
207            }
208            ApplicationMessage::SelectionChanged(indices) => {
209                self.selected_nodes = indices.into_iter().collect();
210            }
211            ApplicationMessage::NodesMoved { delta, indices } => {
212                for idx in indices {
213                    if let Some((pos, _)) = self.nodes.get_mut(idx) {
214                        pos.x += delta.x;
215                        pos.y += delta.y;
216                    }
217                }
218            }
219            ApplicationMessage::CameraReport { pos, zoom } => {
220                self.camera = (pos, zoom);
221            }
222            ApplicationMessage::StatsToggled(on) => {
223                self.stats_visible = on;
224                iced_nodegraph_sdf::set_index_probe(on || self.report);
225                if !on {
226                    // Drop stale data so a re-enabled panel starts fresh
227                    // instead of presenting an old chart as current.
228                    self.latest_info = None;
229                    self.history.clear();
230                }
231            }
232            ApplicationMessage::Info(info) => {
233                let frame: Vec<f32> = info
234                    .timings
235                    .iter()
236                    .map(|t| t.duration.as_secs_f32() * 1_000_000.0)
237                    .collect();
238                if self.history.len() == HIST_CAP {
239                    self.history.pop_front();
240                }
241                self.history.push_back(frame);
242                let now = Instant::now();
243                if let Some(prev) = self.last_frame.replace(now) {
244                    if self.intervals.len() == INTERVAL_CAP {
245                        self.intervals.pop_front();
246                    }
247                    self.intervals
248                        .push_back(now.duration_since(prev).as_secs_f32() * 1000.0);
249                }
250                self.latest_info = Some(*info);
251                if self.report {
252                    self.since_report += 1;
253                    if self.since_report >= REPORT_EVERY {
254                        self.since_report = 0;
255                        self.print_report();
256                    }
257                }
258            }
259        }
260
261        Task::none()
262    }
263
264    fn theme(&self) -> Theme {
265        self.current_theme.clone()
266    }
267
268    fn set_theme(&mut self, theme: Theme) {
269        self.current_theme = theme;
270    }
271
272    fn view(&self) -> iced::Element<'_, ApplicationMessage> {
273        let mut ng = ::iced_nodegraph::NodeGraph::<TypedIds, _, _, _>::new()
274            .on_connect(|from, to| ApplicationMessage::EdgeConnected { from, to })
275            .on_disconnect(|from, to| ApplicationMessage::EdgeDisconnected { from, to })
276            .on_move(|delta, indices| ApplicationMessage::NodesMoved { delta, indices })
277            .on_select(ApplicationMessage::SelectionChanged)
278            .on_camera(|pos, zoom| ApplicationMessage::CameraReport { pos, zoom })
279            // 500 nodes reach well past any one viewport, so the overview is
280            // the only way to see where the camera sits in the whole graph.
281            .minimap(Minimap::default());
282        // The `on_info` frame stream exists only while the stats panel is
283        // shown: live per-frame diagnostics force continuous redraws, so with
284        // the panel hidden the demo is fully idle between interactions.
285        if self.stats_visible {
286            ng = ng.on_info(|info| ApplicationMessage::Info(Box::new(info)));
287        }
288        // `NG_NO_GRID=1` removes the tiling layer the theme default carries,
289        // dropping one full-canvas SDF draw from every frame.
290        if self.no_grid {
291            ng = ng.graph_style(|theme| GraphStyle {
292                tiling: None,
293                ..default_graph_style(theme)
294            });
295        }
296
297        ng = ng.nodes(
298            self.nodes
299                .iter()
300                .enumerate()
301                .map(|(index, (position, node_type))| {
302                    node(index, *position, node_type.create_node(&self.current_theme))
303                        .selected(self.selected_nodes.contains(&index))
304                        .pin_style(pin_style)
305                }),
306        );
307
308        ng = ng.edges(self.edges.iter().map(|(from, to)| edge((), *from, *to)));
309
310        // Top-right overlay: the toggle chip, plus the stats panel while shown.
311        // `opaque` ensures the overlay claims wheel/click events for its own
312        // area so the NodeGraph below doesn't react through it.
313        let toggle = container(
314            toggler(self.stats_visible)
315                .label("stats")
316                .size(16.0)
317                .text_size(11)
318                .on_toggle(ApplicationMessage::StatsToggled),
319        )
320        .style(panel_style)
321        .padding([4, 10]);
322
323        let mut overlay = column![toggle].spacing(8).align_x(iced::Alignment::End);
324        if self.stats_visible {
325            overlay = overlay.push(self.stats_panel());
326        }
327
328        let graph_view: iced::Element<'_, ApplicationMessage> = ng.into();
329
330        stack![
331            graph_view,
332            container(opaque(overlay))
333                .width(Length::Fill)
334                .height(Length::Fill)
335                .padding(10)
336                .align_x(iced::alignment::Horizontal::Right)
337                .align_y(iced::alignment::Vertical::Top)
338        ]
339        .width(Length::Fill)
340        .height(Length::Fill)
341        .into()
342    }
343
344    fn subscription(&self) -> Subscription<ApplicationMessage> {
345        // The widget self-drives redraws while anything animates; no frame clock needed.
346        Subscription::none()
347    }
348}
349
350/// Boots this demo for the gallery.
351pub fn scene() -> (
352    Box<dyn demo_common::Scene>,
353    iced::Task<demo_common::SceneMessage>,
354) {
355    demo_common::erase::<Application>()
356}
357
358impl Application {
359    /// Prints one `NG_REPORT` line: the frame-interval summary plus the GPU
360    /// work and memory counters from [`GraphInfo`].
361    ///
362    /// Intervals are VSYNC-CAPPED, so the absolute value is meaningless while
363    /// the renderer keeps up. The signal is the configuration at which the
364    /// interval LEAVES the vsync floor, and how it grows past it.
365    fn print_report(&mut self) {
366        let Some(i) = self.latest_info.as_ref() else {
367            return;
368        };
369        if self.reports == 0 {
370            println!(
371                "NG_REPORT: frame intervals are vsync-capped - read the point at which \
372                 mean/p95 leave the vsync floor, not the absolute value."
373            );
374            println!(
375                "frames  mean ms  p95 ms  draws  shaded Mpx  evals M  fine max  dropped  \
376                 gpu MiB  index MiB  upload KiB  traffic KiB  cull_skipped"
377            );
378        }
379        let mut sorted: Vec<f32> = self.intervals.iter().copied().collect();
380        sorted.sort_by(f32::total_cmp);
381        let n = sorted.len();
382        let mean = if n == 0 {
383            0.0
384        } else {
385            sorted.iter().sum::<f32>() / n as f32
386        };
387        let p95 = sorted
388            .get((n as f32 * 0.95) as usize)
389            .or(sorted.last())
390            .copied()
391            .unwrap_or(0.0);
392        const MIB: f64 = 1024.0 * 1024.0;
393        println!(
394            "{n:>6}  {mean:>7.2}  {p95:>6.2}  {:>5}  {:>10.2}  {:>7.2}  {:>8}  {:>7}  \
395             {:>7.2}  {:>9.2}  {:>10.1}  {:>11.1}  {}",
396            i.sdf.sdf_draws,
397            i.sdf.shaded_px as f64 / 1e6,
398            i.sdf.segment_evals as f64 / 1e6,
399            i.sdf.fine_slots_max,
400            i.sdf.fine_evicted_tiles,
401            i.sdf.gpu_bytes as f64 / MIB,
402            i.sdf.index_bytes as f64 / MIB,
403            i.sdf.upload_bytes as f64 / 1024.0,
404            i.sdf.index_traffic_bytes as f64 / 1024.0,
405            i.sdf.cull_skipped,
406        );
407        self.reports += 1;
408    }
409
410    fn stats_panel(&self) -> Element<'_, ApplicationMessage> {
411        let palette = self.current_theme.extended_palette();
412
413        let counts_line = |label: &str, c: Counts| {
414            text(format!(
415                "{label}: {}  ({} in view, {} culled)",
416                c.total, c.in_view, c.culled
417            ))
418            .size(12)
419        };
420
421        let info = self.latest_info.as_ref();
422        let (nodes_c, pins_c, edges_c, entries, tiles) = match info {
423            Some(i) => (
424                i.nodes,
425                i.pins,
426                i.edges,
427                i.sdf.entry_count,
428                i.sdf.tile_count,
429            ),
430            None => (
431                Counts::default(),
432                Counts::default(),
433                Counts::default(),
434                0,
435                0,
436            ),
437        };
438
439        // Stack and legend follow execution order (the order ops run each frame:
440        // geometry, shadows, edges, foreground, sdf_prepare).
441        let ops = self.latest_info.as_ref().map_or(0, |i| i.timings.len());
442        let order: Vec<usize> = (0..ops).collect();
443
444        let legend: Element<'_, ApplicationMessage> = match &self.latest_info {
445            Some(info) if !order.is_empty() => column(order.iter().map(|&k| {
446                let t = &info.timings[k];
447                let us = t.duration.as_secs_f32() * 1_000_000.0;
448                row![
449                    swatch(op_color(palette, k)),
450                    text(format!("{us:>5.0} µs   {}", t.label)).size(11),
451                ]
452                .spacing(8)
453                .align_y(iced::Alignment::Center)
454                .into()
455            }))
456            .spacing(3)
457            .into(),
458            _ => text("collecting…").size(11).into(),
459        };
460
461        let chart = canvas(TimingChart {
462            history: &self.history,
463            order,
464        })
465        .width(Length::Fill)
466        .height(Length::Fixed(110.0));
467
468        let body = column![
469            text("Frame CPU — stacked by operation").size(13),
470            chart,
471            legend,
472            counts_line("Nodes", nodes_c),
473            counts_line("Pins", pins_c),
474            counts_line("Edges", edges_c),
475            text(format!("SDF: {entries} entries · {tiles} tiles")).size(12),
476            gpu_rows(info),
477            text(format!(
478                "cam: ({:.1}, {:.1})  zoom: {:.5}",
479                self.camera.0.x, self.camera.0.y, self.camera.1
480            ))
481            .size(12),
482            text("Scroll: Zoom   ·   Right-drag: Pan").size(11),
483        ]
484        .spacing(8)
485        .padding(12)
486        .width(Length::Fixed(248.0));
487
488        container(body).style(panel_style).into()
489    }
490}
491
492/// The GPU work/memory block of the stats panel: the counters that bound SDF
493/// GPU cost. `evals` and `fine max` need the index probe, which the panel arms
494/// while it is visible; `upload` is the RAM->GPU traffic the "shared VRAM
495/// bandwidth" hypothesis predicts, and is 0 on a fully resident idle frame.
496fn gpu_rows(info: Option<&GraphInfo>) -> Element<'_, ApplicationMessage> {
497    let Some(i) = info else {
498        return text("GPU: collecting…").size(12).into();
499    };
500    const MIB: f64 = 1024.0 * 1024.0;
501    let row = |s: String| text(s).size(11).into();
502    column(
503        [
504            format!(
505                "draws {}   shaded {:.2} Mpx",
506                i.sdf.sdf_draws,
507                i.sdf.shaded_px as f64 / 1e6
508            ),
509            format!(
510                "evals {:.2} M   fine max {}/64",
511                i.sdf.segment_evals as f64 / 1e6,
512                i.sdf.fine_slots_max
513            ),
514            format!("dropped tiles {}", i.sdf.fine_evicted_tiles),
515            format!(
516                "coarse overflow {}   dropped items {}",
517                i.sdf.coarse_overflow_tiles, i.sdf.gpu_dropped_items
518            ),
519            format!(
520                "gpu {:.2} MiB   index {:.2} MiB",
521                i.sdf.gpu_bytes as f64 / MIB,
522                i.sdf.index_bytes as f64 / MIB
523            ),
524            format!(
525                "upload {:.1} KiB   traffic {:.1} KiB",
526                i.sdf.upload_bytes as f64 / 1024.0,
527                i.sdf.index_traffic_bytes as f64 / 1024.0
528            ),
529            format!("cull skipped: {}", i.sdf.cull_skipped),
530        ]
531        .map(row),
532    )
533    .spacing(2)
534    .into()
535}
536
537/// Translucent chip/panel background shared by the stats panel and its toggle.
538fn panel_style(theme: &Theme) -> container::Style {
539    let palette = theme.extended_palette();
540    let bg = palette.background.base.color;
541    container::Style {
542        background: Some(iced::Background::Color(Color { a: 0.92, ..bg })),
543        border: iced::Border {
544            color: palette.background.strong.color,
545            width: 1.0,
546            radius: 10.0.into(),
547        },
548        ..container::Style::default()
549    }
550}
551
552/// Palette color for stacked-timing op `i`, from the theme's extended palette.
553fn op_color(palette: &iced::theme::palette::Extended, i: usize) -> Color {
554    match i {
555        0 => palette.primary.base.color,
556        1 => palette.secondary.base.color,
557        2 => palette.success.base.color,
558        3 => palette.danger.base.color,
559        _ => palette.background.strong.color,
560    }
561}
562
563/// A small color swatch for the legend.
564fn swatch(color: Color) -> Element<'static, ApplicationMessage> {
565    container(text(""))
566        .width(Length::Fixed(12.0))
567        .height(Length::Fixed(12.0))
568        .style(move |_theme: &Theme| container::Style {
569            background: Some(iced::Background::Color(color)),
570            border: iced::Border {
571                radius: 3.0.into(),
572                ..Default::default()
573            },
574            ..container::Style::default()
575        })
576        .into()
577}
578
579/// Live stacked-area chart of per-operation CPU time over recent frames.
580struct TimingChart<'a> {
581    history: &'a VecDeque<Vec<f32>>,
582    /// Op indices bottom-to-top; execution order (geometry first, at the base).
583    order: Vec<usize>,
584}
585
586impl canvas::Program<ApplicationMessage, Theme> for TimingChart<'_> {
587    type State = ();
588
589    fn draw(
590        &self,
591        _state: &(),
592        renderer: &iced::Renderer,
593        theme: &Theme,
594        bounds: Rectangle,
595        _cursor: mouse::Cursor,
596    ) -> Vec<canvas::Geometry> {
597        let mut frame = canvas::Frame::new(renderer, bounds.size());
598        let n = self.history.len();
599        if n == 0 {
600            return vec![frame.into_geometry()];
601        }
602        let palette = theme.extended_palette();
603        let max_total = self
604            .history
605            .iter()
606            .map(|f| f.iter().sum::<f32>())
607            .fold(1.0_f32, f32::max);
608        let w = bounds.width;
609        let h = bounds.height;
610        let dx = w / HIST_CAP as f32;
611        let x_of = |i: usize| w - (n - 1 - i) as f32 * dx;
612        let y_of = |v: f32| h - (v / max_total) * h;
613
614        // Stacked areas, ordered with the largest-average op at the base.
615        let cum = |vals: &[f32], upto: usize| -> f32 {
616            self.order[..upto.min(self.order.len())]
617                .iter()
618                .map(|&j| vals.get(j).copied().unwrap_or(0.0))
619                .sum()
620        };
621        for (p, &k) in self.order.iter().enumerate() {
622            let path = canvas::Path::new(|b| {
623                let mut started = false;
624                for i in 0..n {
625                    let pt = iced::Point::new(x_of(i), y_of(cum(&self.history[i], p + 1)));
626                    if started {
627                        b.line_to(pt);
628                    } else {
629                        b.move_to(pt);
630                        started = true;
631                    }
632                }
633                for i in (0..n).rev() {
634                    b.line_to(iced::Point::new(x_of(i), y_of(cum(&self.history[i], p))));
635                }
636                b.close();
637            });
638            frame.fill(&path, op_color(palette, k));
639        }
640        vec![frame.into_geometry()]
641    }
642}