Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-flow/examples/flow_lab.rs

//! Native GPU host and reproducible on-device benchmark. No connections / I/O
//! live in the component; clock, screenshots and reports belong to this host.
use facett_core::{
    Elm, Facet, LookTheme,
    render::{gpu::facett_wgpu_options, lane},
};
use facett_flow::{Camera, FlowGraph, Graph, Msg, gpu};
use serde_json::json;
use std::{path::PathBuf, time::Instant};
#[path = "flow_lab/scenarios.rs"]
mod scenarios;
struct Sample {
    interval: f64,
    cpu: f64,
    gpu: Option<u64>,
}
struct Lab {
    flow: FlowGraph,
    scenario: scenarios::Scenario,
    introspection_ms: Vec<f64>,
    layout: String,
    theme: usize,
    search: String,
    last_search: String,
    filtered: Vec<usize>,
    benchmark: bool,
    preview: bool,
    result_dir: PathBuf,
    started: Instant,
    previous: Instant,
    phase: usize,
    phase_start: Instant,
    samples: Vec<Sample>,
    results: Vec<serde_json::Value>,
    adapter: serde_json::Value,
    screenshot_sent: bool,
    size: [f32; 2],
    ppp: f32,
}
fn percentile(mut xs: Vec<f64>, fraction: f64) -> Option<f64> {
    if xs.is_empty() {
        return None;
    }
    xs.sort_by(f64::total_cmp);
    Some(xs[((xs.len() - 1) as f64 * fraction).round() as usize])
}
impl Lab {
    fn phase_name(&self) -> &'static str {
        ["raw-overview", "raw-pan-zoom", "clusters", "raw-detail"][self.phase.min(3)]
    }
    fn save_phase(&mut self) {
        let intervals: Vec<_> = self.samples.iter().map(|s| s.interval).collect();
        let total: f64 = intervals.iter().sum();
        let stats = lane::clock_stats_of(gpu::lane_name());
        self.results.push(json!({"phase":self.phase_name(),"samples":self.samples.len(),
            "introspection_ms_p50":percentile(self.introspection_ms.clone(),0.5),
            "average_fps": if total>0.0 {Some(self.samples.len() as f64/total)} else {None},
            "frame_interval_ms_p50":percentile(intervals.iter().map(|v|v*1000.0).collect(),0.5),
            "frame_interval_ms_p95":percentile(intervals.iter().map(|v|v*1000.0).collect(),0.95),
            "facet_ui_cpu_ms_p50":percentile(self.samples.iter().map(|s|s.cpu*1000.0).collect(),0.5),
            "facet_ui_cpu_ms_p95":percentile(self.samples.iter().map(|s|s.cpu*1000.0).collect(),0.95),
            "recent_gpu_pass_ms_p50":percentile(self.samples.iter().filter_map(|s|s.gpu.map(|us|us as f64/1000.0)).collect(),0.5),
            "recent_gpu_pass_ms_p95":percentile(self.samples.iter().filter_map(|s|s.gpu.map(|us|us as f64/1000.0)).collect(),0.95),
            "resident_uploads_total":stats.uploads_total,"resident_bytes_total":stats.upload_bytes_total,
            "gpu_encoded_frames_total":lane::frames_of(gpu::lane_name()),"gpu_timestamps":stats.gpu_timestamps,
            "submitted_nodes":self.flow.render_counts().0,"submitted_edges":self.flow.render_counts().1,
            "original_nodes":self.flow.state().graph.nodes.len(),"original_edges":self.flow.state().graph.edges.len(),
            "pane_points":self.size,"pixels_per_point":self.ppp}));
        self.samples.clear();
        self.introspection_ms.clear();
    }
    fn report(&self) {
        let report = json!({"adapter":self.adapter,"build":if cfg!(debug_assertions) { "debug" } else { "release" },"duration_seconds":self.started.elapsed().as_secs_f64(),
            "scenario":self.scenario.metadata(),"compiled_features":facett_flow::compiled_features(),"component_version":env!("CARGO_PKG_VERSION"),"layout":self.layout,"phases":self.results,
            "method":"Native eframe window; each phase 3 seconds warmup + 8 seconds sampled. Frame intervals include host scheduling and vsync; CPU times cover the selected scene UI including its labels and hit probe, with compact-state reads reported separately; GPU timestamps cover offscreen flow pass, excluding egui compositing. Raw mode submits all original nodes/edges; vertex shaders clip offscreen geometry. GPU samples are recently resolved pass timings and can repeat across host samples."});
        std::fs::create_dir_all(&self.result_dir).expect("result directory");
        std::fs::write(
            self.result_dir.join("benchmark.json"),
            serde_json::to_string_pretty(&report).unwrap(),
        )
        .expect("write benchmark");
        println!("{}", serde_json::to_string_pretty(&report).unwrap());
    }
}
impl eframe::App for Lab {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        let now = Instant::now();
        let interval = now.duration_since(self.previous).as_secs_f64();
        self.previous = now;
        let phase_time = now.duration_since(self.phase_start).as_secs_f64();
        let presets = [
            LookTheme::nordisk_dark(),
            LookTheme::nordisk_light(),
            LookTheme::device(),
        ];
        presets[self.theme].apply(ui.ctx());
        ui.input(|i| {
            for event in &i.events {
                if let egui::Event::Screenshot {
                    image, user_data, ..
                } = event
                    && let Some(name) = user_data
                        .data
                        .as_ref()
                        .and_then(|d| d.downcast_ref::<String>())
                {
                    let bytes: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
                    std::fs::create_dir_all(&self.result_dir).unwrap();
                    image::save_buffer(
                        self.result_dir.join(format!("{name}.png")),
                        &bytes,
                        image.size[0] as u32,
                        image.size[1] as u32,
                        image::ColorType::Rgba8,
                    )
                    .expect("save screenshot");
                    if self.preview {
                        ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
                    }
                }
            }
        });
        ui.horizontal(|ui| {
            ui.heading("FALSETT / FLOW");
            ui.separator();
            ui.label(format!(
                "{} nodes · {} connections",
                self.flow.state().graph.nodes.len(),
                self.flow.state().graph.edges.len()
            ));
            for (i, label) in ["Dark", "Light", "Device"].iter().enumerate() {
                if ui.selectable_label(self.theme == i, *label).clicked() {
                    self.theme = i;
                }
            }
        });
        ui.horizontal(|ui| {
            if ui
                .button(if self.flow.state().running {
                    "Pause"
                } else {
                    "Animate"
                })
                .clicked()
            {
                let running = !self.flow.state().running;
                Elm::update(&mut self.flow, Msg::Running(running));
            }
            let mut clustered = self.flow.state().clustered;
            if ui.checkbox(&mut clustered, "Cluster overview").changed() {
                Elm::update(&mut self.flow, Msg::Clustered(clustered));
            }
            let mut reduced = self.flow.state().reduced_motion;
            if ui.checkbox(&mut reduced, "Reduced motion").changed() {
                Elm::update(&mut self.flow, Msg::ReducedMotion(reduced));
            }
            if ui.button("Fit graph").clicked() {
                Elm::update(&mut self.flow, Msg::Fit);
            }
            ui.add_enabled_ui(!self.benchmark && self.scenario.name == "plain", |ui| {
                for layout in ["hub", "stages"] {
                    if ui
                        .selectable_label(
                            self.layout == layout,
                            if layout == "hub" {
                                "Hub layout"
                            } else {
                                "Stages layout"
                            },
                        )
                        .clicked()
                    {
                        let count = self.flow.state().graph.nodes.len();
                        Elm::update(
                            &mut self.flow,
                            Msg::Replace(std::sync::Arc::new(if layout == "hub" {
                                Graph::hub_demo(count)
                            } else {
                                Graph::demo(count)
                            })),
                        );
                        Elm::update(&mut self.flow, Msg::Clustered(layout == "hub"));
                        self.layout = layout.into();
                        self.last_search = "\0".into();
                    }
                }
            });
            ui.label(if self.layout == "hub" {
                "Inputs → central hub → outputs · synthetic data"
            } else {
                "Synthetic five-stage flow · Metal GPU"
            });
        });
        if self.benchmark {
            ui.label(format!(
                "Benchmark · {} · {:.1}/11 s",
                self.phase_name(),
                phase_time
            ));
            let bounds = self.scenario.bounds(&self.flow);
            let bcentre = [bounds.center().x, bounds.center().y];
            let detail_node =
                &self.flow.state().graph.nodes[self.flow.state().graph.nodes.len() / 5];
            let camera = match self.phase {
                1 => Camera {
                    centre: [
                        bcentre[0] + bounds.width() * 0.06 * (phase_time as f32 * 0.8).sin(),
                        bcentre[1] + bounds.height() * 0.09 * (phase_time as f32 * 0.6).cos(),
                    ],
                    zoom: 1.25 + 0.2 * (phase_time as f32 * 0.7).sin(),
                },
                3 => Camera {
                    centre: detail_node.position,
                    zoom: (4.0 / ((802.0 - 64.0) / bounds.height().max(1.0))).clamp(10.0, 50.0),
                },
                _ => Camera {
                    centre: bcentre,
                    zoom: 1.0,
                },
            };
            Elm::update(&mut self.flow, Msg::Camera(camera));
            Elm::update(&mut self.flow, Msg::Clustered(self.phase == 2));
        }
        if presets[self.theme].effects.allows_decorative_motion() {
            Elm::update(&mut self.flow, Msg::Tick(interval.min(0.1)));
        }
        egui::Panel::right("inspector")
            .exact_size(230.0)
            .show(ui, |ui| {
                ui.heading("Explore nodes");
                ui.label("Every node is searchable by its stable ID or label.");
                ui.text_edit_singleline(&mut self.search);
                let graph = self.flow.state().graph.clone();
                if self.last_search != self.search {
                    let needle = self.search.to_lowercase();
                    self.filtered = graph
                        .nodes
                        .iter()
                        .enumerate()
                        .filter(|(_, n)| {
                            needle.is_empty()
                                || n.id.contains(&needle)
                                || n.label.to_lowercase().contains(&needle)
                        })
                        .map(|(i, _)| i)
                        .collect();
                    self.last_search = self.search.clone();
                }
                let filtered = &self.filtered;
                egui::ScrollArea::vertical().max_height(260.0).show_rows(
                    ui,
                    24.0,
                    filtered.len(),
                    |ui, range| {
                        for i in range {
                            let n = &graph.nodes[filtered[i]];
                            ui.push_id(&n.id, |ui| {
                                if ui
                                    .selectable_label(
                                        self.flow.state().selected.as_ref() == Some(&n.id),
                                        &n.label,
                                    )
                                    .clicked()
                                {
                                    Elm::update(&mut self.flow, Msg::Select(Some(n.id.clone())));
                                    Elm::update(
                                        &mut self.flow,
                                        Msg::Camera(Camera {
                                            centre: n.position,
                                            zoom: 10.0,
                                        }),
                                    );
                                }
                            });
                        }
                    },
                );
                ui.separator();
                if let Some(n) = self
                    .flow
                    .state()
                    .selected
                    .as_ref()
                    .and_then(|id| graph.nodes.iter().find(|n| &n.id == id))
                {
                    ui.label(&n.id);
                    ui.label(&n.label);
                    ui.label(&n.group);
                } else {
                    ui.label("Select a node to inspect it.");
                }
                ui.separator();
                let stats = lane::clock_stats_of(gpu::lane_name());
                ui.label(format!("GPU uploads: {}", stats.uploads_total));
                ui.label(format!(
                    "GPU geometry: {:.2} MB",
                    stats.upload_bytes_total as f64 / 1.0e6
                ));
                ui.label(format!(
                    "GPU pass: {}",
                    stats
                        .last_gpu_pass_us
                        .map(|us| format!("{:.2} ms", us as f64 / 1000.0))
                        .unwrap_or("unavailable".into())
                ));
                ui.label(self.adapter["name"].as_str().unwrap_or("GPU"));
            });
        self.size = [ui.available_width(), ui.available_height()];
        self.ppp = ui.ctx().pixels_per_point();
        let cpu = Instant::now();
        egui::CentralPanel::default().show(ui, |ui| self.scenario.draw(&mut self.flow, ui));
        let cpu = cpu.elapsed().as_secs_f64();
        if self.scenario.name == "compact-state" || self.scenario.name == "all" {
            let start = Instant::now();
            std::hint::black_box(self.flow.state_json());
            if self.benchmark && phase_time > 3.0 {
                self.introspection_ms
                    .push(start.elapsed().as_secs_f64() * 1000.0);
            }
        }
        if self.preview {
            ui.ctx().request_repaint();
            if self.started.elapsed().as_secs_f64() > 1.0 && !self.screenshot_sent {
                ui.ctx()
                    .send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(
                        "hub-preview".to_string(),
                    )));
                self.screenshot_sent = true;
            }
        }
        if self.benchmark {
            if phase_time > 3.0 {
                self.samples.push(Sample {
                    interval,
                    cpu,
                    gpu: lane::clock_stats_of(gpu::lane_name()).last_gpu_pass_us,
                });
            }
            if phase_time > 5.0 && !self.screenshot_sent {
                ui.ctx()
                    .send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(
                        self.phase_name().to_string(),
                    )));
                self.screenshot_sent = true;
            }
            if phase_time > 11.0 {
                self.save_phase();
                self.phase += 1;
                self.phase_start = Instant::now();
                self.screenshot_sent = false;
                if self.phase == 4 {
                    self.report();
                    ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
                }
            }
            ui.ctx().request_repaint();
        }
    }
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = std::env::args().collect();
    let scenario_name = args
        .iter()
        .position(|a| a == "--scenario")
        .and_then(|i| args.get(i + 1))
        .cloned()
        .unwrap_or_else(|| "plain".into());
    scenarios::Scenario::validate(&scenario_name).map_err(std::io::Error::other)?;
    let benchmark = args.iter().any(|a| a == "--benchmark");
    let preview = args.iter().any(|a| a == "--preview");
    let theme = match args
        .iter()
        .position(|a| a == "--theme")
        .and_then(|i| args.get(i + 1))
        .map(String::as_str)
        .unwrap_or("dark")
    {
        "dark" => 0,
        "light" => 1,
        "device" => 2,
        _ => return Err(std::io::Error::other("--theme must be dark, light or device").into()),
    };
    let nodes = if let Some(index) = args.iter().position(|a| a == "--nodes") {
        args.get(index + 1)
            .and_then(|s| s.parse::<usize>().ok())
            .filter(|n| (5..=1_000_000).contains(n))
            .ok_or_else(|| std::io::Error::other("--nodes requires a count from 5 to 1000000"))?
    } else {
        10_000
    };
    let layout = args
        .iter()
        .position(|a| a == "--layout")
        .and_then(|i| args.get(i + 1))
        .map(String::as_str)
        .unwrap_or("hub");
    if !["hub", "stages"].contains(&layout) {
        return Err(std::io::Error::other("--layout must be hub or stages").into());
    }
    let layout = layout.to_string();
    let title = format!("Facett · Flow · {nodes} nodes");
    // Reports go to $FLOW_RESULTS, else beside the build (`<target>/flow-lab`), never
    // into the current directory, so a run in the checkout leaves the tree clean.
    let result_dir = std::env::var_os("FLOW_RESULTS")
        .map(PathBuf::from)
        .unwrap_or_else(default_result_dir);
    eprintln!(
        "flow lab: screenshots and benchmark.json go to {}",
        result_dir.display()
    );
    eframe::run_native(
        &title,
        eframe::NativeOptions {
            renderer: eframe::Renderer::Wgpu,
            wgpu_options: facett_wgpu_options(),
            viewport: egui::ViewportBuilder::default().with_inner_size([1400.0, 900.0]),
            ..Default::default()
        },
        Box::new(move |cc| {
            let rs = cc.wgpu_render_state.as_ref().expect("wgpu host");
            let info = rs.adapter.get_info();
            facett_core::render::gpu::refuse_software_adapter("flow lab", &info);
            gpu::install_renderer(rs);
            let now = Instant::now();
            let graph = if layout == "hub" {
                Graph::hub_demo(nodes)
            } else {
                Graph::demo(nodes)
            };
            let (mut flow, scenario) =
                scenarios::Scenario::new(&scenario_name, graph).map_err(std::io::Error::other)?;
            Elm::update(&mut flow, Msg::Clustered(layout == "hub"));
            let adapter = json!({
                "name": info.name,
                "backend": format!("{:?}", info.backend),
                "device_type": format!("{:?}", info.device_type),
                "driver": info.driver,
            });
            Ok(Box::new(Lab {
                flow,
                scenario,
                introspection_ms: Vec::new(),
                layout,
                theme,
                search: String::new(),
                last_search: String::new(),
                filtered: (0..nodes).collect(),
                benchmark,
                preview,
                result_dir,
                started: now,
                previous: now,
                phase: 0,
                phase_start: now,
                samples: Vec::new(),
                results: Vec::new(),
                screenshot_sent: false,
                size: [0.0; 2],
                ppp: 0.0,
                adapter,
            }))
        }),
    )
    .map_err(Into::into)
}
/// `<target>/flow-lab`, from where cargo put this example
/// (`<target>/<profile>/examples/flow_lab`); the temp dir if that cannot be read.
fn default_result_dir() -> PathBuf {
    std::env::current_exe()
        .ok()
        .and_then(|exe| exe.ancestors().nth(3).map(|target| target.join("flow-lab")))
        .unwrap_or_else(|| std::env::temp_dir().join("facett-flow-lab"))
}