Skip to main content

demo_500_nodes/
lib.rs

1//! # 500 Node Benchmark Demo
2//!
3//! Large-scale node graph demonstrating performance with 500+ nodes.
4//! Simulates a procedural shader/material graph with multiple processing stages.
5//!
6//! ## Interactive Demo
7//!
8//! <link rel="stylesheet" href="pkg/demo.css">
9//! <div id="demo-container">
10//!   <div id="demo-loading">
11//!     <div class="demo-spinner"></div>
12//!     <p>Loading demo...</p>
13//!   </div>
14//!   <div id="demo-canvas-container"></div>
15//!   <div id="demo-error">
16//!     <strong>Failed to load demo.</strong> WebGPU required.
17//!   </div>
18//! </div>
19//! <script type="module" src="pkg/demo-loader.js"></script>
20//!
21//! ## Controls
22//!
23//! - **Scroll** - Zoom in/out (zoom out to see all 500 nodes)
24//! - **Right-drag** - Pan the canvas
25//! - **Drag nodes** - Move individual nodes
26//! - **Stats toggle** (top right) - show/hide the live timing panel; while
27//!   hidden the demo renders no frames between interactions
28//!
29//! ## About This Benchmark
30//!
31//! This demo generates a procedural shader graph with 500 nodes arranged in stages:
32//! input sources, noise generators, vector operations, math operations,
33//! texture sampling, blending, and material outputs.
34
35mod graph;
36mod nodes;
37
38#[cfg(target_arch = "wasm32")]
39use wasm_bindgen::prelude::*;
40
41#[cfg(target_arch = "wasm32")]
42#[wasm_bindgen(start)]
43pub fn wasm_init() {
44    console_error_panic_hook::set_once();
45}
46
47use graph::generate_procedural_graph;
48use iced::{
49    Color, Element, Length, Point, Rectangle, Subscription, Theme, Vector, mouse,
50    widget::{canvas, column, container, opaque, row, stack, text, toggler},
51};
52use iced_nodegraph::{
53    Counts, GraphInfo, PinInfo, PinRef, PinStatus, PinStyle, default_pin_style, edge, node,
54};
55use nodes::NodeType;
56
57/// Colors a node's pins by their data-type marker.
58fn pin_style(
59    theme: &iced::Theme,
60    pin: &PinInfo<'_, usize, ::std::any::TypeId>,
61    _other: Option<&PinInfo<'_, usize, ::std::any::TypeId>>,
62    status: PinStatus,
63) -> PinStyle {
64    use nodes::colors;
65    use std::any::TypeId;
66    let ty = *pin.info();
67    let color = if ty == TypeId::of::<colors::Float>() {
68        colors::PIN_FLOAT
69    } else if ty == TypeId::of::<colors::Vec2>() {
70        colors::PIN_VEC2
71    } else if ty == TypeId::of::<colors::Vec3>() {
72        colors::PIN_VEC3
73    } else if ty == TypeId::of::<colors::Vec4>() {
74        colors::PIN_VEC4
75    } else {
76        colors::PIN_GENERIC_IN
77    };
78    PinStyle {
79        color: color.into(),
80        ..default_pin_style(theme, status)
81    }
82}
83use std::collections::{HashSet, VecDeque};
84
85/// How many recent frames the live timing chart keeps.
86const HIST_CAP: usize = 160;
87
88pub fn main() -> iced::Result {
89    #[cfg(target_arch = "wasm32")]
90    let window_settings = iced::window::Settings {
91        platform_specific: iced::window::settings::PlatformSpecific {
92            target: Some(String::from("demo-canvas-container")),
93        },
94        ..Default::default()
95    };
96
97    #[cfg(not(target_arch = "wasm32"))]
98    let window_settings = iced::window::Settings::default();
99
100    iced::application(Application::new, Application::update, Application::view)
101        .subscription(Application::subscription)
102        .title("500 Node Benchmark - iced_nodegraph")
103        .theme(Application::theme)
104        .window(window_settings)
105        .run()
106}
107
108#[cfg(target_arch = "wasm32")]
109#[wasm_bindgen]
110pub fn run_demo() {
111    let _ = main();
112}
113
114#[derive(Debug, Clone)]
115#[allow(dead_code)]
116enum ApplicationMessage {
117    Noop,
118    EdgeConnected {
119        from: PinRef<usize, usize>,
120        to: PinRef<usize, usize>,
121    },
122    EdgeDisconnected {
123        from: PinRef<usize, usize>,
124        to: PinRef<usize, usize>,
125    },
126    SelectionChanged(Vec<usize>),
127    NodesMoved {
128        delta: Vector,
129        indices: Vec<usize>,
130    },
131    Info(GraphInfo),
132    /// Stats-panel toggle (top right). Hiding the panel also drops the
133    /// `on_info` subscription, so the demo stops rendering while idle.
134    StatsToggled(bool),
135    /// Camera reported by the widget on pan/zoom release (uncontrolled camera).
136    /// Used only to read off exact coordinates when reproducing the pan/zoom
137    /// float-collapse display bug.
138    CameraReport {
139        pos: Point,
140        zoom: f32,
141    },
142}
143
144struct Application {
145    edges: Vec<(PinRef<usize, usize>, PinRef<usize, usize>)>,
146    nodes: Vec<(Point, NodeType)>,
147    current_theme: Theme,
148    selected_nodes: HashSet<usize>,
149    /// Last camera (world position, zoom) reported by the widget on pan/zoom
150    /// release. Shown in the stats panel to capture float-collapse repro coords.
151    camera: (Point, f32),
152    /// Most recent per-frame diagnostics from the graph widget.
153    latest_info: Option<GraphInfo>,
154    /// Per-op CPU time (microseconds) for the last `HIST_CAP` frames, oldest
155    /// first. Each entry mirrors `GraphInfo::timings` order.
156    history: VecDeque<Vec<f32>>,
157    /// Whether the live stats panel (and with it the `on_info`-driven frame
158    /// stream) is active. Off = the demo only redraws on interaction.
159    stats_visible: bool,
160}
161
162impl Default for Application {
163    fn default() -> Self {
164        let (nodes, edges) = generate_procedural_graph();
165        Self {
166            edges,
167            nodes,
168            current_theme: Theme::CatppuccinMocha,
169            selected_nodes: HashSet::new(),
170            camera: (Point::ORIGIN, 1.0),
171            latest_info: None,
172            history: VecDeque::with_capacity(HIST_CAP),
173            stats_visible: true,
174        }
175    }
176}
177
178impl Application {
179    fn new() -> Self {
180        Self::default()
181    }
182
183    fn update(&mut self, message: ApplicationMessage) {
184        match message {
185            ApplicationMessage::Noop => (),
186            ApplicationMessage::EdgeConnected { from, to } => {
187                self.edges.push((from, to));
188            }
189            ApplicationMessage::EdgeDisconnected { from, to } => {
190                self.edges.retain(|(f, t)| !(f == &from && t == &to));
191            }
192            ApplicationMessage::SelectionChanged(indices) => {
193                self.selected_nodes = indices.into_iter().collect();
194            }
195            ApplicationMessage::NodesMoved { delta, indices } => {
196                for idx in indices {
197                    if let Some((pos, _)) = self.nodes.get_mut(idx) {
198                        pos.x += delta.x;
199                        pos.y += delta.y;
200                    }
201                }
202            }
203            ApplicationMessage::CameraReport { pos, zoom } => {
204                self.camera = (pos, zoom);
205            }
206            ApplicationMessage::StatsToggled(on) => {
207                self.stats_visible = on;
208                if !on {
209                    // Drop stale data so a re-enabled panel starts fresh
210                    // instead of presenting an old chart as current.
211                    self.latest_info = None;
212                    self.history.clear();
213                }
214            }
215            ApplicationMessage::Info(info) => {
216                let frame: Vec<f32> = info
217                    .timings
218                    .iter()
219                    .map(|t| t.duration.as_secs_f32() * 1_000_000.0)
220                    .collect();
221                if self.history.len() == HIST_CAP {
222                    self.history.pop_front();
223                }
224                self.history.push_back(frame);
225                self.latest_info = Some(info);
226            }
227        }
228    }
229
230    fn theme(&self) -> Theme {
231        self.current_theme.clone()
232    }
233
234    fn view(&self) -> iced::Element<'_, ApplicationMessage> {
235        let mut ng: ::iced_nodegraph::NodeGraph<usize, usize, ::std::any::TypeId, _, _, _> =
236            ::iced_nodegraph::NodeGraph::default()
237                .on_connect(|from, to| ApplicationMessage::EdgeConnected { from, to })
238                .on_disconnect(|from, to| ApplicationMessage::EdgeDisconnected { from, to })
239                .on_move(|delta, indices| ApplicationMessage::NodesMoved { delta, indices })
240                .on_select(ApplicationMessage::SelectionChanged)
241                .selection(&self.selected_nodes)
242                .on_pan(|pos, zoom| ApplicationMessage::CameraReport { pos, zoom });
243        // The `on_info` frame stream exists only while the stats panel is
244        // shown: live per-frame diagnostics force continuous redraws, so with
245        // the panel hidden the demo is fully idle between interactions.
246        if self.stats_visible {
247            ng = ng.on_info(ApplicationMessage::Info);
248        }
249
250        // Add all nodes
251        for (index, (position, node_type)) in self.nodes.iter().enumerate() {
252            ng.push_node(
253                node(index, *position, node_type.create_node(&self.current_theme))
254                    .pin_style(pin_style),
255            );
256        }
257
258        // Add all edges
259        for (from, to) in &self.edges {
260            ng.push_edge(edge!(*from, *to));
261        }
262
263        // Top-right overlay: the toggle chip, plus the stats panel while shown.
264        // `opaque` ensures the overlay claims wheel/click events for its own
265        // area so the NodeGraph below doesn't react through it.
266        let toggle = container(
267            toggler(self.stats_visible)
268                .label("stats")
269                .size(16.0)
270                .text_size(11)
271                .on_toggle(ApplicationMessage::StatsToggled),
272        )
273        .style(panel_style)
274        .padding([4, 10]);
275
276        let mut overlay = column![toggle].spacing(8).align_x(iced::Alignment::End);
277        if self.stats_visible {
278            overlay = overlay.push(self.stats_panel());
279        }
280
281        let graph_view: iced::Element<'_, ApplicationMessage> = ng.into();
282
283        stack![
284            graph_view,
285            container(opaque(overlay))
286                .width(Length::Fill)
287                .height(Length::Fill)
288                .padding(10)
289                .align_x(iced::alignment::Horizontal::Right)
290                .align_y(iced::alignment::Vertical::Top)
291        ]
292        .width(Length::Fill)
293        .height(Length::Fill)
294        .into()
295    }
296
297    fn stats_panel(&self) -> Element<'_, ApplicationMessage> {
298        let palette = self.current_theme.extended_palette();
299
300        let counts_line = |label: &str, c: Counts| {
301            text(format!(
302                "{label}: {}  ({} in view, {} culled)",
303                c.total, c.in_view, c.culled
304            ))
305            .size(12)
306        };
307
308        let (nodes_c, pins_c, edges_c, entries, tiles) = match &self.latest_info {
309            Some(i) => (i.nodes, i.pins, i.edges, i.sdf_entries, i.sdf_tiles),
310            None => (
311                Counts::default(),
312                Counts::default(),
313                Counts::default(),
314                0,
315                0,
316            ),
317        };
318
319        // Stack and legend follow execution order (the order ops run each frame:
320        // geometry, shadows, edges, foreground, sdf_prepare).
321        let ops = self.latest_info.as_ref().map_or(0, |i| i.timings.len());
322        let order: Vec<usize> = (0..ops).collect();
323
324        let legend: Element<'_, ApplicationMessage> = match &self.latest_info {
325            Some(info) if !order.is_empty() => column(order.iter().map(|&k| {
326                let t = &info.timings[k];
327                let us = t.duration.as_secs_f32() * 1_000_000.0;
328                row![
329                    swatch(op_color(palette, k)),
330                    text(format!("{us:>5.0} µs   {}", t.label)).size(11),
331                ]
332                .spacing(8)
333                .align_y(iced::Alignment::Center)
334                .into()
335            }))
336            .spacing(3)
337            .into(),
338            _ => text("collecting…").size(11).into(),
339        };
340
341        let chart = canvas(TimingChart {
342            history: &self.history,
343            order,
344        })
345        .width(Length::Fill)
346        .height(Length::Fixed(110.0));
347
348        let body = column![
349            text("Frame CPU — stacked by operation").size(13),
350            chart,
351            legend,
352            counts_line("Nodes", nodes_c),
353            counts_line("Pins", pins_c),
354            counts_line("Edges", edges_c),
355            text(format!("SDF: {entries} entries · {tiles} tiles")).size(12),
356            text(format!(
357                "cam: ({:.1}, {:.1})  zoom: {:.5}",
358                self.camera.0.x, self.camera.0.y, self.camera.1
359            ))
360            .size(12),
361            text("Scroll: Zoom   ·   Right-drag: Pan").size(11),
362        ]
363        .spacing(8)
364        .padding(12)
365        .width(Length::Fixed(248.0));
366
367        container(body).style(panel_style).into()
368    }
369
370    fn subscription(&self) -> Subscription<ApplicationMessage> {
371        // The widget self-drives redraws while anything animates; no frame clock needed.
372        Subscription::none()
373    }
374}
375
376/// Translucent chip/panel background shared by the stats panel and its toggle.
377fn panel_style(theme: &Theme) -> container::Style {
378    let palette = theme.extended_palette();
379    let bg = palette.background.base.color;
380    container::Style {
381        background: Some(iced::Background::Color(Color { a: 0.92, ..bg })),
382        border: iced::Border {
383            color: palette.background.strong.color,
384            width: 1.0,
385            radius: 10.0.into(),
386        },
387        ..container::Style::default()
388    }
389}
390
391/// Palette color for stacked-timing op `i`, from the theme's extended palette.
392fn op_color(palette: &iced::theme::palette::Extended, i: usize) -> Color {
393    match i {
394        0 => palette.primary.base.color,
395        1 => palette.secondary.base.color,
396        2 => palette.success.base.color,
397        3 => palette.danger.base.color,
398        _ => palette.background.strong.color,
399    }
400}
401
402/// A small color swatch for the legend.
403fn swatch(color: Color) -> Element<'static, ApplicationMessage> {
404    container(text(""))
405        .width(Length::Fixed(12.0))
406        .height(Length::Fixed(12.0))
407        .style(move |_theme: &Theme| container::Style {
408            background: Some(iced::Background::Color(color)),
409            border: iced::Border {
410                radius: 3.0.into(),
411                ..Default::default()
412            },
413            ..container::Style::default()
414        })
415        .into()
416}
417
418/// Live stacked-area chart of per-operation CPU time over recent frames.
419struct TimingChart<'a> {
420    history: &'a VecDeque<Vec<f32>>,
421    /// Op indices bottom-to-top; execution order (geometry first, at the base).
422    order: Vec<usize>,
423}
424
425impl canvas::Program<ApplicationMessage, Theme> for TimingChart<'_> {
426    type State = ();
427
428    fn draw(
429        &self,
430        _state: &(),
431        renderer: &iced::Renderer,
432        theme: &Theme,
433        bounds: Rectangle,
434        _cursor: mouse::Cursor,
435    ) -> Vec<canvas::Geometry> {
436        let mut frame = canvas::Frame::new(renderer, bounds.size());
437        let n = self.history.len();
438        if n == 0 {
439            return vec![frame.into_geometry()];
440        }
441        let palette = theme.extended_palette();
442        let max_total = self
443            .history
444            .iter()
445            .map(|f| f.iter().sum::<f32>())
446            .fold(1.0_f32, f32::max);
447        let w = bounds.width;
448        let h = bounds.height;
449        let dx = w / HIST_CAP as f32;
450        let x_of = |i: usize| w - (n - 1 - i) as f32 * dx;
451        let y_of = |v: f32| h - (v / max_total) * h;
452
453        // Stacked areas, ordered with the largest-average op at the base.
454        let cum = |vals: &[f32], upto: usize| -> f32 {
455            self.order[..upto.min(self.order.len())]
456                .iter()
457                .map(|&j| vals.get(j).copied().unwrap_or(0.0))
458                .sum()
459        };
460        for (p, &k) in self.order.iter().enumerate() {
461            let path = canvas::Path::new(|b| {
462                let mut started = false;
463                for i in 0..n {
464                    let pt = iced::Point::new(x_of(i), y_of(cum(&self.history[i], p + 1)));
465                    if started {
466                        b.line_to(pt);
467                    } else {
468                        b.move_to(pt);
469                        started = true;
470                    }
471                }
472                for i in (0..n).rev() {
473                    b.line_to(iced::Point::new(x_of(i), y_of(cum(&self.history[i], p))));
474                }
475                b.close();
476            });
477            frame.fill(&path, op_color(palette, k));
478        }
479        vec![frame.into_geometry()]
480    }
481}