tess 1.0.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
adapter.h
1#pragma once
2
3#include <tess/core/assert.h>
4#include <tess/ecs/entity_handle.h>
5#include <tess/sim/delta_frame.h>
6#include <tess/sim/path_agent.h>
7#include <tess/sim/path_agent_tick.h>
8
9#include <concepts>
10#include <cstddef>
11#include <cstdint>
12#include <span>
13#include <vector>
14
15// The ECS-agnostic integration layer. Everything here is free of
16// third-party dependencies: concrete ECS adapters (the EnTT and Flecs
17// adapters in their respective subdirectories, or an application's own)
18// implement the concepts below and reuse the shared components, batch
19// scratch, occupancy index, and tick pipeline. The seam is deliberately
20// "agents in deterministic order in, state write-back out" -- request
21// submission, tickets, retry
22// budgets, and exactly-once result application all stay inside the
23// PathAgentState lifecycle (sim/path_agent.h), so adapters can never
24// duplicate or violate it.
25namespace tess {
26
27// EntityHandle and kNullEntityHandle live in <tess/ecs/entity_handle.h>
28// (re-exported here) so dependency-light layers can name entity identity.
29
36template <typename A>
37concept EntityHandleAdapter = requires(const A& adapter, EntityHandle handle,
38 const typename A::entity_type& entity) {
39 typename A::entity_type;
40 { adapter.to_handle(entity) } noexcept -> std::same_as<EntityHandle>;
41 {
42 adapter.to_entity(handle)
43 } noexcept -> std::same_as<typename A::entity_type>;
44};
45
47template <typename A, typename Entity>
48concept PositionAdapter = requires(A& adapter, const A& const_adapter,
49 const Entity& entity, Coord3 coord) {
50 { const_adapter.position(entity) } -> std::convertible_to<Coord3>;
51 adapter.set_position(entity, coord);
52};
53
56 std::size_t count = 0;
57 // True iff any goal was armed or re-armed during collection; the tick
58 // pipeline maps it to mark_pathing_dirty. Goal clears do not set it: a
59 // cleared agent is skipped by every processing pass, so re-searching for the
60 // survivors would be pure waste.
61 bool pathing_dirty = false;
62};
63
71 public:
72 void reserve(std::size_t agent_capacity) {
73 handles_.reserve(agent_capacity);
74 agents_.reserve(agent_capacity);
75 }
76
77 void clear() noexcept {
78 handles_.clear();
79 agents_.clear();
80 }
81
82 void push(EntityHandle handle, const PathAgentState& agent) {
83 handles_.push_back(handle);
84 agents_.push_back(agent);
85 }
86
87 [[nodiscard]] auto size() const noexcept -> std::size_t {
88 return agents_.size();
89 }
90
91 [[nodiscard]] auto agents() noexcept -> std::span<PathAgentState> {
92 return agents_;
93 }
94
95 [[nodiscard]] auto agents() const noexcept
96 -> std::span<const PathAgentState> {
97 return agents_;
98 }
99
100 [[nodiscard]] auto handles() const noexcept -> std::span<const EntityHandle> {
101 return handles_;
102 }
103
104 private:
105 std::vector<EntityHandle> handles_;
106 std::vector<PathAgentState> agents_;
107};
108
115template <typename S>
116concept PathAgentSource = requires(S& source, PathAgentBatch& batch) {
117 { source.collect(batch) } -> std::same_as<PathAgentCollectInfo>;
118};
119
126template <typename S>
128 requires(S& sink, const PathAgentBatch& batch) { sink.apply(batch); };
129
130// Shared components for ECS-side agents. Plain PODs with no dependency on
131// any ECS library, so every adapter (and any game-defined store) can reuse
132// them.
133
140struct AgentId {
141 std::uint64_t value = 0;
142};
143
146 Coord3 coord{};
147};
148
150struct PathGoal {
151 Coord3 coord{};
152};
153
160struct PathState {
161 PathAgentState agent{};
162};
163
169struct OffBoard {};
170
179 public:
180 // Sizes the table so `entity_capacity` entries fit without rehashing.
181 void reserve(std::size_t entity_capacity) {
182 auto target = std::size_t{8};
183 while (target < entity_capacity * 2) {
184 target *= 2;
185 }
186 if (target > slots_.size()) {
187 rehash(target);
188 }
189 }
190
191 // Maps `tile` to `entity`. Returns false (and mutates nothing) for a null
192 // entity or if the tile already maps to a DIFFERENT entity -- occupancy
193 // uniqueness is structural, not advisory. Re-inserting the same mapping
194 // succeeds.
195 // A refusal is not allocation-free at the growth threshold: the table
196 // rehashes before discovering the duplicate tile.
197 [[nodiscard]] auto insert(Coord3 tile, EntityHandle entity) -> bool {
198 if (entity.is_null()) {
199 return false;
200 }
201 // probe_start's fast lane combine relies on this domain; see its
202 // comment.
203 TESS_ASSERT_MSG(tile.x >= 0 && tile.y >= 0 && tile.z >= 0,
204 "TileOccupancyIndex stores world tiles, which are "
205 "non-negative");
206 if (slots_.empty() || (size_ + 1) * 2 > slots_.size()) {
207 rehash(slots_.empty() ? 8 : slots_.size() * 2);
208 }
209 auto index = probe_start(tile);
210 for (;;) {
211 auto& slot = slots_[index];
212 if (slot.entity.is_null()) {
213 slot = Slot{tile, entity};
214 ++size_;
215 return true;
216 }
217 if (slot.tile == tile) {
218 return slot.entity == entity;
219 }
220 index = (index + 1) & mask();
221 }
222 }
223
224 // Unmaps `tile`, returning the entity it held (null if it held none).
225 auto erase(Coord3 tile) noexcept -> EntityHandle {
226 if (slots_.empty()) {
227 return kNullEntityHandle;
228 }
229 auto index = probe_start(tile);
230 for (;;) {
231 auto& slot = slots_[index];
232 if (slot.entity.is_null()) {
233 return kNullEntityHandle;
234 }
235 if (slot.tile == tile) {
236 break;
237 }
238 index = (index + 1) & mask();
239 }
240 const auto erased = slots_[index].entity;
241 // Backward-shift deletion: pull every trailing probe-chain entry
242 // whose ideal slot lies cyclically at or before the hole into the
243 // hole, so no lookup's probe path is ever severed.
244 auto hole = index;
245 auto next = index;
246 for (;;) {
247 next = (next + 1) & mask();
248 const auto& candidate = slots_[next];
249 if (candidate.entity.is_null()) {
250 break;
251 }
252 const auto ideal = probe_start(candidate.tile);
253 const auto in_gap = (next > hole) ? (ideal > hole && ideal <= next)
254 : (ideal > hole || ideal <= next);
255 if (!in_gap) {
256 slots_[hole] = candidate;
257 hole = next;
258 }
259 }
260 slots_[hole] = Slot{};
261 --size_;
262 return erased;
263 }
264
265 // erase(from) + insert(to, entity) as the movement-commit hot path,
266 // with debug asserts that `from` held `entity` and `to` was empty.
267 // Never rehashes: the net size is unchanged.
268 void move(Coord3 from, Coord3 to, EntityHandle entity) noexcept {
269 // Same pinned domain as insert(): move() is the second write path
270 // into the table, and probe_start's fast lane combine relies on it.
271 TESS_ASSERT_MSG(to.x >= 0 && to.y >= 0 && to.z >= 0,
272 "TileOccupancyIndex stores world tiles, which are "
273 "non-negative");
274 const auto erased = erase(from);
275 TESS_ASSERT_MSG(erased == entity,
276 "TileOccupancyIndex::move source held another entity");
277 static_cast<void>(erased);
278 TESS_ASSERT_MSG(entity_at(to).is_null(),
279 "TileOccupancyIndex::move destination already mapped");
280 auto index = probe_start(to);
281 while (!slots_[index].entity.is_null()) {
282 index = (index + 1) & mask();
283 }
284 slots_[index] = Slot{to, entity};
285 ++size_;
286 }
287
288 [[nodiscard]] auto entity_at(Coord3 tile) const noexcept -> EntityHandle {
289 if (slots_.empty()) {
290 return kNullEntityHandle;
291 }
292 auto index = probe_start(tile);
293 for (;;) {
294 const auto& slot = slots_[index];
295 if (slot.entity.is_null()) {
296 return kNullEntityHandle;
297 }
298 if (slot.tile == tile) {
299 return slot.entity;
300 }
301 index = (index + 1) & mask();
302 }
303 }
304
305 [[nodiscard]] auto size() const noexcept -> std::size_t { return size_; }
306
307 void clear() noexcept {
308 for (auto& slot : slots_) {
309 slot = Slot{};
310 }
311 size_ = 0;
312 }
313
314 private:
315 struct Slot {
316 Coord3 tile{};
317 EntityHandle entity = kNullEntityHandle;
318 };
319
320 [[nodiscard]] auto mask() const noexcept -> std::size_t {
321 return slots_.size() - 1;
322 }
323
324 [[nodiscard]] static auto mix(std::uint64_t value) noexcept -> std::uint64_t {
325 value += 0x9E3779B97F4A7C15ULL;
326 value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL;
327 value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL;
328 return value ^ (value >> 31U);
329 }
330
331 [[nodiscard]] auto probe_start(Coord3 tile) const noexcept -> std::size_t {
332 // One avalanche over per-lane multiplies instead of three chained
333 // mix() rounds (6 serial multiplies): the lanes now hash in parallel
334 // and erase's backward-shift, which recomputes probe_start per
335 // displaced entry, pays one round.
336 //
337 // The XOR combine has sign/lane-swap symmetries (Codex review:
338 // (-n, n, 0) collides with (n, -n, 0)), but those inputs are out of
339 // domain -- insert() asserts non-negative world coordinates, and the
340 // symmetry needs a negative lane. Additive and rotated combines were
341 // measured 1.7-1.9x slower here (interleaved A/B), so the domain is
342 // pinned instead of the hash hardened.
343 const auto hash =
344 mix(static_cast<std::uint64_t>(tile.x) * 0x9E3779B97F4A7C15ULL ^
345 static_cast<std::uint64_t>(tile.y) * 0xC2B2AE3D27D4EB4FULL ^
346 static_cast<std::uint64_t>(tile.z) * 0x165667B19E3779F9ULL);
347 return static_cast<std::size_t>(hash) & mask();
348 }
349
350 void rehash(std::size_t new_capacity) {
351 auto old = std::vector<Slot>(new_capacity);
352 old.swap(slots_);
353 size_ = 0;
354 for (const auto& slot : old) {
355 if (!slot.entity.is_null()) {
356 auto index = probe_start(slot.tile);
357 while (!slots_[index].entity.is_null()) {
358 index = (index + 1) & mask();
359 }
360 slots_[index] = slot;
361 ++size_;
362 }
363 }
364 }
365
366 std::vector<Slot> slots_;
367 std::size_t size_ = 0;
368};
369
370template <typename World, typename ClassOrTag, typename OccupancyTag,
371 typename ReservationTag>
378inline auto advance_path_agents_with_index(
379 World& world, PathAgentBatch& batch, const PathRequestRuntime& runtime,
380 TileOccupancyIndex& index, PathAgentAdvanceOptions options = {},
381 DeltaCollector* render_deltas = nullptr) -> PathAgentFrameStats {
382 const auto handles = batch.handles();
383 return advance_path_agents_with_movement<World, ClassOrTag, OccupancyTag,
384 ReservationTag>(
385 world, batch.agents(), runtime, options,
386 [&index, handles, render_deltas](std::size_t agent_index, Coord3 from,
387 Coord3 to) {
388 index.move(from, to, handles[agent_index]);
389 if (render_deltas != nullptr) {
390 render_deltas->record_move(handles[agent_index], from, to);
391 }
392 });
393}
394
395template <typename World, typename ClassOrTag, typename OccupancyTag,
396 typename ReservationTag, PathAgentSource Source, PathAgentSink Sink>
404[[nodiscard]] auto tick_ecs_unit_path_agents(
405 PathAgentTickState& state, World& world, Source& source, Sink& sink,
406 PathAgentBatch& batch, PathRequestRuntime& runtime,
407 TileOccupancyIndex& index, PathAgentTickOptions options = {},
409 DeltaCollector* render_deltas = nullptr) -> PathAgentTickStats {
410 const auto info = source.collect(batch);
411 if (info.pathing_dirty) {
412 mark_pathing_dirty(state);
413 }
414
415 PathAgentTickStats stats;
416 stats.tick = advance_sim_tick(state.clock);
417 if (render_deltas != nullptr) {
418 // Stamp every commit this tick before movement runs.
419 render_deltas->begin_tick(stats.tick);
420 }
421
422 const bool repath_needed =
423 prepare_path_agent_processing(batch.agents(), options, stats);
424 if (state.pathing_dirty || repath_needed) {
425 stats.pathing = process_unit_path_agents<World, ClassOrTag>(
426 world, batch.agents(), runtime, options.cache_policy, graph);
427 stats.processed_paths = true;
428 state.pathing_dirty = false;
429 }
430
431 stats.movement = advance_path_agents_with_index<World, ClassOrTag,
432 OccupancyTag, ReservationTag>(
433 world, batch, runtime, index,
434 PathAgentAdvanceOptions{options.max_steps, options.movement_dirty_mask},
435 render_deltas);
436 sink.apply(batch);
437 return stats;
438}
439
440template <typename World, typename Class, std::uint32_t MaxCost,
441 typename OccupancyTag, typename ReservationTag,
442 PathAgentSource Source, PathAgentSink Sink>
449[[nodiscard]] auto tick_ecs_path_agents(
450 PathAgentTickState& state, World& world, Source& source, Sink& sink,
451 PathAgentBatch& batch, PathRequestRuntime& runtime,
452 TileOccupancyIndex& index, PathAgentTickOptions options = {},
454 DeltaCollector* render_deltas = nullptr) -> PathAgentTickStats {
455 const auto info = source.collect(batch);
456 if (info.pathing_dirty) {
457 mark_pathing_dirty(state);
458 }
459
460 PathAgentTickStats stats;
461 stats.tick = advance_sim_tick(state.clock);
462 if (render_deltas != nullptr) {
463 // Stamp every commit this tick before movement runs.
464 render_deltas->begin_tick(stats.tick);
465 }
466
467 const bool repath_needed =
468 prepare_path_agent_processing(batch.agents(), options, stats);
469 if (state.pathing_dirty || repath_needed) {
470 stats.pathing = process_weighted_path_agents<World, Class, MaxCost>(
471 world, batch.agents(), runtime, options.cache_policy, graph);
472 stats.processed_paths = true;
473 state.pathing_dirty = false;
474 }
475
476 stats.movement = advance_path_agents_with_index<World, Class, OccupancyTag,
477 ReservationTag>(
478 world, batch, runtime, index,
479 PathAgentAdvanceOptions{options.max_steps, options.movement_dirty_mask},
480 render_deltas);
481 sink.apply(batch);
482 return stats;
483}
484
485} // namespace tess
Definition delta_frame.h:318
Definition adapter.h:70
Definition path_runtime.h:197
Region graph storage specialized by dense or sparse residency policy.
Definition topology.h:382
Definition adapter.h:178
Definition world.h:22
Definition adapter.h:37
Definition adapter.h:127
Definition adapter.h:116
Definition adapter.h:48
Definition adapter.h:140
Definition shape.h:46
Definition entity_handle.h:16
Definition adapter.h:169
Configures bounded direct movement and the dirty bits it emits.
Definition path_agent.h:78
Definition adapter.h:55
Stores one agent's goal, route cursor, and retry lifecycle state.
Definition path_agent.h:35
Configures per-tick movement, caching, and blocked-agent retry limits.
Definition path_agent_tick.h:84
Definition path_agent_tick.h:17
Summarizes path planning and movement performed during one tick.
Definition path_agent_tick.h:531
Definition adapter.h:150
Definition adapter.h:160
Definition adapter.h:145