Compare commits
48 Commits
main
...
22531c6ee1
| Author | SHA1 | Date | |
|---|---|---|---|
| 22531c6ee1 | |||
| 3704d7ce1e | |||
| aa1aa6fec6 | |||
| 2e36d40386 | |||
| 9410fee78b | |||
| 645fd00868 | |||
| 15be8f6059 | |||
| 070bdfeac1 | |||
| 093b3deabf | |||
| 013d524544 | |||
| 4587f81071 | |||
| 8166c25ba6 | |||
| d1c8c22705 | |||
| 2c95c7831a | |||
| 4fe552b8bd | |||
| 8423b73dfd | |||
| 34f923cbca | |||
| 6b148362c0 | |||
| 59466fb291 | |||
| 4467a7bd10 | |||
| 85a345d6e7 | |||
| da051ed413 | |||
| 7d06706400 | |||
| c1f0928344 | |||
| dc74fdadcc | |||
| 39375b83ea | |||
| 2afeee50d5 | |||
| 6a68953c9a | |||
| 7c310ebc6a | |||
| 6f6fd7ecc5 | |||
| 67bff6031f | |||
| 53d24f29ab | |||
| 1339bb45ec | |||
| c1be0f75e5 | |||
|
|
a8e94a27b2 | ||
|
|
f5787dc9cc | ||
|
|
03cf0ceb07 | ||
|
|
6196b38cc8 | ||
|
|
9880e4379c | ||
|
|
589904beec | ||
|
|
294f3b82ef | ||
|
|
d0707fde9d | ||
|
|
44f53e8d50 | ||
|
|
99b411bdbd | ||
|
|
c81cd3c561 | ||
|
|
50f2bc4221 | ||
|
|
af7a83300f | ||
|
|
6eb17b6c5d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,9 @@ project.fragment.lock.json
|
||||
# Local design docs — kept out of the repo by request
|
||||
docs/
|
||||
|
||||
# Runtime logs
|
||||
logs/
|
||||
|
||||
# OS noise
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
#version 330 core
|
||||
in vec2 vUv;
|
||||
in float vShade;
|
||||
in float vWorldY;
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform sampler2D atlas;
|
||||
uniform float u_caveDarkStartY;
|
||||
uniform float u_caveDarkEndY;
|
||||
uniform float u_caveMinBrightness;
|
||||
|
||||
void main()
|
||||
{
|
||||
vec4 texel = texture(atlas, vUv);
|
||||
FragColor = vec4(texel.rgb * vShade, 1.0);
|
||||
float t = clamp((u_caveDarkStartY - vWorldY) /
|
||||
(u_caveDarkStartY - u_caveDarkEndY), 0.0, 1.0);
|
||||
float brightness = mix(1.0, u_caveMinBrightness, t);
|
||||
FragColor = vec4(texel.rgb * vShade * brightness, 1.0);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ layout(location = 2) in float aShade;
|
||||
|
||||
out vec2 vUv;
|
||||
out float vShade;
|
||||
out float vWorldY;
|
||||
|
||||
uniform mat4 model;
|
||||
uniform mat4 view;
|
||||
@@ -14,6 +15,8 @@ void main()
|
||||
{
|
||||
vUv = aUv;
|
||||
vShade = aShade;
|
||||
// Vector-on-left convention; matrices are uploaded with transpose=true.
|
||||
gl_Position = vec4(aPos, 1.0) * model * view * projection;
|
||||
// World-space position uses row-vector convention (matches gl_Position below).
|
||||
vec4 worldPos = vec4(aPos, 1.0) * model;
|
||||
vWorldY = worldPos.y;
|
||||
gl_Position = worldPos * view * projection;
|
||||
}
|
||||
|
||||
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); }
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 910 KiB |
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;
|
||||
}
|
||||
}
|
||||
40
Core/Configuration.cs
Normal file
40
Core/Configuration.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
|
||||
// Multiplier applied to (dy)^2 in chunk-load priority. >1 prioritizes
|
||||
// chunks at the player's Y over equally-distant chunks above/below, so the
|
||||
// visible surface fills in before far-underground / overhead chunks.
|
||||
public int VerticalPriorityWeight { get; set; } = 4;
|
||||
|
||||
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;
|
||||
|
||||
// -- Cave generation tuning --
|
||||
public float CaveNoiseFrequency { get; set; } = 0.03f;
|
||||
public float CaveNoiseThreshold { get; set; } = -0.3f;
|
||||
public int WormCellSize { get; set; } = 64;
|
||||
public int WormSpawnProbabilityPermille { get; set; } = 300;
|
||||
public int WormMaxLength { get; set; } = 80;
|
||||
public float WormMinRadius { get; set; } = 1.5f;
|
||||
public float WormMaxRadius { get; set; } = 3.5f;
|
||||
|
||||
// -- Cave lighting (fog-distance darkness) --
|
||||
public float CaveDarkStartY { get; set; } = 72f; // WorldConstants.SeaLevel + 8
|
||||
public float CaveDarkEndY { get; set; } = 32f; // WorldConstants.SeaLevel - 32
|
||||
public float CaveMinBrightness { get; set; } = 0.15f;
|
||||
}
|
||||
150
Core/Game.cs
150
Core/Game.cs
@@ -3,107 +3,100 @@ using OpenTK.Mathematics;
|
||||
using OpenTK.Windowing.Common;
|
||||
using OpenTK.Windowing.Desktop;
|
||||
using OpenTK.Windowing.GraphicsLibraryFramework;
|
||||
using Serilog;
|
||||
using VoxelEngine.Game;
|
||||
using VoxelEngine.Rendering;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Chunk;
|
||||
using VoxelEngine.World.Persistence;
|
||||
using VoxelEngine.World.Streaming;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
|
||||
namespace VoxelEngine.Core;
|
||||
|
||||
public sealed class Game : GameWindow
|
||||
{
|
||||
private const float MoveSpeed = 8f; // units per second
|
||||
private const float Sensitivity = 0.15f; // degrees per pixel
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<Game>();
|
||||
|
||||
private readonly Configuration _cfg;
|
||||
private readonly BlockRegistry _registry = new();
|
||||
|
||||
private Shader _shader = null!;
|
||||
private Texture _texture = null!;
|
||||
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
|
||||
private Camera _camera = null!;
|
||||
private LoadingScreen _loading = null!;
|
||||
private World.World _world = null!;
|
||||
private InMemoryChunkModificationStore _modStore = null!;
|
||||
private WorldGenerator _generator = null!;
|
||||
private ChunkStreamer _streamer = null!;
|
||||
private Player _player = null!;
|
||||
|
||||
private World.World _world;
|
||||
|
||||
public Game(GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
|
||||
: base(gameSettings, nativeSettings) { }
|
||||
public Game(Configuration cfg, GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
|
||||
: base(gameSettings, nativeSettings)
|
||||
{
|
||||
this._cfg = cfg;
|
||||
}
|
||||
|
||||
protected override void 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.Enable(EnableCap.DepthTest);
|
||||
GL.Enable(EnableCap.CullFace);
|
||||
|
||||
this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag");
|
||||
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);
|
||||
|
||||
for (int x = -2; x < 2; x++)
|
||||
for (int z = -2; z < 2; z++)
|
||||
{
|
||||
this._world.SetChunk(new Vector3i(x, 0, z), this.BuildChunk());
|
||||
}
|
||||
int seed = this._cfg.Seed ?? new Random().Next();
|
||||
this._generator = new WorldGenerator(seed, this._registry, this._cfg);
|
||||
|
||||
ChunkMesher mesher = new();
|
||||
foreach ((Vector3i pos, Chunk chunk) in this._world)
|
||||
{
|
||||
Vector3i origin = pos * Chunk.Size;
|
||||
float[] mesh = mesher.Build(
|
||||
chunk,
|
||||
// Spawn at terrain surface above (0, 0).
|
||||
int surfaceY = this._generator.SurfaceHeightAt(0, 0);
|
||||
Vector3 spawnPosition = new(0.5f, surfaceY + 2f, 0.5f);
|
||||
|
||||
this._player = new Player(this._cfg, spawnPosition, this.ClientSize.X / (float)this.ClientSize.Y);
|
||||
|
||||
var mesher = new ChunkMesher(this._texture.Width, this._texture.Height);
|
||||
this._streamer = new ChunkStreamer(
|
||||
this._cfg,
|
||||
this._world,
|
||||
this._generator.Generate,
|
||||
mesher,
|
||||
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(new Vector3(8f, 20f, 40f), this.ClientSize.X / (float)this.ClientSize.Y);
|
||||
new ThreadPoolJobExecutor(),
|
||||
mesh => new ChunkRenderer(mesh));
|
||||
|
||||
this.CursorState = CursorState.Grabbed;
|
||||
}
|
||||
|
||||
private Chunk BuildChunk()
|
||||
{
|
||||
Chunk chunk = new();
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
{
|
||||
chunk[x, y, z] = y switch
|
||||
{
|
||||
Chunk.Size - 1 => this._registry.Grass.Id,
|
||||
>= Chunk.Size - 4 => this._registry.Dirt.Id,
|
||||
_ => this._registry.Stone.Id
|
||||
};
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
protected override void OnUpdateFrame(FrameEventArgs args)
|
||||
{
|
||||
base.OnUpdateFrame(args);
|
||||
if (!this.IsFocused) return;
|
||||
|
||||
if (this.KeyboardState.IsKeyDown(Keys.Escape)) {
|
||||
this.Close(); return;
|
||||
}
|
||||
if (this.KeyboardState.IsKeyDown(Keys.Escape)) { 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._camera.Move(this._camera.Front * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.S)) this._camera.Move(-this._camera.Front * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.A)) this._camera.Move(-this._camera.Right * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.D)) this._camera.Move(this._camera.Right * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.Space)) this._camera.Move(Vector3.UnitY * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._camera.Move(-Vector3.UnitY * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.W)) this._player.Move(this._player.Camera.Front * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.S)) this._player.Move(-this._player.Camera.Front * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.A)) this._player.Move(-this._player.Camera.Right * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.D)) this._player.Move(this._player.Camera.Right * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.Space)) this._player.Move(Vector3.UnitY * distance);
|
||||
if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._player.Move(-Vector3.UnitY * distance);
|
||||
|
||||
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)
|
||||
@@ -112,21 +105,28 @@ public sealed class Game : GameWindow
|
||||
|
||||
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
|
||||
if (!this._streamer.SpawnReady)
|
||||
{
|
||||
this._loading.Draw();
|
||||
this.SwapBuffers();
|
||||
return;
|
||||
}
|
||||
|
||||
this._shader.Use();
|
||||
this._shader.SetMatrix4("view", this._camera.GetViewMatrix());
|
||||
this._shader.SetMatrix4("projection", this._camera.GetProjectionMatrix());
|
||||
this._shader.SetMatrix4("view", this._player.Camera.GetViewMatrix());
|
||||
this._shader.SetMatrix4("projection", this._player.Camera.GetProjectionMatrix());
|
||||
this._shader.SetInt("atlas", 0);
|
||||
this._shader.SetFloat("u_caveDarkStartY", this._cfg.CaveDarkStartY);
|
||||
this._shader.SetFloat("u_caveDarkEndY", this._cfg.CaveDarkEndY);
|
||||
this._shader.SetFloat("u_caveMinBrightness", this._cfg.CaveMinBrightness);
|
||||
this._texture.Use(TextureUnit.Texture0);
|
||||
|
||||
foreach ((Vector3i pos, ChunkRenderer renderer) in this._renderer)
|
||||
this._streamer.DrawAll((pos, renderer) =>
|
||||
{
|
||||
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
|
||||
pos.X * Chunk.Size,
|
||||
pos.Y * Chunk.Size,
|
||||
pos.Z * Chunk.Size
|
||||
));
|
||||
pos.X * Chunk.Size, pos.Y * Chunk.Size, pos.Z * Chunk.Size));
|
||||
renderer.Draw();
|
||||
}
|
||||
});
|
||||
|
||||
this.SwapBuffers();
|
||||
}
|
||||
@@ -135,18 +135,16 @@ public sealed class Game : GameWindow
|
||||
{
|
||||
base.OnResize(e);
|
||||
GL.Viewport(0, 0, e.Width, e.Height);
|
||||
// OnResize can fire before OnLoad (where _camera is created), so guard against it.
|
||||
if (this._camera is not null) this._camera.AspectRatio = e.Width / (float)e.Height;
|
||||
if (this._player is not null) this._player.Camera.AspectRatio = e.Width / (float)e.Height;
|
||||
}
|
||||
|
||||
protected override void OnUnload()
|
||||
{
|
||||
foreach (ChunkRenderer rendererValue in this._renderer.Values)
|
||||
{
|
||||
rendererValue.Dispose();
|
||||
}
|
||||
this._texture.Dispose();
|
||||
this._shader.Dispose();
|
||||
Log.Information("Game shutting down");
|
||||
this._streamer?.Dispose();
|
||||
this._loading?.Dispose();
|
||||
this._texture?.Dispose();
|
||||
this._shader?.Dispose();
|
||||
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);
|
||||
}
|
||||
33
Program.cs
33
Program.cs
@@ -1,16 +1,45 @@
|
||||
using OpenTK.Mathematics;
|
||||
using OpenTK.Windowing.Common;
|
||||
using OpenTK.Windowing.Desktop;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using VoxelEngine.Core;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(
|
||||
restrictedToMinimumLevel: LogEventLevel.Information,
|
||||
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}")
|
||||
.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();
|
||||
|
||||
try
|
||||
{
|
||||
var cfg = new Configuration();
|
||||
|
||||
NativeWindowSettings nativeSettings = new()
|
||||
{
|
||||
ClientSize = new Vector2i(1280, 720),
|
||||
ClientSize = new Vector2i(cfg.WindowWidth, cfg.WindowHeight),
|
||||
Title = "VoxelEngine",
|
||||
APIVersion = new Version(3, 3),
|
||||
Profile = ContextProfile.Core,
|
||||
Flags = ContextFlags.ForwardCompatible
|
||||
};
|
||||
|
||||
using Game game = new(GameWindowSettings.Default, nativeSettings);
|
||||
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;
|
||||
|
||||
public sealed class ChunkRenderer : IDisposable
|
||||
public sealed class ChunkRenderer : VoxelEngine.World.Streaming.IChunkRenderer
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,14 @@ public sealed class Shader : IDisposable
|
||||
public void SetInt(string name, int value) =>
|
||||
GL.Uniform1(this.Location(name), value);
|
||||
|
||||
public void SetFloat(string name, float value) =>
|
||||
GL.Uniform1(this.Location(name), value);
|
||||
|
||||
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);
|
||||
this._uniforms[name] = location;
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ public sealed class Texture : IDisposable
|
||||
{
|
||||
private readonly int _handle;
|
||||
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
|
||||
public Texture(string path)
|
||||
{
|
||||
this._handle = GL.GenTexture();
|
||||
@@ -15,6 +18,8 @@ public sealed class Texture : IDisposable
|
||||
StbImage.stbi_set_flip_vertically_on_load(1);
|
||||
using FileStream stream = File.OpenRead(path);
|
||||
ImageResult image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
|
||||
this.Width = image.Width;
|
||||
this.Height = image.Height;
|
||||
|
||||
GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba,
|
||||
image.Width, image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, image.Data);
|
||||
|
||||
@@ -49,4 +49,58 @@ public class BlockRegistryTests
|
||||
Assert.Equal((ushort)2, registry.Dirt.Id);
|
||||
Assert.Equal((ushort)3, registry.Grass.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SandAndSnow_AreOpaque_WaterIsOpaque()
|
||||
{
|
||||
var registry = new BlockRegistry();
|
||||
Assert.True(registry.Get(4).IsOpaque); // Sand
|
||||
Assert.True(registry.Get(5).IsOpaque); // Snow
|
||||
Assert.True(registry.Get(6).IsOpaque); // Water (single-pass renderer => opaque)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_ResolvesAllSevenBlocks()
|
||||
{
|
||||
var registry = new BlockRegistry();
|
||||
Assert.Equal("Air", registry.Get(0).Name);
|
||||
Assert.Equal("Stone", registry.Get(1).Name);
|
||||
Assert.Equal("Dirt", registry.Get(2).Name);
|
||||
Assert.Equal("Grass", registry.Get(3).Name);
|
||||
Assert.Equal("Sand", registry.Get(4).Name);
|
||||
Assert.Equal("Snow Block", registry.Get(5).Name);
|
||||
Assert.Equal("Water", registry.Get(6).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_ResolvesOreBlocks_ByStableIds()
|
||||
{
|
||||
var registry = new BlockRegistry();
|
||||
|
||||
Assert.Equal("Coal Ore", registry.Get(7).Name);
|
||||
Assert.Equal("Iron Ore", registry.Get(8).Name);
|
||||
Assert.Equal("Gold Ore", registry.Get(9).Name);
|
||||
Assert.Equal("Diamond Ore", registry.Get(10).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ores_AreOpaqueAndSolid()
|
||||
{
|
||||
var registry = new BlockRegistry();
|
||||
foreach (ushort id in new ushort[] { 7, 8, 9, 10 })
|
||||
{
|
||||
Assert.True(registry.Get(id).IsOpaque, $"ore id {id} should be opaque");
|
||||
Assert.True(registry.Get(id).IsSolid, $"ore id {id} should be solid");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamedOreAccessors_MatchIds()
|
||||
{
|
||||
var registry = new BlockRegistry();
|
||||
Assert.Equal((ushort)7, registry.CoalOre.Id);
|
||||
Assert.Equal((ushort)8, registry.IronOre.Id);
|
||||
Assert.Equal((ushort)9, registry.GoldOre.Id);
|
||||
Assert.Equal((ushort)10, registry.DiamondOre.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public class ChunkMesherTests
|
||||
[Fact]
|
||||
public void EmptyChunk_ProducesNoFaces()
|
||||
{
|
||||
var mesh = new ChunkMesher().Build(new Chunk(), new BlockRegistry());
|
||||
var mesh = new ChunkMesher(128, 128).Build(new Chunk(), new BlockRegistry());
|
||||
Assert.Empty(mesh);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public class ChunkMesherTests
|
||||
var chunk = new Chunk();
|
||||
chunk[8, 8, 8] = 1; // stone
|
||||
|
||||
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry());
|
||||
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
|
||||
|
||||
Assert.Equal(6, FaceCount(mesh));
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public class ChunkMesherTests
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
chunk[x, y, z] = 1; // stone
|
||||
|
||||
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry());
|
||||
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
|
||||
|
||||
// 6 sides * 16 * 16 visible faces
|
||||
Assert.Equal(6 * Chunk.Size * Chunk.Size, FaceCount(mesh));
|
||||
@@ -52,10 +52,32 @@ public class ChunkMesherTests
|
||||
var chunk = new Chunk();
|
||||
chunk[0, 0, 0] = 1; // corner block: all 6 neighbors are air/out-of-bounds -> 6 faces
|
||||
|
||||
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry());
|
||||
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
|
||||
|
||||
// Pins the layout: 6 faces * 6 vertices/face * 6 floats/vertex.
|
||||
Assert.Equal(6 * VerticesPerFace * FloatsPerVertex, mesh.Length);
|
||||
Assert.Equal(0, mesh.Length % FloatsPerVertex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mesher_UsesAtlasDimensions_ForTileSize()
|
||||
{
|
||||
var chunk = new Chunk();
|
||||
chunk[0, 0, 0] = 1; // stone, tile (1,0)
|
||||
|
||||
// 128px atlas, 32px tiles => 4x4 grid, normalized tile size 0.25.
|
||||
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
|
||||
|
||||
// Gather all U coords (offset 3) and V coords (offset 4); each must be a
|
||||
// multiple of 0.25 within [0,1].
|
||||
for (int i = 0; i < mesh.Length; i += 6)
|
||||
{
|
||||
float u = mesh[i + 3];
|
||||
float v = mesh[i + 4];
|
||||
Assert.InRange(u, 0f, 1f);
|
||||
Assert.InRange(v, 0f, 1f);
|
||||
Assert.True(System.Math.Abs(u * 4f - System.MathF.Round(u * 4f)) < 1e-4f, $"u={u} not on 0.25 grid");
|
||||
Assert.True(System.Math.Abs(v * 4f - System.MathF.Round(v * 4f)) < 1e-4f, $"v={v} not on 0.25 grid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
77
VoxelEngine.Tests/ConfigurationTests.cs
Normal file
77
VoxelEngine.Tests/ConfigurationTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_HaveCaveTuningConstants()
|
||||
{
|
||||
var c = new Configuration();
|
||||
|
||||
// Algorithm
|
||||
Assert.Equal(0.03f, c.CaveNoiseFrequency);
|
||||
Assert.Equal(-0.3f, c.CaveNoiseThreshold);
|
||||
Assert.Equal(64, c.WormCellSize);
|
||||
Assert.Equal(300, c.WormSpawnProbabilityPermille);
|
||||
Assert.Equal(80, c.WormMaxLength);
|
||||
Assert.Equal(1.5f, c.WormMinRadius);
|
||||
Assert.Equal(3.5f, c.WormMaxRadius);
|
||||
|
||||
// Lighting (fog)
|
||||
Assert.Equal(72f, c.CaveDarkStartY); // SeaLevel (64) + 8
|
||||
Assert.Equal(32f, c.CaveDarkEndY); // SeaLevel (64) - 32
|
||||
Assert.Equal(0.15f, c.CaveMinBrightness);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
422
VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs
Normal file
422
VoxelEngine.Tests/Streaming/ChunkStreamerTests.cs
Normal file
@@ -0,0 +1,422 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallResult_EmptyMesh_SkipsRendererCreationButMarksReady()
|
||||
{
|
||||
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 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, 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,
|
||||
RenderDistanceVertical = 1,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 5, // player's chunk + 4 horizontal cardinals
|
||||
VerticalPriorityWeight = 4,
|
||||
};
|
||||
var (streamer, world, _) = CreateStreamer(cfg);
|
||||
|
||||
// Player chunk at Y=2 so vertical neighbors Y=1 and Y=3 are valid.
|
||||
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 should 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)));
|
||||
}
|
||||
}
|
||||
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, cfg: new Configuration());
|
||||
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);
|
||||
}
|
||||
}
|
||||
54
VoxelEngine.Tests/WorldGen/BiomeMapTests.cs
Normal file
54
VoxelEngine.Tests/WorldGen/BiomeMapTests.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class BiomeMapTests
|
||||
{
|
||||
private static (BiomeMap map, BiomeRegistry biomes) Build(int seed = 42)
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(seed, blocks);
|
||||
var noise = new NoiseSource(seed);
|
||||
var cells = new VoronoiCells(seed, 96f);
|
||||
return (new BiomeMap(noise, cells, biomes), biomes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_HighElevation_IsMountains()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_HotLowElevation_IsDesert()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_TemperateWooded_IsForest()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_ColdLow_IsPlains()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BiomeAt_IsConstantWithinACell()
|
||||
{
|
||||
var (map, _) = Build();
|
||||
IBiome a = map.BiomeAt(500f, 500f);
|
||||
IBiome b = map.BiomeAt(500.01f, 500.01f); // same cell
|
||||
Assert.Same(a, b);
|
||||
}
|
||||
}
|
||||
46
VoxelEngine.Tests/WorldGen/BiomeTests.cs
Normal file
46
VoxelEngine.Tests/WorldGen/BiomeTests.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class BiomeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Desert_TopAndFiller_AreSand()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
Assert.Equal("Sand", biomes.Desert.Surface.Top.Name);
|
||||
Assert.Equal("Sand", biomes.Desert.Surface.Filler.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mountains_UseSnowAboveSnowLine_StoneBelow()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
Assert.Equal("Stone", biomes.Mountains.TopBlock(70).Name);
|
||||
Assert.Equal("Snow Block", biomes.Mountains.TopBlock(120).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plains_TopBlock_IsGrass_RegardlessOfHeight()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
Assert.Equal("Grass", biomes.Plains.TopBlock(40).Name);
|
||||
Assert.Equal("Grass", biomes.Plains.TopBlock(120).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleHeight_IsDeterministic()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
var noise = new NoiseSource(42);
|
||||
float h1 = biomes.Mountains.SampleHeight(noise, 100f, 200f);
|
||||
float h2 = biomes.Mountains.SampleHeight(noise, 100f, 200f);
|
||||
Assert.Equal(h1, h2);
|
||||
}
|
||||
}
|
||||
186
VoxelEngine.Tests/WorldGen/CaveCarverTests.cs
Normal file
186
VoxelEngine.Tests/WorldGen/CaveCarverTests.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Chunk;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class CaveCarverTests
|
||||
{
|
||||
private static readonly BlockRegistry Blocks = new();
|
||||
private static readonly Configuration Cfg = new();
|
||||
|
||||
private static Chunk MakeStoneChunk()
|
||||
{
|
||||
Chunk c = new();
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
c[x, y, z] = Blocks.Stone.Id;
|
||||
return c;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Carve_IsNoOpOnAirChunk()
|
||||
{
|
||||
var carver = new CaveCarver(seed: 42, Blocks, Cfg);
|
||||
Chunk c = new(); // all-air by default
|
||||
|
||||
carver.Carve(c, new Vector3i(0, 4, 0));
|
||||
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
Assert.Equal(Blocks.Air.Id, c[x, y, z]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoiseCaves_OnlyConvertsSolidToAir_NeverWaterNeverOtherBlocks()
|
||||
{
|
||||
var carver = new CaveCarver(seed: 42, Blocks, Cfg);
|
||||
Chunk c = MakeStoneChunk();
|
||||
|
||||
// Plant some water and air voxels we expect untouched.
|
||||
c[1, 1, 1] = Blocks.Water.Id;
|
||||
c[2, 2, 2] = Blocks.Air.Id;
|
||||
|
||||
carver.CarveNoiseCaves(c, new Vector3i(0, 4, 0));
|
||||
|
||||
Assert.Equal(Blocks.Water.Id, c[1, 1, 1]); // water survived
|
||||
Assert.Equal(Blocks.Air.Id, c[2, 2, 2]); // air survived
|
||||
|
||||
// Spot-check that everywhere else is either Stone or Air (no other blocks).
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
{
|
||||
ushort id = c[x, y, z];
|
||||
Assert.True(
|
||||
id == Blocks.Stone.Id || id == Blocks.Air.Id || id == Blocks.Water.Id,
|
||||
$"unexpected block id {id} at ({x},{y},{z})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoiseCaves_IsDeterministic_ForSameSeedAndChunkPos()
|
||||
{
|
||||
var carver1 = new CaveCarver(seed: 1234, Blocks, Cfg);
|
||||
var carver2 = new CaveCarver(seed: 1234, Blocks, Cfg);
|
||||
Chunk a = MakeStoneChunk();
|
||||
Chunk b = MakeStoneChunk();
|
||||
|
||||
carver1.CarveNoiseCaves(a, new Vector3i(2, 4, 3));
|
||||
carver2.CarveNoiseCaves(b, new Vector3i(2, 4, 3));
|
||||
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
Assert.Equal(a[x, y, z], b[x, y, z]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoiseCaves_RespectsBedrockFloor_DoesNotCarveBelowY1()
|
||||
{
|
||||
// ChunkPos.Y = 0 means worldY ranges 0..15. We require Y=0 stays solid.
|
||||
var carver = new CaveCarver(seed: 42, Blocks, Cfg);
|
||||
Chunk c = MakeStoneChunk();
|
||||
|
||||
carver.CarveNoiseCaves(c, new Vector3i(0, 0, 0));
|
||||
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
Assert.Equal(Blocks.Stone.Id, c[x, 0, z]); // worldY = 0 is bedrock
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoiseCaves_SeamContinuity_AdjacentChunksAgreeAtBoundary()
|
||||
{
|
||||
var carver = new CaveCarver(seed: 7, Blocks, Cfg);
|
||||
Chunk c1 = MakeStoneChunk();
|
||||
Chunk c2 = MakeStoneChunk();
|
||||
|
||||
carver.CarveNoiseCaves(c1, new Vector3i(0, 4, 0));
|
||||
carver.CarveNoiseCaves(c2, new Vector3i(1, 4, 0));
|
||||
|
||||
int airAtSeamC1 = 0, airAtSeamC2 = 0;
|
||||
for (int y = 1; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
{
|
||||
if (c1[15, y, z] == Blocks.Air.Id) airAtSeamC1++;
|
||||
if (c2[0, y, z] == Blocks.Air.Id) airAtSeamC2++;
|
||||
}
|
||||
// Sanity: noise threshold = -0.3 yields some carved cells in each.
|
||||
Assert.True(airAtSeamC1 > 0, "c1 seam column had no air — carve too sparse?");
|
||||
Assert.True(airAtSeamC2 > 0, "c2 seam column had no air — carve too sparse?");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WormCaves_IsDeterministic_ForSameSeedAndChunkPos()
|
||||
{
|
||||
var c1 = new CaveCarver(seed: 999, Blocks, Cfg);
|
||||
var c2 = new CaveCarver(seed: 999, Blocks, Cfg);
|
||||
Chunk a = MakeStoneChunk();
|
||||
Chunk b = MakeStoneChunk();
|
||||
|
||||
c1.CarveWormCaves(a, new Vector3i(0, 4, 0));
|
||||
c2.CarveWormCaves(b, new Vector3i(0, 4, 0));
|
||||
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
Assert.Equal(a[x, y, z], b[x, y, z]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WormCaves_OnlyConvertsSolidToAir_NeverWater()
|
||||
{
|
||||
var carver = new CaveCarver(seed: 999, Blocks, Cfg);
|
||||
Chunk c = MakeStoneChunk();
|
||||
// Set every voxel along a diagonal to water — must survive.
|
||||
for (int i = 0; i < Chunk.Size; i++)
|
||||
c[i, i, i] = Blocks.Water.Id;
|
||||
|
||||
carver.CarveWormCaves(c, new Vector3i(0, 4, 0));
|
||||
|
||||
for (int i = 0; i < Chunk.Size; i++)
|
||||
Assert.Equal(Blocks.Water.Id, c[i, i, i]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WormCaves_RespectsBedrockFloor()
|
||||
{
|
||||
var carver = new CaveCarver(seed: 999, Blocks, Cfg);
|
||||
Chunk c = MakeStoneChunk();
|
||||
|
||||
carver.CarveWormCaves(c, new Vector3i(0, 0, 0)); // worldY = 0..15
|
||||
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
Assert.Equal(Blocks.Stone.Id, c[x, 0, z]); // bedrock untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WormCaves_SeamContinuity_VoxelAtSharedWorldCoord_IsSame()
|
||||
{
|
||||
var carver = new CaveCarver(seed: 999, Blocks, Cfg);
|
||||
Chunk c1 = MakeStoneChunk();
|
||||
Chunk c2 = MakeStoneChunk();
|
||||
|
||||
carver.CarveWormCaves(c1, new Vector3i(0, 4, 0));
|
||||
carver.CarveWormCaves(c2, new Vector3i(1, 4, 0));
|
||||
|
||||
int airC1Seam = 0, airC2Seam = 0;
|
||||
for (int y = 1; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
{
|
||||
if (c1[15, y, z] == Blocks.Air.Id) airC1Seam++;
|
||||
if (c2[0, y, z] == Blocks.Air.Id) airC2Seam++;
|
||||
}
|
||||
bool bothEmpty = airC1Seam == 0 && airC2Seam == 0;
|
||||
bool bothCarved = airC1Seam > 0 && airC2Seam > 0;
|
||||
Assert.True(bothEmpty || bothCarved,
|
||||
$"seam mismatch: c1={airC1Seam}, c2={airC2Seam}");
|
||||
}
|
||||
}
|
||||
29
VoxelEngine.Tests/WorldGen/NoiseSourceTests.cs
Normal file
29
VoxelEngine.Tests/WorldGen/NoiseSourceTests.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class NoiseSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SameSeed_SameCoord_IsDeterministic()
|
||||
{
|
||||
var a = new NoiseSource(42);
|
||||
var b = new NoiseSource(42);
|
||||
Assert.Equal(a.Continent(10f, 20f), b.Continent(10f, 20f));
|
||||
Assert.Equal(a.Temperature(10f, 20f), b.Temperature(10f, 20f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Output_InMinusOneToOneRange()
|
||||
{
|
||||
var n = new NoiseSource(7);
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
float c = n.Continent(i * 3.1f, i * 1.7f);
|
||||
float t = n.Temperature(i * 2.3f, i * 0.9f);
|
||||
Assert.InRange(c, -1.001f, 1.001f);
|
||||
Assert.InRange(t, -1.001f, 1.001f);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
VoxelEngine.Tests/WorldGen/TerrainSamplerTests.cs
Normal file
53
VoxelEngine.Tests/WorldGen/TerrainSamplerTests.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class TerrainSamplerTests
|
||||
{
|
||||
private static TerrainSampler Build(int seed = 42)
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(seed, blocks);
|
||||
var noise = new NoiseSource(seed);
|
||||
var cells = new VoronoiCells(seed, 96f);
|
||||
var map = new BiomeMap(noise, cells, biomes);
|
||||
return new TerrainSampler(noise, map);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Height_StaysWithinWorldBounds()
|
||||
{
|
||||
var sampler = Build();
|
||||
for (int x = -300; x < 300; x += 7)
|
||||
for (int z = -300; z < 300; z += 7)
|
||||
{
|
||||
int h = sampler.HeightAt(x, z);
|
||||
Assert.InRange(h, 0, WorldConstants.WorldHeight - 1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Height_IsDeterministic()
|
||||
{
|
||||
var a = Build();
|
||||
var b = Build();
|
||||
Assert.Equal(a.HeightAt(120, 240), b.HeightAt(120, 240));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentColumns_DoNotCliff()
|
||||
{
|
||||
var sampler = Build();
|
||||
for (int x = -200; x < 200; x += 3)
|
||||
for (int z = -200; z < 200; z += 3)
|
||||
{
|
||||
// The dense blur removes inter-biome cliffs (those would be 25+ blocks).
|
||||
// Remaining deltas are rugged intra-Mountains ridged slopes (~9), which
|
||||
// are intended terrain, not cliffs; 16 separates the two.
|
||||
int dx = System.Math.Abs(sampler.HeightAt(x, z) - sampler.HeightAt(x + 1, z));
|
||||
Assert.True(dx <= 16, $"height jump {dx} at ({x},{z})");
|
||||
}
|
||||
}
|
||||
}
|
||||
36
VoxelEngine.Tests/WorldGen/VoronoiCellsTests.cs
Normal file
36
VoxelEngine.Tests/WorldGen/VoronoiCellsTests.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class VoronoiCellsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Nearest_IsDeterministic()
|
||||
{
|
||||
var a = new VoronoiCells(42, 96f);
|
||||
var b = new VoronoiCells(42, 96f);
|
||||
Assert.Equal(a.Nearest(123.4f, 56.7f), b.Nearest(123.4f, 56.7f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nearest_IsLocallyConstant()
|
||||
{
|
||||
var cells = new VoronoiCells(42, 96f);
|
||||
// Two points a tiny step apart almost always share a cell.
|
||||
var c1 = cells.Nearest(500f, 500f);
|
||||
var c2 = cells.Nearest(500.01f, 500.01f);
|
||||
Assert.Equal(c1.Id, c2.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentSeeds_DifferAtSomePoint()
|
||||
{
|
||||
var a = new VoronoiCells(1, 96f);
|
||||
var b = new VoronoiCells(2, 96f);
|
||||
bool anyDiff = false;
|
||||
for (int i = 0; i < 50 && !anyDiff; i++)
|
||||
anyDiff = a.Nearest(i * 31f, i * 17f).Id != b.Nearest(i * 31f, i * 17f).Id;
|
||||
Assert.True(anyDiff);
|
||||
}
|
||||
}
|
||||
99
VoxelEngine.Tests/WorldGen/WorldGeneratorTests.cs
Normal file
99
VoxelEngine.Tests/WorldGen/WorldGeneratorTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Chunk;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class WorldGeneratorTests
|
||||
{
|
||||
private static BlockRegistry Blocks => new();
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_AboveTerrainAndSea_IsAir()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(80, surfaceHeight: 70, biomes.Plains, blocks);
|
||||
Assert.Equal(blocks.Air.Id, id); // 80 > 70 and 80 > SeaLevel(64)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_AboveTerrainBelowSea_IsWater()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(60, surfaceHeight: 50, biomes.Plains, blocks);
|
||||
Assert.Equal(blocks.Water.Id, id); // 60 > 50 but 60 <= SeaLevel(64)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_Surface_IsBiomeTop()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks);
|
||||
Assert.Equal(blocks.Sand.Id, id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_FillerBand_IsBiomeFiller()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks);
|
||||
Assert.Equal(blocks.Dirt.Id, id); // within 3 of surface
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_Deep_IsStone()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks);
|
||||
Assert.Equal(blocks.Stone.Id, id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_IsDeterministic()
|
||||
{
|
||||
var gen1 = new WorldGenerator(99, Blocks, new Configuration());
|
||||
var gen2 = new WorldGenerator(99, Blocks, new Configuration());
|
||||
Chunk a = gen1.Generate(new Vector3i(0, 4, 0));
|
||||
Chunk b = gen2.Generate(new Vector3i(0, 4, 0));
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
Assert.Equal(a[x, y, z], b[x, y, z]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_AppliesCaves_ProducesAirVoxelsBelowSurface()
|
||||
{
|
||||
var gen = new WorldGenerator(seed: 12345, Blocks, new Configuration());
|
||||
// A chunk well below typical surface height — guaranteed mostly stone before carving.
|
||||
Chunk c = gen.Generate(new Vector3i(0, 1, 0)); // worldY = 16..31
|
||||
|
||||
int airCount = 0;
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
if (c[x, y, z] == Blocks.Air.Id) airCount++;
|
||||
|
||||
Assert.True(airCount > 0,
|
||||
"Deep chunk had zero air voxels after generation — caves not running?");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_PreservesBedrockFloor_Y0_IsStone()
|
||||
{
|
||||
var gen = new WorldGenerator(seed: 12345, Blocks, new Configuration());
|
||||
Chunk c = gen.Generate(new Vector3i(0, 0, 0)); // worldY = 0..15
|
||||
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
Assert.Equal(Blocks.Stone.Id, c[x, 0, z]);
|
||||
}
|
||||
}
|
||||
40
VoxelEngine.Tests/WorldGen/WormHashTests.cs
Normal file
40
VoxelEngine.Tests/WorldGen/WormHashTests.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class WormHashTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_IsDeterministic_ForSameInputs()
|
||||
{
|
||||
uint a = WormHash.Hash(3, -2, 7, seed: 1337);
|
||||
uint b = WormHash.Hash(3, -2, 7, seed: 1337);
|
||||
Assert.Equal(a, b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DiffersForDifferentCells()
|
||||
{
|
||||
uint a = WormHash.Hash(3, -2, 7, seed: 1337);
|
||||
uint b = WormHash.Hash(3, -2, 8, seed: 1337);
|
||||
Assert.NotEqual(a, b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DiffersForDifferentSeeds()
|
||||
{
|
||||
uint a = WormHash.Hash(3, -2, 7, seed: 1337);
|
||||
uint b = WormHash.Hash(3, -2, 7, seed: 9999);
|
||||
Assert.NotEqual(a, b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_HandlesNegativeCoords()
|
||||
{
|
||||
// Just shouldn't throw and shouldn't collide trivially with the positive case.
|
||||
uint a = WormHash.Hash(-1, -1, -1, seed: 1);
|
||||
uint b = WormHash.Hash(1, 1, 1, seed: 1);
|
||||
Assert.NotEqual(a, b);
|
||||
}
|
||||
}
|
||||
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,7 +10,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTK" Version="4.9.4" />
|
||||
<PackageReference Include="SimplexNoise" Version="2.0.0" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -21,7 +23,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="World\WorldGen\" />
|
||||
<InternalsVisibleTo Include="VoxelEngine.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
10
World/Blocks/BlockClasses/CoalOreBlock.cs
Normal file
10
World/Blocks/BlockClasses/CoalOreBlock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public sealed class CoalOreBlock : IBlock
|
||||
{
|
||||
public ushort Id => 7;
|
||||
public string Name => "Coal Ore";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => true;
|
||||
public TileCoord GetTextureTile(BlockFace face) => new(0, 0); // TODO: Add Texture
|
||||
}
|
||||
10
World/Blocks/BlockClasses/DiamondOreBlock.cs
Normal file
10
World/Blocks/BlockClasses/DiamondOreBlock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public sealed class DiamondOreBlock : IBlock
|
||||
{
|
||||
public ushort Id => 10;
|
||||
public string Name => "Diamond Ore";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => true;
|
||||
public TileCoord GetTextureTile(BlockFace face) => new(0, 0); // TODO: Add Texture
|
||||
}
|
||||
10
World/Blocks/BlockClasses/GoldOreBlock.cs
Normal file
10
World/Blocks/BlockClasses/GoldOreBlock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public sealed class GoldOreBlock : IBlock
|
||||
{
|
||||
public ushort Id => 9;
|
||||
public string Name => "Gold Ore";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => true;
|
||||
public TileCoord GetTextureTile(BlockFace face) => new(0, 0); // TODO: Add Texture
|
||||
}
|
||||
@@ -9,8 +9,8 @@ public sealed class GrassBlock : IBlock
|
||||
|
||||
public TileCoord GetTextureTile(BlockFace face) => face switch
|
||||
{
|
||||
BlockFace.Top => new(0, 1), // grass top
|
||||
BlockFace.Bottom => new(0, 0), // dirt
|
||||
_ => new(1, 1) // grass side
|
||||
BlockFace.Top => new TileCoord(0, 1), // grass top
|
||||
BlockFace.Bottom => new TileCoord(0, 0), // dirt
|
||||
_ => new TileCoord(1, 1) // grass side
|
||||
};
|
||||
}
|
||||
10
World/Blocks/BlockClasses/IronOreBlock.cs
Normal file
10
World/Blocks/BlockClasses/IronOreBlock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public sealed class IronOreBlock : IBlock
|
||||
{
|
||||
public ushort Id => 8;
|
||||
public string Name => "Iron Ore";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => true;
|
||||
public TileCoord GetTextureTile(BlockFace face) => new(0, 0); // TODO: Add Texture
|
||||
}
|
||||
10
World/Blocks/BlockClasses/SandBlock.cs
Normal file
10
World/Blocks/BlockClasses/SandBlock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public class SandBlock : IBlock
|
||||
{
|
||||
public ushort Id => 4;
|
||||
public string Name => "Sand";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => true;
|
||||
public TileCoord GetTextureTile(BlockFace face) => new(2, 1);
|
||||
}
|
||||
16
World/Blocks/BlockClasses/SnowBlock.cs
Normal file
16
World/Blocks/BlockClasses/SnowBlock.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public class SnowBlock : IBlock
|
||||
{
|
||||
public ushort Id => 5;
|
||||
public string Name => "Snow Block";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => true;
|
||||
|
||||
public TileCoord GetTextureTile(BlockFace face) => face switch
|
||||
{
|
||||
BlockFace.Top => new TileCoord(2, 0),
|
||||
BlockFace.Bottom => new TileCoord(0, 0),
|
||||
_ => new TileCoord(3, 0)
|
||||
};
|
||||
}
|
||||
10
World/Blocks/BlockClasses/WaterBlock.cs
Normal file
10
World/Blocks/BlockClasses/WaterBlock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public class WaterBlock : IBlock
|
||||
{
|
||||
public ushort Id => 6;
|
||||
public string Name => "Water";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => false;
|
||||
public TileCoord GetTextureTile(BlockFace face) => new(3, 1);
|
||||
}
|
||||
7
World/Blocks/BlockIds.txt
Normal file
7
World/Blocks/BlockIds.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Air = 0
|
||||
Stone = 1
|
||||
Dirt = 2
|
||||
Grass = 3
|
||||
Sand = 4
|
||||
Snow = 5
|
||||
Water = 6
|
||||
@@ -8,11 +8,21 @@ public sealed class BlockRegistry
|
||||
public StoneBlock Stone { get; } = new();
|
||||
public DirtBlock Dirt { get; } = new();
|
||||
public GrassBlock Grass { get; } = new();
|
||||
public SandBlock Sand { get; } = new();
|
||||
public SnowBlock Snow { get; } = new();
|
||||
public WaterBlock Water { get; } = new();
|
||||
public CoalOreBlock CoalOre { get; } = new();
|
||||
public IronOreBlock IronOre { get; } = new();
|
||||
public GoldOreBlock GoldOre { get; } = new();
|
||||
public DiamondOreBlock DiamondOre { get; } = new();
|
||||
|
||||
public BlockRegistry()
|
||||
{
|
||||
// Index by id. Order does not matter; slot = block.Id.
|
||||
IBlock[] blocks = new IBlock[] { this.Air, this.Stone, this.Dirt, this.Grass };
|
||||
IBlock[] blocks = new IBlock[]
|
||||
{
|
||||
this.Air, this.Stone, this.Dirt, this.Grass, this.Sand, this.Snow, this.Water,
|
||||
this.CoalOre, this.IronOre, this.GoldOre, this.DiamondOre,
|
||||
};
|
||||
this._byId = new IBlock[blocks.Length];
|
||||
foreach (IBlock block in blocks)
|
||||
{
|
||||
|
||||
5
World/Blocks/TileCoord.cs
Normal file
5
World/Blocks/TileCoord.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
// Position of a tile within the texture atlas, in tile units.
|
||||
// Origin (0,0) is the top-left tile; X increases right, Y increases down.
|
||||
public readonly record struct TileCoord(int X, int Y);
|
||||
@@ -5,9 +5,15 @@ namespace VoxelEngine.World.Chunk;
|
||||
public sealed class ChunkMesher : IChunkMesher
|
||||
{
|
||||
private const int TileSizePixels = 32; // each tile is 32x32 pixels
|
||||
private const int AtlasSizePixels = 64; // atlas is 64x64 pixels (2x2 grid)
|
||||
|
||||
private const float TileSize = (float)TileSizePixels / AtlasSizePixels; // normalized tile size
|
||||
private readonly float _tileU; // normalized tile width
|
||||
private readonly float _tileV; // normalized tile height
|
||||
|
||||
public ChunkMesher(int atlasWidthPixels, int atlasHeightPixels)
|
||||
{
|
||||
this._tileU = (float)ChunkMesher.TileSizePixels / atlasWidthPixels;
|
||||
this._tileV = (float)ChunkMesher.TileSizePixels / atlasHeightPixels;
|
||||
}
|
||||
|
||||
// Per-face geometry: base corner + two in-plane edge vectors (u, v) with
|
||||
// cross(u, v) == outward normal, guaranteeing CCW winding from outside.
|
||||
@@ -72,18 +78,18 @@ public sealed class ChunkMesher : IChunkMesher
|
||||
: (neighborOpaqueOutside?.Invoke(nx, ny, nz) ?? false);
|
||||
if (neighborOpaque) continue;
|
||||
|
||||
ChunkMesher.AddFace(verts, x, y, z, face, block.GetTextureTile(face.Kind));
|
||||
this.AddFace(verts, x, y, z, face, block.GetTextureTile(face.Kind));
|
||||
}
|
||||
}
|
||||
|
||||
return verts.ToArray();
|
||||
}
|
||||
|
||||
private static void AddFace(List<float> verts, int x, int y, int z, Face face, TileCoord tile)
|
||||
private void AddFace(List<float> verts, int x, int y, int z, Face face, TileCoord tile)
|
||||
{
|
||||
float uBase = tile.X * ChunkMesher.TileSize;
|
||||
float uBase = tile.X * this._tileU;
|
||||
// Image is flipped on load, so offset from the top to keep tile (0,0) = top-left.
|
||||
float vBase = 1f - (tile.Y + 1) * ChunkMesher.TileSize;
|
||||
float vBase = 1f - (tile.Y + 1) * this._tileV;
|
||||
|
||||
foreach (int corner in ChunkMesher.Triangle)
|
||||
{
|
||||
@@ -93,8 +99,8 @@ public sealed class ChunkMesher : IChunkMesher
|
||||
float py = y + face.Base.y + face.U.y * cu + face.V.y * cv;
|
||||
float pz = z + face.Base.z + face.U.z * cu + face.V.z * cv;
|
||||
|
||||
float u = uBase + cu * ChunkMesher.TileSize;
|
||||
float v = vBase + cv * ChunkMesher.TileSize;
|
||||
float u = uBase + cu * this._tileU;
|
||||
float v = vBase + cv * this._tileV;
|
||||
|
||||
verts.Add(px); verts.Add(py); verts.Add(pz);
|
||||
verts.Add(u); verts.Add(v);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
using OpenTK.Mathematics;
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Persistence;
|
||||
|
||||
namespace VoxelEngine.World;
|
||||
|
||||
public interface IWorld
|
||||
{
|
||||
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk);
|
||||
public Chunk.Chunk? GetChunk(Vector3i chunkPos);
|
||||
public IBlock GetBlock(int worldX, int worldY, int worldZ);
|
||||
IChunkModificationStore ModificationStore { get; }
|
||||
|
||||
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);
|
||||
374
World/Streaming/ChunkStreamer.cs
Normal file
374
World/Streaming/ChunkStreamer.cs
Normal file
@@ -0,0 +1,374 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using OpenTK.Mathematics;
|
||||
using Serilog;
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Chunk;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||
|
||||
namespace VoxelEngine.World.Streaming;
|
||||
|
||||
public sealed class ChunkStreamer : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<ChunkStreamer>();
|
||||
|
||||
private readonly Configuration _cfg;
|
||||
private readonly IWorld _world;
|
||||
private readonly Func<Vector3i, VoxelChunk> _generate;
|
||||
private readonly IChunkMesher _mesher;
|
||||
private readonly BlockRegistry _registry;
|
||||
private readonly IJobExecutor _executor;
|
||||
private readonly Func<float[], IChunkRenderer> _rendererFactory;
|
||||
|
||||
private readonly Dictionary<Vector3i, IChunkRenderer> _renderers = new();
|
||||
private readonly HashSet<Vector3i> _tracked = new();
|
||||
private readonly HashSet<Vector3i> _ready = new();
|
||||
private readonly HashSet<Vector3i> _dirty = new();
|
||||
private readonly PriorityQueue<Vector3i, int> _pendingLoad = new();
|
||||
private readonly ConcurrentQueue<ChunkJobResult> _completed = new();
|
||||
private readonly Queue<Vector3i> _pendingUnload = new();
|
||||
private readonly HashSet<Vector3i> _failed = new();
|
||||
|
||||
private Vector3i _lastPlayerChunk = new(int.MinValue, int.MinValue, int.MinValue);
|
||||
private int _inFlight;
|
||||
private bool _spawnReady;
|
||||
private bool _spawnAnchorSet;
|
||||
private Vector3i _spawnAnchor;
|
||||
|
||||
public int RendererCount => this._renderers.Count;
|
||||
public bool IsReady(Vector3i pos) => this._ready.Contains(pos);
|
||||
public bool SpawnReady => this._spawnReady;
|
||||
|
||||
public void MarkDirty(Vector3i chunkPos)
|
||||
{
|
||||
if (!this._ready.Contains(chunkPos)) return;
|
||||
this._dirty.Add(chunkPos);
|
||||
}
|
||||
|
||||
public ChunkStreamer(
|
||||
Configuration cfg,
|
||||
IWorld world,
|
||||
Func<Vector3i, VoxelChunk> generate,
|
||||
IChunkMesher mesher,
|
||||
BlockRegistry registry,
|
||||
IJobExecutor executor,
|
||||
Func<float[], IChunkRenderer> rendererFactory)
|
||||
{
|
||||
this._cfg = cfg;
|
||||
this._world = world;
|
||||
this._generate = generate;
|
||||
this._mesher = mesher;
|
||||
this._registry = registry;
|
||||
this._executor = executor;
|
||||
this._rendererFactory = rendererFactory;
|
||||
}
|
||||
|
||||
public void Tick(Vector3i playerChunkPos)
|
||||
{
|
||||
if (playerChunkPos != this._lastPlayerChunk)
|
||||
{
|
||||
this.RecomputeTargetSet(playerChunkPos);
|
||||
this._lastPlayerChunk = playerChunkPos;
|
||||
}
|
||||
|
||||
// Unloads first to free in-flight slots, then dispatch BEFORE drain:
|
||||
// with a synchronous executor (tests), Submit() runs work to completion
|
||||
// before returning, so a single tick must drain its own dispatches.
|
||||
// With the production threadpool executor, jobs don't finish in this
|
||||
// tick -- Drain harvests results submitted on previous ticks. Either
|
||||
// way this ordering is correct.
|
||||
this.ProcessUnloads();
|
||||
this.DispatchJobs();
|
||||
this.DrainCompleted();
|
||||
this.UpdateSpawnReady();
|
||||
}
|
||||
|
||||
public void DrawAll(Action<Vector3i, IChunkRenderer> drawCallback)
|
||||
{
|
||||
foreach (var kvp in this._renderers) drawCallback(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var r in this._renderers.Values) r.Dispose();
|
||||
this._renderers.Clear();
|
||||
}
|
||||
|
||||
private void RecomputeTargetSet(Vector3i p)
|
||||
{
|
||||
if (!this._spawnAnchorSet)
|
||||
{
|
||||
this._spawnAnchor = p;
|
||||
this._spawnAnchorSet = true;
|
||||
}
|
||||
|
||||
int h = this._cfg.RenderDistanceHorizontal;
|
||||
int v = this._cfg.RenderDistanceVertical;
|
||||
|
||||
var target = new HashSet<Vector3i>();
|
||||
for (int dx = -h; dx <= h; dx++)
|
||||
for (int dy = -v; dy <= v; dy++)
|
||||
for (int dz = -h; dz <= h; dz++)
|
||||
{
|
||||
int cy = p.Y + dy;
|
||||
if (cy < 0 || cy >= WorldConstants.WorldHeightChunks) continue;
|
||||
target.Add(new Vector3i(p.X + dx, cy, p.Z + dz));
|
||||
}
|
||||
|
||||
foreach (var pos in target)
|
||||
{
|
||||
if (this._failed.Contains(pos)) continue;
|
||||
if (this._tracked.Add(pos))
|
||||
{
|
||||
int dx = pos.X - p.X;
|
||||
int dy = pos.Y - p.Y;
|
||||
int dz = pos.Z - p.Z;
|
||||
int priority = dx * dx
|
||||
+ dz * dz
|
||||
+ dy * dy * this._cfg.VerticalPriorityWeight;
|
||||
this._pendingLoad.Enqueue(pos, priority);
|
||||
}
|
||||
}
|
||||
|
||||
// Anything currently tracked but no longer in target → unload.
|
||||
var toRemove = new List<Vector3i>();
|
||||
foreach (var tracked in this._tracked)
|
||||
{
|
||||
if (!target.Contains(tracked)) toRemove.Add(tracked);
|
||||
}
|
||||
foreach (var pos in toRemove)
|
||||
{
|
||||
this._tracked.Remove(pos);
|
||||
this._pendingUnload.Enqueue(pos);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainCompleted()
|
||||
{
|
||||
int budget = this._cfg.MaxMeshUploadsPerFrame;
|
||||
while (budget > 0 && this._completed.TryDequeue(out ChunkJobResult? result))
|
||||
{
|
||||
this._inFlight--;
|
||||
budget--;
|
||||
this.InstallResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessUnloads()
|
||||
{
|
||||
while (this._pendingUnload.TryDequeue(out Vector3i pos))
|
||||
{
|
||||
if (this._renderers.TryGetValue(pos, out IChunkRenderer? renderer))
|
||||
{
|
||||
renderer.Dispose();
|
||||
this._renderers.Remove(pos);
|
||||
}
|
||||
this._world.RemoveChunk(pos);
|
||||
this._ready.Remove(pos);
|
||||
this._failed.Remove(pos);
|
||||
Log.Debug("Unloaded {Pos}", pos);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSpawnReady()
|
||||
{
|
||||
if (this._spawnReady || !this._spawnAnchorSet) return;
|
||||
|
||||
int hs = this._cfg.SpawnReadyRadiusHorizontal;
|
||||
int v = this._cfg.RenderDistanceVertical;
|
||||
Vector3i a = this._spawnAnchor;
|
||||
|
||||
for (int dx = -hs; dx <= hs; dx++)
|
||||
for (int dy = -v; dy <= v; dy++)
|
||||
for (int dz = -hs; dz <= hs; dz++)
|
||||
{
|
||||
int cy = a.Y + dy;
|
||||
if (cy < 0 || cy >= WorldConstants.WorldHeightChunks) continue;
|
||||
if (!this._ready.Contains(new Vector3i(a.X + dx, cy, a.Z + dz))) return;
|
||||
}
|
||||
this._spawnReady = true;
|
||||
Log.Information("Spawn area ready at anchor {Anchor}", a);
|
||||
}
|
||||
|
||||
private void InstallResult(ChunkJobResult result)
|
||||
{
|
||||
if (result.Error != null || result.Mesh == null)
|
||||
{
|
||||
this._failed.Add(result.Pos);
|
||||
Log.Error(result.Error, "Chunk {Pos} failed to load", result.Pos);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.IsRemeshOnly)
|
||||
{
|
||||
if (result.Chunk == null) { Log.Error("Load result for {Pos} had null chunk", result.Pos); return; }
|
||||
this._world.SetChunk(result.Pos, result.Chunk);
|
||||
}
|
||||
|
||||
if (this._renderers.TryGetValue(result.Pos, out IChunkRenderer? old))
|
||||
{
|
||||
old.Dispose();
|
||||
this._renderers.Remove(result.Pos);
|
||||
}
|
||||
// Skip renderer allocation for empty meshes (pure-air chunks above
|
||||
// terrain, or chunks whose every face culls). The chunk still counts
|
||||
// as ready — DrawAll's iteration over _renderers naturally no-ops.
|
||||
if (result.Mesh.Length > 0)
|
||||
{
|
||||
this._renderers[result.Pos] = this._rendererFactory(result.Mesh);
|
||||
}
|
||||
this._ready.Add(result.Pos);
|
||||
this._dirty.Remove(result.Pos);
|
||||
|
||||
if (!result.IsRemeshOnly)
|
||||
{
|
||||
this.MarkCardinalNeighborsDirty(result.Pos);
|
||||
Log.Debug("Loaded {Pos}", result.Pos);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug("Remeshed {Pos}", result.Pos);
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkCardinalNeighborsDirty(Vector3i pos)
|
||||
{
|
||||
Vector3i[] offsets =
|
||||
{
|
||||
new(-1, 0, 0), new(1, 0, 0),
|
||||
new(0, -1, 0), new(0, 1, 0),
|
||||
new(0, 0, -1), new(0, 0, 1),
|
||||
};
|
||||
foreach (var off in offsets)
|
||||
{
|
||||
var neighbor = pos + off;
|
||||
if (this._ready.Contains(neighbor)) this._dirty.Add(neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchJobs()
|
||||
{
|
||||
// Dirty re-meshes first (correctness), then new loads (player view).
|
||||
while (this._inFlight < this._cfg.MaxGenerateJobsInFlight && this._dirty.Count > 0)
|
||||
{
|
||||
Vector3i pos = this._dirty.First();
|
||||
this._dirty.Remove(pos);
|
||||
this.DispatchRemesh(pos);
|
||||
}
|
||||
|
||||
while (this._inFlight < this._cfg.MaxGenerateJobsInFlight && this._pendingLoad.Count > 0)
|
||||
{
|
||||
Vector3i pos = this._pendingLoad.Dequeue();
|
||||
this.DispatchLoad(pos);
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchRemesh(Vector3i pos)
|
||||
{
|
||||
VoxelChunk? chunk = this._world.GetChunk(pos);
|
||||
if (chunk == null) return; // got unloaded between MarkDirty and now
|
||||
|
||||
NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);
|
||||
VoxelChunk snapshotChunk = ChunkStreamer.CopyChunk(chunk); // worker reads from copy
|
||||
|
||||
this._inFlight++;
|
||||
|
||||
IChunkMesher mesher = this._mesher;
|
||||
BlockRegistry registry = this._registry;
|
||||
var completed = this._completed;
|
||||
|
||||
this._executor.Submit(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
float[] mesh = mesher.Build(snapshotChunk, registry, (nx, ny, nz) =>
|
||||
neighbors.IsOpaqueOutside(nx, ny, nz, id => registry.Get(id).IsOpaque));
|
||||
completed.Enqueue(new ChunkJobResult(pos, snapshotChunk, mesh, Error: null, IsRemeshOnly: true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completed.Enqueue(new ChunkJobResult(pos, Chunk: null, Mesh: null, Error: ex, IsRemeshOnly: true));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static VoxelChunk CopyChunk(VoxelChunk source)
|
||||
{
|
||||
var copy = new VoxelChunk();
|
||||
for (int x = 0; x < VoxelChunk.Size; x++)
|
||||
for (int y = 0; y < VoxelChunk.Size; y++)
|
||||
for (int z = 0; z < VoxelChunk.Size; z++)
|
||||
{
|
||||
copy[x, y, z] = source[x, y, z];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private void DispatchLoad(Vector3i pos)
|
||||
{
|
||||
NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);
|
||||
this._world.ModificationStore.TryGetDiff(pos, out IReadOnlyDictionary<int, ushort>? diff);
|
||||
|
||||
var job = new ChunkJob(pos, neighbors, diff);
|
||||
this._inFlight++;
|
||||
|
||||
IChunkMesher mesher = this._mesher;
|
||||
BlockRegistry registry = this._registry;
|
||||
var generate = this._generate;
|
||||
var completed = this._completed;
|
||||
|
||||
this._executor.Submit(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
VoxelChunk chunk = generate(job.Pos);
|
||||
if (job.Diff != null)
|
||||
{
|
||||
foreach (var (idx, id) in job.Diff)
|
||||
{
|
||||
int lx = idx / (VoxelChunk.Size * VoxelChunk.Size);
|
||||
int ly = (idx / VoxelChunk.Size) % VoxelChunk.Size;
|
||||
int lz = idx % VoxelChunk.Size;
|
||||
chunk[lx, ly, lz] = id;
|
||||
}
|
||||
}
|
||||
|
||||
float[] mesh = mesher.Build(chunk, registry, (nx, ny, nz) =>
|
||||
job.Neighbors.IsOpaqueOutside(nx, ny, nz, id => registry.Get(id).IsOpaque));
|
||||
|
||||
completed.Enqueue(new ChunkJobResult(job.Pos, chunk, mesh, Error: null, IsRemeshOnly: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completed.Enqueue(new ChunkJobResult(job.Pos, Chunk: null, Mesh: null, Error: ex, IsRemeshOnly: false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private NeighborSnapshots SnapshotNeighbors(Vector3i pos)
|
||||
{
|
||||
ushort[]? Snap(Vector3i p)
|
||||
{
|
||||
VoxelChunk? c = this._world.GetChunk(p);
|
||||
if (c == null) return null;
|
||||
var copy = new ushort[VoxelChunk.Size * VoxelChunk.Size * VoxelChunk.Size];
|
||||
for (int x = 0; x < VoxelChunk.Size; x++)
|
||||
for (int y = 0; y < VoxelChunk.Size; y++)
|
||||
for (int z = 0; z < VoxelChunk.Size; z++)
|
||||
{
|
||||
copy[(x * VoxelChunk.Size + y) * VoxelChunk.Size + z] = c[x, y, z];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
return new NeighborSnapshots(
|
||||
negX: Snap(pos + new Vector3i(-1, 0, 0)),
|
||||
posX: Snap(pos + new Vector3i(1, 0, 0)),
|
||||
negY: Snap(pos + new Vector3i(0, -1, 0)),
|
||||
posY: Snap(pos + new Vector3i(0, 1, 0)),
|
||||
negZ: Snap(pos + new Vector3i(0, 0, -1)),
|
||||
posZ: Snap(pos + new Vector3i(0, 0, 1)));
|
||||
}
|
||||
}
|
||||
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 VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Persistence;
|
||||
|
||||
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 BlockRegistry _registry;
|
||||
|
||||
public World(BlockRegistry registry)
|
||||
{
|
||||
this._registry = registry;
|
||||
}
|
||||
public IChunkModificationStore ModificationStore { get; } = modStore;
|
||||
|
||||
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk)
|
||||
{
|
||||
this._chunks[chunkPos] = chunk;
|
||||
}
|
||||
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk) => this._chunks[chunkPos] = chunk;
|
||||
|
||||
public Chunk.Chunk? GetChunk(Vector3i chunkPos)
|
||||
{
|
||||
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)
|
||||
{
|
||||
Vector3i chunkPos = new(
|
||||
World.FloorDiv(worldX, Chunk.Chunk.Size),
|
||||
World.FloorDiv(worldY, Chunk.Chunk.Size),
|
||||
World.FloorDiv(worldZ, Chunk.Chunk.Size)
|
||||
);
|
||||
ChunkMath.FloorDiv(worldX, Chunk.Chunk.Size),
|
||||
ChunkMath.FloorDiv(worldY, Chunk.Chunk.Size),
|
||||
ChunkMath.FloorDiv(worldZ, Chunk.Chunk.Size));
|
||||
|
||||
Chunk.Chunk? chunk = this.GetChunk(chunkPos);
|
||||
if (chunk == null)
|
||||
{
|
||||
return this._registry.Air;
|
||||
}
|
||||
if (chunk == null) return 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;
|
||||
|
||||
return this._registry.Get(chunk[localX, localY, localZ]);
|
||||
return 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;
|
||||
if (value % size != 0 && value < 0)
|
||||
{
|
||||
quotient--;
|
||||
}
|
||||
return quotient;
|
||||
Vector3i chunkPos = new(
|
||||
ChunkMath.FloorDiv(worldX, Chunk.Chunk.Size),
|
||||
ChunkMath.FloorDiv(worldY, Chunk.Chunk.Size),
|
||||
ChunkMath.FloorDiv(worldZ, Chunk.Chunk.Size));
|
||||
|
||||
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();
|
||||
|
||||
}
|
||||
36
World/WorldGen/BiomeMap.cs
Normal file
36
World/WorldGen/BiomeMap.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class BiomeMap
|
||||
{
|
||||
private readonly NoiseSource _noise;
|
||||
private readonly VoronoiCells _cells;
|
||||
private readonly BiomeRegistry _biomes;
|
||||
|
||||
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes)
|
||||
{
|
||||
this._noise = noise;
|
||||
this._cells = cells;
|
||||
this._biomes = biomes;
|
||||
}
|
||||
|
||||
public int CellId(float worldX, float worldZ) => this._cells.Nearest(worldX, worldZ).Id;
|
||||
|
||||
public IBiome BiomeAt(float worldX, float worldZ)
|
||||
{
|
||||
VoronoiCells.Cell cell = this._cells.Nearest(worldX, worldZ);
|
||||
// Sample climate at the cell center so the biome is constant across the cell.
|
||||
float temperature = this._noise.Temperature(cell.CenterX, cell.CenterZ);
|
||||
float elevation = this._noise.Continent(cell.CenterX, cell.CenterZ);
|
||||
float forest = this._noise.Forest(cell.CenterX, cell.CenterZ);
|
||||
return BiomeMap.Classify(temperature, elevation, forest, this._biomes);
|
||||
}
|
||||
|
||||
// Pure climate -> biome lookup. Thresholds in noise units (~[-1,1]).
|
||||
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes)
|
||||
{
|
||||
if (elevation > 0.25f) return biomes.Mountains; // high ground
|
||||
if (temperature > 0.33f) return biomes.Desert; // hot, low
|
||||
if (temperature > -0.33f && forest > 0f) return biomes.Forest; // temperate, wooded
|
||||
return biomes.Plains; // temperate/cold, low
|
||||
}
|
||||
}
|
||||
20
World/WorldGen/BiomeRegistry.cs
Normal file
20
World/WorldGen/BiomeRegistry.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class BiomeRegistry
|
||||
{
|
||||
public IBiome Plains { get; }
|
||||
public IBiome Desert { get; }
|
||||
public IBiome Mountains { get; }
|
||||
public IBiome Forest { get; }
|
||||
|
||||
public BiomeRegistry(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this.Plains = new PlainsBiome(seed, blocks);
|
||||
this.Desert = new DesertBiome(seed, blocks);
|
||||
this.Mountains = new MountainsBiome(seed, blocks);
|
||||
this.Forest = new ForestBiome(seed, blocks);
|
||||
}
|
||||
}
|
||||
25
World/WorldGen/Biomes/DesertBiome.cs
Normal file
25
World/WorldGen/Biomes/DesertBiome.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
public sealed class DesertBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
|
||||
public string Name => "Desert";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public DesertBiome(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this._detail = new FastNoiseLite(seed + 101);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.015f);
|
||||
this.Surface = new SurfacePalette(blocks.Sand, blocks.Sand, blocks.Stone);
|
||||
}
|
||||
|
||||
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
|
||||
=> WorldConstants.SeaLevel + 4f * noise.Continent(worldX, worldZ)
|
||||
+ 1.5f * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
25
World/WorldGen/Biomes/ForestBiome.cs
Normal file
25
World/WorldGen/Biomes/ForestBiome.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
public sealed class ForestBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
|
||||
public string Name => "Forest";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public ForestBiome(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this._detail = new FastNoiseLite(seed + 103);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.03f);
|
||||
this.Surface = new SurfacePalette(blocks.Grass, blocks.Dirt, blocks.Stone);
|
||||
}
|
||||
|
||||
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
|
||||
=> WorldConstants.SeaLevel + 6f * noise.Continent(worldX, worldZ)
|
||||
+ 4f * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
32
World/WorldGen/Biomes/MountainsBiome.cs
Normal file
32
World/WorldGen/Biomes/MountainsBiome.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
public sealed class MountainsBiome : IBiome
|
||||
{
|
||||
private const int SnowLine = 96;
|
||||
private readonly FastNoiseLite _ridged;
|
||||
private readonly IBlock _stone;
|
||||
private readonly IBlock _snow;
|
||||
|
||||
public string Name => "Mountains";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public MountainsBiome(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this._ridged = new FastNoiseLite(seed + 102);
|
||||
this._ridged.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._ridged.SetFractalType(FastNoiseLite.FractalType.Ridged);
|
||||
this._ridged.SetFractalOctaves(4);
|
||||
this._ridged.SetFrequency(0.01f);
|
||||
this._stone = blocks.Stone;
|
||||
this._snow = blocks.Snow;
|
||||
this.Surface = new SurfacePalette(blocks.Stone, blocks.Stone, blocks.Stone);
|
||||
}
|
||||
|
||||
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
|
||||
=> WorldConstants.SeaLevel + 10f * noise.Continent(worldX, worldZ)
|
||||
+ 45f * this._ridged.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => worldY >= MountainsBiome.SnowLine ? this._snow : this._stone;
|
||||
}
|
||||
25
World/WorldGen/Biomes/PlainsBiome.cs
Normal file
25
World/WorldGen/Biomes/PlainsBiome.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
public sealed class PlainsBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
|
||||
public string Name => "Plains";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public PlainsBiome(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this._detail = new FastNoiseLite(seed + 100);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.02f);
|
||||
this.Surface = new SurfacePalette(blocks.Grass, blocks.Dirt, blocks.Stone);
|
||||
}
|
||||
|
||||
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
|
||||
=> WorldConstants.SeaLevel + 6f * noise.Continent(worldX, worldZ)
|
||||
+ 2f * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
178
World/WorldGen/CaveCarver.cs
Normal file
178
World/WorldGen/CaveCarver.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// Carves caves into an already-terrain-filled chunk. Stateless after
|
||||
// construction; the carve methods are pure functions of (chunk, chunkPos).
|
||||
// Safe to share across worker threads.
|
||||
public sealed class CaveCarver
|
||||
{
|
||||
private readonly int _seed;
|
||||
private readonly BlockRegistry _blocks;
|
||||
private readonly Configuration _cfg;
|
||||
private readonly FastNoiseLite _caveNoise;
|
||||
private readonly FastNoiseLite _wormDirNoise;
|
||||
|
||||
public CaveCarver(int seed, BlockRegistry blocks, Configuration cfg)
|
||||
{
|
||||
this._seed = seed;
|
||||
this._blocks = blocks;
|
||||
this._cfg = cfg;
|
||||
|
||||
this._caveNoise = new FastNoiseLite(seed + 100);
|
||||
this._caveNoise.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._caveNoise.SetFrequency(cfg.CaveNoiseFrequency);
|
||||
|
||||
this._wormDirNoise = new FastNoiseLite(seed + 200);
|
||||
this._wormDirNoise.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._wormDirNoise.SetFrequency(0.05f);
|
||||
}
|
||||
|
||||
public void Carve(VoxelChunk chunk, Vector3i chunkPos)
|
||||
{
|
||||
this.CarveNoiseCaves(chunk, chunkPos);
|
||||
this.CarveWormCaves(chunk, chunkPos);
|
||||
}
|
||||
|
||||
// Per-voxel 3D simplex threshold. Public for direct testability,
|
||||
// matching the pattern of WorldGenerator.BlockAt.
|
||||
public void CarveNoiseCaves(VoxelChunk chunk, Vector3i chunkPos)
|
||||
{
|
||||
int baseX = chunkPos.X * VoxelChunk.Size;
|
||||
int baseY = chunkPos.Y * VoxelChunk.Size;
|
||||
int baseZ = chunkPos.Z * 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++)
|
||||
{
|
||||
int worldY = baseY + y;
|
||||
if (worldY <= 0) continue;
|
||||
|
||||
ushort id = chunk[x, y, z];
|
||||
if (id == this._blocks.Air.Id) continue;
|
||||
if (id == this._blocks.Water.Id) continue;
|
||||
|
||||
float density = this._caveNoise.GetNoise(baseX + x, worldY, baseZ + z);
|
||||
if (density < this._cfg.CaveNoiseThreshold)
|
||||
{
|
||||
chunk[x, y, z] = this._blocks.Air.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void CarveWormCaves(VoxelChunk chunk, Vector3i chunkPos)
|
||||
{
|
||||
int cellSize = this._cfg.WormCellSize;
|
||||
int radius = (int)Math.Ceiling((double)this._cfg.WormMaxLength / cellSize) + 1;
|
||||
|
||||
int chunkBaseX = chunkPos.X * VoxelChunk.Size;
|
||||
int chunkBaseY = chunkPos.Y * VoxelChunk.Size;
|
||||
int chunkBaseZ = chunkPos.Z * VoxelChunk.Size;
|
||||
|
||||
int chunkCellX = CaveCarver.FloorDiv(chunkBaseX, cellSize);
|
||||
int chunkCellY = CaveCarver.FloorDiv(chunkBaseY, cellSize);
|
||||
int chunkCellZ = CaveCarver.FloorDiv(chunkBaseZ, cellSize);
|
||||
|
||||
for (int cdx = -radius; cdx <= radius; cdx++)
|
||||
for (int cdy = -radius; cdy <= radius; cdy++)
|
||||
for (int cdz = -radius; cdz <= radius; cdz++)
|
||||
{
|
||||
int cellX = chunkCellX + cdx;
|
||||
int cellY = chunkCellY + cdy;
|
||||
int cellZ = chunkCellZ + cdz;
|
||||
|
||||
uint h = WormHash.Hash(cellX, cellY, cellZ, this._seed);
|
||||
if ((h % 1000) >= (uint)this._cfg.WormSpawnProbabilityPermille) continue;
|
||||
|
||||
// Hash bit layout: [ox:6][oy:6][oz:6][theta:8][phi:6] = 32 bits
|
||||
float ox = (h & 0x3F) / 64f * cellSize;
|
||||
float oy = ((h >> 6) & 0x3F) / 64f * cellSize;
|
||||
float oz = ((h >> 12) & 0x3F) / 64f * cellSize;
|
||||
Vector3 pos = new(cellX * cellSize + ox,
|
||||
cellY * cellSize + oy,
|
||||
cellZ * cellSize + oz);
|
||||
|
||||
float theta = ((h >> 18) & 0xFF) / 255f * MathF.Tau;
|
||||
float phi = (((h >> 26) & 0x3F) / 63f * 2f - 1f) * 0.3f * MathF.PI;
|
||||
Vector3 dir = new(
|
||||
MathF.Cos(theta) * MathF.Cos(phi),
|
||||
MathF.Sin(phi),
|
||||
MathF.Sin(theta) * MathF.Cos(phi));
|
||||
dir = Vector3.Normalize(dir);
|
||||
|
||||
this.TraceAndCarveWorm(chunk, chunkPos, pos, dir);
|
||||
}
|
||||
}
|
||||
|
||||
private void TraceAndCarveWorm(VoxelChunk chunk, Vector3i chunkPos,
|
||||
Vector3 startPos, Vector3 startDir)
|
||||
{
|
||||
int maxLen = this._cfg.WormMaxLength;
|
||||
float minR = this._cfg.WormMinRadius;
|
||||
float maxR = this._cfg.WormMaxRadius;
|
||||
|
||||
Vector3 pos = startPos;
|
||||
Vector3 dir = startDir;
|
||||
|
||||
for (int step = 0; step < maxLen; step++)
|
||||
{
|
||||
float t = step / (float)maxLen;
|
||||
float bulgeT = 0.5f + 0.5f * MathF.Sin(t * MathF.Tau * 3f);
|
||||
float r = minR + (maxR - minR) * bulgeT;
|
||||
|
||||
this.CarveSphere(chunk, chunkPos, pos, r);
|
||||
|
||||
float px = this._wormDirNoise.GetNoise(pos.X, pos.Y, pos.Z);
|
||||
float py = this._wormDirNoise.GetNoise(pos.X + 100f, pos.Y, pos.Z);
|
||||
float pz = this._wormDirNoise.GetNoise(pos.X, pos.Y, pos.Z + 100f);
|
||||
Vector3 perturbation = new(px, py, pz);
|
||||
dir = Vector3.Normalize(dir + perturbation * 0.3f);
|
||||
|
||||
dir = new Vector3(dir.X, dir.Y * 0.7f, dir.Z);
|
||||
dir = Vector3.Normalize(dir);
|
||||
|
||||
pos += dir;
|
||||
}
|
||||
}
|
||||
|
||||
private void CarveSphere(VoxelChunk chunk, Vector3i chunkPos, Vector3 center, float r)
|
||||
{
|
||||
int baseX = chunkPos.X * VoxelChunk.Size;
|
||||
int baseY = chunkPos.Y * VoxelChunk.Size;
|
||||
int baseZ = chunkPos.Z * VoxelChunk.Size;
|
||||
|
||||
int xMin = Math.Max(0, (int)MathF.Floor(center.X - r) - baseX);
|
||||
int xMax = Math.Min(VoxelChunk.Size - 1, (int)MathF.Ceiling(center.X + r) - baseX);
|
||||
int yMin = Math.Max(0, (int)MathF.Floor(center.Y - r) - baseY);
|
||||
int yMax = Math.Min(VoxelChunk.Size - 1, (int)MathF.Ceiling(center.Y + r) - baseY);
|
||||
int zMin = Math.Max(0, (int)MathF.Floor(center.Z - r) - baseZ);
|
||||
int zMax = Math.Min(VoxelChunk.Size - 1, (int)MathF.Ceiling(center.Z + r) - baseZ);
|
||||
|
||||
float r2 = r * r;
|
||||
|
||||
for (int x = xMin; x <= xMax; x++)
|
||||
for (int y = yMin; y <= yMax; y++)
|
||||
for (int z = zMin; z <= zMax; z++)
|
||||
{
|
||||
int worldY = baseY + y;
|
||||
if (worldY <= 0) continue;
|
||||
|
||||
float vx = baseX + x + 0.5f;
|
||||
float vy = baseY + y + 0.5f;
|
||||
float vz = baseZ + z + 0.5f;
|
||||
float dx = vx - center.X, dy = vy - center.Y, dz = vz - center.Z;
|
||||
if (dx * dx + dy * dy + dz * dz > r2) continue;
|
||||
|
||||
ushort id = chunk[x, y, z];
|
||||
if (id == this._blocks.Air.Id) continue;
|
||||
if (id == this._blocks.Water.Id) continue;
|
||||
chunk[x, y, z] = this._blocks.Air.Id;
|
||||
}
|
||||
}
|
||||
|
||||
private static int FloorDiv(int a, int b) => a >= 0 ? a / b : -(((-a) + b - 1) / b);
|
||||
}
|
||||
2506
World/WorldGen/FastNoiseLite.cs
Normal file
2506
World/WorldGen/FastNoiseLite.cs
Normal file
File diff suppressed because it is too large
Load Diff
17
World/WorldGen/IBiome.cs
Normal file
17
World/WorldGen/IBiome.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public interface IBiome
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
// Absolute target surface height at the column (continuous, pre-rounding).
|
||||
float SampleHeight(NoiseSource noise, float worldX, float worldZ);
|
||||
|
||||
SurfacePalette Surface { get; }
|
||||
|
||||
// Surface block for a given height; lets biomes vary the top by altitude
|
||||
// (e.g. Mountains use Snow above the snow line). Defaults to Surface.Top.
|
||||
IBlock TopBlock(int worldY);
|
||||
}
|
||||
32
World/WorldGen/NoiseSource.cs
Normal file
32
World/WorldGen/NoiseSource.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// Seeded wrapper over FastNoiseLite. All Get* methods return roughly [-1, 1].
|
||||
public sealed class NoiseSource
|
||||
{
|
||||
private readonly FastNoiseLite _continent;
|
||||
private readonly FastNoiseLite _temperature;
|
||||
private readonly FastNoiseLite _forest;
|
||||
|
||||
public NoiseSource(int seed)
|
||||
{
|
||||
this._continent = new FastNoiseLite(seed);
|
||||
this._continent.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._continent.SetFractalType(FastNoiseLite.FractalType.FBm);
|
||||
this._continent.SetFractalOctaves(4);
|
||||
this._continent.SetFrequency(0.0035f);
|
||||
|
||||
this._temperature = new FastNoiseLite(seed + 1);
|
||||
this._temperature.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._temperature.SetFractalType(FastNoiseLite.FractalType.FBm);
|
||||
this._temperature.SetFractalOctaves(3);
|
||||
this._temperature.SetFrequency(0.0015f);
|
||||
|
||||
this._forest = new FastNoiseLite(seed + 2);
|
||||
this._forest.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._forest.SetFrequency(0.02f);
|
||||
}
|
||||
|
||||
public float Continent(float x, float z) => this._continent.GetNoise(x, z);
|
||||
public float Temperature(float x, float z) => this._temperature.GetNoise(x, z);
|
||||
public float Forest(float x, float z) => this._forest.GetNoise(x, z);
|
||||
}
|
||||
7
World/WorldGen/SurfacePalette.cs
Normal file
7
World/WorldGen/SurfacePalette.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// Surface block layering for a biome: the top block, the filler band below it,
|
||||
// and the deep "under" block (stone). Filler depth is fixed at 3 blocks.
|
||||
public readonly record struct SurfacePalette(IBlock Top, IBlock Filler, IBlock Under);
|
||||
38
World/WorldGen/TerrainSampler.cs
Normal file
38
World/WorldGen/TerrainSampler.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class TerrainSampler
|
||||
{
|
||||
// Box-blur radius (in blocks) applied to the biome-height field to smooth
|
||||
// biome borders. A full (2R+1)x(2R+1) kernel at 1-block spacing bounds the
|
||||
// per-block height change to ~maxBiomeHeightDiff / (2R+1), preventing cliffs.
|
||||
// A sparse kernel does NOT bound it (axis-aligned borders flip several samples
|
||||
// at once), so the kernel must be dense.
|
||||
private const int BlendRadius = 6;
|
||||
|
||||
private readonly NoiseSource _noise;
|
||||
private readonly BiomeMap _biomes;
|
||||
|
||||
public TerrainSampler(NoiseSource noise, BiomeMap biomes)
|
||||
{
|
||||
this._noise = noise;
|
||||
this._biomes = biomes;
|
||||
}
|
||||
|
||||
public int HeightAt(float worldX, float worldZ)
|
||||
{
|
||||
// For each kernel point find the biome THERE, but evaluate its height AT the
|
||||
// column. Averaging blurs the hard biome borders into smooth transitions.
|
||||
float sum = 0f;
|
||||
int count = 0;
|
||||
for (int dz = -TerrainSampler.BlendRadius; dz <= TerrainSampler.BlendRadius; dz++)
|
||||
for (int dx = -TerrainSampler.BlendRadius; dx <= TerrainSampler.BlendRadius; dx++)
|
||||
{
|
||||
IBiome biome = this._biomes.BiomeAt(worldX + dx, worldZ + dz);
|
||||
sum += biome.SampleHeight(this._noise, worldX, worldZ);
|
||||
count++;
|
||||
}
|
||||
float avg = sum / count;
|
||||
int h = (int)System.MathF.Round(avg);
|
||||
return System.Math.Clamp(h, 0, WorldConstants.WorldHeight - 1);
|
||||
}
|
||||
}
|
||||
60
World/WorldGen/VoronoiCells.cs
Normal file
60
World/WorldGen/VoronoiCells.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// A jittered-grid Voronoi partition. Pure integer-hash based, fully deterministic.
|
||||
public sealed class VoronoiCells
|
||||
{
|
||||
public readonly record struct Cell(int Id, float CenterX, float CenterZ);
|
||||
|
||||
private readonly int _seed;
|
||||
private readonly float _cellSize;
|
||||
|
||||
public VoronoiCells(int seed, float cellSize)
|
||||
{
|
||||
this._seed = seed;
|
||||
this._cellSize = cellSize;
|
||||
}
|
||||
|
||||
public Cell Nearest(float x, float z)
|
||||
{
|
||||
int gx = (int)System.MathF.Floor(x / this._cellSize);
|
||||
int gz = (int)System.MathF.Floor(z / this._cellSize);
|
||||
|
||||
float bestDist = float.MaxValue;
|
||||
int bestId = 0;
|
||||
float bestCx = 0f, bestCz = 0f;
|
||||
|
||||
for (int oz = -1; oz <= 1; oz++)
|
||||
for (int ox = -1; ox <= 1; ox++)
|
||||
{
|
||||
int cgx = gx + ox;
|
||||
int cgz = gz + oz;
|
||||
float jitterX = VoronoiCells.Jitter(cgx, cgz, this._seed);
|
||||
float jitterZ = VoronoiCells.Jitter(cgx, cgz, this._seed + 9871);
|
||||
float cx = (cgx + jitterX) * this._cellSize;
|
||||
float cz = (cgz + jitterZ) * this._cellSize;
|
||||
float dx = cx - x, dz = cz - z;
|
||||
float dist = dx * dx + dz * dz;
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
bestId = (int)VoronoiCells.Hash(cgx, cgz, this._seed);
|
||||
bestCx = cx;
|
||||
bestCz = cz;
|
||||
}
|
||||
}
|
||||
return new Cell(bestId, bestCx, bestCz);
|
||||
}
|
||||
|
||||
private static float Jitter(int x, int z, int seed)
|
||||
=> (VoronoiCells.Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
|
||||
|
||||
private static uint Hash(int x, int z, int seed)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
uint h = (uint)(x * 374761393 + z * 668265263 + seed * 362437);
|
||||
h = (h ^ (h >> 13)) * 1274126177u;
|
||||
return h ^ (h >> 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
World/WorldGen/WorldConstants.cs
Normal file
10
World/WorldGen/WorldConstants.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public static class WorldConstants
|
||||
{
|
||||
public const int WorldHeightChunks = 8;
|
||||
public const int WorldHeight = WorldConstants.WorldHeightChunks * VoxelChunk.Size; // 128
|
||||
public const int SeaLevel = 64;
|
||||
}
|
||||
67
World/WorldGen/WorldGenerator.cs
Normal file
67
World/WorldGen/WorldGenerator.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class WorldGenerator
|
||||
{
|
||||
private const int FillerDepth = 3;
|
||||
private const float CellSize = 96f;
|
||||
|
||||
private readonly BlockRegistry _blocks;
|
||||
private readonly BiomeMap _biomeMap;
|
||||
private readonly TerrainSampler _terrain;
|
||||
private readonly CaveCarver _caves;
|
||||
|
||||
public WorldGenerator(int seed, BlockRegistry blocks, Configuration cfg)
|
||||
{
|
||||
this._blocks = blocks;
|
||||
NoiseSource noise = new(seed);
|
||||
VoronoiCells cells = new(seed, WorldGenerator.CellSize);
|
||||
BiomeRegistry biomes = new(seed, blocks);
|
||||
this._biomeMap = new BiomeMap(noise, cells, biomes);
|
||||
this._terrain = new TerrainSampler(noise, this._biomeMap);
|
||||
this._caves = new CaveCarver(seed, blocks, cfg);
|
||||
}
|
||||
|
||||
public int SurfaceHeightAt(int worldX, int worldZ) => this._terrain.HeightAt(worldX, worldZ);
|
||||
|
||||
public VoxelChunk Generate(Vector3i chunkPos)
|
||||
{
|
||||
VoxelChunk chunk = new();
|
||||
int baseX = chunkPos.X * VoxelChunk.Size;
|
||||
int baseY = chunkPos.Y * VoxelChunk.Size;
|
||||
int baseZ = chunkPos.Z * VoxelChunk.Size;
|
||||
|
||||
for (int x = 0; x < VoxelChunk.Size; x++)
|
||||
for (int z = 0; z < VoxelChunk.Size; z++)
|
||||
{
|
||||
int worldX = baseX + x;
|
||||
int worldZ = baseZ + z;
|
||||
int height = this._terrain.HeightAt(worldX, worldZ);
|
||||
IBiome biome = this._biomeMap.BiomeAt(worldX, worldZ);
|
||||
|
||||
for (int y = 0; y < VoxelChunk.Size; y++)
|
||||
{
|
||||
int worldY = baseY + y;
|
||||
chunk[x, y, z] = WorldGenerator.BlockAt(worldY, height, biome, this._blocks);
|
||||
}
|
||||
}
|
||||
|
||||
this._caves.Carve(chunk, chunkPos);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks)
|
||||
{
|
||||
if (worldY > surfaceHeight)
|
||||
return worldY <= WorldConstants.SeaLevel ? blocks.Water.Id : blocks.Air.Id;
|
||||
if (worldY == surfaceHeight)
|
||||
return biome.TopBlock(worldY).Id;
|
||||
if (worldY >= surfaceHeight - WorldGenerator.FillerDepth)
|
||||
return biome.Surface.Filler.Id;
|
||||
return biome.Surface.Under.Id;
|
||||
}
|
||||
}
|
||||
21
World/WorldGen/WormHash.cs
Normal file
21
World/WorldGen/WormHash.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// Deterministic 32-bit mixing hash for worm-cell coordinates.
|
||||
// Cell coords can be negative; cast through unchecked to preserve bit pattern.
|
||||
internal static class WormHash
|
||||
{
|
||||
public static uint Hash(int cellX, int cellY, int cellZ, int seed)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
uint h = (uint)seed;
|
||||
h ^= (uint)cellX * 73856093u;
|
||||
h ^= (uint)cellY * 19349663u;
|
||||
h ^= (uint)cellZ * 83492791u;
|
||||
h ^= h >> 13;
|
||||
h *= 0x5bd1e995u;
|
||||
h ^= h >> 15;
|
||||
return h;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user