FastFlow | Fast flowfield generation for performant swarm pathfinding

FastFlow

Fast flowfield generation for performant swarm pathfinding

| Download Module | Play Demonstration (Uncopylocked) |


Introduction


Roblox’s built-in pathfinding service is great for the general use case. However, in RTS or zombie games, running the pathfinder for each individual unit can quickly become inefficient. A common solution is to use a flowfield, which tells units what direction to move in when given their location:

Regardless of how many units we move to the target, the flowfield only needs to be generated once, making it great in cases where a swarm is drawn to a single static goal.

Walkthrough


First, we need to represent the pathfinding environment as a grid. This must be done using FastFlow's built-in grid module:
local Storage = game:GetService("ReplicatedStorage")
local FastFlow = require(Storage.FastFlow)
local Grid = FastFlow.Grid

-- Create a new grid object with a given max coordinate (size)
local maximumCoordinate = 20
local map = Grid.New(maximumCoordinate)

-- Set the value of cell (x, y) (integer coordinates)
local x, y = 0, 0
local value = 0
map:SetCell(Vector2.new(x, y), value)

-- Get the value of cell (x, y) (integer coordinates)
map:GetCell(Vector2.new(x, y))

-- Set intraversable cells (walls) to true, leave all other cells as nil

For our example, we will use a map generated through perlin noise:

Note that the grid must have cells along its borders marked as walls
(FastFlow will do this automatically)

Here's how we could setup a grid representing the noisemap
local seed = Vector2.new(0, 3)
local resolution = 10

for x = -maximumCoordinate, maximumCoordinate, 1 do
	for y = -maximumCoordinate, maximumCoordinate, 1 do
		if math.noise(x / resolution + seed.X, y / resolution + seed.Y) > 0 then
			map:SetCell(Vector2.new(x, y), true)
		end
	end
end

Now, we can use this grid to create a pathfinder and generate a simple full-map flowfield:

-- Create a pathfinder using our newly constructed grid
local pathfinder = FastFlow.NewPathfinder(map)

-- Generate a flowfield leading to a given goal
local goal = Vector2.new(1, 1)
local flowfield = pathfinder:GenerateFlowfield(goal)

-- Obtain the direction of the flowfield at a given cell
local position = Vector2.new(0, 0)
local direction = flowfield:GetDirection(position) -- returns a unit vector


The goal cell is marked as red (look at the center)

However, generating full-map flowfields is expensive. FastFlow can generate pruned flowfields when given starting positions:
-- Generate a pruned flowfield using a table of starting cells
local goal = Vector2.new(1, 1)
local start = Vector2.new(-15, -15)
local flowfield = pathfinder:GenerateFlowfield(goal, {start})


The start cell is marked as blue (look at the bottom left corner)
Grid lines now show chunks rather than cells

Here's how the size of chunks can be adjusted

The grid lines have been adjusted to outline chunks rather than cells. The width of these chunks can be adjusted in a pathfinder’s constructor:

-- Create a pathfinder with a chunk width of size * 2 + 1
local chunkSize = 5
local pathfinder = FastFlow.NewPathfinder(map, chunkSize)

Now imagine that a pathfinding agent is pushed out of the flowfield zone. We can extend the flowfield to neighboring chunks:

-- Generate a pruned flowfield
local flowfield = pathfinder:GenerateFlowfield(goal, {start})

-- Sample the flowfield at an out-of-bounds position
local position = Vector2.new(-10, -10)
if not flowfield:GetDirection(position) then -- returns nil
	pathfinder:MergeFlowfield(flowfield, position)
end


The extended area is marked as green

Documentation


Grid


GridStatic FastFlow.Grid


Grid GridStatic.New(int maxCoordinate, any fill = nil)


void Grid:SetCell(Vector2 pos, any value)


any Grid:GetCell(Vector2 pos)


boolean Grid:IsCellInBounds(Vector2 pos)


void Grid:ClearGrid()


Pathfinder


Pathfinder FastFlow.NewPathfinder(Grid walls, int chunkSize = 4, boolean omitPreprocessing = false)


Flowfield Pathfinder:GenerateFlowfield(Vector2 goal, {Vector2} startPositions = {})


Flowfield Pathfinder:MergeFlowfield(Flowfield Path, Vector2 start)


Vector2 Pathfinder:FindOpenCell(Vector2 pos)


void Pathfinder:Visualize(number cellWidth = 1, number yLevel = 0, boolean showWalls = false, boolean showCellGrid = false, boolean showChunkGrid = false, boolean showHPA = false)


void Pathfinder:SetupBorders()


void Pathfinder:SetupPruning()


Flowfield


Vector2 Flowfield:GetDirection(Vector2 pos)


number Flowfield:GetDistance(Vector2 pos)


Performance


All tests are performed on a MacBook Pro 2020 M1 chip
The map is a 101 x 101 perlin noise grid

Preprocessing
Pruned Flowfield
Full Flowfield

11.76 ms
1.93 ms
9.41 ms
78 Likes

You know, earlier today I was actually wondering how games like combat imitation are able to handle 120 enemies which can path find without exhausting server resources, and while I am sure they just split the work between clients.

This answers my original question as to how to optimize tons of AI’s without any workarounds or tricks, this seems like a cool resource, good job.

4 Likes

This is a very great module. The documentation section can be better, though, as certain types are not present on the Engine. And perhaps a tutorial video might be great.

Am I missing something?

My bad, I made a few bugfixes and uploaded a new model but forgot to make it public
The issue should be fixed now

2 Likes

Now this is used for an RPG game ,and his pathfinding looks like old but fine

Is the an editable play place or a .rbxl to check out?

Thanks @bob_factory

hey, do you mind sharing an uncopylocked place or .rbxl file for better understanding of your module?

I have absolutely no idea how this works. Absolutely no idea what was explained in the post. But I’m getting the message that this is a great module. So good work!

1 Like

Here’s an uncopylocked demonstration of FastFlow in action (sorry this took so long, I’ve been busy recently):

I also released a new version of FastFlow:

  • Throws a warning when noninteger coordinates are inputted, instead of simply spamming the console with errors
  • Added a few new functions like grid clearing and pathfinder visualization
  • Bug fixes

When I have time, I’ll release an update to support more dynamic maps

2 Likes

How do you make it scale up wedges / slanted platforms?

Do you mean representing a slanted wall on the map grid?
image
I haven’t provided any built-in rasterization functions, but if this is what you mean, I’ll send some code tomorrow.

Sorry, I misunderstood. I think what you really meant is how the soldiers walk up ramps in the demonstration video

The soldiers aren’t controlled by roblox physics - they’re anchored and I CFrame them manually every heartbeat. I run my own 2d physics engine in the background (for the implementation, check out the uncopylocked place provided above), then find the respective height of each 2d position.

To calculate the height of any horizontal position:
I divide the map using a spatial hashgrid, which basically provides the highest (and therefore height-determining) part within each grid cell. When a horizontal position is provided, I can instantly look up this part. If it’s a rectangle, great! Simply add size.Y/2 + position.Y for the height. Otherwise, if its a wedge, you can either use dot products or slope to find the height.

Gotcha, I’ll implement that into the uncopylocked code and see how it works, Thanks!

Seems like a very nice module and I am looking forward to trying to use this but is it possible to make this work with roblox terrain? I haven’t really used the module in great depth yet unfortunately but if it could that’d be really awesome.

It should be able to work as long as if the terrain only has one height for each horizontal position - otherwise, you’ll need to modify the module itself. The main challenge is converting the terrain to a 2d grid. From there, you can simply plug the grid into the fastflow module.

Here’s an example:

local Storage = game:GetService("ReplicatedStorage")
local FastFlow = require(Storage.FastFlow)
local Grid = FastFlow.Grid

local RAYCAST_HEIGHT = 20 -- terrain height limit
local RAYCAST_LENGTH = 40 -- terrain height range
local TERRAIN_FILTER = RaycastParams.new() -- filter to raycast for terrain only
TERRAIN_FILTER.FilterDescendantsInstances = {workspace.Terrain}
TERRAIN_FILTER.FilterType = Enum.RaycastFilterType.Include

local MAX_WALKABLE_SLOPE = 2 -- maximum walkable slope (anything steeper will be treated as a wall)
local MAP_WIDTH = 400 -- the width of the map, in studs
local CELL_WIDTH = 4 -- the width of each cell in the map grid, in studs
local MAX_GRID_COORD = math.ceil(MAP_WIDTH / CELL_WIDTH / 2)
local MAP = Grid.New(MAX_GRID_COORD)

for x = -MAX_GRID_COORD, MAX_GRID_COORD, 1 do
	for y = -MAX_GRID_COORD, MAX_GRID_COORD, 1 do
		local ray = workspace:Raycast(Vector3.new(x * CELL_WIDTH, RAYCAST_HEIGHT, y * CELL_WIDTH), Vector3.new(0, -RAYCAST_LENGTH, 0), TERRAIN_FILTER)
		
		if ray and math.sqrt(ray.Normal.X * ray.Normal.X + ray.Normal.Z * ray.Normal.Z) / ray.Normal.Y > MAX_WALKABLE_SLOPE then
			MAP:SetCell(Vector2.new(x, y), true)
		end
	end
end

local Pathfinder = FastFlow.NewPathfinder(MAP)

Basically, it overlays a grid above a square map centered on (0, 0), measures the terrain’s slope in each cell through a downward raycast, and marks the cells as walls if the slope is too steep.

2 Likes

Absolutely amazing resource, ive spent the past few hours adding support for walls with rotation and a bunch of other cool dynamic stuff, thanks a lot for this.

How can i create an arrow visualization like you have here of all the chunks, so far all im able to do is just get the generated directions created when GetDirection() is called

image

1 Like

看不懂,大佬牛逼

字数不够