ludic.nav (18.1): a DetourCrowd per kind of walker

The shim builds DetourCrowd from the same pinned Recast & Detour tag. nav_crowd_start puts a crowd on a kind's mesh, with the mesh's tastes as its filters. Walkers are added (snapped to the mesh), sent and sped through verbs, stepped together, and read back into nav_agent_*. nav_crowd_us times the stepping with the OS clock. Dropping a mesh drops its crowd first. On the test meadow, twenty walkers crossing head-on never come closer than their two radii (0.7003 m), all arrive, and a step costs about 12 us. nav 15/15 on the Mac and the PC; the DLL still imports KERNEL32 alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-28 00:24:30 +03:00
parent c2902a2246
commit 521efe67db
12 changed files with 267 additions and 10 deletions

View file

@ -71,3 +71,13 @@ across the seams, a tile under water with no polygons, and the set saved and loa
`native/build.sh` fetches Recast & Detour v1.6.0, checks its SHA-256, and builds Recast, Detour
and the shim (`native/shim/nav_shim.cpp`) into `lib/<target>/` - the same script on the Mac and on
the PC (Git Bash, the LLVM installer's clang). `native/LICENSE-recastnavigation` ships with it.
## Crowds (phase 18)
`nav_crowd_start(kind, max, max_radius)` puts a DetourCrowd on a kind's mesh, with the mesh's tastes as
its filters. `nav_crowd_add(kind, x, y, z, radius, height, speed, filter)` adds a walker (snapped to
the mesh, -1 off it), `nav_crowd_target` and `nav_crowd_speed` say where and how fast, and
`nav_crowd_step(dt)` steps every crowd. `nav_crowd_read` puts a walker into `nav_agent_x/y/z/vx/vz`,
and `nav_crowd_us()` is what the stepping has cost. The mechanic decides and the crowd moves, and
dropping a mesh drops its crowd first. On the test meadow, twenty walkers crossing head-on never come
closer than their two radii, and a step costs about 12 us.

View file

@ -0,0 +1,61 @@
# crowd.ludic - a crowd per kind of walker (phase 18): many walkers steered along their paths at
# once, each kept clear of the others. The mechanic decides where and how fast; the crowd moves.
# a crowd on kind's mesh for at most max walkers, none wider than max_radius; false with no mesh
export function nav_crowd_start(nav_st: mut NavState, kind: int, max: int, max_radius: float) -> bool {
let h = nv_mesh(nav_st, kind)
if h == null { return false }
nv_crowd_drop(nav_st, kind)
nav_st.nv_crowds[kind] = nvc_crowd_new(h, max, max_radius)
return nav_st.nv_crowds[kind] != null
}
function nv_crowd(nav_st: NavState, kind: int) -> pointer {
if kind < 0 or kind >= NAV_KINDS { return null }
return nav_st.nv_crowds[kind]
}
# let kind's crowd go (before its mesh, which it walks on)
function nv_crowd_drop(nav_st: mut NavState, kind: int) -> void {
if kind < 0 or kind >= NAV_KINDS or nav_st.nv_crowds[kind] == null { return }
nvc_crowd_free(nav_st.nv_crowds[kind])
nav_st.nv_crowds[kind] = null
}
# a walker at (x, y, z) with its width, height, top speed and taste: its index, or -1
export function nav_crowd_add(nav_st: NavState, kind: int, x: float, y: float, z: float, radius: float, height: float, speed: float, filter: int) -> int {
let c = nv_crowd(nav_st, kind)
if c == null { return -1 }
return nvc_crowd_add(c, x, y, z, radius, height, speed, filter)
}
export function nav_crowd_remove(nav_st: NavState, kind: int, i: int) -> void {
let c = nv_crowd(nav_st, kind)
if c != null and i >= 0 { nvc_crowd_remove(c, i) }
}
# where walker i is going, snapped to the mesh; false off it
export function nav_crowd_target(nav_st: NavState, kind: int, i: int, x: float, y: float, z: float) -> bool {
let c = nv_crowd(nav_st, kind)
return c != null and i >= 0 and nvc_crowd_target(c, i, x, y, z) == 1
}
export function nav_crowd_speed(nav_st: NavState, kind: int, i: int, speed: float) -> void {
let c = nv_crowd(nav_st, kind)
if c != null and i >= 0 { nvc_crowd_speed(c, i, speed) }
}
# every crowd one step on
export function nav_crowd_step(nav_st: NavState, dt: float) -> void {
for k in 0 .. NAV_KINDS { if nav_st.nv_crowds[k] != null { nvc_crowd_update(nav_st.nv_crowds[k], dt) } }
}
# walker i read into nav_agent_*; false when there is none
export function nav_crowd_read(nav_st: NavState, kind: int, i: int) -> bool {
let c = nv_crowd(nav_st, kind)
return c != null and i >= 0 and nvc_crowd_read(c, i, nav_st.nv_agent) == 1
}
export function nav_agent_x(nav_st: NavState) -> float { return nav_st.nv_agent[0] }
export function nav_agent_y(nav_st: NavState) -> float { return nav_st.nv_agent[1] }
export function nav_agent_z(nav_st: NavState) -> float { return nav_st.nv_agent[2] }
export function nav_agent_vx(nav_st: NavState) -> float { return nav_st.nv_agent[3] }
export function nav_agent_vz(nav_st: NavState) -> float { return nav_st.nv_agent[5] }
# microseconds the crowds have spent stepping
export function nav_crowd_us(nav_st: NavState) -> int {
var us = 0
for k in 0 .. NAV_KINDS { if nav_st.nv_crowds[k] != null { us += nvc_crowd_us(nav_st.nv_crowds[k]) } }
return us
}

View file

@ -8,3 +8,4 @@ import "native.ludic"
import "state.ludic"
import "build.ludic"
import "query.ludic"
import "crowd.ludic"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -15,4 +15,13 @@ extern function nvc_us(h: pointer) -> int = "nav_us"
extern function nvc_raycast(h: pointer, f: int, sx: float, sy: float, sz: float, ex: float, ez: float) -> int = "nav_raycast"
extern function nvc_random_near(h: pointer, f: int, x: float, y: float, z: float, r: float, seed: int, out: pointer) -> int = "nav_random_near"
extern function nvc_stage(size: int) -> pointer = "nav_stage"
extern function nvc_crowd_new(h: pointer, max: int, max_radius: float) -> pointer = "nav_crowd_new"
extern function nvc_crowd_free(c: pointer) -> void = "nav_crowd_free"
extern function nvc_crowd_add(c: pointer, x: float, y: float, z: float, radius: float, height: float, speed: float, filter: int) -> int = "nav_crowd_add"
extern function nvc_crowd_remove(c: pointer, i: int) -> void = "nav_crowd_remove"
extern function nvc_crowd_target(c: pointer, i: int, x: float, y: float, z: float) -> int = "nav_crowd_target"
extern function nvc_crowd_speed(c: pointer, i: int, speed: float) -> void = "nav_crowd_speed"
extern function nvc_crowd_update(c: pointer, dt: float) -> void = "nav_crowd_update"
extern function nvc_crowd_read(c: pointer, i: int, out: pointer) -> int = "nav_crowd_read"
extern function nvc_crowd_us(c: pointer) -> int = "nav_crowd_us"
extern function nvc_load_staged(buf: pointer, size: int) -> pointer = "nav_load_staged"

View file

@ -1,7 +1,7 @@
#!/bin/sh
# builds lib/<target>/ for ludic.nav: Recast & Detour at a pinned tag, and the shim over them.
# Recast builds the mesh, Detour answers it; DetourTileCache and DetourCrowd come with phases 17.8
# and 18. Polygon refs are 64-bit (DT_POLYREF64): an 8 km map in 64 m tiles is 16384 tiles, which
# Recast builds the mesh, Detour answers it, DetourCrowd steers many walkers at once (phase 18);
# DetourTileCache would come with 17.8. Polygon refs are 64-bit (DT_POLYREF64): an 8 km map in 64 m tiles is 16384 tiles, which
# 32-bit refs would leave 256 polygons each. Objects go to build/native; a few seconds on eight cores.
set -eu
PKG="$(cd "$(dirname "$0")/.." && pwd)"
@ -14,12 +14,12 @@ native_fetch "$SRC" "https://github.com/recastnavigation/recastnavigation/archiv
OBJ="$PKG/build/native/$(native_target)"
mkdir -p "$OBJ"
CXX="$(native_cxx)"
INC="-I$SRC/Recast/Include -I$SRC/Detour/Include"
INC="-I$SRC/Recast/Include -I$SRC/Detour/Include -I$SRC/DetourCrowd/Include"
FLAGS="$(native_cflags) -std=c++17 -ffp-contract=off -fno-exceptions -fno-rtti -DNDEBUG -DDT_POLYREF64 $INC"
case "$(native_target)" in windows-*) FLAGS="$FLAGS -D_CRT_SECURE_NO_WARNINGS" ;; esac
JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
( cd "$SRC" && ls Recast/Source/*.cpp Detour/Source/*.cpp ) | while read -r f; do
( cd "$SRC" && ls Recast/Source/*.cpp Detour/Source/*.cpp DetourCrowd/Source/*.cpp ) | while read -r f; do
o="$OBJ/$(basename "$f" .cpp).o"
[ "$o" -nt "$SRC/$f" ] || echo "$f $o"
done | xargs -P "$JOBS" -n 2 sh -c '"$0" '"$FLAGS"' -c "'"$SRC"'/$1" -o "$2"' "$CXX"

View file

@ -0,0 +1,87 @@
// nav_crowd.inl - DetourCrowd over a navmesh: many walkers steered along their paths at once, each
// kept clear of the others. The mechanic decides where and how fast; the crowd only moves them.
struct Crowd {
dtCrowd *crowd = nullptr;
Nav *nav = nullptr;
long long ns = 0; // time spent stepping it, for the measure (18.8)
};
// a crowd of at most max walkers, none wider than max_radius, with the mesh's tastes as its filters
NAV_SHIM void *nav_crowd_new(void *h, int max, float max_radius) {
Nav *n = static_cast<Nav *>(h);
Crowd *c = new Crowd();
c->nav = n;
c->crowd = dtAllocCrowd();
if (!c->crowd || !c->crowd->init(max, max_radius, n->mesh)) { dtFreeCrowd(c->crowd); delete c; return nullptr; }
for (int f = 0; f < NAV_FILTERS && f < DT_CROWD_MAX_QUERY_FILTER_TYPE; ++f) *c->crowd->getEditableFilter(f) = n->filters[f];
return c;
}
NAV_SHIM void nav_crowd_free(void *p) {
Crowd *c = static_cast<Crowd *>(p);
if (!c) return;
dtFreeCrowd(c->crowd);
delete c;
}
// a walker at (x, y, z): its index, or -1 when the crowd is full or the point is off the mesh
NAV_SHIM int nav_crowd_add(void *p, float x, float y, float z, float radius, float height, float speed, int filter) {
Crowd *c = static_cast<Crowd *>(p);
dtCrowdAgentParams ap;
memset(&ap, 0, sizeof(ap));
ap.radius = radius;
ap.height = height;
ap.maxSpeed = speed;
ap.maxAcceleration = speed * 4.0f;
ap.collisionQueryRange = radius * 12.0f;
ap.pathOptimizationRange = radius * 30.0f;
ap.separationWeight = 2.0f;
ap.updateFlags = DT_CROWD_ANTICIPATE_TURNS | DT_CROWD_OBSTACLE_AVOIDANCE | DT_CROWD_SEPARATION | DT_CROWD_OPTIMIZE_TOPO | DT_CROWD_OPTIMIZE_VIS;
ap.obstacleAvoidanceType = 3;
ap.queryFilterType = (unsigned char)(filter >= 0 && filter < NAV_FILTERS ? filter : 0);
float pos[3] = {x, y, z}, near[3];
dtPolyRef ref = 0;
c->nav->query->findNearestPoly(pos, c->nav->ext, c->crowd->getFilter(ap.queryFilterType), &ref, near);
if (!ref) return -1;
return c->crowd->addAgent(near, &ap);
}
NAV_SHIM void nav_crowd_remove(void *p, int i) { static_cast<Crowd *>(p)->crowd->removeAgent(i); }
// send walker i toward (x, y, z), snapped to the mesh; 0 when the point is off it
NAV_SHIM int nav_crowd_target(void *p, int i, float x, float y, float z) {
Crowd *c = static_cast<Crowd *>(p);
const dtCrowdAgent *a = c->crowd->getAgent(i);
if (!a || !a->active) return 0;
float e[3] = {x, y, z}, near[3];
dtPolyRef ref = 0;
c->nav->query->findNearestPoly(e, c->nav->ext, c->crowd->getFilter(a->params.queryFilterType), &ref, near);
if (!ref) return 0;
return c->crowd->requestMoveTarget(i, ref, near) ? 1 : 0;
}
// a new top speed, as a walker goes from a walk to a run
NAV_SHIM void nav_crowd_speed(void *p, int i, float speed) {
Crowd *c = static_cast<Crowd *>(p);
const dtCrowdAgent *a = c->crowd->getAgent(i);
if (!a || !a->active) return;
dtCrowdAgentParams ap = a->params;
ap.maxSpeed = speed;
ap.maxAcceleration = speed * 4.0f;
c->crowd->updateAgentParameters(i, &ap);
}
NAV_SHIM void nav_crowd_update(void *p, float dt) {
Crowd *c = static_cast<Crowd *>(p);
long long t0 = nav_now_ns();
c->crowd->update(dt, nullptr);
c->ns += nav_now_ns() - t0;
}
// walker i's position and velocity into out[0..5]; 0 when there is no such walker
NAV_SHIM int nav_crowd_read(void *p, int i, float *out) {
const dtCrowdAgent *a = static_cast<Crowd *>(p)->crowd->getAgent(i);
if (!a || !a->active) return 0;
dtVcopy(out, a->npos);
dtVcopy(out + 3, a->vel);
return 1;
}
NAV_SHIM int nav_crowd_us(void *p) { return static_cast<int>(static_cast<Crowd *>(p)->ns / 1000); }

View file

@ -6,6 +6,7 @@
#include <DetourNavMeshBuilder.h>
#include <DetourNavMeshQuery.h>
#include <DetourCommon.h>
#include <DetourCrowd.h>
#include <cstring>
#include <cstdlib>
#include <cmath>
@ -77,3 +78,4 @@ const dtQueryFilter *nav_filter(Nav *n, int f) { return &n->filters[f >= 0 && f
#include "nav_build.inl"
#include "nav_tiles.inl"
#include "nav_query.inl"
#include "nav_crowd.inl"

View file

@ -45,6 +45,8 @@ export state NavState {
nv_asked: int = 0 # paths asked for, found (two corners or more), and cut short
nv_found: int = 0
nv_short: int = 0
nv_crowds: []pointer = nv_none() # a crowd per kind, on that kind's mesh (crowd.ludic)
nv_agent: []float = floats(6) # the last walker read: position, velocity
}
function nv_none() -> []pointer {
let m = new []pointer
@ -57,6 +59,7 @@ function nv_mesh(nav_st: NavState, kind: int) -> pointer {
}
function nv_set(nav_st: mut NavState, kind: int, h: pointer) -> bool {
if kind < 0 or kind >= NAV_KINDS { return false }
nv_crowd_drop(nav_st, kind)
if nav_st.nv_meshes[kind] != null { nvc_free(nav_st.nv_meshes[kind]) }
nav_st.nv_meshes[kind] = h
return h != null

View file

@ -0,0 +1,84 @@
# crowd_test.ludic - a crowd on the test meadow: ten walkers crossing ten head-on, and none walks
# through another; a crowd goes with its mesh, and nothing off the mesh joins it
import "ludic.nav"
import "ludic.base"
import "fakes/meadow.ludic"
program CrowdTest {
numbers float
state CrowdTestState {
ids: []int = new []int
gx: []float = new []float
gz: []float = new []float
}
# ten from the west side to the east and ten back, in rows 2.4 m apart that meet in the middle
function cross(nav_st: mut NavState, ct: mut CrowdTestState) -> void {
expect(nav_build(nav_st, NAV_PERSON, ground(40, 0), new NavConfig))
expect(nav_crowd_start(nav_st, NAV_PERSON, 32, 0.6))
for i in 0 .. 20 {
let east = i < 10
let z = 8.0 + float(i % 10) * 2.4
var x0 = 4.0
var x1 = 36.0
if not east {
x0 = 36.0
x1 = 4.0
}
let id = nav_crowd_add(nav_st, NAV_PERSON, x0, 0.0, z, 0.35, 1.8, 1.4, 0)
expect(id >= 0)
expect(nav_crowd_target(nav_st, NAV_PERSON, id, x1, 0.0, z))
push(ct.ids, id)
push(ct.gx, x1)
push(ct.gz, z)
}
}
# the closest any two walkers are now
function closest(nav_st: NavState, ct: CrowdTestState, xs: []float, zs: []float) -> float {
for i in 0 .. 20 {
nav_crowd_read(nav_st, NAV_PERSON, ct.ids[i])
xs[i] = nav_agent_x(nav_st)
zs[i] = nav_agent_z(nav_st)
}
var best = 1000.0
for i in 0 .. 20 {
for j in i + 1 .. 20 { best = Math.min(best, Math.sqrt((xs[i] - xs[j]) * (xs[i] - xs[j]) + (zs[i] - zs[j]) * (zs[i] - zs[j]))) }
}
return best
}
test "twenty cross head-on and none walks through another; all arrive" (nav_st: mut NavState, ct: mut CrowdTestState) {
cross(nav_st, ct)
let xs = floats(20)
let zs = floats(20)
var worst = 1000.0
for k in 0 .. 1800 {
nav_crowd_step(nav_st, 1.0 / 60.0)
worst = Math.min(worst, closest(nav_st, ct, xs, zs))
}
var arrived = 0
for i in 0 .. 20 {
nav_crowd_read(nav_st, NAV_PERSON, ct.ids[i])
let dx = nav_agent_x(nav_st) - ct.gx[i]
let dz = nav_agent_z(nav_st) - ct.gz[i]
if dx * dx + dz * dz < 1.5 * 1.5 { arrived += 1 }
}
print(`crowd: closest two ever {worst} m (two radii 0.7), {arrived} of 20 arrived, {nav_crowd_us(nav_st)} us over 1800 steps`)
expect(worst > 0.55)
expect(arrived >= 18)
nav_reset(nav_st)
}
test "a crowd goes with its mesh, and a walker off the mesh is refused" (nav_st: mut NavState) {
expect(not nav_crowd_start(nav_st, NAV_PERSON, 8, 0.6))
expect(nav_build(nav_st, NAV_PERSON, ground(40, 0), new NavConfig))
expect(nav_crowd_start(nav_st, NAV_PERSON, 8, 0.6))
expect_eq(nav_crowd_add(nav_st, NAV_PERSON, -50.0, 0.0, -50.0, 0.35, 1.8, 1.4, 0), -1)
let a = nav_crowd_add(nav_st, NAV_PERSON, 5.0, 0.0, 5.0, 0.35, 1.8, 1.4, 0)
expect(a >= 0)
expect(not nav_crowd_target(nav_st, NAV_PERSON, a, -50.0, 0.0, -50.0))
nav_drop(nav_st, NAV_PERSON)
expect(not nav_crowd_read(nav_st, NAV_PERSON, a))
expect_eq(nav_crowd_add(nav_st, NAV_PERSON, 5.0, 0.0, 5.0, 0.35, 1.8, 1.4, 0), -1)
}
}