tess 1.0.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/fail_fast.h>
5#include <tess/core/shape.h>
6#include <tess/ecs/entity_handle.h>
7#include <tess/path/path_runtime.h>
8#include <tess/path/path_view.h>
9#include <tess/sim/path_agent.h>
10#include <tess/storage/residency.h>
11#include <tess/storage/world.h>
12
13#include <algorithm>
14#include <atomic>
15#include <cstddef>
16#include <cstdint>
17#include <limits>
18#include <memory>
19#include <span>
20#include <type_traits>
21#include <vector>
22
23// The render bridge: versioned frames of tile, entity, and overlay
24// deltas collected into caller-owned storage and consumed by a renderer.
25//
26// Tile deltas are INVALIDATION records, not value payloads: the consumer
27// re-reads the current world for the covered tiles when applying, which
28// is idempotent and convergent (anything changed after publish is dirty
29// again and re-invalidated next frame). Chunk dirty metadata is already
30// a cross-tick coalescer (mask OR, bounds union), so tile collection
31// happens once per published frame through the lost-update-safe
32// observe/clear-observed protocol -- multi-tick coalescing costs nothing.
33//
34// Entity deltas are pushed at commit time through the ECS pipeline's
35// optional collector hook and the EnTT lifecycle intents; consecutive
36// moves of one entity coalesce last-writer-wins within a frame while
37// every other kind is a non-coalescible barrier. Completeness holds for
38// the tick_ecs_*/tick_entt_* plus lifecycle-intent surface. Direct span
39// drivers bypass recording by construction.
40//
41// Coalesced entity records are NOT a serializable per-record sequence:
42// a coalesced move sits at its first commit's position, so replaying
43// records in order can transiently place two entities on one tile.
44// Consumers key presentation state by entity and validate any
45// tile-exclusivity only at frame end.
46namespace tess {
47
48// Monotonic frame-chain version. A consumer echoes the last applied
49// frame's to_version; value 0 is reserved for a consumer that has never
50// applied anything (a collector never publishes from_version 0), so a
51// fresh consumer can only start from a baseline.
54 std::uint64_t value = 0;
55
56 friend constexpr auto operator==(RenderVersion, RenderVersion) noexcept
57 -> bool = default;
58};
59
60// One chunk's tile-change record. tile_count == 0 means box-granular:
61// repaint every tile in `bounds` (pre-clipped to the chunk). Otherwise
62// frame.tiles[first_tile .. first_tile + tile_count) lists the changed
63// tiles individually.
66 ChunkKey chunk_key{};
67 DirtyMask dirty_mask{};
68 // meta.content_version at observation time; debugging only -- clears do not
69 // bump it and sparse rematerialization resets it, so it is NOT the frame
70 // contract.
71 ContentVersion content_version{};
72 Box3 bounds{};
73 std::uint32_t first_tile = 0;
74 std::uint32_t tile_count = 0;
75};
76
78struct TileDelta {
79 Coord3 coord{};
80 LocalTileId local_tile_id{};
81 DirtyMask dirty_mask{};
82};
83
85enum class EntityDeltaKind : std::uint8_t {
86 Moved,
87 Teleported,
88 Spawned,
89 Despawned,
90 Parked,
91 Placed,
92};
93
96 EntityHandle entity{};
97 EntityDeltaKind kind = EntityDeltaKind::Moved;
98 // Spawned/Placed: from == to. Despawned/Parked: the released tile.
99 Coord3 from{};
100 Coord3 to{};
101 // Sim tick of the last coalesced commit (== the only commit when
102 // uncoalesced). Consumers use it to distinguish "moved this frame"
103 // from resting entities.
104 std::uint64_t last_tick = 0;
105};
106
107// One agent's remaining route this frame:
108// frame.overlay_nodes[first_node .. first_node + node_count). Overlays
109// are FULL-REPLACEMENT decorations: every applied frame replaces the
110// consumer's whole overlay set (possibly with the empty set), so no
111// create/update/remove lifecycle exists. Nodes are copies -- safe for
112// the frame's lifetime, gone at the next publish.
115 EntityHandle entity{};
116 // Identity/debugging only; the nodes are already copied.
117 PathTicket ticket{};
118 std::uint32_t first_node = 0;
119 std::uint32_t node_count = 0;
120};
121
124 RenderVersion from_version{};
125 RenderVersion to_version{};
126 // First and last sim tick folded into this frame, and how many ticks
127 // begin_tick reported (0 while paused). Records made between ticks
128 // carry the previous tick's stamp.
129 std::uint64_t first_tick = 0;
130 std::uint64_t last_tick = 0;
131 std::uint32_t ticks = 0;
132 // Union of the dirty masks collected into this frame.
133 DirtyMask dirty_mask{};
134 bool baseline = false;
135 // Storage capacity was exceeded (or the collector was hard-reset with
136 // clear()): the frame is NOT safely applicable as a delta and the
137 // consumer must resync from a baseline. delta_frame_applicable treats
138 // this as a structural gap.
139 bool truncated = false;
140};
141
142// Immutable view into collector-owned storage. The spans stay valid until
143// the next publish() or reserve() on the collector, and NOT until "the next
144// mutating call" as this comment claimed until 2026-08-09: begin_tick,
145// record_* and collect_* fill the PENDING buffers and never touch the
146// published ones, which is the point of the swap in publish() -- recording
147// the next frame proceeds while the current one is being applied. reserve()
148// was missing from the list and does belong on it: it re-reserves the
149// published vectors too, so it can reallocate a live frame's storage.
150//
151// Move-assignment and move-construction also invalidate: they replace or
152// empty the published vectors. The collector is not copyable, so there is
153// no copy to reason about.
154//
155// `header` is a value copy and outlives all of that.
156//
157// Single-buffered by design: renderers own their persistent presentation
158// memory. Holding a frame across a publish() is therefore outside the
159// contract -- the buffers it points into become the pending accumulator,
160// are cleared, and are refilled. Accessors validate the collector generation
161// before exposing a span and fail fast if publication, reserve, move, or
162// destruction has invalidated the view.
164class DeltaFrame {
165 public:
166 DeltaFrameHeader header{};
167
168 [[nodiscard]] auto chunks() const noexcept
169 -> std::span<const TileChunkDelta> {
170 validate();
171 return chunks_;
172 }
173
174 [[nodiscard]] auto tiles() const noexcept -> std::span<const TileDelta> {
175 validate();
176 return tiles_;
177 }
178
179 [[nodiscard]] auto entities() const noexcept -> std::span<const EntityDelta> {
180 validate();
181 return entities_;
182 }
183
184 [[nodiscard]] auto overlays() const noexcept
185 -> std::span<const PathOverlayDelta> {
186 validate();
187 return overlays_;
188 }
189
190 [[nodiscard]] auto overlay_nodes() const noexcept -> std::span<const Coord3> {
191 validate();
192 return overlay_nodes_;
193 }
194
195 // Overlays are stateless per-frame decorations and never affect
196 // version semantics or emptiness. Truncated frames are never empty:
197 // the header itself carries the must-resync signal.
198 [[nodiscard]] auto empty() const noexcept -> bool {
199 validate();
200 return chunks_.empty() && tiles_.empty() && entities_.empty() &&
201 !header.baseline && !header.truncated;
202 }
203
204 private:
205 friend class DeltaCollector;
206
207 DeltaFrame(DeltaFrameHeader frame_header,
208 std::span<const TileChunkDelta> chunks,
209 std::span<const TileDelta> tiles,
210 std::span<const EntityDelta> entities,
211 std::span<const PathOverlayDelta> overlays,
212 std::span<const Coord3> overlay_nodes,
213 std::weak_ptr<const std::atomic<std::uint64_t>> generation,
214 std::uint64_t expected_generation) noexcept
215 : header{frame_header},
216 chunks_{chunks},
217 tiles_{tiles},
218 entities_{entities},
219 overlays_{overlays},
220 overlay_nodes_{overlay_nodes},
221 generation_{std::move(generation)},
222 expected_generation_{expected_generation} {}
223
224 void validate() const noexcept {
225 const auto generation = generation_.lock();
226 if (generation == nullptr ||
227 generation->load(std::memory_order_relaxed) != expected_generation_) {
228 detail::fail_fast("stale DeltaFrame view accessed");
229 }
230 }
231
232 std::span<const TileChunkDelta> chunks_{};
233 std::span<const TileDelta> tiles_{};
234 std::span<const EntityDelta> entities_{};
235 std::span<const PathOverlayDelta> overlays_{};
236 std::span<const Coord3> overlay_nodes_{};
237 std::weak_ptr<const std::atomic<std::uint64_t>> generation_{};
238 std::uint64_t expected_generation_ = 0;
239};
240
241// True when a consumer at `consumer` can apply `header`'s frame:
242// truncation never applies -- not even for baselines, because a baseline
243// that overflowed chunk storage covers only part of the world while
244// claiming full sync (size baseline consumers' chunk capacity to the
245// whole world / resident set); un-truncated baselines always apply (the
246// consumer adopts to_version unconditionally and re-snapshots its entity
247// presentation); otherwise the chain must match exactly. A fresh
248// consumer ({0}) can only ever start from a baseline because collectors
249// never publish from_version 0.
251[[nodiscard]] constexpr auto delta_frame_applicable(
252 const DeltaFrameHeader& header, RenderVersion consumer) noexcept -> bool {
253 if (header.truncated) {
254 return false;
255 }
256 if (header.baseline) {
257 return true;
258 }
259 return consumer.value != 0 && consumer == header.from_version;
260}
261
264 // Per chunk: emit per-tile records while the clipped dirty box holds
265 // at most this many tiles; above it (or when tile storage cannot take
266 // them) emit one box-granular record instead. 0 = always box-granular.
267 std::uint32_t sparse_tile_threshold = 64;
268 // Fold consecutive moves of one entity into a single record. Serves
269 // redraw-at-tile consumers; motion-interpolating renderers should
270 // disable it so every step's span stays one tile.
271 bool coalesce_moves = true;
272};
273
274// Cumulative counters, never reset by publish.
277 std::uint64_t frames_published = 0;
278 std::uint64_t baselines_published = 0;
279 std::uint64_t chunk_records = 0;
280 std::uint64_t tile_records = 0;
281 std::uint64_t box_records = 0;
282 std::uint64_t entity_records = 0;
283 std::uint64_t moves_coalesced = 0;
284 std::uint64_t overlay_records = 0;
285 std::uint64_t overlay_nodes_copied = 0;
286 // Overlay overflow only: overlays are best-effort decorations, so
287 // dropping them never truncates the frame.
288 std::uint64_t overlay_truncations = 0;
289 std::uint64_t truncations = 0;
290};
291
292// Caller-owned delta accumulator and frame publisher. reserve() sizes
293// every buffer once; steady state performs no allocation -- records past
294// capacity are dropped and reported through `header.truncated` rather than
295// growing storage mid-frame. The collector must be the SOLE clearing
296// owner of every dirty bit in the masks it collects: another consumer
297// clearing (or reading-then-expecting) those bits races the frame
298// protocol. Note that dirty_bounds is shared across all mask owners --
299// clearing a subset mask retains the union bounds while any other
300// owner's bit is set, so interleaved ownership widens boxes
301// (conservative over-report, never wrong).
318class DeltaCollector {
319 public:
320 DeltaCollector() = default;
321 explicit DeltaCollector(DeltaCollectorOptions options) : options_(options) {}
322
323 // Non-copyable. A copy duplicates all five published and pending vectors
324 // silently, and leaves two collectors each believing they are the sole
325 // owner clearing the dirty bits they collected -- collection consumes
326 // those bits, so a second collector over the same world observes nothing
327 // and publishes an empty frame that still advances its own version.
328 // Nothing in the tree copies one.
329 //
330 // Movable, because factories return a collector by value. Declaring the
331 // copy operations (even as deleted) suppresses the implicit move
332 // operations, so they are defaulted explicitly rather than left to be
333 // silently absent. Moving still invalidates any live frame: the spans
334 // then point into buffers the destination owns.
335 //
336 // A moved-from collector behaves as if cleared: its next publish is
337 // forced truncated, so a consumer on its chain resyncs instead of
338 // silently accepting an empty frame. See MovedFromFlag below for how
339 // that survives the defaulted moves.
340 DeltaCollector(const DeltaCollector&) = delete;
341 auto operator=(const DeltaCollector&) -> DeltaCollector& = delete;
342 // Generation safety needs fresh shared state after a move and may allocate,
343 // so the move is intentionally not noexcept.
344 // NOLINTNEXTLINE(performance-noexcept-move-constructor)
345 DeltaCollector(DeltaCollector&&) = default;
346 // NOLINTNEXTLINE(performance-noexcept-move-constructor)
347 auto operator=(DeltaCollector&&) -> DeltaCollector& = default;
348
349 // Setup-time capacities; entity_capacity also sizes the coalescing
350 // map (kept at load factor <= 0.5). Consumers publishing baselines
351 // must size chunk_capacity to the whole world (dense) or the resident
352 // set (sparse): a baseline that overflows is truncated and therefore
353 // never applicable.
354 void reserve(std::size_t chunk_capacity, std::size_t tile_capacity,
355 std::size_t entity_capacity, std::size_t overlay_capacity = 0,
356 std::size_t overlay_node_capacity = 0) {
357 frame_generation_.invalidate();
358 pending_chunks_.reserve(chunk_capacity);
359 published_chunks_.reserve(chunk_capacity);
360 pending_tiles_.reserve(tile_capacity);
361 published_tiles_.reserve(tile_capacity);
362 pending_entities_.reserve(entity_capacity);
363 published_entities_.reserve(entity_capacity);
364 pending_overlays_.reserve(overlay_capacity);
365 published_overlays_.reserve(overlay_capacity);
366 pending_overlay_nodes_.reserve(overlay_node_capacity);
367 published_overlay_nodes_.reserve(overlay_node_capacity);
368 // Sized from the REALIZED capacity, not the requested one. Both probe
369 // loops rely on a null slot terminating them, and `append_entity`
370 // admits while `size() != capacity()`. `reserve(n)` only guarantees
371 // `capacity() >= n`, so an implementation that rounds up past
372 // `2 * entity_capacity` would let the table fill and both loops spin
373 // forever. No shipped standard library over-allocates here, which is
374 // exactly why this would not be found by testing.
375 const auto entity_slots =
376 std::max(pending_entities_.capacity(), published_entities_.capacity());
377 auto slots = std::size_t{8};
378 while (slots < entity_slots * 2) {
379 slots *= 2;
380 }
381 if (slots > coalesce_slots_.size()) {
382 coalesce_slots_.assign(slots, CoalesceSlot{});
383 }
384 }
385
386 // Stamps subsequent entity records; call once per sim tick (the tick
387 // pipeline does this through its collector hook).
388 void begin_tick(std::uint64_t tick) noexcept {
389 current_tick_ = tick;
390 if (pending_ticks_ == 0) {
391 pending_first_tick_ = tick;
392 }
393 pending_last_tick_ = tick;
394 ++pending_ticks_;
395 }
396
397 // Entity recording. Failed intents must not be recorded; the ECS hook
398 // sites key on the intent's success return.
399 void record_move(EntityHandle entity, Coord3 from, Coord3 to) {
400 if (options_.coalesce_moves) {
401 if (auto* slot = find_coalesce_slot(entity);
402 slot != nullptr && slot->record_index != kBarrier) {
403 auto& record = pending_entities_[slot->record_index];
404 if (record.to == from) {
405 record.to = to;
406 record.last_tick = current_tick_;
407 ++stats_.moves_coalesced;
408 return;
409 }
410 }
411 }
412 const auto index = append_entity(
413 EntityDelta{entity, EntityDeltaKind::Moved, from, to, current_tick_});
414 if (options_.coalesce_moves && index != kDropped) {
415 upsert_coalesce_slot(entity, index);
416 }
417 }
418
419 void record_teleport(EntityHandle entity, Coord3 from, Coord3 to) {
420 record_barrier(EntityDelta{entity, EntityDeltaKind::Teleported, from, to,
421 current_tick_});
422 }
423
424 void record_spawn(EntityHandle entity, Coord3 at) {
425 record_barrier(
426 EntityDelta{entity, EntityDeltaKind::Spawned, at, at, current_tick_});
427 }
428
429 void record_despawn(EntityHandle entity, Coord3 at) {
430 record_barrier(
431 EntityDelta{entity, EntityDeltaKind::Despawned, at, at, current_tick_});
432 }
433
434 void record_park(EntityHandle entity, Coord3 at) {
435 record_barrier(
436 EntityDelta{entity, EntityDeltaKind::Parked, at, at, current_tick_});
437 }
438
439 void record_place(EntityHandle entity, Coord3 at) {
440 record_barrier(
441 EntityDelta{entity, EntityDeltaKind::Placed, at, at, current_tick_});
442 }
443
444 // Appends one chunk record; used by the collect_* templates. Returns
445 // the record's index or kDropped when chunk storage is full.
446 auto append_chunk_record(TileChunkDelta record) -> std::size_t {
447 if (pending_chunks_.size() == pending_chunks_.capacity()) {
448 note_truncation();
449 return kDropped;
450 }
451 pending_chunks_.push_back(record);
452 ++stats_.chunk_records;
453 if (record.tile_count == 0) {
454 ++stats_.box_records;
455 }
456 return pending_chunks_.size() - 1;
457 }
458
459 // Appends one per-tile record; collect_tile_deltas falls back to a
460 // box record when tile storage cannot hold a chunk's tiles, so this
461 // returning kDropped is handled without truncation.
462 auto append_tile_record(TileDelta record) -> std::size_t {
463 if (pending_tiles_.size() == pending_tiles_.capacity()) {
464 return kDropped;
465 }
466 pending_tiles_.push_back(record);
467 ++stats_.tile_records;
468 return pending_tiles_.size() - 1;
469 }
470
471 [[nodiscard]] auto pending_tile_count() const noexcept -> std::size_t {
472 return pending_tiles_.size();
473 }
474
475 void note_collected_mask(DirtyMask dirty_mask) noexcept {
476 pending_dirty_mask_ |= dirty_mask;
477 }
478
479 // Baseline collection supersedes every pending Dirty record AND any
480 // pending truncation: dropped tile records are covered by the full
481 // repaint and dropped entity records by the consumer's baseline
482 // re-snapshot, so only a baseline that itself overflows stays
483 // truncated (and therefore unusable).
484 void drop_pending_tile_state() noexcept {
485 pending_chunks_.clear();
486 pending_tiles_.clear();
487 pending_truncated_ = false;
488 }
489
490 void mark_baseline_pending() noexcept { baseline_pending_ = true; }
491
492 // Copies `remaining`'s nodes into collector storage NOW; the source
493 // view may dangle afterwards. Empty views stage nothing. Overflowing
494 // overlay storage drops the overlay (never the frame): overlays are
495 // best-effort, full-replacement decorations.
496 void stage_path_overlay(EntityHandle entity, PathTicket ticket,
497 PathView remaining) {
498 if (remaining.empty()) {
499 return;
500 }
501 if (pending_overlays_.size() == pending_overlays_.capacity() ||
502 pending_overlay_nodes_.size() + remaining.size() >
503 pending_overlay_nodes_.capacity()) {
504 ++stats_.overlay_truncations;
505 return;
506 }
507 const auto first_node =
508 static_cast<std::uint32_t>(pending_overlay_nodes_.size());
509 for (const auto& node : remaining) {
510 pending_overlay_nodes_.push_back(node);
511 }
512 pending_overlays_.push_back(
513 PathOverlayDelta{entity, ticket, first_node,
514 static_cast<std::uint32_t>(remaining.size())});
515 ++stats_.overlay_records;
516 stats_.overlay_nodes_copied += remaining.size();
517 }
518
519 // Seals pending state into an immutable frame. The version bumps iff
520 // the frame carries chunk/tile/entity state or is a baseline; empty
521 // publishes return from == to. A baseline drops pending entity
522 // records (the consumer re-snapshots its entity presentation on every
523 // baseline apply -- tess does not own entities, so entity loss is
524 // only recoverable that way). After a hard clear(), the next
525 // non-baseline publish is forced truncated so the consumer resyncs.
526 [[nodiscard]] auto publish() -> DeltaFrame {
527 frame_generation_.invalidate();
528 if (baseline_pending_) {
529 drop_pending_entities();
530 }
531 // Truncated publishes always advance the chain, even header-only
532 // ones: a lossy consumer that misses a gap frame must not be able
533 // to apply the next delta as if nothing was dropped.
534 const auto state_carrying =
535 !pending_chunks_.empty() || !pending_tiles_.empty() ||
536 !pending_entities_.empty() || baseline_pending_ || pending_truncated_ ||
537 needs_baseline_ || moved_from_.value;
538 auto header = DeltaFrameHeader{};
539 header.from_version = version_;
540 if (state_carrying) {
541 ++version_.value;
542 }
543 header.to_version = version_;
544 header.first_tick = pending_first_tick_;
545 header.last_tick = pending_last_tick_;
546 header.ticks = pending_ticks_;
547 header.dirty_mask = pending_dirty_mask_;
548 header.baseline = baseline_pending_;
549 header.truncated =
550 pending_truncated_ ||
551 ((needs_baseline_ || moved_from_.value) && !baseline_pending_);
552 if (baseline_pending_) {
553 needs_baseline_ = false;
554 moved_from_.value = false;
555 }
556
557 clear_coalesce_slots();
558 published_chunks_.swap(pending_chunks_);
559 published_tiles_.swap(pending_tiles_);
560 published_entities_.swap(pending_entities_);
561 published_overlays_.swap(pending_overlays_);
562 published_overlay_nodes_.swap(pending_overlay_nodes_);
563 pending_chunks_.clear();
564 pending_tiles_.clear();
565 pending_entities_.clear();
566 pending_overlays_.clear();
567 pending_overlay_nodes_.clear();
568 pending_dirty_mask_ = {};
569 pending_ticks_ = 0;
570 pending_first_tick_ = 0;
571 pending_last_tick_ = 0;
572 pending_truncated_ = false;
573 baseline_pending_ = false;
574
575 ++stats_.frames_published;
576 if (header.baseline) {
577 ++stats_.baselines_published;
578 }
579 return DeltaFrame{header,
580 published_chunks_,
581 published_tiles_,
582 published_entities_,
583 published_overlays_,
584 published_overlay_nodes_,
585 frame_generation_.state,
586 frame_generation_.value()};
587 }
588
589 // Hard reset of pending state. Poisons the stream: dropped records
590 // are unrecoverable, so the next publish is forced truncated unless
591 // it is a baseline. Contract: a world swap or regeneration is
592 // clear() followed by collect_baseline() before the next publish.
593 void clear() noexcept {
594 clear_coalesce_slots();
595 pending_chunks_.clear();
596 pending_tiles_.clear();
597 pending_entities_.clear();
598 pending_overlays_.clear();
599 pending_overlay_nodes_.clear();
600 pending_dirty_mask_ = {};
601 pending_ticks_ = 0;
602 pending_first_tick_ = 0;
603 pending_last_tick_ = 0;
604 pending_truncated_ = false;
605 baseline_pending_ = false;
606 needs_baseline_ = true;
607 }
608
609 [[nodiscard]] auto version() const noexcept -> RenderVersion {
610 return version_;
611 }
612
613 [[nodiscard]] auto options() const noexcept -> const DeltaCollectorOptions& {
614 return options_;
615 }
616
617 [[nodiscard]] auto stats() const noexcept -> const DeltaCollectorStats& {
618 return stats_;
619 }
620
621 static constexpr std::size_t kDropped = static_cast<std::size_t>(-1);
622
623 private:
624 static constexpr std::size_t kBarrier = static_cast<std::size_t>(-2);
625
626 struct CoalesceSlot {
627 EntityHandle entity = kNullEntityHandle;
628 std::size_t record_index = kDropped;
629 };
630
631 void record_barrier(EntityDelta record) {
632 const auto index = append_entity(record);
633 if (options_.coalesce_moves && index != kDropped) {
634 // A barrier blocks folding across it: later moves of this entity
635 // start a fresh record.
636 upsert_coalesce_slot(record.entity, kBarrier);
637 }
638 }
639
640 auto append_entity(EntityDelta record) -> std::size_t {
641 if (pending_entities_.size() == pending_entities_.capacity()) {
642 note_truncation();
643 return kDropped;
644 }
645 pending_entities_.push_back(record);
646 ++stats_.entity_records;
647 return pending_entities_.size() - 1;
648 }
649
650 void note_truncation() noexcept {
651 pending_truncated_ = true;
652 ++stats_.truncations;
653 }
654
655 void drop_pending_entities() noexcept {
656 clear_coalesce_slots();
657 pending_entities_.clear();
658 }
659
660 [[nodiscard]] static auto mix(std::uint64_t value) noexcept -> std::uint64_t {
661 value += 0x9E3779B97F4A7C15ULL;
662 value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL;
663 value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL;
664 return value ^ (value >> 31U);
665 }
666
667 [[nodiscard]] auto slot_mask() const noexcept -> std::size_t {
668 return coalesce_slots_.size() - 1;
669 }
670
671 [[nodiscard]] auto find_coalesce_slot(EntityHandle entity) noexcept
672 -> CoalesceSlot* {
673 if (coalesce_slots_.empty()) {
674 return nullptr;
675 }
676 auto index = static_cast<std::size_t>(mix(entity.value)) & slot_mask();
677 for (;;) {
678 auto& slot = coalesce_slots_[index];
679 if (slot.entity.is_null()) {
680 return nullptr;
681 }
682 if (slot.entity == entity) {
683 return &slot;
684 }
685 index = (index + 1) & slot_mask();
686 }
687 }
688
689 void upsert_coalesce_slot(EntityHandle entity, std::size_t record_index) {
690 if (coalesce_slots_.empty()) {
691 return;
692 }
693 auto index = static_cast<std::size_t>(mix(entity.value)) & slot_mask();
694 for (;;) {
695 auto& slot = coalesce_slots_[index];
696 if (slot.entity.is_null() || slot.entity == entity) {
697 // Capacity discipline: entity records and slots share
698 // entity_capacity with slots at half load, so an insert past
699 // load factor cannot happen while records fit; records past
700 // capacity were dropped before reaching here.
701 slot.entity = entity;
702 slot.record_index = record_index;
703 return;
704 }
705 index = (index + 1) & slot_mask();
706 }
707 }
708
709 // O(published records), not O(capacity): erase exactly the keys this
710 // frame inserted, with backward-shift deletion keeping probe chains
711 // intact (entity handles are never reused across frames, so leftover
712 // slots would otherwise accumulate forever).
713 void clear_coalesce_slots() noexcept {
714 if (coalesce_slots_.empty()) {
715 return;
716 }
717 for (const auto& record : pending_entities_) {
718 erase_coalesce_slot(record.entity);
719 }
720 }
721
722 void erase_coalesce_slot(EntityHandle entity) noexcept {
723 auto index = static_cast<std::size_t>(mix(entity.value)) & slot_mask();
724 for (;;) {
725 auto& slot = coalesce_slots_[index];
726 if (slot.entity.is_null()) {
727 return; // already erased (coalesced records repeat entities)
728 }
729 if (slot.entity == entity) {
730 break;
731 }
732 index = (index + 1) & slot_mask();
733 }
734 auto hole = index;
735 auto next = index;
736 for (;;) {
737 next = (next + 1) & slot_mask();
738 const auto& candidate = coalesce_slots_[next];
739 if (candidate.entity.is_null()) {
740 break;
741 }
742 const auto ideal =
743 static_cast<std::size_t>(mix(candidate.entity.value)) & slot_mask();
744 const auto in_gap = (next > hole) ? (ideal > hole && ideal <= next)
745 : (ideal > hole || ideal <= next);
746 if (!in_gap) {
747 coalesce_slots_[hole] = candidate;
748 hole = next;
749 }
750 }
751 coalesce_slots_[hole] = CoalesceSlot{};
752 }
753
754 // Poisons the SOURCE of a move, so a moved-from collector's next publish
755 // is forced truncated and its consumer resyncs rather than silently
756 // accepting an applicable empty frame on a chain that still looks
757 // continuous.
758 //
759 // The poison lives in the member's own move operations rather than in a
760 // hand-written DeltaCollector move. That keeps both enclosing moves
761 // `= default`, so every member -- including any added later --
762 // participates memberwise as usual. Hand-writing the enclosing move
763 // instead would silently drop a future member, which is the same class
764 // of silent failure as the bug being fixed.
765 //
766 // Bounds of what this fixes: it closes the moved-from chain-continuity
767 // hole only. Two live collectors clearing one world's dirty bits remains
768 // the sole-clearing-owner contract above, and no type-level check can
769 // enforce that.
770 struct MovedFromFlag {
771 bool value = false;
772
773 MovedFromFlag() = default;
774 MovedFromFlag(const MovedFromFlag&) = default;
775 auto operator=(const MovedFromFlag&) -> MovedFromFlag& = default;
776 ~MovedFromFlag() = default;
777
778 MovedFromFlag(MovedFromFlag&& other) noexcept : value{other.value} {
779 other.value = true;
780 }
781
782 auto operator=(MovedFromFlag&& other) noexcept -> MovedFromFlag& {
783 if (this == &other) {
784 value = true;
785 } else {
786 value = other.value;
787 other.value = true;
788 }
789 return *this;
790 }
791 };
792
793 struct FrameGeneration {
794 std::shared_ptr<std::atomic<std::uint64_t>> state =
795 std::make_shared<std::atomic<std::uint64_t>>(1);
796
797 FrameGeneration() = default;
798 FrameGeneration(const FrameGeneration&) = delete;
799 auto operator=(const FrameGeneration&) -> FrameGeneration& = delete;
800 ~FrameGeneration() = default;
801
802 // NOLINTNEXTLINE(performance-noexcept-move-constructor)
803 FrameGeneration(FrameGeneration&& other)
804 : state{[&other] {
805 other.invalidate();
806 return std::make_shared<std::atomic<std::uint64_t>>(1);
807 }()} {}
808
809 // NOLINTNEXTLINE(performance-noexcept-move-constructor)
810 auto operator=(FrameGeneration&& other) -> FrameGeneration& {
811 invalidate();
812 if (this == &other) {
813 return *this;
814 }
815 other.invalidate();
816 state = std::make_shared<std::atomic<std::uint64_t>>(1);
817 return *this;
818 }
819
820 void invalidate() noexcept {
821 state->fetch_add(1, std::memory_order_relaxed);
822 }
823
824 [[nodiscard]] auto value() const noexcept -> std::uint64_t {
825 return state->load(std::memory_order_relaxed);
826 }
827 };
828
829 // These two sentinels must move before any owned span storage. They poison
830 // and invalidate the source before FrameGeneration's replacement-state
831 // allocation can throw, so exceptional move construction cannot destroy
832 // transferred vectors while leaving an old frame generation live.
833 MovedFromFlag moved_from_{};
834 FrameGeneration frame_generation_{};
835 DeltaCollectorOptions options_{};
836 RenderVersion version_{1}; // 0 is reserved for fresh consumers
837 std::vector<TileChunkDelta> pending_chunks_;
838 std::vector<TileChunkDelta> published_chunks_;
839 std::vector<TileDelta> pending_tiles_;
840 std::vector<TileDelta> published_tiles_;
841 std::vector<EntityDelta> pending_entities_;
842 std::vector<EntityDelta> published_entities_;
843 std::vector<PathOverlayDelta> pending_overlays_;
844 std::vector<PathOverlayDelta> published_overlays_;
845 std::vector<Coord3> pending_overlay_nodes_;
846 std::vector<Coord3> published_overlay_nodes_;
847 std::vector<CoalesceSlot> coalesce_slots_;
848 DirtyMask pending_dirty_mask_{};
849 std::uint64_t current_tick_ = 0;
850 std::uint64_t pending_first_tick_ = 0;
851 std::uint64_t pending_last_tick_ = 0;
852 std::uint32_t pending_ticks_ = 0;
853 bool pending_truncated_ = false;
854 bool baseline_pending_ = false;
855 bool needs_baseline_ = false;
856 DeltaCollectorStats stats_{};
857};
858
859namespace detail {
860
861// Clips a chunk's dirty bounds to its own world-space box. Every tile in
862// the result is inside the shape and resolves to this chunk. An empty
863// intersection (possible when another mask owner's marks widened the
864// union bounds away from this chunk) degrades to the chunk's full box:
865// invalidation must stay conservative.
866// Saturating origin+extent: dirty-bound unions may carry extents at or
867// above 2^63, and a wrapped signed add here would clip to the wrong box
868// (or be outright UB) instead of conservatively covering the chunk.
869[[nodiscard]] constexpr auto saturated_axis_end(std::int64_t origin,
870 std::uint64_t extent) noexcept
871 -> std::int64_t {
872 constexpr auto kMax = std::numeric_limits<std::int64_t>::max();
873 if (extent >= static_cast<std::uint64_t>(kMax)) {
874 return kMax;
875 }
876 const auto span = static_cast<std::int64_t>(extent);
877 if (origin > kMax - span) {
878 return kMax;
879 }
880 return origin + span;
881}
882
883template <typename Shape>
884[[nodiscard]] auto clip_dirty_bounds_to_chunk(ChunkKey chunk_key,
885 Box3 bounds) noexcept -> Box3 {
886 using Traits = ShapeTraits<Shape>;
887 const auto chunk = chunk_coord<Shape>(chunk_key);
888 const auto chunk_origin =
889 Coord3{static_cast<std::int64_t>(chunk.x * Traits::chunk.x),
890 static_cast<std::int64_t>(chunk.y * Traits::chunk.y),
891 static_cast<std::int64_t>(chunk.z * Traits::chunk.z)};
892 const auto chunk_end =
893 Coord3{chunk_origin.x + static_cast<std::int64_t>(Traits::chunk.x),
894 chunk_origin.y + static_cast<std::int64_t>(Traits::chunk.y),
895 chunk_origin.z + static_cast<std::int64_t>(Traits::chunk.z)};
896 const auto begin = Coord3{std::max(bounds.origin.x, chunk_origin.x),
897 std::max(bounds.origin.y, chunk_origin.y),
898 std::max(bounds.origin.z, chunk_origin.z)};
899 const auto end =
900 Coord3{std::min(saturated_axis_end(bounds.origin.x, bounds.extent.x),
901 chunk_end.x),
902 std::min(saturated_axis_end(bounds.origin.y, bounds.extent.y),
903 chunk_end.y),
904 std::min(saturated_axis_end(bounds.origin.z, bounds.extent.z),
905 chunk_end.z)};
906 if (begin.x >= end.x || begin.y >= end.y || begin.z >= end.z) {
907 return Box3{chunk_origin,
908 Extent3{Traits::chunk.x, Traits::chunk.y, Traits::chunk.z}};
909 }
910 return Box3{begin, Extent3{static_cast<std::uint64_t>(end.x - begin.x),
911 static_cast<std::uint64_t>(end.y - begin.y),
912 static_cast<std::uint64_t>(end.z - begin.z)}};
913}
914
915template <typename World>
916void collect_chunk_tile_deltas(DeltaCollector& collector, World& world,
917 ChunkKey chunk_key, DirtyMask dirty_mask) {
918 using Shape = typename World::shape_type;
919 const auto observed = world.observe_dirty(chunk_key, dirty_mask);
920 if (!observed.mask) {
921 return;
922 }
923 const auto clipped =
924 clip_dirty_bounds_to_chunk<Shape>(chunk_key, observed.bounds);
925 const auto tile_count =
926 clipped.extent.x * clipped.extent.y * clipped.extent.z;
927
928 auto record = TileChunkDelta{};
929 record.chunk_key = chunk_key;
930 record.dirty_mask = observed.mask;
931 record.content_version = observed.content_version;
932 record.bounds = clipped;
933
934 const auto threshold = collector.options().sparse_tile_threshold;
935 auto emitted_tiles = std::uint32_t{0};
936 if (threshold != 0 && tile_count <= threshold) {
937 const auto first_tile =
938 static_cast<std::uint32_t>(collector.pending_tile_count());
939 auto fits = true;
940 const auto end_x =
941 clipped.origin.x + static_cast<std::int64_t>(clipped.extent.x);
942 const auto end_y =
943 clipped.origin.y + static_cast<std::int64_t>(clipped.extent.y);
944 const auto end_z =
945 clipped.origin.z + static_cast<std::int64_t>(clipped.extent.z);
946 for (auto z = clipped.origin.z; z < end_z && fits; ++z) {
947 for (auto y = clipped.origin.y; y < end_y && fits; ++y) {
948 for (auto x = clipped.origin.x; x < end_x && fits; ++x) {
949 const auto coord = Coord3{x, y, z};
950 const auto appended = collector.append_tile_record(
951 TileDelta{coord, local_tile_id<Shape>(local_coord<Shape>(coord)),
952 observed.mask});
953 if (appended == DeltaCollector::kDropped) {
954 fits = false;
955 } else {
956 ++emitted_tiles;
957 }
958 }
959 }
960 }
961 if (fits) {
962 record.first_tile = first_tile;
963 record.tile_count = emitted_tiles;
964 } else {
965 // Tile storage cannot hold this chunk: degrade to a box record.
966 // The already-appended tiles stay referenced by no record and are
967 // ignored by consumers (records are the only entry point).
968 record.first_tile = 0;
969 record.tile_count = 0;
970 }
971 }
972
973 if (collector.append_chunk_record(record) == DeltaCollector::kDropped) {
974 // Chunk storage is full: leave the dirty bits set so the chunk
975 // re-emits next frame after the (truncated) resync.
976 return;
977 }
978 // Lost-update-safe clear: if a mark landed between observe and here,
979 // leave the bits set -- the chunk re-emits next frame and the already
980 // emitted record is a harmless duplicate invalidation.
981 (void)world.clear_dirty_observed(chunk_key, observed);
982}
983
984} // namespace detail
985
986// Observes, records, and clears every still-current chunk
987// dirty under `dirty_mask`. Dense worlds scan all chunk metadata; sparse
988// worlds scan the resident set only (a non-resident chunk holds no data
989// and cannot be dirty). NOTE for sparse worlds: evicting and rematerializing a
990// chunk resets its metadata, so changes made while a consumer's shadow
991// held the old content are NOT re-invalidated automatically; residency
992// change records are deliberately deferred until a sparse render
993// consumer exists, and such consumers must treat rematerialization as a
994// baseline trigger themselves.
996template <typename World>
997void collect_tile_deltas(DeltaCollector& collector, World& world,
998 DirtyMask dirty_mask) {
999 if (!dirty_mask) {
1000 return;
1001 }
1002 collector.note_collected_mask(dirty_mask);
1003 if constexpr (std::is_same_v<typename World::residency_type,
1004 AlwaysResident>) {
1005 for (std::uint64_t key = 0; key < World::chunk_count; ++key) {
1006 detail::collect_chunk_tile_deltas(collector, world, ChunkKey{key},
1007 dirty_mask);
1008 }
1009 } else {
1010 for (const auto chunk_key : world.resident_chunk_keys()) {
1011 detail::collect_chunk_tile_deltas(collector, world, chunk_key,
1012 dirty_mask);
1013 }
1014 }
1015}
1016
1017// Full-scope baseline: emits one box record covering every chunk (dense)
1018// or every resident chunk (sparse) with `dirty_mask`,
1019// drops pending Dirty records (superseded), clears the mask's dirty bits
1020// (plain clears -- the baseline repaints everything, and later marks
1021// simply re-dirty), and marks the pending frame as a baseline. Scoped
1022// (box / chunk-set) baselines deliberately do not exist: a partial
1023// baseline that adopts the frame version would permanently lose every
1024// out-of-scope invalidation from a gap.
1026template <typename World>
1027void collect_baseline(DeltaCollector& collector, World& world,
1028 DirtyMask dirty_mask) {
1029 using Shape = typename World::shape_type;
1030 using Traits = ShapeTraits<Shape>;
1031 collector.note_collected_mask(dirty_mask);
1032 collector.drop_pending_tile_state();
1033
1034 const auto emit = [&](ChunkKey chunk_key) {
1035 const auto chunk = chunk_coord<Shape>(chunk_key);
1036 auto record = TileChunkDelta{};
1037 record.chunk_key = chunk_key;
1038 record.dirty_mask = dirty_mask;
1039 record.content_version = world.meta(chunk_key).content_version;
1040 record.bounds =
1041 Box3{Coord3{static_cast<std::int64_t>(chunk.x * Traits::chunk.x),
1042 static_cast<std::int64_t>(chunk.y * Traits::chunk.y),
1043 static_cast<std::int64_t>(chunk.z * Traits::chunk.z)},
1044 Extent3{Traits::chunk.x, Traits::chunk.y, Traits::chunk.z}};
1045 (void)collector.append_chunk_record(record);
1046 world.clear_dirty(chunk_key, dirty_mask);
1047 };
1048
1049 if constexpr (std::is_same_v<typename World::residency_type,
1050 AlwaysResident>) {
1051 for (std::uint64_t key = 0; key < World::chunk_count; ++key) {
1052 emit(ChunkKey{key});
1053 }
1054 } else {
1055 for (const auto chunk_key : world.resident_chunk_keys()) {
1056 emit(chunk_key);
1057 }
1058 }
1059 collector.mark_baseline_pending();
1060}
1061
1075inline void collect_path_overlays(DeltaCollector& collector,
1076 const PathRequestRuntime& runtime,
1077 std::span<const PathAgentState> agents,
1078 std::span<const EntityHandle> handles) {
1079 TESS_ASSERT(agents.size() == handles.size());
1080 for (std::size_t i = 0; i < agents.size(); ++i) {
1081 const auto& agent = agents[i];
1082 if (!agent.has_goal || agent.last_result != PathStatus::Found) {
1083 continue;
1084 }
1085 const auto result = runtime.result(agent.ticket);
1086 if (result.status != PathStatus::Found || result.path.empty()) {
1087 continue;
1088 }
1089 collector.stage_path_overlay(handles[i], agent.ticket,
1090 result.path.suffix(agent.path_index));
1091 }
1092}
1093
1102inline void collect_path_overlays(DeltaCollector& collector,
1103 const PathRequestRuntime& runtime,
1104 std::span<const PathAgentState> agents,
1105 std::span<const EntityHandle> handles,
1106 std::span<const std::size_t> selection) {
1107 TESS_ASSERT(agents.size() == handles.size());
1108 for (const auto index : selection) {
1109 TESS_ASSERT(index < agents.size());
1110 const auto& agent = agents[index];
1111 if (!agent.has_goal || agent.last_result != PathStatus::Found) {
1112 continue;
1113 }
1114 const auto result = runtime.result(agent.ticket);
1115 if (result.status != PathStatus::Found || result.path.empty()) {
1116 continue;
1117 }
1118 collector.stage_path_overlay(handles[index], agent.ticket,
1119 result.path.suffix(agent.path_index));
1120 }
1121}
1122
1133inline void collect_path_overlays(DeltaCollector& collector,
1134 std::span<const PathAgentState> agents,
1135 const PathAgentRoutes& routes,
1136 std::span<const EntityHandle> handles) {
1137 TESS_ASSERT(agents.size() == handles.size());
1138 TESS_ASSERT(routes.routes.size() >= agents.size());
1139 for (std::size_t i = 0; i < agents.size(); ++i) {
1140 const auto& agent = agents[i];
1141 if (!agent.has_goal || agent.last_result != PathStatus::Found) {
1142 continue;
1143 }
1144 const PathView route{routes.routes[i]};
1145 if (route.empty()) {
1146 continue;
1147 }
1148 collector.stage_path_overlay(handles[i], agent.ticket,
1149 route.suffix(agent.path_index));
1150 }
1151}
1152
1160inline void collect_path_overlays(DeltaCollector& collector,
1161 std::span<const PathAgentState> agents,
1162 const PathAgentRoutes& routes,
1163 std::span<const EntityHandle> handles,
1164 std::span<const std::size_t> selection) {
1165 TESS_ASSERT(agents.size() == handles.size());
1166 TESS_ASSERT(routes.routes.size() >= agents.size());
1167 for (const auto index : selection) {
1168 TESS_ASSERT(index < agents.size());
1169 const auto& agent = agents[index];
1170 if (!agent.has_goal || agent.last_result != PathStatus::Found) {
1171 continue;
1172 }
1173 const PathView route{routes.routes[index]};
1174 if (route.empty()) {
1175 continue;
1176 }
1177 collector.stage_path_overlay(handles[index], agent.ticket,
1178 route.suffix(agent.path_index));
1179 }
1180}
1181
1182} // namespace tess
Definition delta_frame.h:318
Views collector-owned invalidation records for one published frame.
Definition delta_frame.h:164
Definition path_runtime.h:197
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:94
Definition shape.h:86
Definition metadata_types.h:86
Definition shape.h:46
Configures sparse-tile emission and entity-move coalescing.
Definition delta_frame.h:263
Holds lifetime counters for a delta collector.
Definition delta_frame.h:276
Carries frame-chain continuity, tick coverage, and resync requirements.
Definition delta_frame.h:123
Definition metadata_types.h:12
Describes one entity transition, possibly coalesced within the frame.
Definition delta_frame.h:95
Definition entity_handle.h:16
Definition shape.h:14
Definition shape.h:78
Owns index-paired route copies retained across scoped processing passes.
Definition path_agent.h:115
References one agent's copied route within a frame's overlay-node storage.
Definition delta_frame.h:114
Definition path_runtime.h:29
Identifies a point in the collector's monotonic published-frame chain.
Definition delta_frame.h:53
Definition shape.h:320
Definition shape.h:296
Describes a chunk-level tile invalidation and any detailed tile slice.
Definition delta_frame.h:65
Identifies one invalidated tile within a detailed chunk record.
Definition delta_frame.h:78