Tedd.Voxtree 2.0.13

dotnet add package Tedd.Voxtree --version 2.0.13
                    
NuGet\Install-Package Tedd.Voxtree -Version 2.0.13
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Tedd.Voxtree" Version="2.0.13" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Tedd.Voxtree" Version="2.0.13" />
                    
Directory.Packages.props
<PackageReference Include="Tedd.Voxtree" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Tedd.Voxtree --version 2.0.13
                    
#r "nuget: Tedd.Voxtree, 2.0.13"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Tedd.Voxtree@2.0.13
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Tedd.Voxtree&version=2.0.13
                    
Install as a Cake Addin
#tool nuget:?package=Tedd.Voxtree&version=2.0.13
                    
Install as a Cake Tool

Tedd.Voxtree

Tedd.Voxtree is a compact voxel storage library for read-optimized chunks, streamed worlds, and exact-width unmanaged values. A single channel is stored in one versioned, contiguous octree or dense encoding and exposed through two views:

  • Octree, a sealed owned wrapper.
  • OctreeSpan, a readonly ref struct over caller-owned encoded memory.

OctreeChunk groups independently encoded channels. WorldEntity maps those chunks into signed 64-bit global coordinates for effectively unbounded worlds. OctreeWorld remains the bounded sparse-region representation when unloaded and known-air extents must be distinguished. Linear and Morton-ordered dense blocks can be extracted, edited, rebuilt, saved, and reloaded. See the bulk editing and streaming guide for implementation notes and extended examples.

For successful, valid-input operations, the caller-buffer build path and OctreeSpan lookup path perform no managed heap allocations. The owned build path allocates one final byte[] for the encoded tree. Error paths may allocate exception objects. Performance beyond these steady-state allocation contracts remains a hypothesis until measured on the target runtime and hardware.

Installation

The package includes net11.0, net10.0, and netstandard2.1 assets. .NET 11 is currently preview; the repository pins SDK 11.0.100-preview.7.26381.103. The .NET 10/11 builds use runtime-vectorized uniform-region detection, equality and masked searches/counting, and uninitialized result-array allocation when every byte is subsequently written. The .NET Standard build uses portable scalar fallbacks. The API and encoded byte format are identical across targets; runtime-specific performance must be measured on the deployment hardware.

dotnet add package Tedd.Voxtree

Or use a floating reference for the current 2.x release line, then pin the resolved version through the package lock file or your normal dependency update process:

<PackageReference Include="Tedd.Voxtree" Version="2.*" />

Supported capabilities

Layer Types and capabilities
Dense buffers Linear X/Y/Z-major and Morton order; checked indexing, conversion, channel-major views, and aligned or rectangular extraction
Single channel Owned Octree/Octree<T> and zero-copy OctreeSpan/OctreeSpan<T>; owned, caller-buffer, and dense builds; validation and versioned serialization
Spatial queries Point lookup, filters, Any, match counts, bounded hit enumeration, rectangular decode, aligned block decode, and nearest-match search
Multi-channel chunks Immutable OctreeChunk/OctreeChunk<T> snapshots, selective channel rebuilds, full chunk packets, and borrowed channel encodings
Intensive editing HotOctreeChunk/HotOctreeChunk<T> retain channel-major dense Morton data and re-encode once on commit
Sparse editing DeferredOctreeChunk/DeferredOctreeChunk<T> retain pooled point edits; DeferredChunkStore tracks and synchronizes scheduled repackaging
Bounded sparse worlds OctreeWorld/OctreeWorld<T> distinguish unloaded, known-empty, and resident regions; support cross-chunk queries, extraction, manifests, fixed capacities, and caller-owned workspace
Unbounded worlds WorldEntity/WorldEntity<T> route signed 64-bit coordinates to power-of-two chunks with shifts and use a small LRU-assisted lookup cache before the chunk dictionary
Persistence and residency Automatic on-demand loads, clean implicit-zero chunks, dirty tracking, atomic saves, dirty flush on unload, SaveAllChunks, access sequencing, and a dynamic soft resident-byte target
Compression Direct chunk packets, Brotli, Deflate, GZip, ZLib, and Zstandard; the .NET 11 Zstandard codec is the default
LOD Application-generated power-of-two LOD layers with shift-based addressing, independent residency, and independent disk namespaces
Locality caches Owned and caller-backed neighborhood windows with incremental slab refresh and explicit revision/invalidation control
Concurrency Immutable chunk publication, lock-free owned-tree replacement, thread-safe bounded worlds, and amortized read/write batch scopes
Generic values Core voxel, chunk, neighborhood, query, and world APIs have exact-bit generic forms for unmanaged values whose size is 1, 2, 4, 8, or 16 bytes

The library does not generate LOD data, select an LOD, simulate moving entities, or infer whether unloaded data is air. Those policies remain application concerns.

Data model

An octree has levels in the inclusive range 0..9:

side  = 1 << levels
count = side * side * side

Level 0 represents one voxel. Level 9 represents a 512 x 512 x 512 volume. Consequently every chunk side is a power of two. These are single encoded chunk/channel limits. Outer worlds support depths through 20, with Int64 logical voxel counts and independently configurable chunk depth. For example:

var world = new OctreeWorld(levels: 10, chunkLevels: 5, channelCount: 4,
    chunkCapacity: 256, branchCapacity: 2048);
// A 1024-cubed world containing independently loadable 32-cubed chunks.
// No dense 1024-cubed buffer is allocated; the initial state is unloaded.

For an unbounded signed coordinate system, configure WorldEntity with the power-of-two side length directly. A side of 32 produces ChunkShift == 5; global coordinates are routed with >> 5, including negative coordinates:

var world = new WorldEntity(chunkSize: 32, channelCount: 4);
var chunk = OctreeChunk.Empty(levels: 5, channelCount: 4);
world.SetChunk(chunkX: -1, chunkY: 0, chunkZ: 2, chunk);

uint material = world.Get(channel: 0, x: -1, y: 7, z: 64);
// Global (-1, 7, 64) resolves to chunk (-1, 0, 2), local (31, 7, 0).

WorldEntity.Chunks is a read-only view of its base-layer chunk dictionary; ResidentChunks includes base and LOD addresses. Point and chunk reads first consult a hash-indexed cache of the ten most recently used chunks. This improves highly local reads; cold misses still fall back to the dictionary and cost more than a direct dictionary miss. SetChunk, TryGetChunk, RemoveChunk, and Chunks use chunk coordinates; Get, TryGet, and ResolveCoordinates use global voxel coordinates. WorldEntity is not thread-safe.

Edit-intensive chunks can be promoted to exclusively owned, channel-major dense Morton storage. Repeated edits then avoid repeated octree decode/rebuild cycles:

var hot = world.MarkChunkHot(-1, 0, 2);
hot[0, 31, 7, 0] = 17;
world.CommitHotChunk(-1, 0, 2, hot); // Re-encode once and replace the snapshot.

HotOctreeChunk and HotOctreeChunk<T> are not thread-safe; one worker must own each editor. The world continues exposing the immutable source until publication. TryCommitHotChunk rejects a stale source rather than overwriting a newer chunk.

WorldEntity can persist chunks in a coordinate-sharded directory and maintain an estimated resident-byte target:

var storage = new ChunkStorageOptions("world/chunks")
{
    Compression = ChunkCompression.Zstandard,
    CompressionLevel = CompressionLevel.Fastest
};
var streamed = new WorldEntity(32, 4, storage, maxResidentBytes: 512L * 1024 * 1024);
streamed.SetChunk(-1, 0, 2, chunk);       // New snapshots are dirty.
streamed.SaveAllChunks();                 // Writes dirty chunks only.
streamed.UnloadChunk(-1, 0, 2);          // Flushes if dirty, then unloads.
uint value = streamed.Get(0, -1, 7, 64); // Loads automatically on demand.

// A missing file is materialized as a clean zero chunk and remains fileless.
uint air = streamed.Get(0, 3200, 0, 0);

streamed.MaxResidentBytes = 256L * 1024 * 1024; // Dynamic soft target.
int evicted = streamed.TrimToMemoryTarget();     // Save dirty LRU chunks, then evict.

Files record their compression format, LOD level, coordinates, lengths, and CRC32, followed by the versioned chunk packet. The save preference therefore need not match the format of existing files. Supported formats are direct octree packets, Brotli, Deflate, GZip, ZLib, and Zstandard. Zstandard is the default and uses .NET 11's native implementation; select another format on earlier targets. ZLib is native on the .NET 10/11 targets but is unavailable through the .NET Standard 2.1 asset.

SetChunk and committed hot edits mark their address dirty. SaveAllChunks writes only dirty residents; UnloadChunk, RemoveChunk, Clear, and LRU eviction flush dirty data before release. Disk-loaded chunks and missing-file zero chunks remain clean, so an untouched implicit-zero chunk is never written.

EstimatedResidentBytes counts serialized chunk-packet bytes, not total CLR object or process memory. MaxResidentBytes is consequently a soft target. Assigning it is side-effect free; explicit trimming enforces a changed value, while storage loads trim older residents automatically and retain the requested chunk if it alone exceeds the target. World accessors advance each chunk's exact world-local LastAccessSequence; metadata inspection and Chunks enumeration do not. Storage operations are synchronous, and WorldEntity remains non-thread-safe.

LOD storage uses the same fixed-size chunk schema while each successive level covers twice the base-world distance per axis. For 32-sample chunks, LOD 0 covers 32 base voxels, LOD 1 covers 64, and LOD 2 covers 128; routing uses shifts 5, 6, and 7 respectively:

var lodAddress = streamed.GetChunkAddress(lodLevel: 2, x: -1, y: 7, z: 128);
streamed.SetChunk(lodAddress, externallyGeneratedLodChunk);
streamed.SaveChunk(lodAddress);
uint coarse = streamed.GetLod(lodLevel: 2, channel: 0, x: -1, y: 7, z: 128);

ChunkAddress keeps LOD namespaces independent on disk and in memory. The library performs only power-of-two addressing, storage, and residency. LOD generation, invalidation, selection, and semantic consistency remain the application's responsibility.

The dense source must contain exactly count values in this order:

index = (x * side * side) + (y * side) + z

z is therefore the fastest-changing coordinate, followed by y, then x. This ordering is part of the public input contract; do not pass a z/y/x-major buffer without reordering it.

The generic APIs (Octree<T>, OctreeSpan<T>, chunks, neighborhoods, and worlds) accept any unmanaged 1-, 2-, 4-, 8-, or 16-byte value type, including every IBinaryInteger<TSelf> implementation and exact-width custom structs. See Generic voxel values for representation and format details.

Dense linear and Morton layouts

Dense operations accept the linear order above or Morton order. Conversion and index calculation write only to caller-provided storage:

const int levels = 5;
const int side = 1 << levels;
var linear = new uint[side * side * side];
var morton = new uint[linear.Length];

linear[(1 * side + 2) * side + 3] = 42;
DenseVoxel.Convert(linear, morton, levels,
    DenseVoxelLayout.Linear, DenseVoxelLayout.Morton);

int mortonIndex = DenseVoxel.GetIndex(1, 2, 3, levels, DenseVoxelLayout.Morton);
uint value = morton[mortonIndex]; // 42

var tree = new Octree(levels);
tree.Build(morton, DenseVoxelLayout.Morton);
tree.CopyTo(morton, DenseVoxelLayout.Morton);

DenseVoxelBlockSpan overlays channel-major caller storage and indexes it by channel and coordinates. CopyBlockTo decodes a cubic block in either layout; CopyRegionTo decodes an arbitrary VoxelBox in packed linear order.

Generic voxel values

Generic forms preserve the complete bit representation without numeric conversion, boxing, or per-value allocation:

const int levels = 5;
var values = new UInt128[1 << (levels * 3)];
values[DenseVoxel<UInt128>.GetIndex(3, 7, 11, levels,
    DenseVoxelLayout.Linear)] = UInt128.MaxValue;

var tree = new Octree<UInt128>(levels, values);
UInt128 value = tree.Get(3, 7, 11);

var encoded = new byte[Octree<UInt128>.GetMaximumSize(levels)];
int written = Octree<UInt128>.Build(values, levels, encoded);
var view = new OctreeSpan<UInt128>(encoded.AsSpan(0, written));

Enums, native integers, Int128/UInt128, and deterministic-layout unmanaged structs are supported when their width is exactly 1, 2, 4, 8, or 16 bytes. Generic encodings use format version 2 and record the element width; the original UInt32 API retains its version-1 encoding.

Owned tree

Use Octree when the tree must outlive an input buffer, cross an async boundary, or be retained in a heap object:

using Tedd.Voxtree;

const int levels = 2;
const int side = 1 << levels;

var values = new uint[side * side * side];
values[(1 * side * side) + (2 * side) + 3] = 42;

var tree = new Octree(levels, values);

uint value = tree.Get(1, 2, 3);       // 42
uint same = tree[1, 2, 3];            // 42
bool inside = tree.Contains(1, 2, 3); // true

if (tree.TryGet(1, 2, 3, out uint found))
{
    // found == 42
}

The equivalent staged form is useful when the wrapper is created before its source is available:

var tree = new Octree(levels);
tree.Build(values);

Build replaces the wrapper's owned encoding. The voxel data is read-only between builds.

Caller-owned and span-backed tree

Use the static sizing/building API with OctreeSpan when the storage lifetime is controlled by the caller. The following path uses stack storage throughout:

using Tedd.Voxtree;

const int levels = 2;
const int side = 1 << levels;

Span<uint> values = stackalloc uint[side * side * side];
values.Clear(); // Stack storage is not guaranteed to be initialized.
values[(1 * side * side) + (2 * side) + 3] = 42;

int required = Octree.GetRequiredSize(values, levels);
Span<byte> storage = stackalloc byte[required];

if (!Octree.TryBuild(values, levels, storage, out int bytesWritten))
{
    throw new InvalidOperationException("The destination is too small.");
}

// Always exclude unused destination capacity.
var tree = new OctreeSpan(storage[..bytesWritten]);
uint value = tree[1, 2, 3];

GetRequiredSize calculates the exact encoded length for the supplied values. Octree.Build(values, levels, destination) is the throwing caller-buffer alternative and returns the number of bytes written. In both cases, construct the view from destination[..bytesWritten], not the unsliced destination.

When one buffer will serve many volumes, Octree.GetMaximumSize(levels) returns a value-independent capacity. This avoids the sizing traversal; each build still reports its exact used length.

OctreeSpan does not copy or own its input. The backing bytes must remain alive and unchanged for the entire lifetime of the view. As a ref struct, the view is stack-bound: it cannot be boxed, stored in an ordinary class or struct field, captured by a closure, or used across await or yield.

For pooled or unmanaged buffers, apply the same lifetime rule and return or free the buffer only after the last lookup.

Existing encoded data

Use TryCreate for untrusted or fallible span-backed input:

ReadOnlySpan<byte> packet = GetPacket();

if (!OctreeSpan.TryCreate(packet, out OctreeSpan tree) || !tree.IsWellFormed())
{
    return; // Reject malformed or unsupported input.
}

uint value = tree.Get(0, 0, 0);

Use FromEncoded when an owned copy is required:

byte[] packet = File.ReadAllBytes("volume.octree");
Octree tree = Octree.FromEncoded(packet);

FromEncoded validates the encoding and copies it. AsSpan() obtains a zero-copy, stack-bound view over an owned Octree. The Data property exposes encoded data as read-only memory (ReadOnlyMemory<byte> on Octree, ReadOnlySpan<byte> on OctreeSpan).

CopyTo(Span<uint>) and TryCopyTo(Span<uint>) decode the complete tree back to the same dense X/Y/Z-major layout accepted by the builders:

var dense = new uint[tree.Count];
tree.CopyTo(dense);

Pass DenseVoxelLayout.Morton to CopyTo or TryCopyTo to decode directly into Morton order. Morton builds reuse scanned uniform runs, and decoding fills contiguous Morton octants without a linear scratch buffer. See dense conversion details and the repeatable before/after benchmarks.

Single-channel API summary

API Purpose Allocation behavior
new Octree(levels) Create an unbuilt owned wrapper Wrapper allocation only
new Octree(levels, values) Build an owned tree Wrapper plus one encoded byte[]
Octree.Build(values) Build or rebuild an owned tree One final encoded byte[]
Octree.GetRequiredSize(values, levels) Calculate exact caller-buffer capacity No managed allocation
Octree.GetMaximumSize(levels) Calculate reusable worst-case capacity without inspecting values No managed allocation
Octree.Build(values, levels, destination) Build into a caller buffer; return bytes written No managed allocation
Octree.TryBuild(values, levels, destination, out bytesWritten) Non-throwing insufficient-capacity path No managed allocation
Octree.FromEncoded(data) Validate and copy an existing encoding Wrapper plus one owned byte[]
new OctreeSpan(data) Create a zero-copy view No managed allocation
OctreeSpan.TryCreate(data, out tree) Attempt to create a zero-copy view No managed allocation
Get / [x, y, z] Read a voxel No managed allocation
TryGet Read only when coordinates are valid No managed allocation
Contains Test coordinate bounds No managed allocation
CopyTo / TryCopyTo Decode into a caller-provided Span<uint> No managed allocation

Both representations expose Levels, SideLength, Count, EncodedLength, and Data. Octree also exposes IsBuilt; OctreeSpan exposes IsValid and IsWellFormed(). Octree.MaxLevels is 9, and Octree.FormatVersion identifies the current byte-format version.

Area and proximity queries

Octree and OctreeSpan provide allocation-free spatial queries:

Method Behavior
Any(box, filter) Stops at the first matching voxel
CountMatches(box, filter) Counts uniform regions without expanding them
Query(box, filter, destination, out written) Writes VoxelHit results; returns false if more matches exist
CopyRegionTo(box, destination) Decodes only a region into packed X/Y/Z-major values
TryFindNearest(x, y, z, radius, filter, out hit) Finds the nearest matching voxel center within an inclusive Euclidean radius

VoxelBox uses inclusive minima and exclusive maxima. Bounds must be inside the chunk; an empty box produces no matches. Nearest-query centers must be inside the chunk, and ties resolve by ascending X, then Y, then Z. Region traversal prunes disjoint octants; nearest traversal also prunes by the closest possible distance to an octant. Homogeneous regions are handled in bulk. For tree-backed occupancy boxes of at most four cells, Any selects point lookups to avoid general traversal setup for small support footprints.

VoxelFilter.Any, NonZero, EqualTo(value), NotEqualTo(value), and Masked(mask, expected) avoid predicate delegates and boxing. A default filter matches everything, including air. Use NonZero only when zero means empty in the queried channel. For material-dependent collision, maintain a collision channel or collision bits and query that classification.

// Integer cells immediately below a 2-by-2 footprint; Y is the vertical axis.
var feet = new VoxelBox(x, y - 1, z, x + 2, y, z + 2);
bool hasSupport = collisionChannel.Any(feet, VoxelFilter.NonZero);

if (blocks.TryFindNearest(x, y, z, 8, VoxelFilter.EqualTo(waterBlockId), out var water))
{
    // water.X, water.Y, water.Z identify the nearest matching voxel center.
}

Span<VoxelHit> hits = stackalloc VoxelHit[64];
bool complete = blocks.Query(searchBox, VoxelFilter.NonZero, hits, out int written);
// hits[..written] is usable even when complete is false.

These queries operate on voxel cells. They do not implement continuous or swept collision detection. A falling decision must also account for entity bounds, velocity, collision rules, and neighboring chunks. Use OctreeWorld.TryAny for cross-chunk occupancy checks, or split boxes into local channel queries. Unloaded world regions remain unknown; the application decides whether to load or defer. Query and regional decode destinations must not overlap encoded bytes. Validate untrusted encoded data before spatial queries; malformed input may leave partial destination output.

Sparse edits and scheduled repackaging

DeferredOctreeChunk and DeferredOctreeChunk<T> keep point edits beside an immutable compressed snapshot. Reads through the owner consult those edits first. Positions are shared across channels; writing the same position and channel overwrites its previous value, including explicit zero values.

using var edits = new DeferredOctreeChunk(
    chunk, capacity: 256, deferredWritesEnabled: true); // true is the default
edits[0, 1, 2, 3] = 43;
uint current = edits[0, 1, 2, 3];
OctreeChunk updated = edits.Repackage();

Capacity counts distinct positions, independently of channel count. Exceeding it promotes the chunk to dense Morton storage. MakeHot() explicitly promotes for bulk editing and returns a borrowed HotOctreeChunk; finish through the owner's Repackage(), and stop using borrowed spans afterward. The owner remains usable for further edits. Sparse repackaging rebuilds only changed channels and shares the other encodings. GetChunk(), CopyBlockTo(), SerializedLength, and CopyEncodedTo() apply pending edits before exposing complete content.

Set deferredWritesEnabled: false to retain the same owner and repackaging API while promoting to dense Morton storage on the first write. Construction remains lazy in both modes. The option is fixed for the owner's lifetime and is exposed through DeferredWritesEnabled.

For multiple chunks and a client-scheduled worker, use DeferredChunkStore or DeferredChunkStore<T>. All store operations are synchronized; the store owns its editors and exposes immutable snapshots only.

using var edits = new DeferredChunkStore(
    capacity: 256, deferredWritesEnabled: true); // Applies to newly installed chunks.
var address = new ChunkAddress(0, -1, 0, 2);
edits.SetChunk(address, chunk);
edits.Set(address, channel: 0, x: 1, y: 2, z: 3, value: 43);
uint current = edits.Get(address, channel: 0, x: 1, y: 2, z: 3);

ChunkAddress[] pending = edits.GetPendingRepackageChunks();
int processed = edits.Repackage(maxChunks: 4); // May run on a client worker.
OctreeChunk updated = edits.GetChunk(address); // Flushes this chunk if necessary.
world.SetChunk(address, updated); // Publish explicitly into a WorldEntity.

Use the store as the authoritative read/write path while edits are pending. Existing worlds and previously returned snapshots retain their published values until explicitly replaced. The pending-address copy is advisory and deduplicated; another operation may repackage or remove an address before it is processed. Repackaging holds the store lock, so bound each batch to limit stalls. Publication into a WorldEntity requires the application's usual synchronization.

Dispose owners and stores when finished to return pooled edit buffers. A standalone owner requires exclusive access; disposing it discards unpublished edits. Pool retention consumes memory even when managed allocation counters report no new array allocations.

Repeated checks around moving entities

OctreeNeighborhoodCache is a persistent, reusable dense window. Construct it once with a power-of-two side length, or pass caller-owned Memory<uint>. OctreeNeighborhoodSpan provides the same window over Span<uint> for stack, pooled, or unmanaged storage without allocating a wrapper.

var neighborhood = new OctreeNeighborhoodCache(side: 8); // 2 KiB voxel buffer.

// Before each query batch:
int refreshed = neighborhood.Update(collisionChannel, entityX, entityY, entityZ);
bool occupied = neighborhood.Get(entityX, entityY - 1, entityZ) != 0;
bool supported = neighborhood.Any(footprintInsideCachedBounds, VoxelFilter.NonZero);

Update centers and clamps the window to the chunk. It preserves overlapping cells with ring indexing and fills only newly exposed slabs. A one-cell axis move of an 8-cubed window refreshes 64 values; an unchanged window refreshes zero. Teleports or source replacement refresh the whole window. Rebuilding an owned Octree changes its encoding identity, which Update detects automatically. Call Update before each query batch: Get/Any deliberately read the last cache snapshot without consulting the tree.

For a borrowed OctreeSpan, pass a revision to Update(view, revision, x, y, z) and change it on every in-place encoding update. Invalidate() forces a refill; Invalidate(changedBox) does so only when the changed region intersects the window. Neither cache form is thread-safe. Its storage must remain exclusive and alive while in use. Cache bounds must fit inside one chunk; inspect Bounds before issuing cached queries.

Cache capacity costs 4 * side^3 bytes plus wrapper metadata. A cache per worker or active entity can help repeated local reads, but refresh overhead may exceed the benefit for one short occupancy test. The benchmark suite includes this cost. The .NET 10 support benchmark measured roughly 81 ns for a moving four-cell Any check versus 806 ns when maintaining an 8-cubed cache for that check alone. Repeated reads of compressed neighborhoods did benefit from caching; already dense data generally did not. See the recorded benchmarks below.

Channels, block updates, and dynamic entities

Use independent Octree instances as channels with the same levels and coordinate layout, or group them in an OctreeChunk. Its dense import/export operates on all channels in one channel-major buffer; WithDenseChannel rebuilds only one changed channel. The existing UInt32 payload also supports packed bitfields; Masked filters can select collision or material bits without unpacking every channel.

const int levels = 5;
const int channels = 4;
var values = new uint[(1 << (levels * 3)) * channels];
var dense = new DenseVoxelBlockSpan(
    values, levels, channels, DenseVoxelLayout.Morton);

dense[channel: 0, x: 1, y: 2, z: 3] = 42; // Material
dense[channel: 1, x: 1, y: 2, z: 3] = 1;  // Collision

OctreeChunk chunk = OctreeChunk.FromDense(
    levels, channels, values, DenseVoxelLayout.Morton);

// Rebuild one channel while sharing every unchanged channel encoding.
dense.GetChannelSpan(3).Fill(12);
chunk = chunk.WithDenseChannel(
    channel: 3, dense.GetChannelSpan(3), DenseVoxelLayout.Morton);

// Retain dense Morton storage during intensive edits, then encode once.
HotOctreeChunk hot = chunk.MarkHot();
hot[channel: 0, x: 1, y: 2, z: 3] = 43;
OctreeChunk snapshot = hot.UnmarkHot();

// Serialize or transport all channels as one checked packet.
var packet = new byte[snapshot.SerializedLength];
snapshot.CopyEncodedTo(packet);
OctreeChunk restored = OctreeChunk.FromEncoded(packet);
Data Suggested initial layout
Block/material ID Independent channel
Collision class/flags Independent channel or packed material bits
Orientation Separate when sparsely populated or rarely queried
Fluid type and amount Consider packing together when read and updated together
Light, temperature, simulation state Independent channels by update/access pattern
Moving entities Separate mutable spatial hash/grid or dynamic AABB index

Independent channels preserve homogeneous regions when an unrelated attribute varies, allow selective reads, and let a fluid update avoid rebuilding the block channel. Packed attributes reduce traversals when all attributes are always needed. SpatialChannels benchmarks both layouts using identical data; no layout is universally optimal. Group publication of related channels at the application level if readers require a consistent multi-channel snapshot.

This is a read-optimized chunk representation. Use MarkHot() on an isolated chunk, or MarkChunkHot() on a world, to retain a mutable Morton-ordered dense representation across repeated edits. Commit()/UnmarkHot() return an immutable octree; the world-specific commit methods also publish it. Do not rebuild voxel octrees for entity movement: keep entity IDs, positions, velocity, and bounds in a separate mutable index and query voxel collision channels for terrain interaction.

Bounded sparse worlds

OctreeWorld provides a finite coordinate space without allocating a dense world or a complete chunk-reference grid. Unloaded data remains unknown; known zero regions and resident chunks collapse independently in its outer octree:

using var world = new OctreeWorld(
    levels: 10, chunkLevels: 5, channelCount: 4,
    chunkCapacity: 256, branchCapacity: 2048);

// Generation or storage established that this adjacent chunk-sized region is air.
if (!world.TrySetEmptyRegion(x: 32, y: 0, z: 0, levels: 5))
    throw new InvalidOperationException("Increase branch capacity.");

// LoadChunk uses chunk coordinates; point and query APIs use voxel coordinates.
world.LoadChunk(0, 0, 0, OctreeChunk.Empty(levels: 5, channelCount: 4));
uint material = world.Get(channel: 0, x: 1, y: 2, z: 3);

var box = new VoxelBox(0, 0, 0, 64, 32, 32);
if (!world.TryAny(box, channel: 1, VoxelFilter.NonZero, out bool occupied))
{
    // The region contains unloaded data, so a negative answer is inconclusive.
}

// This block crosses from the resident chunk into the known-empty region.
var allChannels = new uint[(1 << (5 * 3)) * world.ChannelCount];
world.CopyBlockTo(16, 0, 0, levels: 5, allChannels, DenseVoxelLayout.Morton);

GetRegion reports Unloaded, Empty, or Loaded. QueryRegions exports a caller-buffer manifest, and TryLoadRegion imports or translates it:

var bounds = new VoxelBox(0, 0, 0, 128, 128, 128);
var manifest = new OctreeWorldRegion[1 + 7 * world.BranchCount];
if (!world.QueryRegions(bounds, manifest, out int regionCount))
    throw new InvalidOperationException("Manifest capacity is insufficient.");

using var destination = new OctreeWorld(
    levels: 16, chunkLevels: 5, channelCount: 4,
    chunkCapacity: 256, branchCapacity: 4096);

foreach (var region in manifest.AsSpan(0, regionCount))
{
    if (!destination.TryLoadRegion(region, offsetX: 2048))
        throw new InvalidOperationException("Destination capacity is insufficient.");
}

Capacity can be provisioned internally or supplied as exclusive caller-owned memory. After construction, normal operations allocate no managed memory apart from synchronization warmup and contention paths:

const int branches = 2048;
const int chunkSlots = 256;
var workspace = new int[
    OctreeWorld.GetRequiredWorkspaceLength(branches, chunkSlots)];
var chunks = new OctreeChunk?[chunkSlots];

using var world = new OctreeWorld(
    levels: 10, chunkLevels: 5, channelCount: 4,
    workspace, chunks, branchCapacity: branches);

Use initiallyEmpty: true only when the complete world is known to contain zeros in every channel. It is an assertion about data, not an unloaded-data policy. The generic OctreeWorld<T> mirrors this API.

Validation and errors

  • Single-channel/chunk levels outside 0..9 throw ArgumentOutOfRangeException; world levels range from the configured chunk depth through 20.
  • A dense source length other than (1 << levels)^3 throws ArgumentException; partial volumes are not accepted.
  • Get and the indexer require each coordinate to be in [0, SideLength), otherwise they throw ArgumentOutOfRangeException. TryGet returns false for an out-of-range coordinate, and Contains reports whether the coordinate is in range.
  • Calling Get, CopyTo, or AsSpan on an unbuilt owned Octree throws InvalidOperationException. Check IsBuilt when its state is uncertain.
  • TryBuild returns false with bytesWritten == 0 when the destination is too small. The throwing caller-buffer Build throws ArgumentException.
  • TryCopyTo returns false when its destination contains fewer than Count values; CopyTo rejects insufficient capacity.
  • OctreeSpan.TryCreate returns false for a malformed or unsupported header. The OctreeSpan constructor throws FormatException for the same header failure.
  • Header validation alone does not traverse the encoded structure. Use IsWellFormed() before querying untrusted data; Octree.FromEncoded performs full structural and primitive-encoding validation and throws FormatException for malformed or unsupported input.

The Try* methods avoid exceptions only for their documented routine failure conditions. They do not suppress programmer errors such as invalid levels or an incorrectly sized dense source.

Source values and encoded build destinations must not overlap. Encoded input and dense decode destinations must likewise not overlap; TryCopyTo returns false and CopyTo throws ArgumentException. If TryBuild fails for insufficient capacity, bytes in the attempted destination are unspecified even though bytesWritten is zero.

Memory and format characteristics

  • The representation is one contiguous byte sequence rather than a graph of heap-allocated node objects.
  • Homogeneous regions collapse into value leaves. The encoded size therefore depends on the spatial distribution of values, not only the voxel count.
  • If the tree form would not beat direct storage, the encoder selects a dense payload. Consequently, GetMaximumSize(levels) is 2 + (4 * Count) bytes and the encoded representation never exceeds that bound.
  • Offsets are stored inside the encoding; there are no process-local pointers. Encoded data may be copied or persisted as bytes.
  • Dense payloads are persisted in little-endian UInt32 order; access remains portable across machine endianness.
  • The encoding carries a format version. Package version 2.0.0 and byte-format Octree.FormatVersion == 1 are distinct version domains.
  • Consumers must treat Data and external backing storage as immutable while a tree is being read. Mutating encoded bytes invalidates the view and can corrupt subsequent lookups.

The package 2.0 encoding contract is not compatible with the archived package 1.x encoding. Generic format 2 and the current non-generic UInt32 format 1 are also intentionally distinct. Validate persisted data before use and rebuild incompatible data from its dense source.

Concurrency

OctreeWorld synchronizes reads with a shared ReaderWriterLockSlim and writes with an exclusive lock. Individual queries, including multi-channel extraction, see one consistent world state. Use batches for consistency across several calls and to amortize lock acquisition:

using (world.BeginReadBatch())
{
    uint material = world.Get(0, x, y, z);
    uint collision = world.Get(1, x, y, z);
    // Both values come from the same world state; other readers can proceed.
}

// Build immutable replacement chunks before entering the write batch.
using (world.BeginWriteBatch())
{
    world.LoadChunk(0, 0, 0, firstReplacement);
    world.LoadChunk(1, 0, 0, secondReplacement);
    // Reads are also permitted here. Other threads wait until disposal.
}

For one active chunk, CommitHotChunk performs Morton-to-octree encoding before acquiring its write lock and then publishes only if the source snapshot is still current. For atomic publication of several hot chunks, call Commit() on each editor before entering a short write batch, then call CommitHotChunk inside it. Readers continue seeing the previous immutable snapshots until publication.

Batches are synchronous, stack-only scopes. Dispose on the creating thread in reverse acquisition order; do not copy them or hold them across await. Compatible batches may nest. Writing from a read batch throws LockRecursionException; begin a write batch for read-modify-write work. Successful changes persist if a later operation fails: batches provide exclusion, not rollback. Revision still advances per successful change. Keep batches short. Dispose a world after all its workers and batches have finished.

Octree uses lock-free immutable publication: concurrent builds and reads are safe when each build source is stable. The last build to publish wins. Capture AsSpan() once for several reads of the same encoding. OctreeChunk is an immutable multi-channel snapshot; a chunk returned by TryGetChunk remains usable after replacement or eviction. Borrowed bytes and caller-owned workspace must retain their documented immutability and lifetime. Hot chunks, mutable dense views, and neighborhood caches remain exclusive per-worker resources.

The synchronization benchmark measured 26.1 ns per individual world read versus 19.9 ns in 1,024-operation read batches on .NET 10 / Ryzen 9 5950X, with zero warm-path allocation. These are uncontended measurements; batch size and writer latency remain workload-dependent.

Octree.CreateLookup() captures an immutable UInt32 point-lookup snapshot. Compressed nodes use nine 32-bit words each (36 bytes) so reads can follow child indexes directly. Compilation validates the source and allocates the index once; dense snapshots share the source encoding and uniform snapshots retain one value. Use StorageBytes to inspect retained payload size, excluding CLR object headers. The snapshot supports concurrent reads and continues returning its captured values after the source octree is rebuilt. For a chunk channel, use chunk.GetChannel(0).CreateLookup(); this span API copies dense input so its backing storage can be reused after compilation. Dense channel spans remain suitable when contiguous scans or edits justify expanding every voxel.

For dirty-block coalescing and publication scheduling, see threading and deferred updates.

Performance hypotheses

The benchmark suite is intended to test these hypotheses rather than presuppose them:

  1. The caller-buffer builder and span-backed lookup path allocate zero managed bytes per operation.
  2. The owned builder allocates one final array and avoids a temporary node graph.
  3. The v2 post-order/backward-offset tree layout and direct dense fallback improve lookup latency relative to v1's forward-offset encoding for relevant data distributions.
  4. Homogeneous and spatially clustered inputs compress substantially below dense UInt32[] storage.
  5. Highly heterogeneous inputs select the dense fallback, trading two header bytes for direct indexing rather than tree traversal.

Results vary with data distribution, level count, JIT, runtime, CPU, and access pattern. Treat a claim as measured only when accompanied by BenchmarkDotNet output for the relevant source revision and environment. Historical reports directly under src/Tedd.Voxtree.Benchmark/Results predate v2; 2026-09-05 records the spatial/channel milestone and 2026-09-05-bulk records the subsequent dense-block/world extension.

The recorded .NET 8 reference screening run confirmed 0 B caller-span builds and lookups. Random level-5 lookup measured approximately 7.5 times faster than v1, while compressed-tree lookup ranged from near parity to slower than v1. See BENCHMARKS.md for the environment, method, and qualified results.

Build, test, and benchmark

Run the test suite:

dotnet test src/Tedd.Voxtree.sln -c Release

Run all BenchmarkDotNet cases:

dotnet run -c Release -f net10.0 --project src/Tedd.Voxtree.Benchmark -- --filter '*'

The benchmark project retains the version 1 implementation as the internal Tedd.Voxtree.Benchmark.Archive.V1.OctreeV1 baseline. It is test infrastructure, not supported public API. Benchmark names and categories label that implementation as V1 so current, archived, span-backed, and dense-array results remain distinguishable.

Use a Release build, close competing workloads, and compare allocation columns as well as elapsed time. Commit benchmark artifacts only with their runtime, operating system, CPU, and BenchmarkDotNet metadata intact.

Use -f net11.0 for preview-runtime measurements or -f net8.0 to exercise the portable .NET Standard asset. The test project runs on all three hosts. Spatial benchmarks can be selected with --filter '*Spatial*'. BenchmarkDotNet 0.16.0-preview.1 supplies .NET 11 support.

Migrating from version 1

Version 2 is a major release because it replaces the public construction/storage contract and the encoded representation. Recompile consumers, rebuild persisted trees from dense voxel data, and choose explicitly between owned Octree and borrowed OctreeSpan lifetimes. The v1 implementation remains archived only in the benchmark project for controlled comparisons.

See CHANGELOG.md for the release summary.

License

Tedd.Voxtree is licensed under the GNU Lesser General Public License version 2.1. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed.  net11.0 is compatible. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.13 32 9/20/2026
2.0.11 51 9/16/2026
2.0.8 39 9/15/2026

Pooled sparse point edits through DeferredOctreeChunk, synchronized scheduling and bounded repackaging through DeferredChunkStore, and a default-enabled deferredWritesEnabled switch for choosing sparse or first-write dense editing. Existing serialized formats remain compatible.