feat(nav): 17.3 - a map in square tiles (nav_tiled, nav_tile_build; a tile must be a whole number of cells or its seams never join, and the shim refuses one that is not; 64-bit polygon refs for 16384 tiles), ground as a NavGround with boulders as convex footprints, one save format for one tile or many, and nav_random_near - a reachable point from the caller's seed, bit-identical on the Mac and the PC. 10 tests on both

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-27 15:42:37 +03:00
parent 9b8606483d
commit e6893f58be
16 changed files with 436 additions and 109 deletions

View file

@ -14,15 +14,21 @@ import "ludic.nav"
`NAV_LARGE` (an elk, a bear, a horse) and `NAV_SMALL` (a hare, a marmot) each have their own,
because what a hare slips between a bear walks round. A `NavConfig` says what a mesh is built
for: its voxels and its walker's height, radius, step and steepest slope.
- **Built from triangles, or loaded from a bake.** `nav_build` rasterizes triangles - each with an
area byte, 0 not walkable and 1..62 a kind of ground - and marks every cylinder given (a trunk, a
post) as somewhere nothing stands; ground steeper than the slope is cleared whatever its area. A
game bakes its maps once and loads the bytes (`nav_save_file` / `nav_load_file`): a map is never
built at start-up.
- **Built from ground, or loaded from a bake.** A `NavGround` is triangles - each with an area
byte, 0 not walkable and 1..62 a kind of ground - and what nothing stands in: cylinders (a trunk,
a post) and convex footprints (a boulder). Ground steeper than the slope is cleared whatever its
area. `nav_build` makes one mesh of it; a map is square tiles (`nav_tiled`, then `nav_tile_build`
for each tile from the ground under it and a few metres round), and a tile must be a whole number
of cells across or its seams never join - the shim refuses one that is not. A game bakes its maps
once and loads the bytes (`nav_save_file` / `nav_load_file`, one format for one tile or many): a
map is never built at start-up. Polygon refs are 64-bit, so a map may have 16384 tiles.
- **A path is corners.** `nav_path` answers the way from one point to another as the corners
where it turns, the first the start and the last the end. When the end cannot be reached the
path stops as near it as the mesh allows and `nav_partial` says so - a walker goes there and
gives up, never through a river. Either end off the mesh is `-1`.
- **A wander's goal is somewhere it can go.** `nav_random_near` answers a point about r away that a
walker standing at the start can reach - never across a river without a ford - from a seed the
caller draws from its own `Rng`, so the same seed is the same point on every machine.
- **A kind of ground has a cost** (`nav_area_cost`): a trail cheaper than a meadow, scree dearer.
- **The same question gets the same answer.** Detour is deterministic for the same mesh and the
same points, so co-op's order of dice is untouched by asking it.
@ -32,12 +38,15 @@ import "ludic.nav"
| | |
| --- | --- |
| `NAV_PERSON`, `NAV_LARGE`, `NAV_SMALL`, `NAV_CORNERS` | the kinds of walker, and a path's most corners (256) |
| `NavConfig { cell, cell_h, height, radius, climb, slope }` | what a mesh is built for (a person by default) |
| `nav_build(kind, v, nv, t, nt, area, cyl, nc, config) -> bool`, `nav_polygons(kind)` | a mesh from triangles and cylinders (`x, y, z, r, h` each) |
| `NavConfig { cell, cell_h, height, radius, climb, slope }` | what a mesh is built for (a person by default, 0.25 m cells) |
| `NavGround { v, nv, t, nt, area, cyl, nc, foot, nf }` | what it is built from: triangles, an area byte each, cylinders (`x, y, z, r, h`) and footprints (`n`, n points `x z`, `ymin`, `ymax`) |
| `nav_build(kind, ground, config) -> bool`, `nav_polygons(kind)` | one mesh over all of it |
| `nav_tiled(kind, ox, oz, tile, max_tiles, max_polys) -> bool`, `nav_tile_build(kind, tx, tz, ground, config) -> int` | a map of square tiles, a tile at a time (its polygons, 0 none, -1 failed) |
| `nav_save_file(kind, path)`, `nav_load_file(kind, path)`, `nav_reset()` | a baked mesh written and read; every mesh let go |
| `nav_nearest(kind, x, y, z) -> bool`, `nav_near_x/y/z()` | the nearest walkable point within a couple of metres |
| `nav_path(kind, sx, sy, sz, ex, ey, ez) -> int`, `nav_corners()`, `nav_corner_x/y/z(i)`, `nav_partial(kind)` | a way as corners |
| `nav_straight(kind, sx, sy, sz, ex, ez) -> bool` | does the straight line stay walkable |
| `nav_random_near(kind, x, y, z, r, seed) -> bool`, `nav_near_x/y/z()` | a reachable point about r away, from the caller's seed |
| `nav_area_cost(kind, area, cost)` | how dear a kind of ground is to cross |
## Tests
@ -46,8 +55,10 @@ import "ludic.nav"
ludic test packages/ludic.nav
```
A 40 m meadow built by hand: a path round a post, over a river's ford, stopping at the bank of a
river with none, the nearest point, and a saved mesh answering as the built one did.
A 40 m meadow built by hand: a path round a post and a boulder's footprint, over a river's ford,
stopping at the bank of a river with none, the nearest point, random points that never cross the
river, and a saved mesh answering as the built one did. And a 128 m meadow as four tiles: a path
across the seams, a tile under water with no polygons, and the set saved and loaded.
## The native library

View file

@ -1,8 +1,11 @@
# build.ludic - a kind's mesh from triangles, or from a file a bake wrote
# v: nv points (x, y, z); t: nt triangles (three indices each); area: one byte per triangle, 0 not
# walkable, 1..62 a kind of ground; cyl: nc cylinders (x, y, z, r, h) nothing stands in
export function nav_build(nav_st: mut NavState, kind: int, v: []float, nv: int, t: []int, nt: int, area: []byte, cyl: []float, nc: int, c: NavConfig) -> bool {
if len(v) < nv * 3 or len(t) < nt * 3 or len(area) < nt or len(cyl) < nc * 5 { return false }
# build.ludic - a kind's mesh: whole from its ground, or tile by tile (a map), or from a bake's file
function nv_ground_ok(g: NavGround) -> bool {
if g.v == null or g.t == null or g.area == null { return false }
if len(g.v) < g.nv * 3 or len(g.t) < g.nt * 3 or len(g.area) < g.nt { return false }
if g.nc > 0 and (g.cyl == null or len(g.cyl) < g.nc * 5) { return false }
return g.nf == 0 or (g.foot != null and len(g.foot) >= g.nf)
}
function nv_cfg(c: NavConfig) -> []float {
let cfg = floats(6)
cfg[0] = c.cell
cfg[1] = c.cell_h
@ -10,7 +13,28 @@ export function nav_build(nav_st: mut NavState, kind: int, v: []float, nv: int,
cfg[3] = c.radius
cfg[4] = c.climb
cfg[5] = c.slope
return nv_set(nav_st, kind, nvc_build(v, nv, t, nt, area, cyl, nc, cfg))
return cfg
}
function nv_or_none(xs: []float) -> []float {
if xs == null { return floats(1) }
return xs
}
# one mesh over the whole of g
export function nav_build(nav_st: mut NavState, kind: int, g: NavGround, c: NavConfig) -> bool {
if not nv_ground_ok(g) { return false }
return nv_set(nav_st, kind, nvc_build(g.v, g.nv, g.t, g.nt, g.area, nv_or_none(g.cyl), g.nc, nv_or_none(g.foot), g.nf, nv_cfg(c)))
}
# an empty mesh of square tiles `tile` metres across from (ox, oz), for a map built a tile at a time
export function nav_tiled(nav_st: mut NavState, kind: int, ox: float, oz: float, tile: float, max_tiles: int, max_polys: int) -> bool {
return nv_set(nav_st, kind, nvc_tiled(ox, oz, tile, max_tiles, max_polys))
}
# tile (tx, tz) from ground g covering it and a few metres round it: its polygons, 0 none, -1 failed
export function nav_tile_build(nav_st: NavState, kind: int, tx: int, tz: int, g: NavGround, c: NavConfig) -> int {
let h = nv_mesh(nav_st, kind)
if h == null or not nv_ground_ok(g) { return -1 }
return nvc_tile_build(h, tx, tz, g.v, g.nv, g.t, g.nt, g.area, nv_or_none(g.cyl), g.nc, nv_or_none(g.foot), g.nf, nv_cfg(c))
}
# the polygons a kind's mesh has (0: none)
@ -25,6 +49,7 @@ export function nav_save_file(nav_st: NavState, kind: int, path: string) -> bool
let h = nv_mesh(nav_st, kind)
if h == null { return false }
let n = nvc_save(h, null, 0)
if n <= 0 { return false }
let buf = buffer(n)
if nvc_save(h, buf, n) != n { return false }
return Fs.write_bytes(path, buf, n)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,6 +1,8 @@
# native.ludic - the shim's symbols (native/shim/nav_shim.cpp). A navmesh is a handle the package
# keeps; points come back through a float buffer it owns. None of this is exported.
extern function nvc_build(v: pointer, nv: int, t: pointer, nt: int, area: pointer, cyl: pointer, nc: int, c: pointer) -> pointer = "nav_build"
extern function nvc_build(v: pointer, nv: int, t: pointer, nt: int, area: pointer, cyl: pointer, nc: int, foot: pointer, nf: int, c: pointer) -> pointer = "nav_build"
extern function nvc_tiled(ox: float, oz: float, tile: float, max_tiles: int, max_polys: int) -> pointer = "nav_tiled"
extern function nvc_tile_build(h: pointer, tx: int, tz: int, v: pointer, nv: int, t: pointer, nt: int, area: pointer, cyl: pointer, nc: int, foot: pointer, nf: int, c: pointer) -> int = "nav_tile_build"
extern function nvc_save(h: pointer, buf: pointer, cap: int) -> int = "nav_save"
extern function nvc_load(buf: pointer, size: int) -> pointer = "nav_load"
extern function nvc_free(h: pointer) -> void = "nav_free"
@ -10,3 +12,4 @@ extern function nvc_area_cost(h: pointer, area: int, cost: float) -> void = "nav
extern function nvc_path(h: pointer, sx: float, sy: float, sz: float, ex: float, ey: float, ez: float, out: pointer, max: int) -> int = "nav_path"
extern function nvc_partial(h: pointer) -> int = "nav_partial"
extern function nvc_raycast(h: pointer, sx: float, sy: float, sz: float, ex: float, ez: float) -> int = "nav_raycast"
extern function nvc_random_near(h: pointer, x: float, y: float, z: float, r: float, seed: int, out: pointer) -> int = "nav_random_near"

View file

@ -1,7 +1,8 @@
#!/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. Objects go to build/native; a few seconds on eight cores.
# and 18. 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)"
. "$PKG/../../tools/native/lib.sh"
@ -14,7 +15,7 @@ OBJ="$PKG/build/native/$(native_target)"
mkdir -p "$OBJ"
CXX="$(native_cxx)"
INC="-I$SRC/Recast/Include -I$SRC/Detour/Include"
FLAGS="$(native_cflags) -std=c++17 -ffp-contract=off -fno-exceptions -fno-rtti -DNDEBUG $INC"
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)"

View file

@ -1,6 +1,6 @@
// nav_build.inl - a navmesh from triangles: Recast's solo pipeline (rasterize, filter, erode,
// regions, contours, polygons, detail) and one Detour tile. c[] is the configuration:
// cell size, cell height, agent height, agent radius, max climb, max slope (degrees).
// nav_build.inl - Recast's pipeline, shared by a whole mesh and a tile: rasterize the triangles,
// filter, mark what nothing stands in, erode, regions, contours, polygons, detail, Detour's data.
// c[] is the configuration: cell size, cell height, agent height, radius, max climb, max slope.
// cylinders (x, y, z, r, h each) no agent may stand in: trunks, posts, tents
static void nav_mark_cylinders(rcContext &ctx, rcCompactHeightfield &chf, const float *cyl, int nc) {
@ -10,12 +10,21 @@ static void nav_mark_cylinders(rcContext &ctx, rcCompactHeightfield &chf, const
}
}
// v: nv points (x, y, z); t: nt triangles; area: one per triangle, 0 not walkable, 1..62 a kind of
// ground (its cost is the filter's); the slope clears what is too steep whatever its area
NAV_SHIM void *nav_build(const float *v, int nv, const int *t, int nt, const unsigned char *area,
const float *cyl, int nc, const float *c) {
rcContext ctx(false);
rcConfig cfg;
// convex footprints no agent may stand in (a boulder): each is n, n points (x, z), ymin, ymax
static void nav_mark_footprints(rcContext &ctx, rcCompactHeightfield &chf, const float *f, int nf) {
float pts[3 * 64];
int i = 0;
while (i < nf) {
int n = (int)f[i];
if (n < 3 || n > 64 || i + 1 + n * 2 + 2 > nf) return;
float y0 = f[i + 1 + n * 2], y1 = f[i + 2 + n * 2];
for (int k = 0; k < n; ++k) { pts[k * 3] = f[i + 1 + k * 2]; pts[k * 3 + 1] = y0; pts[k * 3 + 2] = f[i + 2 + k * 2]; }
rcMarkConvexPolyArea(&ctx, pts, n, y0, y1, RC_NULL_AREA, chf);
i += 3 + n * 2;
}
}
static void nav_config(rcConfig &cfg, const float *c) {
memset(&cfg, 0, sizeof(cfg));
cfg.cs = c[0];
cfg.ch = c[1];
@ -30,11 +39,18 @@ NAV_SHIM void *nav_build(const float *v, int nv, const int *t, int nt, const uns
cfg.maxVertsPerPoly = 6;
cfg.detailSampleDist = cfg.cs * 6.0f;
cfg.detailSampleMaxError = cfg.ch;
rcCalcBounds(v, nv, cfg.bmin, cfg.bmax);
rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height);
}
std::vector<unsigned char> areas(area, area + nt);
rcClearUnwalkableTriangles(&ctx, cfg.walkableSlopeAngle, v, nv, t, nt, areas.data());
struct NavIn {
const float *v; int nv; const int *t; int nt; const unsigned char *area;
const float *cyl; int nc; const float *foot; int nf; const float *c;
};
// the pipeline over cfg's box; the tile's Detour data into data / size. 0 polygons is not an error
static bool nav_pipeline(rcConfig &cfg, const NavIn &in, int tx, int tz, unsigned char **data, int *size, int *polys) {
rcContext ctx(false);
std::vector<unsigned char> areas(in.area, in.area + in.nt);
rcClearUnwalkableTriangles(&ctx, cfg.walkableSlopeAngle, in.v, in.nv, in.t, in.nt, areas.data());
rcHeightfield *hf = rcAllocHeightfield();
rcCompactHeightfield *chf = rcAllocCompactHeightfield();
rcContourSet *cs = rcAllocContourSet();
@ -42,7 +58,7 @@ NAV_SHIM void *nav_build(const float *v, int nv, const int *t, int nt, const uns
rcPolyMeshDetail *dm = rcAllocPolyMeshDetail();
bool ok = hf && chf && cs && pm && dm &&
rcCreateHeightfield(&ctx, *hf, cfg.width, cfg.height, cfg.bmin, cfg.bmax, cfg.cs, cfg.ch) &&
rcRasterizeTriangles(&ctx, v, nv, t, areas.data(), nt, *hf, cfg.walkableClimb);
rcRasterizeTriangles(&ctx, in.v, in.nv, in.t, areas.data(), in.nt, *hf, cfg.walkableClimb);
if (ok) {
rcFilterLowHangingWalkableObstacles(&ctx, cfg.walkableClimb, *hf);
rcFilterLedgeSpans(&ctx, cfg.walkableHeight, cfg.walkableClimb, *hf);
@ -50,16 +66,16 @@ NAV_SHIM void *nav_build(const float *v, int nv, const int *t, int nt, const uns
ok = rcBuildCompactHeightfield(&ctx, cfg.walkableHeight, cfg.walkableClimb, *hf, *chf);
}
if (ok) {
nav_mark_cylinders(ctx, *chf, cyl, nc);
nav_mark_cylinders(ctx, *chf, in.cyl, in.nc);
nav_mark_footprints(ctx, *chf, in.foot, in.nf);
ok = rcErodeWalkableArea(&ctx, cfg.walkableRadius, *chf) && rcBuildDistanceField(&ctx, *chf) &&
rcBuildRegions(&ctx, *chf, 0, cfg.minRegionArea, cfg.mergeRegionArea) &&
rcBuildRegions(&ctx, *chf, cfg.borderSize, cfg.minRegionArea, cfg.mergeRegionArea) &&
rcBuildContours(&ctx, *chf, cfg.maxSimplificationError, cfg.maxEdgeLen, *cs) &&
rcBuildPolyMesh(&ctx, *cs, cfg.maxVertsPerPoly, *pm) &&
rcBuildPolyMeshDetail(&ctx, *pm, *chf, cfg.detailSampleDist, cfg.detailSampleMaxError, *dm);
}
unsigned char *data = nullptr;
int size = 0;
if (ok) {
*polys = ok ? pm->npolys : 0;
if (ok && pm->npolys > 0) {
for (int i = 0; i < pm->npolys; ++i) pm->flags[i] = pm->areas[i] ? 1 : 0;
dtNavMeshCreateParams p;
memset(&p, 0, sizeof(p));
@ -67,30 +83,28 @@ NAV_SHIM void *nav_build(const float *v, int nv, const int *t, int nt, const uns
p.polyFlags = pm->flags; p.polyCount = pm->npolys; p.nvp = pm->nvp;
p.detailMeshes = dm->meshes; p.detailVerts = dm->verts; p.detailVertsCount = dm->nverts;
p.detailTris = dm->tris; p.detailTriCount = dm->ntris;
p.walkableHeight = c[2]; p.walkableRadius = c[3]; p.walkableClimb = c[4];
p.walkableHeight = in.c[2]; p.walkableRadius = in.c[3]; p.walkableClimb = in.c[4];
p.tileX = tx; p.tileY = tz;
rcVcopy(p.bmin, pm->bmin); rcVcopy(p.bmax, pm->bmax);
p.cs = cfg.cs; p.ch = cfg.ch; p.buildBvTree = true;
ok = dtCreateNavMeshData(&p, &data, &size);
ok = dtCreateNavMeshData(&p, data, size);
}
rcFreeHeightField(hf); rcFreeCompactHeightfield(chf); rcFreeContourSet(cs); rcFreePolyMesh(pm); rcFreePolyMeshDetail(dm);
if (!ok) return nullptr;
return nav_from(data, size);
return ok;
}
// the tile's bytes, to write to a file: first the size (buf null), then the copy
NAV_SHIM int nav_save(void *h, unsigned char *buf, int cap) {
Nav *n = static_cast<Nav *>(h);
if (!buf) return n->size;
if (cap < n->size) return -1;
memcpy(buf, n->data, n->size);
return n->size;
}
// a navmesh from bytes nav_save wrote
NAV_SHIM void *nav_load(const unsigned char *buf, int size) {
unsigned char *data = static_cast<unsigned char *>(dtAlloc(size, DT_ALLOC_PERM));
if (!data) return nullptr;
memcpy(data, buf, size);
// one mesh over all the triangles given: v nv points (x, y, z); t nt triangles; area a byte each,
// 0 not walkable and 1..62 a kind of ground; the slope clears what is too steep whatever its area
NAV_SHIM void *nav_build(const float *v, int nv, const int *t, int nt, const unsigned char *area,
const float *cyl, int nc, const float *foot, int nf, const float *c) {
rcConfig cfg;
nav_config(cfg, c);
rcCalcBounds(v, nv, cfg.bmin, cfg.bmax);
rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height);
NavIn in = {v, nv, t, nt, area, cyl, nc, foot, nf, c};
unsigned char *data = nullptr;
int size = 0, polys = 0;
if (!nav_pipeline(cfg, in, 0, 0, &data, &size, &polys) || polys == 0) return nullptr;
return nav_from(data, size);
}
@ -102,7 +116,13 @@ NAV_SHIM void nav_free(void *h) {
delete n;
}
// every polygon in every tile
NAV_SHIM int nav_polys(void *h) {
const dtMeshTile *t = static_cast<const dtNavMesh *>(static_cast<Nav *>(h)->mesh)->getTile(0);
return t && t->header ? t->header->polyCount : 0;
const dtNavMesh *m = static_cast<Nav *>(h)->mesh;
int total = 0;
for (int i = 0; i < m->getMaxTiles(); ++i) {
const dtMeshTile *t = m->getTile(i);
if (t && t->header) total += t->header->polyCount;
}
return total;
}

View file

@ -51,3 +51,22 @@ NAV_SHIM int nav_raycast(void *h, float sx, float sy, float sz, float ex, float
if (dtStatusFailed(n->query->raycast(a, so, e, &n->filter, &t, nrm, n->polys, &np, NAV_MAX_POLYS))) return -1;
return t >= 1.0f ? 1 : 0;
}
// a point about r from (x, y, z) that a walker there can reach, into out; 0 when there is none.
// Detour asks a function for its dice: this one is seeded by the caller, so the same seed from the
// package's Rng gives the same point on every machine
static unsigned int nav_rs = 1;
static float nav_frand() {
nav_rs = nav_rs * 1664525u + 1013904223u;
return (float)(nav_rs >> 8) * (1.0f / 16777216.0f);
}
NAV_SHIM int nav_random_near(void *h, float x, float y, float z, float r, int seed, float *out) {
Nav *n = static_cast<Nav *>(h);
float p[3] = {x, y, z}, on[3];
dtPolyRef start = nav_poly_at(n, p, on);
if (!start) return 0;
nav_rs = (unsigned int)seed * 2654435761u + 1u;
dtPolyRef ref = 0;
if (dtStatusFailed(n->query->findRandomPointAroundCircle(start, on, r, &n->filter, nav_frand, &ref, out))) return 0;
return ref ? 1 : 0;
}

View file

@ -27,24 +27,26 @@ struct Nav {
dtQueryFilter filter;
float ext[3] = {2.0f, 4.0f, 2.0f}; // how far to look for the mesh around a point
dtPolyRef polys[NAV_MAX_POLYS];
unsigned char *data = nullptr; // the tile as built or loaded (the mesh frees it)
int size = 0;
int partial = 0; // the last path stopped short of its end
};
Nav *nav_from(unsigned char *data, int size) {
// a navmesh with its query, from params (a set of tiles) or from one tile's data
Nav *nav_open(dtNavMesh *mesh) {
Nav *n = new Nav();
n->mesh = dtAllocNavMesh();
if (!n->mesh || dtStatusFailed(n->mesh->init(data, size, DT_TILE_FREE_DATA))) { dtFree(data); dtFreeNavMesh(n->mesh); delete n; return nullptr; }
n->data = data;
n->size = size;
n->mesh = mesh;
n->query = dtAllocNavMeshQuery();
if (!n->query || dtStatusFailed(n->query->init(n->mesh, NAV_MAX_NODES))) { dtFreeNavMeshQuery(n->query); dtFreeNavMesh(n->mesh); delete n; return nullptr; }
n->filter.setIncludeFlags(0xffff);
n->filter.setExcludeFlags(0);
return n;
}
Nav *nav_from(unsigned char *data, int size) {
dtNavMesh *mesh = dtAllocNavMesh();
if (!mesh || dtStatusFailed(mesh->init(data, size, DT_TILE_FREE_DATA))) { dtFree(data); dtFreeNavMesh(mesh); return nullptr; }
return nav_open(mesh);
}
} // namespace
#include "nav_build.inl"
#include "nav_tiles.inl"
#include "nav_query.inl"

View file

@ -0,0 +1,102 @@
// nav_tiles.inl - a map as square tiles: a tiled mesh, one tile built from the triangles under it
// and a border round it, and every tile written and read back as one set of bytes
// an empty mesh of tiles `tile` metres across, the first at (ox, oz)
NAV_SHIM void *nav_tiled(float ox, float oz, float tile, int max_tiles, int max_polys) {
dtNavMeshParams p;
memset(&p, 0, sizeof(p));
p.orig[0] = ox; p.orig[1] = 0.0f; p.orig[2] = oz;
p.tileWidth = tile; p.tileHeight = tile;
p.maxTiles = max_tiles; p.maxPolys = max_polys;
dtNavMesh *mesh = dtAllocNavMesh();
if (!mesh || dtStatusFailed(mesh->init(&p))) { dtFreeNavMesh(mesh); return nullptr; }
return nav_open(mesh);
}
// tile (tx, tz) built from triangles that cover it and a border of the walker's radius and three
// cells round it, replacing whatever was there; its polygons, 0 for none, -1 when it failed
NAV_SHIM int nav_tile_build(void *h, int tx, int tz, const float *v, int nv, const int *t, int nt,
const unsigned char *area, const float *cyl, int nc, const float *foot, int nf, const float *c) {
Nav *n = static_cast<Nav *>(h);
const dtNavMeshParams *mp = n->mesh->getParams();
rcConfig cfg;
nav_config(cfg, c);
cfg.tileSize = (int)(mp->tileWidth / cfg.cs + 0.5f);
// a tile a cell short of its width leaves a seam its neighbour's polygons never meet
if (fabsf(cfg.tileSize * cfg.cs - mp->tileWidth) > 0.001f) return -1;
cfg.borderSize = cfg.walkableRadius + 3;
cfg.width = cfg.height = cfg.tileSize + cfg.borderSize * 2;
float lo[3], hi[3];
rcCalcBounds(v, nv, lo, hi);
cfg.bmin[0] = mp->orig[0] + tx * mp->tileWidth - cfg.borderSize * cfg.cs;
cfg.bmin[2] = mp->orig[2] + tz * mp->tileHeight - cfg.borderSize * cfg.cs;
cfg.bmax[0] = mp->orig[0] + (tx + 1) * mp->tileWidth + cfg.borderSize * cfg.cs;
cfg.bmax[2] = mp->orig[2] + (tz + 1) * mp->tileHeight + cfg.borderSize * cfg.cs;
cfg.bmin[1] = lo[1] - 1.0f;
cfg.bmax[1] = hi[1] + c[2] + 1.0f;
NavIn in = {v, nv, t, nt, area, cyl, nc, foot, nf, c};
unsigned char *data = nullptr;
int size = 0, polys = 0;
n->mesh->removeTile(n->mesh->getTileRefAt(tx, tz, 0), nullptr, nullptr);
if (!nav_pipeline(cfg, in, tx, tz, &data, &size, &polys)) return -1;
if (polys == 0) return 0;
if (dtStatusFailed(n->mesh->addTile(data, size, DT_TILE_FREE_DATA, 0, nullptr))) { dtFree(data); return -1; }
return polys;
}
// the set's bytes: "NAVT", a version, the mesh's params, then each tile's ref, size and data.
// First call with buf null for the size, then with a buffer that big
static const int NAV_MAGIC = 'N' << 24 | 'A' << 16 | 'V' << 8 | 'T';
static const int NAV_VERSION = 1;
static void nav_put(unsigned char *buf, int &at, const void *p, int n) {
if (buf) memcpy(buf + at, p, n);
at += n;
}
NAV_SHIM int nav_save(void *h, unsigned char *buf, int cap) {
const dtNavMesh *m = static_cast<Nav *>(h)->mesh;
const dtNavMeshParams *p = m->getParams();
int at = 0, count = 0;
for (int i = 0; i < m->getMaxTiles(); ++i) {
const dtMeshTile *t = m->getTile(i);
if (t && t->header && t->dataSize) count++;
}
nav_put(buf, at, &NAV_MAGIC, 4); nav_put(buf, at, &NAV_VERSION, 4); nav_put(buf, at, &count, 4);
nav_put(buf, at, p->orig, 12); nav_put(buf, at, &p->tileWidth, 4); nav_put(buf, at, &p->tileHeight, 4);
nav_put(buf, at, &p->maxTiles, 4); nav_put(buf, at, &p->maxPolys, 4);
for (int i = 0; i < m->getMaxTiles(); ++i) {
const dtMeshTile *t = m->getTile(i);
if (!t || !t->header || !t->dataSize) continue;
unsigned long long ref = (unsigned long long)m->getTileRef(t);
if (buf && at + 12 + t->dataSize > cap) return -1;
nav_put(buf, at, &ref, 8); nav_put(buf, at, &t->dataSize, 4); nav_put(buf, at, t->data, t->dataSize);
}
return at;
}
// a mesh from bytes nav_save wrote; null for anything else
NAV_SHIM void *nav_load(const unsigned char *buf, int size) {
int at = 0, magic = 0, version = 0, count = 0;
if (size < 40) return nullptr;
memcpy(&magic, buf, 4); memcpy(&version, buf + 4, 4); memcpy(&count, buf + 8, 4);
if (magic != NAV_MAGIC || version != NAV_VERSION) return nullptr;
dtNavMeshParams p;
memset(&p, 0, sizeof(p));
memcpy(p.orig, buf + 12, 12); memcpy(&p.tileWidth, buf + 24, 4); memcpy(&p.tileHeight, buf + 28, 4);
memcpy(&p.maxTiles, buf + 32, 4); memcpy(&p.maxPolys, buf + 36, 4);
at = 40;
dtNavMesh *mesh = dtAllocNavMesh();
if (!mesh || dtStatusFailed(mesh->init(&p))) { dtFreeNavMesh(mesh); return nullptr; }
for (int i = 0; i < count; ++i) {
unsigned long long ref = 0;
int n = 0;
if (at + 12 > size) break;
memcpy(&ref, buf + at, 8); memcpy(&n, buf + at + 8, 4);
at += 12;
if (n <= 0 || at + n > size) break;
unsigned char *data = static_cast<unsigned char *>(dtAlloc(n, DT_ALLOC_PERM));
memcpy(data, buf + at, n);
at += n;
if (dtStatusFailed(mesh->addTile(data, n, DT_TILE_FREE_DATA, (dtTileRef)ref, nullptr))) dtFree(data);
}
return nav_open(mesh);
}

View file

@ -11,6 +11,14 @@ export function nav_near_x(nav_st: NavState) -> float { return nav_st.nv_near[0]
export function nav_near_y(nav_st: NavState) -> float { return nav_st.nv_near[1] }
export function nav_near_z(nav_st: NavState) -> float { return nav_st.nv_near[2] }
# a point about r from (x, y, z) that a walker standing there can reach (a wander's goal), read back
# as the nearest is; the seed is the caller's dice, so the same seed is the same point everywhere
export function nav_random_near(nav_st: NavState, kind: int, x: float, y: float, z: float, r: float, seed: int) -> bool {
let h = nv_mesh(nav_st, kind)
if h == null { return false }
return nvc_random_near(h, x, y, z, r, seed, nav_st.nv_near) == 1
}
# a way from one point to another: its corners, first the start and last the end (or as near the end
# as the mesh reaches - nav_partial says which); -1 when either end is off the mesh
export function nav_path(nav_st: mut NavState, kind: int, sx: float, sy: float, sz: float, ex: float, ey: float, ez: float) -> int {

View file

@ -7,9 +7,10 @@ export const NAV_KINDS: int = 3
export const NAV_CORNERS: int = 256 # a path's corners at most; a longer one is cut there
# what a mesh is built for: its voxels (cell across, cell high) and its walker (height, radius,
# the step it takes whatever its gradient, the steepest ground it walks, degrees)
# the step it takes whatever its gradient, the steepest ground it walks, degrees). A tile must be a
# whole number of cells across, or its seams never join: 0.25 m is 256 to a 64 m tile
export property NavConfig {
cell: float = 0.3
cell: float = 0.25
cell_h: float = 0.2
height: float = 1.8
radius: float = 0.35
@ -17,6 +18,21 @@ export property NavConfig {
slope: float = 45.0
}
# what a mesh is built from: v holds nv points (x, y, z) and t nt triangles (three indices each);
# area a byte per triangle, 0 not walkable and 1..62 a kind of ground; cyl nc cylinders (x, y, z,
# r, h) and foot nf floats of convex footprints (n, n points x z, ymin, ymax) nothing stands in
export property NavGround {
v: []float = null
nv: int = 0
t: []int = null
nt: int = 0
area: []byte = null
cyl: []float = null
nc: int = 0
foot: []float = null
nf: int = 0
}
export state NavState {
nv_meshes: []pointer = nv_none()
nv_out: []float = floats(NAV_CORNERS * 3)

View file

@ -0,0 +1,61 @@
# fakes/meadow.ludic - ground built by hand for ludic.nav's tests: n metre squares a side, flat at
# y = 0; river 1 crosses x 18..22 with a ford at z 30..34, river 2 without one, river 0 is none
function ground(n: int, river: int) -> NavGround {
let g = new NavGround
g.nv = (n + 1) * (n + 1)
g.v = floats(g.nv * 3)
for j in 0 .. n + 1 {
for i in 0 .. n + 1 {
let o = (j * (n + 1) + i) * 3
g.v[o] = float(i)
g.v[o + 2] = float(j)
}
}
g.nt = n * n * 2
g.t = new []int
g.area = buffer(g.nt)
for j in 0 .. n {
for i in 0 .. n {
let a = j * (n + 1) + i
push(g.t, a)
push(g.t, a + n + 1)
push(g.t, a + 1)
push(g.t, a + 1)
push(g.t, a + n + 1)
push(g.t, a + n + 2)
var k = 1
if river > 0 and i >= 18 and i < 22 and not (river == 1 and j >= 30 and j < 34) { k = 0 }
g.area[(j * n + i) * 2] = k
g.area[(j * n + i) * 2 + 1] = k
}
}
return g
}
# a post r wide at (x, z), from under the ground to over a walker's head
function post_at(x: float, z: float, r: float) -> []float {
let c = floats(5)
c[0] = x
c[1] = -1.0
c[2] = z
c[3] = r
c[4] = 4.0
return c
}
# a square footprint 2h across at (x, z), as a boulder's is written: n, the corners, ymin, ymax
function square_at(x: float, z: float, h: float) -> []float {
let f = floats(11)
f[0] = 4.0
f[1] = x - h
f[2] = z - h
f[3] = x + h
f[4] = z - h
f[5] = x + h
f[6] = z + h
f[7] = x - h
f[8] = z + h
f[9] = -1.0
f[10] = 3.0
return f
}

View file

@ -3,47 +3,15 @@
# the ford, stops at the bank when there is none, and a saved mesh answers as the built one did.
import "ludic.nav"
import "ludic.base"
import "fakes/meadow.ludic"
program NavTest {
numbers float
# the meadow, flat at y = 0; river 1 has a ford at z 30..34, river 2 none, river 0 is no river
function meadow(nav_st: mut NavState, kind: int, river: int, post: bool) -> bool {
let n = 40
let v = floats((n + 1) * (n + 1) * 3)
for j in 0 .. n + 1 {
for i in 0 .. n + 1 {
let o = (j * (n + 1) + i) * 3
v[o] = float(i)
v[o + 1] = 0.0
v[o + 2] = float(j)
}
}
let t = new []int
let area = buffer(n * n * 2)
for j in 0 .. n {
for i in 0 .. n {
let a = j * (n + 1) + i
push(t, a)
push(t, a + n + 1)
push(t, a + 1)
push(t, a + 1)
push(t, a + n + 1)
push(t, a + n + 2)
var k = 1
if river > 0 and i >= 18 and i < 22 and not (river == 1 and j >= 30 and j < 34) { k = 0 }
area[(j * n + i) * 2] = k
area[(j * n + i) * 2 + 1] = k
}
}
let cyl = floats(5)
cyl[0] = 20.0
cyl[1] = -1.0
cyl[2] = 20.0
cyl[3] = 2.0
cyl[4] = 4.0
var nc = 0
if post { nc = 1 }
return nav_build(nav_st, kind, v, (n + 1) * (n + 1), t, n * n * 2, area, cyl, nc, new NavConfig)
let g = ground(40, river)
if post { g.cyl = post_at(20.0, 20.0, 2.0) }
if post { g.nc = 1 }
return nav_build(nav_st, kind, g, new NavConfig)
}
function last_x(nav_st: NavState) -> float { return nav_corner_x(nav_st, nav_corners(nav_st) - 1) }
function last_z(nav_st: NavState) -> float { return nav_corner_z(nav_st, nav_corners(nav_st) - 1) }
@ -92,6 +60,31 @@ program NavTest {
expect_eq(nav_path(nav_st, NAV_LARGE, 5.0, 0.0, 5.0, 10.0, 0.0, 10.0), -1)
}
test "a random point near is always one a walker there can reach, and a seed is always the same point" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 2, false))
for seed in 0 .. 50 {
expect(nav_random_near(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 30.0, seed))
expect(nav_near_x(nav_st) < 18.0)
}
nav_random_near(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 30.0, 7)
let x7 = nav_near_x(nav_st)
let z7 = nav_near_z(nav_st)
nav_random_near(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 30.0, 8)
expect(Math.abs(nav_near_x(nav_st) - x7) + Math.abs(nav_near_z(nav_st) - z7) > 0.01)
nav_random_near(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 30.0, 7)
expect_eq(nav_near_x(nav_st), x7)
expect_eq(nav_near_z(nav_st), z7)
}
test "a boulder's footprint is walked round" (nav_st: mut NavState) {
let g = ground(40, 0)
g.foot = square_at(20.0, 20.0, 3.0)
g.nf = len(g.foot)
expect(nav_build(nav_st, NAV_PERSON, g, new NavConfig))
expect(not nav_straight(nav_st, NAV_PERSON, 5.0, 0.0, 20.0, 35.0, 20.0))
expect(nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 20.0, 35.0, 0.0, 20.0) >= 3)
}
test "a mesh saved and loaded answers as the built one did" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 1, true))
let path = Os.temp_dir() + "/ludic_nav_test.navmesh"

View file

@ -0,0 +1,66 @@
# tiles_test.ludic - ludic.nav's map in tiles: a 128 m meadow as four 64 m tiles, one of them under
# water. A path crosses the tiles' seams, the water's tile has no polygons and nothing inside it is
# a way's end, and the set saved and loaded answers as the built one did.
import "ludic.nav"
import "ludic.base"
import "fakes/meadow.ludic"
program TilesTest {
numbers float
# four tiles over ground(128), the far corner's squares (x and z past 64) under water when wet
function map(nav_st: mut NavState, wet: bool) -> int {
let g = ground(128, 0)
if wet {
for j in 64 .. 128 { for i in 64 .. 128 { g.area[(j * 128 + i) * 2] = 0 } }
for j in 64 .. 128 { for i in 64 .. 128 { g.area[(j * 128 + i) * 2 + 1] = 0 } }
}
g.cyl = post_at(96.0, 20.0, 2.0)
g.nc = 1
expect(nav_tiled(nav_st, NAV_PERSON, 0.0, 0.0, 64.0, 16, 4096))
var empty = 0
for tz in 0 .. 2 {
for tx in 0 .. 2 {
let p = nav_tile_build(nav_st, NAV_PERSON, tx, tz, g, new NavConfig)
expect(p >= 0)
if p == 0 { empty += 1 }
}
}
return empty
}
function last_x(nav_st: NavState) -> float { return nav_corner_x(nav_st, nav_corners(nav_st) - 1) }
function last_z(nav_st: NavState) -> float { return nav_corner_z(nav_st, nav_corners(nav_st) - 1) }
test "a path crosses the tiles' seams and goes round the post in the far tile" (nav_st: mut NavState) {
expect_eq(map(nav_st, false), 0)
expect(nav_polygons(nav_st, NAV_PERSON) > 0)
let n = nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 20.0, 120.0, 0.0, 20.0)
expect(n >= 3)
expect(not nav_partial(nav_st, NAV_PERSON))
expect(Math.abs(last_x(nav_st) - 120.0) < 0.1)
let d = nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 10.0, 120.0, 0.0, 120.0)
expect(d >= 2)
expect(not nav_partial(nav_st, NAV_PERSON))
}
test "a tile under water has no polygons: a way into it is none, the shore beside it is reached" (nav_st: mut NavState) {
expect_eq(map(nav_st, true), 1)
expect(not nav_nearest(nav_st, NAV_PERSON, 100.0, 0.0, 100.0))
expect_eq(nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 10.0, 70.0, 0.0, 70.0), -1)
expect(nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 10.0, 62.0, 0.0, 70.0) >= 2)
expect(not nav_partial(nav_st, NAV_PERSON))
expect(Math.abs(last_z(nav_st) - 70.0) < 0.1)
}
test "the tiles saved and loaded answer as the built ones did" (nav_st: mut NavState) {
map(nav_st, true)
let path = Os.temp_dir() + "/ludic_nav_tiles.navmesh"
expect(nav_save_file(nav_st, NAV_PERSON, path))
expect(nav_load_file(nav_st, NAV_LARGE, path))
expect_eq(nav_polygons(nav_st, NAV_LARGE), nav_polygons(nav_st, NAV_PERSON))
let a = nav_path(nav_st, NAV_PERSON, 10.0, 0.0, 20.0, 120.0, 0.0, 20.0)
let ax = last_x(nav_st)
let b = nav_path(nav_st, NAV_LARGE, 10.0, 0.0, 20.0, 120.0, 0.0, 20.0)
expect_eq(a, b)
expect_near(last_x(nav_st), ax, 0.0001)
}
}