From 6527d52adf80d41f0b3877501fa7ae8c8ee505a3 Mon Sep 17 00:00:00 2001 From: Michael de Gans Date: Sat, 15 Jun 2024 16:29:22 -0700 Subject: [PATCH] Fix force directed layout speed Fix Force directed layout slower on 60hz displays #16 The time step of the force-directed layout is now based on the current frame rate. Dragging a window between a 60hz display and a 120hz display now works propertly. This should even account for frame dropping. The time step is smoothed with each new step being averaged with the last acounting for frame pacing issues and jitter. Additionally, a speed slider has been added to the UI to control a multiplier to the time step. --- resources/speed.png | Bin 0 -> 1373 bytes resources/speed.svg | 1 + src/app.rs | 32 ++++++++++++++++++++++++++++++-- src/node.rs | 44 ++++++++++++++++++++++++++++++++------------ src/story.rs | 13 +++++++++---- 5 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 resources/speed.png create mode 100644 resources/speed.svg diff --git a/resources/speed.png b/resources/speed.png new file mode 100644 index 0000000000000000000000000000000000000000..a5e90a564ea02e807c7d12ecaf820d9c295f54b8 GIT binary patch literal 1373 zcmb8v`#;lr0LSsqmrZh?QX1Njhr4x<!Cd~lA#{TrIrd&ikVTN$eLJUoPvT+QF)jE#5HIDNiob$u`{rnGJxBW;ynrcWj0DvZufG2Mm_U}|x zwq8=0=)46KPxd(scs+)*0Kj(>@g4!!PJQD<2a5J<+?t%#5>C7-Vi!Qx;Y~;j!=@Xg zq`Vw+D&es;r6F(6JePAOWLd$B_<2FKV6)Il$G1HBFCnB~EP$LrI6S8#S>->=h=iZQ zy3-qMwVId5b=sLNK4L|UV!M1be z^VKw8KKhKC&#IU6lyH0dXDXjc=dRnLeIdtosY}UBuL^#!LpNhPh@R*wwDH&pf8jL2 z#pU;8oB?BFn{U2ceENpmaq(nkhiCPm|5Z>TO~iyEE{tTR7;U&e&~0^doeBLS{oc#f zdD>O4FJ7ZxYhX@#mAy^8wb8;rh1Zb0he5^dRM*$mp%q2f6L6n%{Kl3%J)+%pSMtgncTEM$}0LJ1Z}a zEwmsfJw~t^{1K2@Z7bjqTG9UFnLePQmGc zX`5LSKNfrBSbWG_v3-n-UDOc^<@$5cs(ZU|LZHVCNy?Zso@dPyL2#PIPtK`au@93i z*=W(QZ5`FN^mGK0566+}Wz%!P=qTLVfg*>5U zEXE;^CN=vN!By6AP4^OA8M)ByB`K%gVHV)wAfhNz1y>@7U0XV$P1lVF=I$f1E_+4> ziH#Pf1sjay-=9ThuVFJ+6~`REAwD!g7AcLUTq0RBX&GE@YVA%}0s}G0L1l%Pyli8s zUqzC^-5qs9H>Xjuca)?TM^`mKpIvcJnn;GZiYS*soD-=Pm08J1D`R&h@NgS{`=D0E zps&@B7brD|Qva6Lq**wGI$jEgeA+~>b}9F#)YeX!i>sB>g@b-mRkvURm&K|3bH~jp z6nxtVUP9!fn6{&awJ_uAz%wWu-f~#Q@-`td792hwu?EdyIS`?VQea$0CyLQ-ju2Wd zFe@;w`CIucXwVM$qcS2baP2gN8<6SNf;jAqozlg2MP1A~|2_XKG`-<%J+-*wKqMrn zMN?BpX=iig6tk*crLT2u#V5#(AXn}M?jbEW^3Tn2VvG{Qhj>^EN2sgRgMv8qOlo%8o0ymrEHAFal9;{fZiN%I|2*Yo~>b^5W7Zee;-Y#ni{ mM31xtkiK=8;h4k!a|6|B(2{GUmu9rpOF;A@;dzI{(*FS#zi*KM literal 0 HcmV?d00001 diff --git a/resources/speed.svg b/resources/speed.svg new file mode 100644 index 0000000..08c3c7c --- /dev/null +++ b/resources/speed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index ab70eab..9e997d3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -42,7 +42,6 @@ impl RightSidebarPage { } } } - #[derive(Default)] struct RightSidebar { pub text: Option, @@ -99,6 +98,8 @@ pub struct App { settings: Settings, left_sidebar: LeftSidebar, right_sidebar: RightSidebar, + last_frame_time: f64, + time_step: f64, /// Temporary node storage for copy/paste. node_clipboard: Option>, /// Modal error messages. @@ -197,12 +198,19 @@ impl App { }) .unwrap_or_default(); + let mut last_frame_time = 0.0; + ctx.input(|state| { + last_frame_time = state.time; + }); + #[allow(unused_mut)] let mut new = Self { stories, settings, active_story: None, trash, + last_frame_time, + time_step: 1.0 / 60.0, ..Default::default() }; @@ -216,6 +224,19 @@ impl App { new } + /// Update frame time. + pub fn update_time_step(&mut self, ctx: &egui::Context) { + let mut this_frame_time = 0.0; + ctx.input(|state| { + this_frame_time = state.time; + }); + self.time_step = (self.time_step + + (this_frame_time - self.last_frame_time).max(1.0 / 15.0)) + / 2.0; + self.last_frame_time = this_frame_time; + } + + /// Add a new story. pub fn new_story(&mut self, title: String, author: String) { self.stories.push(Story::new(title, author)); self.active_story = Some(self.stories.len() - 1); @@ -512,6 +533,9 @@ impl App { // function is running since it is not accessible from any other // thread. + // Time step for custom simulations and animations. + let time_step = self.time_step as f32; + egui::SidePanel::right("right_sidebar") .default_width(200.0) .resizable(true) @@ -609,7 +633,7 @@ impl App { let layout = self.settings.layout.clone(); if let Some(story) = self.story_mut() { if let Some(action) = - story.draw(ui, lock_topology, layout, DrawMode::Tree) + story.draw(ui, lock_topology, layout, DrawMode::Tree, time_step) { self.handle_story_action(action); } @@ -864,6 +888,8 @@ impl App { ctx: &eframe::egui::Context, _frame: &mut eframe::Frame, ) { + let time_step = self.time_step as f32; + egui::CentralPanel::default().show(ctx, |ui| { let mut new_pieces = Vec::new(); @@ -901,6 +927,7 @@ impl App { generation_in_progress, layout, DrawMode::Nodes, + time_step, ) { self.handle_story_action(action) } @@ -1407,6 +1434,7 @@ impl eframe::App for App { ctx: &eframe::egui::Context, frame: &mut eframe::Frame, ) { + self.update_time_step(ctx); if self.handle_errors(ctx) { // An error message is displayed. We skip the rest of the UI. This // is how we do "modal" in egui. diff --git a/src/node.rs b/src/node.rs index bbd585e..84972dd 100644 --- a/src/node.rs +++ b/src/node.rs @@ -9,10 +9,6 @@ pub struct Piece { pub end: usize, } -/// Time step for the force-directed layout. -// FIXME: This should be a parameter and based on the (previous) frame time -// or perhaps the average over several frames. -const TIME_STEP: f32 = 1.0 / 60.0; /// Damping factor for the force-directed layout. const DAMPING: f32 = 0.10; /// Boundary damping factor when nodes hit the boundaries and bounce back. @@ -126,6 +122,8 @@ pub enum PositionalLayout { /// How much nodes should be attracted to the centroid. This is inverse /// square. gravity: f32, + /// How fast the layout should converge. + speed: f32, }, } @@ -144,6 +142,7 @@ impl PositionalLayout { repulsion: 125.0, attraction: 2.5, gravity: 1.0, + speed: 1.0, } } @@ -154,6 +153,7 @@ impl PositionalLayout { repulsion, attraction, gravity, + speed, } => { ui.horizontal(|ui| { crate::icon!(ui, "../resources/expand.png", 24.0) @@ -179,6 +179,13 @@ impl PositionalLayout { ) }) .response + | ui.horizontal(|ui| { + crate::icon!(ui, "../resources/speed.png", 24.0) + | ui.add(egui::Slider::new(speed, 0.0..=10.0)) + .on_hover_text_at_pointer( + "How fast the layout should converge. Increasing this can help with many small nodes.", + ) + }).response } } } @@ -197,6 +204,7 @@ impl PositionalLayout { debug: Option<&mut egui::Ui>, global_centroid: Pos2, global_cum_mass: f32, + mut time_step: f32, ) -> bool { let mut redraw = false; @@ -205,6 +213,7 @@ impl PositionalLayout { repulsion, attraction, gravity, + speed, } => { // The general idea is for nodes to repel each other with // inverse square force and attract to each other with linear @@ -225,6 +234,8 @@ impl PositionalLayout { // are attracted to a weighted average of these centroids. This // is to keep the tree centered and balanced. + time_step *= speed; + let mut stack = vec![(node, None)]; while let Some((node, parent_meta)) = stack.pop() { // Apply damping to the velocity. @@ -284,7 +295,7 @@ impl PositionalLayout { .normalized(); // Children always repel each other. - node.children[i].meta.vel += force * TIME_STEP; + node.children[i].meta.vel += force * time_step; } // Repel parent node (if any) @@ -299,7 +310,7 @@ impl PositionalLayout { // Repulsion from parent should be stronger. This // helps make the tree more balanced and tree-like. node.children[i].meta.vel += - force * LOCAL_GLOBAL_RATIO * TIME_STEP; + force * LOCAL_GLOBAL_RATIO * time_step; cum_mass += parent.mass(); centroid += parent.pos.to_vec2(); } @@ -368,7 +379,7 @@ impl PositionalLayout { let dist = node.meta.pos.distance(centroid); let force = gravity * mass * cum_mass / dist.powi(2) * (centroid - node.meta.pos).normalized(); - node.meta.vel += force * TIME_STEP; + node.meta.vel += force * time_step; } // Bounce off the boundaries. Thanks to Bing's Copilot for @@ -417,9 +428,9 @@ impl PositionalLayout { let force = attraction_force - repulsion_force; if !rect.intersects(child_rect) { - child.meta.vel += force * TIME_STEP; + child.meta.vel += force * time_step; } else { - child.meta.vel -= force * TIME_STEP; + child.meta.vel -= force * time_step; child.meta.vel *= BOUNDARY_DAMPING; } @@ -739,6 +750,7 @@ impl Node { active_path: Option<&[usize]>, lock_topology: bool, layout: Layout, + time_step: f32, ) -> Option { let active_path = active_path.unwrap_or(&[]); let mut ret = None; // the default, meaning no action is needed. @@ -808,6 +820,7 @@ impl Node { layout, global_centroid, global_cum_mass, + time_step, ) { if action.delete { // How to delete a node? We're taking a reference to the @@ -995,6 +1008,7 @@ impl Node { layout: Layout, global_centroid: Pos2, global_cum_mass: f32, + time_step: f32, ) -> Option { // because this is only used in debug builds. #[allow(unused_assignments)] @@ -1011,6 +1025,7 @@ impl Node { }, global_centroid, global_cum_mass, + time_step, ); if repaint { // Positions have changed, request a repaint. @@ -1126,13 +1141,18 @@ impl Node { lock_topology: bool, layout: Layout, mode: crate::story::DrawMode, + time_step: f32, ) -> Option { use crate::story::DrawMode; match mode { - DrawMode::Nodes => { - self.draw_nodes(ui, selected_path, lock_topology, layout) - } + DrawMode::Nodes => self.draw_nodes( + ui, + selected_path, + lock_topology, + layout, + time_step, + ), DrawMode::Tree => { egui::ScrollArea::vertical() .show(ui, |ui| { diff --git a/src/story.rs b/src/story.rs index d3706e9..78d6a11 100644 --- a/src/story.rs +++ b/src/story.rs @@ -166,16 +166,21 @@ impl Story { lock_topology: bool, layout: crate::node::Layout, mode: DrawMode, + time_step: f32, ) -> Option { use crate::node::PathAction; let selected_path = self.active_path.as_ref().map(|v| v.as_slice()); // Draw, and update active path if changed. - if let Some(PathAction { path, mut action }) = - self.root - .draw(ui, selected_path, lock_topology, layout, mode) - { + if let Some(PathAction { path, mut action }) = self.root.draw( + ui, + selected_path, + lock_topology, + layout, + mode, + time_step, + ) { if !lock_topology { // Any action unless we're locked should update the active path. self.active_path = Some(path);