Skip to content

Commit

Permalink
Fix force directed layout speed
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mdegans committed Jun 15, 2024
1 parent a19c3b8 commit 6527d52
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 18 deletions.
Binary file added resources/speed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions resources/speed.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 30 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ impl RightSidebarPage {
}
}
}

#[derive(Default)]
struct RightSidebar {
pub text: Option<String>,
Expand Down Expand Up @@ -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<Node<Meta>>,
/// Modal error messages.
Expand Down Expand Up @@ -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()
};

Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -901,6 +927,7 @@ impl App {
generation_in_progress,
layout,
DrawMode::Nodes,
time_step,
) {
self.handle_story_action(action)
}
Expand Down Expand Up @@ -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.
Expand Down
44 changes: 32 additions & 12 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
},
}

Expand All @@ -144,6 +142,7 @@ impl PositionalLayout {
repulsion: 125.0,
attraction: 2.5,
gravity: 1.0,
speed: 1.0,
}
}

Expand All @@ -154,6 +153,7 @@ impl PositionalLayout {
repulsion,
attraction,
gravity,
speed,
} => {
ui.horizontal(|ui| {
crate::icon!(ui, "../resources/expand.png", 24.0)
Expand All @@ -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
}
}
}
Expand All @@ -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;

Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -739,6 +750,7 @@ impl Node<Meta> {
active_path: Option<&[usize]>,
lock_topology: bool,
layout: Layout,
time_step: f32,
) -> Option<PathAction> {
let active_path = active_path.unwrap_or(&[]);
let mut ret = None; // the default, meaning no action is needed.
Expand Down Expand Up @@ -808,6 +820,7 @@ impl Node<Meta> {
layout,
global_centroid,
global_cum_mass,
time_step,
) {
if action.delete {
// How to delete a node? We're taking a reference to the
Expand Down Expand Up @@ -995,6 +1008,7 @@ impl Node<Meta> {
layout: Layout,
global_centroid: Pos2,
global_cum_mass: f32,
time_step: f32,
) -> Option<Action> {
// because this is only used in debug builds.
#[allow(unused_assignments)]
Expand All @@ -1011,6 +1025,7 @@ impl Node<Meta> {
},
global_centroid,
global_cum_mass,
time_step,
);
if repaint {
// Positions have changed, request a repaint.
Expand Down Expand Up @@ -1126,13 +1141,18 @@ impl Node<Meta> {
lock_topology: bool,
layout: Layout,
mode: crate::story::DrawMode,
time_step: f32,
) -> Option<PathAction> {
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| {
Expand Down
13 changes: 9 additions & 4 deletions src/story.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,21 @@ impl Story {
lock_topology: bool,
layout: crate::node::Layout,
mode: DrawMode,
time_step: f32,
) -> Option<crate::node::Action> {
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);
Expand Down

0 comments on commit 6527d52

Please sign in to comment.