From 9b92c37e9e7fcee3c9d682b7c64890014b2aed67 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:32:47 +0800 Subject: [PATCH] feat(recastnavigation): add compat.recastnavigation 1.6.0 --- .../2026-08-29-add-recastnavigation-plan.md | 172 ++++++++ README.md | 1 + README.zh-CN.md | 1 + docs/repository-and-schema.md | 1 + docs/zh/repository-and-schema.md | 1 + mcpp.toml | 2 + pkgs/c/compat.recastnavigation.lua | 170 ++++++++ .../recastnavigation-features/mcpp.toml | 24 ++ .../tests/features.cpp | 400 ++++++++++++++++++ tests/examples/recastnavigation/mcpp.toml | 24 ++ .../recastnavigation/tests/navmesh_path.cpp | 249 +++++++++++ 11 files changed, 1045 insertions(+) create mode 100644 .agents/docs/2026-08-29-add-recastnavigation-plan.md create mode 100644 pkgs/c/compat.recastnavigation.lua create mode 100644 tests/examples/recastnavigation-features/mcpp.toml create mode 100644 tests/examples/recastnavigation-features/tests/features.cpp create mode 100644 tests/examples/recastnavigation/mcpp.toml create mode 100644 tests/examples/recastnavigation/tests/navmesh_path.cpp diff --git a/.agents/docs/2026-08-29-add-recastnavigation-plan.md b/.agents/docs/2026-08-29-add-recastnavigation-plan.md new file mode 100644 index 00000000..58d5243e --- /dev/null +++ b/.agents/docs/2026-08-29-add-recastnavigation-plan.md @@ -0,0 +1,172 @@ +# 新增 compat.recastnavigation + +> 日期:2026-08-29 · 验证工具链:mcpp `2026.8.27.2`(与 `validate.yml` 的 `MCPP_VERSION` 对齐) + +## 1. 动机 + +[Recast Navigation](https://github.com/recastnavigation/recastnavigation) 是游戏行业导航网格 +事实标准:Recast 负责把关卡几何烘成导航网格,Detour 负责在网格上跑运行时寻路。Godot、 +Unreal、O3DE、Bullet 等大量项目直接或间接依赖它。索引此前没有它,任何需要寻路的 mcpp +工程都得自己拖源码。 + +上游不提供 mcpp 支持,因此以 `compat` 形态(Form B 内联)适配。版本取最新 tag **v1.6.0** +(`git ls-remote --tags` 确认,旧 tag 为 1.5.1)。许可证为 **Zlib**(`License.txt`)。 + +本次**不做模块化**:用户明确要求以传统 `#include` 形态提供,不生成 `.cppm` 包装。这与 +`nlohmann.json` 那条路不同 —— 后者是 header-only,不包装就无法 `import`;recastnavigation +是编译型库,`#include ` 是它自己的安装契约,包装反而会与上游 +ABI 和现有集成代码脱节。 + +## 2. 形态判定:**A**(源码 compat,C++ 版)+ 三个 source-gated feature + +判定依据来自实际解包与试编译,而非读 CMake: + +| 检查项 | 结论 | +|---|---| +| 可编译源码 | 5 个模块共 **30 个 `.cpp`**,全在 `<模块>/Source/` 下 | +| 外部依赖 | **无**。五个模块全部 `#include` 的并集 = 自身头文件 + `` `` `` `` `` `` `` `` `` | +| configure 产物 | **无**。`version.h.in` 由 `configure_file()` 生成,但**源码树里没有任何文件 include 它**,纯属安装产物 | +| C++23 可编译性 | `clang++ -std=c++23 -Wall` 编译 30 个 TU **全部通过,零告警** | + +因此不需要 `generated_files` 里的 config 快照(对比 `compat.msdfgen` 的 A+E):这儿没有 +「不生成就编不了」的头文件。唯一需要生成的是**转发头**,见第 4 节。 + +`-lm`:Recast 用到 `log()`(`RecastRegion.cpp`)与 `sqrtf/fabsf/ceilf/floorf/cosf/sinf`。 +glibc 早于 2.34 时这些在独立的 libm 里,故 `linux` / `macosx` 各加一条 `ldflags = {"-lm"}` +(参照 `compat.nanosvg`)。 + +## 3. 五模块 → 一库三 feature 的切分 + +上游是五个独立 CMake target,依赖关联网状(`DebugUtils` → `Recast` + `Detour` + +`DetourTileCache`;`DetourCrowd` / `DetourTileCache` → `Detour`)。mcpp 的 feature 只能门控 +`sources`,且所有 feature 编进**同一个 lib target**,没有 target 间依赖图。切分如下: + +| 上游模块 | 归属 | 理由 | +|---|---|---| +| `Recast` | **核心** | 烘网格 | +| `Detour` | **核心** | 走网格。两者互不依赖,但几乎没有消费者只用一个 —— 拆成 feature 只会给最常用的组合加一道门槛 | +| `DetourCrowd` | feature `crowd` | 群体仿真与局部避障,真实项目完全可能不用 | +| `DetourTileCache` | feature `tilecache` | 流式 tile 与动态障碍 | +| `DebugUtils` | feature `debug-utils`,**`implies = { "tilecache" }`** | 调试绘制 | + +`debug-utils` 必须 imply `tilecache`:上游 `DebugUtils/CMakeLists.txt` 无条件链接 +`DetourTileCache`,其源码引用 `dtTileCache*` 符号。所有 feature 编进同一 lib,若只开 +`debug-utils`,这些引用会变成**链接期 undefined reference**,而且是消费者侧的报错,很难 +定位。`implies` 让「开 debug 绘制却拿不到 tilecache」这个状态不可表达。 + +### 3.1 两个刻意**不**做成 feature 的 CMake 选项 + +`RECASTNAVIGATION_DT_POLYREF64` 与 `RECASTNAVIGATION_DT_VIRTUAL_QUERYFILTER` 在上游是 +`target_compile_definitions(... PUBLIC)`:前者改 `dtPolyRef` 的位宽,后者给 `dtQueryFilter` +加虚表。两者都改变**跨库边界类型的 ABI**。 + +feature 的 `defines` 只作用于**本包自己的 TU**,消费者看不到。若把它们做成 feature,就得到 +「库按 A 编译、消费者按 B 编译」的 ODR/ABI 分裂,失败既晚又难查。故两者保持上游默认 +(OFF),这也是所有二进制发行版的取值。这与 `compat.eigen` 的 `EIGEN_MPL2_ONLY`、 +`compat.msdfgen` 的 `MSDFGEN_USE_CPP11` 是同一条规则。 + +## 4. include 拼写:两种都保留 + +上游自己的安装把五个模块的头文件**拍平**放进 `/recastnavigation/`,且每个模块 +的 `CMakeLists.txt` 都写着: + +```cmake +INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} ${CMAKE_INSTALL_INCLUDEDIR}/recastnavigation +``` + +即**两个目录都在 interface include path 上**,所以对着一份真实安装,`#include ` +与 `#include ` 都合法 —— 这正是两种拼写都在野外存活的原因。 + +源码树内构建只能直接得到前者(`Recast/Include/Recast.h`)。为了让后者也成立,描述符生成 +了 **26 个转发头**(25 个公开头 + `version.h`),每个只有两行: + +```c +#pragma once +#include +``` + +- `include_dirs` 列出五个模块的 `Include/` + `mcpp_generated`,两种拼写同时成立; +- `mcpp_generated` 在 include path 上,而 `mcpp_generated/recastnavigation/` 不在,因此 + `` 不会自递归; +- `version.h` 按 `version.h.in` 实例化(`RECASTNAV_VERSION_NUM` 取上游的逗号形式 + `1,6,0,0`)。源码不 include 它,缺了也不会编译失败,但它是公开安装面的一部分。 + +这条与 `compat.msdfgen` 的做法一致。26 个文件采用转义单行字符串而非长括号:每个文件只有 +两行,用 `[==[ ]==]` 包 26 次会把本该可审阅的清单埋进括号噪音里(多行、有实义的 +`version.h` 仍用长括号)。 + +**注意**:feature 只能门控 sources,头无法隐藏。不开 `crowd` 时 +`` 依然可 include,失败发生在**链接期**。这与 +`compat.eigen` 的「纯头组件不可门控」是同一条限制。 + +## 5. CN 镜像 + +**未配置**。发布到 `mcpp-res` 需要 gitcode token,本次编写环境没有 +(`~/.config/gitcode-tool/config.json` 不存在,`gtc` 亦不在 PATH)。按 `docs/zh/cn-mirror.md` +的回退方案,`url` 写作纯字符串,CN 用户回落到 GitHub 上游;`check_mirror_urls.lua` 允许此 +形式(只校验表形式)。 + +后续有 `gtc` 权限的维护者可改为 `{ GLOBAL = ..., CN = ... }` 表形式,**上传的 tarball 必须与 +GLOBAL 字节一致**,因此下列 sha256 不变: + +``` +d48ca0121962fa0639502c0f56c4e3ae72f98e55d88727225444f500775c0074 + https://github.com/recastnavigation/recastnavigation/archive/refs/tags/v1.6.0.tar.gz +``` + +该值经 `archive/refs/tags/` 与 `codeload` 两个 URL 各下载两次、`cmp` 确认字节一致后得出。 + +## 6. 验证 + +两个 workspace 成员,均含可失败断言并以 `return ok ? 0 : 1` 收尾。 + +### `tests/examples/recastnavigation`(核心构建) + +地面是 20×20、**中间挖了 4×4 洞**的四块四边形,从 (2,2) 走到 (18,18) 必须绕行。逐项防住 +「包坏了但测试依然绿」: + +| 断言 | 防住的失败 | +|---|---| +| `nverts > 0` / `npolys > 0` | 栅格化出空网格 | +| 8 个三角形全部被标记为 walkable | 绕序反了(正常量朝下)。这是**静默**失败:不会报错,只会得到空网格 | +| `findStraightPath` 点数 ≥ 3 | 洞没进网格 / 网格是一整块方形,路径穿洞而过 | +| 终点距目标 < 2.0 | findPath 提前截断 | +| 路径长度 > 直线距离 + 0.5 | 同上,独立复核 | + +递归测试同时**混用两种 include 拼写**:Recast 走生成的 `recastnavigation/` 前缀,Detour 走 +`` 裸拼写。只有一种能用时,shim 层就是未被覆盖的。 + +### `tests/examples/recastnavigation-features` + +依赖**只声明 `features = ["crowd", "debug-utils"]`,不写 `tilecache`** —— 这是刻意的:它同时 +验证了第 3 节的 `implies` 链,链一旦失效,本成员在**链接期**失败。 + +- **crowd**:加一个 agent,请求移动目标,跑 400 步 `update(1/60)`,断言它离目标近了 5 米 + 以上(真走了,不是原地不动); +- **tilecache**:`rcBuildHeightfieldLayers` → `dtBuildTileCacheRegions/Contours/PolyMesh` + (builder 半边)→ 自制 passthrough compressor → `dtBuildTileCacheLayer` → `addTile` → + `buildNavMeshTile`,再断言 tile **能回答 `findNearestPoly`**。最后一条是必要的:poly flags + 为 0 的 tile 同样「存在且有几何」,但答不了任何查询; +- **debug-utils**:实现 `duDebugDraw`(抽象接口)并计数,`duDebugDrawNavMesh` 驱动它,断言 + 收到顶点且收到三角形批次。只做 header-only 的 DebugUtils 撑不到 `begin()`。 + +### 两处踩坑(均在原型阶段用 ASan 定位) + +1. `dtFreeTileCacheContourSet` / `dtFreeTileCachePolyMesh` 会 `alloc->free(cset)` —— **连容器 + 本身一起释放**。因此必须用 `dtAllocTileCacheContourSet` / `dtAllocTileCachePolyMesh` + 从堆上取,栈上对象会导致 `free on address which was not malloc()-ed`; +2. 手工填 `dtTileCacheLayer` 的 heights/areas/cons 会让 `dtBuildTileCachePolyMesh` 产出 + **0 个 poly**。正确路径是 `rcBuildHeightfieldLayers`(见 `RecastDemo/Source/ + Sample_TempObstacles.cpp`)由 compact heightfield 派生层数据;且该 heightfield 必须带 + `borderSize`(`walkableRadius + 3`),所以这是一次**独立配置**的栅格化,不能复用核心 + 测试那份 chf。 + +两个成员在提交前均于本地以 `clang++ -fsanitize=address` 跑通,无报错、无泄漏。 + +## 7. 注意事项 / 后续 + +- **未覆盖**:`RecastDemo`(需 SDL2 + OpenGL + 自带 imgui)与 `Tests/`(自带 catch2)未收录, + 符合本仓「只收库本身」的惯例。 +- **未收录上游的两个 ABI 开关**,理由见 3.1。若 mcpp 日后支持跨包可见的 `defines`(即 + `PUBLIC` 语义),可再开 `polyref64` / `virtual-queryfilter` 两个 feature。 +- **镜像缺失**意味着 CN 用户首次拉取走 GitHub。见第 5 节。 diff --git a/README.md b/README.md index e2369ee1..80bf17be 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ A few descriptors worth opening first, one per common shape: | C-source compat | [`compat.cjson`](pkgs/c/compat.cjson.lua) | One `.c` compiled into a lib; the optional extension sits behind a `features` gate | | Header-only | [`compat.gtl`](pkgs/c/compat.gtl.lua) | Nothing to compile — `include_dirs` and an anchor TU | | Whole-source build + generated config | [`compat.c-ares`](pkgs/c/compat.c-ares.lua) | The config header configure would have produced is snapshotted into `generated_files` | +| Multi-component upstream flattened into one lib | [`compat.recastnavigation`](pkgs/c/compat.recastnavigation.lua) | Recast Navigation 1.6.0 — upstream is five inter-dependent CMake libraries; here the two every consumer uses are the base and the other three are `features`, all compiled into one lib. Their dependency edges have to be rebuilt by hand, which is why `debug-utils` carries `implies = { "tilecache" }`: upstream links DetourTileCache unconditionally, and without the implication a consumer asking only for debug drawing fails at link with missing `dtTileCache*` symbols. Upstream's install puts every header flat under `include/recastnavigation/` **and** keeps both that directory and its parent on the interface include path, so both `` and `` are legal against a real install — 26 generated forwarding headers restore the second spelling for a source-tree build. `RECASTNAVIGATION_DT_POLYREF64` and `RECASTNAVIGATION_DT_VIRTUAL_QUERYFILTER` are deliberately NOT features: they change the ABI of types crossing the library boundary, and a feature's `defines` reach only the package's own TUs | | C++23 module wrapper | [`nlohmann.json`](pkgs/n/nlohmann.json.lua) | A generated `.cppm` turns a header-only library into `import` | | External build system | [`compat.openssl`](pkgs/c/compat.openssl.lua) | An `install()` hook drives upstream's own Perl Configure + Make | diff --git a/README.zh-CN.md b/README.zh-CN.md index 34117e6e..64cfef09 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,6 +40,7 @@ mcpp self config --mirror CN # 切换至国内镜像,默认使用 GLOBAL 上 | C 源码 compat | [`compat.cjson`](pkgs/c/compat.cjson.lua) | 单个 `.c` 编成库;可选扩展由 `features` 门控 | | header-only | [`compat.gtl`](pkgs/c/compat.gtl.lua) | 没有可编译内容 —— `include_dirs` 加一个 anchor TU | | 全源码直编 + 生成 config | [`compat.c-ares`](pkgs/c/compat.c-ares.lua) | 把 configure 本该生成的 config 头快照进 `generated_files` | +| 多组件上游拍平进单个库 | [`compat.recastnavigation`](pkgs/c/compat.recastnavigation.lua) | Recast Navigation 1.6.0 —— 上游是五个互相依赖的 CMake target;这里把「人人都要用」的两个作为核心,其余三个做 `features`,全部编进同一个 lib。依赖边必须手工重建,这正是 `debug-utils` 带 `implies = { "tilecache" }` 的原因:上游无条件链接 DetourTileCache,没有这条 implication,只开调试绘制的消费者会在**链接期**撞上缺失的 `dtTileCache*` 符号。上游安装把头文件拍平进 `include/recastnavigation/`,同时把该目录**及其父目录**都放进 interface include path,于是对着真实安装 `#include ` 与 `#include ` 都合法 —— 26 个生成的转发头把第二种拼写还给源码树构建。`RECASTNAVIGATION_DT_POLYREF64` 与 `RECASTNAVIGATION_DT_VIRTUAL_QUERYFILTER` 刻意**不**做成 feature:它们改变跨库边界类型的 ABI,而 feature 的 `defines` 只作用于本包自己的 TU | | C++23 module wrapper | [`nlohmann.json`](pkgs/n/nlohmann.json.lua) | 一份生成的 `.cppm` 把 header-only 库变成 `import` 即用 | | 外部构建系统 | [`compat.openssl`](pkgs/c/compat.openssl.lua) | `install()` 钩子驱动上游自己的 Perl Configure + Make | diff --git a/docs/repository-and-schema.md b/docs/repository-and-schema.md index 13f33e6c..3ba8f7fe 100644 --- a/docs/repository-and-schema.md +++ b/docs/repository-and-schema.md @@ -266,3 +266,4 @@ on `main` — no new mcpp release required. Browse online at: https://mcpplibs.g | header-only (pure headers) | `pkgs/c/compat.opengl.lua`, `compat.khrplatform.lua` | — | `.agents/docs/2026-06-03-gl-runtime-packages-plan.md` | | External build system (`install()`-driven) | `pkgs/c/compat.openblas.lua` (Make), `compat.openssl.lua` (Perl Configure + Make) | `tests/examples/openblas/`, `openssl/` | `docs/superpowers/specs/2026-07-26-openssl-asio-tls-design.md` / #124 | | A feature pulling in a dependency (cross-package) | the `ssl` feature of `pkgs/c/chriskohlhoff.asio.lua` → `compat.openssl` | `tests/examples/asio-ssl/` | same as above | +| Multi-component upstream flattened into one lib | `pkgs/c/compat.recastnavigation.lua` (five upstream CMake targets → a base build plus `crowd` / `tilecache` / `debug-utils`; `debug-utils` `implies` `tilecache`) | `tests/examples/recastnavigation/`, `recastnavigation-features/` | `.agents/docs/2026-08-29-add-recastnavigation-plan.md` | diff --git a/docs/zh/repository-and-schema.md b/docs/zh/repository-and-schema.md index 9e55db37..4e3faee9 100644 --- a/docs/zh/repository-and-schema.md +++ b/docs/zh/repository-and-schema.md @@ -217,3 +217,4 @@ bash tests/run_members.sh --all --cache local # 绕过包构建缓 | header-only(纯头) | `pkgs/c/compat.opengl.lua`、`compat.khrplatform.lua` | — | `.agents/docs/2026-06-03-gl-runtime-packages-plan.md` | | 外部构建系统(`install()` 驱动) | `pkgs/c/compat.openblas.lua`(Make)、`compat.openssl.lua`(Perl Configure + Make) | `tests/examples/openblas/`、`openssl/` | `docs/superpowers/specs/2026-07-26-openssl-asio-tls-design.md` / #124 | | feature 拉起依赖(跨包) | `pkgs/c/chriskohlhoff.asio.lua` 的 `ssl` feature → `compat.openssl` | `tests/examples/asio-ssl/` | 同上 | +| 多组件上游拍平进单个库 | `pkgs/c/compat.recastnavigation.lua`(上游五个 CMake target → 核心构建 + `crowd`/`tilecache`/`debug-utils`;`debug-utils` `implies` `tilecache`) | `tests/examples/recastnavigation/`、`recastnavigation-features/` | `.agents/docs/2026-08-29-add-recastnavigation-plan.md` | diff --git a/mcpp.toml b/mcpp.toml index 6178478d..2156b06c 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -62,6 +62,8 @@ members = [ "tests/examples/nlohmann.json", "tests/examples/openblas", "tests/examples/openssl", + "tests/examples/recastnavigation", + "tests/examples/recastnavigation-features", "tests/examples/protobuf", "tests/examples/protobuf-upb", "tests/examples/protobuf-gzip", diff --git a/pkgs/c/compat.recastnavigation.lua b/pkgs/c/compat.recastnavigation.lua new file mode 100644 index 00000000..dc663de0 --- /dev/null +++ b/pkgs/c/compat.recastnavigation.lua @@ -0,0 +1,170 @@ +-- compat.recastnavigation — Recast 与 Detour,大量上线游戏背后共用的导航 +-- 网格工具集:Recast 负责把关卡几何烘成导航网格,Detour 负责在网格上跑 +-- 运行时寻路。 +-- +-- 形态:A(源码 compat,C++ 版),带三个按源码门控的 feature。 +-- 上游是五个 CMake 库,从一份裸源码列表即可干净构建——30 个 .cpp、只依赖 +-- 标准库、没有 configure 步骤、没有 config 头、没有外部依赖(已核实:五个 +-- 模块全部 #include 的并集 = 自身头文件 + +-- )。 +-- 因此没有任何「为了能编译而必须生成」的东西;下面的 generated_files +-- 只是为了还原安装接口(见下)。 +-- +-- 核心构建装了什么,以及为什么 Recast 和 Detour 不是 feature。 +-- 上游把 Recast 和 Detour 做成互不依赖的独立库,所以它们本可以各自成为 +-- feature。没有这么做,是因为几乎没有消费者只要其中一半:Recast 烘网格、 +-- Detour 走网格,一个逼你为「人人都要的组合」多报一次名字的包,只是一道 +-- 速度障碍。真正可选的是真实项目完全可能不带的三块——群体仿真、流式 +-- tile 和调试绘制。 +-- +-- 为什么 debug-utils 必须 imply tilecache。 +-- 上游的 DebugUtils 链接 Recast、Detour 和 DetourTileCache +-- (DebugUtils/CMakeLists.txt),且其源码无条件引用 dtTileCache 的符号。 +-- 本包所有 feature 编进同一个 lib target,因此只开 debug-utils 会留下 +-- 无法解析的引用,消费者在链接期报缺 dtTileCache* 符号——报错晚、难定位。 +-- implies 让「开了调试绘制却拿不到 tilecache」这个状态无法被表达出来。 +-- +-- include 拼写——两种都成立,而且是有意的。 +-- 上游自己的安装把每个头文件平铺进 /recastnavigation/,并把 +-- /recastnavigation 同时放到 target 的 +-- INTERFACE include 目录(见各模块 CMakeLists.txt 里那句 INCLUDES +-- DESTINATION)。所以对着一份真实安装,#include 与 +-- #include 同样合法——这正是两种拼写都在 +-- 野外存活的原因。在源码树内构建只能直接得到前一种,于是 +-- mcpp_generated/recastnavigation/*.h 把后一种补回来:每个公开头一个 +-- 转发头,外加一份上游 configure_file() 会产出的 version.h。 +-- package-types.md「feature 不能隐藏头」的规则在这里同样成立——不开 +-- crowd, 也照样能解析,用了才在链接期 +-- 失败。 +-- +-- 两个刻意不做成 feature 的 CMake 选项。 +-- RECASTNAVIGATION_DT_POLYREF64 和 RECASTNAVIGATION_DT_VIRTUAL_QUERYFILTER +-- 在上游是 target_compile_definitions(... PUBLIC):前者改 dtPolyRef 的 +-- 位宽,后者给 dtQueryFilter 加虚表,改的都是跨库边界类型的 ABI。 +-- feature 的 defines 只能到达本包自己的翻译单元,用 feature 门控它们会 +-- 得到「库按一种方式编、消费者按另一种方式编」的 ODR/ABI 分裂——失败 +-- 既晚又难查。因此两者保持上游默认(OFF),这也是所有二进制发行版的取值。 +-- +-- 镜像:暂无 CN 镜像。发布需要 mcpp-res 的写权限,编写本描述符时没有, +-- 所以 url 写成纯字符串;CN 用户回落到 GitHub。有 gtc 权限的维护者可以 +-- 改成惯用的 { GLOBAL = ..., CN = ... } 表——要上传的 tarball 与 GLOBAL +-- 那份逐字节一致,因此下面的 sha256 不变。 +package = { + spec = "1", + namespace = "compat", + name = "recastnavigation", + description = "Recast Navigation — navmesh generation (Recast) and runtime pathfinding (Detour); optional crowd simulation, tile cache and debug draw", + licenses = {"Zlib"}, + repo = "https://github.com/recastnavigation/recastnavigation", + type = "package", + + xpm = { + linux = { + ["1.6.0"] = { + url = "https://github.com/recastnavigation/recastnavigation/archive/refs/tags/v1.6.0.tar.gz", + sha256 = "d48ca0121962fa0639502c0f56c4e3ae72f98e55d88727225444f500775c0074", + }, + }, + macosx = { + ["1.6.0"] = { + url = "https://github.com/recastnavigation/recastnavigation/archive/refs/tags/v1.6.0.tar.gz", + sha256 = "d48ca0121962fa0639502c0f56c4e3ae72f98e55d88727225444f500775c0074", + }, + }, + windows = { + ["1.6.0"] = { + url = "https://github.com/recastnavigation/recastnavigation/archive/refs/tags/v1.6.0.tar.gz", + sha256 = "d48ca0121962fa0639502c0f56c4e3ae72f98e55d88727225444f500775c0074", + }, + }, + }, + + mcpp = { + language = "c++23", + import_std = false, + -- 每个模块的 Include/ 目录一条,对应上游的 + -- `target_include_directories( PUBLIC /Include)`。 + -- 裸拼写 `#include ` 靠这些条目解析。 + include_dirs = { + "*/Recast/Include", + "*/Detour/Include", + "*/DetourCrowd/Include", + "*/DetourTileCache/Include", + "*/DebugUtils/Include", + "mcpp_generated", + }, + generated_files = { + -- 上游安装的拼写,每个公开头一个转发头。 + -- 写成转义单行字符串而非长括号,因为每个文件只有两行; + -- 给 26 个文件都套上 `[==[ ]==]` 会把本该便于审阅的清单 + -- 埋进括号噪音里。 + ["mcpp_generated/recastnavigation/Recast.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/RecastAlloc.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/RecastAssert.h"] = "#pragma once\n#include \n", + + ["mcpp_generated/recastnavigation/DetourAlloc.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourAssert.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourCommon.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourMath.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourNavMesh.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourNavMeshBuilder.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourNavMeshQuery.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourNode.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourStatus.h"] = "#pragma once\n#include \n", + + ["mcpp_generated/recastnavigation/DetourCrowd.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourLocalBoundary.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourObstacleAvoidance.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourPathCorridor.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourPathQueue.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourProximityGrid.h"] = "#pragma once\n#include \n", + + ["mcpp_generated/recastnavigation/DetourTileCache.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourTileCacheBuilder.h"] = "#pragma once\n#include \n", + + ["mcpp_generated/recastnavigation/DebugDraw.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/DetourDebugDraw.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/RecastDebugDraw.h"] = "#pragma once\n#include \n", + ["mcpp_generated/recastnavigation/RecastDump.h"] = "#pragma once\n#include \n", + + -- 上游的 version.h.in 按 1.6.0 实例化。数字形式是上游自己的: + -- LIB_VERSION "1.6.0" 先补一个 ".0",再把点换成逗号,装进用户 + -- 机器的就是这个内容。源码树里没有任何文件 include 它—— + -- 缺了也不会编译失败——但它是公开安装面的一部分,消费者检查 + -- RECASTNAV_VERSION 时应当能找到它。 + ["mcpp_generated/recastnavigation/version.h"] = [==[ +#pragma once + +/* Define to the library version */ +#define RECASTNAV_VERSION "1.6.0" +#define RECASTNAV_VERSION_NUM 1,6,0,0 +]==], + }, + -- Recast 和 Detour:每个消费者都会用到的两个模块,也是上游仅有的 + -- 两个只依赖自身的构建目标。 + sources = { + "*/Recast/Source/*.cpp", + "*/Detour/Source/*.cpp", + }, + targets = { ["recastnavigation"] = { kind = "lib" } }, + features = { + -- DetourCrowd:在 Detour 之上的 agent 转向与局部避障。 + ["crowd"] = { sources = { "*/DetourCrowd/Source/*.cpp" } }, + -- DetourTileCache:流式导航网格 tile,外加运行时能刻进 tile 的 + -- 动态障碍。 + ["tilecache"] = { sources = { "*/DetourTileCache/Source/*.cpp" } }, + -- DebugUtils:duDebugDraw 抽象绘制接口,加上驱动它的 + -- Recast/Detour/TileCache 各渲染器。无条件依赖 DetourTileCache, + -- 所以才有上面的 implies。 + ["debug-utils"] = { + implies = { "tilecache" }, + sources = { "*/DebugUtils/Source/*.cpp" }, + }, + }, + deps = { }, + -- Recast 调用 log()/sqrtf() 等;glibc 早于 2.34 时它们在独立的 + -- libm 里。Windows 没有对应物。 + linux = { ldflags = { "-lm" } }, + macosx = { ldflags = { "-lm" } }, + }, +} diff --git a/tests/examples/recastnavigation-features/mcpp.toml b/tests/examples/recastnavigation-features/mcpp.toml new file mode 100644 index 00000000..e405eee3 --- /dev/null +++ b/tests/examples/recastnavigation-features/mcpp.toml @@ -0,0 +1,24 @@ +# recastnavigation-features 测试工程:以三个可选 feature 全开的方式消费 +# compat.recastnavigation——`crowd`、`tilecache`、`debug-utils`——并逐个断言 +# 它们干了真实的活。mcpp-index 自引用 workspace 的一员;workspace 根的 +# [indices] 重定向让依赖解析到签入的描述符 +# (pkgs/c/compat.recastnavigation.lua)。 +# +# 兄弟成员 tests/examples/recastnavigation 覆盖无 feature 的核心构建 +# (Recast + Detour);本成员证明三个被门控的组件——每个都只在被点名时 +# 才编进库里: +# +# crowd -> 一个 agent 被引导着穿过网格,而且必须真的挪了地方 +# tilecache -> 构建高度场层、压缩、缓存,再物化回一块导航网格 tile +# debug-utils -> 用一个 duDebugDraw 实现驱动导航网格绘制,并给收到的 +# 图元计数 +# +# `debug-utils` 会传递拉入 `tilecache`(上游的 DebugUtils 无条件链接 +# DetourTileCache),所以只点调试绘制的消费者依然能拿到可链接的库—— +# 这条 implication 也是本成员要守住的东西之一。 +[package] +name = "recastnavigation-features-tests" +version = "0.1.0" + +[dependencies.compat] +recastnavigation = { version = "1.6.0", features = ["crowd", "debug-utils"] } diff --git a/tests/examples/recastnavigation-features/tests/features.cpp b/tests/examples/recastnavigation-features/tests/features.cpp new file mode 100644 index 00000000..dfd3c3c2 --- /dev/null +++ b/tests/examples/recastnavigation-features/tests/features.cpp @@ -0,0 +1,400 @@ +// compat.recastnavigation——三个按 feature 门控的组件,逐个让它做出 +// 可观察的工作。 +// +// `recastnavigation-features` 只点名 `crowd` 和 `debug-utils`。 +// `tilecache` 没被点名,这是刻意的:上游的 DebugUtils 无条件链接 +// DetourTileCache,所以描述符让 `debug-utils` imply `tilecache`。这条 +// implication 哪天失效,本成员会在链接期失败,而不是悄悄发出一个没法 +// 画调试图的包。 +// +// 一切都通过生成的 `recastnavigation/` include 前缀行使——与上游自己的 +// CMake 安装产出一致——所以 shim 层对 feature 头也有覆盖,不只是核心头。 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +import std; + +namespace { + +constexpr float kSize = 20.0f; + +constexpr float kAgentHeight = 2.0f; +constexpr float kAgentRadius = 0.6f; +constexpr float kAgentClimb = 0.9f; + +// y=0 处一块 20x20 的平面四边形,俯视为顺时针绕序,使 rcCalcTriNormal +// 产出朝上的法线——rcMarkWalkableTriangles 测试 `norm[1] > threshold`, +// 绕序反了不会报错,只会得到空网格。 +constexpr float kFloorVerts[] = { 0, 0, 0, 0, 0, kSize, kSize, 0, kSize, kSize, 0, 0 }; +constexpr int kFloorTris[] = { 0, 1, 2, 0, 2, 3 }; + +void fillConfig(rcConfig& cfg, float cs) { + cfg = rcConfig{}; + cfg.cs = cs; + cfg.ch = 0.2f; + cfg.walkableSlopeAngle = 45.0f; + cfg.walkableHeight = static_cast(std::ceil(kAgentHeight / cfg.ch)); + cfg.walkableClimb = static_cast(std::floor(kAgentClimb / cfg.ch)); + cfg.walkableRadius = static_cast(std::ceil(kAgentRadius / cfg.cs)); + cfg.maxEdgeLen = static_cast(12.0f / cfg.cs); + cfg.maxSimplificationError = 1.3f; + cfg.minRegionArea = static_cast(rcSqr(8.0f)); + cfg.mergeRegionArea = static_cast(rcSqr(20.0f)); + cfg.maxVertsPerPoly = 6; + cfg.detailSampleDist = cfg.cs * 6.0f; + cfg.detailSampleMaxError = cfg.ch * 1.0f; + cfg.bmin[0] = 0.0f; cfg.bmin[1] = 0.0f; cfg.bmin[2] = 0.0f; + cfg.bmax[0] = kSize; cfg.bmax[1] = 0.0f; cfg.bmax[2] = kSize; +} + +// 两条流水线共用的前半段:栅格化、过滤、压缩。 +rcCompactHeightfield* buildCompact(rcContext& ctx, const rcConfig& cfg) { + rcHeightfield* solid = rcAllocHeightfield(); + if (!solid) return nullptr; + if (!rcCreateHeightfield(&ctx, *solid, cfg.width, cfg.height, cfg.bmin, cfg.bmax, cfg.cs, cfg.ch)) { + rcFreeHeightField(solid); + return nullptr; + } + unsigned char triareas[2] = { 0, 0 }; + rcMarkWalkableTriangles(&ctx, cfg.walkableSlopeAngle, kFloorVerts, 4, kFloorTris, 2, triareas); + rcRasterizeTriangles(&ctx, kFloorVerts, 4, kFloorTris, triareas, 2, *solid, cfg.walkableClimb); + rcFilterLowHangingWalkableObstacles(&ctx, cfg.walkableClimb, *solid); + rcFilterLedgeSpans(&ctx, cfg.walkableHeight, cfg.walkableClimb, *solid); + rcFilterWalkableLowHeightSpans(&ctx, cfg.walkableHeight, *solid); + + rcCompactHeightfield* chf = rcAllocCompactHeightfield(); + if (!chf || !rcBuildCompactHeightfield(&ctx, cfg.walkableHeight, cfg.walkableClimb, *solid, *chf)) { + rcFreeCompactHeightfield(chf); + rcFreeHeightField(solid); + return nullptr; + } + rcFreeHeightField(solid); + return chf; +} + +// dtTileCacheCompressor 是用户提供的接口——RecastDemo 往里接的是 fastlz。 +// 对一个只关心 tile 能完整往返的测试来说,memcpy 就是很好的压缩方案, +// 还顺带把 fastlz 依赖挡在外面。 +struct PassthroughCompressor final : dtTileCacheCompressor { + int maxCompressedSize(const int bufferSize) override { return bufferSize; } + dtStatus compress(const unsigned char* buffer, const int bufferSize, + unsigned char* compressed, const int maxCompressedSize, int* compressedSize) override { + if (bufferSize > maxCompressedSize) return DT_FAILURE; + std::memcpy(compressed, buffer, static_cast(bufferSize)); + *compressedSize = bufferSize; + return DT_SUCCESS; + } + dtStatus decompress(const unsigned char* compressed, const int compressedSize, + unsigned char* buffer, const int maxBufferSize, int* bufferSize) override { + if (compressedSize > maxBufferSize) return DT_FAILURE; + std::memcpy(buffer, compressed, static_cast(compressedSize)); + *bufferSize = compressedSize; + return DT_SUCCESS; + } +}; + +// dtTileCache 把每块刚建好的 tile 交给 mesh process 打 flag。没有这一步, +// 多边形的 flags 会是 0,什么都查不了——这正是「tile 存在」这条断言 +// 抓不住的失败模式。 +struct FlagWalkable final : dtTileCacheMeshProcess { + void process(dtNavMeshCreateParams* params, unsigned char* polyAreas, unsigned short* polyFlags) override { + for (int i = 0; i < params->polyCount; ++i) + polyFlags[i] = (polyAreas[i] == DT_TILECACHE_WALKABLE_AREA) ? 1 : 0; + } +}; + +// 给 DebugUtils 向渲染器要的东西计数。duDebugDraw 是抽象接口,实现一个 +// 是证明 DebugUtils 既编译过又链接得上的唯一办法——只做头的 DebugUtils +// 撑不过 begin()。 +struct RecordingDraw final : duDebugDraw { + int vertices = 0; + int begins = 0; + int triBatches = 0; + + void depthMask(bool) override {} + void texture(bool) override {} + void begin(duDebugDrawPrimitives prim, float) override { + ++begins; + if (prim == DU_DRAW_TRIS) ++triBatches; + } + void vertex(const float*, unsigned int) override { ++vertices; } + void vertex(const float, const float, const float, unsigned int) override { ++vertices; } + void vertex(const float*, unsigned int, const float*) override { ++vertices; } + void vertex(const float, const float, const float, unsigned int, const float, const float) override { ++vertices; } + void end() override {} +}; + +} // namespace + +int main() { + bool ok = true; + const auto check = [&](bool cond, std::string_view what) { + if (!cond) { + std::println("FAIL: {}", what); + ok = false; + } + }; + + rcContext ctx; + + // ══ Recast:一张独立网格,crowd 和 DebugUtils 都要用它 ═════════════════ + rcConfig cfg; + fillConfig(cfg, 0.5f); + rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height); + + rcCompactHeightfield* chf = buildCompact(ctx, cfg); + check(chf != nullptr, "compact heightfield"); + + rcErodeWalkableArea(&ctx, cfg.walkableRadius, *chf); + rcBuildDistanceField(&ctx, *chf); + rcBuildRegions(&ctx, *chf, 0, cfg.minRegionArea, cfg.mergeRegionArea); + + rcContourSet* cset = rcAllocContourSet(); + rcBuildContours(&ctx, *chf, cfg.maxSimplificationError, cfg.maxEdgeLen, *cset); + rcPolyMesh* pmesh = rcAllocPolyMesh(); + rcBuildPolyMesh(&ctx, *cset, cfg.maxVertsPerPoly, *pmesh); + rcPolyMeshDetail* dmesh = rcAllocPolyMeshDetail(); + rcBuildPolyMeshDetail(&ctx, *pmesh, *chf, cfg.detailSampleDist, cfg.detailSampleMaxError, *dmesh); + std::println("solo poly mesh: {} verts, {} polys", pmesh->nverts, pmesh->npolys); + check(pmesh->npolys > 0, "core Recast build produced polygons"); + rcFreeCompactHeightfield(chf); + rcFreeContourSet(cset); + + for (int i = 0; i < pmesh->npolys; ++i) + pmesh->flags[i] = (pmesh->areas[i] == RC_WALKABLE_AREA) ? 1 : 0; + + dtNavMeshCreateParams params{}; + params.verts = pmesh->verts; + params.vertCount = pmesh->nverts; + params.polys = pmesh->polys; + params.polyFlags = pmesh->flags; + params.polyAreas = pmesh->areas; + params.polyCount = pmesh->npolys; + params.nvp = pmesh->nvp; + params.detailMeshes = dmesh->meshes; + params.detailVerts = dmesh->verts; + params.detailVertsCount = dmesh->nverts; + params.detailTris = dmesh->tris; + params.detailTriCount = dmesh->ntris; + params.walkableHeight = kAgentHeight; + params.walkableRadius = kAgentRadius; + params.walkableClimb = kAgentClimb; + rcVcopy(params.bmin, pmesh->bmin); + rcVcopy(params.bmax, pmesh->bmax); + params.cs = cfg.cs; + params.ch = cfg.ch; + params.buildBvTree = true; + + unsigned char* navData = nullptr; + int navDataSize = 0; + check(dtCreateNavMeshData(¶ms, &navData, &navDataSize), "dtCreateNavMeshData"); + + dtNavMesh* nav = dtAllocNavMesh(); + check(nav != nullptr, "dtAllocNavMesh"); + check(dtStatusSucceed(nav->init(navData, navDataSize, DT_TILE_FREE_DATA)), "dtNavMesh::init"); + dtNavMeshQuery* query = dtAllocNavMeshQuery(); + check(dtStatusSucceed(query->init(nav, 2048)), "dtNavMeshQuery::init"); + + const float ext[3] = { 2.0f, 4.0f, 2.0f }; + dtQueryFilter filter; + filter.setIncludeFlags(1); + filter.setExcludeFlags(0); + + // ══ crowd ══════════════════════════════════════════════════════════════ + dtCrowd crowd; + check(crowd.init(4, kAgentRadius, nav), "dtCrowd::init"); + + dtCrowdAgentParams agentParams{}; + agentParams.radius = kAgentRadius; + agentParams.height = kAgentHeight; + agentParams.maxAcceleration = 8.0f; + agentParams.maxSpeed = 3.5f; + agentParams.collisionQueryRange = kAgentRadius * 12.0f; + agentParams.pathOptimizationRange = kAgentRadius * 30.0f; + agentParams.updateFlags = DT_CROWD_ANTICIPATE_TURNS | DT_CROWD_OBSTACLE_AVOIDANCE | DT_CROWD_OPTIMIZE_VIS; + agentParams.obstacleAvoidanceType = 3; + agentParams.separationWeight = 2.0f; + + const float start[3] = { 2.0f, 0.2f, 2.0f }; + const float goal[3] = { 18.0f, 0.2f, 18.0f }; + const int agent = crowd.addAgent(start, &agentParams); + check(agent >= 0, "crowd.addAgent"); + + dtPolyRef goalRef = 0; + float goalNear[3] = { 0, 0, 0 }; + check(dtStatusSucceed(query->findNearestPoly(goal, ext, &filter, &goalRef, goalNear)), "findNearestPoly(goal)"); + check(crowd.requestMoveTarget(agent, goalRef, goalNear), "crowd.requestMoveTarget"); + + const float distanceBefore = dtVdist(start, goal); + for (int i = 0; i < 400; ++i) crowd.update(1.0f / 60.0f, nullptr); + const dtCrowdAgent* moved = crowd.getAgent(agent); + check(moved != nullptr, "crowd agent survives 400 updates"); + if (moved) { + const float distanceAfter = dtVdist(moved->npos, goal); + std::println("crowd: {} -> {} from goal (state {})", distanceBefore, distanceAfter, moved->state); + // dtCrowd 在这里约 6.7 秒的模拟时间内完成引导;一步都没有积分过的 + // agent 会仍然待在起点。 + check(distanceAfter < distanceBefore - 5.0f, "crowd agent actually walked toward the goal"); + } + + // ══ tilecache ══════════════════════════════════════════════════════════ + // rcBuildHeightfieldLayers 需要可走区域外有一圈不可通行的边界,这就是 + // 为什么这里是第二次、单独配置的栅格化,而不是复用上面的 compact + // heightfield。 + rcConfig tcfg; + fillConfig(tcfg, 0.5f); + const int tileSizeVoxels = 40; // 一块 tile 盖住整个地面 + tcfg.walkableRadius = static_cast(std::ceil(kAgentRadius / tcfg.cs)); + tcfg.tileSize = tileSizeVoxels; + tcfg.borderSize = tcfg.walkableRadius + 3; + const float tileWorldSize = static_cast(tileSizeVoxels) * tcfg.cs; + tcfg.bmin[0] -= tcfg.borderSize * tcfg.cs; + tcfg.bmin[2] -= tcfg.borderSize * tcfg.cs; + tcfg.bmax[0] += tcfg.borderSize * tcfg.cs; + tcfg.bmax[2] += tcfg.borderSize * tcfg.cs; + rcCalcGridSize(tcfg.bmin, tcfg.bmax, tcfg.cs, &tcfg.width, &tcfg.height); + + rcCompactHeightfield* tchf = buildCompact(ctx, tcfg); + check(tchf != nullptr, "tiled compact heightfield"); + rcErodeWalkableArea(&ctx, tcfg.walkableRadius, *tchf); + + rcHeightfieldLayerSet* lset = rcAllocHeightfieldLayerSet(); + check(rcBuildHeightfieldLayers(&ctx, *tchf, tcfg.borderSize, tcfg.walkableHeight, *lset), + "rcBuildHeightfieldLayers"); + std::println("heightfield layers: {}", lset->nlayers); + check(lset->nlayers > 0, "heightfield layers were built"); + rcFreeCompactHeightfield(tchf); + + dtNavMesh* tiledNav = dtAllocNavMesh(); + dtNavMeshParams navParams{}; + rcVcopy(navParams.orig, cfg.bmin); + navParams.tileWidth = tileWorldSize; + navParams.tileHeight = tileWorldSize; + navParams.maxTiles = 8; + navParams.maxPolys = 128; + check(dtStatusSucceed(tiledNav->init(&navParams)), "tiled navmesh init"); + + PassthroughCompressor compressor; + FlagWalkable meshProcess; + dtTileCacheAlloc tileAlloc; + dtTileCache tileCache; + + dtTileCacheParams tcParams{}; + rcVcopy(tcParams.orig, cfg.bmin); + tcParams.cs = tcfg.cs; + tcParams.ch = tcfg.ch; + tcParams.width = tileSizeVoxels; + tcParams.height = tileSizeVoxels; + tcParams.walkableHeight = kAgentHeight; + tcParams.walkableRadius = kAgentRadius; + tcParams.walkableClimb = kAgentClimb; + tcParams.maxSimplificationError = tcfg.maxSimplificationError; + tcParams.maxTiles = 8; + tcParams.maxObstacles = 32; + check(dtStatusSucceed(tileCache.init(&tcParams, &tileAlloc, &compressor, &meshProcess)), "dtTileCache::init"); + + const rcHeightfieldLayer* rlayer = &lset->layers[0]; + dtTileCacheLayerHeader header{}; + header.magic = DT_TILECACHE_MAGIC; + header.version = DT_TILECACHE_VERSION; + header.tx = 0; + header.ty = 0; + header.tlayer = 0; + rcVcopy(header.bmin, rlayer->bmin); + rcVcopy(header.bmax, rlayer->bmax); + header.width = static_cast(rlayer->width); + header.height = static_cast(rlayer->height); + header.minx = static_cast(rlayer->minx); + header.maxx = static_cast(rlayer->maxx); + header.miny = static_cast(rlayer->miny); + header.maxy = static_cast(rlayer->maxy); + header.hmin = static_cast(rlayer->hmin); + header.hmax = static_cast(rlayer->hmax); + + // DetourTileCache 的 builder 半边,在同一层上端到端驱动一遍。 + dtTileCacheLayer layer{}; + layer.header = &header; + layer.heights = rlayer->heights; + layer.areas = rlayer->areas; + layer.cons = rlayer->cons; + layer.regs = static_cast(dtAlloc(rlayer->width * rlayer->height, DT_ALLOC_TEMP)); + std::memset(layer.regs, 0xff, static_cast(rlayer->width) * rlayer->height); + + check(dtStatusSucceed(dtBuildTileCacheRegions(&tileAlloc, layer, tcfg.walkableClimb)), + "dtBuildTileCacheRegions"); + // dtFreeTileCacheContourSet / dtFreeTileCachePolyMesh 连容器本身带载荷 + // 一起释放,所以两者都必须用配套的 dtAlloc* 辅助函数从堆上取—— + // 释放栈上对象会直接崩。 + dtTileCacheContourSet* contours = dtAllocTileCacheContourSet(&tileAlloc); + check(contours != nullptr, "dtAllocTileCacheContourSet"); + check(dtStatusSucceed(dtBuildTileCacheContours(&tileAlloc, layer, tcfg.walkableClimb, + tcfg.maxSimplificationError, *contours)), + "dtBuildTileCacheContours"); + dtTileCachePolyMesh* layerMesh = dtAllocTileCachePolyMesh(&tileAlloc); + check(layerMesh != nullptr, "dtAllocTileCachePolyMesh"); + check(dtStatusSucceed(dtBuildTileCachePolyMesh(&tileAlloc, *contours, *layerMesh)), "dtBuildTileCachePolyMesh"); + std::println("tile cache poly mesh: {} polys", layerMesh->npolys); + check(layerMesh->npolys > 0, "tile cache builder produced polygons"); + dtFreeTileCacheContourSet(&tileAlloc, contours); + dtFreeTileCachePolyMesh(&tileAlloc, layerMesh); + dtFree(layer.regs); + + // 缓存半边:压缩这一层,交给 dtTileCache,再让它从中物化出一块 + // 导航网格 tile。 + unsigned char* tileData = nullptr; + int tileDataSize = 0; + check(dtStatusSucceed(dtBuildTileCacheLayer(&compressor, &header, rlayer->heights, rlayer->areas, + rlayer->cons, &tileData, &tileDataSize)), + "dtBuildTileCacheLayer"); + dtCompressedTileRef tileRef = 0; + check(dtStatusSucceed(tileCache.addTile(tileData, tileDataSize, DT_COMPRESSEDTILE_FREE_DATA, &tileRef)), + "dtTileCache::addTile"); + check(tileRef != 0, "addTile returned a reference"); + check(dtStatusSucceed(tileCache.buildNavMeshTile(tileRef, tiledNav)), "dtTileCache::buildNavMeshTile"); + + const dtMeshTile* built = tiledNav->getTileAt(0, 0, 0); + check(built != nullptr, "the tile cache materialized a navmesh tile"); + if (built && built->header) std::println("cached tile polys: {}", built->header->polyCount); + check(built != nullptr && built->header != nullptr && built->header->polyCount > 0, + "that tile holds polygons"); + + // ……以及它是可用的,而不只是存在:多边形 flags 为 0 的网格照样有 + // 几何,却什么查询都答不了。 + dtNavMeshQuery* tiledQuery = dtAllocNavMeshQuery(); + check(dtStatusSucceed(tiledQuery->init(tiledNav, 128)), "tiled query init"); + dtPolyRef tiledRef = 0; + float tiledNear[3] = { 0, 0, 0 }; + const float middle[3] = { 10.0f, 0.2f, 10.0f }; + check(dtStatusSucceed(tiledQuery->findNearestPoly(middle, ext, &filter, &tiledRef, tiledNear)), + "findNearestPoly on the cached tile"); + check(tiledRef != 0, "the cached tile answers queries"); + dtFreeNavMeshQuery(tiledQuery); + + // ══ debug-utils ════════════════════════════════════════════════════════ + RecordingDraw draw; + duDebugDrawNavMesh(&draw, *nav, 0); + std::println("debug draw: {} begins ({} triangle batches), {} vertices", + draw.begins, draw.triBatches, draw.vertices); + check(draw.vertices > 0, "DebugUtils emitted vertices for the navmesh"); + check(draw.triBatches > 0, "DebugUtils emitted triangle batches"); + + dtFreeNavMesh(tiledNav); + dtFreeNavMeshQuery(query); + dtFreeNavMesh(nav); + rcFreeHeightfieldLayerSet(lset); + rcFreePolyMesh(pmesh); + rcFreePolyMeshDetail(dmesh); + + std::println("compat.recastnavigation feature test: {}", ok ? "ok" : "FAILED"); + return ok ? 0 : 1; +} diff --git a/tests/examples/recastnavigation/mcpp.toml b/tests/examples/recastnavigation/mcpp.toml new file mode 100644 index 00000000..a0951e2a --- /dev/null +++ b/tests/examples/recastnavigation/mcpp.toml @@ -0,0 +1,24 @@ +# recastnavigation 测试工程:用 Recast 烘一张导航网格,用 Detour 在上面 +# 走路,并对路径做断言。mcpp-index 自引用 workspace 的一员——workspace 根 +# 的 [indices] 重定向指向本仓库,所以 compat.recastnavigation 解析到签入的 +# 描述符(pkgs/c/compat.recastnavigation.lua)。 +# +# 地面是 20x20、中间挖穿一个 4x4 洞的正方形,因此从 (2,2) 到 (18,18) 的 +# 路线不可能是一条直线段。「包坏了但测试依然绿」的情况有三种,这里逐项 +# 防住: +# +# * 栅格化出空网格 -> 对 nverts/npolys 做断言 +# * 网格是一整块大四方形 -> 直线路径必须拐弯,洞若被构建器悄悄填平, +# 就在这里失败 +# * Detour 没被真正链接 -> findPath / findStraightPath 都被调用, +# 缺库在链接期就失败,根本到不了这里 +# +# 本成员只测核心构建(Recast + Detour);`crowd`、`tilecache`、 +# `debug-utils` 三个 feature 由 tests/examples/recastnavigation-features +# 覆盖。 +[package] +name = "recastnavigation-tests" +version = "0.1.0" + +[dependencies.compat] +recastnavigation = "1.6.0" diff --git a/tests/examples/recastnavigation/tests/navmesh_path.cpp b/tests/examples/recastnavigation/tests/navmesh_path.cpp new file mode 100644 index 00000000..a285f977 --- /dev/null +++ b/tests/examples/recastnavigation/tests/navmesh_path.cpp @@ -0,0 +1,249 @@ +// compat.recastnavigation——烘一张导航网格,在上面走路,对路径做断言。 +// +// 地面是 20x20、中间挖穿一个 4x4 洞的正方形,所以从 (2,2) 到 (18,18) 的 +// 路线不可能是一条直线段。「包坏了但测试依然绿」的情况有三种,每一种 +// 都被单独防住: +// +// * 网格栅格化成了空集 -> 对 nverts/npolys 做断言 +// * 网格是一整块大四方形 -> 直线路径必须拐弯,洞若被构建器悄悄填平, +// 就在这里失败 +// * Detour 没被真正链接 -> findPath / findStraightPath 都被调用, +// 缺库在链接期就失败,根本到不了这里 +// +// 两种 include 拼写是有意混用的:Recast 走生成的 `recastnavigation/` +// 前缀,Detour 走裸的 ``。上游的 CMake 安装把两条路径 +// 都放到接口 include path 上;若只有一种能用,shim 层就是没被测到的。 +#include +#include +#include +#include +#include +#include +#include +#include +import std; + +namespace { + +constexpr float kSize = 20.0f; // 地面边长(x 与 z) +constexpr float kHoleLo = 8.0f; // 洞在两根轴上都横跨 [8,12] +constexpr float kHoleHi = 12.0f; + +constexpr float kAgentHeight = 2.0f; +constexpr float kAgentRadius = 0.6f; +constexpr float kAgentClimb = 0.9f; + +struct Quad { float ax, az, bx, bz, cx, cz, dx, dz; }; + +// 四个四边形拼成环形。俯视(沿 -y 轴往下看)时角点顺序为顺时针——这正是 +// rcCalcTriNormal 产出朝上法线的原因;rcMarkWalkableTriangles 测试的是 +// `norm[1] > threshold`,绕序反了不会报错,只会静默得到一张空网格。 +constexpr Quad kQuads[] = { + { 0, 0, 0, kHoleLo, kSize, kHoleLo, kSize, 0 }, // 南侧条带 + { 0, kHoleHi, 0, kSize, kSize, kSize, kSize, kHoleHi }, // 北侧条带 + { 0, kHoleLo, 0, kHoleHi, kHoleLo, kHoleHi, kHoleLo, kHoleLo }, // 西侧条带 + { kHoleHi, kHoleLo, kHoleHi, kHoleHi, kSize, kHoleHi, kSize, kHoleLo }, // 东侧条带 +}; + +struct FloorMesh { + std::vector verts; + std::vector tris; + + FloorMesh() { + for (const Quad& q : kQuads) { + const int base = static_cast(verts.size()) / 3; + const float corners[4][2] = { + { q.ax, q.az }, { q.bx, q.bz }, { q.cx, q.cz }, { q.dx, q.dz } + }; + for (const auto& c : corners) { + verts.push_back(c[0]); + verts.push_back(0.0f); + verts.push_back(c[1]); + } + tris.insert(tris.end(), { base + 0, base + 1, base + 2 }); + tris.insert(tris.end(), { base + 0, base + 2, base + 3 }); + } + } + + int vertCount() const { return static_cast(verts.size()) / 3; } + int triCount() const { return static_cast(tris.size()) / 3; } +}; + +} // namespace + +int main() { + bool ok = true; + const auto check = [&](bool cond, std::string_view what) { + if (!cond) { + std::println("FAIL: {}", what); + ok = false; + } + }; + + const FloorMesh floor; + std::println("geometry: {} verts, {} tris", floor.vertCount(), floor.triCount()); + + rcContext ctx; + + rcConfig cfg{}; + cfg.cs = 0.3f; + cfg.ch = 0.2f; + cfg.walkableSlopeAngle = 45.0f; + cfg.walkableHeight = static_cast(std::ceil(kAgentHeight / cfg.ch)); + cfg.walkableClimb = static_cast(std::floor(kAgentClimb / cfg.ch)); + cfg.walkableRadius = static_cast(std::ceil(kAgentRadius / cfg.cs)); + cfg.maxEdgeLen = static_cast(12.0f / cfg.cs); + cfg.maxSimplificationError = 1.3f; + cfg.minRegionArea = static_cast(rcSqr(8.0f)); + cfg.mergeRegionArea = static_cast(rcSqr(20.0f)); + cfg.maxVertsPerPoly = 6; + cfg.detailSampleDist = cfg.cs * 6.0f; + cfg.detailSampleMaxError = cfg.ch * 1.0f; + cfg.bmin[0] = 0.0f; cfg.bmin[1] = 0.0f; cfg.bmin[2] = 0.0f; + cfg.bmax[0] = kSize; cfg.bmax[1] = 0.0f; cfg.bmax[2] = kSize; + rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height); + std::println("grid: {}x{}", cfg.width, cfg.height); + + // ── Recast:原始三角形 -> 多边形网格 ────────────────────────────────── + rcHeightfield* solid = rcAllocHeightfield(); + check(solid != nullptr, "rcAllocHeightfield"); + check(rcCreateHeightfield(&ctx, *solid, cfg.width, cfg.height, cfg.bmin, cfg.bmax, cfg.cs, cfg.ch), + "rcCreateHeightfield"); + + std::vector triareas(static_cast(floor.triCount()), 0); + rcMarkWalkableTriangles(&ctx, cfg.walkableSlopeAngle, floor.verts.data(), floor.vertCount(), + floor.tris.data(), floor.triCount(), triareas.data()); + const int walkableTris = static_cast(std::ranges::count(triareas, RC_WALKABLE_AREA)); + std::println("walkable triangles: {} / {}", walkableTris, floor.triCount()); + check(walkableTris == floor.triCount(), "every floor triangle is walkable (winding gives +y normals)"); + + check(rcRasterizeTriangles(&ctx, floor.verts.data(), floor.vertCount(), floor.tris.data(), + triareas.data(), floor.triCount(), *solid, cfg.walkableClimb), + "rcRasterizeTriangles"); + rcFilterLowHangingWalkableObstacles(&ctx, cfg.walkableClimb, *solid); + rcFilterLedgeSpans(&ctx, cfg.walkableHeight, cfg.walkableClimb, *solid); + rcFilterWalkableLowHeightSpans(&ctx, cfg.walkableHeight, *solid); + + rcCompactHeightfield* chf = rcAllocCompactHeightfield(); + check(rcBuildCompactHeightfield(&ctx, cfg.walkableHeight, cfg.walkableClimb, *solid, *chf), + "rcBuildCompactHeightfield"); + rcFreeHeightField(solid); + + check(rcErodeWalkableArea(&ctx, cfg.walkableRadius, *chf), "rcErodeWalkableArea"); + check(rcBuildDistanceField(&ctx, *chf), "rcBuildDistanceField"); + check(rcBuildRegions(&ctx, *chf, 0, cfg.minRegionArea, cfg.mergeRegionArea), "rcBuildRegions"); + + rcContourSet* cset = rcAllocContourSet(); + check(rcBuildContours(&ctx, *chf, cfg.maxSimplificationError, cfg.maxEdgeLen, *cset), "rcBuildContours"); + + rcPolyMesh* pmesh = rcAllocPolyMesh(); + check(rcBuildPolyMesh(&ctx, *cset, cfg.maxVertsPerPoly, *pmesh), "rcBuildPolyMesh"); + std::println("poly mesh: {} verts, {} polys", pmesh->nverts, pmesh->npolys); + check(pmesh->nverts > 0, "Recast produced vertices"); + check(pmesh->npolys > 0, "Recast produced polygons"); + + rcPolyMeshDetail* dmesh = rcAllocPolyMeshDetail(); + check(rcBuildPolyMeshDetail(&ctx, *pmesh, *chf, cfg.detailSampleDist, cfg.detailSampleMaxError, *dmesh), + "rcBuildPolyMeshDetail"); + rcFreeCompactHeightfield(chf); + rcFreeContourSet(cset); + + // ── Detour:多边形网格 -> 可查询的导航网格 ──────────────────────────── + // Detour 只会走带查询过滤器所含 flag 的多边形。 + for (int i = 0; i < pmesh->npolys; ++i) + pmesh->flags[i] = (pmesh->areas[i] == RC_WALKABLE_AREA) ? 1 : 0; + + dtNavMeshCreateParams params{}; + params.verts = pmesh->verts; + params.vertCount = pmesh->nverts; + params.polys = pmesh->polys; + params.polyFlags = pmesh->flags; + params.polyAreas = pmesh->areas; + params.polyCount = pmesh->npolys; + params.nvp = pmesh->nvp; + params.detailMeshes = dmesh->meshes; + params.detailVerts = dmesh->verts; + params.detailVertsCount = dmesh->nverts; + params.detailTris = dmesh->tris; + params.detailTriCount = dmesh->ntris; + params.walkableHeight = kAgentHeight; + params.walkableRadius = kAgentRadius; + params.walkableClimb = kAgentClimb; + rcVcopy(params.bmin, pmesh->bmin); + rcVcopy(params.bmax, pmesh->bmax); + params.cs = cfg.cs; + params.ch = cfg.ch; + params.buildBvTree = true; + + unsigned char* navData = nullptr; + int navDataSize = 0; + check(dtCreateNavMeshData(¶ms, &navData, &navDataSize), "dtCreateNavMeshData"); + + dtNavMesh* nav = dtAllocNavMesh(); + check(nav != nullptr, "dtAllocNavMesh"); + check(dtStatusSucceed(nav->init(navData, navDataSize, DT_TILE_FREE_DATA)), "dtNavMesh::init"); + + dtNavMeshQuery* query = dtAllocNavMeshQuery(); + check(query != nullptr, "dtAllocNavMeshQuery"); + check(dtStatusSucceed(query->init(nav, 2048)), "dtNavMeshQuery::init"); + + dtQueryFilter filter; + filter.setIncludeFlags(1); + filter.setExcludeFlags(0); + + const float ext[3] = { 2.0f, 4.0f, 2.0f }; + const float start[3] = { 2.0f, 0.0f, 2.0f }; + const float goal[3] = { 18.0f, 0.0f, 18.0f }; + + dtPolyRef startRef = 0, endRef = 0; + float startNear[3] = { 0, 0, 0 }, endNear[3] = { 0, 0, 0 }; + check(dtStatusSucceed(query->findNearestPoly(start, ext, &filter, &startRef, startNear)), "findNearestPoly(start)"); + check(dtStatusSucceed(query->findNearestPoly(goal, ext, &filter, &endRef, endNear)), "findNearestPoly(goal)"); + check(startRef != 0, "start polygon found"); + check(endRef != 0, "goal polygon found"); + + std::vector corridor(256); + int corridorLen = 0; + check(dtStatusSucceed(query->findPath(startRef, endRef, startNear, endNear, &filter, + corridor.data(), &corridorLen, static_cast(corridor.size()))), + "findPath"); + std::println("path corridor: {} polys", corridorLen); + check(corridorLen > 0, "findPath returned a corridor"); + + std::vector straight(256 * 3); + std::vector straightFlags(256); + std::vector straightRefs(256); + int straightLen = 0; + check(dtStatusSucceed(query->findStraightPath(startNear, endNear, corridor.data(), corridorLen, + straight.data(), straightFlags.data(), straightRefs.data(), + &straightLen, 256)), + "findStraightPath"); + std::println("straight path: {} points", straightLen); + + check(straightLen >= 2, "straight path has a start and an end"); + // 洞挡住了直对角线,所以最短路径必须拐弯。只有两个点意味着查询笔直地 + // 穿过了洞——即洞没进网格,或者网格是一整块大四方形。 + check(straightLen >= 3, "path bends around the hole (>= 3 straight points)"); + + if (straightLen >= 2) { + const float* last = &straight[(straightLen - 1) * 3]; + const float gap = dtVdist(last, goal); + std::println("endpoint distance to goal: {:.3f}", gap); + check(gap < 2.0f, "path ends at the goal"); + + float walked = 0.0f; + for (int i = 1; i < straightLen; ++i) + walked += dtVdist(&straight[(i - 1) * 3], &straight[i * 3]); + const float direct = dtVdist(start, goal); + std::println("path length {:.3f} vs direct {:.3f}", walked, direct); + check(walked > direct + 0.5f, "path is longer than the direct line (it went around)"); + } + + dtFreeNavMeshQuery(query); + dtFreeNavMesh(nav); + rcFreePolyMesh(pmesh); + rcFreePolyMeshDetail(dmesh); + + std::println("compat.recastnavigation smoke test: {}", ok ? "ok" : "FAILED"); + return ok ? 0 : 1; +}