Compare commits
19 Commits
c1be0f75e5
...
4fe552b8bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe552b8bd | |||
| 8423b73dfd | |||
| 34f923cbca | |||
| 6b148362c0 | |||
| 59466fb291 | |||
| 4467a7bd10 | |||
| 85a345d6e7 | |||
| da051ed413 | |||
| 7d06706400 | |||
| c1f0928344 | |||
| dc74fdadcc | |||
| 39375b83ea | |||
| 2afeee50d5 | |||
| 6a68953c9a | |||
| 7c310ebc6a | |||
| 6f6fd7ecc5 | |||
| 67bff6031f | |||
| 53d24f29ab | |||
| 1339bb45ec |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,9 @@ project.fragment.lock.json
|
|||||||
# Local design docs — kept out of the repo by request
|
# Local design docs — kept out of the repo by request
|
||||||
docs/
|
docs/
|
||||||
|
|
||||||
|
# Runtime logs
|
||||||
|
logs/
|
||||||
|
|
||||||
# OS noise
|
# OS noise
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|||||||
3
Assets/shaders/loading.frag
Normal file
3
Assets/shaders/loading.frag
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#version 330 core
|
||||||
|
out vec4 FragColor;
|
||||||
|
void main() { FragColor = vec4(0.10, 0.12, 0.16, 1.0); }
|
||||||
3
Assets/shaders/loading.vert
Normal file
3
Assets/shaders/loading.vert
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#version 330 core
|
||||||
|
layout(location = 0) in vec2 aPos;
|
||||||
|
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
|
||||||
13
Core/ChunkMath.cs
Normal file
13
Core/ChunkMath.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace VoxelEngine.Core;
|
||||||
|
|
||||||
|
internal static class ChunkMath
|
||||||
|
{
|
||||||
|
// Floor division: floors toward negative infinity (not toward zero like C#'s /).
|
||||||
|
// Required for correct world-coord -> chunk-index mapping for negative coordinates.
|
||||||
|
internal static int FloorDiv(int value, int size)
|
||||||
|
{
|
||||||
|
int q = value / size;
|
||||||
|
if (value % size != 0 && value < 0) q--;
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Core/Configuration.cs
Normal file
21
Core/Configuration.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace VoxelEngine.Core;
|
||||||
|
|
||||||
|
public sealed class Configuration
|
||||||
|
{
|
||||||
|
public int RenderDistanceHorizontal { get; set; } = 8;
|
||||||
|
public int RenderDistanceVertical { get; set; } = 2;
|
||||||
|
public int SpawnReadyRadiusHorizontal { get; set; } = 1;
|
||||||
|
|
||||||
|
public int MaxGenerateJobsInFlight { get; set; } = 8;
|
||||||
|
public int MaxMeshUploadsPerFrame { get; set; } = 4;
|
||||||
|
public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2);
|
||||||
|
|
||||||
|
public float MoveSpeed { get; set; } = 8f;
|
||||||
|
public float MouseSensitivity { get; set; } = 0.15f;
|
||||||
|
public float Fov { get; set; } = 60f;
|
||||||
|
|
||||||
|
public int WindowWidth { get; set; } = 1280;
|
||||||
|
public int WindowHeight { get; set; } = 720;
|
||||||
|
|
||||||
|
public int? Seed { get; set; } = null;
|
||||||
|
}
|
||||||
140
Core/Game.cs
140
Core/Game.cs
@@ -3,75 +3,75 @@ using OpenTK.Mathematics;
|
|||||||
using OpenTK.Windowing.Common;
|
using OpenTK.Windowing.Common;
|
||||||
using OpenTK.Windowing.Desktop;
|
using OpenTK.Windowing.Desktop;
|
||||||
using OpenTK.Windowing.GraphicsLibraryFramework;
|
using OpenTK.Windowing.GraphicsLibraryFramework;
|
||||||
|
using Serilog;
|
||||||
|
using VoxelEngine.Game;
|
||||||
using VoxelEngine.Rendering;
|
using VoxelEngine.Rendering;
|
||||||
using VoxelEngine.World.Blocks;
|
using VoxelEngine.World.Blocks;
|
||||||
using VoxelEngine.World.Chunk;
|
using VoxelEngine.World.Chunk;
|
||||||
|
using VoxelEngine.World.Persistence;
|
||||||
|
using VoxelEngine.World.Streaming;
|
||||||
using VoxelEngine.World.WorldGen;
|
using VoxelEngine.World.WorldGen;
|
||||||
|
|
||||||
namespace VoxelEngine.Core;
|
namespace VoxelEngine.Core;
|
||||||
|
|
||||||
public sealed class Game : GameWindow
|
public sealed class Game : GameWindow
|
||||||
{
|
{
|
||||||
private const float MoveSpeed = 8f; // units per second
|
private static readonly ILogger Log = Serilog.Log.ForContext<Game>();
|
||||||
private const float Sensitivity = 0.15f; // degrees per pixel
|
|
||||||
private const int Seed = 1337;
|
|
||||||
private const bool UseSeed = false;
|
|
||||||
|
|
||||||
|
private readonly Configuration _cfg;
|
||||||
private readonly BlockRegistry _registry = new();
|
private readonly BlockRegistry _registry = new();
|
||||||
|
|
||||||
private Shader _shader = null!;
|
private Shader _shader = null!;
|
||||||
private Texture _texture = null!;
|
private Texture _texture = null!;
|
||||||
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
|
private LoadingScreen _loading = null!;
|
||||||
private Camera _camera = null!;
|
private World.World _world = null!;
|
||||||
|
private InMemoryChunkModificationStore _modStore = null!;
|
||||||
private WorldGenerator _generator = null!;
|
private WorldGenerator _generator = null!;
|
||||||
|
private ChunkStreamer _streamer = null!;
|
||||||
|
private Player _player = null!;
|
||||||
|
|
||||||
private World.World _world;
|
public Game(Configuration cfg, GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
|
||||||
|
: base(gameSettings, nativeSettings)
|
||||||
public Game(GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
|
{
|
||||||
: base(gameSettings, nativeSettings) { }
|
this._cfg = cfg;
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnLoad()
|
protected override void OnLoad()
|
||||||
{
|
{
|
||||||
base.OnLoad();
|
base.OnLoad();
|
||||||
|
|
||||||
|
Log.Information("Game starting with seed={Seed} renderDistanceH={H} renderDistanceV={V}",
|
||||||
|
this._cfg.Seed, this._cfg.RenderDistanceHorizontal, this._cfg.RenderDistanceVertical);
|
||||||
|
|
||||||
GL.ClearColor(0.45f, 0.65f, 0.95f, 1.0f);
|
GL.ClearColor(0.45f, 0.65f, 0.95f, 1.0f);
|
||||||
GL.Enable(EnableCap.DepthTest);
|
GL.Enable(EnableCap.DepthTest);
|
||||||
GL.Enable(EnableCap.CullFace);
|
GL.Enable(EnableCap.CullFace);
|
||||||
|
|
||||||
this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag");
|
this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag");
|
||||||
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png");
|
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png");
|
||||||
|
this._loading = new LoadingScreen();
|
||||||
|
|
||||||
this._world = new World.World(this._registry);
|
this._modStore = new InMemoryChunkModificationStore();
|
||||||
|
this._world = new World.World(this._registry, this._modStore);
|
||||||
|
|
||||||
int seed = Game.UseSeed ? Game.Seed : new Random().Next();
|
int seed = this._cfg.Seed ?? new Random().Next();
|
||||||
|
|
||||||
this._generator = new WorldGenerator(seed, this._registry);
|
this._generator = new WorldGenerator(seed, this._registry);
|
||||||
|
|
||||||
for (int cx = -4; cx < 4; cx++)
|
// Spawn at terrain surface above (0, 0).
|
||||||
for (int cz = -4; cz < 4; cz++)
|
int surfaceY = this._generator.SurfaceHeightAt(0, 0);
|
||||||
for (int cy = 0; cy < WorldConstants.WorldHeightChunks; cy++)
|
Vector3 spawnPosition = new(0.5f, surfaceY + 2f, 0.5f);
|
||||||
{
|
|
||||||
Vector3i pos = new(cx, cy, cz);
|
|
||||||
this._world.SetChunk(pos, this._generator.Generate(pos));
|
|
||||||
}
|
|
||||||
|
|
||||||
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
|
this._player = new Player(this._cfg, spawnPosition, this.ClientSize.X / (float)this.ClientSize.Y);
|
||||||
foreach ((Vector3i pos, Chunk chunk) in this._world)
|
|
||||||
{
|
|
||||||
Vector3i origin = pos * Chunk.Size;
|
|
||||||
float[] mesh = mesher.Build(
|
|
||||||
chunk,
|
|
||||||
this._registry,
|
|
||||||
(nx, ny, nz) => this._world.GetBlock(
|
|
||||||
origin.X + nx,
|
|
||||||
origin.Y + ny,
|
|
||||||
origin.Z + nz).IsOpaque
|
|
||||||
);
|
|
||||||
this._renderer[pos] = new ChunkRenderer(mesh);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._camera = new Camera(
|
var mesher = new ChunkMesher(this._texture.Width, this._texture.Height);
|
||||||
new Vector3(8f, WorldConstants.SeaLevel + 30f, 40f),
|
this._streamer = new ChunkStreamer(
|
||||||
this.ClientSize.X / (float)this.ClientSize.Y);
|
this._cfg,
|
||||||
|
this._world,
|
||||||
|
this._generator.Generate,
|
||||||
|
mesher,
|
||||||
|
this._registry,
|
||||||
|
new ThreadPoolJobExecutor(),
|
||||||
|
mesh => new ChunkRenderer(mesh));
|
||||||
|
|
||||||
this.CursorState = CursorState.Grabbed;
|
this.CursorState = CursorState.Grabbed;
|
||||||
}
|
}
|
||||||
@@ -81,24 +81,22 @@ public sealed class Game : GameWindow
|
|||||||
base.OnUpdateFrame(args);
|
base.OnUpdateFrame(args);
|
||||||
if (!this.IsFocused) return;
|
if (!this.IsFocused) return;
|
||||||
|
|
||||||
if (this.KeyboardState.IsKeyDown(Keys.Escape)) {
|
if (this.KeyboardState.IsKeyDown(Keys.Escape)) { this.Close(); return; }
|
||||||
this.Close(); return;
|
|
||||||
}
|
|
||||||
|
|
||||||
float speedMulti = 1f;
|
float speedMulti = this.KeyboardState.IsKeyDown(Keys.LeftShift) ? 1.8f : 1f;
|
||||||
|
float distance = this._player.MoveSpeed * speedMulti * (float)args.Time;
|
||||||
if (this.KeyboardState.IsKeyDown(Keys.LeftShift)) speedMulti = 1.8f ;
|
|
||||||
|
|
||||||
float distance = Game.MoveSpeed * speedMulti * (float)args.Time;
|
if (this.KeyboardState.IsKeyDown(Keys.W)) this._player.Move(this._player.Camera.Front * distance);
|
||||||
if (this.KeyboardState.IsKeyDown(Keys.W)) this._camera.Move(this._camera.Front * distance);
|
if (this.KeyboardState.IsKeyDown(Keys.S)) this._player.Move(-this._player.Camera.Front * distance);
|
||||||
if (this.KeyboardState.IsKeyDown(Keys.S)) this._camera.Move(-this._camera.Front * distance);
|
if (this.KeyboardState.IsKeyDown(Keys.A)) this._player.Move(-this._player.Camera.Right * distance);
|
||||||
if (this.KeyboardState.IsKeyDown(Keys.A)) this._camera.Move(-this._camera.Right * distance);
|
if (this.KeyboardState.IsKeyDown(Keys.D)) this._player.Move(this._player.Camera.Right * distance);
|
||||||
if (this.KeyboardState.IsKeyDown(Keys.D)) this._camera.Move(this._camera.Right * distance);
|
if (this.KeyboardState.IsKeyDown(Keys.Space)) this._player.Move(Vector3.UnitY * distance);
|
||||||
if (this.KeyboardState.IsKeyDown(Keys.Space)) this._camera.Move(Vector3.UnitY * distance);
|
if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._player.Move(-Vector3.UnitY * distance);
|
||||||
if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._camera.Move(-Vector3.UnitY * distance);
|
|
||||||
|
|
||||||
Vector2 delta = this.MouseState.Delta;
|
Vector2 delta = this.MouseState.Delta;
|
||||||
this._camera.AddRotation(delta.X * Game.Sensitivity, -delta.Y * Game.Sensitivity);
|
this._player.AddRotation(delta.X * this._player.MouseSensitivity, -delta.Y * this._player.MouseSensitivity);
|
||||||
|
|
||||||
|
this._streamer.Tick(this._player.ChunkPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnRenderFrame(FrameEventArgs args)
|
protected override void OnRenderFrame(FrameEventArgs args)
|
||||||
@@ -107,21 +105,25 @@ public sealed class Game : GameWindow
|
|||||||
|
|
||||||
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||||
|
|
||||||
this._shader.Use();
|
if (!this._streamer.SpawnReady)
|
||||||
this._shader.SetMatrix4("view", this._camera.GetViewMatrix());
|
{
|
||||||
this._shader.SetMatrix4("projection", this._camera.GetProjectionMatrix());
|
this._loading.Draw();
|
||||||
this._shader.SetInt("atlas", 0);
|
this.SwapBuffers();
|
||||||
this._texture.Use();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ((Vector3i pos, ChunkRenderer renderer) in this._renderer)
|
this._shader.Use();
|
||||||
|
this._shader.SetMatrix4("view", this._player.Camera.GetViewMatrix());
|
||||||
|
this._shader.SetMatrix4("projection", this._player.Camera.GetProjectionMatrix());
|
||||||
|
this._shader.SetInt("atlas", 0);
|
||||||
|
this._texture.Use(TextureUnit.Texture0);
|
||||||
|
|
||||||
|
this._streamer.DrawAll((pos, renderer) =>
|
||||||
{
|
{
|
||||||
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
|
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
|
||||||
pos.X * Chunk.Size,
|
pos.X * Chunk.Size, pos.Y * Chunk.Size, pos.Z * Chunk.Size));
|
||||||
pos.Y * Chunk.Size,
|
|
||||||
pos.Z * Chunk.Size
|
|
||||||
));
|
|
||||||
renderer.Draw();
|
renderer.Draw();
|
||||||
}
|
});
|
||||||
|
|
||||||
this.SwapBuffers();
|
this.SwapBuffers();
|
||||||
}
|
}
|
||||||
@@ -130,18 +132,16 @@ public sealed class Game : GameWindow
|
|||||||
{
|
{
|
||||||
base.OnResize(e);
|
base.OnResize(e);
|
||||||
GL.Viewport(0, 0, e.Width, e.Height);
|
GL.Viewport(0, 0, e.Width, e.Height);
|
||||||
// OnResize can fire before OnLoad (where _camera is created), so guard against it.
|
if (this._player is not null) this._player.Camera.AspectRatio = e.Width / (float)e.Height;
|
||||||
if (this._camera is not null) this._camera.AspectRatio = e.Width / (float)e.Height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnUnload()
|
protected override void OnUnload()
|
||||||
{
|
{
|
||||||
foreach (ChunkRenderer rendererValue in this._renderer.Values)
|
Log.Information("Game shutting down");
|
||||||
{
|
this._streamer?.Dispose();
|
||||||
rendererValue.Dispose();
|
this._loading?.Dispose();
|
||||||
}
|
this._texture?.Dispose();
|
||||||
this._texture.Dispose();
|
this._shader?.Dispose();
|
||||||
this._shader.Dispose();
|
|
||||||
base.OnUnload();
|
base.OnUnload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
Game/Player.cs
Normal file
38
Game/Player.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using OpenTK.Mathematics;
|
||||||
|
using VoxelEngine.Core;
|
||||||
|
using VoxelEngine.Rendering;
|
||||||
|
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||||
|
|
||||||
|
namespace VoxelEngine.Game;
|
||||||
|
|
||||||
|
public sealed class Player
|
||||||
|
{
|
||||||
|
private readonly Configuration _cfg;
|
||||||
|
|
||||||
|
public Camera Camera { get; }
|
||||||
|
public Vector3 Position => this.Camera.Position;
|
||||||
|
public float MoveSpeed => this._cfg.MoveSpeed;
|
||||||
|
public float MouseSensitivity => this._cfg.MouseSensitivity;
|
||||||
|
|
||||||
|
public Vector3i ChunkPosition
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
Vector3 p = this.Position;
|
||||||
|
return new Vector3i(
|
||||||
|
ChunkMath.FloorDiv((int)MathF.Floor(p.X), VoxelChunk.Size),
|
||||||
|
ChunkMath.FloorDiv((int)MathF.Floor(p.Y), VoxelChunk.Size),
|
||||||
|
ChunkMath.FloorDiv((int)MathF.Floor(p.Z), VoxelChunk.Size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Player(Configuration cfg, Vector3 spawnPosition, float aspectRatio)
|
||||||
|
{
|
||||||
|
this._cfg = cfg;
|
||||||
|
this.Camera = new Camera(spawnPosition, aspectRatio) { Fov = cfg.Fov };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Move(Vector3 delta) => this.Camera.Move(delta);
|
||||||
|
|
||||||
|
public void AddRotation(float yawDelta, float pitchDelta) => this.Camera.AddRotation(yawDelta, pitchDelta);
|
||||||
|
}
|
||||||
49
Program.cs
49
Program.cs
@@ -1,16 +1,45 @@
|
|||||||
using OpenTK.Mathematics;
|
using OpenTK.Mathematics;
|
||||||
using OpenTK.Windowing.Common;
|
using OpenTK.Windowing.Common;
|
||||||
using OpenTK.Windowing.Desktop;
|
using OpenTK.Windowing.Desktop;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
using VoxelEngine.Core;
|
using VoxelEngine.Core;
|
||||||
|
|
||||||
NativeWindowSettings nativeSettings = new()
|
Log.Logger = new LoggerConfiguration()
|
||||||
{
|
.MinimumLevel.Debug()
|
||||||
ClientSize = new Vector2i(1280, 720),
|
.Enrich.FromLogContext()
|
||||||
Title = "VoxelEngine",
|
.WriteTo.Console(
|
||||||
APIVersion = new Version(3, 3),
|
restrictedToMinimumLevel: LogEventLevel.Information,
|
||||||
Profile = ContextProfile.Core,
|
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
|
||||||
Flags = ContextFlags.ForwardCompatible
|
.WriteTo.File(
|
||||||
};
|
path: "logs/voxelengine-.log",
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 14,
|
||||||
|
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
using Game game = new(GameWindowSettings.Default, nativeSettings);
|
try
|
||||||
game.Run();
|
{
|
||||||
|
var cfg = new Configuration();
|
||||||
|
|
||||||
|
NativeWindowSettings nativeSettings = new()
|
||||||
|
{
|
||||||
|
ClientSize = new Vector2i(cfg.WindowWidth, cfg.WindowHeight),
|
||||||
|
Title = "VoxelEngine",
|
||||||
|
APIVersion = new Version(3, 3),
|
||||||
|
Profile = ContextProfile.Core,
|
||||||
|
Flags = ContextFlags.ForwardCompatible
|
||||||
|
};
|
||||||
|
|
||||||
|
using Game game = new(cfg, GameWindowSettings.Default, nativeSettings);
|
||||||
|
game.Run();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "Unhandled exception at top level");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using OpenTK.Graphics.OpenGL4;
|
|||||||
|
|
||||||
namespace VoxelEngine.Rendering;
|
namespace VoxelEngine.Rendering;
|
||||||
|
|
||||||
public sealed class ChunkRenderer : IDisposable
|
public sealed class ChunkRenderer : VoxelEngine.World.Streaming.IChunkRenderer
|
||||||
{
|
{
|
||||||
private const int FloatsPerVertex = 6; // pos.xyz, uv, shade
|
private const int FloatsPerVertex = 6; // pos.xyz, uv, shade
|
||||||
|
|
||||||
|
|||||||
52
Rendering/LoadingScreen.cs
Normal file
52
Rendering/LoadingScreen.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using OpenTK.Graphics.OpenGL4;
|
||||||
|
|
||||||
|
namespace VoxelEngine.Rendering;
|
||||||
|
|
||||||
|
public sealed class LoadingScreen : IDisposable
|
||||||
|
{
|
||||||
|
private readonly int _vao;
|
||||||
|
private readonly int _vbo;
|
||||||
|
private readonly Shader _shader;
|
||||||
|
|
||||||
|
public LoadingScreen()
|
||||||
|
{
|
||||||
|
this._shader = new Shader("Assets/shaders/loading.vert", "Assets/shaders/loading.frag");
|
||||||
|
|
||||||
|
// Single triangle covering the entire NDC quad.
|
||||||
|
float[] vertices =
|
||||||
|
{
|
||||||
|
-1f, -1f,
|
||||||
|
3f, -1f,
|
||||||
|
-1f, 3f,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._vao = GL.GenVertexArray();
|
||||||
|
GL.BindVertexArray(this._vao);
|
||||||
|
|
||||||
|
this._vbo = GL.GenBuffer();
|
||||||
|
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
|
||||||
|
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.StaticDraw);
|
||||||
|
|
||||||
|
GL.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), 0);
|
||||||
|
GL.EnableVertexAttribArray(0);
|
||||||
|
|
||||||
|
GL.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw()
|
||||||
|
{
|
||||||
|
GL.Disable(EnableCap.DepthTest);
|
||||||
|
this._shader.Use();
|
||||||
|
GL.BindVertexArray(this._vao);
|
||||||
|
GL.DrawArrays(PrimitiveType.Triangles, 0, 3);
|
||||||
|
GL.BindVertexArray(0);
|
||||||
|
GL.Enable(EnableCap.DepthTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
GL.DeleteBuffer(this._vbo);
|
||||||
|
GL.DeleteVertexArray(this._vao);
|
||||||
|
this._shader.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,11 +38,9 @@ public sealed class Shader : IDisposable
|
|||||||
|
|
||||||
private int Location(string name)
|
private int Location(string name)
|
||||||
{
|
{
|
||||||
if (!this._uniforms.TryGetValue(name, out int location))
|
if (this._uniforms.TryGetValue(name, out int location)) return location;
|
||||||
{
|
location = GL.GetUniformLocation(this._handle, name);
|
||||||
location = GL.GetUniformLocation(this._handle, name);
|
this._uniforms[name] = location;
|
||||||
this._uniforms[name] = location;
|
|
||||||
}
|
|
||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
VoxelEngine.Tests/ConfigurationTests.cs
Normal file
57
VoxelEngine.Tests/ConfigurationTests.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using VoxelEngine.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VoxelEngine.Tests;
|
||||||
|
|
||||||
|
public class ConfigurationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_AreSane()
|
||||||
|
{
|
||||||
|
var c = new Configuration();
|
||||||
|
Assert.True(c.RenderDistanceHorizontal >= 1);
|
||||||
|
Assert.True(c.RenderDistanceVertical >= 1);
|
||||||
|
Assert.True(c.SpawnReadyRadiusHorizontal >= 0);
|
||||||
|
Assert.True(c.MaxGenerateJobsInFlight >= 1);
|
||||||
|
Assert.True(c.MaxMeshUploadsPerFrame >= 1);
|
||||||
|
Assert.True(c.WorkerThreadCount >= 1);
|
||||||
|
Assert.True(c.MoveSpeed > 0f);
|
||||||
|
Assert.True(c.MouseSensitivity > 0f);
|
||||||
|
Assert.InRange(c.Fov, 1f, 179f);
|
||||||
|
Assert.True(c.WindowWidth > 0);
|
||||||
|
Assert.True(c.WindowHeight > 0);
|
||||||
|
Assert.Null(c.Seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Properties_AreMutable()
|
||||||
|
{
|
||||||
|
var c = new Configuration { RenderDistanceHorizontal = 4, Seed = 1337 };
|
||||||
|
Assert.Equal(4, c.RenderDistanceHorizontal);
|
||||||
|
Assert.Equal(1337, c.Seed);
|
||||||
|
|
||||||
|
var c2 = new Configuration
|
||||||
|
{
|
||||||
|
RenderDistanceVertical = 2,
|
||||||
|
SpawnReadyRadiusHorizontal = 0,
|
||||||
|
MaxGenerateJobsInFlight = 2,
|
||||||
|
MaxMeshUploadsPerFrame = 1,
|
||||||
|
WorkerThreadCount = 2,
|
||||||
|
MoveSpeed = 4f,
|
||||||
|
MouseSensitivity = 0.5f,
|
||||||
|
Fov = 90f,
|
||||||
|
WindowWidth = 800,
|
||||||
|
WindowHeight = 600,
|
||||||
|
};
|
||||||
|
Assert.Equal(2, c2.RenderDistanceVertical);
|
||||||
|
Assert.Equal(0, c2.SpawnReadyRadiusHorizontal);
|
||||||
|
Assert.Equal(2, c2.MaxGenerateJobsInFlight);
|
||||||
|
Assert.Equal(1, c2.MaxMeshUploadsPerFrame);
|
||||||
|
Assert.Equal(2, c2.WorkerThreadCount);
|
||||||
|
Assert.Equal(4f, c2.MoveSpeed);
|
||||||
|
Assert.Equal(0.5f, c2.MouseSensitivity);
|
||||||
|
Assert.Equal(90f, c2.Fov);
|
||||||
|
Assert.Equal(800, c2.WindowWidth);
|
||||||
|
Assert.Equal(600, c2.WindowHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using OpenTK.Mathematics;
|
||||||
|
using VoxelEngine.World.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VoxelEngine.Tests.Persistence;
|
||||||
|
|
||||||
|
public class InMemoryChunkModificationStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NewStore_HasNoDiffs()
|
||||||
|
{
|
||||||
|
var s = new InMemoryChunkModificationStore();
|
||||||
|
Assert.False(s.HasDiff(new Vector3i(0, 0, 0)));
|
||||||
|
Assert.False(s.TryGetDiff(new Vector3i(0, 0, 0), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordEdit_StoresAndIsRetrievable()
|
||||||
|
{
|
||||||
|
var s = new InMemoryChunkModificationStore();
|
||||||
|
Vector3i pos = new(1, 2, 3);
|
||||||
|
s.RecordEdit(pos, 42, 7);
|
||||||
|
|
||||||
|
Assert.True(s.HasDiff(pos));
|
||||||
|
Assert.True(s.TryGetDiff(pos, out var diff));
|
||||||
|
Assert.Single(diff!);
|
||||||
|
Assert.Equal((ushort)7, diff![42]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordEdit_OverwritesSameLocalIndex()
|
||||||
|
{
|
||||||
|
var s = new InMemoryChunkModificationStore();
|
||||||
|
Vector3i pos = new(0, 0, 0);
|
||||||
|
s.RecordEdit(pos, 10, 1);
|
||||||
|
s.RecordEdit(pos, 10, 2);
|
||||||
|
|
||||||
|
Assert.True(s.TryGetDiff(pos, out var diff));
|
||||||
|
Assert.Single(diff!);
|
||||||
|
Assert.Equal((ushort)2, diff![10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordEdit_MultipleChunks_AreIndependent()
|
||||||
|
{
|
||||||
|
var s = new InMemoryChunkModificationStore();
|
||||||
|
s.RecordEdit(new Vector3i(0, 0, 0), 0, 1);
|
||||||
|
s.RecordEdit(new Vector3i(1, 0, 0), 0, 2);
|
||||||
|
|
||||||
|
Assert.True(s.TryGetDiff(new Vector3i(0, 0, 0), out var a));
|
||||||
|
Assert.True(s.TryGetDiff(new Vector3i(1, 0, 0), out var b));
|
||||||
|
Assert.Equal((ushort)1, a![0]);
|
||||||
|
Assert.Equal((ushort)2, b![0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearDiff_RemovesChunk()
|
||||||
|
{
|
||||||
|
var s = new InMemoryChunkModificationStore();
|
||||||
|
Vector3i pos = new(5, 5, 5);
|
||||||
|
s.RecordEdit(pos, 0, 1);
|
||||||
|
s.ClearDiff(pos);
|
||||||
|
Assert.False(s.HasDiff(pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
53
VoxelEngine.Tests/PlayerTests.cs
Normal file
53
VoxelEngine.Tests/PlayerTests.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using OpenTK.Mathematics;
|
||||||
|
using VoxelEngine.Core;
|
||||||
|
using VoxelEngine.Game;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace VoxelEngine.Tests;
|
||||||
|
|
||||||
|
public class PlayerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ChunkPosition_Origin_IsZero()
|
||||||
|
{
|
||||||
|
var p = new Player(new Configuration(), new Vector3(0, 0, 0), aspectRatio: 1f);
|
||||||
|
Assert.Equal(new Vector3i(0, 0, 0), p.ChunkPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChunkPosition_PositiveCoords_FloorsDown()
|
||||||
|
{
|
||||||
|
var p = new Player(new Configuration(), new Vector3(17.5f, 32f, 8f), aspectRatio: 1f);
|
||||||
|
Assert.Equal(new Vector3i(1, 2, 0), p.ChunkPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChunkPosition_NegativeCoords_FloorsToNegativeInfinity()
|
||||||
|
{
|
||||||
|
var p = new Player(new Configuration(), new Vector3(-1f, -16f, -17f), aspectRatio: 1f);
|
||||||
|
Assert.Equal(new Vector3i(-1, -1, -2), p.ChunkPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChunkPosition_PositiveExactBoundary_IsNextChunk()
|
||||||
|
{
|
||||||
|
var p = new Player(new Configuration(), new Vector3(16f, 0f, 0f), aspectRatio: 1f);
|
||||||
|
Assert.Equal(new Vector3i(1, 0, 0), p.ChunkPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_UpdatesPosition()
|
||||||
|
{
|
||||||
|
var p = new Player(new Configuration(), new Vector3(0, 0, 0), aspectRatio: 1f);
|
||||||
|
p.Move(new Vector3(5, 0, 0));
|
||||||
|
Assert.Equal(5f, p.Position.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_AppliesFovFromConfiguration()
|
||||||
|
{
|
||||||
|
var cfg = new Configuration { Fov = 75f };
|
||||||
|
var p = new Player(cfg, new Vector3(0, 0, 0), aspectRatio: 1.5f);
|
||||||
|
Assert.Equal(75f, p.Camera.Fov);
|
||||||
|
}
|
||||||
|
}
|
||||||
354
VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs
Normal file
354
VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
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_LoadsNewChunks()
|
||||||
|
{
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkDirty_RemeshesChunkOnNextTick()
|
||||||
|
{
|
||||||
|
var cfg = new Configuration
|
||||||
|
{
|
||||||
|
RenderDistanceHorizontal = 0,
|
||||||
|
RenderDistanceVertical = 0,
|
||||||
|
MaxGenerateJobsInFlight = 100,
|
||||||
|
MaxMeshUploadsPerFrame = 100,
|
||||||
|
};
|
||||||
|
var (streamer, _, created) = CreateStreamer(cfg);
|
||||||
|
|
||||||
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
|
Assert.Single(created);
|
||||||
|
var initialRenderer = created[0];
|
||||||
|
|
||||||
|
streamer.MarkDirty(new Vector3i(0, 0, 0));
|
||||||
|
streamer.Tick(new Vector3i(0, 0, 0)); // same chunk, but a remesh is queued
|
||||||
|
|
||||||
|
// Two renderers exist over the lifetime — old disposed, new live.
|
||||||
|
Assert.Equal(2, created.Count);
|
||||||
|
Assert.True(initialRenderer.Disposed);
|
||||||
|
Assert.False(created[1].Disposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_LoadingChunk_MarksReadyCardinalNeighborsDirty()
|
||||||
|
{
|
||||||
|
var cfg = new Configuration
|
||||||
|
{
|
||||||
|
RenderDistanceHorizontal = 1,
|
||||||
|
RenderDistanceVertical = 0,
|
||||||
|
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, RenderDistanceVertical = 0 };
|
||||||
|
var (streamer, _, created) = CreateStreamer(cfg);
|
||||||
|
|
||||||
|
streamer.MarkDirty(new Vector3i(99, 0, 99));
|
||||||
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
|
|
||||||
|
// Only the chunk in the load box was created; no spurious remesh.
|
||||||
|
Assert.Single(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_GeneratorThrows_LogsAndDoesNotRetry()
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
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, mesher, registry, executor, factory);
|
||||||
|
|
||||||
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
|
Assert.Equal(1, generateCalls);
|
||||||
|
Assert.False(streamer.IsReady(new Vector3i(0, 0, 0)));
|
||||||
|
|
||||||
|
// Tick again — same chunk, same position. Should NOT call generate again.
|
||||||
|
streamer.Tick(new Vector3i(0, 0, 0));
|
||||||
|
Assert.Equal(1, generateCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs
Normal file
84
VoxelEngine.Tests/Streaming/StreamingIntegrationTests.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
namespace VoxelEngine.Tests.Streaming;
|
||||||
|
|
||||||
|
public class StreamingIntegrationTests
|
||||||
|
{
|
||||||
|
private static (ChunkStreamer streamer, World.World world, InMemoryChunkModificationStore store)
|
||||||
|
BuildRealStack(Configuration cfg)
|
||||||
|
{
|
||||||
|
var registry = new BlockRegistry();
|
||||||
|
var store = new InMemoryChunkModificationStore();
|
||||||
|
var world = new World.World(registry, store);
|
||||||
|
var gen = new WorldGenerator(seed: 1337, blocks: registry);
|
||||||
|
var mesher = new ChunkMesher(atlasWidthPixels: 128, atlasHeightPixels: 32);
|
||||||
|
var executor = new SynchronousJobExecutor();
|
||||||
|
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
|
||||||
|
var streamer = new ChunkStreamer(cfg, world, gen.Generate, mesher, registry, executor, factory);
|
||||||
|
return (streamer, world, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlayerWalksAcrossWorld_LoadBoxFollowsPlayer()
|
||||||
|
{
|
||||||
|
var cfg = new Configuration
|
||||||
|
{
|
||||||
|
RenderDistanceHorizontal = 1,
|
||||||
|
RenderDistanceVertical = 0,
|
||||||
|
MaxGenerateJobsInFlight = 100,
|
||||||
|
MaxMeshUploadsPerFrame = 100,
|
||||||
|
};
|
||||||
|
var (streamer, world, _) = BuildRealStack(cfg);
|
||||||
|
|
||||||
|
for (int step = 0; step < 5; step++)
|
||||||
|
{
|
||||||
|
streamer.Tick(new Vector3i(step, 0, 0));
|
||||||
|
|
||||||
|
for (int dx = -1; dx <= 1; dx++)
|
||||||
|
for (int dz = -1; dz <= 1; dz++)
|
||||||
|
{
|
||||||
|
var pos = new Vector3i(step + dx, 0, dz);
|
||||||
|
Assert.NotNull(world.GetChunk(pos));
|
||||||
|
Assert.True(streamer.IsReady(pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ModificationStore_SurvivesUnloadReload()
|
||||||
|
{
|
||||||
|
var cfg = new Configuration
|
||||||
|
{
|
||||||
|
RenderDistanceHorizontal = 0,
|
||||||
|
RenderDistanceVertical = 0,
|
||||||
|
MaxGenerateJobsInFlight = 100,
|
||||||
|
MaxMeshUploadsPerFrame = 100,
|
||||||
|
};
|
||||||
|
var (streamer, world, store) = BuildRealStack(cfg);
|
||||||
|
|
||||||
|
// Use a block id that differs from what WorldGenerator places at
|
||||||
|
// world (0,0,0). At y=0 every biome's "Under" block is Stone (id 1),
|
||||||
|
// so SetBlock(..., Stone) would hit World.SetBlock's no-op guard and
|
||||||
|
// skip the diff record. Dirt (id 2) is always a real change.
|
||||||
|
var pos = new Vector3i(0, 0, 0);
|
||||||
|
streamer.Tick(pos);
|
||||||
|
Assert.True(streamer.IsReady(pos));
|
||||||
|
Assert.True(world.SetBlock(0, 0, 0, /*Dirt*/ (ushort)2));
|
||||||
|
|
||||||
|
streamer.Tick(new Vector3i(50, 0, 0));
|
||||||
|
Assert.Null(world.GetChunk(pos));
|
||||||
|
Assert.True(store.HasDiff(pos));
|
||||||
|
|
||||||
|
streamer.Tick(pos);
|
||||||
|
Assert.NotNull(world.GetChunk(pos));
|
||||||
|
Assert.Equal((ushort)2, world.GetBlock(0, 0, 0).Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
VoxelEngine.Tests/WorldTests.cs
Normal file
88
VoxelEngine.Tests/WorldTests.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
using OpenTK.Mathematics;
|
||||||
|
using VoxelEngine.World;
|
||||||
|
using VoxelEngine.World.Blocks;
|
||||||
|
using VoxelEngine.World.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||||
|
|
||||||
|
namespace VoxelEngine.Tests;
|
||||||
|
|
||||||
|
public class WorldTests
|
||||||
|
{
|
||||||
|
private static (IWorld, BlockRegistry, IChunkModificationStore) Create()
|
||||||
|
{
|
||||||
|
var registry = new BlockRegistry();
|
||||||
|
var store = new InMemoryChunkModificationStore();
|
||||||
|
IWorld world = new World.World(registry, store);
|
||||||
|
return (world, registry, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetBlock_LoadedChunk_WritesBlockAndRecordsDiff()
|
||||||
|
{
|
||||||
|
var (world, registry, store) = Create();
|
||||||
|
var chunkPos = new Vector3i(0, 0, 0);
|
||||||
|
world.SetChunk(chunkPos, new VoxelChunk());
|
||||||
|
|
||||||
|
bool result = world.SetBlock(2, 3, 4, registry.Stone.Id);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(registry.Stone.Id, world.GetBlock(2, 3, 4).Id);
|
||||||
|
Assert.True(store.HasDiff(chunkPos));
|
||||||
|
Assert.True(store.TryGetDiff(chunkPos, out var diff));
|
||||||
|
int expectedIndex = (2 * VoxelChunk.Size + 3) * VoxelChunk.Size + 4;
|
||||||
|
Assert.Equal(registry.Stone.Id, diff![expectedIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetBlock_UnloadedChunk_RecordsDiffButReturnsFalse()
|
||||||
|
{
|
||||||
|
var (world, registry, store) = Create();
|
||||||
|
var chunkPos = new Vector3i(5, 0, 0);
|
||||||
|
|
||||||
|
bool result = world.SetBlock(5 * VoxelChunk.Size, 0, 0, registry.Stone.Id);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
Assert.True(store.HasDiff(chunkPos));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetBlock_UnloadedChunk_ReturnsAir()
|
||||||
|
{
|
||||||
|
var (world, registry, _) = Create();
|
||||||
|
Assert.Same(registry.Air, world.GetBlock(0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetBlock_NegativeCoords_FloorsToCorrectChunk()
|
||||||
|
{
|
||||||
|
var (world, registry, store) = Create();
|
||||||
|
var chunkPos = new Vector3i(-1, 0, 0);
|
||||||
|
world.SetChunk(chunkPos, new VoxelChunk());
|
||||||
|
|
||||||
|
bool result = world.SetBlock(-1, 0, 0, registry.Stone.Id);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(registry.Stone.Id, world.GetBlock(-1, 0, 0).Id);
|
||||||
|
Assert.True(store.HasDiff(chunkPos));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveChunk_RemovesAndGetBlockReturnsAir()
|
||||||
|
{
|
||||||
|
var (world, registry, _) = Create();
|
||||||
|
var chunkPos = new Vector3i(0, 0, 0);
|
||||||
|
world.SetChunk(chunkPos, new VoxelChunk());
|
||||||
|
world.RemoveChunk(chunkPos);
|
||||||
|
|
||||||
|
Assert.Null(world.GetChunk(chunkPos));
|
||||||
|
Assert.Same(registry.Air, world.GetBlock(0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ModificationStore_AccessorReturnsConstructorInjectedStore()
|
||||||
|
{
|
||||||
|
var (world, _, store) = Create();
|
||||||
|
Assert.Same(store, world.ModificationStore);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="OpenTK" Version="4.9.4" />
|
<PackageReference Include="OpenTK" Version="4.9.4" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="StbImageSharp" Version="2.30.15" />
|
<PackageReference Include="StbImageSharp" Version="2.30.15" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
using OpenTK.Mathematics;
|
using OpenTK.Mathematics;
|
||||||
using VoxelEngine.World.Blocks;
|
using VoxelEngine.World.Blocks;
|
||||||
|
using VoxelEngine.World.Persistence;
|
||||||
|
|
||||||
namespace VoxelEngine.World;
|
namespace VoxelEngine.World;
|
||||||
|
|
||||||
public interface IWorld
|
public interface IWorld
|
||||||
{
|
{
|
||||||
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk);
|
IChunkModificationStore ModificationStore { get; }
|
||||||
public Chunk.Chunk? GetChunk(Vector3i chunkPos);
|
|
||||||
public IBlock GetBlock(int worldX, int worldY, int worldZ);
|
void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk);
|
||||||
}
|
void RemoveChunk(Vector3i chunkPos);
|
||||||
|
Chunk.Chunk? GetChunk(Vector3i chunkPos);
|
||||||
|
IBlock GetBlock(int worldX, int worldY, int worldZ);
|
||||||
|
bool SetBlock(int worldX, int worldY, int worldZ, ushort blockId);
|
||||||
|
}
|
||||||
|
|||||||
11
World/Persistence/IChunkModificationStore.cs
Normal file
11
World/Persistence/IChunkModificationStore.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using OpenTK.Mathematics;
|
||||||
|
|
||||||
|
namespace VoxelEngine.World.Persistence;
|
||||||
|
|
||||||
|
public interface IChunkModificationStore
|
||||||
|
{
|
||||||
|
bool TryGetDiff(Vector3i chunkPos, out IReadOnlyDictionary<int, ushort>? diff);
|
||||||
|
void RecordEdit(Vector3i chunkPos, int localIndex, ushort blockId);
|
||||||
|
void ClearDiff(Vector3i chunkPos);
|
||||||
|
bool HasDiff(Vector3i chunkPos);
|
||||||
|
}
|
||||||
35
World/Persistence/InMemoryChunkModificationStore.cs
Normal file
35
World/Persistence/InMemoryChunkModificationStore.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using OpenTK.Mathematics;
|
||||||
|
|
||||||
|
namespace VoxelEngine.World.Persistence;
|
||||||
|
|
||||||
|
public sealed class InMemoryChunkModificationStore : IChunkModificationStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Vector3i, Dictionary<int, ushort>> _diffs = new();
|
||||||
|
|
||||||
|
public bool TryGetDiff(Vector3i chunkPos, out IReadOnlyDictionary<int, ushort>? diff)
|
||||||
|
{
|
||||||
|
if (this._diffs.TryGetValue(chunkPos, out Dictionary<int, ushort>? d))
|
||||||
|
{
|
||||||
|
// Returned snapshot: callers may safely retain and enumerate even
|
||||||
|
// after concurrent RecordEdit on the same chunk.
|
||||||
|
diff = new Dictionary<int, ushort>(d);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
diff = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEdit(Vector3i chunkPos, int localIndex, ushort blockId)
|
||||||
|
{
|
||||||
|
if (!this._diffs.TryGetValue(chunkPos, out Dictionary<int, ushort>? d))
|
||||||
|
{
|
||||||
|
d = new Dictionary<int, ushort>();
|
||||||
|
this._diffs[chunkPos] = d;
|
||||||
|
}
|
||||||
|
d[localIndex] = blockId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearDiff(Vector3i chunkPos) => this._diffs.Remove(chunkPos);
|
||||||
|
|
||||||
|
public bool HasDiff(Vector3i chunkPos) => this._diffs.ContainsKey(chunkPos);
|
||||||
|
}
|
||||||
21
World/Streaming/ChunkJob.cs
Normal file
21
World/Streaming/ChunkJob.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using OpenTK.Mathematics;
|
||||||
|
using VoxelEngine.World.Chunk;
|
||||||
|
|
||||||
|
namespace VoxelEngine.World.Streaming;
|
||||||
|
|
||||||
|
// Job dispatched to an IJobExecutor for a fresh chunk load. The worker
|
||||||
|
// generates the chunk, applies any modification diff, and meshes the result.
|
||||||
|
// Remesh-only jobs are dispatched separately by DispatchRemesh and capture
|
||||||
|
// their state via closures, so they do not use this record.
|
||||||
|
internal sealed record ChunkJob(
|
||||||
|
Vector3i Pos,
|
||||||
|
NeighborSnapshots Neighbors,
|
||||||
|
IReadOnlyDictionary<int, ushort>? Diff);
|
||||||
|
|
||||||
|
// Posted by the worker back to the main thread via a ConcurrentQueue.
|
||||||
|
internal sealed record ChunkJobResult(
|
||||||
|
Vector3i Pos,
|
||||||
|
Chunk.Chunk? Chunk,
|
||||||
|
float[]? Mesh,
|
||||||
|
Exception? Error,
|
||||||
|
bool IsRemeshOnly);
|
||||||
364
World/Streaming/ChunkStreamer.cs
Normal file
364
World/Streaming/ChunkStreamer.cs
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
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 priority = (pos.X - p.X) * (pos.X - p.X)
|
||||||
|
+ (pos.Y - p.Y) * (pos.Y - p.Y)
|
||||||
|
+ (pos.Z - p.Z) * (pos.Z - p.Z);
|
||||||
|
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[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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
6
World/Streaming/IChunkRenderer.cs
Normal file
6
World/Streaming/IChunkRenderer.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace VoxelEngine.World.Streaming;
|
||||||
|
|
||||||
|
public interface IChunkRenderer : IDisposable
|
||||||
|
{
|
||||||
|
void Draw();
|
||||||
|
}
|
||||||
15
World/Streaming/IJobExecutor.cs
Normal file
15
World/Streaming/IJobExecutor.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace VoxelEngine.World.Streaming;
|
||||||
|
|
||||||
|
public interface IJobExecutor
|
||||||
|
{
|
||||||
|
void Submit(Action work);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ThreadPoolJobExecutor : IJobExecutor
|
||||||
|
{
|
||||||
|
// Fire-and-forget: the Task is intentionally discarded. The caller's `work`
|
||||||
|
// delegate MUST catch its own exceptions and report them via a dedicated
|
||||||
|
// channel (e.g. ChunkJobResult.Error in the streaming pipeline). Unhandled
|
||||||
|
// exceptions here are silently lost.
|
||||||
|
public void Submit(Action work) => _ = Task.Run(work);
|
||||||
|
}
|
||||||
52
World/Streaming/NeighborSnapshots.cs
Normal file
52
World/Streaming/NeighborSnapshots.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using VoxelEngine.World.Chunk;
|
||||||
|
|
||||||
|
namespace VoxelEngine.World.Streaming;
|
||||||
|
|
||||||
|
// Snapshots of the six cardinal neighbor chunks' block arrays, taken on the
|
||||||
|
// main thread at job-dispatch time. Each slot is null if that neighbor is not
|
||||||
|
// currently loaded. The ChunkMesher reads only these arrays from a worker
|
||||||
|
// thread — never the live World.
|
||||||
|
public sealed class NeighborSnapshots
|
||||||
|
{
|
||||||
|
public ushort[]? NegX { get; }
|
||||||
|
public ushort[]? PosX { get; }
|
||||||
|
public ushort[]? NegY { get; }
|
||||||
|
public ushort[]? PosY { get; }
|
||||||
|
public ushort[]? NegZ { get; }
|
||||||
|
public ushort[]? PosZ { get; }
|
||||||
|
|
||||||
|
// Snapshots take ownership of the arrays passed in. Callers must hand
|
||||||
|
// in fresh copies (not live references into a chunk's _ids buffer) —
|
||||||
|
// the snapshot does NOT copy defensively. This is enforced at the
|
||||||
|
// single call-site in ChunkStreamer.SnapshotNeighbors which allocates
|
||||||
|
// fresh arrays. The arrays must not be mutated after construction.
|
||||||
|
public NeighborSnapshots(
|
||||||
|
ushort[]? negX, ushort[]? posX,
|
||||||
|
ushort[]? negY, ushort[]? posY,
|
||||||
|
ushort[]? negZ, ushort[]? posZ)
|
||||||
|
{
|
||||||
|
this.NegX = negX; this.PosX = posX;
|
||||||
|
this.NegY = negY; this.PosY = posY;
|
||||||
|
this.NegZ = negZ; this.PosZ = posZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the out-of-chunk position (nx, ny, nz) — exactly one of
|
||||||
|
// which is out of [0, Chunk.Chunk.Size) — is occupied by an opaque block
|
||||||
|
// in the snapshot. Returns false if the relevant neighbor is null.
|
||||||
|
public bool IsOpaqueOutside(int nx, int ny, int nz, Func<ushort, bool> isOpaque)
|
||||||
|
{
|
||||||
|
ushort[]? n;
|
||||||
|
int lx = nx, ly = ny, lz = nz;
|
||||||
|
if (nx < 0) { n = this.NegX; lx = Chunk.Chunk.Size - 1; }
|
||||||
|
else if (nx >= Chunk.Chunk.Size) { n = this.PosX; lx = 0; }
|
||||||
|
else if (ny < 0) { n = this.NegY; ly = Chunk.Chunk.Size - 1; }
|
||||||
|
else if (ny >= Chunk.Chunk.Size) { n = this.PosY; ly = 0; }
|
||||||
|
else if (nz < 0) { n = this.NegZ; lz = Chunk.Chunk.Size - 1; }
|
||||||
|
else if (nz >= Chunk.Chunk.Size) { n = this.PosZ; lz = 0; }
|
||||||
|
else return false; // not actually outside — shouldn't happen, mesher guards
|
||||||
|
|
||||||
|
if (n == null) return false;
|
||||||
|
int index = (lx * Chunk.Chunk.Size + ly) * Chunk.Chunk.Size + lz;
|
||||||
|
return isOpaque(n[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +1,62 @@
|
|||||||
using System.Collections;
|
|
||||||
using OpenTK.Mathematics;
|
using OpenTK.Mathematics;
|
||||||
|
using VoxelEngine.Core;
|
||||||
using VoxelEngine.World.Blocks;
|
using VoxelEngine.World.Blocks;
|
||||||
|
using VoxelEngine.World.Persistence;
|
||||||
|
|
||||||
namespace VoxelEngine.World;
|
namespace VoxelEngine.World;
|
||||||
|
|
||||||
public sealed class World : IWorld, IEnumerable<KeyValuePair<Vector3i, Chunk.Chunk>>
|
public sealed class World(BlockRegistry registry, IChunkModificationStore modStore)
|
||||||
|
: IWorld
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Vector3i, Chunk.Chunk> _chunks = new();
|
private readonly Dictionary<Vector3i, Chunk.Chunk> _chunks = new();
|
||||||
private readonly BlockRegistry _registry;
|
|
||||||
|
|
||||||
public World(BlockRegistry registry)
|
public IChunkModificationStore ModificationStore { get; } = modStore;
|
||||||
{
|
|
||||||
this._registry = registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk)
|
|
||||||
{
|
|
||||||
this._chunks[chunkPos] = chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Chunk.Chunk? GetChunk(Vector3i chunkPos)
|
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk) => this._chunks[chunkPos] = chunk;
|
||||||
{
|
|
||||||
return this._chunks.GetValueOrDefault(chunkPos);
|
public void RemoveChunk(Vector3i chunkPos) => this._chunks.Remove(chunkPos);
|
||||||
}
|
|
||||||
|
public Chunk.Chunk? GetChunk(Vector3i chunkPos) => this._chunks.GetValueOrDefault(chunkPos);
|
||||||
|
|
||||||
public IBlock GetBlock(int worldX, int worldY, int worldZ)
|
public IBlock GetBlock(int worldX, int worldY, int worldZ)
|
||||||
{
|
{
|
||||||
Vector3i chunkPos = new(
|
Vector3i chunkPos = new(
|
||||||
World.FloorDiv(worldX, Chunk.Chunk.Size),
|
ChunkMath.FloorDiv(worldX, Chunk.Chunk.Size),
|
||||||
World.FloorDiv(worldY, Chunk.Chunk.Size),
|
ChunkMath.FloorDiv(worldY, Chunk.Chunk.Size),
|
||||||
World.FloorDiv(worldZ, Chunk.Chunk.Size)
|
ChunkMath.FloorDiv(worldZ, Chunk.Chunk.Size));
|
||||||
);
|
|
||||||
|
|
||||||
Chunk.Chunk? chunk = this.GetChunk(chunkPos);
|
Chunk.Chunk? chunk = this.GetChunk(chunkPos);
|
||||||
if (chunk == null)
|
if (chunk == null) return registry.Air;
|
||||||
{
|
|
||||||
return this._registry.Air;
|
int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
|
||||||
}
|
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
|
||||||
|
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
|
||||||
int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
|
return registry.Get(chunk[localX, localY, localZ]);
|
||||||
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
|
|
||||||
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
|
|
||||||
|
|
||||||
return this._registry.Get(chunk[localX, localY, localZ]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int FloorDiv(int value, int size)
|
public bool SetBlock(int worldX, int worldY, int worldZ, ushort blockId)
|
||||||
{
|
{
|
||||||
int quotient = value / size;
|
Vector3i chunkPos = new(
|
||||||
if (value % size != 0 && value < 0)
|
ChunkMath.FloorDiv(worldX, Chunk.Chunk.Size),
|
||||||
{
|
ChunkMath.FloorDiv(worldY, Chunk.Chunk.Size),
|
||||||
quotient--;
|
ChunkMath.FloorDiv(worldZ, Chunk.Chunk.Size));
|
||||||
}
|
|
||||||
return quotient;
|
Chunk.Chunk? chunk = this.GetChunk(chunkPos);
|
||||||
|
|
||||||
|
int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
|
||||||
|
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
|
||||||
|
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
|
||||||
|
int localIndex = (localX * Chunk.Chunk.Size + localY) * Chunk.Chunk.Size + localZ;
|
||||||
|
|
||||||
|
// For a loaded chunk, no-op writes shouldn't pollute the diff store.
|
||||||
|
if (chunk != null && chunk[localX, localY, localZ] == blockId) return true;
|
||||||
|
|
||||||
|
this.ModificationStore.RecordEdit(chunkPos, localIndex, blockId);
|
||||||
|
|
||||||
|
if (chunk == null) return false;
|
||||||
|
|
||||||
|
chunk[localX, localY, localZ] = blockId;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerator<KeyValuePair<Vector3i, Chunk.Chunk>> GetEnumerator()
|
}
|
||||||
=> this._chunks.GetEnumerator();
|
|
||||||
|
|
||||||
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed class WorldGenerator
|
|||||||
this._terrain = new TerrainSampler(noise, this._biomeMap);
|
this._terrain = new TerrainSampler(noise, this._biomeMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int SurfaceHeightAt(int worldX, int worldZ) => this._terrain.HeightAt(worldX, worldZ);
|
||||||
|
|
||||||
public VoxelChunk Generate(Vector3i chunkPos)
|
public VoxelChunk Generate(Vector3i chunkPos)
|
||||||
{
|
{
|
||||||
VoxelChunk chunk = new();
|
VoxelChunk chunk = new();
|
||||||
|
|||||||
Reference in New Issue
Block a user