tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
delta_frame.h
1#pragma once
2
3#include <tess/core/assert.h>
4#include <tess/core/shape.h>
5#include <tess/ecs/entity_handle.h>
6#include <tess/path/path_runtime.h>
7#include <tess/path/path_view.h>
8#include <tess/sim/path_agent.h>
9#include <tess/storage/residency.h>
10#include <tess/storage/world.h>
11
12#include <algorithm>
13#include <cstddef>
14#include <cstdint>
15#include <limits>
16#include <span>
17#include <type_traits>
18#include <vector>
19
20// The M11 render bridge: versioned frames of tile, entity, and overlay
21// deltas collected into caller-owned storage and consumed by a renderer.
22//
23// Tile deltas are INVALIDATION records, not value payloads: the consumer
24// re-reads the current world for the covered tiles when applying, which
25// is idempotent and convergent (anything changed after publish is dirty
26// again and re-invalidated next frame). Chunk dirty metadata is already
27// a cross-tick coalescer (flags OR, bounds union), so tile collection
28// happens once per published frame through the lost-update-safe
29// observe/clear-observed protocol -- multi-tick coalescing costs nothing.
30//
31// Entity deltas are pushed at commit time through the ECS pipeline's
32// optional collector hook and the EnTT lifecycle intents; consecutive
33// moves of one entity coalesce last-writer-wins within a frame while
34// every other kind is a non-coalescible barrier. Completeness holds for
35// the tick_ecs_*/tick_entt_* + lifecycle-intent surface; legacy span
36// drivers bypass recording by construction.
37//
38// Coalesced entity records are NOT a serializable per-record sequence:
39// a coalesced move sits at its first commit's position, so replaying
40// records in order can transiently place two entities on one tile.
41// Consumers key presentation state by entity and validate any
42// tile-exclusivity only at frame end.
43namespace tess {
44
45// Monotonic frame-chain version. A consumer echoes the last applied
46// frame's to_version; value 0 is reserved for a consumer that has never
47// applied anything (a collector never publishes from_version 0), so a
48// fresh consumer can only start from a baseline.
51 std::uint64_t value = 0;
52
53 friend constexpr auto operator==(RenderVersion, RenderVersion) noexcept
54 -> bool = default;
55};
56
57// One chunk's tile-change record. tile_count == 0 means box-granular:
58// repaint every tile in `bounds` (pre-clipped to the chunk). Otherwise
59// frame.tiles[first_tile .. first_tile + tile_count) lists the changed
60// tiles individually.
63 ChunkKey chunk_key{};
64 std::uint32_t dirty_flags = 0;
65 // meta.version at observation time; debugging only -- clears do not
66 // bump it and sparse reloads reset it, so it is NOT the frame contract.
67 std::uint32_t chunk_version = 0;
68 Box3 bounds{};
69 std::uint32_t first_tile = 0;
70 std::uint32_t tile_count = 0;
71};
72
74struct TileDelta {
75 Coord3 coord{};
76 LocalTileId local_tile_id{};
77 std::uint32_t dirty_flags = 0;
78};
79
81enum class EntityDeltaKind : std::uint8_t {
82 Moved,
83 Teleported,
84 Spawned,
85 Despawned,
86 Parked,
87 Placed,
88};
89
92 EntityHandle entity{};
93 EntityDeltaKind kind = EntityDeltaKind::Moved;
94 // Spawned/Placed: from == to. Despawned/Parked: the released tile.
95 Coord3 from{};
96 Coord3 to{};
97 // Sim tick of the last coalesced commit (== the only commit when
98 // uncoalesced). Consumers use it to distinguish "moved this frame"
99 // from resting entities.
100 std::uint64_t last_tick = 0;
101};
102
103// One agent's remaining route this frame:
104// frame.overlay_nodes[first_node .. first_node + node_count). Overlays
105// are FULL-REPLACEMENT decorations: every applied frame replaces the
106// consumer's whole overlay set (possibly with the empty set), so no
107// create/update/remove lifecycle exists. Nodes are copies -- safe for
108// the frame's lifetime, gone at the next publish.
111 EntityHandle entity{};
112 // Identity/debugging only; the nodes are already copied.
113 PathTicket ticket{};
114 std::uint32_t first_node = 0;
115 std::uint32_t node_count = 0;
116};
117
120 RenderVersion from_version{};
121 RenderVersion to_version{};
122 // First and last sim tick folded into this frame, and how many ticks
123 // begin_tick reported (0 while paused). Records made between ticks
124 // carry the previous tick's stamp.
125 std::uint64_t first_tick = 0;
126 std::uint64_t last_tick = 0;
127 std::uint32_t ticks = 0;
128 // Union of the dirty masks collected into this frame.
129 std::uint32_t dirty_mask = 0;
130 bool baseline = false;
131 // Storage capacity was exceeded (or the collector was hard-reset with
132 // clear()): the frame is NOT safely applicable as a delta and the
133 // consumer must resync from a baseline. delta_frame_applicable treats
134 // this as a structural gap.
135 bool truncated = false;
136};
137
138// Immutable view into collector-owned storage. Valid until the next
139// mutating call on the collector (begin_tick / record_* / collect_* /
140// publish / clear). Single-buffered by design: renderers own their
141// persistent presentation memory.
144 DeltaFrameHeader header{};
145 std::span<const TileChunkDelta> chunks{};
146 std::span<const TileDelta> tiles{};
147 std::span<const EntityDelta> entities{};
148 std::span<const PathOverlayDelta> overlays{};
149 std::span<const Coord3> overlay_nodes{};
150
151 // Overlays are stateless per-frame decorations and never affect
152 // version semantics or emptiness. Truncated frames are never empty:
153 // the header itself carries the must-resync signal.
154 [[nodiscard]] auto empty() const noexcept -> bool {
155 return chunks.empty() && tiles.empty() && entities.empty() &&
156 !header.baseline && !header.truncated;
157 }
158};
159
160// True when a consumer at `consumer` can apply `header`'s frame:
161// truncation never applies -- not even for baselines, because a baseline
162// that overflowed chunk storage covers only part of the world while
163// claiming full sync (size baseline consumers' chunk capacity to the
164// whole world / resident set); un-truncated baselines always apply (the
165// consumer adopts to_version unconditionally and re-snapshots its entity
166// presentation); otherwise the chain must match exactly. A fresh
167// consumer ({0}) can only ever start from a baseline because collectors
168// never publish from_version 0.
170[[nodiscard]] constexpr auto delta_frame_applicable(
171 const DeltaFrameHeader& header, RenderVersion consumer) noexcept -> bool {
172 if (header.truncated) {
173 return false;
174 }
175 if (header.baseline) {
176 return true;
177 }
178 return consumer.value != 0 && consumer == header.from_version;
179}
180
183 // Per chunk: emit per-tile records while the clipped dirty box holds
184 // at most this many tiles; above it (or when tile storage cannot take
185 // them) emit one box-granular record instead. 0 = always box-granular.
186 std::uint32_t sparse_tile_threshold = 64;
187 // Fold consecutive moves of one entity into a single record. Serves
188 // redraw-at-tile consumers; motion-interpolating renderers should
189 // disable it so every step's span stays one tile.
190 bool coalesce_moves = true;
191};
192
193// Cumulative counters, never reset by publish.
196 std::uint64_t frames_published = 0;
197 std::uint64_t baselines_published = 0;
198 std::uint64_t chunk_records = 0;
199 std::uint64_t tile_records = 0;
200 std::uint64_t box_records = 0;
201 std::uint64_t entity_records = 0;
202 std::uint64_t moves_coalesced = 0;
203 std::uint64_t overlay_records = 0;
204 std::uint64_t overlay_nodes_copied = 0;
205 // Overlay overflow only: overlays are best-effort decorations, so
206 // dropping them never truncates the frame.
207 std::uint64_t overlay_truncations = 0;
208 std::uint64_t truncations = 0;
209};
210
211// Caller-owned delta accumulator and frame publisher. reserve() sizes
212// every buffer once; steady state performs no allocation -- records past
213// capacity are dropped and flagged (header.truncated) rather than
214// growing storage mid-frame. The collector must be the SOLE clearing
215// owner of every dirty bit in the masks it collects: another consumer
216// clearing (or reading-then-expecting) those bits races the frame
217// protocol. Note that dirty_bounds is shared across all flag owners --
218// clearing a subset mask retains the union bounds while any other
219// owner's bit is set, so interleaved ownership widens boxes
220// (conservative over-report, never wrong).
226class DeltaCollector {
227 public:
228 DeltaCollector() = default;
229 explicit DeltaCollector(DeltaCollectorOptions options) : options_(options) {}
230
231 // Setup-time capacities; entity_capacity also sizes the coalescing
232 // map (kept at load factor <= 0.5). Consumers publishing baselines
233 // must size chunk_capacity to the whole world (dense) or the resident
234 // set (sparse): a baseline that overflows is truncated and therefore
235 // never applicable.
236 void reserve(std::size_t chunk_capacity, std::size_t tile_capacity,
237 std::size_t entity_capacity, std::size_t overlay_capacity = 0,
238 std::size_t overlay_node_capacity = 0) {
239 pending_chunks_.reserve(chunk_capacity);
240 published_chunks_.reserve(chunk_capacity);
241 pending_tiles_.reserve(tile_capacity);
242 published_tiles_.reserve(tile_capacity);
243 pending_entities_.reserve(entity_capacity);
244 published_entities_.reserve(entity_capacity);
245 pending_overlays_.reserve(overlay_capacity);
246 published_overlays_.reserve(overlay_capacity);
247 pending_overlay_nodes_.reserve(overlay_node_capacity);
248 published_overlay_nodes_.reserve(overlay_node_capacity);
249 auto slots = std::size_t{8};
250 while (slots < entity_capacity * 2) {
251 slots *= 2;
252 }
253 if (slots > coalesce_slots_.size()) {
254 coalesce_slots_.assign(slots, CoalesceSlot{});
255 }
256 }
257
258 // Stamps subsequent entity records; call once per sim tick (the tick
259 // pipeline does this through its collector hook).
260 void begin_tick(std::uint64_t tick) noexcept {
261 current_tick_ = tick;
262 if (pending_ticks_ == 0) {
263 pending_first_tick_ = tick;
264 }
265 pending_last_tick_ = tick;
266 ++pending_ticks_;
267 }
268
269 // Entity recording. Failed intents must not be recorded; the ECS hook
270 // sites key on the intent's success return.
271 void record_move(EntityHandle entity, Coord3 from, Coord3 to) {
272 if (options_.coalesce_moves) {
273 if (auto* slot = find_coalesce_slot(entity);
274 slot != nullptr && slot->record_index != kBarrier) {
275 auto& record = pending_entities_[slot->record_index];
276 if (record.to == from) {
277 record.to = to;
278 record.last_tick = current_tick_;
279 ++stats_.moves_coalesced;
280 return;
281 }
282 }
283 }
284 const auto index = append_entity(
285 EntityDelta{entity, EntityDeltaKind::Moved, from, to, current_tick_});
286 if (options_.coalesce_moves && index != kDropped) {
287 upsert_coalesce_slot(entity, index);
288 }
289 }
290
291 void record_teleport(EntityHandle entity, Coord3 from, Coord3 to) {
292 record_barrier(EntityDelta{entity, EntityDeltaKind::Teleported, from, to,
293 current_tick_});
294 }
295
296 void record_spawn(EntityHandle entity, Coord3 at) {
297 record_barrier(
298 EntityDelta{entity, EntityDeltaKind::Spawned, at, at, current_tick_});
299 }
300
301 void record_despawn(EntityHandle entity, Coord3 at) {
302 record_barrier(
303 EntityDelta{entity, EntityDeltaKind::Despawned, at, at, current_tick_});
304 }
305
306 void record_park(EntityHandle entity, Coord3 at) {
307 record_barrier(
308 EntityDelta{entity, EntityDeltaKind::Parked, at, at, current_tick_});
309 }
310
311 void record_place(EntityHandle entity, Coord3 at) {
312 record_barrier(
313 EntityDelta{entity, EntityDeltaKind::Placed, at, at, current_tick_});
314 }
315
316 // Appends one chunk record; used by the collect_* templates. Returns
317 // the record's index or kDropped when chunk storage is full.
318 auto append_chunk_record(TileChunkDelta record) -> std::size_t {
319 if (pending_chunks_.size() == pending_chunks_.capacity()) {
320 note_truncation();
321 return kDropped;
322 }
323 pending_chunks_.push_back(record);
324 ++stats_.chunk_records;
325 if (record.tile_count == 0) {
326 ++stats_.box_records;
327 }
328 return pending_chunks_.size() - 1;
329 }
330
331 // Appends one per-tile record; collect_tile_deltas falls back to a
332 // box record when tile storage cannot hold a chunk's tiles, so this
333 // returning kDropped is handled without truncation.
334 auto append_tile_record(TileDelta record) -> std::size_t {
335 if (pending_tiles_.size() == pending_tiles_.capacity()) {
336 return kDropped;
337 }
338 pending_tiles_.push_back(record);
339 ++stats_.tile_records;
340 return pending_tiles_.size() - 1;
341 }
342
343 [[nodiscard]] auto pending_tile_count() const noexcept -> std::size_t {
344 return pending_tiles_.size();
345 }
346
347 void note_collected_mask(std::uint32_t dirty_mask) noexcept {
348 pending_dirty_mask_ |= dirty_mask;
349 }
350
351 // Baseline collection supersedes every pending Dirty record AND any
352 // pending truncation: dropped tile records are covered by the full
353 // repaint and dropped entity records by the consumer's baseline
354 // re-snapshot, so only a baseline that itself overflows stays
355 // truncated (and therefore unusable).
356 void drop_pending_tile_state() noexcept {
357 pending_chunks_.clear();
358 pending_tiles_.clear();
359 pending_truncated_ = false;
360 }
361
362 void mark_baseline_pending() noexcept { baseline_pending_ = true; }
363
364 // Copies `remaining`'s nodes into collector storage NOW; the source
365 // view may dangle afterwards. Empty views stage nothing. Overflowing
366 // overlay storage drops the overlay (never the frame): overlays are
367 // best-effort, full-replacement decorations.
368 void stage_path_overlay(EntityHandle entity, PathTicket ticket,
369 PathView remaining) {
370 if (remaining.empty()) {
371 return;
372 }
373 if (pending_overlays_.size() == pending_overlays_.capacity() ||
374 pending_overlay_nodes_.size() + remaining.size() >
375 pending_overlay_nodes_.capacity()) {
376 ++stats_.overlay_truncations;
377 return;
378 }
379 const auto first_node =
380 static_cast<std::uint32_t>(pending_overlay_nodes_.size());
381 for (const auto& node : remaining) {
382 pending_overlay_nodes_.push_back(node);
383 }
384 pending_overlays_.push_back(
385 PathOverlayDelta{entity, ticket, first_node,
386 static_cast<std::uint32_t>(remaining.size())});
387 ++stats_.overlay_records;
388 stats_.overlay_nodes_copied += remaining.size();
389 }
390
391 // Seals pending state into an immutable frame. The version bumps iff
392 // the frame carries chunk/tile/entity state or is a baseline; empty
393 // publishes return from == to. A baseline drops pending entity
394 // records (the consumer re-snapshots its entity presentation on every
395 // baseline apply -- tess does not own entities, so entity loss is
396 // only recoverable that way). After a hard clear(), the next
397 // non-baseline publish is forced truncated so the consumer resyncs.
398 [[nodiscard]] auto publish() -> DeltaFrame {
399 if (baseline_pending_) {
400 drop_pending_entities();
401 }
402 // Truncated publishes always advance the chain, even header-only
403 // ones: a lossy consumer that misses a gap frame must not be able
404 // to apply the next delta as if nothing was dropped.
405 const auto state_carrying =
406 !pending_chunks_.empty() || !pending_tiles_.empty() ||
407 !pending_entities_.empty() || baseline_pending_ || pending_truncated_ ||
408 needs_baseline_;
409 auto header = DeltaFrameHeader{};
410 header.from_version = version_;
411 if (state_carrying) {
412 ++version_.value;
413 }
414 header.to_version = version_;
415 header.first_tick = pending_first_tick_;
416 header.last_tick = pending_last_tick_;
417 header.ticks = pending_ticks_;
418 header.dirty_mask = pending_dirty_mask_;
419 header.baseline = baseline_pending_;
420 header.truncated =
421 pending_truncated_ || (needs_baseline_ && !baseline_pending_);
422 if (baseline_pending_) {
423 needs_baseline_ = false;
424 }
425
426 clear_coalesce_slots();
427 published_chunks_.swap(pending_chunks_);
428 published_tiles_.swap(pending_tiles_);
429 published_entities_.swap(pending_entities_);
430 published_overlays_.swap(pending_overlays_);
431 published_overlay_nodes_.swap(pending_overlay_nodes_);
432 pending_chunks_.clear();
433 pending_tiles_.clear();
434 pending_entities_.clear();
435 pending_overlays_.clear();
436 pending_overlay_nodes_.clear();
437 pending_dirty_mask_ = 0;
438 pending_ticks_ = 0;
439 pending_first_tick_ = 0;
440 pending_last_tick_ = 0;
441 pending_truncated_ = false;
442 baseline_pending_ = false;
443
444 ++stats_.frames_published;
445 if (header.baseline) {
446 ++stats_.baselines_published;
447 }
448 return DeltaFrame{header,
449 published_chunks_,
450 published_tiles_,
451 published_entities_,
452 published_overlays_,
453 published_overlay_nodes_};
454 }
455
456 // Hard reset of pending state. Poisons the stream: dropped records
457 // are unrecoverable, so the next publish is forced truncated unless
458 // it is a baseline. Contract: a world swap or regeneration is
459 // clear() followed by collect_baseline() before the next publish.
460 void clear() noexcept {
461 clear_coalesce_slots();
462 pending_chunks_.clear();
463 pending_tiles_.clear();
464 pending_entities_.clear();
465 pending_overlays_.clear();
466 pending_overlay_nodes_.clear();
467 pending_dirty_mask_ = 0;
468 pending_ticks_ = 0;
469 pending_first_tick_ = 0;
470 pending_last_tick_ = 0;
471 pending_truncated_ = false;
472 baseline_pending_ = false;
473 needs_baseline_ = true;
474 }
475
476 [[nodiscard]] auto version() const noexcept -> RenderVersion {
477 return version_;
478 }
479
480 [[nodiscard]] auto options() const noexcept -> const DeltaCollectorOptions& {
481 return options_;
482 }
483
484 [[nodiscard]] auto stats() const noexcept -> const DeltaCollectorStats& {
485 return stats_;
486 }
487
488 static constexpr std::size_t kDropped = static_cast<std::size_t>(-1);
489
490 private:
491 static constexpr std::size_t kBarrier = static_cast<std::size_t>(-2);
492
493 struct CoalesceSlot {
494 EntityHandle entity = kNullEntityHandle;
495 std::size_t record_index = kDropped;
496 };
497
498 void record_barrier(EntityDelta record) {
499 const auto index = append_entity(record);
500 if (options_.coalesce_moves && index != kDropped) {
501 // A barrier blocks folding across it: later moves of this entity
502 // start a fresh record.
503 upsert_coalesce_slot(record.entity, kBarrier);
504 }
505 }
506
507 auto append_entity(EntityDelta record) -> std::size_t {
508 if (pending_entities_.size() == pending_entities_.capacity()) {
509 note_truncation();
510 return kDropped;
511 }
512 pending_entities_.push_back(record);
513 ++stats_.entity_records;
514 return pending_entities_.size() - 1;
515 }
516
517 void note_truncation() noexcept {
518 pending_truncated_ = true;
519 ++stats_.truncations;
520 }
521
522 void drop_pending_entities() noexcept {
523 clear_coalesce_slots();
524 pending_entities_.clear();
525 }
526
527 [[nodiscard]] static auto mix(std::uint64_t value) noexcept -> std::uint64_t {
528 value += 0x9E3779B97F4A7C15ULL;
529 value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL;
530 value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL;
531 return value ^ (value >> 31U);
532 }
533
534 [[nodiscard]] auto slot_mask() const noexcept -> std::size_t {
535 return coalesce_slots_.size() - 1;
536 }
537
538 [[nodiscard]] auto find_coalesce_slot(EntityHandle entity) noexcept
539 -> CoalesceSlot* {
540 if (coalesce_slots_.empty()) {
541 return nullptr;
542 }
543 auto index = static_cast<std::size_t>(mix(entity.value)) & slot_mask();
544 for (;;) {
545 auto& slot = coalesce_slots_[index];
546 if (slot.entity.is_null()) {
547 return nullptr;
548 }
549 if (slot.entity == entity) {
550 return &slot;
551 }
552 index = (index + 1) & slot_mask();
553 }
554 }
555
556 void upsert_coalesce_slot(EntityHandle entity, std::size_t record_index) {
557 if (coalesce_slots_.empty()) {
558 return;
559 }
560 auto index = static_cast<std::size_t>(mix(entity.value)) & slot_mask();
561 for (;;) {
562 auto& slot = coalesce_slots_[index];
563 if (slot.entity.is_null() || slot.entity == entity) {
564 // Capacity discipline: entity records and slots share
565 // entity_capacity with slots at half load, so an insert past
566 // load factor cannot happen while records fit; records past
567 // capacity were dropped before reaching here.
568 slot.entity = entity;
569 slot.record_index = record_index;
570 return;
571 }
572 index = (index + 1) & slot_mask();
573 }
574 }
575
576 // O(published records), not O(capacity): erase exactly the keys this
577 // frame inserted, with backward-shift deletion keeping probe chains
578 // intact (entity handles are never reused across frames, so leftover
579 // slots would otherwise accumulate forever).
580 void clear_coalesce_slots() noexcept {
581 if (coalesce_slots_.empty()) {
582 return;
583 }
584 for (const auto& record : pending_entities_) {
585 erase_coalesce_slot(record.entity);
586 }
587 }
588
589 void erase_coalesce_slot(EntityHandle entity) noexcept {
590 auto index = static_cast<std::size_t>(mix(entity.value)) & slot_mask();
591 for (;;) {
592 auto& slot = coalesce_slots_[index];
593 if (slot.entity.is_null()) {
594 return; // already erased (coalesced records repeat entities)
595 }
596 if (slot.entity == entity) {
597 break;
598 }
599 index = (index + 1) & slot_mask();
600 }
601 auto hole = index;
602 auto next = index;
603 for (;;) {
604 next = (next + 1) & slot_mask();
605 const auto& candidate = coalesce_slots_[next];
606 if (candidate.entity.is_null()) {
607 break;
608 }
609 const auto ideal =
610 static_cast<std::size_t>(mix(candidate.entity.value)) & slot_mask();
611 const auto in_gap = (next > hole) ? (ideal > hole && ideal <= next)
612 : (ideal > hole || ideal <= next);
613 if (!in_gap) {
614 coalesce_slots_[hole] = candidate;
615 hole = next;
616 }
617 }
618 coalesce_slots_[hole] = CoalesceSlot{};
619 }
620
621 DeltaCollectorOptions options_{};
622 RenderVersion version_{1}; // 0 is reserved for fresh consumers
623 std::vector<TileChunkDelta> pending_chunks_;
624 std::vector<TileChunkDelta> published_chunks_;
625 std::vector<TileDelta> pending_tiles_;
626 std::vector<TileDelta> published_tiles_;
627 std::vector<EntityDelta> pending_entities_;
628 std::vector<EntityDelta> published_entities_;
629 std::vector<PathOverlayDelta> pending_overlays_;
630 std::vector<PathOverlayDelta> published_overlays_;
631 std::vector<Coord3> pending_overlay_nodes_;
632 std::vector<Coord3> published_overlay_nodes_;
633 std::vector<CoalesceSlot> coalesce_slots_;
634 std::uint32_t pending_dirty_mask_ = 0;
635 std::uint64_t current_tick_ = 0;
636 std::uint64_t pending_first_tick_ = 0;
637 std::uint64_t pending_last_tick_ = 0;
638 std::uint32_t pending_ticks_ = 0;
639 bool pending_truncated_ = false;
640 bool baseline_pending_ = false;
641 bool needs_baseline_ = false;
642 DeltaCollectorStats stats_{};
643};
644
645namespace detail {
646
647// Clips a chunk's dirty bounds to its own world-space box. Every tile in
648// the result is inside the shape and resolves to this chunk. An empty
649// intersection (possible when another flag owner's marks widened the
650// union bounds away from this chunk) degrades to the chunk's full box:
651// invalidation must stay conservative.
652// Saturating origin+extent: dirty-bound unions may carry extents at or
653// above 2^63, and a wrapped signed add here would clip to the wrong box
654// (or be outright UB) instead of conservatively covering the chunk.
655[[nodiscard]] constexpr auto saturated_axis_end(std::int64_t origin,
656 std::uint64_t extent) noexcept
657 -> std::int64_t {
658 constexpr auto kMax = std::numeric_limits<std::int64_t>::max();
659 if (extent >= static_cast<std::uint64_t>(kMax)) {
660 return kMax;
661 }
662 const auto span = static_cast<std::int64_t>(extent);
663 if (origin > kMax - span) {
664 return kMax;
665 }
666 return origin + span;
667}
668
669template <typename Shape>
670[[nodiscard]] auto clip_dirty_bounds_to_chunk(ChunkKey chunk_key,
671 Box3 bounds) noexcept -> Box3 {
672 using Traits = ShapeTraits<Shape>;
673 const auto chunk = chunk_coord<Shape>(chunk_key);
674 const auto chunk_origin =
675 Coord3{static_cast<std::int64_t>(chunk.x * Traits::chunk.x),
676 static_cast<std::int64_t>(chunk.y * Traits::chunk.y),
677 static_cast<std::int64_t>(chunk.z * Traits::chunk.z)};
678 const auto chunk_end =
679 Coord3{chunk_origin.x + static_cast<std::int64_t>(Traits::chunk.x),
680 chunk_origin.y + static_cast<std::int64_t>(Traits::chunk.y),
681 chunk_origin.z + static_cast<std::int64_t>(Traits::chunk.z)};
682 const auto begin = Coord3{std::max(bounds.origin.x, chunk_origin.x),
683 std::max(bounds.origin.y, chunk_origin.y),
684 std::max(bounds.origin.z, chunk_origin.z)};
685 const auto end =
686 Coord3{std::min(saturated_axis_end(bounds.origin.x, bounds.extent.x),
687 chunk_end.x),
688 std::min(saturated_axis_end(bounds.origin.y, bounds.extent.y),
689 chunk_end.y),
690 std::min(saturated_axis_end(bounds.origin.z, bounds.extent.z),
691 chunk_end.z)};
692 if (begin.x >= end.x || begin.y >= end.y || begin.z >= end.z) {
693 return Box3{chunk_origin,
694 Extent3{Traits::chunk.x, Traits::chunk.y, Traits::chunk.z}};
695 }
696 return Box3{begin, Extent3{static_cast<std::uint64_t>(end.x - begin.x),
697 static_cast<std::uint64_t>(end.y - begin.y),
698 static_cast<std::uint64_t>(end.z - begin.z)}};
699}
700
701template <typename World>
702void collect_chunk_tile_deltas(DeltaCollector& collector, World& world,
703 ChunkKey chunk_key, std::uint32_t dirty_mask) {
704 using Shape = typename World::shape_type;
705 const auto observed = world.observe_dirty(chunk_key, dirty_mask);
706 if (observed.flags == 0) {
707 return;
708 }
709 const auto clipped =
710 clip_dirty_bounds_to_chunk<Shape>(chunk_key, observed.bounds);
711 const auto tile_count =
712 clipped.extent.x * clipped.extent.y * clipped.extent.z;
713
714 auto record = TileChunkDelta{};
715 record.chunk_key = chunk_key;
716 record.dirty_flags = observed.flags;
717 record.chunk_version = observed.version;
718 record.bounds = clipped;
719
720 const auto threshold = collector.options().sparse_tile_threshold;
721 auto emitted_tiles = std::uint32_t{0};
722 if (threshold != 0 && tile_count <= threshold) {
723 const auto first_tile =
724 static_cast<std::uint32_t>(collector.pending_tile_count());
725 auto fits = true;
726 const auto end_x =
727 clipped.origin.x + static_cast<std::int64_t>(clipped.extent.x);
728 const auto end_y =
729 clipped.origin.y + static_cast<std::int64_t>(clipped.extent.y);
730 const auto end_z =
731 clipped.origin.z + static_cast<std::int64_t>(clipped.extent.z);
732 for (auto z = clipped.origin.z; z < end_z && fits; ++z) {
733 for (auto y = clipped.origin.y; y < end_y && fits; ++y) {
734 for (auto x = clipped.origin.x; x < end_x && fits; ++x) {
735 const auto coord = Coord3{x, y, z};
736 const auto appended = collector.append_tile_record(
737 TileDelta{coord, local_tile_id<Shape>(local_coord<Shape>(coord)),
738 observed.flags});
739 if (appended == DeltaCollector::kDropped) {
740 fits = false;
741 } else {
742 ++emitted_tiles;
743 }
744 }
745 }
746 }
747 if (fits) {
748 record.first_tile = first_tile;
749 record.tile_count = emitted_tiles;
750 } else {
751 // Tile storage cannot hold this chunk: degrade to a box record.
752 // The already-appended tiles stay referenced by no record and are
753 // ignored by consumers (records are the only entry point).
754 record.first_tile = 0;
755 record.tile_count = 0;
756 }
757 }
758
759 if (collector.append_chunk_record(record) == DeltaCollector::kDropped) {
760 // Chunk storage is full: leave the dirty bits set so the chunk
761 // re-emits next frame after the (truncated) resync.
762 return;
763 }
764 // Lost-update-safe clear: if a mark landed between observe and here,
765 // leave the bits set -- the chunk re-emits next frame and the already
766 // emitted record is a harmless duplicate invalidation.
767 (void)world.clear_dirty_observed(chunk_key, observed);
768}
769
770} // namespace detail
771
772// Observes, records, and clears (observed-generation-safe) every chunk
773// dirty under `dirty_mask`. Dense worlds scan all chunk metadata; sparse
774// worlds scan the resident set only (a non-resident chunk holds no data
775// and cannot be dirty). NOTE for sparse worlds: evicting and reloading a
776// chunk resets its metadata, so changes made while a consumer's shadow
777// held the old content are NOT re-invalidated automatically; residency
778// change records are deliberately deferred until a sparse render
779// consumer exists, and such consumers must currently treat reloads as a
780// baseline trigger themselves.
782template <typename World>
783void collect_tile_deltas(DeltaCollector& collector, World& world,
784 std::uint32_t dirty_mask) {
785 if (dirty_mask == 0) {
786 return;
787 }
788 collector.note_collected_mask(dirty_mask);
789 if constexpr (std::is_same_v<typename World::residency_type,
791 for (std::uint64_t key = 0; key < World::chunk_count; ++key) {
792 detail::collect_chunk_tile_deltas(collector, world, ChunkKey{key},
793 dirty_mask);
794 }
795 } else {
796 for (const auto chunk_key : world.resident_chunk_keys()) {
797 detail::collect_chunk_tile_deltas(collector, world, chunk_key,
798 dirty_mask);
799 }
800 }
801}
802
803// Full-scope baseline: emits one box record covering every chunk (dense)
804// or every resident chunk (sparse) with `dirty_flags = dirty_mask`,
805// drops pending Dirty records (superseded), clears the mask's dirty bits
806// (plain clears -- the baseline repaints everything, and later marks
807// simply re-dirty), and marks the pending frame as a baseline. Scoped
808// (box / chunk-set) baselines deliberately do not exist: a partial
809// baseline that adopts the frame version would permanently lose every
810// out-of-scope invalidation from a gap.
812template <typename World>
813void collect_baseline(DeltaCollector& collector, World& world,
814 std::uint32_t dirty_mask) {
815 using Shape = typename World::shape_type;
816 using Traits = ShapeTraits<Shape>;
817 collector.note_collected_mask(dirty_mask);
818 collector.drop_pending_tile_state();
819
820 const auto emit = [&](ChunkKey chunk_key) {
821 const auto chunk = chunk_coord<Shape>(chunk_key);
822 auto record = TileChunkDelta{};
823 record.chunk_key = chunk_key;
824 record.dirty_flags = dirty_mask;
825 record.chunk_version = world.meta(chunk_key).version;
826 record.bounds =
827 Box3{Coord3{static_cast<std::int64_t>(chunk.x * Traits::chunk.x),
828 static_cast<std::int64_t>(chunk.y * Traits::chunk.y),
829 static_cast<std::int64_t>(chunk.z * Traits::chunk.z)},
830 Extent3{Traits::chunk.x, Traits::chunk.y, Traits::chunk.z}};
831 (void)collector.append_chunk_record(record);
832 world.clear_dirty(chunk_key, dirty_mask);
833 };
834
835 if constexpr (std::is_same_v<typename World::residency_type,
837 for (std::uint64_t key = 0; key < World::chunk_count; ++key) {
838 emit(ChunkKey{key});
839 }
840 } else {
841 for (const auto chunk_key : world.resident_chunk_keys()) {
842 emit(chunk_key);
843 }
844 }
845 collector.mark_baseline_pending();
846}
847
848// Stages the remaining route of every agent (or of `selection`, indices
849// into agents/handles). Gates on has_goal && status == Found before
850// touching a ticket: a cleared ticket is value-zero and could alias a
851// live generation-zero slot, and the gate avoids the runtime's
852// stale-ticket debug assert PROVIDED the batch and runtime are
853// generation-consistent -- the guarantee holds for batches collected by
854// the last processing pass (every armed non-Unreachable agent was
855// re-ticketed), but NOT for a batch that outlived a clear_requests()
856// call. Precondition: whenever the runtime is cleared outside the tick
857// pipeline, clear the batch beside it before the next collection. Nodes are
858// copied at call time; the source PathView storage may be reused immediately
859// after. Ordering contract: run lifecycle intents BEFORE the tick and collect
860// overlays AFTER it -- an intent executed between tick and collection
861// leaves that agent's overlay one frame stale (entity deltas themselves
862// stay correct through the hooks).
864inline void collect_path_overlays(DeltaCollector& collector,
865 const PathRequestRuntime& runtime,
866 std::span<const PathAgentState> agents,
867 std::span<const EntityHandle> handles) {
868 TESS_ASSERT(agents.size() == handles.size());
869 for (std::size_t i = 0; i < agents.size(); ++i) {
870 const auto& agent = agents[i];
871 if (!agent.has_goal || agent.status != PathStatus::Found) {
872 continue;
873 }
874 const auto result = runtime.result(agent.ticket);
875 if (result.status != PathStatus::Found || result.path.empty()) {
876 continue;
877 }
878 collector.stage_path_overlay(handles[i], agent.ticket,
879 result.path.suffix(agent.path_index));
880 }
881}
882
884inline void collect_path_overlays(DeltaCollector& collector,
885 const PathRequestRuntime& runtime,
886 std::span<const PathAgentState> agents,
887 std::span<const EntityHandle> handles,
888 std::span<const std::size_t> selection) {
889 TESS_ASSERT(agents.size() == handles.size());
890 for (const auto index : selection) {
891 const auto& agent = agents[index];
892 if (!agent.has_goal || agent.status != PathStatus::Found) {
893 continue;
894 }
895 const auto result = runtime.result(agent.ticket);
896 if (result.status != PathStatus::Found || result.path.empty()) {
897 continue;
898 }
899 collector.stage_path_overlay(handles[index], agent.ticket,
900 result.path.suffix(agent.path_index));
901 }
902}
903
904} // namespace tess
Definition delta_frame.h:226
Definition path_runtime.h:92
Definition path_view.h:21
constexpr auto empty() const noexcept -> bool
Definition path_view.h:45
constexpr auto size() const noexcept -> std::size_t
Definition path_view.h:41
Definition world.h:22
Definition world.h:18
Definition shape.h:75
Definition shape.h:67
Definition shape.h:30
Configures sparse-tile emission and entity-move coalescing.
Definition delta_frame.h:182
Holds lifetime counters for a delta collector.
Definition delta_frame.h:195
Carries frame-chain continuity, tick coverage, and resync requirements.
Definition delta_frame.h:119
Views collector-owned invalidation records for one published frame.
Definition delta_frame.h:143
Describes one entity transition, possibly coalesced within the frame.
Definition delta_frame.h:91
Definition entity_handle.h:16
Definition shape.h:13
Definition shape.h:59
References one agent's copied route within a frame's overlay-node storage.
Definition delta_frame.h:110
Definition path_runtime.h:26
Identifies a point in the collector's monotonic published-frame chain.
Definition delta_frame.h:50
Definition shape.h:244
Definition shape.h:225
Describes a chunk-level tile invalidation and any detailed tile slice.
Definition delta_frame.h:62
Identifies one invalidated tile within a detailed chunk record.
Definition delta_frame.h:74