Files
VoxelEngine/VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs

576 lines
22 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Linq;
using OpenTK.Mathematics;
using VoxelEngine.Core;
using VoxelEngine.World;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using VoxelEngine.World.Persistence;
using VoxelEngine.World.Streaming;
using VoxelEngine.World.WorldGen;
using Xunit;
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
namespace VoxelEngine.Tests.Streaming;
// Synchronous executor: runs the submitted action on the calling thread.
internal sealed class SynchronousJobExecutor : IJobExecutor
{
public void Submit(Action work) => work();
}
// Fake renderer: never touches GL. Just records lifecycle.
internal sealed class FakeRenderer : IChunkRenderer
{
public bool Disposed { get; private set; }
public int DrawCount { get; private set; }
public float[] Mesh { get; }
public FakeRenderer(float[] mesh) { this.Mesh = mesh; }
public void Draw() => this.DrawCount++;
public void Dispose() => this.Disposed = true;
}
// Minimal mesher returning a fixed-length non-empty mesh.
internal sealed class FakeMesher : IChunkMesher
{
public float[] Build(VoxelChunk chunk, BlockRegistry registry, Func<int, int, int, bool>? neighborOpaqueOutside = null)
=> new float[6]; // one vertex worth
}
// Mesher that returns an empty mesh, simulating a pure-air chunk.
internal sealed class EmptyMesher : IChunkMesher
{
public float[] Build(VoxelChunk chunk, BlockRegistry registry, Func<int, int, int, bool>? neighborOpaqueOutside = null)
=> Array.Empty<float>();
}
public class ChunkStreamerTests
{
private static (ChunkStreamer streamer, World.World world, List<FakeRenderer> created) CreateStreamer(
Configuration cfg, Func<int, int, int>? surfaceHeightAt = null)
{
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
var world = new World.World(registry, store);
var mesher = new FakeMesher();
var executor = new SynchronousJobExecutor();
var created = new List<FakeRenderer>();
Func<float[], IChunkRenderer> factory = m =>
{
var r = new FakeRenderer(m);
created.Add(r);
return r;
};
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
Func<int, int, int> height = surfaceHeightAt ?? ((_, _) => 8);
var streamer = new ChunkStreamer(cfg, world, generate, height, mesher, registry, executor, factory);
return (streamer, world, created);
}
// Ticks at a fixed player position until the streamer stops producing new
// renderers (queued loads + their one-shot in-column remesh cascade have all
// drained). Capped to avoid an infinite loop if something never settles.
private static void TickToQuiescence(ChunkStreamer streamer, List<FakeRenderer> created, Vector3i player)
{
int last = -1;
for (int i = 0; i < 200 && created.Count != last; i++)
{
last = created.Count;
streamer.Tick(player);
}
}
[Fact]
public void Tick_FirstCall_LoadsAllChunksInLoadBox()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0));
// H=1 → a 3×3 horizontal box of full columns. The default test surface
// (8) is below sea level, so each column loads bedrock→waterline; assert
// the box is covered at the player's chunk Y rather than a raw count.
for (int x = -1; x <= 1; x++)
for (int z = -1; z <= 1; z++)
{
var pos = new Vector3i(x, 0, z);
Assert.NotNull(world.GetChunk(pos));
Assert.True(streamer.IsReady(pos));
}
}
[Fact]
public void Tick_PlayerMoves_LoadsNewChunks()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // loads 9
// After the second tick the 3 new x=2 chunks must be ready.
// Cardinal-neighbor dirty-marking may also produce remesh renderers, so
// we assert on world state rather than an exact renderer count.
streamer.Tick(new Vector3i(1, 0, 0));
for (int z = -1; z <= 1; z++)
{
var newPos = new Vector3i(2, 0, z);
Assert.NotNull(world.GetChunk(newPos));
Assert.True(streamer.IsReady(newPos));
}
// x=-1 column must be unloaded
for (int z = -1; z <= 1; z++)
{
Assert.Null(world.GetChunk(new Vector3i(-1, 0, z)));
Assert.False(streamer.IsReady(new Vector3i(-1, 0, z)));
}
}
[Fact]
public void Tick_RespectsMaxMeshUploadsPerFrame()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 2, // cap drains per tick
};
var (streamer, _, created) = CreateStreamer(cfg);
// With sync executor every submitted job completes immediately and
// posts into _completed. The cap throttles how many drain per Tick().
streamer.Tick(new Vector3i(0, 0, 0)); // dispatch fills the queue; drain ≤ 2
Assert.Equal(2, created.Count);
streamer.Tick(new Vector3i(0, 0, 0)); // same player chunk, drains 2 more
Assert.Equal(4, created.Count);
}
[Fact]
public void Tick_ClampsYToWorldBounds()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
// Surface far above the world ceiling -> top chunk clamps to
// WorldHeightChunks-1 (31). Full column loads cy 0..31 and nothing above.
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 100_000);
streamer.Tick(new Vector3i(0, 0, 0));
for (int cy = 0; cy < WorldConstants.WorldHeightChunks; cy++)
{
var pos = new Vector3i(0, cy, 0);
Assert.NotNull(world.GetChunk(pos));
Assert.True(streamer.IsReady(pos));
}
}
[Fact]
public void Tick_AppliesModificationDiffOnLoad()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
var world = new World.World(registry, store);
var mesher = new FakeMesher();
var executor = new SynchronousJobExecutor();
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk(); // all air
// Pre-record an edit before the chunk loads.
var pos = new Vector3i(0, 0, 0);
int localIndex = (5 * VoxelChunk.Size + 6) * VoxelChunk.Size + 7;
store.RecordEdit(pos, localIndex, registry.Stone.Id);
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
streamer.Tick(pos);
Assert.Equal(registry.Stone.Id, world.GetBlock(5, 6, 7).Id);
}
[Fact]
public void Tick_PlayerMovesAway_UnloadsOldChunksAndDisposesRenderer()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // loads the (0,*,0) column
Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0)));
Assert.NotEmpty(created);
streamer.Tick(new Vector3i(5, 0, 0)); // (0,*,0) now out of range → unloaded
// The player chunk (priority 0) loaded first, so created[0] is its
// renderer; moving away must dispose it and drop the chunk.
Assert.True(created[0].Disposed);
Assert.Null(world.GetChunk(new Vector3i(0, 0, 0)));
Assert.False(streamer.IsReady(new Vector3i(0, 0, 0)));
}
[Fact]
public void Tick_UnloadKeepsModificationStoreDiff()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
var world = new World.World(registry, store);
var mesher = new FakeMesher();
var executor = new SynchronousJobExecutor();
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
var pos = new Vector3i(0, 0, 0);
streamer.Tick(pos);
store.RecordEdit(pos, 0, registry.Stone.Id);
streamer.Tick(new Vector3i(99, 0, 0)); // unloads (0,0,0)
Assert.True(store.HasDiff(pos)); // diff survives unload
}
[Fact]
public void SpawnReady_FlipsTrueOnceSpawnAreaLoaded()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
SpawnReadyRadiusHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, _) = CreateStreamer(cfg);
Assert.False(streamer.SpawnReady);
// The spawn area is now full columns (bedrock→waterline) per (x,z); tick
// until it finishes loading. Cap generously to avoid an infinite loop.
for (int i = 0; i < 200 && !streamer.SpawnReady; i++)
streamer.Tick(new Vector3i(0, 0, 0));
Assert.True(streamer.SpawnReady);
}
[Fact]
public void MarkDirty_RemeshesChunkOnNextTick()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, created) = CreateStreamer(cfg);
// The default test surface (8) is below sea level, so (0,*,0) is a full
// bedrock→waterline column. Loads mark in-column vertical neighbours
// dirty, so tick to quiescence (loads + the one-shot remesh cascade)
// before measuring. Remeshes do NOT re-dirty neighbours, so it settles.
TickToQuiescence(streamer, created, new Vector3i(0, 0, 0));
int afterLoad = created.Count;
int liveBefore = streamer.RendererCount;
// Capture the live renderer currently installed for (0,0,0).
IChunkRenderer? initialRenderer = null;
streamer.DrawVisible(
pos => pos == new Vector3i(0, 0, 0),
(_, r) => initialRenderer = r);
Assert.NotNull(initialRenderer);
streamer.MarkDirty(new Vector3i(0, 0, 0));
streamer.Tick(new Vector3i(0, 0, 0)); // exactly one remesh queued (no cascade)
// Exactly one extra renderer (the remesh) — old disposed, new live, live
// count unchanged.
Assert.Equal(afterLoad + 1, created.Count);
Assert.Equal(liveBefore, streamer.RendererCount);
Assert.True(((FakeRenderer)initialRenderer!).Disposed);
Assert.False(created[^1].Disposed);
}
[Fact]
public void Tick_LoadingChunk_MarksReadyCardinalNeighborsDirty()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // load initial 3×3
int firstWave = created.Count; // 9 chunks
// Move so we load a new x=2 column. The new chunks become ready and
// mark their cardinal neighbors (x=1 column) dirty during Drain.
streamer.Tick(new Vector3i(1, 0, 0)); // dispatch + drain new loads → dirty marks set
streamer.Tick(new Vector3i(1, 0, 0)); // dispatch the queued remeshes → drain them
// Should have: 3 new loads + at least 3 remeshes of the x=1 column.
Assert.True(created.Count > firstWave + 3,
$"Expected remeshes beyond the 3 new loads; got {created.Count - firstWave}");
}
[Fact]
public void MarkDirty_OnUntrackedChunk_IsNoOp()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, created) = CreateStreamer(cfg);
// Settle the full submerged column (loads + their one-shot remesh
// cascade) so the renderer count is stable before the no-op check.
TickToQuiescence(streamer, created, new Vector3i(0, 0, 0));
int afterLoad = created.Count;
streamer.MarkDirty(new Vector3i(99, 0, 99)); // not tracked
streamer.Tick(new Vector3i(0, 0, 0)); // no remesh should be queued
Assert.Equal(afterLoad, created.Count);
}
[Fact]
public void Tick_GeneratorThrows_LogsAndDoesNotRetry()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
var world = new World.World(registry, store);
var mesher = new FakeMesher();
var executor = new SynchronousJobExecutor();
int generateCalls = 0;
Func<Vector3i, VoxelChunk> generate = _ =>
{
generateCalls++;
throw new InvalidOperationException("boom");
};
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
streamer.Tick(new Vector3i(0, 0, 0));
int callsAfterFirst = generateCalls;
Assert.True(callsAfterFirst > 0);
Assert.False(streamer.IsReady(new Vector3i(0, 0, 0)));
// Tick again — same position. Failed chunks must not be retried.
streamer.Tick(new Vector3i(0, 0, 0));
Assert.Equal(callsAfterFirst, generateCalls);
}
[Fact]
public void InstallResult_EmptyMesh_SkipsRendererCreationButMarksReady()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
var world = new World.World(registry, store);
var mesher = new EmptyMesher();
var executor = new SynchronousJobExecutor();
int rendererFactoryCalls = 0;
Func<float[], IChunkRenderer> factory = m =>
{
rendererFactoryCalls++;
return new FakeRenderer(m);
};
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
var pos = new Vector3i(0, 0, 0);
streamer.Tick(pos);
Assert.True(streamer.IsReady(pos));
Assert.Equal(0, rendererFactoryCalls);
Assert.Equal(0, streamer.RendererCount);
}
[Fact]
public void Tick_VerticalPriorityWeight_PrefersHorizontalAtSameDistance()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 5, // player's chunk + 4 horizontal cardinals
VerticalPriorityWeight = 4,
};
// Surface at worldY 48 -> top chunk 3, so a full column is cy 0..3 and
// the player at Y=2 has in-column vertical neighbours at Y=1 and Y=3.
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 48);
var playerPos = new Vector3i(0, 2, 0);
streamer.Tick(playerPos);
// Drain budget = 5 = priority 0 (player) + 4 priority-1 (horizontal
// cardinals). With VerticalPriorityWeight=4, vertical cardinals
// (Y=1, Y=3) have priority 4 -- they must NOT be among the first 5.
Assert.NotNull(world.GetChunk(playerPos));
Assert.NotNull(world.GetChunk(new Vector3i( 1, 2, 0)));
Assert.NotNull(world.GetChunk(new Vector3i(-1, 2, 0)));
Assert.NotNull(world.GetChunk(new Vector3i( 0, 2, 1)));
Assert.NotNull(world.GetChunk(new Vector3i( 0, 2, -1)));
Assert.Null(world.GetChunk(new Vector3i(0, 3, 0)));
Assert.Null(world.GetChunk(new Vector3i(0, 1, 0)));
}
[Fact]
public void Tick_LoadsFullColumn_FromBedrockToSurface()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0, // single column at the player
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
// Land surface at worldY 296 -> top chunk floor(296/16) = 18 (above the
// sea-level chunk 16, so no water raises it). Full column loads cy 0..18;
// chunk Y 19+ is air above the surface and must NOT load.
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 296);
streamer.Tick(new Vector3i(0, 0, 0));
Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); // bedrock
Assert.NotNull(world.GetChunk(new Vector3i(0, 9, 0))); // mid
Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface
Assert.Null(world.GetChunk(new Vector3i(0, 19, 0))); // air above surface
Assert.Null(world.GetChunk(new Vector3i(0, 25, 0))); // air above surface
}
[Fact]
public void Tick_LoadsWaterSurface_OverSubmergedColumn()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0, // single column at the player
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
// Terrain surface at worldY 100 (chunk 6) but well below SeaLevel 256.
// Opaque water fills worldY 101..256, so the column must load through the
// sea-level chunk floor(256/16)=16, not stop at the terrain top (chunk 6).
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 100);
streamer.Tick(new Vector3i(0, 0, 0));
Assert.NotNull(world.GetChunk(new Vector3i(0, 6, 0))); // terrain top
Assert.NotNull(world.GetChunk(new Vector3i(0, 10, 0))); // submerged water column
Assert.NotNull(world.GetChunk(new Vector3i(0, 16, 0))); // chunk holding the waterline (y=256)
Assert.Null(world.GetChunk(new Vector3i(0, 17, 0))); // above the waterline -- not loaded
}
[Fact]
public void Tick_LoadsEditedSectionAboveSurface()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var registry = new BlockRegistry();
var store = new InMemoryChunkModificationStore();
var world = new World.World(registry, store);
var mesher = new FakeMesher();
var executor = new SynchronousJobExecutor();
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
// Land surface at worldY 296 -> top chunk 18 (above the sea-level chunk
// 16, so no water raises it). A player edit sits in chunk Y 25, above the
// loaded column; that edited section must still load.
var edited = new Vector3i(0, 25, 0);
store.RecordEdit(edited, 0, registry.Stone.Id);
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 296, mesher, registry, executor, factory);
streamer.Tick(new Vector3i(0, 0, 0));
Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface column top
Assert.NotNull(world.GetChunk(edited)); // edited air section above surface
Assert.Null(world.GetChunk(new Vector3i(0, 20, 0))); // unedited air -- not loaded
}
[Fact]
public void RecomputeTargetSet_CachesSurfaceHeights_OnlySamplesNewColumns()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
SpawnReadyRadiusHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
int calls = 0;
var (streamer, _, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => { calls++; return 8; });
streamer.Tick(new Vector3i(0, 0, 0));
int afterFirst = calls; // 9 columns × 5 samples = 45 (spawn-check reuses cache)
streamer.Tick(new Vector3i(1, 0, 0)); // overlaps 6 columns; only the cx=2 strip is new
int newCalls = calls - afterFirst;
Assert.Equal(45, afterFirst);
Assert.Equal(15, newCalls); // 3 new columns × 5 — cached columns are not re-sampled
}
[Fact]
public void DrawVisible_DrawsOnlyChunksPassingPredicate_AndReturnsCount()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, _, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // 9 columns, all chunk Y 0, non-empty meshes
int drawn = streamer.DrawVisible(
pos => pos == new Vector3i(0, 0, 0), // only the centre chunk "visible"
(_, r) => r.Draw());
Assert.Equal(1, drawn);
Assert.Equal(1, created.Sum(r => r.DrawCount));
}
}