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#[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
33fn 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
61const HIST_CAP: usize = 160;
63
64const INTERVAL_CAP: usize = 120;
66const REPORT_EVERY: u32 = 60;
68
69fn env_var<T: std::str::FromStr>(key: &str) -> Option<T> {
72 std::env::var(key).ok()?.parse().ok()
73}
74
75fn 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 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 StatsToggled(bool),
115 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 camera: (Point, f32),
132 latest_info: Option<GraphInfo>,
134 history: VecDeque<Vec<f32>>,
137 stats_visible: bool,
140 no_grid: bool,
143 report: bool,
146 last_frame: Option<Instant>,
148 intervals: VecDeque<f32>,
150 since_report: u32,
152 reports: u32,
154}
155
156impl Default for Application {
157 fn default() -> Self {
158 let (mut nodes, mut edges) = generate_procedural_graph();
159 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 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 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 .minimap(Minimap::default());
282 if self.stats_visible {
286 ng = ng.on_info(|info| ApplicationMessage::Info(Box::new(info)));
287 }
288 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 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 Subscription::none()
347 }
348}
349
350pub 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 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 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
492fn 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
537fn 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
552fn 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
563fn 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
579struct TimingChart<'a> {
581 history: &'a VecDeque<Vec<f32>>,
582 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 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}