using System.Collections.Concurrent; using System.Linq; using OpenTK.Mathematics; using Serilog; using VoxelEngine.Core; using VoxelEngine.World.Blocks; using VoxelEngine.World.Chunk; using VoxelEngine.World.WorldGen; using VoxelChunk = VoxelEngine.World.Chunk.Chunk; namespace VoxelEngine.World.Streaming; public sealed class ChunkStreamer : IDisposable { private static readonly ILogger Log = Serilog.Log.ForContext(); private readonly Configuration _cfg; private readonly IWorld _world; private readonly Func _generate; private readonly Func _surfaceHeightAt; private readonly IChunkMesher _mesher; private readonly BlockRegistry _registry; private readonly IJobExecutor _executor; private readonly Func _rendererFactory; private readonly Dictionary _renderers = new(); private readonly HashSet _tracked = new(); private readonly HashSet _ready = new(); private readonly HashSet _dirty = new(); private readonly PriorityQueue _pendingLoad = new(); private readonly ConcurrentQueue _completed = new(); private readonly Queue _pendingUnload = new(); private readonly HashSet _failed = new(); private Vector3i _lastPlayerChunk = new(int.MinValue, int.MinValue, int.MinValue); private int _inFlight; private bool _spawnReady; private bool _spawnAnchorSet; private Vector3i _spawnAnchor; public int RendererCount => this._renderers.Count; public int TrackedCount => this._tracked.Count; public bool IsReady(Vector3i pos) => this._ready.Contains(pos); public bool SpawnReady => this._spawnReady; public void MarkDirty(Vector3i chunkPos) { if (!this._ready.Contains(chunkPos)) return; this._dirty.Add(chunkPos); } public ChunkStreamer( Configuration cfg, IWorld world, Func generate, Func surfaceHeightAt, IChunkMesher mesher, BlockRegistry registry, IJobExecutor executor, Func rendererFactory) { this._cfg = cfg; this._world = world; this._generate = generate; this._surfaceHeightAt = surfaceHeightAt; this._mesher = mesher; this._registry = registry; this._executor = executor; this._rendererFactory = rendererFactory; } public void Tick(Vector3i playerChunkPos) { if (playerChunkPos != this._lastPlayerChunk) { this.RecomputeTargetSet(playerChunkPos); this._lastPlayerChunk = playerChunkPos; } // Unloads first to free in-flight slots, then dispatch BEFORE drain: // with a synchronous executor (tests), Submit() runs work to completion // before returning, so a single tick must drain its own dispatches. // With the production threadpool executor, jobs don't finish in this // tick -- Drain harvests results submitted on previous ticks. Either // way this ordering is correct. this.ProcessUnloads(); this.DispatchJobs(); this.DrainCompleted(); this.UpdateSpawnReady(); } // Draws every loaded chunk for which `isChunkVisible` returns true. Returns // the number of chunks actually drawn (passed the visibility test). The // visibility predicate lives with the caller so the streamer stays free of // any rendering/Frustum dependency. public int DrawVisible(Func isChunkVisible, Action draw) { int drawn = 0; foreach (var kvp in this._renderers) { if (!isChunkVisible(kvp.Key)) continue; draw(kvp.Key, kvp.Value); drawn++; } return drawn; } public void Dispose() { foreach (var r in this._renderers.Values) r.Dispose(); this._renderers.Clear(); } private void RecomputeTargetSet(Vector3i p) { if (!this._spawnAnchorSet) { this._spawnAnchor = p; this._spawnAnchorSet = true; } var target = new HashSet(); this.CollectTargets(p, this._cfg.RenderDistanceHorizontal, target); foreach (var pos in target) { if (this._failed.Contains(pos)) continue; if (this._tracked.Add(pos)) { int dx = pos.X - p.X; int dy = pos.Y - p.Y; int dz = pos.Z - p.Z; int priority = dx * dx + dz * dz + dy * dy * this._cfg.VerticalPriorityWeight; this._pendingLoad.Enqueue(pos, priority); } } // Anything currently tracked but no longer in target → unload. var toRemove = new List(); foreach (var tracked in this._tracked) { if (!target.Contains(tracked)) toRemove.Add(tracked); } foreach (var pos in toRemove) { this._tracked.Remove(pos); this._pendingUnload.Enqueue(pos); } } // Fills `into` with the chunk positions to load for a viewer at chunk // `center`, over a square horizontal region of Chebyshev radius `radiusH`. // Per column the loaded Y-range is the union of the surface shell and the // player slab, clamped to the world's vertical bounds. private void CollectTargets(Vector3i center, int radiusH, HashSet into) { int v = this._cfg.RenderDistanceVertical; int skirt = this._cfg.SurfaceShellSkirtBelow; for (int dx = -radiusH; dx <= radiusH; dx++) for (int dz = -radiusH; dz <= radiusH; dz++) { int cx = center.X + dx; int cz = center.Z + dz; (int minH, int maxH) = this.SurfaceHeightRange(cx, cz); int shellBot = ChunkMath.FloorDiv(minH, VoxelChunk.Size) - skirt; int shellTop = ChunkMath.FloorDiv(maxH, VoxelChunk.Size); ChunkStreamer.AddColumn(into, cx, cz, shellBot, shellTop); ChunkStreamer.AddColumn(into, cx, cz, center.Y - v, center.Y + v); } } private static void AddColumn(HashSet into, int cx, int cz, int loCy, int hiCy) { int lo = Math.Max(0, loCy); int hi = Math.Min(WorldConstants.WorldHeightChunks - 1, hiCy); for (int cy = lo; cy <= hi; cy++) into.Add(new Vector3i(cx, cy, cz)); } // Min/max terrain surface height across a chunk column's 16×16 footprint, // sampled at the four corners and the centre (catches slope spread cheaply). private (int min, int max) SurfaceHeightRange(int cx, int cz) { int x0 = cx * VoxelChunk.Size; int z0 = cz * VoxelChunk.Size; int x1 = x0 + VoxelChunk.Size - 1; int z1 = z0 + VoxelChunk.Size - 1; int xm = x0 + VoxelChunk.Size / 2; int zm = z0 + VoxelChunk.Size / 2; int h0 = this._surfaceHeightAt(x0, z0); int h1 = this._surfaceHeightAt(x1, z0); int h2 = this._surfaceHeightAt(x0, z1); int h3 = this._surfaceHeightAt(x1, z1); int h4 = this._surfaceHeightAt(xm, zm); int min = Math.Min(Math.Min(h0, h1), Math.Min(Math.Min(h2, h3), h4)); int max = Math.Max(Math.Max(h0, h1), Math.Max(Math.Max(h2, h3), h4)); return (min, max); } private void DrainCompleted() { int budget = this._cfg.MaxMeshUploadsPerFrame; while (budget > 0 && this._completed.TryDequeue(out ChunkJobResult? result)) { this._inFlight--; budget--; this.InstallResult(result); } } private void ProcessUnloads() { while (this._pendingUnload.TryDequeue(out Vector3i pos)) { if (this._renderers.TryGetValue(pos, out IChunkRenderer? renderer)) { renderer.Dispose(); this._renderers.Remove(pos); } this._world.RemoveChunk(pos); this._ready.Remove(pos); this._failed.Remove(pos); Log.Debug("Unloaded {Pos}", pos); } } private void UpdateSpawnReady() { if (this._spawnReady || !this._spawnAnchorSet) return; var spawnTargets = new HashSet(); this.CollectTargets(this._spawnAnchor, this._cfg.SpawnReadyRadiusHorizontal, spawnTargets); foreach (var pos in spawnTargets) { if (this._failed.Contains(pos)) continue; if (!this._ready.Contains(pos)) return; } this._spawnReady = true; Log.Information("Spawn area ready at anchor {Anchor}", this._spawnAnchor); } private void InstallResult(ChunkJobResult result) { if (result.Error != null || result.Mesh == null) { this._failed.Add(result.Pos); Log.Error(result.Error, "Chunk {Pos} failed to load", result.Pos); return; } if (!result.IsRemeshOnly) { if (result.Chunk == null) { Log.Error("Load result for {Pos} had null chunk", result.Pos); return; } this._world.SetChunk(result.Pos, result.Chunk); } if (this._renderers.TryGetValue(result.Pos, out IChunkRenderer? old)) { old.Dispose(); this._renderers.Remove(result.Pos); } // Skip renderer allocation for empty meshes (pure-air chunks above // terrain, or chunks whose every face culls). The chunk still counts // as ready — DrawVisible's iteration over _renderers naturally no-ops. if (result.Mesh.Length > 0) { this._renderers[result.Pos] = this._rendererFactory(result.Mesh); } this._ready.Add(result.Pos); this._dirty.Remove(result.Pos); if (!result.IsRemeshOnly) { this.MarkCardinalNeighborsDirty(result.Pos); Log.Debug("Loaded {Pos}", result.Pos); } else { Log.Debug("Remeshed {Pos}", result.Pos); } } private void MarkCardinalNeighborsDirty(Vector3i pos) { Vector3i[] offsets = { new(-1, 0, 0), new(1, 0, 0), new(0, -1, 0), new(0, 1, 0), new(0, 0, -1), new(0, 0, 1), }; foreach (var off in offsets) { var neighbor = pos + off; if (this._ready.Contains(neighbor)) this._dirty.Add(neighbor); } } private void DispatchJobs() { // Dirty re-meshes first (correctness), then new loads (player view). while (this._inFlight < this._cfg.MaxGenerateJobsInFlight && this._dirty.Count > 0) { Vector3i pos = this._dirty.First(); this._dirty.Remove(pos); this.DispatchRemesh(pos); } while (this._inFlight < this._cfg.MaxGenerateJobsInFlight && this._pendingLoad.Count > 0) { Vector3i pos = this._pendingLoad.Dequeue(); this.DispatchLoad(pos); } } private void DispatchRemesh(Vector3i pos) { VoxelChunk? chunk = this._world.GetChunk(pos); if (chunk == null) return; // got unloaded between MarkDirty and now NeighborSnapshots neighbors = this.SnapshotNeighbors(pos); VoxelChunk snapshotChunk = ChunkStreamer.CopyChunk(chunk); // worker reads from copy this._inFlight++; IChunkMesher mesher = this._mesher; BlockRegistry registry = this._registry; var completed = this._completed; this._executor.Submit(() => { try { float[] mesh = mesher.Build(snapshotChunk, registry, (nx, ny, nz) => neighbors.IsOpaqueOutside(nx, ny, nz, id => registry.Get(id).IsOpaque)); completed.Enqueue(new ChunkJobResult(pos, snapshotChunk, mesh, Error: null, IsRemeshOnly: true)); } catch (Exception ex) { completed.Enqueue(new ChunkJobResult(pos, Chunk: null, Mesh: null, Error: ex, IsRemeshOnly: true)); } }); } private static VoxelChunk CopyChunk(VoxelChunk source) { var copy = new VoxelChunk(); for (int x = 0; x < VoxelChunk.Size; x++) for (int y = 0; y < VoxelChunk.Size; y++) for (int z = 0; z < VoxelChunk.Size; z++) { copy[x, y, z] = source[x, y, z]; } return copy; } private void DispatchLoad(Vector3i pos) { NeighborSnapshots neighbors = this.SnapshotNeighbors(pos); this._world.ModificationStore.TryGetDiff(pos, out IReadOnlyDictionary? diff); var job = new ChunkJob(pos, neighbors, diff); this._inFlight++; IChunkMesher mesher = this._mesher; BlockRegistry registry = this._registry; var generate = this._generate; var completed = this._completed; this._executor.Submit(() => { try { VoxelChunk chunk = generate(job.Pos); if (job.Diff != null) { foreach (var (idx, id) in job.Diff) { int lx = idx / (VoxelChunk.Size * VoxelChunk.Size); int ly = (idx / VoxelChunk.Size) % VoxelChunk.Size; int lz = idx % VoxelChunk.Size; chunk[lx, ly, lz] = id; } } float[] mesh = mesher.Build(chunk, registry, (nx, ny, nz) => job.Neighbors.IsOpaqueOutside(nx, ny, nz, id => registry.Get(id).IsOpaque)); completed.Enqueue(new ChunkJobResult(job.Pos, chunk, mesh, Error: null, IsRemeshOnly: false)); } catch (Exception ex) { completed.Enqueue(new ChunkJobResult(job.Pos, Chunk: null, Mesh: null, Error: ex, IsRemeshOnly: false)); } }); } private NeighborSnapshots SnapshotNeighbors(Vector3i pos) { ushort[]? Snap(Vector3i p) { VoxelChunk? c = this._world.GetChunk(p); if (c == null) return null; var copy = new ushort[VoxelChunk.Size * VoxelChunk.Size * VoxelChunk.Size]; for (int x = 0; x < VoxelChunk.Size; x++) for (int y = 0; y < VoxelChunk.Size; y++) for (int z = 0; z < VoxelChunk.Size; z++) { copy[(x * VoxelChunk.Size + y) * VoxelChunk.Size + z] = c[x, y, z]; } return copy; } return new NeighborSnapshots( negX: Snap(pos + new Vector3i(-1, 0, 0)), posX: Snap(pos + new Vector3i(1, 0, 0)), negY: Snap(pos + new Vector3i(0, -1, 0)), posY: Snap(pos + new Vector3i(0, 1, 0)), negZ: Snap(pos + new Vector3i(0, 0, -1)), posZ: Snap(pos + new Vector3i(0, 0, 1))); } }