Move path computations to an actual worker to prepare for threading.

This moves the "async" pathfinding computations to a worker, preparing
the architecture for threading.

Tested By: Kuba386, Stan`
Differential Revision: https://code.wildfiregames.com/D1918
This was SVN commit r22902.
This commit is contained in:
wraitii 2019-09-15 09:27:10 +00:00
parent 2333b1814e
commit d592bf9cb6
6 changed files with 198 additions and 141 deletions

View file

@ -540,8 +540,8 @@ void CSimulation2Impl::UpdateComponents(CSimContext& simContext, fixed turnLengt
CmpPtr<ICmpPathfinder> cmpPathfinder(simContext, SYSTEM_ENTITY);
if (cmpPathfinder)
{
cmpPathfinder->FetchAsyncResultsAndSendMessages();
cmpPathfinder->UpdateGrid();
cmpPathfinder->FinishAsyncRequests();
}
// Push AI commands onto the queue before we use them
@ -555,14 +555,17 @@ void CSimulation2Impl::UpdateComponents(CSimContext& simContext, fixed turnLengt
// Process newly generated move commands so the UI feels snappy
if (cmpPathfinder)
cmpPathfinder->ProcessSameTurnMoves();
{
cmpPathfinder->StartProcessingMoves(true);
cmpPathfinder->FetchAsyncResultsAndSendMessages();
}
// Send all the update phases
{
PROFILE2("Sim - Update");
CMessageUpdate msgUpdate(turnLengthFixed);
componentManager.BroadcastMessage(msgUpdate);
}
{
CMessageUpdate_MotionFormation msgUpdate(turnLengthFixed);
componentManager.BroadcastMessage(msgUpdate);
@ -570,7 +573,10 @@ void CSimulation2Impl::UpdateComponents(CSimContext& simContext, fixed turnLengt
// Process move commands for formations (group proxy)
if (cmpPathfinder)
cmpPathfinder->ProcessSameTurnMoves();
{
cmpPathfinder->StartProcessingMoves(true);
cmpPathfinder->FetchAsyncResultsAndSendMessages();
}
{
PROFILE2("Sim - Motion Unit");
@ -583,12 +589,12 @@ void CSimulation2Impl::UpdateComponents(CSimContext& simContext, fixed turnLengt
componentManager.BroadcastMessage(msgUpdate);
}
// Process moves resulting from group proxy movement (unit needs to catch up or realign) and any others
if (cmpPathfinder)
cmpPathfinder->ProcessSameTurnMoves();
// Clean up any entities destroyed during the simulation update
componentManager.FlushDestroyedComponents();
// Process all remaining moves
if (cmpPathfinder)
cmpPathfinder->StartProcessingMoves(false);
}
void CSimulation2Impl::Interpolate(float simFrameLength, float frameOffset, float realFrameLength)

View file

@ -55,8 +55,6 @@ void CCmpPathfinder::Init(const CParamNode& UNUSED(paramNode))
m_AtlasOverlay = NULL;
m_SameTurnMovesCount = 0;
m_VertexPathfinder = std::unique_ptr<VertexPathfinder>(new VertexPathfinder(m_MapSize, m_TerrainOnlyGrid));
m_LongPathfinder = std::unique_ptr<LongPathfinder>(new LongPathfinder());
m_PathfinderHier = std::unique_ptr<HierarchicalPathfinder>(new HierarchicalPathfinder());
@ -98,12 +96,16 @@ void CCmpPathfinder::Init(const CParamNode& UNUSED(paramNode))
m_PassClasses.push_back(PathfinderPassability(mask, it->second));
m_PassClassMasks[name] = mask;
}
m_Workers.emplace_back(PathfinderWorker{});
}
CCmpPathfinder::~CCmpPathfinder() {};
void CCmpPathfinder::Deinit()
{
m_Workers.clear();
SetDebugOverlay(false); // cleans up memory
SAFE_DELETE(m_AtlasOverlay);
@ -149,7 +151,6 @@ void CCmpPathfinder::SerializeCommon(S& serialize)
SerializeVector<SerializeLongRequest>()(serialize, "long requests", m_LongPathRequests);
SerializeVector<SerializeShortRequest>()(serialize, "short requests", m_ShortPathRequests);
serialize.NumberU32_Unbounded("next ticket", m_NextAsyncTicket);
serialize.NumberU16_Unbounded("same turn moves count", m_SameTurnMovesCount);
serialize.NumberU16_Unbounded("map size", m_MapSize);
}
@ -184,9 +185,6 @@ void CCmpPathfinder::HandleMessage(const CMessage& msg, bool UNUSED(global))
m_TerrainDirty = true;
UpdateGrid();
break;
case MT_TurnStart:
m_SameTurnMovesCount = 0;
break;
}
}
@ -703,10 +701,46 @@ void CCmpPathfinder::TerrainUpdateHelper(bool expandPassability/* = true */)
//////////////////////////////////////////////////////////
// Async pathfinder workers
void CCmpPathfinder::ComputePath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const
CCmpPathfinder::PathfinderWorker::PathfinderWorker() {}
template<typename T>
void CCmpPathfinder::PathfinderWorker::PushRequests(std::vector<T>&, ssize_t)
{
m_LongPathfinder->ComputePath(*m_PathfinderHier, x0, z0, goal, passClass, ret);
static_assert(sizeof(T) == 0, "Only specializations can be used");
}
template<> void CCmpPathfinder::PathfinderWorker::PushRequests(std::vector<LongPathRequest>& from, ssize_t amount)
{
m_LongRequests.insert(m_LongRequests.end(), std::make_move_iterator(from.end() - amount), std::make_move_iterator(from.end()));
}
template<> void CCmpPathfinder::PathfinderWorker::PushRequests(std::vector<ShortPathRequest>& from, ssize_t amount)
{
m_ShortRequests.insert(m_ShortRequests.end(), std::make_move_iterator(from.end() - amount), std::make_move_iterator(from.end()));
}
void CCmpPathfinder::PathfinderWorker::Work(const CCmpPathfinder& pathfinder)
{
while (!m_LongRequests.empty())
{
const LongPathRequest& req = m_LongRequests.back();
WaypointPath path;
pathfinder.m_LongPathfinder->ComputePath(*pathfinder.m_PathfinderHier, req.x0, req.z0, req.goal, req.passClass, path);
m_Results.emplace_back(req.ticket, req.notify, path);
m_LongRequests.pop_back();
}
while (!m_ShortRequests.empty())
{
const ShortPathRequest& req = m_ShortRequests.back();
WaypointPath path = pathfinder.m_VertexPathfinder->ComputeShortPath(req, CmpPtr<ICmpObstructionManager>(pathfinder.GetSystemEntity()));
m_Results.emplace_back(req.ticket, req.notify, path);
m_ShortRequests.pop_back();
}
}
u32 CCmpPathfinder::ComputePathAsync(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, entity_id_t notify)
@ -716,118 +750,98 @@ u32 CCmpPathfinder::ComputePathAsync(entity_pos_t x0, entity_pos_t z0, const Pat
return req.ticket;
}
u32 CCmpPathfinder::ComputeShortPathAsync(entity_pos_t x0, entity_pos_t z0, entity_pos_t clearance, entity_pos_t range, const PathGoal& goal, pass_class_t passClass, bool avoidMovingUnits, entity_id_t group, entity_id_t notify)
u32 CCmpPathfinder::ComputeShortPathAsync(entity_pos_t x0, entity_pos_t z0, entity_pos_t clearance, entity_pos_t range,
const PathGoal& goal, pass_class_t passClass, bool avoidMovingUnits,
entity_id_t group, entity_id_t notify)
{
ShortPathRequest req = { m_NextAsyncTicket++, x0, z0, clearance, range, goal, passClass, avoidMovingUnits, group, notify };
m_ShortPathRequests.push_back(req);
return req.ticket;
}
WaypointPath CCmpPathfinder::ComputeShortPath(const ShortPathRequest& request) const
void CCmpPathfinder::ComputePathImmediate(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const
{
m_LongPathfinder->ComputePath(*m_PathfinderHier, x0, z0, goal, passClass, ret);
}
WaypointPath CCmpPathfinder::ComputeShortPathImmediate(const ShortPathRequest& request) const
{
return m_VertexPathfinder->ComputeShortPath(request, CmpPtr<ICmpObstructionManager>(GetSystemEntity()));
}
// Async processing:
void CCmpPathfinder::FinishAsyncRequests()
void CCmpPathfinder::FetchAsyncResultsAndSendMessages()
{
PROFILE2("Finish Async Requests");
// Save the request queue in case it gets modified while iterating
std::vector<LongPathRequest> longRequests;
m_LongPathRequests.swap(longRequests);
PROFILE2("FetchAsyncResults");
std::vector<ShortPathRequest> shortRequests;
m_ShortPathRequests.swap(shortRequests);
// TODO: we should only compute one path per entity per turn
// TODO: this computation should be done incrementally, spread
// across multiple frames (or even multiple turns)
ProcessLongRequests(longRequests);
ProcessShortRequests(shortRequests);
}
void CCmpPathfinder::ProcessLongRequests(const std::vector<LongPathRequest>& longRequests)
{
PROFILE2("Process Long Requests");
for (size_t i = 0; i < longRequests.size(); ++i)
// WARNING: the order in which moves are pulled must be consistent when using 1 or n workers.
// We fetch in the same order we inserted in, but we push moves backwards, so this works.
std::vector<PathResult> results;
for (PathfinderWorker& worker : m_Workers)
{
const LongPathRequest& req = longRequests[i];
WaypointPath path;
ComputePath(req.x0, req.z0, req.goal, req.passClass, path);
CMessagePathResult msg(req.ticket, path);
GetSimContext().GetComponentManager().PostMessage(req.notify, msg);
results.insert(results.end(), std::make_move_iterator(worker.m_Results.begin()), std::make_move_iterator(worker.m_Results.end()));
worker.m_Results.clear();
}
{
PROFILE2("PostMessages");
for (PathResult& path : results)
{
CMessagePathResult msg(path.ticket, path.path);
GetSimContext().GetComponentManager().PostMessage(path.notify, msg);
}
}
}
void CCmpPathfinder::ProcessShortRequests(const std::vector<ShortPathRequest>& shortRequests)
void CCmpPathfinder::StartProcessingMoves(bool useMax)
{
PROFILE2("Process Short Requests");
for (size_t i = 0; i < shortRequests.size(); ++i)
{
const ShortPathRequest& req = shortRequests[i];
WaypointPath path = m_VertexPathfinder->ComputeShortPath(req, CmpPtr<ICmpObstructionManager>(GetSystemEntity()));
CMessagePathResult msg(req.ticket, path);
GetSimContext().GetComponentManager().PostMessage(req.notify, msg);
}
std::vector<LongPathRequest> longRequests = PopMovesToProcess(m_LongPathRequests, useMax, m_MaxSameTurnMoves);
std::vector<ShortPathRequest> shortRequests = PopMovesToProcess(m_ShortPathRequests, useMax, m_MaxSameTurnMoves - longRequests.size());
PushRequestsToWorkers(longRequests);
PushRequestsToWorkers(shortRequests);
for (PathfinderWorker& worker : m_Workers)
worker.Work(*this);
}
void CCmpPathfinder::ProcessSameTurnMoves()
template <typename T>
std::vector<T> CCmpPathfinder::PopMovesToProcess(std::vector<T>& requests, bool useMax, size_t maxMoves)
{
if (!m_LongPathRequests.empty())
std::vector<T> poppedRequests;
if (useMax)
{
// Figure out how many moves we can do this time
i32 moveCount = m_MaxSameTurnMoves - m_SameTurnMovesCount;
if (moveCount <= 0)
return;
// Copy the long request elements we are going to process into a new array
std::vector<LongPathRequest> longRequests;
if ((i32)m_LongPathRequests.size() <= moveCount)
size_t amount = std::min(requests.size(), maxMoves);
if (amount > 0)
{
m_LongPathRequests.swap(longRequests);
moveCount = (i32)longRequests.size();
poppedRequests.insert(poppedRequests.begin(), std::make_move_iterator(requests.end() - amount), std::make_move_iterator(requests.end()));
requests.erase(requests.end() - amount, requests.end());
}
else
{
longRequests.resize(moveCount);
copy(m_LongPathRequests.begin(), m_LongPathRequests.begin() + moveCount, longRequests.begin());
m_LongPathRequests.erase(m_LongPathRequests.begin(), m_LongPathRequests.begin() + moveCount);
}
ProcessLongRequests(longRequests);
m_SameTurnMovesCount = (u16)(m_SameTurnMovesCount + moveCount);
}
else
{
poppedRequests.swap(requests);
requests.clear();
}
if (!m_ShortPathRequests.empty())
return poppedRequests;
}
template <typename T>
void CCmpPathfinder::PushRequestsToWorkers(std::vector<T>& from)
{
if (from.empty())
return;
// Trivial load-balancing, / rounds towards zero so add 1 to ensure we do push all requests.
size_t amount = from.size() / m_Workers.size() + 1;
// WARNING: the order in which moves are pushed must be consistent when using 1 or n workers.
// In this instance, work is distributed in a strict LIFO order, effectively reversing tickets.
for (PathfinderWorker& worker : m_Workers)
{
// Figure out how many moves we can do now
i32 moveCount = m_MaxSameTurnMoves - m_SameTurnMovesCount;
if (moveCount <= 0)
return;
// Copy the short request elements we are going to process into a new array
std::vector<ShortPathRequest> shortRequests;
if ((i32)m_ShortPathRequests.size() <= moveCount)
{
m_ShortPathRequests.swap(shortRequests);
moveCount = (i32)shortRequests.size();
}
else
{
shortRequests.resize(moveCount);
copy(m_ShortPathRequests.begin(), m_ShortPathRequests.begin() + moveCount, shortRequests.begin());
m_ShortPathRequests.erase(m_ShortPathRequests.begin(), m_ShortPathRequests.begin() + moveCount);
}
ProcessShortRequests(shortRequests);
m_SameTurnMovesCount = (u16)(m_SameTurnMovesCount + moveCount);
amount = std::min(amount, from.size()); // Since we are rounding up before, ensure we aren't pushing beyond the end.
worker.PushRequests(from, amount);
from.erase(from.end() - amount, from.end());
}
}

View file

@ -57,6 +57,33 @@ class AtlasOverlay;
*/
class CCmpPathfinder final : public ICmpPathfinder
{
protected:
class PathfinderWorker
{
friend CCmpPathfinder;
public:
PathfinderWorker();
// Process path requests, checking if we should stop before each new one.
void Work(const CCmpPathfinder& pathfinder);
private:
// Insert requests in m_[Long/Short]Requests depending on from.
// This could be removed when we may use if-constexpr in CCmpPathfinder::PushRequestsToWorkers
template<typename T>
void PushRequests(std::vector<T>& from, ssize_t amount);
// Stores our results, the main thread will fetch this.
std::vector<PathResult> m_Results;
std::vector<LongPathRequest> m_LongRequests;
std::vector<ShortPathRequest> m_ShortRequests;
};
// Allow the workers to access our private variables
friend class PathfinderWorker;
public:
static void ClassInit(CComponentManager& componentManager)
{
@ -81,8 +108,8 @@ public:
std::vector<LongPathRequest> m_LongPathRequests;
std::vector<ShortPathRequest> m_ShortPathRequests;
u32 m_NextAsyncTicket; // unique IDs for asynchronous path requests
u16 m_SameTurnMovesCount; // current number of same turn moves we have processed this turn
u32 m_NextAsyncTicket; // Unique IDs for asynchronous path requests.
u16 m_MaxSameTurnMoves; // Compute only this many paths when useMax is true in StartProcessingMoves.
// Lazily-constructed dynamic state (not serialized):
@ -101,9 +128,8 @@ public:
std::unique_ptr<HierarchicalPathfinder> m_PathfinderHier;
std::unique_ptr<LongPathfinder> m_LongPathfinder;
// For responsiveness we will process some moves in the same turn they were generated in
u16 m_MaxSameTurnMoves; // max number of moves that can be created and processed in the same turn
// Workers process pathing requests.
std::vector<PathfinderWorker> m_Workers;
AtlasOverlay* m_AtlasOverlay;
@ -168,11 +194,11 @@ public:
virtual Grid<u16> ComputeShoreGrid(bool expandOnWater = false);
virtual void ComputePath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const;
virtual void ComputePathImmediate(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const;
virtual u32 ComputePathAsync(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, entity_id_t notify);
virtual WaypointPath ComputeShortPath(const ShortPathRequest& request) const;
virtual WaypointPath ComputeShortPathImmediate(const ShortPathRequest& request) const;
virtual u32 ComputeShortPathAsync(entity_pos_t x0, entity_pos_t z0, entity_pos_t clearance, entity_pos_t range, const PathGoal& goal, pass_class_t passClass, bool avoidMovingUnits, entity_id_t controller, entity_id_t notify);
@ -194,13 +220,15 @@ public:
virtual ICmpObstruction::EFoundationCheck CheckBuildingPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, entity_id_t id, pass_class_t passClass, bool onlyCenterPoint) const;
virtual void FinishAsyncRequests();
virtual void FetchAsyncResultsAndSendMessages();
void ProcessLongRequests(const std::vector<LongPathRequest>& longRequests);
virtual void StartProcessingMoves(bool useMax);
void ProcessShortRequests(const std::vector<ShortPathRequest>& shortRequests);
template <typename T>
std::vector<T> PopMovesToProcess(std::vector<T>& requests, bool useMax = false, size_t maxMoves = 0);
virtual void ProcessSameTurnMoves();
template <typename T>
void PushRequestsToWorkers(std::vector<T>& from);
/**
* Regenerates the grid based on the current obstruction list, if necessary

View file

@ -686,7 +686,7 @@ void CCmpRallyPointRenderer::RecomputeRallyPointPath(size_t index, CmpPtr<ICmpPo
start.X = m_RallyPoints[index-1].X;
start.Y = m_RallyPoints[index-1].Y;
}
cmpPathfinder->ComputePath(start.X, start.Y, goal, cmpPathfinder->GetPassabilityClass(m_LinePassabilityClass), path);
cmpPathfinder->ComputePathImmediate(start.X, start.Y, goal, cmpPathfinder->GetPassabilityClass(m_LinePassabilityClass), path);
// Check if we got a path back; if not we probably have two markers less than one tile apart.
if (path.m_Waypoints.size() < 2)

View file

@ -33,6 +33,19 @@ class IObstructionTestFilter;
template<typename T> class Grid;
// Returned by asynchronous workers, used to send messages in the main thread.
struct WaypointPath;
struct PathResult
{
PathResult() = default;
PathResult(u32 t, entity_id_t n, WaypointPath p) : ticket(t), notify(n), path(p) {};
u32 ticket;
entity_id_t notify;
WaypointPath path;
};
/**
* Pathfinder algorithms.
*
@ -88,41 +101,37 @@ public:
*/
virtual Grid<u16> ComputeShoreGrid(bool expandOnWater = false) = 0;
/**
* Compute a tile-based path from the given point to the goal, and return the set of waypoints.
* The waypoints correspond to the centers of horizontally/vertically adjacent tiles
* along the path.
*/
virtual void ComputePath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const = 0;
/**
* Asynchronous version of ComputePath.
* Request a long path computation, asynchronously.
* The result will be sent as CMessagePathResult to 'notify'.
* Returns a unique non-zero number, which will match the 'ticket' in the result,
* so callers can recognise each individual request they make.
*/
virtual u32 ComputePathAsync(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, entity_id_t notify) = 0;
/**
* If the debug overlay is enabled, render the path that will computed by ComputePath.
/*
* Request a long-path computation immediately
*/
virtual void SetDebugPath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass) = 0;
virtual void ComputePathImmediate(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const = 0;
/**
* Compute a precise path from the given point to the goal, and return the set of waypoints.
* The path is based on the full set of obstructions that pass the filter, such that
* a unit of clearance 'clearance' will be able to follow the path with no collisions.
* The path is restricted to a box of radius 'range' from the starting point.
*/
virtual WaypointPath ComputeShortPath(const ShortPathRequest& request) const = 0;
/**
* Asynchronous version of ComputeShortPath (using ControlGroupObstructionFilter).
* Request a short path computation, asynchronously.
* The result will be sent as CMessagePathResult to 'notify'.
* Returns a unique non-zero number, which will match the 'ticket' in the result,
* so callers can recognise each individual request they make.
*/
virtual u32 ComputeShortPathAsync(entity_pos_t x0, entity_pos_t z0, entity_pos_t clearance, entity_pos_t range, const PathGoal& goal, pass_class_t passClass, bool avoidMovingUnits, entity_id_t group, entity_id_t notify) = 0;
virtual u32 ComputeShortPathAsync(entity_pos_t x0, entity_pos_t z0, entity_pos_t clearance, entity_pos_t range, const PathGoal& goal, pass_class_t passClass, bool avoidMovingUnits, entity_id_t controller, entity_id_t notify) = 0;
/*
* Request a short-path computation immediately.
*/
virtual WaypointPath ComputeShortPathImmediate(const ShortPathRequest& request) const = 0;
/**
* If the debug overlay is enabled, render the path that will computed by ComputePath.
*/
virtual void SetDebugPath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass) = 0;
/**
* Check whether the given movement line is valid and doesn't hit any obstructions
@ -171,12 +180,12 @@ public:
/**
* Finish computing asynchronous path requests and send the CMessagePathResult messages.
*/
virtual void FinishAsyncRequests() = 0;
virtual void FetchAsyncResultsAndSendMessages() = 0;
/**
* Process moves during the same turn they were created in to improve responsiveness.
* Tell asynchronous pathfinder threads that they can begin computing paths.
*/
virtual void ProcessSameTurnMoves() = 0;
virtual void StartProcessingMoves(bool useMax) = 0;
/**
* Regenerates the grid based on the current obstruction list, if necessary

View file

@ -183,7 +183,7 @@ public:
PathGoal goal = { PathGoal::POINT, x1, z1 };
WaypointPath path;
cmp->ComputePath(x0, z0, goal, cmp->GetPassabilityClass("default"), path);
cmp->ComputePathImmediate(x0, z0, goal, cmp->GetPassabilityClass("default"), path);
}
t = timer_Time() - t;
@ -214,7 +214,7 @@ public:
}
PathGoal goal = { PathGoal::POINT, range, range };
WaypointPath path = cmpPathfinder->ComputeShortPath(ShortPathRequest{ 0, range/3, range/3, fixed::FromInt(2), range, goal, 0, false, 0, 0 });
WaypointPath path = cmpPathfinder->ComputeShortPathImmediate(ShortPathRequest{ 0, range/3, range/3, fixed::FromInt(2), range, goal, 0, false, 0, 0 });
for (size_t i = 0; i < path.m_Waypoints.size(); ++i)
printf("# %d: %f %f\n", (int)i, path.m_Waypoints[i].x.ToFloat(), path.m_Waypoints[i].z.ToFloat());
}
@ -369,7 +369,7 @@ public:
PathGoal goal = { PathGoal::POINT, x1, z1 };
WaypointPath path;
cmpPathfinder->ComputePath(x0, z0, goal, cmpPathfinder->GetPassabilityClass("default"), path);
cmpPathfinder->ComputePathImmediate(x0, z0, goal, cmpPathfinder->GetPassabilityClass("default"), path);
u32 debugSteps;
double debugTime;
@ -418,7 +418,7 @@ public:
for (int i = 0; i < n; ++i)
{
WaypointPath path;
cmpPathfinder->ComputePath(x0, z0, goal, cmpPathfinder->GetPassabilityClass("default"), path);
cmpPathfinder->ComputePathImmediate(x0, z0, goal, cmpPathfinder->GetPassabilityClass("default"), path);
}
t = timer_Time() - t;
debug_printf("### RepeatPath %fms each (%fs total)\n", 1000*t / n, t);