From dc74fdadcc8f294705371948b957c2fbd42cab25 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 1 Jun 2026 10:48:56 +0200 Subject: [PATCH] feat: add streaming primitives (IChunkRenderer, IJobExecutor, NeighborSnapshots, ChunkJob) --- Rendering/ChunkRenderer.cs | 2 +- World/Streaming/ChunkJob.cs | 21 +++++++++++ World/Streaming/IChunkRenderer.cs | 6 ++++ World/Streaming/IJobExecutor.cs | 15 ++++++++ World/Streaming/NeighborSnapshots.cs | 52 ++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 World/Streaming/ChunkJob.cs create mode 100644 World/Streaming/IChunkRenderer.cs create mode 100644 World/Streaming/IJobExecutor.cs create mode 100644 World/Streaming/NeighborSnapshots.cs diff --git a/Rendering/ChunkRenderer.cs b/Rendering/ChunkRenderer.cs index d31ad11..e77a9a6 100644 --- a/Rendering/ChunkRenderer.cs +++ b/Rendering/ChunkRenderer.cs @@ -2,7 +2,7 @@ using OpenTK.Graphics.OpenGL4; namespace VoxelEngine.Rendering; -public sealed class ChunkRenderer : IDisposable +public sealed class ChunkRenderer : VoxelEngine.World.Streaming.IChunkRenderer { private const int FloatsPerVertex = 6; // pos.xyz, uv, shade diff --git a/World/Streaming/ChunkJob.cs b/World/Streaming/ChunkJob.cs new file mode 100644 index 0000000..e643aa1 --- /dev/null +++ b/World/Streaming/ChunkJob.cs @@ -0,0 +1,21 @@ +using OpenTK.Mathematics; +using VoxelEngine.World.Chunk; + +namespace VoxelEngine.World.Streaming; + +// Job dispatched to an IJobExecutor. For a fresh load, ExistingChunk is null +// and the worker generates + applies diff + meshes. For a remesh-only job, +// ExistingChunk holds the chunk and the worker only meshes (no generation). +internal sealed record ChunkJob( + Vector3i Pos, + Chunk.Chunk? ExistingChunk, + NeighborSnapshots Neighbors, + IReadOnlyDictionary? Diff); + +// Posted by the worker back to the main thread via a ConcurrentQueue. +internal sealed record ChunkJobResult( + Vector3i Pos, + Chunk.Chunk? Chunk, + float[]? Mesh, + Exception? Error, + bool IsRemeshOnly); diff --git a/World/Streaming/IChunkRenderer.cs b/World/Streaming/IChunkRenderer.cs new file mode 100644 index 0000000..ac7d7a6 --- /dev/null +++ b/World/Streaming/IChunkRenderer.cs @@ -0,0 +1,6 @@ +namespace VoxelEngine.World.Streaming; + +public interface IChunkRenderer : IDisposable +{ + void Draw(); +} diff --git a/World/Streaming/IJobExecutor.cs b/World/Streaming/IJobExecutor.cs new file mode 100644 index 0000000..3866a3f --- /dev/null +++ b/World/Streaming/IJobExecutor.cs @@ -0,0 +1,15 @@ +namespace VoxelEngine.World.Streaming; + +public interface IJobExecutor +{ + void Submit(Action work); +} + +public sealed class ThreadPoolJobExecutor : IJobExecutor +{ + // Fire-and-forget: the Task is intentionally discarded. The caller's `work` + // delegate MUST catch its own exceptions and report them via a dedicated + // channel (e.g. ChunkJobResult.Error in the streaming pipeline). Unhandled + // exceptions here are silently lost. + public void Submit(Action work) => _ = Task.Run(work); +} diff --git a/World/Streaming/NeighborSnapshots.cs b/World/Streaming/NeighborSnapshots.cs new file mode 100644 index 0000000..0105a12 --- /dev/null +++ b/World/Streaming/NeighborSnapshots.cs @@ -0,0 +1,52 @@ +using VoxelEngine.World.Chunk; + +namespace VoxelEngine.World.Streaming; + +// Snapshots of the six cardinal neighbor chunks' block arrays, taken on the +// main thread at job-dispatch time. Each slot is null if that neighbor is not +// currently loaded. The ChunkMesher reads only these arrays from a worker +// thread — never the live World. +public sealed class NeighborSnapshots +{ + public ushort[]? NegX { get; } + public ushort[]? PosX { get; } + public ushort[]? NegY { get; } + public ushort[]? PosY { get; } + public ushort[]? NegZ { get; } + public ushort[]? PosZ { get; } + + // Snapshots take ownership of the arrays passed in. Callers must hand + // in fresh copies (not live references into a chunk's _ids buffer) — + // the snapshot does NOT copy defensively. This is enforced at the + // single call-site in ChunkStreamer.SnapshotNeighbors which allocates + // fresh arrays. The arrays must not be mutated after construction. + public NeighborSnapshots( + ushort[]? negX, ushort[]? posX, + ushort[]? negY, ushort[]? posY, + ushort[]? negZ, ushort[]? posZ) + { + this.NegX = negX; this.PosX = posX; + this.NegY = negY; this.PosY = posY; + this.NegZ = negZ; this.PosZ = posZ; + } + + // Returns true if the out-of-chunk position (nx, ny, nz) — exactly one of + // which is out of [0, Chunk.Chunk.Size) — is occupied by an opaque block + // in the snapshot. Returns false if the relevant neighbor is null. + public bool IsOpaqueOutside(int nx, int ny, int nz, Func isOpaque) + { + ushort[]? n; + int lx = nx, ly = ny, lz = nz; + if (nx < 0) { n = this.NegX; lx = Chunk.Chunk.Size - 1; } + else if (nx >= Chunk.Chunk.Size) { n = this.PosX; lx = 0; } + else if (ny < 0) { n = this.NegY; ly = Chunk.Chunk.Size - 1; } + else if (ny >= Chunk.Chunk.Size) { n = this.PosY; ly = 0; } + else if (nz < 0) { n = this.NegZ; lz = Chunk.Chunk.Size - 1; } + else if (nz >= Chunk.Chunk.Size) { n = this.PosZ; lz = 0; } + else return false; // not actually outside — shouldn't happen, mesher guards + + if (n == null) return false; + int index = (lx * Chunk.Chunk.Size + ly) * Chunk.Chunk.Size + lz; + return isOpaque(n[index]); + } +}