Files
VoxelEngine/World/Streaming/ChunkStreamer.cs
MDietermann d1c8c22705 perf: skip renderer for chunks with empty mesh
Pure-air chunks (above terrain) and chunks where every face culls
produce a zero-length mesh from ChunkMesher.Build(). Previously
InstallResult still allocated a real ChunkRenderer (VBO + VAO) and
registered a zero-vertex draw call. With RenderDistanceVertical=2 and
a player at surface Y, the topmost vertical layer is typically all
pure air -- roughly H*H = ~289 wasted renderers per spawn.

The chunk still gets added to _ready so SpawnReady, dirty-marking and
unload bookkeeping continue to work; DrawAll's iteration over the
renderer dictionary naturally skips entries that don't exist.
2026-06-01 13:44:51 +02:00

375 lines
12 KiB
C#

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<ChunkStreamer>();
private readonly Configuration _cfg;
private readonly IWorld _world;
private readonly Func<Vector3i, VoxelChunk> _generate;
private readonly IChunkMesher _mesher;
private readonly BlockRegistry _registry;
private readonly IJobExecutor _executor;
private readonly Func<float[], IChunkRenderer> _rendererFactory;
private readonly Dictionary<Vector3i, IChunkRenderer> _renderers = new();
private readonly HashSet<Vector3i> _tracked = new();
private readonly HashSet<Vector3i> _ready = new();
private readonly HashSet<Vector3i> _dirty = new();
private readonly PriorityQueue<Vector3i, int> _pendingLoad = new();
private readonly ConcurrentQueue<ChunkJobResult> _completed = new();
private readonly Queue<Vector3i> _pendingUnload = new();
private readonly HashSet<Vector3i> _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 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<Vector3i, VoxelChunk> generate,
IChunkMesher mesher,
BlockRegistry registry,
IJobExecutor executor,
Func<float[], IChunkRenderer> rendererFactory)
{
this._cfg = cfg;
this._world = world;
this._generate = generate;
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();
}
public void DrawAll(Action<Vector3i, IChunkRenderer> drawCallback)
{
foreach (var kvp in this._renderers) drawCallback(kvp.Key, kvp.Value);
}
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;
}
int h = this._cfg.RenderDistanceHorizontal;
int v = this._cfg.RenderDistanceVertical;
var target = new HashSet<Vector3i>();
for (int dx = -h; dx <= h; dx++)
for (int dy = -v; dy <= v; dy++)
for (int dz = -h; dz <= h; dz++)
{
int cy = p.Y + dy;
if (cy < 0 || cy >= WorldConstants.WorldHeightChunks) continue;
target.Add(new Vector3i(p.X + dx, cy, p.Z + dz));
}
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<Vector3i>();
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);
}
}
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;
int hs = this._cfg.SpawnReadyRadiusHorizontal;
int v = this._cfg.RenderDistanceVertical;
Vector3i a = this._spawnAnchor;
for (int dx = -hs; dx <= hs; dx++)
for (int dy = -v; dy <= v; dy++)
for (int dz = -hs; dz <= hs; dz++)
{
int cy = a.Y + dy;
if (cy < 0 || cy >= WorldConstants.WorldHeightChunks) continue;
if (!this._ready.Contains(new Vector3i(a.X + dx, cy, a.Z + dz))) return;
}
this._spawnReady = true;
Log.Information("Spawn area ready at anchor {Anchor}", a);
}
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 — DrawAll'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<int, ushort>? 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)));
}
}