576 lines
22 KiB
C#
576 lines
22 KiB
C#
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));
|
||
}
|
||
}
|