@woosh/meep-engine 2.163.6 → 2.163.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.d.ts +21 -0
- package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.d.ts.map +1 -0
- package/src/core/geom/2d/line/line_segment_intersection_fraction_2d.js +42 -0
- package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_island_erode.d.ts +2 -2
- package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_island_erode.d.ts.map +1 -1
- package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_face_island_erode.js +120 -179
- package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_fill_small_holes.d.ts +9 -10
- package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_fill_small_holes.d.ts.map +1 -1
- package/src/core/geom/3d/topology/struct/binary/io/bt_mesh_fill_small_holes.js +20 -14
- package/src/core/geom/3d/topology/struct/binary/query/bt_face_island_flood_fill.d.ts +17 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_face_island_flood_fill.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_face_island_flood_fill.js +45 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_build_boundary_euclidean_distance_field.d.ts +40 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_build_boundary_euclidean_distance_field.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_build_boundary_euclidean_distance_field.js +84 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_compute_face_islands.d.ts.map +1 -1
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_compute_face_islands.js +53 -78
- package/src/core/geom/vec3/v3_matrix3_rotate.d.ts +16 -0
- package/src/core/geom/vec3/v3_matrix3_rotate.d.ts.map +1 -0
- package/src/core/geom/vec3/v3_matrix3_rotate.js +49 -0
- package/src/core/geom/vec3/v3_orthonormal_matrix_from_normal.d.ts +2 -2
- package/src/core/geom/vec3/v3_orthonormal_matrix_from_normal.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_orthonormal_matrix_from_normal.js +46 -46
- package/src/engine/graphics/sh3/path_tracer/sampling/getBiasedNormalSample.d.ts.map +1 -1
- package/src/engine/graphics/sh3/path_tracer/sampling/getBiasedNormalSample.js +6 -28
- package/src/engine/navigation/mesh/PATHFINDING_PLAN.md +185 -0
- package/src/engine/navigation/mesh/bt_mesh_face_find_path.d.ts +11 -0
- package/src/engine/navigation/mesh/bt_mesh_face_find_path.d.ts.map +1 -1
- package/src/engine/navigation/mesh/bt_mesh_face_find_path.js +623 -100
- package/src/engine/navigation/mesh/build/clip_soup_against_overhangs.d.ts +11 -0
- package/src/engine/navigation/mesh/build/clip_soup_against_overhangs.d.ts.map +1 -0
- package/src/engine/navigation/mesh/build/clip_soup_against_overhangs.js +472 -0
- package/src/engine/navigation/mesh/build/navmesh_build_topology.d.ts.map +1 -1
- package/src/engine/navigation/mesh/build/navmesh_build_topology.js +36 -39
- package/src/engine/navigation/mesh/navmesh_polyanya_find_path.d.ts +17 -0
- package/src/engine/navigation/mesh/navmesh_polyanya_find_path.d.ts.map +1 -0
- package/src/engine/navigation/mesh/navmesh_polyanya_find_path.js +613 -0
- package/src/engine/navigation/mesh/build/bt_mesh_carve_height_clearance.d.ts +0 -28
- package/src/engine/navigation/mesh/build/bt_mesh_carve_height_clearance.d.ts.map +0 -1
- package/src/engine/navigation/mesh/build/bt_mesh_carve_height_clearance.js +0 -358
- package/src/engine/navigation/mesh/build/enforce_agent_height_clearance.d.ts +0 -23
- package/src/engine/navigation/mesh/build/enforce_agent_height_clearance.d.ts.map +0 -1
- package/src/engine/navigation/mesh/build/enforce_agent_height_clearance.js +0 -319
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Navmesh shortest-path: goal-directed, early-terminating, edge-based Eikonal A*
|
|
2
|
+
|
|
3
|
+
## Where we are
|
|
4
|
+
|
|
5
|
+
`bt_mesh_face_find_path` returns the face **corridor** that
|
|
6
|
+
`NavigationMesh.find_path` feeds to `funnel_string_pull`. The funnel produces the exact
|
|
7
|
+
shortest path *inside* the corridor, so the corridor must **contain the true geodesic** or the final
|
|
8
|
+
path is permanently too long.
|
|
9
|
+
|
|
10
|
+
The original implementation ran A* on the face dual graph using **centroid-to-centroid distance**.
|
|
11
|
+
That metric is shape-sensitive: across thin/sliver triangles the centroid path zig-zags so hard that a
|
|
12
|
+
dense patch looks more expensive than detouring around large skirt triangles, so it routed *around*
|
|
13
|
+
finely-tessellated regions (measured 1.2–1.55× the straight line). `bt_mesh_face_find_path.shortest_path.spec.js`
|
|
14
|
+
locks this down (flat convex mesh ⇒ straight line is the true shortest path; every pair must be within
|
|
15
|
+
1% of it).
|
|
16
|
+
|
|
17
|
+
The current implementation fixes correctness with an **exact geodesic field**:
|
|
18
|
+
|
|
19
|
+
1. `collect_island` — BFS the whole connected component from the start (reachability + vertex set).
|
|
20
|
+
2. `bt_mesh_build_boundary_distance_field` — Fast Marching (Eikonal, Kimmel–Sethian) over **every**
|
|
21
|
+
island vertex, seeded at the goal face ⇒ true along-surface distance-to-goal at each vertex.
|
|
22
|
+
3. `trace_corridor` — descend the field's in-plane gradient from the start (hugs the geodesic), with a
|
|
23
|
+
`search_corridor` best-first fallback for the cases the gradient can't follow cleanly (winding
|
|
24
|
+
around a hole).
|
|
25
|
+
|
|
26
|
+
This is correct (all 92 navigation tests pass) but **per query it touches the entire connected
|
|
27
|
+
component**: O(component) flood-fill + O(component · log) full Eikonal solve, regardless of how close
|
|
28
|
+
start and goal are. On a large navmesh that is a real regression versus the old localized A*. The
|
|
29
|
+
benchmark (`bt_mesh_face_find_path.bench.spec.js`, behind `.skip`) measures exactly this.
|
|
30
|
+
|
|
31
|
+
## Target design
|
|
32
|
+
|
|
33
|
+
A single **goal-directed, early-terminating** Eikonal search, with the corridor reconstructed from
|
|
34
|
+
**edge/portal** parents rather than the fragile gradient trace.
|
|
35
|
+
|
|
36
|
+
### Phase 1 — goal-directed, early-terminating fill (the big perf win, low risk)
|
|
37
|
+
|
|
38
|
+
Replace `collect_island` + full `bt_mesh_build_boundary_distance_field` with one Fast Marching pass
|
|
39
|
+
that is ordered as **A*** and stops as soon as it has resolved the start:
|
|
40
|
+
|
|
41
|
+
- Seed the Eikonal wavefront at the **goal** face's three vertices (distance 0).
|
|
42
|
+
- Order the heap by `f = g + h`, where `g` is the tentative geodesic distance-to-goal and
|
|
43
|
+
`h(v) = ‖v − start_centroid‖` (straight-line distance to the start). Euclidean `h` is **admissible
|
|
44
|
+
and consistent** for geodesic distance (geodesic ≥ Euclidean, and Euclidean obeys the triangle
|
|
45
|
+
inequality), so a popped vertex has its **final** `g` — exactly what the Eikonal update needs from
|
|
46
|
+
its inputs. The heuristic only changes *pop order*, pulling the front toward the start so we explore
|
|
47
|
+
the start↔goal "ellipse" instead of the whole component.
|
|
48
|
+
- **Early termination:** stop once all three vertices of the **start** face have been popped (frozen).
|
|
49
|
+
Every vertex on the geodesic corridor has `g ≤ g(start)` and `f ≤ g(start)` (consistent `h` ⇒ on an
|
|
50
|
+
optimal path `f` is constant `= total`), so when the start is frozen the whole corridor is already
|
|
51
|
+
frozen. Reachability falls out for free: if the heap drains without freezing the start face, there is
|
|
52
|
+
no path ⇒ return 0 (drops the separate `collect_island` BFS entirely).
|
|
53
|
+
|
|
54
|
+
Keep `trace_corridor` + `search_corridor` unchanged. Both only ever move *downhill* (toward smaller
|
|
55
|
+
distance-to-goal), and the downhill region is exactly what the early-terminated fill computed, so they
|
|
56
|
+
operate entirely within the resolved ellipse — including the around-a-hole geodesic, whose faces all
|
|
57
|
+
satisfy `g ≤ g(start)` and are therefore frozen.
|
|
58
|
+
|
|
59
|
+
**Causality / correctness note to honour:** the popped-is-final guarantee holds only if Eikonal
|
|
60
|
+
updates use **frozen** inputs. Pure-`g` (and consistent `g + h`) ordering already makes
|
|
61
|
+
*popped ⇒ final* because every update produces a value `≥` the popping vertex's `g` and the heap pops
|
|
62
|
+
non-decreasing. The Dijkstra edge-relaxation fallback (for not-yet-fronted / obtuse cases) only ever
|
|
63
|
+
supplies an upper bound that a later Eikonal update lowers *before* the vertex is popped, so it does
|
|
64
|
+
not violate finality. This must be verified, not assumed, when the fill is split out from
|
|
65
|
+
`bt_mesh_build_boundary_distance_field` (whose label-correcting full solve never relied on early-stop
|
|
66
|
+
finality).
|
|
67
|
+
|
|
68
|
+
### Phase 2 — exact any-angle (Polyanya), standalone
|
|
69
|
+
|
|
70
|
+
The original sketch here was "swap the corridor extractor for a portal-based search." That was tried
|
|
71
|
+
(descend the lowest-field portal, re-enter at its midpoint) and **did not move the ~2% around-obstacle
|
|
72
|
+
gap** — see `_diag` runs: the corridor `touchesCornerL=true touchesCornerR=false`, i.e. it cuts one
|
|
73
|
+
reflex corner on the descending side. The root cause is structural: the corner-*cutting* corridor is
|
|
74
|
+
shorter by *every* corridor metric (centroid, portal-midpoint, field); only the funnel knows the
|
|
75
|
+
corner-*touching* corridor yields a shorter final path. So no corridor-extraction swap can be exact —
|
|
76
|
+
you must run the funnel *inside* the search.
|
|
77
|
+
|
|
78
|
+
That is **Polyanya** (Cui/Harabor/Grastien, IJCAI 2017): A* over (root, interval-on-edge) search nodes.
|
|
79
|
+
The cone from `root` through an interval is projected into the next triangle and clipped against its
|
|
80
|
+
two far edges; visible pieces keep `root` (observable), pieces hidden behind a **reflex corner** at an
|
|
81
|
+
interval endpoint turn there (the corner becomes the new root, non-observable). It returns the exact
|
|
82
|
+
point path, turning only at obstacle corners — no corridor, no funnel post-pass.
|
|
83
|
+
|
|
84
|
+
Built as a **standalone 2-D module** `navmesh_polyanya_find_path` (xy-plane; not yet wired into the
|
|
85
|
+
3-D `NavigationMesh`). Two things were essential to get it correct *and* terminating:
|
|
86
|
+
- **Reflex-corner restriction:** only boundary vertices whose incident-triangle angles sum to > 180°
|
|
87
|
+
are turning roots. Admitting flat/convex boundary vertices makes the search blow up combinatorially.
|
|
88
|
+
- **Per-(edge, root) interval dedup:** a fixed root implies a fixed `g`, so the first node to cover a
|
|
89
|
+
stretch of an edge is optimal there; later nodes are clipped to the still-uncovered part. Without
|
|
90
|
+
this the wavefront re-floods every edge at the same `f` (the straight-line heuristic ignores
|
|
91
|
+
obstacles) and never terminates.
|
|
92
|
+
|
|
93
|
+
## Sequencing
|
|
94
|
+
|
|
95
|
+
1. **[done]** Land this plan + the benchmark (behind `.skip`) + the geodesic implementation. Baseline
|
|
96
|
+
(full-island `bt_mesh_build_boundary_distance_field`, ~100k-face mesh): **mean 2714 ms/search**,
|
|
97
|
+
~0.4 searches/s. A large part of that was `insert_or_update`'s **O(n) linear-scan decrease-key**,
|
|
98
|
+
making the fill O(V²).
|
|
99
|
+
2. **[done]** Phase 1. Self-contained goal-directed fill with **lazy-deletion** heap (kills the O(V²))
|
|
100
|
+
and early termination, replacing `collect_island` + the full solve. Same mesh: **mean 42.5 ms/search**
|
|
101
|
+
(p50 36, p99 126, min 0.17 for local queries), ~24 searches/s — **~64× faster**. `shortest_path.spec`
|
|
102
|
+
and all 92 navigation tests stay green; the gradient trace + graph-walk fallback are kept, with a
|
|
103
|
+
full-field re-solve as the backstop for under-resolved hole-winding corridors.
|
|
104
|
+
3. **[done]** Phase 2. Standalone exact `navmesh_polyanya_find_path` (2-D). Validated by
|
|
105
|
+
`navmesh_polyanya_find_path.spec.js`: exact on analytic oracles (straight line, single hole,
|
|
106
|
+
U-barrier, asymmetric wall — all corner-hugging to 4 decimals) and, over 60 random pairs on a holed
|
|
107
|
+
mesh, never longer than the FMM+funnel path and never shorter than the straight line (strictly
|
|
108
|
+
shorter on detours). Benchmark on the same ~100k-face mesh: **mean 7.1 ms/search** (p50 3.3, p90 5.5,
|
|
109
|
+
p99 9.5), ~141 searches/s — faster *typical* than Phase 1 because most pairs have line-of-sight (avg
|
|
110
|
+
2.0 path points) and Polyanya short-circuits, where the FMM fills the whole ellipse regardless.
|
|
111
|
+
**Caveat:** a heavy tail (originally max ~6.2 s) on rare far pairs that force long, corner-rich
|
|
112
|
+
detours.
|
|
113
|
+
|
|
114
|
+
**Optimisation pass (done):** made the hot path allocation-light — `reserve` rewritten to flat,
|
|
115
|
+
in-place range arrays writing into a scratch buffer (no per-call uncovered/merged/`[lo,hi]` arrays,
|
|
116
|
+
no sort), and the string dedup key replaced by nested numeric-keyed Maps (the crossed edge is
|
|
117
|
+
uniquely `(from_face, neighbour)`). Same ~100k-face mesh: **mean 4.7 ms** (p50 2.0, p99 6.8), tail
|
|
118
|
+
**6.2 s → 3.35 s** (~33% faster mean, ~46% lower tail); `spec` stays green. The residual tail is now
|
|
119
|
+
dominated by node *count* (the Euclidean heuristic is weak on long forced detours), not per-node
|
|
120
|
+
cost.
|
|
121
|
+
|
|
122
|
+
**GC-free node storage (done):** search nodes moved out of per-node object literals into fixed
|
|
123
|
+
16-word records in a `BinaryElementPool` buffer, referenced by integer id, with the open list a
|
|
124
|
+
`Uint32Heap4` (id + f32 score) instead of an object heap. The buffer is driven by a monotonic
|
|
125
|
+
counter reset per query (nodes are never released mid-search and `is_allocated()` is never read), so
|
|
126
|
+
the pool's per-node occupancy bit and its O(capacity) `clear()` — itself a spike after a large query
|
|
127
|
+
— are bypassed; the buffer only ever grows (geometrically) and the cached `data_*` views are
|
|
128
|
+
re-read after a grow. This is a deliberate **latency**, not throughput, choice: it is a touch slower
|
|
129
|
+
on mean throughput (**~4.7 → ~6.0 ms**, p99 6.8 → 18.3 on the bench — object literals are cheap and
|
|
130
|
+
the typical query is tiny, avg ~2 points, so there was little GC to remove and the typed-array
|
|
131
|
+
bookkeeping costs more than it saves *in aggregate*), but pathfinding is a hot path and the point is
|
|
132
|
+
to emit **zero garbage** so it never triggers a GC pause that jitters the rest of the engine's frame.
|
|
133
|
+
Unpredictable pauses are worse than a predictable constant cost here. (Two earlier pool variants were
|
|
134
|
+
measured — `allocate()`/`clear()` was slower still; pool-as-buffer is the kept one.)
|
|
135
|
+
|
|
136
|
+
**3-D / intrinsic (done):** `navmesh_polyanya_find_path` takes 3-D start/goal and writes a 3-D point
|
|
137
|
+
path. It assumes **no global "up"** — the search follows the surface by UNFOLDING. Each node carries the
|
|
138
|
+
2-D positions of its entry edge in a frame accumulated along its own corridor; expanding flattens the
|
|
139
|
+
next triangle into that frame by placing the apex from its two 3-D edge lengths (an isometry of the
|
|
140
|
+
triangle), on the side fixed by the face winding. So frame distances are true geodesic distances and `g`
|
|
141
|
+
is **exact on a curved/folded surface**, not just a planar one. Corners are detected from the 3-D
|
|
142
|
+
incident-angle sum (also intrinsic). The goal has no fixed position across frames, so the A* heuristic is
|
|
143
|
+
the exact straight-3-D-chord shortest root→interval→goal (a lower bound on the geodesic, hence
|
|
144
|
+
admissible); a terminal's *exact* cost unfolds the goal into that node's frame (barycentric). Waypoints
|
|
145
|
+
lift back to 3-D from vertex ids — turning corners are mesh vertices (exact), start/goal are the given
|
|
146
|
+
points, a goal-edge bend is the exact point on that edge. Validated: flat oracles still exact; a
|
|
147
|
+
rigidly-rotated holed mesh reproduces the flat path R-for-R (rotation-invariance, no up passed); and a
|
|
148
|
+
developable curved strip's straight geodesic crosses every crease with **no spurious turn** (the
|
|
149
|
+
unfolding flattens several non-coplanar faces into one frame). `e1 = up × …` and any per-face/per-region
|
|
150
|
+
up decision are gone.
|
|
151
|
+
|
|
152
|
+
**Precision fix (with the 3-D work):** the rotation test surfaced a latent bug — the search's `1e-9`
|
|
153
|
+
tolerances assume exact coordinates, but vertices are stored **float32**, so once a mesh is *not*
|
|
154
|
+
axis-aligned its edge fractions carry ~`coord·2^-23` quantisation noise, far above `1e-9`. Coverage
|
|
155
|
+
intervals then fail to merge, the same edge stretches re-cover as slivers, and the wavefront floods
|
|
156
|
+
without converging. Fix: a parameter-space tolerance `PEPS = 1e-4` (edge fractions, interval coverage,
|
|
157
|
+
vertex snapping) sized to float32 precision, kept separate from the tight area-sign `EPS`. Never moves a
|
|
158
|
+
waypoint (corners are exact vertex positions). The old axis-aligned integer-grid tests never exposed this
|
|
159
|
+
because integers are exact in float32; the standing rotation test now jitters the lattice off the grid (a
|
|
160
|
+
perfectly integer lattice rotated exactly onto an axis is a measure-zero collinearity degeneracy that
|
|
161
|
+
does not occur on real navmeshes).
|
|
162
|
+
|
|
163
|
+
**Cost of going intrinsic (measured):** full ~100k-face bench, intrinsic — **mean 9.1 ms/search**
|
|
164
|
+
(p50 3.9, p90 14.2, p99 40.9, max 4.47 s), 100 % found, ~110 searches/s. Versus the previous
|
|
165
|
+
single-plane-projection build on the same mesh: object-heap **4.7 ms** (p50 2.0, p99 6.8) and GC-free
|
|
166
|
+
pool **~6.0 ms** (p99 18.3). So intrinsic is ~1.5–2× slower in the mean and markedly heavier in the
|
|
167
|
+
tail (p99 6.8 → 40.9). The whole gap is the **heuristic**: the straight-3-D-chord lower bound ignores
|
|
168
|
+
on-surface curvature, so it is weaker than the old in-plane touch distance and expands more nodes. The
|
|
169
|
+
per-node work (an extra unfold + a 3-D touch) is minor by comparison. This is the price of "follow face
|
|
170
|
+
normals" with no up assumption, and it buys exactness on curved surfaces.
|
|
171
|
+
|
|
172
|
+
Remaining levers: a tighter still-admissible heuristic (the biggest win — e.g. a 3-D touch distance
|
|
173
|
+
that accounts for the next face's tilt, or a cached lower-bound field), the paper's advanced interval
|
|
174
|
+
pruning, and wiring `navmesh_polyanya_find_path` into `NavigationMesh.find_path` as an alternative
|
|
175
|
+
backend (it now matches that contract — 3-D points in, 3-D points out, no up-vector — and just needs
|
|
176
|
+
BVH start/goal snapping).
|
|
177
|
+
|
|
178
|
+
## Gates
|
|
179
|
+
|
|
180
|
+
- **Correctness:** `bt_mesh_face_find_path.shortest_path.spec.js` (true shortest path on a flat convex
|
|
181
|
+
mesh) + the existing `bt_mesh_face_find_path.spec.js` + the `NavigationMesh` / `navmesh_*` suites
|
|
182
|
+
(holes, gaps, erosion — the fallback's job).
|
|
183
|
+
- **Performance:** `bt_mesh_face_find_path.bench.spec.js` — random searches on a ~100k-face seeded
|
|
184
|
+
mesh for a fixed wall-clock budget; reports throughput and per-search timing percentiles. `.skip` by
|
|
185
|
+
default (not a quality gate); run on demand.
|
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
* Find a shortest path through topology faces.
|
|
3
3
|
* If a path is found - the result will contain start and goal faces.
|
|
4
4
|
*
|
|
5
|
+
* The corridor is steered by an exact-geodesic distance field rather than a face-graph metric. A
|
|
6
|
+
* goal-directed Fast Marching pass (exact Eikonal triangle update) solves the true along-surface
|
|
7
|
+
* distance-to-goal at each vertex, sweeping toward the start and stopping as soon as the start face is
|
|
8
|
+
* resolved. Because the field measures the real walked distance (not centroid-to-centroid hops), the
|
|
9
|
+
* corridor heads straight at the goal and crosses a finely tessellated patch instead of skirting around
|
|
10
|
+
* it. The corridor is then extracted by descending the field's gradient from the start (which hugs the
|
|
11
|
+
* geodesic, so the downstream funnel/string-pull recovers the exact shortest path); where the gradient
|
|
12
|
+
* is an unreliable guide -- winding around a hole -- a best-first graph walk on the same field takes
|
|
13
|
+
* over. A full (non-early-terminated) solve is used as a backstop for the rare case the bounded fill
|
|
14
|
+
* left the corridor under-resolved.
|
|
15
|
+
*
|
|
5
16
|
* NOTE: if either start or goal faces are not part of the topology - an empty path will be produced.
|
|
6
17
|
*
|
|
7
18
|
* @param {number[]|Uint32Array} output path will be written here as a sequence of face IDs
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bt_mesh_face_find_path.d.ts","sourceRoot":"","sources":["../../../../../src/engine/navigation/mesh/bt_mesh_face_find_path.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"bt_mesh_face_find_path.d.ts","sourceRoot":"","sources":["../../../../../src/engine/navigation/mesh/bt_mesh_face_find_path.js"],"names":[],"mappings":"AA0oBA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,+CAVW,MAAM,EAAE,GAAC,WAAW,iBACpB,MAAM,gBACN,MAAM,8CAEN,MAAM,GAGJ,MAAM,CAuDlB"}
|