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

244 lines
8.5 KiB
C#
Raw 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 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 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
}
public class ChunkStreamerTests
{
private static (ChunkStreamer streamer, World.World world, List<FakeRenderer> created) CreateStreamer(Configuration cfg)
{
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();
var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory);
return (streamer, world, created);
}
[Fact]
public void Tick_FirstCall_LoadsAllChunksInLoadBox()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0));
// Load box H=1, V=0 → 3 × 1 × 3 = 9 chunks
Assert.Equal(9, created.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_LoadsNewChunksOnly()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // loads 9
int firstWave = created.Count;
streamer.Tick(new Vector3i(1, 0, 0)); // loads only the 3 new column to +x
Assert.Equal(firstWave + 3, created.Count);
}
[Fact]
public void Tick_RespectsMaxMeshUploadsPerFrame()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 1,
RenderDistanceVertical = 0,
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,
RenderDistanceVertical = 2,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
// Player at y=0 (chunk Y = 0). V=2 wants Y ∈ [-2, 2], but valid Y is [0, 8).
// So only Y ∈ {0, 1, 2} qualifies → 1 × 3 × 1 = 3 chunks.
streamer.Tick(new Vector3i(0, 0, 0));
Assert.Equal(3, created.Count);
Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0)));
Assert.NotNull(world.GetChunk(new Vector3i(0, 1, 0)));
Assert.NotNull(world.GetChunk(new Vector3i(0, 2, 0)));
}
[Fact]
public void Tick_AppliesModificationDiffOnLoad()
{
var cfg = new Configuration
{
RenderDistanceHorizontal = 0,
RenderDistanceVertical = 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, 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,
RenderDistanceVertical = 0,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 100,
};
var (streamer, world, created) = CreateStreamer(cfg);
streamer.Tick(new Vector3i(0, 0, 0)); // loads (0,0,0)
Assert.Single(created);
Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0)));
streamer.Tick(new Vector3i(5, 0, 0)); // (0,0,0) now out of range; (5,0,0) loaded
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,
RenderDistanceVertical = 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, 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,
RenderDistanceVertical = 0,
SpawnReadyRadiusHorizontal = 1,
MaxGenerateJobsInFlight = 100,
MaxMeshUploadsPerFrame = 1, // throttle to expose progression
};
var (streamer, _, _) = CreateStreamer(cfg);
Assert.False(streamer.SpawnReady);
// Tick once per chunk needed (3×3×1 = 9 chunks at this Y). Spawn area
// restricted by Y bounds (only Y=0 valid here) → 9 ticks.
for (int i = 0; i < 9; i++) streamer.Tick(new Vector3i(0, 0, 0));
Assert.True(streamer.SpawnReady);
}
}