scene/storage.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | // SPDX-License-Identifier: BSD-3-Clause | ||
| 2 | /// @file storage.h | ||
| 3 | /// @brief Columnar prim storage: prims in buckets by type and applied API schemas, each attribute | ||
| 4 | /// one array per bucket, with versions per bucket, column and 64-row chunk for change tracking. | ||
| 5 | #pragma once | ||
| 6 | |||
| 7 | #include "ernie/scene/path.h" | ||
| 8 | #include "ernie/util/bytes.h" | ||
| 9 | #include "ernie/util/glm.h" | ||
| 10 | #include "ernie/util/tensor.h" | ||
| 11 | |||
| 12 | #include <glm/gtc/quaternion.hpp> | ||
| 13 | |||
| 14 | #include <algorithm> | ||
| 15 | #include <array> | ||
| 16 | #include <cstdint> | ||
| 17 | #include <deque> | ||
| 18 | #include <limits> | ||
| 19 | #include <memory> | ||
| 20 | #include <optional> | ||
| 21 | #include <span> | ||
| 22 | #include <stdexcept> | ||
| 23 | #include <tuple> | ||
| 24 | #include <type_traits> | ||
| 25 | #include <unordered_map> | ||
| 26 | #include <utility> | ||
| 27 | #include <vector> | ||
| 28 | |||
| 29 | namespace ernie { | ||
| 30 | |||
| 31 | /// @brief A prim's handle: its slot, and the slot's generation when the prim was defined, so a | ||
| 32 | /// handle to a removed prim is detected rather than reaching its slot's next prim. | ||
| 33 | 77583 | struct PrimId { | |
| 34 | 5068 | std::uint32_t slot = std::numeric_limits<std::uint32_t>::max(); | |
| 35 | 5068 | std::uint32_t generation = 0; | |
| 36 | |||
| 37 | 5068 | bool operator==(const PrimId&) const = default; | |
| 38 | }; | ||
| 39 | |||
| 40 | using AttributeId = Token; | ||
| 41 | using BucketId = std::uint32_t; | ||
| 42 | |||
| 43 | /// @brief The element types columns hold. Bools are bytes, so a column is always a contiguous | ||
| 44 | /// array; tokens and path ids are the scene's interned names and paths. | ||
| 45 | enum class ColumnType : std::uint8_t { | ||
| 46 | Bool, | ||
| 47 | Int, | ||
| 48 | Double, | ||
| 49 | Vec2, | ||
| 50 | Vec3, | ||
| 51 | Vec4, | ||
| 52 | Quat, | ||
| 53 | Matrix, | ||
| 54 | TokenValue, | ||
| 55 | Relationship, | ||
| 56 | Relationships, | ||
| 57 | FloatArray, | ||
| 58 | TokenArray, | ||
| 59 | Tensor, | ||
| 60 | }; | ||
| 61 | |||
| 62 | /// A relationship's single target, distinct from a token though both are 32-bit ids. | ||
| 63 | struct Target { | ||
| 64 | 683 | PathId path = kNoPath; | |
| 65 | 683 | bool operator==(const Target&) const = default; | |
| 66 | }; | ||
| 67 | |||
| 68 | /// The element type of each column type, in `ColumnType`'s order. | ||
| 69 | using ColumnElements = | ||
| 70 | std::tuple<std::uint8_t, std::int32_t, double, glm::dvec2, glm::dvec3, glm::dvec4, glm::dquat, | ||
| 71 | glm::dmat4, Token, Target, std::vector<Target>, std::vector<float>, | ||
| 72 | std::vector<Token>, Tensor>; | ||
| 73 | static_assert(std::tuple_size_v<ColumnElements> == | ||
| 74 | static_cast<std::size_t>(ColumnType::Tensor) + 1); | ||
| 75 | |||
| 76 | /// The column type of element type `T`. | ||
| 77 | template <typename T> | ||
| 78 | inline constexpr ColumnType kColumnType = | ||
| 79 | []<typename... Ts>(std::type_identity<std::tuple<Ts...>>) { | ||
| 80 | constexpr std::array matches{std::is_same_v<T, Ts>...}; | ||
| 81 | constexpr auto index = | ||
| 82 | static_cast<std::size_t>(std::ranges::find(matches, true) - matches.begin()); | ||
| 83 | static_assert(index < matches.size(), "Not a column element type"); | ||
| 84 | return static_cast<ColumnType>(index); | ||
| 85 | }(std::type_identity<ColumnElements>{}); | ||
| 86 | |||
| 87 | /// @brief Calls `visit(std::type_identity<T>{})` with the element type `T` of a column type. | ||
| 88 | template <std::size_t I = 0, typename Visit> | ||
| 89 | 214369 | decltype(auto) WithColumnType(ColumnType type, Visit&& visit) { | |
| 90 | if constexpr (I + 1 < std::tuple_size_v<ColumnElements>) { | ||
| 91 | 213781 | if (static_cast<std::size_t>(type) != I) { | |
| 92 | 188553 | return WithColumnType<I + 1>(type, std::forward<Visit>(visit)); | |
| 93 | } | ||
| 94 | } | ||
| 95 | 25816 | return visit(std::type_identity<std::tuple_element_t<I, ColumnElements>>{}); | |
| 96 | } | ||
| 97 | |||
| 98 | /// Rows a version chunk covers: one word of the authored bits. | ||
| 99 | inline constexpr std::uint32_t kChunkRows = BitSet::kWordBits; | ||
| 100 | |||
| 101 | /// @brief A column's values, their element type erased. | ||
| 102 | 17092 | class ColumnValues { | |
| 103 | public: | ||
| 104 | 6857 | virtual ~ColumnValues() = default; | |
| 105 | virtual ColumnType Type() const = 0; | ||
| 106 | /// @return An empty column of the same type and fallback. | ||
| 107 | virtual std::unique_ptr<ColumnValues> Clone() const = 0; | ||
| 108 | /// Appends `count` rows holding the fallback. | ||
| 109 | virtual void Grow(std::size_t count) = 0; | ||
| 110 | /// Moves the last row into `row` and drops the last. | ||
| 111 | virtual void SwapRemove(std::size_t row) = 0; | ||
| 112 | /// Puts the fallback back in `row`. | ||
| 113 | virtual void Reset(std::size_t row) = 0; | ||
| 114 | /// Copies `source`'s value at `from` into `row`; `source` holds the same type. | ||
| 115 | virtual void CopyRow(std::size_t row, const ColumnValues& source, std::size_t from) = 0; | ||
| 116 | }; | ||
| 117 | |||
| 118 | 6857 | template <typename T> class TypedColumn final : public ColumnValues { | |
| 119 | public: | ||
| 120 | 9744 | explicit TypedColumn(T fallback) : fallback_(std::move(fallback)) {} | |
| 121 | |||
| 122 | 78410 | ColumnType Type() const override { | |
| 123 | 78410 | return kColumnType<T>; | |
| 124 | } | ||
| 125 | 8450 | std::unique_ptr<ColumnValues> Clone() const override { | |
| 126 | 8450 | return std::make_unique<TypedColumn<T>>(fallback_); | |
| 127 | } | ||
| 128 | 189053 | void Grow(std::size_t count) override { | |
| 129 | 189053 | values_.resize(values_.size() + count, fallback_); | |
| 130 | 189053 | } | |
| 131 | 144854 | void SwapRemove(std::size_t row) override { | |
| 132 | 144854 | if (row + 1 != values_.size()) { | |
| 133 | 3431 | values_[row] = std::move(values_.back()); | |
| 134 | } | ||
| 135 | 144854 | values_.pop_back(); | |
| 136 | 144854 | } | |
| 137 | 10 | void Reset(std::size_t row) override { | |
| 138 | 10 | values_[row] = fallback_; | |
| 139 | 10 | } | |
| 140 | 11 | void CopyRow(std::size_t row, const ColumnValues& source, std::size_t from) override { | |
| 141 | 11 | values_[row] = static_cast<const TypedColumn&>(source).values_[from]; | |
| 142 | 11 | } | |
| 143 | |||
| 144 | std::vector<T>& Values() { | ||
| 145 | return values_; | ||
| 146 | } | ||
| 147 | 54769 | const std::vector<T>& Values() const { | |
| 148 | 54780 | return values_; | |
| 149 | } | ||
| 150 | |||
| 151 | private: | ||
| 152 | std::vector<T> values_; | ||
| 153 | T fallback_; | ||
| 154 | }; | ||
| 155 | |||
| 156 | /// @brief One column of a bucket's layout: an attribute and how to make its values. | ||
| 157 | 44596 | struct ColumnLayout { | |
| 158 | AttributeId attribute = 0; | ||
| 159 | std::shared_ptr<const ColumnValues> prototype; | ||
| 160 | |||
| 161 | /// @return A column of `T` whose unauthored rows hold `fallback`. | ||
| 162 | 8450 | template <typename T> static ColumnLayout Make(AttributeId attribute, T fallback = {}) { | |
| 163 | 8450 | return {attribute, std::make_shared<TypedColumn<T>>(std::move(fallback))}; | |
| 164 | 8450 | } | |
| 165 | }; | ||
| 166 | |||
| 167 | /// @brief What a bucket groups: a prim type and its applied API schemas, kept sorted. | ||
| 168 | 12030 | struct BucketKey { | |
| 169 | 2326 | Token type = 0; | |
| 170 | 2326 | std::vector<Token> apiSchemas; | |
| 171 | |||
| 172 | /// @return The key with its API schemas sorted and without repeats. | ||
| 173 | BucketKey Normalized() const; | ||
| 174 | 2326 | bool operator==(const BucketKey&) const = default; | |
| 175 | }; | ||
| 176 | |||
| 177 | /// @brief A change to a bucket's rows. | ||
| 178 | struct StructuralChange { | ||
| 179 | enum class Kind : std::uint8_t { | ||
| 180 | /// `count` rows added from `row`. | ||
| 181 | Added, | ||
| 182 | /// The prim at `row` removed. | ||
| 183 | Removed, | ||
| 184 | /// The prim at `from` moved to `row` (the last row, into a removed one's place). | ||
| 185 | Moved, | ||
| 186 | /// The bucket's `count` rows, all new to a reader too far behind to replay its log. | ||
| 187 | Reset, | ||
| 188 | }; | ||
| 189 | std::uint64_t version = 0; | ||
| 190 | Kind kind = Kind::Added; | ||
| 191 | std::uint32_t row = 0; | ||
| 192 | std::uint32_t count = 1; | ||
| 193 | std::uint32_t from = 0; | ||
| 194 | }; | ||
| 195 | |||
| 196 | /// @brief One attribute of every prim of a bucket, with its authored rows and versions. | ||
| 197 | struct Column { | ||
| 198 | AttributeId attribute = 0; | ||
| 199 | std::unique_ptr<ColumnValues> values; | ||
| 200 | BitSet authored; | ||
| 201 | /// The last version that changed any of chunk c's rows. | ||
| 202 | std::vector<std::uint64_t> chunkVersions; | ||
| 203 | std::uint64_t version = 0; | ||
| 204 | |||
| 205 | /// Sizes the authored bits and chunk versions for `rows` rows. | ||
| 206 | 333907 | void Resize(std::uint32_t rows) { | |
| 207 | 333907 | authored.Resize(rows); | |
| 208 | 333907 | chunkVersions.resize(BitSet::WordCount(rows), 0); | |
| 209 | 333907 | } | |
| 210 | }; | ||
| 211 | |||
| 212 | /// @brief Prims of one key, a row each, in structure-of-arrays columns. | ||
| 213 | struct Bucket { | ||
| 214 | BucketKey key; | ||
| 215 | /// The prim of each row. | ||
| 216 | std::vector<PrimId> prims; | ||
| 217 | std::vector<Column> columns; | ||
| 218 | std::unordered_map<AttributeId, std::uint32_t> columnIndex; | ||
| 219 | /// The last version that changed the bucket's rows or values. | ||
| 220 | std::uint64_t version = 0; | ||
| 221 | /// Row changes newer than `logStart`; older ones are dropped. | ||
| 222 | std::deque<StructuralChange> log; | ||
| 223 | std::uint64_t logStart = 0; | ||
| 224 | |||
| 225 | 204416 | std::uint32_t Rows() const { | |
| 226 | 204416 | return static_cast<std::uint32_t>(prims.size()); | |
| 227 | } | ||
| 228 | /// @return The attribute's column, or null if the bucket has none. | ||
| 229 | 129181 | const Column* Find(AttributeId attribute) const { | |
| 230 | 129181 | const auto found = columnIndex.find(attribute); | |
| 231 | 129181 | return found == columnIndex.end() ? nullptr : &columns[found->second]; | |
| 232 | } | ||
| 233 | 84536 | Column* Find(AttributeId attribute) { | |
| 234 | 84536 | return const_cast<Column*>(std::as_const(*this).Find(attribute)); | |
| 235 | } | ||
| 236 | }; | ||
| 237 | |||
| 238 | /// @brief Rows [begin, end) of a column, changed. | ||
| 239 | struct RowRange { | ||
| 240 | 2 | std::uint32_t begin = 0; | |
| 241 | 2 | std::uint32_t end = 0; | |
| 242 | 2 | bool operator==(const RowRange&) const = default; | |
| 243 | }; | ||
| 244 | |||
| 245 | /// @brief What changed since a cursor (`PrimStore::ChangesSince`). | ||
| 246 | 301 | struct ChangeSet { | |
| 247 | /// The cursor to pass next time. | ||
| 248 | std::uint64_t version = 0; | ||
| 249 | struct Rows { | ||
| 250 | BucketId bucket = 0; | ||
| 251 | StructuralChange change; | ||
| 252 | }; | ||
| 253 | 8906 | struct Values { | |
| 254 | BucketId bucket = 0; | ||
| 255 | AttributeId attribute = 0; | ||
| 256 | std::vector<RowRange> rows; | ||
| 257 | }; | ||
| 258 | /// Row changes in order. | ||
| 259 | std::vector<Rows> rows; | ||
| 260 | /// Changed values, by column. | ||
| 261 | std::vector<Values> values; | ||
| 262 | }; | ||
| 263 | |||
| 264 | /// @brief The scene's prims in columns. | ||
| 265 | /// One writer at a time; readers between writes. Every write stamps a new version; a reader's | ||
| 266 | /// cursor is the version it last saw, and `ChangesSince` returns what changed after it without | ||
| 267 | /// scanning unchanged buckets, columns or chunks. | ||
| 268 | class PrimStore { | ||
| 269 | public: | ||
| 270 | 242 | PrimStore() = default; | |
| 271 | PrimStore(const PrimStore&) = delete; | ||
| 272 | PrimStore& operator=(const PrimStore&) = delete; | ||
| 273 | PrimStore(PrimStore&&) = default; | ||
| 274 | PrimStore& operator=(PrimStore&&) = default; | ||
| 275 | |||
| 276 | 39658 | PathTable& Paths() { | |
| 277 | 67252 | return paths_; | |
| 278 | } | ||
| 279 | 21640 | const PathTable& Paths() const { | |
| 280 | 16519 | return paths_; | |
| 281 | } | ||
| 282 | 6345 | TokenTable& Tokens() { | |
| 283 | 5877 | return paths_.Names(); | |
| 284 | } | ||
| 285 | 21962 | const TokenTable& Tokens() const { | |
| 286 | 21962 | return paths_.Names(); | |
| 287 | } | ||
| 288 | |||
| 289 | /// @return The bucket of `key`, made with `layout`'s columns if new. | ||
| 290 | BucketId AddBucket(const BucketKey& key, std::span<const ColumnLayout> layout); | ||
| 291 | std::optional<BucketId> FindBucket(const BucketKey& key) const; | ||
| 292 | 63104 | const Bucket& GetBucket(BucketId bucket) const { | |
| 293 | 62795 | return *buckets_[bucket]; | |
| 294 | } | ||
| 295 | 77 | std::size_t BucketCount() const { | |
| 296 | 77 | return buckets_.size(); | |
| 297 | } | ||
| 298 | |||
| 299 | /// @brief Defines a prim at `path` in `bucket`, its attributes at their fallbacks. | ||
| 300 | /// @return The prim; the existing one if `path` already has one in `bucket`. | ||
| 301 | /// @throws std::invalid_argument If `path` has a prim in another bucket. | ||
| 302 | PrimId Define(PathId path, BucketId bucket); | ||
| 303 | /// @brief Defines prims at `paths` in `bucket` as one write: one row change, one version. | ||
| 304 | std::vector<PrimId> DefineMany(std::span<const PathId> paths, BucketId bucket); | ||
| 305 | /// @brief Removes a prim; the bucket's last row moves into its place. | ||
| 306 | void Remove(PrimId prim); | ||
| 307 | /// @brief Moves a prim to another bucket, keeping its id and the authored values of the | ||
| 308 | /// attributes both buckets have. | ||
| 309 | void Move(PrimId prim, BucketId bucket); | ||
| 310 | |||
| 311 | bool Alive(PrimId prim) const; | ||
| 312 | std::optional<PrimId> Find(PathId path) const; | ||
| 313 | 69078 | PathId PrimPath(PrimId prim) const { | |
| 314 | 69078 | return Slot(prim).path; | |
| 315 | } | ||
| 316 | 102383 | BucketId PrimBucket(PrimId prim) const { | |
| 317 | 102387 | return Slot(prim).bucket; | |
| 318 | } | ||
| 319 | 48868 | std::uint32_t PrimRow(PrimId prim) const { | |
| 320 | 48868 | return Slot(prim).row; | |
| 321 | } | ||
| 322 | 1 | std::size_t PrimCount() const { | |
| 323 | 1 | return slots_.size() - free_.size(); | |
| 324 | } | ||
| 325 | |||
| 326 | /// @brief Authors an attribute of a prim, adding a column of `T` to its bucket if it has none. | ||
| 327 | 4983 | template <typename T> void Set(PrimId prim, AttributeId attribute, T value) { | |
| 328 | 4983 | const SlotData& slot = Slot(prim); | |
| 329 | 4983 | Column& column = FindOrAddColumn<T>(slot.bucket, attribute); | |
| 330 | 4982 | Values<T>(column)[slot.row] = std::move(value); | |
| 331 | 4982 | Stamp(slot.bucket, column, slot.row, slot.row + 1, Next()); | |
| 332 | 4982 | } | |
| 333 | /// @return The authored value, or null if unauthored or of another type. | ||
| 334 | 28767 | template <typename T> const T* Get(PrimId prim, AttributeId attribute) const { | |
| 335 | 28767 | const SlotData& slot = Slot(prim); | |
| 336 | 28767 | const Column* column = buckets_[slot.bucket]->Find(attribute); | |
| 337 | 29110 | if (column == nullptr || !column->authored.Test(slot.row) || | |
| 338 | 343 | column->values->Type() != kColumnType<T>) { | |
| 339 | 28599 | return nullptr; | |
| 340 | } | ||
| 341 | 168 | return &Values<T>(*column)[slot.row]; | |
| 342 | } | ||
| 343 | /// @brief Writes a value derived from others into a column the bucket has, leaving it | ||
| 344 | /// unauthored and touching the row only when the value changes. | ||
| 345 | /// @return Whether it changed; false too when the bucket has no such column. | ||
| 346 | 28164 | template <typename T> bool Derive(PrimId prim, AttributeId attribute, const T& value) { | |
| 347 | 28164 | const SlotData& slot = Slot(prim); | |
| 348 | 28164 | Column* column = buckets_[slot.bucket]->Find(attribute); | |
| 349 | 56272 | if (column == nullptr || Values<T>(*column)[slot.row] == value) { | |
| 350 | 28037 | return false; | |
| 351 | } | ||
| 352 | 127 | Values<T>(*column)[slot.row] = value; | |
| 353 | 127 | Touch(slot.bucket, *column, slot.row, slot.row + 1, Next()); | |
| 354 | 127 | return true; | |
| 355 | } | ||
| 356 | /// @brief Unauthors an attribute, putting its fallback back. | ||
| 357 | void Clear(PrimId prim, AttributeId attribute); | ||
| 358 | |||
| 359 | /// @brief Writes a whole column at once: every row authored, one change of all rows. | ||
| 360 | /// @return The values to fill, valid until the next structural change. | ||
| 361 | 1 | template <typename T> std::span<T> Write(BucketId bucket, AttributeId attribute) { | |
| 362 | 1 | Column& column = FindOrAddColumn<T>(bucket, attribute); | |
| 363 | 1 | const std::uint32_t rows = buckets_[bucket]->Rows(); | |
| 364 | 2 | Stamp(bucket, column, 0, rows, Next()); | |
| 365 | 1 | return Values<T>(column); | |
| 366 | } | ||
| 367 | /// @return A column's values, fallbacks where unauthored; empty if the bucket has none. | ||
| 368 | 21383 | template <typename T> std::span<const T> Read(BucketId bucket, AttributeId attribute) const { | |
| 369 | 21383 | const Column* column = buckets_[bucket]->Find(attribute); | |
| 370 | 21383 | return column == nullptr ? std::span<const T>() : std::span<const T>(Values<T>(*column)); | |
| 371 | } | ||
| 372 | |||
| 373 | 124 | std::uint64_t Version() const { | |
| 374 | 123 | return version_; | |
| 375 | } | ||
| 376 | /// @return What changed after `cursor`; its `version` is the next cursor. | ||
| 377 | ChangeSet ChangesSince(std::uint64_t cursor) const; | ||
| 378 | |||
| 379 | private: | ||
| 380 | 2951 | struct SlotData { | |
| 381 | std::uint32_t generation = 0; | ||
| 382 | BucketId bucket = 0; | ||
| 383 | std::uint32_t row = 0; | ||
| 384 | PathId path = kNoPath; | ||
| 385 | bool alive = false; | ||
| 386 | }; | ||
| 387 | struct KeyHash { | ||
| 388 | std::size_t operator()(const BucketKey& key) const; | ||
| 389 | }; | ||
| 390 | |||
| 391 | 148611 | std::uint64_t Next() { | |
| 392 | 148611 | return ++version_; | |
| 393 | } | ||
| 394 | const SlotData& Slot(PrimId prim) const; | ||
| 395 | 103500 | SlotData& Slot(PrimId prim) { | |
| 396 | 103500 | return const_cast<SlotData&>(std::as_const(*this).Slot(prim)); | |
| 397 | } | ||
| 398 | PrimId AddSlot(PathId path, BucketId bucket, std::uint32_t row); | ||
| 399 | /// Appends `count` rows at their fallbacks to a bucket, logged as one change. | ||
| 400 | std::uint32_t AddRows(BucketId bucket, std::uint32_t count); | ||
| 401 | /// Drops a prim's row from its bucket, the last row moving into its place. | ||
| 402 | void DropRow(const SlotData& slot); | ||
| 403 | void Log(Bucket& bucket, StructuralChange change); | ||
| 404 | /// Marks rows [begin, end) authored and changed at `version`. | ||
| 405 | void Stamp(BucketId bucket, Column& column, std::uint32_t begin, std::uint32_t end, | ||
| 406 | std::uint64_t version); | ||
| 407 | /// Marks rows [begin, end) changed at `version`. | ||
| 408 | void Touch(BucketId bucket, Column& column, std::uint32_t begin, std::uint32_t end, | ||
| 409 | std::uint64_t version); | ||
| 410 | Column& AddColumn(Bucket& bucket, AttributeId attribute, std::unique_ptr<ColumnValues> values); | ||
| 411 | |||
| 412 | 4984 | template <typename T> Column& FindOrAddColumn(BucketId bucket, AttributeId attribute) { | |
| 413 | 4984 | Bucket& b = *buckets_[bucket]; | |
| 414 | 4984 | Column* found = b.Find(attribute); | |
| 415 | 4984 | return found != nullptr ? *found | |
| 416 | 5176 | : AddColumn(b, attribute, std::make_unique<TypedColumn<T>>(T{})); | |
| 417 | } | ||
| 418 | /// @throws std::invalid_argument if the column holds another type. | ||
| 419 | 54770 | template <typename T> static const std::vector<T>& Values(const Column& column) { | |
| 420 | 54770 | if (column.values->Type() != kColumnType<T>) { | |
| 421 | 1 | throw std::invalid_argument("The column holds another type"); | |
| 422 | } | ||
| 423 | 54769 | return static_cast<const TypedColumn<T>&>(*column.values).Values(); | |
| 424 | } | ||
| 425 | 33219 | template <typename T> static std::vector<T>& Values(Column& column) { | |
| 426 | 33219 | return const_cast<std::vector<T>&>(Values<T>(std::as_const(column))); | |
| 427 | } | ||
| 428 | |||
| 429 | PathTable paths_; | ||
| 430 | std::vector<std::unique_ptr<Bucket>> buckets_; | ||
| 431 | std::unordered_map<BucketKey, BucketId, KeyHash> bucketIndex_; | ||
| 432 | std::vector<SlotData> slots_; | ||
| 433 | std::vector<std::uint32_t> free_; | ||
| 434 | /// The prim slot of each path, or none. | ||
| 435 | std::vector<std::uint32_t> pathSlots_; | ||
| 436 | std::uint64_t version_ = 0; | ||
| 437 | }; | ||
| 438 | |||
| 439 | } // namespace ernie | ||
| 440 |