tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
field_product_cache.h
1#pragma once
2
3#include <tess/core/tag_identity.h>
4#include <tess/path/path.h>
5
6#include <algorithm>
7#include <cstddef>
8#include <cstdint>
9#include <limits>
10#include <memory>
11#include <span>
12#include <utility>
13#include <vector>
14
15namespace tess {
16
18class GoalSet {
19 public:
20 void reserve(std::size_t count) { goals_.reserve(count); }
21
22 void clear() noexcept { goals_.clear(); }
23
24 void add(Coord3 goal) { goals_.push_back(goal); }
25
26 [[nodiscard]] auto empty() const noexcept -> bool { return goals_.empty(); }
27
28 [[nodiscard]] auto size() const noexcept -> std::size_t {
29 return goals_.size();
30 }
31
32 [[nodiscard]] auto goals() const noexcept -> std::span<const Coord3> {
33 return goals_;
34 }
35
36 private:
37 std::vector<Coord3> goals_;
38};
39
42 PathStatus status = PathStatus::NoPath;
43 std::uint32_t cost = 0;
44 Coord3 target{};
45 std::size_t expanded_nodes = 0;
46 std::size_t reached_nodes = 0;
47 std::span<const Coord3> path;
48};
49
55 public:
56 void reserve_goals(std::size_t count) { goals_.reserve(count); }
57
58 void reserve_nodes(std::size_t node_count) { distance_.reserve(node_count); }
59
60 // Dependency sets are bounded by the world's chunk count (failure
61 // products capture every chunk): reserve chunk_count to keep steady-state
62 // rebuilds allocation-free.
63 void reserve_dependencies(std::size_t count) { dependencies_.reserve(count); }
64
65 void clear() noexcept {
66 status_ = PathStatus::NoPath;
67 expanded_nodes_ = 0;
68 reached_nodes_ = 0;
69 tile_count_ = 0;
70 chunk_count_ = 0;
71 local_tile_count_ = 0;
72 goals_.clear();
73 distance_.clear();
74 dependencies_.clear();
75 }
76
77 // See WeightedRouteProduct::is_valid: empty dependencies are invalid by
78 // definition so cleared/never-built products never replay vacuously.
79 template <typename World>
80 [[nodiscard]] auto is_valid(const World& world) const noexcept -> bool {
81 return !dependencies_.empty() && dependencies_.is_valid(world);
82 }
83
84 [[nodiscard]] auto status() const noexcept -> PathStatus { return status_; }
85
86 [[nodiscard]] auto goals() const noexcept -> std::span<const Coord3> {
87 return goals_;
88 }
89
90 [[nodiscard]] auto dependencies() const noexcept
91 -> std::span<const ChunkVersionDependencies::ChunkVersionDependency> {
92 return dependencies_.chunks();
93 }
94
95 [[nodiscard]] auto expanded_nodes() const noexcept -> std::size_t {
96 return expanded_nodes_;
97 }
98
99 [[nodiscard]] auto reached_nodes() const noexcept -> std::size_t {
100 return reached_nodes_;
101 }
102
103 [[nodiscard]] auto byte_size() const noexcept -> std::size_t {
104 return distance_.size() * sizeof(std::uint32_t) +
105 goals_.size() * sizeof(Coord3) +
106 dependencies_.size() *
108 }
109
110 private:
111 template <typename World, typename Tag>
112 friend auto build_distance_field_product(const World& world,
113 const GoalSet& goals,
114 DistanceFieldScratch& scratch,
115 DistanceFieldProduct& product)
117
118 template <typename World, typename Tag>
119 friend auto distance_field_product_path(const World& world, Coord3 start,
120 const DistanceFieldProduct& product,
121 DistanceFieldScratch& scratch)
122 -> PathResult;
123
124 template <typename World, typename Tag>
125 friend auto nearest_target(const World& world, Coord3 start,
126 const DistanceFieldProduct& product,
127 DistanceFieldScratch& scratch)
129
130 [[nodiscard]] auto is_goal(Coord3 coord) const noexcept -> bool {
131 for (const auto goal : goals_) {
132 if (goal == coord) {
133 return true;
134 }
135 }
136 return false;
137 }
138
139 PathStatus status_ = PathStatus::NoPath;
140 std::size_t expanded_nodes_ = 0;
141 std::size_t reached_nodes_ = 0;
142 std::size_t tile_count_ = 0;
143 std::uint64_t chunk_count_ = 0;
144 std::uint64_t local_tile_count_ = 0;
145 Extent3 shape_size_{};
146 Extent3 chunk_extent_{};
147 std::vector<Coord3> goals_;
148 std::vector<std::uint32_t> distance_;
149 ChunkVersionDependencies dependencies_;
150};
151
154 std::size_t entries = 0;
155 std::size_t bytes = 0;
156 std::size_t hits = 0;
157 std::size_t misses = 0;
158 std::size_t evictions = 0;
159 std::size_t stale_rejections = 0;
160};
161
162// Entries validate against world content versions, not a world instance:
163// keep one cache per world (see RouteCacheScratch fingerprint notes for the
164// aliasing mechanism).
170class FieldProductCache {
171 public:
172 explicit FieldProductCache(
173 std::size_t byte_budget =
174 std::numeric_limits<std::size_t>::max()) noexcept
175 : byte_budget_(byte_budget) {}
176
177 void set_byte_budget(std::size_t byte_budget) {
178 byte_budget_ = byte_budget;
179 evict_to_budget();
180 }
181
182 void reserve_entries(std::size_t count) { entries_.reserve(count); }
183
184 void clear() noexcept {
185 entries_.clear();
186 bytes_ = 0;
187 }
188
189 void reset_stats() noexcept {
190 hits_ = 0;
191 misses_ = 0;
192 evictions_ = 0;
193 stale_rejections_ = 0;
194 }
195
196 [[nodiscard]] auto stats() const noexcept -> FieldProductCacheStats {
198 entries_.size(), bytes_, hits_, misses_, evictions_, stale_rejections_,
199 };
200 }
201
202 // The returned pointer targets heap storage that never moves when other
203 // entries are stored or evicted. It stays valid until a `store()` or
204 // eviction touches this exact key, or until `clear()`. Stale products are
205 // erased on lookup and reported as stale rejections.
206 template <typename World, typename Tag>
207 [[nodiscard]] auto lookup(const World& world, const GoalSet& goals)
208 -> const DistanceFieldProduct* {
209 for (std::size_t i = 0; i < entries_.size(); ++i) {
210 auto& entry = entries_[i];
211 if (!key_matches<World, Tag>(entry.key, goals.goals())) {
212 continue;
213 }
214 if (!entry.product->is_valid(world)) {
215 ++stale_rejections_;
216 erase_entry(i);
217 return nullptr;
218 }
219 ++hits_;
220 entry.last_used = ++clock_;
221 return entry.product.get();
222 }
223
224 ++misses_;
225 return nullptr;
226 }
227
228 // Takes ownership of `product` by move; world-sized field data is never
229 // copied. The argument is left moved-from (empty but reusable). A product
230 // whose entry exceeds the byte budget cannot be cached at all; that store
231 // clears the entire cache and returns false.
232 template <typename World, typename Tag>
233 auto store(DistanceFieldProduct&& product) -> bool {
234 if (product.status() != PathStatus::Found) {
235 return false;
236 }
237
238 auto key = make_key<World, Tag>(product.goals());
239 const auto bytes = entry_byte_size(key, product);
240 if (bytes > byte_budget_) {
241 clear();
242 return false;
243 }
244
245 for (std::size_t i = 0; i < entries_.size(); ++i) {
246 if (keys_equal(entries_[i].key, key)) {
247 bytes_ -= entries_[i].bytes;
248 *entries_[i].product = std::move(product);
249 entries_[i].last_used = ++clock_;
250 entries_[i].bytes = bytes;
251 bytes_ += bytes;
252 evict_to_budget();
253 return true;
254 }
255 }
256
257 auto& entry = entries_.emplace_back();
258 entry.key = std::move(key);
259 entry.product = std::make_unique<DistanceFieldProduct>(std::move(product));
260 entry.last_used = ++clock_;
261 entry.bytes = bytes;
262 bytes_ += bytes;
263 evict_to_budget();
264 return true;
265 }
266
267 private:
268 struct Key {
269 std::uintptr_t passability_field = 0;
270 std::size_t tile_count = 0;
271 std::uint64_t chunk_count = 0;
272 std::uint64_t local_tile_count = 0;
273 Extent3 shape_size{};
274 Extent3 chunk_extent{};
275 std::vector<Coord3> goals;
276 };
277
278 // Each product lives behind a `unique_ptr` so `entries_` growth and
279 // eviction of other entries never relocate a product that a caller still
280 // borrows through `lookup()`.
281 struct Entry {
282 Key key;
283 std::unique_ptr<DistanceFieldProduct> product;
284 std::uint64_t last_used = 0;
285 std::size_t bytes = 0;
286 };
287
288 template <typename World, typename Tag>
289 [[nodiscard]] static auto make_key(std::span<const Coord3> goals) -> Key {
290 return Key{
291 detail::tag_identity<Tag>(),
292 detail::tile_count<World>(),
293 World::chunk_count,
294 World::local_tile_count,
295 ShapeTraits<typename World::shape_type>::size,
296 ShapeTraits<typename World::shape_type>::chunk,
297 std::vector<Coord3>{goals.begin(), goals.end()},
298 };
299 }
300
301 [[nodiscard]] static auto keys_equal(const Key& lhs, const Key& rhs) noexcept
302 -> bool {
303 return lhs.passability_field == rhs.passability_field &&
304 lhs.tile_count == rhs.tile_count &&
305 lhs.chunk_count == rhs.chunk_count &&
306 lhs.local_tile_count == rhs.local_tile_count &&
307 lhs.shape_size == rhs.shape_size &&
308 lhs.chunk_extent == rhs.chunk_extent && lhs.goals == rhs.goals;
309 }
310
311 template <typename World, typename Tag>
312 [[nodiscard]] static auto key_matches(const Key& key,
313 std::span<const Coord3> goals) noexcept
314 -> bool {
315 return key.passability_field == detail::tag_identity<Tag>() &&
316 key.tile_count == detail::tile_count<World>() &&
317 key.chunk_count == World::chunk_count &&
318 key.local_tile_count == World::local_tile_count &&
319 key.shape_size == ShapeTraits<typename World::shape_type>::size &&
320 key.chunk_extent == ShapeTraits<typename World::shape_type>::chunk &&
321 key.goals.size() == goals.size() &&
322 std::equal(key.goals.begin(), key.goals.end(), goals.begin());
323 }
324
325 [[nodiscard]] static auto entry_byte_size(
326 const Key& key, const DistanceFieldProduct& product) noexcept
327 -> std::size_t {
328 return sizeof(Entry) + sizeof(DistanceFieldProduct) +
329 key.goals.size() * sizeof(Coord3) + product.byte_size();
330 }
331
332 void erase_entry(std::size_t index) noexcept {
333 bytes_ -= entries_[index].bytes;
334 entries_.erase(entries_.begin() + static_cast<std::ptrdiff_t>(index));
335 }
336
337 void evict_to_budget() noexcept {
338 while (bytes_ > byte_budget_ && !entries_.empty()) {
339 auto oldest = std::size_t{0};
340 for (std::size_t i = 1; i < entries_.size(); ++i) {
341 if (entries_[i].last_used < entries_[oldest].last_used) {
342 oldest = i;
343 }
344 }
345 erase_entry(oldest);
346 ++evictions_;
347 }
348 }
349
350 std::vector<Entry> entries_;
351 std::size_t byte_budget_ = 0;
352 std::size_t bytes_ = 0;
353 std::size_t hits_ = 0;
354 std::size_t misses_ = 0;
355 std::size_t evictions_ = 0;
356 std::size_t stale_rejections_ = 0;
357 std::uint64_t clock_ = 0;
358};
359
364template <typename World, typename Tag>
365auto build_distance_field_product(const World& world, const GoalSet& goals,
366 DistanceFieldScratch& scratch,
367 DistanceFieldProduct& product)
369 using Shape = typename World::shape_type;
370 // Sizes its distance arrays by the global tile count and treats missing
371 // chunks as blocked with no MissingChunkPolicy, so on a sparse world it would
372 // allocate for the whole (possibly astronomical) shape. Dense-only until the
373 // distance-field family is ported to NodeIndexSpace; this also transitively
374 // guards distance_field_product_path and nearest_target, which only consume
375 // a product built here.
376 static_assert(
377 std::is_same_v<typename World::residency_type, AlwaysResident>,
378 "build_distance_field_product is dense-only; the sparse distance-field "
379 "slice lands later.");
380 constexpr auto infinite_distance = std::numeric_limits<std::uint32_t>::max();
381
382 TESS_DIAG_EVENT_VALUE(path_clear, scratch.touched_.size());
383 scratch.clear_build();
384 product.clear();
385 product.goals_.assign(goals.goals().begin(), goals.goals().end());
386 product.tile_count_ = detail::tile_count<World>();
387 product.chunk_count_ = World::chunk_count;
388 product.local_tile_count_ = World::local_tile_count;
389 product.shape_size_ = ShapeTraits<Shape>::size;
390 product.chunk_extent_ = ShapeTraits<Shape>::chunk;
391
392 if (goals.empty()) {
393 return DistanceFieldResult{PathStatus::InvalidGoal, 0, 0};
394 }
395 for (const auto goal : goals.goals()) {
396 if (!contains<Shape>(goal)) {
397 return DistanceFieldResult{PathStatus::InvalidGoal, 0, 0};
398 }
399 TESS_DIAG_EVENT(path_goal_passability_check);
400 if (!detail::is_passable<World, Tag>(world, goal)) {
401 return DistanceFieldResult{PathStatus::InvalidGoal, 0, 0};
402 }
403 }
404
405 const auto node_count = detail::tile_count<World>();
406 if (scratch.distance_.size() != node_count) {
407 TESS_DIAG_EVENT(path_initialize);
408 scratch.generation_.assign(node_count, 0);
409 scratch.distance_.assign(node_count, infinite_distance);
410 }
411
412 for (const auto goal : goals.goals()) {
413 const auto goal_index = detail::tile_index<Shape>(goal);
414 const auto goal_offset = static_cast<std::size_t>(goal_index);
415 if (scratch.is_current(goal_offset)) {
416 continue;
417 }
418 scratch.distance_[goal_offset] = 0;
419 scratch.touch_node(goal_index);
420 TESS_DIAG_EVENT(path_touch_node);
421 scratch.frontier_.push_back(goal_index);
422 TESS_DIAG_EVENT(path_heap_push);
423 }
424
425 std::size_t expanded_nodes = 0;
426 std::size_t head = 0;
427 while (head < scratch.frontier_.size()) {
428 const auto current = scratch.frontier_[head];
429 ++head;
430 TESS_DIAG_EVENT(path_heap_pop);
431 ++expanded_nodes;
432
433 const auto current_offset = static_cast<std::size_t>(current);
434 const auto current_distance =
435 scratch.distance_at(current_offset, infinite_distance);
436 const auto current_coord = detail::tile_coord<Shape>(current);
437 detail::for_each_indexed_axis_neighbor<Shape>(
438 current_coord, current, [&](Coord3, std::uint64_t neighbor_index) {
439 TESS_DIAG_EVENT(path_neighbor_candidate);
440 const auto neighbor_offset = static_cast<std::size_t>(neighbor_index);
441 if (scratch.is_current(neighbor_offset)) {
442 TESS_DIAG_EVENT(path_neighbor_closed);
443 return;
444 }
445 TESS_DIAG_EVENT(path_passability_check);
446 if (!detail::is_passable_index<World, Tag>(world, neighbor_index)) {
447 TESS_DIAG_EVENT(path_neighbor_blocked);
448 return;
449 }
450
451 scratch.distance_[neighbor_offset] = current_distance + 1;
452 scratch.touch_node(neighbor_index);
453 TESS_DIAG_EVENT(path_touch_node);
454 scratch.frontier_.push_back(neighbor_index);
455 TESS_DIAG_EVENT(path_heap_push);
456 });
457 }
458
459 product.distance_.assign(node_count, infinite_distance);
460 for (const auto index : scratch.touched_) {
461 const auto offset = static_cast<std::size_t>(index);
462 product.distance_[offset] = scratch.distance_[offset];
463 const auto key = tile_key<Shape>(detail::tile_coord<Shape>(index));
464 product.dependencies_.add_chunk(world, chunk_key<Shape>(key));
465 }
466 // The flood never touches a node inside a fully-blocked chunk, but an edit
467 // that opens one changes reachability, so it must invalidate the product.
468 // Axis moves cross at most one chunk face: every blocked tile adjacent to
469 // the reached region lies in a touched chunk or one of its face
470 // neighbors, so depending on those neighbors covers the whole blocked
471 // frontier. The scratch seen-set keeps this pass linear (add_chunk's
472 // duplicate scan would make it quadratic in chunk count), and indexing
473 // re-reads chunks() each pass because appends may grow the vector.
474 {
475 using Traits = ShapeTraits<Shape>;
476 auto& seen = scratch.chunk_seen_;
477 seen.assign(static_cast<std::size_t>(World::chunk_count), 0);
478 const auto touched_chunks = product.dependencies_.size();
479 for (std::size_t i = 0; i < touched_chunks; ++i) {
480 seen[static_cast<std::size_t>(
481 product.dependencies_.chunks()[i].key.value)] = 1;
482 }
483 for (std::size_t i = 0; i < touched_chunks; ++i) {
484 const auto center =
485 chunk_coord<Shape>(product.dependencies_.chunks()[i].key);
486 const auto add = [&](ChunkCoord3 neighbor) {
487 const auto key = chunk_key<Shape>(neighbor);
488 auto& mark = seen[static_cast<std::size_t>(key.value)];
489 if (mark == 0) {
490 mark = 1;
491 product.dependencies_.add_chunk_unique(world, key);
492 }
493 };
494 if (center.x > 0) {
495 add(ChunkCoord3{center.x - 1, center.y, center.z});
496 }
497 if (center.x + 1 < Traits::chunk_count_x) {
498 add(ChunkCoord3{center.x + 1, center.y, center.z});
499 }
500 if (center.y > 0) {
501 add(ChunkCoord3{center.x, center.y - 1, center.z});
502 }
503 if (center.y + 1 < Traits::chunk_count_y) {
504 add(ChunkCoord3{center.x, center.y + 1, center.z});
505 }
506 if (center.z > 0) {
507 add(ChunkCoord3{center.x, center.y, center.z - 1});
508 }
509 if (center.z + 1 < Traits::chunk_count_z) {
510 add(ChunkCoord3{center.x, center.y, center.z + 1});
511 }
512 }
513 }
514 product.status_ = PathStatus::Found;
515 product.expanded_nodes_ = expanded_nodes;
516 product.reached_nodes_ = scratch.touched_.size();
517
518 return DistanceFieldResult{product.status_, product.expanded_nodes_,
519 product.reached_nodes_};
520}
521
525template <typename World, typename Tag>
526auto distance_field_product_path(const World& world, Coord3 start,
527 const DistanceFieldProduct& product,
528 DistanceFieldScratch& scratch) -> PathResult {
529 using Shape = typename World::shape_type;
530 // Indexes product.distance_ by raw tile id, so it is dense-only until the
531 // distance-field product family is ported to NodeIndexSpace. The single-goal
532 // build_distance_field / distance_field_path pair is already sparse-capable.
533 static_assert(
534 std::is_same_v<typename World::residency_type, AlwaysResident>,
535 "distance_field_product_path is dense-only; the sparse distance-field "
536 "product slice lands later.");
537 constexpr auto infinite_distance = std::numeric_limits<std::uint32_t>::max();
538
539 scratch.clear_path();
540 if (!contains<Shape>(start)) {
541 return PathResult{PathStatus::InvalidStart, 0, 0, 0, scratch.path_};
542 }
543 TESS_DIAG_EVENT(path_start_passability_check);
544 if (!detail::is_passable<World, Tag>(world, start)) {
545 return PathResult{PathStatus::InvalidStart, 0, 0, 0, scratch.path_};
546 }
547 if (product.status_ != PathStatus::Found || !product.is_valid(world) ||
548 product.tile_count_ != detail::tile_count<World>() ||
549 product.chunk_count_ != World::chunk_count ||
550 product.local_tile_count_ != World::local_tile_count ||
551 product.shape_size_ != ShapeTraits<Shape>::size ||
552 product.chunk_extent_ != ShapeTraits<Shape>::chunk) {
553 return PathResult{PathStatus::NoPath, 0, 0, 0, scratch.path_};
554 }
555
556 const auto start_index = detail::tile_index<Shape>(start);
557 auto current = start_index;
558 auto current_distance = product.distance_[static_cast<std::size_t>(current)];
559 if (current_distance == infinite_distance) {
560 return PathResult{PathStatus::NoPath, 0, 0, product.reached_nodes_,
561 scratch.path_};
562 }
563
564 scratch.path_.push_back(start);
565 TESS_DIAG_EVENT(path_reconstruct_node);
566 while (current_distance > 0) {
567 const auto current_coord = detail::tile_coord<Shape>(current);
568 auto next = current;
569 auto next_distance = current_distance;
570 detail::for_each_indexed_axis_neighbor<Shape>(
571 current_coord, current, [&](Coord3, std::uint64_t neighbor_index) {
572 const auto neighbor_distance =
573 product.distance_[static_cast<std::size_t>(neighbor_index)];
574 if (neighbor_distance < next_distance) {
575 next = neighbor_index;
576 next_distance = neighbor_distance;
577 }
578 });
579
580 if (next == current || next_distance + 1 != current_distance) {
581 scratch.path_.clear();
582 return PathResult{PathStatus::NoPath, 0, 0, product.reached_nodes_,
583 scratch.path_};
584 }
585
586 current = next;
587 current_distance = product.distance_[static_cast<std::size_t>(current)];
588 scratch.path_.push_back(detail::tile_coord<Shape>(current));
589 TESS_DIAG_EVENT(path_reconstruct_node);
590 }
591
592 return PathResult{PathStatus::Found,
593 product.distance_[static_cast<std::size_t>(start_index)],
594 scratch.path_.size(), product.reached_nodes_,
595 scratch.path_};
596}
597
601template <typename World, typename Tag>
602auto nearest_target(const World& world, Coord3 start,
603 const DistanceFieldProduct& product,
605 // Consumes a dense-only distance-field product; guarded directly rather than
606 // only transitively through distance_field_product_path below.
607 static_assert(
608 std::is_same_v<typename World::residency_type, AlwaysResident>,
609 "nearest_target is dense-only; the sparse distance-field product slice "
610 "lands later.");
611 const auto path =
612 distance_field_product_path<World, Tag>(world, start, product, scratch);
613 auto target = Coord3{};
614 if (path.status == PathStatus::Found && !path.path.empty()) {
615 target = path.path.back();
616 }
617 return NearestTargetResult{
618 path.status, path.cost, target,
619 path.expanded_nodes, path.reached_nodes, path.path,
620 };
621}
622
623} // namespace tess
Definition path.h:234
Definition field_product_cache.h:54
friend auto distance_field_product_path(const World &world, Coord3 start, const DistanceFieldProduct &product, DistanceFieldScratch &scratch) -> PathResult
Reconstructs a borrowed path from a valid multi-goal product.
Definition field_product_cache.h:526
friend auto nearest_target(const World &world, Coord3 start, const DistanceFieldProduct &product, DistanceFieldScratch &scratch) -> NearestTargetResult
Finds the nearest reachable goal represented by a valid product.
Definition field_product_cache.h:602
friend auto build_distance_field_product(const World &world, const GoalSet &goals, DistanceFieldScratch &scratch, DistanceFieldProduct &product) -> DistanceFieldResult
Builds a dense multi-goal field into caller-owned reusable storage.
Definition field_product_cache.h:365
Definition path.h:642
Owns an ordered set of goals used to build a reusable distance product.
Definition field_product_cache.h:18
Definition world.h:22
Definition shape.h:39
Definition shape.h:30
Reports distance-field construction status and search work.
Definition path.h:72
Definition shape.h:13
Summarizes field-product cache residency and lookup outcomes.
Definition field_product_cache.h:153
Reports the closest reachable goal and a scratch-owned path to it.
Definition field_product_cache.h:41
Definition path.h:63
Definition shape.h:244
Definition shape.h:225