Skip to content

Commit

Permalink
Add multithreaded pathfinding usable for long distances (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
Rikatemu authored Jun 14, 2024
1 parent 3deafdb commit d8b62ed
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 10 deletions.
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[package]
name = "repath"
version = "0.0.6"
version = "0.0.7"
edition = "2021"
authors = ["Jaroslav Patočka <[email protected]>"]
description = "A fast pathfinding library using A* algorithm, caching and precomputation."
description = "A fast pathfinding library using A* algorithm, caching, precomputation and path segmentation with concurrent pathfinding."
readme = "README.md"
keywords = ["pathfinding", "game", "server", "gameserver", "navmesh"]
categories = ["game-development", "algorithms", "data-structures"]
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ RePath's speed comes from its combination of precomputation, efficient search al
- **Precomputation**: Quickly precomputes random paths in parallel using [Rayon](https://crates.io/crates/rayon) and stores them in a cache.
- **LRU Cache**: Efficient memory usage and quick access to recent paths.
- **Scalable**: Handles large game worlds and numerous NPCs.
- **Multithreading**: Utilizes multiple threads for both precomputation and pathfinding.

## Usage

Expand All @@ -31,7 +32,7 @@ Add RePath to your `Cargo.toml`:

```toml
[dependencies]
repath = "0.0.6"
repath = "0.0.7"
```

Make sure you have the OBJ file containing the navmesh in the same directory as your project.
Expand All @@ -58,16 +59,25 @@ fn main() {
let start_coords = (0.0, 0.0, 0.0);
let end_coords = (10.0, 10.0, 10.0);

// Find a path from start to end coordinates
// Find a path from start to end coordinates using single thread (good for short distances)
if let Some(path) = pathfinder.find_path(start_coords, end_coords) {
println!("Found path: {:?}", path);
} else {
println!("No path found.");
}

// Find a path from start to end coordinates using multiple threads (good for long distances)
// This should not be used for short distances as it can be slower than single thread because of segmentation and multithreading overhead
let segment_count = 2; // Splits the path into two segments and calculates them in parallel
if let Some(path) = pathfinder.find_path_multithreaded(start_coords, end_coords, segment_count) {
println!("Found path: {:?}", path);
} else {
println!("No path found.");
}
}
```

### Benchmark
### Benchmark - Single Threaded Pathfinding

The following graphs show the performance of RePath in pathfinding scenarios. The benchmark was conducted on i7-9700K CPU with 16GB DDR4 RAM with these settings:

Expand All @@ -91,7 +101,7 @@ Results can vary a lot, depending if the path was already cached or not. In shor

Precomputation is experimental right now and it seems the benefits are not that big, because on large maps it's very unlikely that the same path will be found again. However, it can be useful for small maps or navmeshes with smaller number of vertices and faces. For short distances you can get a sub-millisecond pathfinding time.

While the precomputation is multithreaded, the pathfinding itself is not. This is because the pathfinding is usually very fast and multithreading it would not bring any benefits for short distances. However if you need to find paths for very long distances, you can calculate middle point between start and end and then calculate paths from start to middle and from middle to end in parallel.
You can use `find_path_multithreaded` method to calculate paths in parallel. This is useful for long distances, but it's not recommended for short distances because of the overhead of splitting the path into segments and calculating them in parallel.

### License

Expand Down
16 changes: 12 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,20 @@ mod tests {

// Define start and end coordinates for pathfinding
let start_coords = (0.0, 0.0, 0.0);
let end_coords = (10.0, 10.0, 10.0);
let end_coords = (40.0, 40.0, 40.0);

let path = pathfinder.find_path(start_coords, end_coords);
// Find path using a single thread
let start = std::time::Instant::now();
let path1 = pathfinder.find_path(start_coords, end_coords);
println!("Time to find path singlethreaded: {:?}", start.elapsed());

println!("{:?}", path);
assert!(path1.is_some());

assert!(path.is_some());
// Find path using multiple threads
let start = std::time::Instant::now();
let path2 = pathfinder.find_path_multithreaded(start_coords, end_coords, 2);
println!("Time to find path multithreaded: {:?}", start.elapsed());

assert!(path2.is_some());
}
}
61 changes: 61 additions & 0 deletions src/pathfinder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use rand::prelude::*;

/// The RePathfinder struct holds the graph and cache used for pathfinding.
pub struct RePathfinder {
graph: Graph,
cache: Arc<Mutex<LruCache<(usize, usize), Option<Vec<(Node, u64)>>>>>,
}

impl RePathfinder {
/// Creates a new RePathfinder instance with the given settings.
/// This includes loading the graph from the provided navmesh file and precomputing paths.
pub fn new(settings: RePathSettings) -> Self {
let graph = parse_obj(&settings.navmesh_filename);
let cache_capacity = NonZeroUsize::new(settings.cache_capacity).expect("Capacity must be non-zero");
Expand All @@ -23,6 +26,7 @@ impl RePathfinder {
let node_ids: Vec<_> = graph.nodes.keys().cloned().collect();
let processed_paths = Arc::new(std::sync::atomic::AtomicUsize::new(0));

// Precompute paths between random pairs of nodes within a specified radius
(0..settings.total_precompute_pairs).into_par_iter().for_each(|_| {
let mut rng = rand::thread_rng();
let start_node_id = *node_ids.choose(&mut rng).unwrap();
Expand Down Expand Up @@ -50,10 +54,67 @@ impl RePathfinder {
RePathfinder { graph, cache }
}

/// Finds a path from start_coords to end_coords using a single thread.
/// This function uses the A* algorithm and the precomputed cache for efficient pathfinding.
pub fn find_path(&self, start_coords: (f64, f64, f64), end_coords: (f64, f64, f64)) -> Option<Vec<(Node, u64)>> {
let start_node_id = self.graph.nearest_node(start_coords.0, start_coords.1, start_coords.2)?;
let end_node_id = self.graph.nearest_node(end_coords.0, end_coords.1, end_coords.2)?;

self.graph.a_star(start_node_id, end_node_id, &self.cache)
}

/// Finds a path from start_coords to end_coords using multiple threads.
/// This function splits the pathfinding task into segments, which are processed concurrently.
///
/// Note: Use this function only for long paths. For shorter paths, the overhead of multithreading may result in slower performance compared to the single-threaded version.
/// Additionally, the resulting path may be slightly different due to the segmentation and concurrent processing.
pub fn find_path_multithreaded(&self, start_coords: (f64, f64, f64), end_coords: (f64, f64, f64), segment_count: u8) -> Option<Vec<(Node, u64)>> {
if segment_count <= 1 {
return self.find_path(start_coords, end_coords);
}

// Calculate intermediate points
let mut points = vec![start_coords];
for i in 1..segment_count {
let t = i as f64 / segment_count as f64;
let intermediate_point = (
start_coords.0 + t * (end_coords.0 - start_coords.0),
start_coords.1 + t * (end_coords.1 - start_coords.1),
start_coords.2 + t * (end_coords.2 - start_coords.2),
);
points.push(intermediate_point);
}
points.push(end_coords);

// Debug intermediate points
println!("Intermediate points: {:?}", points);

// Create tasks for each segment
let segments: Vec<_> = points.windows(2).collect();
let paths: Vec<_> = segments.into_par_iter()
.map(|segment| {
println!("Segment: {:?}", segment);
let start_node_id = self.graph.nearest_node(segment[0].0, segment[0].1, segment[0].2)?;
let end_node_id = self.graph.nearest_node(segment[1].0, segment[1].1, segment[1].2)?;
let path = self.graph.a_star(start_node_id, end_node_id, &self.cache);
println!("Path for segment: {:?}", path);
path
})
.collect::<Vec<_>>();

// Combine paths
let mut full_path = Vec::new();
for path_option in paths {
if let Some(mut path) = path_option {
if !full_path.is_empty() {
path.remove(0); // Remove duplicate node
}
full_path.append(&mut path);
} else {
return None; // If any segment fails, the whole path fails
}
}

Some(full_path)
}
}
14 changes: 14 additions & 0 deletions src/settings.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
use serde::{Serialize, Deserialize};

/// Configuration settings for the RePathfinder.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RePathSettings {
/// The filename of the navigation mesh in Wavefront OBJ format.
pub navmesh_filename: String,

/// The radius within which to precompute paths between nodes.
/// Higher values will result in longer precomputation times but faster pathfinding for long distances.
pub precompute_radius: f64,

/// The total number of node pairs for which paths will be precomputed.
/// Higher values will result in longer precomputation times but more efficient pathfinding.
pub total_precompute_pairs: usize,

/// The capacity of the LRU cache for storing precomputed paths.
/// Higher values allow more paths to be stored but will use more memory.
pub cache_capacity: usize,

/// Whether to use the precomputed cache for pathfinding.
/// Set to false to disable the use of precomputed paths.
pub use_precomputed_cache: bool,
}

0 comments on commit d8b62ed

Please sign in to comment.