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"))
}