Compare commits
32 Commits
4fe552b8bd
...
feat/proce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89eda0c1f2 | ||
|
|
689536348b | ||
|
|
e034d10339 | ||
|
|
96b8fffcd4 | ||
|
|
9193df2710 | ||
|
|
102d8166aa | ||
|
|
225c6f7253 | ||
|
|
cba6b8c761 | ||
|
|
715444d10a | ||
|
|
3c7e7a46a3 | ||
|
|
2677b1b003 | ||
|
|
9c28bf152f | ||
|
|
94930faccb | ||
|
|
43527e617b | ||
|
|
9daa9f8267 | ||
|
|
89ac70f1ab | ||
|
|
262933238c | ||
|
|
6c808b507b | ||
| 22531c6ee1 | |||
| 3704d7ce1e | |||
| aa1aa6fec6 | |||
| 2e36d40386 | |||
| 9410fee78b | |||
| 645fd00868 | |||
| 15be8f6059 | |||
| 070bdfeac1 | |||
| 093b3deabf | |||
| 013d524544 | |||
| 4587f81071 | |||
| 8166c25ba6 | |||
| d1c8c22705 | |||
| 2c95c7831a |
@@ -1,13 +1,35 @@
|
||||
#version 330 core
|
||||
in vec2 vUv;
|
||||
in float vShade;
|
||||
in float vWorldY;
|
||||
in vec3 vWorldPos;
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform sampler2D atlas;
|
||||
uniform float u_caveDarkStartY;
|
||||
uniform float u_caveDarkEndY;
|
||||
uniform float u_caveMinBrightness;
|
||||
|
||||
uniform vec3 u_cameraPos;
|
||||
uniform float u_fogStart;
|
||||
uniform float u_fogEnd;
|
||||
uniform vec3 u_skyColor;
|
||||
|
||||
void main()
|
||||
{
|
||||
vec4 texel = texture(atlas, vUv);
|
||||
FragColor = vec4(texel.rgb * vShade, 1.0);
|
||||
|
||||
// Y-based cave darkness.
|
||||
float t = clamp((u_caveDarkStartY - vWorldY) /
|
||||
(u_caveDarkStartY - u_caveDarkEndY), 0.0, 1.0);
|
||||
float brightness = mix(1.0, u_caveMinBrightness, t);
|
||||
vec3 color = texel.rgb * vShade * brightness;
|
||||
|
||||
// Distance fog to the sky color, applied after cave darkness.
|
||||
float dist = length(vWorldPos - u_cameraPos);
|
||||
float fog = clamp((dist - u_fogStart) / (u_fogEnd - u_fogStart), 0.0, 1.0);
|
||||
color = mix(color, u_skyColor, fog);
|
||||
|
||||
FragColor = vec4(color, 1.0);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ layout(location = 2) in float aShade;
|
||||
|
||||
out vec2 vUv;
|
||||
out float vShade;
|
||||
out float vWorldY;
|
||||
out vec3 vWorldPos;
|
||||
|
||||
uniform mat4 model;
|
||||
uniform mat4 view;
|
||||
@@ -14,6 +16,9 @@ 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;
|
||||
vWorldPos = worldPos.xyz;
|
||||
vWorldY = worldPos.y;
|
||||
gl_Position = worldPos * view * projection;
|
||||
}
|
||||
|
||||
10
Assets/shaders/debugtext.frag
Normal file
10
Assets/shaders/debugtext.frag
Normal file
@@ -0,0 +1,10 @@
|
||||
#version 330 core
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform vec3 u_color;
|
||||
|
||||
void main()
|
||||
{
|
||||
FragColor = vec4(u_color, 1.0);
|
||||
}
|
||||
12
Assets/shaders/debugtext.vert
Normal file
12
Assets/shaders/debugtext.vert
Normal file
@@ -0,0 +1,12 @@
|
||||
#version 330 core
|
||||
|
||||
// Positions are already in normalized device coordinates; u_offset nudges the
|
||||
// whole batch by a few pixels (in NDC) for the drop-shadow pass.
|
||||
layout (location = 0) in vec2 aPos;
|
||||
|
||||
uniform vec2 u_offset;
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = vec4(aPos + u_offset, 0.0, 1.0);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,21 +1,208 @@
|
||||
namespace VoxelEngine.Core;
|
||||
|
||||
using OpenTK.Mathematics;
|
||||
|
||||
public sealed class Configuration
|
||||
{
|
||||
#region -- World --
|
||||
|
||||
// World generation seed. null → a fresh random seed each run; set a value for
|
||||
// a reproducible world.
|
||||
public int? Seed { get; set; } = null;
|
||||
|
||||
// Blocks above the terrain surface at (0,0) where the player spawns.
|
||||
public float SpawnHeightOffset { get; set; } = 2f;
|
||||
|
||||
#endregion -- World --
|
||||
|
||||
#region -- Rendering & View --
|
||||
|
||||
// Horizontal view radius in chunks (Chebyshev). Drives how far terrain loads
|
||||
// and the fog / far-plane distance. Higher = see further, much heavier load.
|
||||
public int RenderDistanceHorizontal { get; set; } = 8;
|
||||
public int RenderDistanceVertical { get; set; } = 2;
|
||||
public int SpawnReadyRadiusHorizontal { get; set; } = 1;
|
||||
|
||||
public int MaxGenerateJobsInFlight { get; set; } = 8;
|
||||
public int MaxMeshUploadsPerFrame { get; set; } = 4;
|
||||
public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2);
|
||||
|
||||
public float MoveSpeed { get; set; } = 8f;
|
||||
public float MouseSensitivity { get; set; } = 0.15f;
|
||||
// Camera vertical field of view, in degrees.
|
||||
public float Fov { get; set; } = 60f;
|
||||
|
||||
// Near clip plane distance (world units); anything nearer is not drawn.
|
||||
// (The far plane is derived from RenderDistanceHorizontal over in Player.)
|
||||
public float CameraNearPlane { get; set; } = 0.1f;
|
||||
|
||||
// Distance fog begins at this fraction of the fog-end distance (fog end is
|
||||
// derived from RenderDistanceHorizontal). 0.7 → fog starts at 70% of the edge.
|
||||
public float FogStartFraction { get; set; } = 0.7f;
|
||||
|
||||
// Sky / clear colour, also the distance-fog target colour (RGB, 0–1).
|
||||
public Vector3 SkyColor { get; set; } = new(0.45f, 0.65f, 0.95f);
|
||||
|
||||
#endregion -- Rendering & View --
|
||||
|
||||
#region -- Window --
|
||||
|
||||
// Initial window size in pixels.
|
||||
public int WindowWidth { get; set; } = 1280;
|
||||
public int WindowHeight { get; set; } = 720;
|
||||
|
||||
public int? Seed { get; set; } = null;
|
||||
#endregion -- Window --
|
||||
|
||||
#region -- Chunk Streaming --
|
||||
|
||||
// Horizontal radius (chunks) around spawn that must finish loading before the
|
||||
// loading screen clears.
|
||||
public int SpawnReadyRadiusHorizontal { get; set; } = 1;
|
||||
|
||||
// Max chunk generate/mesh jobs running on worker threads at once.
|
||||
public int MaxGenerateJobsInFlight { get; set; } = 8;
|
||||
|
||||
// Max finished chunk meshes uploaded to the GPU per frame (caps per-frame hitches).
|
||||
public int MaxMeshUploadsPerFrame { get; set; } = 4;
|
||||
|
||||
// Background worker threads used for chunk generation/meshing.
|
||||
public int WorkerThreadCount { get; set; } = Math.Max(1, Environment.ProcessorCount / 2);
|
||||
|
||||
// Multiplier applied to (dy)² 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;
|
||||
|
||||
#endregion -- Chunk Streaming --
|
||||
|
||||
#region -- Player & Controls --
|
||||
|
||||
// Movement speed, in blocks per second.
|
||||
public float MoveSpeed { get; set; } = 8f;
|
||||
|
||||
// Mouse-look sensitivity (degrees of rotation per pixel of mouse movement).
|
||||
public float MouseSensitivity { get; set; } = 0.15f;
|
||||
|
||||
#endregion -- Player & Controls --
|
||||
|
||||
#region -- World Gen: Terrain Shape --
|
||||
|
||||
// Average width in blocks of one biome region (Voronoi cell). Larger = bigger,
|
||||
// fewer biomes.
|
||||
public float BiomeCellSize { get; set; } = 960f;
|
||||
|
||||
// Blocks over which terrain height eases from one biome to its neighbour across
|
||||
// their shared border. Larger = gentler, wider transitions.
|
||||
public float BiomeBlendWidth { get; set; } = 128f;
|
||||
|
||||
// Filler layers (dirt/sand) placed directly below the surface block before the
|
||||
// biome's solid under-material (usually stone) begins.
|
||||
public int SurfaceFillerDepth { get; set; } = 3;
|
||||
|
||||
#endregion -- World Gen: Terrain Shape --
|
||||
|
||||
#region -- World Gen: Climate Noise --
|
||||
// Low-frequency fields that decide biome placement. Lower frequency = larger,
|
||||
// smoother climate regions; more octaves = more fine variation.
|
||||
|
||||
// Elevation field — high values become Mountains, low values lowland.
|
||||
public float ContinentFrequency { get; set; } = 0.0035f;
|
||||
public int ContinentOctaves { get; set; } = 4;
|
||||
|
||||
// Temperature field — high values become Desert.
|
||||
public float TemperatureFrequency { get; set; } = 0.0015f;
|
||||
public int TemperatureOctaves { get; set; } = 3;
|
||||
|
||||
// Forest-density field — high values become Forest (else Plains).
|
||||
public float ForestFrequency { get; set; } = 0.02f;
|
||||
|
||||
#endregion -- World Gen: Climate Noise --
|
||||
|
||||
#region -- World Gen: Biome Selection --
|
||||
// Thresholds on the climate fields (values ≈ -1..1), tested in order:
|
||||
// mountains → desert → forest → plains.
|
||||
|
||||
// Elevation at/above which a cell is Mountains.
|
||||
public float MountainsElevationThreshold { get; set; } = 0.25f;
|
||||
|
||||
// Temperature above which a non-mountain cell is Desert.
|
||||
public float DesertTemperatureThreshold { get; set; } = 0.33f;
|
||||
|
||||
// Minimum temperature for Forest (colder than this → Plains).
|
||||
public float ForestTemperatureThreshold { get; set; } = -0.33f;
|
||||
|
||||
// Minimum forest-density for Forest (below this → Plains).
|
||||
public float ForestDensityThreshold { get; set; } = 0f;
|
||||
|
||||
#endregion -- World Gen: Biome Selection --
|
||||
|
||||
#region -- World Gen: Biome Surface Heights --
|
||||
// Per biome: surfaceHeight = SeaLevel + ContinentAmplitude·continentNoise
|
||||
// + DetailAmplitude·detailNoise. Amplitudes are in blocks; detail frequency
|
||||
// sets the bumpiness scale (higher = smaller, choppier features).
|
||||
|
||||
// Plains — gentle rolling grassland.
|
||||
public float PlainsContinentAmplitude { get; set; } = 24f;
|
||||
public float PlainsDetailAmplitude { get; set; } = 8f;
|
||||
public float PlainsDetailFrequency { get; set; } = 0.01f;
|
||||
|
||||
// Forest — like plains, a touch hillier.
|
||||
public float ForestContinentAmplitude { get; set; } = 24f;
|
||||
public float ForestDetailAmplitude { get; set; } = 16f;
|
||||
public float ForestDetailFrequency { get; set; } = 0.01f;
|
||||
|
||||
// Desert — flat with low dunes.
|
||||
public float DesertContinentAmplitude { get; set; } = 16f;
|
||||
public float DesertDetailAmplitude { get; set; } = 6f;
|
||||
public float DesertDetailFrequency { get; set; } = 0.008f;
|
||||
|
||||
// Mountains — tall ridged peaks (uses ridged noise instead of a smooth detail
|
||||
// field). Peaks roughly cap at SeaLevel + ContinentAmplitude + RidgedAmplitude.
|
||||
public float MountainsContinentAmplitude { get; set; } = 40f;
|
||||
public float MountainsRidgedAmplitude { get; set; } = 45f;
|
||||
public float MountainsRidgedFrequency { get; set; } = 0.005f;
|
||||
public int MountainsRidgedOctaves { get; set; } = 4;
|
||||
// World-Y at/above which mountain tops render as snow.
|
||||
public int MountainsSnowLine { get; set; } = 280;
|
||||
|
||||
#endregion -- World Gen: Biome Surface Heights --
|
||||
|
||||
#region -- Cave Generation --
|
||||
|
||||
// Spatial frequency of the 3D simplex cave-density field; higher = smaller,
|
||||
// more frequent pockets.
|
||||
public float CaveNoiseFrequency { get; set; } = 0.03f;
|
||||
|
||||
// Density cutoff below which a solid voxel becomes air; higher = more cave.
|
||||
public float CaveNoiseThreshold { get; set; } = -0.3f;
|
||||
|
||||
// Edge length (blocks) of a worm-spawn grid cell; one worm candidate per cell.
|
||||
public int WormCellSize { get; set; } = 64;
|
||||
|
||||
// Per-cell chance out of 1000 that a worm spawns; 300 = 30%.
|
||||
public int WormSpawnProbabilityPermille { get; set; } = 300;
|
||||
|
||||
// Number of trace steps (≈ blocks) a worm travels.
|
||||
public int WormMaxLength { get; set; } = 80;
|
||||
|
||||
// Worm tunnel radius bounds; interpolated + sine-modulated along the path to
|
||||
// form bulges/chambers.
|
||||
public float WormMinRadius { get; set; } = 1.5f;
|
||||
public float WormMaxRadius { get; set; } = 3.5f;
|
||||
|
||||
// Ceiling: caves carve only at or below this world-Y ("no caves above").
|
||||
// Default 256 = SeaLevel → caves stay below sea level, keeping the taller
|
||||
// surface terrain solid. Raise toward WorldHeight-1 to allow surface caves.
|
||||
public int MinCaveY { get; set; } = 256;
|
||||
|
||||
#endregion -- Cave Generation --
|
||||
|
||||
#region -- Cave Lighting --
|
||||
// Underground darkening: brightness fades from CaveDarkStartY down to
|
||||
// CaveDarkEndY, never dropping below CaveMinBrightness.
|
||||
|
||||
public float CaveDarkStartY { get; set; } = 264f; // WorldConstants.SeaLevel + 8
|
||||
public float CaveDarkEndY { get; set; } = 224f; // WorldConstants.SeaLevel - 32
|
||||
public float CaveMinBrightness { get; set; } = 0.15f; // floor brightness deep underground (0–1)
|
||||
|
||||
#endregion -- Cave Lighting --
|
||||
|
||||
#region -- Debug Overlay --
|
||||
|
||||
// Pixel size of each font "pixel" in the on-screen debug text (biome + coords).
|
||||
public int DebugOverlayTextScale { get; set; } = 3;
|
||||
|
||||
#endregion -- Debug Overlay --
|
||||
}
|
||||
|
||||
47
Core/Game.cs
47
Core/Game.cs
@@ -24,11 +24,13 @@ public sealed class Game : GameWindow
|
||||
private Shader _shader = null!;
|
||||
private Texture _texture = null!;
|
||||
private LoadingScreen _loading = null!;
|
||||
private DebugOverlay _debugOverlay = null!;
|
||||
private World.World _world = null!;
|
||||
private InMemoryChunkModificationStore _modStore = null!;
|
||||
private WorldGenerator _generator = null!;
|
||||
private ChunkStreamer _streamer = null!;
|
||||
private Player _player = null!;
|
||||
private double _logAccumSeconds;
|
||||
|
||||
public Game(Configuration cfg, GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
|
||||
: base(gameSettings, nativeSettings)
|
||||
@@ -40,34 +42,36 @@ public sealed class Game : GameWindow
|
||||
{
|
||||
base.OnLoad();
|
||||
|
||||
Log.Information("Game starting with seed={Seed} renderDistanceH={H} renderDistanceV={V}",
|
||||
this._cfg.Seed, this._cfg.RenderDistanceHorizontal, this._cfg.RenderDistanceVertical);
|
||||
Log.Information("Game starting with seed={Seed} renderDistanceH={H}",
|
||||
this._cfg.Seed, this._cfg.RenderDistanceHorizontal);
|
||||
|
||||
GL.ClearColor(0.45f, 0.65f, 0.95f, 1.0f);
|
||||
GL.ClearColor(this._cfg.SkyColor.X, this._cfg.SkyColor.Y, this._cfg.SkyColor.Z, 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._debugOverlay = new DebugOverlay();
|
||||
|
||||
this._modStore = new InMemoryChunkModificationStore();
|
||||
this._world = new World.World(this._registry, this._modStore);
|
||||
|
||||
int seed = this._cfg.Seed ?? new Random().Next();
|
||||
this._generator = new WorldGenerator(seed, this._registry);
|
||||
this._generator = new WorldGenerator(seed, this._registry, this._cfg);
|
||||
|
||||
// Spawn at terrain surface above (0, 0).
|
||||
int surfaceY = this._generator.SurfaceHeightAt(0, 0);
|
||||
Vector3 spawnPosition = new(0.5f, surfaceY + 2f, 0.5f);
|
||||
Vector3 spawnPosition = new(0.5f, surfaceY + this._cfg.SpawnHeightOffset, 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);
|
||||
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
|
||||
this._streamer = new ChunkStreamer(
|
||||
this._cfg,
|
||||
this._world,
|
||||
this._generator.Generate,
|
||||
this._generator.SurfaceHeightAt,
|
||||
mesher,
|
||||
this._registry,
|
||||
new ThreadPoolJobExecutor(),
|
||||
@@ -116,15 +120,43 @@ public sealed class Game : GameWindow
|
||||
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);
|
||||
float fogEnd = this._cfg.RenderDistanceHorizontal * Chunk.Size;
|
||||
this._shader.SetFloat("u_fogEnd", fogEnd);
|
||||
this._shader.SetFloat("u_fogStart", fogEnd * this._cfg.FogStartFraction);
|
||||
this._shader.SetVector3("u_cameraPos", this._player.Camera.Position);
|
||||
this._shader.SetVector3("u_skyColor", this._cfg.SkyColor);
|
||||
this._texture.Use(TextureUnit.Texture0);
|
||||
|
||||
this._streamer.DrawAll((pos, renderer) =>
|
||||
Matrix4 viewProj = this._player.Camera.GetViewMatrix() * this._player.Camera.GetProjectionMatrix();
|
||||
Frustum frustum = Frustum.FromViewProjection(viewProj);
|
||||
|
||||
int drawn = this._streamer.DrawVisible(
|
||||
pos =>
|
||||
{
|
||||
Vector3 min = new(pos.X * Chunk.Size, pos.Y * Chunk.Size, pos.Z * Chunk.Size);
|
||||
Vector3 max = min + new Vector3(Chunk.Size);
|
||||
return frustum.Intersects(min, max);
|
||||
},
|
||||
(pos, renderer) =>
|
||||
{
|
||||
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
|
||||
pos.X * Chunk.Size, pos.Y * Chunk.Size, pos.Z * Chunk.Size));
|
||||
renderer.Draw();
|
||||
});
|
||||
|
||||
Vector3 playerPos = this._player.Position;
|
||||
int px = (int)System.Math.Floor(playerPos.X);
|
||||
int py = (int)System.Math.Floor(playerPos.Y);
|
||||
int pz = (int)System.Math.Floor(playerPos.Z);
|
||||
this._debugOverlay.Draw(new[]
|
||||
{
|
||||
$"BIOME: {this._generator.BiomeNameAt(px, pz)}",
|
||||
$"XYZ: {px}, {py}, {pz}",
|
||||
}, this.ClientSize.X, this.ClientSize.Y, this._cfg.DebugOverlayTextScale);
|
||||
|
||||
this.SwapBuffers();
|
||||
}
|
||||
|
||||
@@ -139,6 +171,7 @@ public sealed class Game : GameWindow
|
||||
{
|
||||
Log.Information("Game shutting down");
|
||||
this._streamer?.Dispose();
|
||||
this._debugOverlay?.Dispose();
|
||||
this._loading?.Dispose();
|
||||
this._texture?.Dispose();
|
||||
this._shader?.Dispose();
|
||||
|
||||
@@ -29,7 +29,12 @@ public sealed class Player
|
||||
public Player(Configuration cfg, Vector3 spawnPosition, float aspectRatio)
|
||||
{
|
||||
this._cfg = cfg;
|
||||
this.Camera = new Camera(spawnPosition, aspectRatio) { Fov = cfg.Fov };
|
||||
this.Camera = new Camera(spawnPosition, aspectRatio)
|
||||
{
|
||||
Fov = cfg.Fov,
|
||||
NearPlane = cfg.CameraNearPlane,
|
||||
FarPlane = (cfg.RenderDistanceHorizontal + 1) * VoxelChunk.Size,
|
||||
};
|
||||
}
|
||||
|
||||
public void Move(Vector3 delta) => this.Camera.Move(delta);
|
||||
|
||||
@@ -20,7 +20,7 @@ Log.Logger = new LoggerConfiguration()
|
||||
|
||||
try
|
||||
{
|
||||
var cfg = new Configuration();
|
||||
Configuration cfg = new();
|
||||
|
||||
NativeWindowSettings nativeSettings = new()
|
||||
{
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed class Camera
|
||||
public float Yaw { get; private set; } = -90f; // degrees; -90 looks down -Z
|
||||
public float Pitch { get; private set; } // degrees
|
||||
public float Fov { get; set; } = 60f; // degrees
|
||||
public float NearPlane { get; set; } = 0.1f; // world units
|
||||
public float FarPlane { get; set; } = 100f; // world units
|
||||
public float AspectRatio { get; set; }
|
||||
|
||||
public Vector3 Front { get; private set; } = -Vector3.UnitZ;
|
||||
@@ -36,7 +38,7 @@ public sealed class Camera
|
||||
|
||||
public Matrix4 GetProjectionMatrix() =>
|
||||
Matrix4.CreatePerspectiveFieldOfView(
|
||||
MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, 0.1f, 100f);
|
||||
MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, this.NearPlane, this.FarPlane);
|
||||
|
||||
private void UpdateVectors()
|
||||
{
|
||||
@@ -48,7 +50,7 @@ public sealed class Camera
|
||||
MathF.Sin(pitch),
|
||||
MathF.Sin(yaw) * MathF.Cos(pitch)));
|
||||
|
||||
this.Right = Vector3.Normalize(Vector3.Cross(this.Front, Camera.WorldUp));
|
||||
this.Right = Vector3.Normalize(Vector3.Cross(this.Front, WorldUp));
|
||||
this.Up = Vector3.Normalize(Vector3.Cross(this.Right, this.Front));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ using OpenTK.Graphics.OpenGL4;
|
||||
|
||||
namespace VoxelEngine.Rendering;
|
||||
|
||||
public sealed class ChunkRenderer : VoxelEngine.World.Streaming.IChunkRenderer
|
||||
public sealed class ChunkRenderer : World.Streaming.IChunkRenderer
|
||||
{
|
||||
private const int FloatsPerVertex = 6; // pos.xyz, uv, shade
|
||||
|
||||
@@ -12,8 +12,8 @@ public sealed class ChunkRenderer : VoxelEngine.World.Streaming.IChunkRenderer
|
||||
|
||||
public ChunkRenderer(float[] vertices)
|
||||
{
|
||||
this._vertexCount = vertices.Length / ChunkRenderer.FloatsPerVertex;
|
||||
int stride = ChunkRenderer.FloatsPerVertex * sizeof(float);
|
||||
this._vertexCount = vertices.Length / FloatsPerVertex;
|
||||
int stride = FloatsPerVertex * sizeof(float);
|
||||
|
||||
this._vao = GL.GenVertexArray();
|
||||
GL.BindVertexArray(this._vao);
|
||||
|
||||
164
Rendering/DebugOverlay.cs
Normal file
164
Rendering/DebugOverlay.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using OpenTK.Graphics.OpenGL4;
|
||||
using OpenTK.Mathematics;
|
||||
|
||||
namespace VoxelEngine.Rendering;
|
||||
|
||||
// Minimal, asset-free debug text. A 5x7 bitmap font is rendered as solid quads
|
||||
// (one quad per lit font pixel) through a trivial 2D shader. Glyphs are defined
|
||||
// as visual ASCII-art rows so they can be eyeballed for correctness. Meant for
|
||||
// low-volume debug HUDs, not general text.
|
||||
public sealed class DebugOverlay : IDisposable
|
||||
{
|
||||
private const int GlyphW = 5;
|
||||
private const int GlyphH = 7;
|
||||
|
||||
private readonly int _vao;
|
||||
private readonly int _vbo;
|
||||
private readonly Shader _shader;
|
||||
private readonly List<float> _verts = new();
|
||||
|
||||
public DebugOverlay()
|
||||
{
|
||||
this._shader = new Shader("Assets/shaders/debugtext.vert", "Assets/shaders/debugtext.frag");
|
||||
|
||||
this._vao = GL.GenVertexArray();
|
||||
GL.BindVertexArray(this._vao);
|
||||
this._vbo = GL.GenBuffer();
|
||||
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
|
||||
GL.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), 0);
|
||||
GL.EnableVertexAttribArray(0);
|
||||
GL.BindVertexArray(0);
|
||||
}
|
||||
|
||||
// Draws `lines` stacked top-down in the top-left corner. `scale` is pixels
|
||||
// per font pixel; fbWidth/fbHeight are the current framebuffer size, used to
|
||||
// map pixel coordinates to NDC. Drawn over the scene (no depth, no culling).
|
||||
public void Draw(IReadOnlyList<string> lines, int fbWidth, int fbHeight, int scale = 3)
|
||||
{
|
||||
if (fbWidth <= 0 || fbHeight <= 0 || lines.Count == 0) return;
|
||||
|
||||
this._verts.Clear();
|
||||
const int marginX = 10;
|
||||
const int marginY = 10;
|
||||
int lineStride = (GlyphH + 2) * scale;
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
this.AddString(lines[i], marginX, marginY + i * lineStride, scale, fbWidth, fbHeight);
|
||||
|
||||
if (this._verts.Count == 0) return;
|
||||
|
||||
GL.BindVertexArray(this._vao);
|
||||
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
|
||||
GL.BufferData(BufferTarget.ArrayBuffer, this._verts.Count * sizeof(float),
|
||||
this._verts.ToArray(), BufferUsageHint.DynamicDraw);
|
||||
|
||||
bool depthWasOn = GL.IsEnabled(EnableCap.DepthTest);
|
||||
bool cullWasOn = GL.IsEnabled(EnableCap.CullFace);
|
||||
GL.Disable(EnableCap.DepthTest);
|
||||
GL.Disable(EnableCap.CullFace);
|
||||
|
||||
this._shader.Use();
|
||||
int vertexCount = this._verts.Count / 2;
|
||||
|
||||
// Drop shadow (offset down-right by ~2px) then white text, so it stays
|
||||
// legible over sky, grass or stone alike.
|
||||
this._shader.SetVector2("u_offset", new Vector2(4f / fbWidth, -4f / fbHeight));
|
||||
this._shader.SetVector3("u_color", new Vector3(0f, 0f, 0f));
|
||||
GL.DrawArrays(PrimitiveType.Triangles, 0, vertexCount);
|
||||
|
||||
this._shader.SetVector2("u_offset", Vector2.Zero);
|
||||
this._shader.SetVector3("u_color", new Vector3(1f, 1f, 1f));
|
||||
GL.DrawArrays(PrimitiveType.Triangles, 0, vertexCount);
|
||||
|
||||
GL.BindVertexArray(0);
|
||||
if (depthWasOn) GL.Enable(EnableCap.DepthTest);
|
||||
if (cullWasOn) GL.Enable(EnableCap.CullFace);
|
||||
}
|
||||
|
||||
private void AddString(string text, int pxX, int pxY, int scale, int fbWidth, int fbHeight)
|
||||
{
|
||||
int cursorX = pxX;
|
||||
foreach (char ch in text)
|
||||
{
|
||||
if (DebugOverlay.Glyphs.TryGetValue(char.ToUpperInvariant(ch), out string[]? rows))
|
||||
{
|
||||
for (int r = 0; r < GlyphH; r++)
|
||||
for (int c = 0; c < GlyphW; c++)
|
||||
{
|
||||
if (rows[r][c] != ' ')
|
||||
this.AddQuad(cursorX + c * scale, pxY + r * scale, scale, fbWidth, fbHeight);
|
||||
}
|
||||
}
|
||||
cursorX += (GlyphW + 1) * scale; // one font-pixel gap between glyphs
|
||||
}
|
||||
}
|
||||
|
||||
// Append two triangles for the pixel-space square at (px,py) of side `s`.
|
||||
// Pixel space is top-left origin, y down; converted to NDC here.
|
||||
private void AddQuad(int px, int py, int s, int fbWidth, int fbHeight)
|
||||
{
|
||||
float x0 = px / (float)fbWidth * 2f - 1f;
|
||||
float x1 = (px + s) / (float)fbWidth * 2f - 1f;
|
||||
float y0 = 1f - py / (float)fbHeight * 2f;
|
||||
float y1 = 1f - (py + s) / (float)fbHeight * 2f;
|
||||
|
||||
this._verts.AddRange(new[]
|
||||
{
|
||||
x0, y0, x1, y0, x1, y1,
|
||||
x0, y0, x1, y1, x0, y1,
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GL.DeleteBuffer(this._vbo);
|
||||
GL.DeleteVertexArray(this._vao);
|
||||
this._shader.Dispose();
|
||||
}
|
||||
|
||||
// 5x7 font. Each glyph is 7 rows of 5 columns; non-space = lit pixel.
|
||||
private static readonly Dictionary<char, string[]> Glyphs = new()
|
||||
{
|
||||
['A'] = new[] { " ### ", "# #", "# #", "#####", "# #", "# #", "# #" },
|
||||
['B'] = new[] { "#### ", "# #", "# #", "#### ", "# #", "# #", "#### " },
|
||||
['C'] = new[] { " ####", "# ", "# ", "# ", "# ", "# ", " ####" },
|
||||
['D'] = new[] { "#### ", "# #", "# #", "# #", "# #", "# #", "#### " },
|
||||
['E'] = new[] { "#####", "# ", "# ", "#### ", "# ", "# ", "#####" },
|
||||
['F'] = new[] { "#####", "# ", "# ", "#### ", "# ", "# ", "# " },
|
||||
['G'] = new[] { " ####", "# ", "# ", "# ##", "# #", "# #", " ####" },
|
||||
['H'] = new[] { "# #", "# #", "# #", "#####", "# #", "# #", "# #" },
|
||||
['I'] = new[] { "#####", " # ", " # ", " # ", " # ", " # ", "#####" },
|
||||
['J'] = new[] { "#####", " # ", " # ", " # ", "# # ", "# # ", " ## " },
|
||||
['K'] = new[] { "# #", "# # ", "# # ", "## ", "# # ", "# # ", "# #" },
|
||||
['L'] = new[] { "# ", "# ", "# ", "# ", "# ", "# ", "#####" },
|
||||
['M'] = new[] { "# #", "## ##", "# # #", "# #", "# #", "# #", "# #" },
|
||||
['N'] = new[] { "# #", "## #", "# # #", "# ##", "# #", "# #", "# #" },
|
||||
['O'] = new[] { " ### ", "# #", "# #", "# #", "# #", "# #", " ### " },
|
||||
['P'] = new[] { "#### ", "# #", "# #", "#### ", "# ", "# ", "# " },
|
||||
['Q'] = new[] { " ### ", "# #", "# #", "# #", "# # #", "# # ", " ## #" },
|
||||
['R'] = new[] { "#### ", "# #", "# #", "#### ", "# # ", "# # ", "# #" },
|
||||
['S'] = new[] { " ####", "# ", "# ", " ### ", " #", " #", "#### " },
|
||||
['T'] = new[] { "#####", " # ", " # ", " # ", " # ", " # ", " # " },
|
||||
['U'] = new[] { "# #", "# #", "# #", "# #", "# #", "# #", " ### " },
|
||||
['V'] = new[] { "# #", "# #", "# #", "# #", "# #", " # # ", " # " },
|
||||
['W'] = new[] { "# #", "# #", "# #", "# # #", "# # #", "## ##", "# #" },
|
||||
['X'] = new[] { "# #", "# #", " # # ", " # ", " # # ", "# #", "# #" },
|
||||
['Y'] = new[] { "# #", "# #", " # # ", " # ", " # ", " # ", " # " },
|
||||
['Z'] = new[] { "#####", " #", " # ", " # ", " # ", "# ", "#####" },
|
||||
['0'] = new[] { " ### ", "# #", "# ##", "# # #", "## #", "# #", " ### " },
|
||||
['1'] = new[] { " # ", " ## ", " # ", " # ", " # ", " # ", "#####" },
|
||||
['2'] = new[] { " ### ", "# #", " #", " # ", " # ", " # ", "#####" },
|
||||
['3'] = new[] { "#####", " # ", " # ", " # ", " #", "# #", " ### " },
|
||||
['4'] = new[] { " # ", " ## ", " # # ", "# # ", "#####", " # ", " # " },
|
||||
['5'] = new[] { "#####", "# ", "#### ", " #", " #", "# #", " ### " },
|
||||
['6'] = new[] { " ### ", "# ", "# ", "#### ", "# #", "# #", " ### " },
|
||||
['7'] = new[] { "#####", " #", " # ", " # ", " # ", " # ", " # " },
|
||||
['8'] = new[] { " ### ", "# #", "# #", " ### ", "# #", "# #", " ### " },
|
||||
['9'] = new[] { " ### ", "# #", "# #", " ####", " #", " #", " ### " },
|
||||
[' '] = new[] { " ", " ", " ", " ", " ", " ", " " },
|
||||
[':'] = new[] { " ", " # ", " # ", " ", " # ", " # ", " " },
|
||||
[','] = new[] { " ", " ", " ", " ", " # ", " # ", " # " },
|
||||
['-'] = new[] { " ", " ", " ", "#####", " ", " ", " " },
|
||||
['.'] = new[] { " ", " ", " ", " ", " ", " ## ", " ## " },
|
||||
['/'] = new[] { " #", " #", " # ", " # ", " # ", "# ", "# " },
|
||||
};
|
||||
}
|
||||
61
Rendering/Frustum.cs
Normal file
61
Rendering/Frustum.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using OpenTK.Mathematics;
|
||||
|
||||
namespace VoxelEngine.Rendering;
|
||||
|
||||
// View-frustum for culling chunk AABBs. Built from a world→clip matrix in this
|
||||
// engine's row-vector convention (clip = worldPos * view * projection, matching
|
||||
// the chunk vertex shader). Planes point inward; a point is inside when it is on
|
||||
// the positive side of all six.
|
||||
public readonly struct Frustum
|
||||
{
|
||||
// Each Vector4 is (a, b, c, d) for plane a*x + b*y + c*z + d = 0, normalized.
|
||||
private readonly Vector4 _left, _right, _bottom, _top, _near, _far;
|
||||
|
||||
private Frustum(Vector4 l, Vector4 r, Vector4 b, Vector4 t, Vector4 n, Vector4 f)
|
||||
{
|
||||
this._left = l; this._right = r; this._bottom = b;
|
||||
this._top = t; this._near = n; this._far = f;
|
||||
}
|
||||
|
||||
// `vp` maps a world-space row vector p to clip space via p * vp. In that
|
||||
// convention clip.x = p · column0, clip.w = p · column3, etc., so each plane
|
||||
// is a combination of vp's columns (Gribb–Hartmann, row-vector form).
|
||||
public static Frustum FromViewProjection(Matrix4 vp)
|
||||
{
|
||||
Vector4 c0 = vp.Column0, c1 = vp.Column1, c2 = vp.Column2, c3 = vp.Column3;
|
||||
return new Frustum(
|
||||
Normalize(c3 + c0), // left: clip.w + clip.x >= 0
|
||||
Normalize(c3 - c0), // right: clip.w - clip.x >= 0
|
||||
Normalize(c3 + c1), // bottom: clip.w + clip.y >= 0
|
||||
Normalize(c3 - c1), // top: clip.w - clip.y >= 0
|
||||
Normalize(c3 + c2), // near: clip.w + clip.z >= 0 (GL NDC z in [-1,1])
|
||||
Normalize(c3 - c2)); // far: clip.w - clip.z >= 0
|
||||
}
|
||||
|
||||
// True if the axis-aligned box [min,max] is at least partially inside.
|
||||
public bool Intersects(Vector3 min, Vector3 max)
|
||||
{
|
||||
return InsideOrCrossing(this._left, min, max)
|
||||
&& InsideOrCrossing(this._right, min, max)
|
||||
&& InsideOrCrossing(this._bottom, min, max)
|
||||
&& InsideOrCrossing(this._top, min, max)
|
||||
&& InsideOrCrossing(this._near, min, max)
|
||||
&& InsideOrCrossing(this._far, min, max);
|
||||
}
|
||||
|
||||
// The box is fully outside a plane only if its most-positive vertex (the
|
||||
// corner farthest along the plane normal) is still behind the plane.
|
||||
private static bool InsideOrCrossing(Vector4 plane, Vector3 min, Vector3 max)
|
||||
{
|
||||
float px = plane.X >= 0 ? max.X : min.X;
|
||||
float py = plane.Y >= 0 ? max.Y : min.Y;
|
||||
float pz = plane.Z >= 0 ? max.Z : min.Z;
|
||||
return plane.X * px + plane.Y * py + plane.Z * pz + plane.W >= 0f;
|
||||
}
|
||||
|
||||
private static Vector4 Normalize(Vector4 p)
|
||||
{
|
||||
float len = new Vector3(p.X, p.Y, p.Z).Length;
|
||||
return len > 0f ? p / len : p;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ public sealed class Shader : IDisposable
|
||||
|
||||
public Shader(string vertexPath, string fragmentPath)
|
||||
{
|
||||
int vertex = Shader.CompileFromFile(ShaderType.VertexShader, vertexPath);
|
||||
int fragment = Shader.CompileFromFile(ShaderType.FragmentShader, fragmentPath);
|
||||
int vertex = CompileFromFile(ShaderType.VertexShader, vertexPath);
|
||||
int fragment = CompileFromFile(ShaderType.FragmentShader, fragmentPath);
|
||||
|
||||
this._handle = GL.CreateProgram();
|
||||
GL.AttachShader(this._handle, vertex);
|
||||
@@ -36,6 +36,15 @@ 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);
|
||||
|
||||
public void SetVector3(string name, Vector3 value) =>
|
||||
GL.Uniform3(this.Location(name), value.X, value.Y, value.Z);
|
||||
|
||||
public void SetVector2(string name, Vector2 value) =>
|
||||
GL.Uniform2(this.Location(name), value.X, value.Y);
|
||||
|
||||
private int Location(string name)
|
||||
{
|
||||
if (this._uniforms.TryGetValue(name, out int location)) return location;
|
||||
|
||||
@@ -71,4 +71,36 @@ public class BlockRegistryTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,4 +40,12 @@ public class CameraTests
|
||||
var camera = new Camera(Vector3.Zero, 1.5f);
|
||||
Assert.NotEqual(Matrix4.Identity, camera.GetProjectionMatrix());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarPlane_ChangesProjection()
|
||||
{
|
||||
var near = new Camera(Vector3.Zero, 1f) { FarPlane = 100f };
|
||||
var far = new Camera(Vector3.Zero, 1f) { FarPlane = 200f };
|
||||
Assert.NotEqual(near.GetProjectionMatrix(), far.GetProjectionMatrix());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ public class ConfigurationTests
|
||||
{
|
||||
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);
|
||||
@@ -32,7 +31,6 @@ public class ConfigurationTests
|
||||
|
||||
var c2 = new Configuration
|
||||
{
|
||||
RenderDistanceVertical = 2,
|
||||
SpawnReadyRadiusHorizontal = 0,
|
||||
MaxGenerateJobsInFlight = 2,
|
||||
MaxMeshUploadsPerFrame = 1,
|
||||
@@ -43,7 +41,6 @@ public class ConfigurationTests
|
||||
WindowWidth = 800,
|
||||
WindowHeight = 600,
|
||||
};
|
||||
Assert.Equal(2, c2.RenderDistanceVertical);
|
||||
Assert.Equal(0, c2.SpawnReadyRadiusHorizontal);
|
||||
Assert.Equal(2, c2.MaxGenerateJobsInFlight);
|
||||
Assert.Equal(1, c2.MaxMeshUploadsPerFrame);
|
||||
@@ -54,4 +51,34 @@ public class ConfigurationTests
|
||||
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(264f, c.CaveDarkStartY); // SeaLevel (256) + 8
|
||||
Assert.Equal(224f, c.CaveDarkEndY); // SeaLevel (256) - 32
|
||||
Assert.Equal(0.15f, c.CaveMinBrightness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_HaveVisibilityAndCaveCeilingConstants()
|
||||
{
|
||||
var c = new Configuration();
|
||||
|
||||
Assert.Equal(0.7f, c.FogStartFraction);
|
||||
Assert.Equal(new OpenTK.Mathematics.Vector3(0.45f, 0.65f, 0.95f), c.SkyColor);
|
||||
Assert.Equal(256, c.MinCaveY);
|
||||
}
|
||||
}
|
||||
|
||||
44
VoxelEngine.Tests/FrustumTests.cs
Normal file
44
VoxelEngine.Tests/FrustumTests.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests;
|
||||
|
||||
public class FrustumTests
|
||||
{
|
||||
// Camera at the origin, default yaw -90° → looks down -Z.
|
||||
private static Frustum MakeFrustum()
|
||||
{
|
||||
var cam = new Camera(new Vector3(0, 0, 0), 16f / 9f);
|
||||
return Frustum.FromViewProjection(cam.GetViewMatrix() * cam.GetProjectionMatrix());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AABBInFront_IsVisible()
|
||||
{
|
||||
var f = MakeFrustum();
|
||||
Assert.True(f.Intersects(new Vector3(-2, -2, -22), new Vector3(2, 2, -18)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AABBBehindCamera_IsCulled()
|
||||
{
|
||||
var f = MakeFrustum();
|
||||
Assert.False(f.Intersects(new Vector3(-2, -2, 18), new Vector3(2, 2, 22)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AABBStraddlingCamera_Intersects()
|
||||
{
|
||||
var f = MakeFrustum();
|
||||
Assert.True(f.Intersects(new Vector3(-2, -2, -5), new Vector3(2, 2, 5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AABBFarToTheSide_IsCulled()
|
||||
{
|
||||
var f = MakeFrustum();
|
||||
// Far along +X at a depth in front of the camera — outside horizontal FOV.
|
||||
Assert.False(f.Intersects(new Vector3(1000, -2, -5), new Vector3(1004, 2, -1)));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World;
|
||||
@@ -5,6 +6,7 @@ using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Chunk;
|
||||
using VoxelEngine.World.Persistence;
|
||||
using VoxelEngine.World.Streaming;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||
|
||||
@@ -35,9 +37,17 @@ internal sealed class FakeMesher : IChunkMesher
|
||||
=> 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)
|
||||
private static (ChunkStreamer streamer, World.World world, List<FakeRenderer> created) CreateStreamer(
|
||||
Configuration cfg, Func<int, int, int>? surfaceHeightAt = null)
|
||||
{
|
||||
var registry = new BlockRegistry();
|
||||
var store = new InMemoryChunkModificationStore();
|
||||
@@ -52,17 +62,30 @@ public class ChunkStreamerTests
|
||||
return r;
|
||||
};
|
||||
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
|
||||
var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory);
|
||||
Func<int, int, int> height = surfaceHeightAt ?? ((_, _) => 8);
|
||||
var streamer = new ChunkStreamer(cfg, world, generate, height, mesher, registry, executor, factory);
|
||||
return (streamer, world, created);
|
||||
}
|
||||
|
||||
// Ticks at a fixed player position until the streamer stops producing new
|
||||
// renderers (queued loads + their one-shot in-column remesh cascade have all
|
||||
// drained). Capped to avoid an infinite loop if something never settles.
|
||||
private static void TickToQuiescence(ChunkStreamer streamer, List<FakeRenderer> created, Vector3i player)
|
||||
{
|
||||
int last = -1;
|
||||
for (int i = 0; i < 200 && created.Count != last; i++)
|
||||
{
|
||||
last = created.Count;
|
||||
streamer.Tick(player);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_FirstCall_LoadsAllChunksInLoadBox()
|
||||
{
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
@@ -70,8 +93,9 @@ public class ChunkStreamerTests
|
||||
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
|
||||
// Load box H=1, V=0 → 3 × 1 × 3 = 9 chunks
|
||||
Assert.Equal(9, created.Count);
|
||||
// H=1 → a 3×3 horizontal box of full columns. The default test surface
|
||||
// (8) is below sea level, so each column loads bedrock→waterline; assert
|
||||
// the box is covered at the player's chunk Y rather than a raw count.
|
||||
for (int x = -1; x <= 1; x++)
|
||||
for (int z = -1; z <= 1; z++)
|
||||
{
|
||||
@@ -87,7 +111,6 @@ public class ChunkStreamerTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
@@ -120,7 +143,6 @@ public class ChunkStreamerTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 2, // cap drains per tick
|
||||
};
|
||||
@@ -141,19 +163,21 @@ public class ChunkStreamerTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0,
|
||||
RenderDistanceVertical = 2,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
var (streamer, world, created) = CreateStreamer(cfg);
|
||||
// Surface far above the world ceiling -> top chunk clamps to
|
||||
// WorldHeightChunks-1 (31). Full column loads cy 0..31 and nothing above.
|
||||
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 100_000);
|
||||
|
||||
// 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)));
|
||||
|
||||
for (int cy = 0; cy < WorldConstants.WorldHeightChunks; cy++)
|
||||
{
|
||||
var pos = new Vector3i(0, cy, 0);
|
||||
Assert.NotNull(world.GetChunk(pos));
|
||||
Assert.True(streamer.IsReady(pos));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -162,7 +186,6 @@ public class ChunkStreamerTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
@@ -179,7 +202,7 @@ public class ChunkStreamerTests
|
||||
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);
|
||||
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
|
||||
streamer.Tick(pos);
|
||||
|
||||
Assert.Equal(registry.Stone.Id, world.GetBlock(5, 6, 7).Id);
|
||||
@@ -191,18 +214,19 @@ public class ChunkStreamerTests
|
||||
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);
|
||||
streamer.Tick(new Vector3i(0, 0, 0)); // loads the (0,*,0) column
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0)));
|
||||
Assert.NotEmpty(created);
|
||||
|
||||
streamer.Tick(new Vector3i(5, 0, 0)); // (0,0,0) now out of range; (5,0,0) loaded
|
||||
streamer.Tick(new Vector3i(5, 0, 0)); // (0,*,0) now out of range → unloaded
|
||||
|
||||
// The player chunk (priority 0) loaded first, so created[0] is its
|
||||
// renderer; moving away must dispose it and drop the chunk.
|
||||
Assert.True(created[0].Disposed);
|
||||
Assert.Null(world.GetChunk(new Vector3i(0, 0, 0)));
|
||||
Assert.False(streamer.IsReady(new Vector3i(0, 0, 0)));
|
||||
@@ -214,7 +238,6 @@ public class ChunkStreamerTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
@@ -225,7 +248,7 @@ public class ChunkStreamerTests
|
||||
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 streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
|
||||
|
||||
var pos = new Vector3i(0, 0, 0);
|
||||
streamer.Tick(pos);
|
||||
@@ -241,17 +264,17 @@ public class ChunkStreamerTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
RenderDistanceVertical = 0,
|
||||
SpawnReadyRadiusHorizontal = 1,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 1, // throttle to expose progression
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
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));
|
||||
// The spawn area is now full columns (bedrock→waterline) per (x,z); tick
|
||||
// until it finishes loading. Cap generously to avoid an infinite loop.
|
||||
for (int i = 0; i < 200 && !streamer.SpawnReady; i++)
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
Assert.True(streamer.SpawnReady);
|
||||
}
|
||||
|
||||
@@ -261,23 +284,35 @@ public class ChunkStreamerTests
|
||||
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];
|
||||
// The default test surface (8) is below sea level, so (0,*,0) is a full
|
||||
// bedrock→waterline column. Loads mark in-column vertical neighbours
|
||||
// dirty, so tick to quiescence (loads + the one-shot remesh cascade)
|
||||
// before measuring. Remeshes do NOT re-dirty neighbours, so it settles.
|
||||
TickToQuiescence(streamer, created, new Vector3i(0, 0, 0));
|
||||
int afterLoad = created.Count;
|
||||
int liveBefore = streamer.RendererCount;
|
||||
|
||||
// Capture the live renderer currently installed for (0,0,0).
|
||||
IChunkRenderer? initialRenderer = null;
|
||||
streamer.DrawVisible(
|
||||
pos => pos == new Vector3i(0, 0, 0),
|
||||
(_, r) => initialRenderer = r);
|
||||
Assert.NotNull(initialRenderer);
|
||||
|
||||
streamer.MarkDirty(new Vector3i(0, 0, 0));
|
||||
streamer.Tick(new Vector3i(0, 0, 0)); // same chunk, but a remesh is queued
|
||||
streamer.Tick(new Vector3i(0, 0, 0)); // exactly one remesh queued (no cascade)
|
||||
|
||||
// Two renderers exist over the lifetime — old disposed, new live.
|
||||
Assert.Equal(2, created.Count);
|
||||
Assert.True(initialRenderer.Disposed);
|
||||
Assert.False(created[1].Disposed);
|
||||
// Exactly one extra renderer (the remesh) — old disposed, new live, live
|
||||
// count unchanged.
|
||||
Assert.Equal(afterLoad + 1, created.Count);
|
||||
Assert.Equal(liveBefore, streamer.RendererCount);
|
||||
Assert.True(((FakeRenderer)initialRenderer!).Disposed);
|
||||
Assert.False(created[^1].Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -286,7 +321,6 @@ public class ChunkStreamerTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
@@ -308,14 +342,23 @@ public class ChunkStreamerTests
|
||||
[Fact]
|
||||
public void MarkDirty_OnUntrackedChunk_IsNoOp()
|
||||
{
|
||||
var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0 };
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
var (streamer, _, created) = CreateStreamer(cfg);
|
||||
|
||||
streamer.MarkDirty(new Vector3i(99, 0, 99));
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
// Settle the full submerged column (loads + their one-shot remesh
|
||||
// cascade) so the renderer count is stable before the no-op check.
|
||||
TickToQuiescence(streamer, created, new Vector3i(0, 0, 0));
|
||||
int afterLoad = created.Count;
|
||||
|
||||
// Only the chunk in the load box was created; no spurious remesh.
|
||||
Assert.Single(created);
|
||||
streamer.MarkDirty(new Vector3i(99, 0, 99)); // not tracked
|
||||
streamer.Tick(new Vector3i(0, 0, 0)); // no remesh should be queued
|
||||
|
||||
Assert.Equal(afterLoad, created.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -324,7 +367,6 @@ public class ChunkStreamerTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
@@ -341,14 +383,193 @@ public class ChunkStreamerTests
|
||||
};
|
||||
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
|
||||
|
||||
var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory);
|
||||
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
|
||||
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
Assert.Equal(1, generateCalls);
|
||||
int callsAfterFirst = generateCalls;
|
||||
Assert.True(callsAfterFirst > 0);
|
||||
Assert.False(streamer.IsReady(new Vector3i(0, 0, 0)));
|
||||
|
||||
// Tick again — same chunk, same position. Should NOT call generate again.
|
||||
// Tick again — same position. Failed chunks must not be retried.
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
Assert.Equal(1, generateCalls);
|
||||
Assert.Equal(callsAfterFirst, generateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallResult_EmptyMesh_SkipsRendererCreationButMarksReady()
|
||||
{
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
var registry = new BlockRegistry();
|
||||
var store = new InMemoryChunkModificationStore();
|
||||
var world = new World.World(registry, store);
|
||||
var mesher = new EmptyMesher();
|
||||
var executor = new SynchronousJobExecutor();
|
||||
int rendererFactoryCalls = 0;
|
||||
Func<float[], IChunkRenderer> factory = m =>
|
||||
{
|
||||
rendererFactoryCalls++;
|
||||
return new FakeRenderer(m);
|
||||
};
|
||||
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
|
||||
|
||||
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 8, mesher, registry, executor, factory);
|
||||
var pos = new Vector3i(0, 0, 0);
|
||||
streamer.Tick(pos);
|
||||
|
||||
Assert.True(streamer.IsReady(pos));
|
||||
Assert.Equal(0, rendererFactoryCalls);
|
||||
Assert.Equal(0, streamer.RendererCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_VerticalPriorityWeight_PrefersHorizontalAtSameDistance()
|
||||
{
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 5, // player's chunk + 4 horizontal cardinals
|
||||
VerticalPriorityWeight = 4,
|
||||
};
|
||||
// Surface at worldY 48 -> top chunk 3, so a full column is cy 0..3 and
|
||||
// the player at Y=2 has in-column vertical neighbours at Y=1 and Y=3.
|
||||
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 48);
|
||||
|
||||
var playerPos = new Vector3i(0, 2, 0);
|
||||
streamer.Tick(playerPos);
|
||||
|
||||
// Drain budget = 5 = priority 0 (player) + 4 priority-1 (horizontal
|
||||
// cardinals). With VerticalPriorityWeight=4, vertical cardinals
|
||||
// (Y=1, Y=3) have priority 4 -- they must NOT be among the first 5.
|
||||
Assert.NotNull(world.GetChunk(playerPos));
|
||||
Assert.NotNull(world.GetChunk(new Vector3i( 1, 2, 0)));
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(-1, 2, 0)));
|
||||
Assert.NotNull(world.GetChunk(new Vector3i( 0, 2, 1)));
|
||||
Assert.NotNull(world.GetChunk(new Vector3i( 0, 2, -1)));
|
||||
Assert.Null(world.GetChunk(new Vector3i(0, 3, 0)));
|
||||
Assert.Null(world.GetChunk(new Vector3i(0, 1, 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LoadsFullColumn_FromBedrockToSurface()
|
||||
{
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0, // single column at the player
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
// Land surface at worldY 296 -> top chunk floor(296/16) = 18 (above the
|
||||
// sea-level chunk 16, so no water raises it). Full column loads cy 0..18;
|
||||
// chunk Y 19+ is air above the surface and must NOT load.
|
||||
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 296);
|
||||
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); // bedrock
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 9, 0))); // mid
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface
|
||||
Assert.Null(world.GetChunk(new Vector3i(0, 19, 0))); // air above surface
|
||||
Assert.Null(world.GetChunk(new Vector3i(0, 25, 0))); // air above surface
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LoadsWaterSurface_OverSubmergedColumn()
|
||||
{
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0, // single column at the player
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
// Terrain surface at worldY 100 (chunk 6) but well below SeaLevel 256.
|
||||
// Opaque water fills worldY 101..256, so the column must load through the
|
||||
// sea-level chunk floor(256/16)=16, not stop at the terrain top (chunk 6).
|
||||
var (streamer, world, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => 100);
|
||||
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 6, 0))); // terrain top
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 10, 0))); // submerged water column
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 16, 0))); // chunk holding the waterline (y=256)
|
||||
Assert.Null(world.GetChunk(new Vector3i(0, 17, 0))); // above the waterline -- not loaded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LoadsEditedSectionAboveSurface()
|
||||
{
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
var registry = new BlockRegistry();
|
||||
var store = new InMemoryChunkModificationStore();
|
||||
var world = new World.World(registry, store);
|
||||
var mesher = new FakeMesher();
|
||||
var executor = new SynchronousJobExecutor();
|
||||
Func<float[], IChunkRenderer> factory = m => new FakeRenderer(m);
|
||||
Func<Vector3i, VoxelChunk> generate = _ => new VoxelChunk();
|
||||
|
||||
// Land surface at worldY 296 -> top chunk 18 (above the sea-level chunk
|
||||
// 16, so no water raises it). A player edit sits in chunk Y 25, above the
|
||||
// loaded column; that edited section must still load.
|
||||
var edited = new Vector3i(0, 25, 0);
|
||||
store.RecordEdit(edited, 0, registry.Stone.Id);
|
||||
|
||||
var streamer = new ChunkStreamer(cfg, world, generate, (_, _) => 296, mesher, registry, executor, factory);
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
|
||||
Assert.NotNull(world.GetChunk(new Vector3i(0, 18, 0))); // surface column top
|
||||
Assert.NotNull(world.GetChunk(edited)); // edited air section above surface
|
||||
Assert.Null(world.GetChunk(new Vector3i(0, 20, 0))); // unedited air -- not loaded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecomputeTargetSet_CachesSurfaceHeights_OnlySamplesNewColumns()
|
||||
{
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
SpawnReadyRadiusHorizontal = 1,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
int calls = 0;
|
||||
var (streamer, _, _) = CreateStreamer(cfg, surfaceHeightAt: (_, _) => { calls++; return 8; });
|
||||
|
||||
streamer.Tick(new Vector3i(0, 0, 0));
|
||||
int afterFirst = calls; // 9 columns × 5 samples = 45 (spawn-check reuses cache)
|
||||
streamer.Tick(new Vector3i(1, 0, 0)); // overlaps 6 columns; only the cx=2 strip is new
|
||||
int newCalls = calls - afterFirst;
|
||||
|
||||
Assert.Equal(45, afterFirst);
|
||||
Assert.Equal(15, newCalls); // 3 new columns × 5 — cached columns are not re-sampled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawVisible_DrawsOnlyChunksPassingPredicate_AndReturnsCount()
|
||||
{
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
var (streamer, _, created) = CreateStreamer(cfg);
|
||||
streamer.Tick(new Vector3i(0, 0, 0)); // 9 columns, all chunk Y 0, non-empty meshes
|
||||
|
||||
int drawn = streamer.DrawVisible(
|
||||
pos => pos == new Vector3i(0, 0, 0), // only the centre chunk "visible"
|
||||
(_, r) => r.Draw());
|
||||
|
||||
Assert.Equal(1, drawn);
|
||||
Assert.Equal(1, created.Sum(r => r.DrawCount));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ public class StreamingIntegrationTests
|
||||
var registry = new BlockRegistry();
|
||||
var store = new InMemoryChunkModificationStore();
|
||||
var world = new World.World(registry, store);
|
||||
var gen = new WorldGenerator(seed: 1337, blocks: registry);
|
||||
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);
|
||||
var streamer = new ChunkStreamer(cfg, world, gen.Generate, gen.SurfaceHeightAt, mesher, registry, executor, factory);
|
||||
return (streamer, world, store);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ public class StreamingIntegrationTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 1,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
@@ -58,7 +57,6 @@ public class StreamingIntegrationTests
|
||||
var cfg = new Configuration
|
||||
{
|
||||
RenderDistanceHorizontal = 0,
|
||||
RenderDistanceVertical = 0,
|
||||
MaxGenerateJobsInFlight = 100,
|
||||
MaxMeshUploadsPerFrame = 100,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
@@ -9,38 +10,38 @@ 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 biomes = new BiomeRegistry(seed, blocks, new Configuration());
|
||||
var noise = new NoiseSource(seed, new Configuration());
|
||||
var cells = new VoronoiCells(seed, 96f);
|
||||
return (new BiomeMap(noise, cells, biomes), biomes);
|
||||
return (new BiomeMap(noise, cells, biomes, new Configuration()), biomes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_HighElevation_IsMountains()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes));
|
||||
Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes, new Configuration()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_HotLowElevation_IsDesert()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes));
|
||||
Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes, new Configuration()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_TemperateWooded_IsForest()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes));
|
||||
Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes, new Configuration()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_ColdLow_IsPlains()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes));
|
||||
Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes, new Configuration()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
@@ -10,7 +11,7 @@ public class BiomeTests
|
||||
public void Desert_TopAndFiller_AreSand()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
var biomes = new BiomeRegistry(42, blocks, new Configuration());
|
||||
Assert.Equal("Sand", biomes.Desert.Surface.Top.Name);
|
||||
Assert.Equal("Sand", biomes.Desert.Surface.Filler.Name);
|
||||
}
|
||||
@@ -19,16 +20,16 @@ public class BiomeTests
|
||||
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);
|
||||
var biomes = new BiomeRegistry(42, blocks, new Configuration());
|
||||
Assert.Equal("Stone", biomes.Mountains.TopBlock(200).Name); // below snow line
|
||||
Assert.Equal("Snow Block", biomes.Mountains.TopBlock(420).Name); // above snow line (384)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plains_TopBlock_IsGrass_RegardlessOfHeight()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
var biomes = new BiomeRegistry(42, blocks, new Configuration());
|
||||
Assert.Equal("Grass", biomes.Plains.TopBlock(40).Name);
|
||||
Assert.Equal("Grass", biomes.Plains.TopBlock(120).Name);
|
||||
}
|
||||
@@ -37,8 +38,8 @@ public class BiomeTests
|
||||
public void SampleHeight_IsDeterministic()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
var noise = new NoiseSource(42);
|
||||
var biomes = new BiomeRegistry(42, blocks, new Configuration());
|
||||
var noise = new NoiseSource(42, new Configuration());
|
||||
float h1 = biomes.Mountains.SampleHeight(noise, 100f, 200f);
|
||||
float h2 = biomes.Mountains.SampleHeight(noise, 100f, 200f);
|
||||
Assert.Equal(h1, h2);
|
||||
|
||||
202
VoxelEngine.Tests/WorldGen/CaveCarverTests.cs
Normal file
202
VoxelEngine.Tests/WorldGen/CaveCarverTests.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
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}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Carve_RespectsMinCaveY_NoCarvingAboveCeiling()
|
||||
{
|
||||
var cfg = new Configuration { MinCaveY = 20 };
|
||||
var carver = new CaveCarver(seed: 42, Blocks, cfg);
|
||||
Chunk c = MakeStoneChunk();
|
||||
|
||||
// Chunk Y=2 → worldY 32..47, all above MinCaveY=20 → must stay solid.
|
||||
carver.Carve(c, new Vector3i(0, 2, 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.Stone.Id, c[x, y, z]);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
@@ -8,8 +9,8 @@ public class NoiseSourceTests
|
||||
[Fact]
|
||||
public void SameSeed_SameCoord_IsDeterministic()
|
||||
{
|
||||
var a = new NoiseSource(42);
|
||||
var b = new NoiseSource(42);
|
||||
var a = new NoiseSource(42, new Configuration());
|
||||
var b = new NoiseSource(42, new Configuration());
|
||||
Assert.Equal(a.Continent(10f, 20f), b.Continent(10f, 20f));
|
||||
Assert.Equal(a.Temperature(10f, 20f), b.Temperature(10f, 20f));
|
||||
}
|
||||
@@ -17,7 +18,7 @@ public class NoiseSourceTests
|
||||
[Fact]
|
||||
public void Output_InMinusOneToOneRange()
|
||||
{
|
||||
var n = new NoiseSource(7);
|
||||
var n = new NoiseSource(7, new Configuration());
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
float c = n.Continent(i * 3.1f, i * 1.7f);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
@@ -9,11 +10,11 @@ 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 biomes = new BiomeRegistry(seed, blocks, new Configuration());
|
||||
var noise = new NoiseSource(seed, new Configuration());
|
||||
var cells = new VoronoiCells(seed, 96f);
|
||||
var map = new BiomeMap(noise, cells, biomes);
|
||||
return new TerrainSampler(noise, map);
|
||||
var map = new BiomeMap(noise, cells, biomes, new Configuration());
|
||||
return new TerrainSampler(noise, map, new Configuration());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -43,11 +44,11 @@ public class TerrainSamplerTests
|
||||
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.
|
||||
// The dense blur removes inter-biome cliffs. Remaining deltas are
|
||||
// rugged intra-Mountains ridged slopes, now ~4x larger after the
|
||||
// 128->512 rescale; 64 separates intended slopes from true cliffs.
|
||||
int dx = System.Math.Abs(sampler.HeightAt(x, z) - sampler.HeightAt(x + 1, z));
|
||||
Assert.True(dx <= 16, $"height jump {dx} at ({x},{z})");
|
||||
Assert.True(dx <= 64, $"height jump {dx} at ({x},{z})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Chunk;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
@@ -14,17 +15,17 @@ public class WorldGeneratorTests
|
||||
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)
|
||||
var biomes = new BiomeRegistry(1, blocks, new Configuration());
|
||||
ushort id = WorldGenerator.BlockAt(300, surfaceHeight: 290, biomes.Plains, blocks, fillerDepth: 3);
|
||||
Assert.Equal(blocks.Air.Id, id); // 300 > 290 (above surface) and 300 > SeaLevel(256)
|
||||
}
|
||||
|
||||
[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);
|
||||
var biomes = new BiomeRegistry(1, blocks, new Configuration());
|
||||
ushort id = WorldGenerator.BlockAt(60, surfaceHeight: 50, biomes.Plains, blocks, fillerDepth: 3);
|
||||
Assert.Equal(blocks.Water.Id, id); // 60 > 50 but 60 <= SeaLevel(64)
|
||||
}
|
||||
|
||||
@@ -32,8 +33,8 @@ public class WorldGeneratorTests
|
||||
public void BlockAt_Surface_IsBiomeTop()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks);
|
||||
var biomes = new BiomeRegistry(1, blocks, new Configuration());
|
||||
ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks, fillerDepth: 3);
|
||||
Assert.Equal(blocks.Sand.Id, id);
|
||||
}
|
||||
|
||||
@@ -41,8 +42,8 @@ public class WorldGeneratorTests
|
||||
public void BlockAt_FillerBand_IsBiomeFiller()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks);
|
||||
var biomes = new BiomeRegistry(1, blocks, new Configuration());
|
||||
ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks, fillerDepth: 3);
|
||||
Assert.Equal(blocks.Dirt.Id, id); // within 3 of surface
|
||||
}
|
||||
|
||||
@@ -50,16 +51,16 @@ public class WorldGeneratorTests
|
||||
public void BlockAt_Deep_IsStone()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks);
|
||||
var biomes = new BiomeRegistry(1, blocks, new Configuration());
|
||||
ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks, fillerDepth: 3);
|
||||
Assert.Equal(blocks.Stone.Id, id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_IsDeterministic()
|
||||
{
|
||||
var gen1 = new WorldGenerator(99, Blocks);
|
||||
var gen2 = new WorldGenerator(99, Blocks);
|
||||
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++)
|
||||
@@ -67,4 +68,32 @@ public class WorldGeneratorTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,8 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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(2, 2); // 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, 2); // 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(3, 2); // TODO: Add Texture
|
||||
}
|
||||
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(1, 2); // TODO: Add Texture
|
||||
}
|
||||
@@ -11,12 +11,18 @@ public sealed class BlockRegistry
|
||||
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, this.Sand, this.Snow, this.Water };
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -4,16 +4,16 @@ public sealed class Chunk
|
||||
{
|
||||
public const int Size = 16;
|
||||
|
||||
private readonly ushort[] _ids = new ushort[Chunk.Size * Chunk.Size * Chunk.Size];
|
||||
private readonly ushort[] _ids = new ushort[Size * Size * Size];
|
||||
|
||||
public ushort this[int x, int y, int z]
|
||||
{
|
||||
get => this._ids[Chunk.Index(x, y, z)];
|
||||
set => this._ids[Chunk.Index(x, y, z)] = value;
|
||||
get => this._ids[Index(x, y, z)];
|
||||
set => this._ids[Index(x, y, z)] = value;
|
||||
}
|
||||
|
||||
public static bool InBounds(int x, int y, int z) =>
|
||||
x is >= 0 and < Chunk.Size && y is >= 0 and < Chunk.Size && z is >= 0 and < Chunk.Size;
|
||||
x is >= 0 and < Size && y is >= 0 and < Size && z is >= 0 and < Size;
|
||||
|
||||
private static int Index(int x, int y, int z) => (x * Chunk.Size + y) * Chunk.Size + z;
|
||||
private static int Index(int x, int y, int z) => (x * Size + y) * Size + z;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ public sealed class ChunkMesher : IChunkMesher
|
||||
|
||||
public ChunkMesher(int atlasWidthPixels, int atlasHeightPixels)
|
||||
{
|
||||
this._tileU = (float)ChunkMesher.TileSizePixels / atlasWidthPixels;
|
||||
this._tileV = (float)ChunkMesher.TileSizePixels / atlasHeightPixels;
|
||||
this._tileU = (float)TileSizePixels / atlasWidthPixels;
|
||||
this._tileV = (float)TileSizePixels / atlasHeightPixels;
|
||||
}
|
||||
|
||||
// Per-face geometry: base corner + two in-plane edge vectors (u, v) with
|
||||
@@ -65,7 +65,7 @@ public sealed class ChunkMesher : IChunkMesher
|
||||
IBlock block = registry.Get(chunk[x, y, z]);
|
||||
if (!block.IsOpaque) continue;
|
||||
|
||||
foreach (Face face in ChunkMesher.Faces)
|
||||
foreach (Face face in Faces)
|
||||
{
|
||||
int nx = x + face.Normal.x;
|
||||
int ny = y + face.Normal.y;
|
||||
@@ -91,9 +91,9 @@ public sealed class ChunkMesher : IChunkMesher
|
||||
// Image is flipped on load, so offset from the top to keep tile (0,0) = top-left.
|
||||
float vBase = 1f - (tile.Y + 1) * this._tileV;
|
||||
|
||||
foreach (int corner in ChunkMesher.Triangle)
|
||||
foreach (int corner in Triangle)
|
||||
{
|
||||
(float cu, float cv) = ChunkMesher.Corners[corner];
|
||||
(float cu, float cv) = Corners[corner];
|
||||
|
||||
float px = x + face.Base.x + face.U.x * cu + face.V.x * cv;
|
||||
float py = y + face.Base.y + face.U.y * cu + face.V.y * cv;
|
||||
|
||||
@@ -14,9 +14,19 @@ public sealed class ChunkStreamer : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<ChunkStreamer>();
|
||||
|
||||
// How many chunks below the surface the spawn area must load before the
|
||||
// loading screen clears (just enough to stand on and see; the rest streams in).
|
||||
private const int SpawnReadyDepthChunks = 2;
|
||||
|
||||
private readonly Configuration _cfg;
|
||||
private readonly IWorld _world;
|
||||
private readonly Func<Vector3i, VoxelChunk> _generate;
|
||||
private readonly Func<int, int, int> _surfaceHeightAt;
|
||||
// Per chunk-column (cx,cz) surface-TOP height, memoized so each column's
|
||||
// samples are taken once and reused across ticks. Pruned to the horizontal
|
||||
// window each recompute so it stays bounded. Full-column loading only needs
|
||||
// the top; the bottom is always bedrock (cy 0).
|
||||
private readonly Dictionary<(int cx, int cz), int> _surfaceTopCache = new();
|
||||
private readonly IChunkMesher _mesher;
|
||||
private readonly BlockRegistry _registry;
|
||||
private readonly IJobExecutor _executor;
|
||||
@@ -38,6 +48,7 @@ public sealed class ChunkStreamer : IDisposable
|
||||
private Vector3i _spawnAnchor;
|
||||
|
||||
public int RendererCount => this._renderers.Count;
|
||||
public int TrackedCount => this._tracked.Count;
|
||||
public bool IsReady(Vector3i pos) => this._ready.Contains(pos);
|
||||
public bool SpawnReady => this._spawnReady;
|
||||
|
||||
@@ -51,6 +62,7 @@ public sealed class ChunkStreamer : IDisposable
|
||||
Configuration cfg,
|
||||
IWorld world,
|
||||
Func<Vector3i, VoxelChunk> generate,
|
||||
Func<int, int, int> surfaceHeightAt,
|
||||
IChunkMesher mesher,
|
||||
BlockRegistry registry,
|
||||
IJobExecutor executor,
|
||||
@@ -59,6 +71,7 @@ public sealed class ChunkStreamer : IDisposable
|
||||
this._cfg = cfg;
|
||||
this._world = world;
|
||||
this._generate = generate;
|
||||
this._surfaceHeightAt = surfaceHeightAt;
|
||||
this._mesher = mesher;
|
||||
this._registry = registry;
|
||||
this._executor = executor;
|
||||
@@ -85,14 +98,25 @@ public sealed class ChunkStreamer : IDisposable
|
||||
this.UpdateSpawnReady();
|
||||
}
|
||||
|
||||
public void DrawAll(Action<Vector3i, IChunkRenderer> drawCallback)
|
||||
// Draws every loaded chunk for which `isChunkVisible` returns true. Returns
|
||||
// the number of chunks actually drawn (passed the visibility test). The
|
||||
// visibility predicate lives with the caller so the streamer stays free of
|
||||
// any rendering/Frustum dependency.
|
||||
public int DrawVisible(Func<Vector3i, bool> isChunkVisible, Action<Vector3i, IChunkRenderer> draw)
|
||||
{
|
||||
foreach (var kvp in this._renderers) drawCallback(kvp.Key, kvp.Value);
|
||||
int drawn = 0;
|
||||
foreach (KeyValuePair<Vector3i, IChunkRenderer> kvp in this._renderers)
|
||||
{
|
||||
if (!isChunkVisible(kvp.Key)) continue;
|
||||
draw(kvp.Key, kvp.Value);
|
||||
drawn++;
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var r in this._renderers.Values) r.Dispose();
|
||||
foreach (IChunkRenderer r in this._renderers.Values) r.Dispose();
|
||||
this._renderers.Clear();
|
||||
}
|
||||
|
||||
@@ -104,42 +128,122 @@ public sealed class ChunkStreamer : IDisposable
|
||||
this._spawnAnchorSet = true;
|
||||
}
|
||||
|
||||
int h = this._cfg.RenderDistanceHorizontal;
|
||||
int v = this._cfg.RenderDistanceVertical;
|
||||
HashSet<Vector3i> target = new();
|
||||
this.CollectTargets(p, this._cfg.RenderDistanceHorizontal, target);
|
||||
|
||||
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)
|
||||
foreach (Vector3i pos in target)
|
||||
{
|
||||
if (this._failed.Contains(pos)) continue;
|
||||
if (this._tracked.Add(pos))
|
||||
{
|
||||
int priority = (pos.X - p.X) * (pos.X - p.X)
|
||||
+ (pos.Y - p.Y) * (pos.Y - p.Y)
|
||||
+ (pos.Z - p.Z) * (pos.Z - p.Z);
|
||||
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)
|
||||
List<Vector3i> toRemove = new();
|
||||
foreach (Vector3i tracked in this._tracked)
|
||||
{
|
||||
if (!target.Contains(tracked)) toRemove.Add(tracked);
|
||||
}
|
||||
foreach (var pos in toRemove)
|
||||
foreach (Vector3i pos in toRemove)
|
||||
{
|
||||
this._tracked.Remove(pos);
|
||||
this._pendingUnload.Enqueue(pos);
|
||||
}
|
||||
|
||||
this.PruneSurfaceCache(p);
|
||||
}
|
||||
|
||||
// Drop cached surface tops for columns outside the current horizontal
|
||||
// window so the cache stays bounded as the player explores.
|
||||
private void PruneSurfaceCache(Vector3i p)
|
||||
{
|
||||
int keep = this._cfg.RenderDistanceHorizontal + 1;
|
||||
List<(int cx, int cz)>? stale = null;
|
||||
foreach ((int cx, int cz) key in this._surfaceTopCache.Keys)
|
||||
{
|
||||
if (Math.Abs(key.cx - p.X) > keep || Math.Abs(key.cz - p.Z) > keep)
|
||||
(stale ??= new()).Add(key);
|
||||
}
|
||||
if (stale != null)
|
||||
foreach ((int cx, int cz) key in stale) this._surfaceTopCache.Remove(key);
|
||||
}
|
||||
|
||||
// Fills `into` with the chunk positions to load for a viewer at chunk
|
||||
// `center`, over a square horizontal region of Chebyshev radius `radiusH`.
|
||||
// Per column the full vertical stack from bedrock (cy 0) up to the
|
||||
// surface-top chunk (clamped up to the sea-level chunk so submerged columns
|
||||
// load their opaque-water surface) is loaded, plus any section above that
|
||||
// holding player edits (e.g. a built tower).
|
||||
private void CollectTargets(Vector3i center, int radiusH, HashSet<Vector3i> into)
|
||||
{
|
||||
for (int dx = -radiusH; dx <= radiusH; dx++)
|
||||
for (int dz = -radiusH; dz <= radiusH; dz++)
|
||||
{
|
||||
int cx = center.X + dx;
|
||||
int cz = center.Z + dz;
|
||||
|
||||
int top = this.SurfaceTopChunk(cx, cz);
|
||||
AddColumn(into, cx, cz, 0, top);
|
||||
|
||||
// Player-built structures can rise above the surface; load any edited
|
||||
// section above the column top so they remain visible.
|
||||
for (int cy = top + 1; cy < WorldConstants.WorldHeightChunks; cy++)
|
||||
{
|
||||
Vector3i pos = new(cx, cy, cz);
|
||||
if (this._world.ModificationStore.HasDiff(pos)) into.Add(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddColumn(HashSet<Vector3i> into, int cx, int cz, int loCy, int hiCy)
|
||||
{
|
||||
int lo = Math.Max(0, loCy);
|
||||
int hi = Math.Min(WorldConstants.WorldHeightChunks - 1, hiCy);
|
||||
for (int cy = lo; cy <= hi; cy++)
|
||||
into.Add(new Vector3i(cx, cy, cz));
|
||||
}
|
||||
|
||||
// Chunk Y of a column's loaded top: the terrain surface chunk, raised to the
|
||||
// sea-level (waterline) chunk for submerged columns, and clamped to the world
|
||||
// ceiling. The bottom of a loaded column is always bedrock (cy 0).
|
||||
private int SurfaceTopChunk(int cx, int cz)
|
||||
=> Math.Min(
|
||||
WorldConstants.WorldHeightChunks - 1,
|
||||
Math.Max(
|
||||
ChunkMath.FloorDiv(this.SurfaceTopHeight(cx, cz), VoxelChunk.Size),
|
||||
ChunkMath.FloorDiv(WorldConstants.SeaLevel, VoxelChunk.Size)));
|
||||
|
||||
// Max terrain surface height across a chunk column's 16x16 footprint,
|
||||
// sampled at the four corners and the centre (cheap bound on the column's
|
||||
// top). Cached per column. The bottom of the loaded stack is always bedrock,
|
||||
// so only the top is needed.
|
||||
private int SurfaceTopHeight(int cx, int cz)
|
||||
{
|
||||
if (this._surfaceTopCache.TryGetValue((cx, cz), out int cached))
|
||||
return cached;
|
||||
|
||||
int x0 = cx * VoxelChunk.Size;
|
||||
int z0 = cz * VoxelChunk.Size;
|
||||
int x1 = x0 + VoxelChunk.Size - 1;
|
||||
int z1 = z0 + VoxelChunk.Size - 1;
|
||||
int xm = x0 + VoxelChunk.Size / 2;
|
||||
int zm = z0 + VoxelChunk.Size / 2;
|
||||
|
||||
int max = Math.Max(
|
||||
Math.Max(this._surfaceHeightAt(x0, z0), this._surfaceHeightAt(x1, z0)),
|
||||
Math.Max(Math.Max(this._surfaceHeightAt(x0, z1), this._surfaceHeightAt(x1, z1)),
|
||||
this._surfaceHeightAt(xm, zm)));
|
||||
|
||||
this._surfaceTopCache[(cx, cz)] = max;
|
||||
return max;
|
||||
}
|
||||
|
||||
private void DrainCompleted()
|
||||
@@ -169,24 +273,34 @@ public sealed class ChunkStreamer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// The loading screen waits on this. It only needs the VISIBLE surface around
|
||||
// spawn to be ready — gating on the full bedrock column would block on the
|
||||
// deepest chunks, which carry the lowest load priority in the whole render
|
||||
// volume and stream in last (minutes for tall mountain spawn columns). Those
|
||||
// deep chunks are occluded at spawn and arrive shortly after.
|
||||
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;
|
||||
int cx = a.X + dx;
|
||||
int cz = a.Z + dz;
|
||||
int top = this.SurfaceTopChunk(cx, cz);
|
||||
int bottom = Math.Max(0, top - SpawnReadyDepthChunks);
|
||||
for (int cy = bottom; cy <= top; cy++)
|
||||
{
|
||||
Vector3i pos = new(cx, cy, cz);
|
||||
if (this._failed.Contains(pos)) continue;
|
||||
if (!this._ready.Contains(pos)) return;
|
||||
}
|
||||
}
|
||||
|
||||
this._spawnReady = true;
|
||||
Log.Information("Spawn area ready at anchor {Anchor}", a);
|
||||
Log.Information("Spawn area ready at anchor {Anchor}", this._spawnAnchor);
|
||||
}
|
||||
|
||||
private void InstallResult(ChunkJobResult result)
|
||||
@@ -207,8 +321,15 @@ public sealed class ChunkStreamer : IDisposable
|
||||
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 — DrawVisible'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);
|
||||
|
||||
@@ -231,9 +352,9 @@ public sealed class ChunkStreamer : IDisposable
|
||||
new(0, -1, 0), new(0, 1, 0),
|
||||
new(0, 0, -1), new(0, 0, 1),
|
||||
};
|
||||
foreach (var off in offsets)
|
||||
foreach (Vector3i off in offsets)
|
||||
{
|
||||
var neighbor = pos + off;
|
||||
Vector3i neighbor = pos + off;
|
||||
if (this._ready.Contains(neighbor)) this._dirty.Add(neighbor);
|
||||
}
|
||||
}
|
||||
@@ -261,13 +382,13 @@ public sealed class ChunkStreamer : IDisposable
|
||||
if (chunk == null) return; // got unloaded between MarkDirty and now
|
||||
|
||||
NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);
|
||||
VoxelChunk snapshotChunk = ChunkStreamer.CopyChunk(chunk); // worker reads from copy
|
||||
VoxelChunk snapshotChunk = CopyChunk(chunk); // worker reads from copy
|
||||
|
||||
this._inFlight++;
|
||||
|
||||
IChunkMesher mesher = this._mesher;
|
||||
BlockRegistry registry = this._registry;
|
||||
var completed = this._completed;
|
||||
ConcurrentQueue<ChunkJobResult> completed = this._completed;
|
||||
|
||||
this._executor.Submit(() =>
|
||||
{
|
||||
@@ -286,7 +407,7 @@ public sealed class ChunkStreamer : IDisposable
|
||||
|
||||
private static VoxelChunk CopyChunk(VoxelChunk source)
|
||||
{
|
||||
var copy = new VoxelChunk();
|
||||
VoxelChunk copy = new();
|
||||
for (int x = 0; x < VoxelChunk.Size; x++)
|
||||
for (int y = 0; y < VoxelChunk.Size; y++)
|
||||
for (int z = 0; z < VoxelChunk.Size; z++)
|
||||
@@ -301,13 +422,13 @@ public sealed class ChunkStreamer : IDisposable
|
||||
NeighborSnapshots neighbors = this.SnapshotNeighbors(pos);
|
||||
this._world.ModificationStore.TryGetDiff(pos, out IReadOnlyDictionary<int, ushort>? diff);
|
||||
|
||||
var job = new ChunkJob(pos, neighbors, diff);
|
||||
ChunkJob job = new(pos, neighbors, diff);
|
||||
this._inFlight++;
|
||||
|
||||
IChunkMesher mesher = this._mesher;
|
||||
BlockRegistry registry = this._registry;
|
||||
var generate = this._generate;
|
||||
var completed = this._completed;
|
||||
Func<Vector3i, VoxelChunk> generate = this._generate;
|
||||
ConcurrentQueue<ChunkJobResult> completed = this._completed;
|
||||
|
||||
this._executor.Submit(() =>
|
||||
{
|
||||
@@ -316,7 +437,7 @@ public sealed class ChunkStreamer : IDisposable
|
||||
VoxelChunk chunk = generate(job.Pos);
|
||||
if (job.Diff != null)
|
||||
{
|
||||
foreach (var (idx, id) in job.Diff)
|
||||
foreach ((int idx, ushort id) in job.Diff)
|
||||
{
|
||||
int lx = idx / (VoxelChunk.Size * VoxelChunk.Size);
|
||||
int ly = (idx / VoxelChunk.Size) % VoxelChunk.Size;
|
||||
@@ -343,7 +464,7 @@ public sealed class ChunkStreamer : IDisposable
|
||||
{
|
||||
VoxelChunk? c = this._world.GetChunk(p);
|
||||
if (c == null) return null;
|
||||
var copy = new ushort[VoxelChunk.Size * VoxelChunk.Size * VoxelChunk.Size];
|
||||
ushort[] 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++)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using VoxelEngine.Core;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class BiomeMap
|
||||
@@ -5,32 +7,51 @@ public sealed class BiomeMap
|
||||
private readonly NoiseSource _noise;
|
||||
private readonly VoronoiCells _cells;
|
||||
private readonly BiomeRegistry _biomes;
|
||||
private readonly Configuration _cfg;
|
||||
|
||||
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes)
|
||||
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes, Configuration cfg)
|
||||
{
|
||||
this._noise = noise;
|
||||
this._cells = cells;
|
||||
this._biomes = biomes;
|
||||
this._cfg = cfg;
|
||||
}
|
||||
|
||||
public int CellId(float worldX, float worldZ) => this._cells.Nearest(worldX, worldZ).Id;
|
||||
|
||||
public IBiome BiomeAt(float worldX, float worldZ)
|
||||
=> this.BiomeForCell(this._cells.Nearest(worldX, worldZ));
|
||||
|
||||
// Climate -> biome, sampled at the cell centre so the biome is constant
|
||||
// across the cell.
|
||||
public IBiome BiomeForCell(VoronoiCells.Cell cell)
|
||||
{
|
||||
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);
|
||||
return Classify(temperature, elevation, forest, this._biomes, this._cfg);
|
||||
}
|
||||
|
||||
// The biomes of the two nearest cells plus how far inside the nearer cell the
|
||||
// point sits (SecondDist - FirstDist, in blocks; 0 exactly on the border).
|
||||
// TerrainSampler uses this to ease height across biome borders.
|
||||
public readonly record struct BiomeBlend(IBiome Near, IBiome Far, float BorderDistance);
|
||||
|
||||
public BiomeBlend NearestTwo(float worldX, float worldZ)
|
||||
{
|
||||
VoronoiCells.NearestPair p = this._cells.NearestTwo(worldX, worldZ);
|
||||
return new BiomeBlend(
|
||||
this.BiomeForCell(p.First),
|
||||
this.BiomeForCell(p.Second),
|
||||
p.SecondDist - p.FirstDist);
|
||||
}
|
||||
|
||||
// Pure climate -> biome lookup. Thresholds in noise units (~[-1,1]).
|
||||
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes)
|
||||
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes, Configuration cfg)
|
||||
{
|
||||
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
|
||||
if (elevation > cfg.MountainsElevationThreshold) return biomes.Mountains; // high ground
|
||||
if (temperature > cfg.DesertTemperatureThreshold) return biomes.Desert; // hot, low
|
||||
if (temperature > cfg.ForestTemperatureThreshold && forest > cfg.ForestDensityThreshold) return biomes.Forest; // temperate, wooded
|
||||
return biomes.Plains; // temperate/cold, low
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
@@ -10,11 +11,11 @@ public sealed class BiomeRegistry
|
||||
public IBiome Mountains { get; }
|
||||
public IBiome Forest { get; }
|
||||
|
||||
public BiomeRegistry(int seed, BlockRegistry blocks)
|
||||
public BiomeRegistry(int seed, BlockRegistry blocks, Configuration cfg)
|
||||
{
|
||||
this.Plains = new PlainsBiome(seed, blocks);
|
||||
this.Desert = new DesertBiome(seed, blocks);
|
||||
this.Mountains = new MountainsBiome(seed, blocks);
|
||||
this.Forest = new ForestBiome(seed, blocks);
|
||||
this.Plains = new PlainsBiome(seed, blocks, cfg);
|
||||
this.Desert = new DesertBiome(seed, blocks, cfg);
|
||||
this.Mountains = new MountainsBiome(seed, blocks, cfg);
|
||||
this.Forest = new ForestBiome(seed, blocks, cfg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
@@ -5,21 +6,23 @@ namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
public sealed class DesertBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
private readonly Configuration _cfg;
|
||||
|
||||
public string Name => "Desert";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public DesertBiome(int seed, BlockRegistry blocks)
|
||||
public DesertBiome(int seed, BlockRegistry blocks, Configuration cfg)
|
||||
{
|
||||
this._cfg = cfg;
|
||||
this._detail = new FastNoiseLite(seed + 101);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.015f);
|
||||
this._detail.SetFrequency(cfg.DesertDetailFrequency);
|
||||
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);
|
||||
=> WorldConstants.SeaLevel + this._cfg.DesertContinentAmplitude * noise.Continent(worldX, worldZ)
|
||||
+ this._cfg.DesertDetailAmplitude * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
@@ -5,21 +6,23 @@ namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
public sealed class ForestBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
private readonly Configuration _cfg;
|
||||
|
||||
public string Name => "Forest";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public ForestBiome(int seed, BlockRegistry blocks)
|
||||
public ForestBiome(int seed, BlockRegistry blocks, Configuration cfg)
|
||||
{
|
||||
this._cfg = cfg;
|
||||
this._detail = new FastNoiseLite(seed + 103);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.03f);
|
||||
this._detail.SetFrequency(cfg.ForestDetailFrequency);
|
||||
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);
|
||||
=> WorldConstants.SeaLevel + this._cfg.ForestContinentAmplitude * noise.Continent(worldX, worldZ)
|
||||
+ this._cfg.ForestDetailAmplitude * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
using VoxelEngine.Core;
|
||||
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;
|
||||
private readonly Configuration _cfg;
|
||||
|
||||
public string Name => "Mountains";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public MountainsBiome(int seed, BlockRegistry blocks)
|
||||
public MountainsBiome(int seed, BlockRegistry blocks, Configuration cfg)
|
||||
{
|
||||
this._cfg = cfg;
|
||||
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._ridged.SetFractalOctaves(cfg.MountainsRidgedOctaves);
|
||||
this._ridged.SetFrequency(cfg.MountainsRidgedFrequency);
|
||||
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);
|
||||
=> WorldConstants.SeaLevel + this._cfg.MountainsContinentAmplitude * noise.Continent(worldX, worldZ)
|
||||
+ this._cfg.MountainsRidgedAmplitude * this._ridged.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => worldY >= MountainsBiome.SnowLine ? this._snow : this._stone;
|
||||
public IBlock TopBlock(int worldY) => worldY >= this._cfg.MountainsSnowLine ? this._snow : this._stone;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
@@ -5,21 +6,23 @@ namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
public sealed class PlainsBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
private readonly Configuration _cfg;
|
||||
|
||||
public string Name => "Plains";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public PlainsBiome(int seed, BlockRegistry blocks)
|
||||
public PlainsBiome(int seed, BlockRegistry blocks, Configuration cfg)
|
||||
{
|
||||
this._cfg = cfg;
|
||||
this._detail = new FastNoiseLite(seed + 100);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.02f);
|
||||
this._detail.SetFrequency(cfg.PlainsDetailFrequency);
|
||||
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);
|
||||
=> WorldConstants.SeaLevel + this._cfg.PlainsContinentAmplitude * noise.Continent(worldX, worldZ)
|
||||
+ this._cfg.PlainsDetailAmplitude * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
|
||||
180
World/WorldGen/CaveCarver.cs
Normal file
180
World/WorldGen/CaveCarver.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
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;
|
||||
if (worldY > this._cfg.MinCaveY) 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 = FloorDiv(chunkBaseX, cellSize);
|
||||
int chunkCellY = FloorDiv(chunkBaseY, cellSize);
|
||||
int chunkCellZ = 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;
|
||||
if (worldY > this._cfg.MinCaveY) 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);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using VoxelEngine.Core;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// Seeded wrapper over FastNoiseLite. All Get* methods return roughly [-1, 1].
|
||||
@@ -7,23 +9,23 @@ public sealed class NoiseSource
|
||||
private readonly FastNoiseLite _temperature;
|
||||
private readonly FastNoiseLite _forest;
|
||||
|
||||
public NoiseSource(int seed)
|
||||
public NoiseSource(int seed, Configuration cfg)
|
||||
{
|
||||
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._continent.SetFractalOctaves(cfg.ContinentOctaves);
|
||||
this._continent.SetFrequency(cfg.ContinentFrequency);
|
||||
|
||||
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._temperature.SetFractalOctaves(cfg.TemperatureOctaves);
|
||||
this._temperature.SetFrequency(cfg.TemperatureFrequency);
|
||||
|
||||
this._forest = new FastNoiseLite(seed + 2);
|
||||
this._forest.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._forest.SetFrequency(0.02f);
|
||||
this._forest.SetFrequency(cfg.ForestFrequency);
|
||||
}
|
||||
|
||||
public float Continent(float x, float z) => this._continent.GetNoise(x, z);
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
using VoxelEngine.Core;
|
||||
|
||||
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;
|
||||
private readonly Configuration _cfg;
|
||||
|
||||
public TerrainSampler(NoiseSource noise, BiomeMap biomes)
|
||||
public TerrainSampler(NoiseSource noise, BiomeMap biomes, Configuration cfg)
|
||||
{
|
||||
this._noise = noise;
|
||||
this._biomes = biomes;
|
||||
this._cfg = cfg;
|
||||
}
|
||||
|
||||
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++;
|
||||
// Blend the two nearest biomes' heights (each evaluated AT this column) by
|
||||
// how deep inside the nearer cell we are: exactly on the border they mix
|
||||
// 50/50, easing to 100% of the nearer biome over BiomeBlendWidth blocks.
|
||||
BiomeMap.BiomeBlend blend = this._biomes.NearestTwo(worldX, worldZ);
|
||||
float hNear = blend.Near.SampleHeight(this._noise, worldX, worldZ);
|
||||
float hFar = blend.Far.SampleHeight(this._noise, worldX, worldZ);
|
||||
|
||||
float fracFar = 0.5f * (1f - SmoothStep01(blend.BorderDistance / this._cfg.BiomeBlendWidth));
|
||||
float avg = hNear * (1f - fracFar) + hFar * fracFar;
|
||||
|
||||
int h = (int)MathF.Round(avg);
|
||||
return Math.Clamp(h, 0, WorldConstants.WorldHeight - 1);
|
||||
}
|
||||
float avg = sum / count;
|
||||
int h = (int)System.MathF.Round(avg);
|
||||
return System.Math.Clamp(h, 0, WorldConstants.WorldHeight - 1);
|
||||
|
||||
// Hermite smoothstep on [0,1].
|
||||
private static float SmoothStep01(float t)
|
||||
{
|
||||
t = Math.Clamp(t, 0f, 1f);
|
||||
return t * t * (3f - 2f * t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ public sealed class VoronoiCells
|
||||
|
||||
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);
|
||||
int gx = (int)MathF.Floor(x / this._cellSize);
|
||||
int gz = (int)MathF.Floor(z / this._cellSize);
|
||||
|
||||
float bestDist = float.MaxValue;
|
||||
int bestId = 0;
|
||||
@@ -28,8 +28,8 @@ public sealed class VoronoiCells
|
||||
{
|
||||
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 jitterX = Jitter(cgx, cgz, this._seed);
|
||||
float jitterZ = 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;
|
||||
@@ -37,7 +37,7 @@ public sealed class VoronoiCells
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
bestId = (int)VoronoiCells.Hash(cgx, cgz, this._seed);
|
||||
bestId = (int)Hash(cgx, cgz, this._seed);
|
||||
bestCx = cx;
|
||||
bestCz = cz;
|
||||
}
|
||||
@@ -45,8 +45,48 @@ public sealed class VoronoiCells
|
||||
return new Cell(bestId, bestCx, bestCz);
|
||||
}
|
||||
|
||||
public readonly record struct NearestPair(Cell First, Cell Second, float FirstDist, float SecondDist);
|
||||
|
||||
// The two nearest cells to (x,z) with their (Euclidean) distances. Used to
|
||||
// ease terrain height across the border between adjacent biome cells. Same
|
||||
// 3x3 jittered-grid search as Nearest — the two closest centres always fall
|
||||
// within that neighbourhood for jitter in [0,1).
|
||||
public NearestPair NearestTwo(float x, float z)
|
||||
{
|
||||
int gx = (int)MathF.Floor(x / this._cellSize);
|
||||
int gz = (int)MathF.Floor(z / this._cellSize);
|
||||
|
||||
float best = float.MaxValue, second = float.MaxValue;
|
||||
Cell bestCell = default, secondCell = default;
|
||||
|
||||
for (int oz = -1; oz <= 1; oz++)
|
||||
for (int ox = -1; ox <= 1; ox++)
|
||||
{
|
||||
int cgx = gx + ox;
|
||||
int cgz = gz + oz;
|
||||
float jitterX = Jitter(cgx, cgz, this._seed);
|
||||
float jitterZ = 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;
|
||||
Cell cell = new((int)Hash(cgx, cgz, this._seed), cx, cz);
|
||||
if (dist < best)
|
||||
{
|
||||
second = best; secondCell = bestCell;
|
||||
best = dist; bestCell = cell;
|
||||
}
|
||||
else if (dist < second)
|
||||
{
|
||||
second = dist; secondCell = cell;
|
||||
}
|
||||
}
|
||||
return new NearestPair(bestCell, secondCell,
|
||||
MathF.Sqrt(best), MathF.Sqrt(second));
|
||||
}
|
||||
|
||||
private static float Jitter(int x, int z, int seed)
|
||||
=> (VoronoiCells.Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
|
||||
=> (Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
|
||||
|
||||
private static uint Hash(int x, int z, int seed)
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ 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;
|
||||
public const int WorldHeightChunks = 32;
|
||||
public const int WorldHeight = WorldHeightChunks * VoxelChunk.Size; // 512
|
||||
public const int SeaLevel = 256;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.Core;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||
|
||||
@@ -6,25 +7,28 @@ namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class WorldGenerator
|
||||
{
|
||||
private const int FillerDepth = 3;
|
||||
private const float CellSize = 96f;
|
||||
|
||||
private readonly Configuration _cfg;
|
||||
private readonly BlockRegistry _blocks;
|
||||
private readonly BiomeMap _biomeMap;
|
||||
private readonly TerrainSampler _terrain;
|
||||
private readonly CaveCarver _caves;
|
||||
|
||||
public WorldGenerator(int seed, BlockRegistry blocks)
|
||||
public WorldGenerator(int seed, BlockRegistry blocks, Configuration cfg)
|
||||
{
|
||||
this._cfg = 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);
|
||||
NoiseSource noise = new(seed, cfg);
|
||||
VoronoiCells cells = new(seed, cfg.BiomeCellSize);
|
||||
BiomeRegistry biomes = new(seed, blocks, cfg);
|
||||
this._biomeMap = new BiomeMap(noise, cells, biomes, cfg);
|
||||
this._terrain = new TerrainSampler(noise, this._biomeMap, cfg);
|
||||
this._caves = new CaveCarver(seed, blocks, cfg);
|
||||
}
|
||||
|
||||
public int SurfaceHeightAt(int worldX, int worldZ) => this._terrain.HeightAt(worldX, worldZ);
|
||||
|
||||
public string BiomeNameAt(int worldX, int worldZ) => this._biomeMap.BiomeAt(worldX, worldZ).Name;
|
||||
|
||||
public VoxelChunk Generate(Vector3i chunkPos)
|
||||
{
|
||||
VoxelChunk chunk = new();
|
||||
@@ -43,20 +47,21 @@ public sealed class WorldGenerator
|
||||
for (int y = 0; y < VoxelChunk.Size; y++)
|
||||
{
|
||||
int worldY = baseY + y;
|
||||
chunk[x, y, z] = WorldGenerator.BlockAt(worldY, height, biome, this._blocks);
|
||||
chunk[x, y, z] = BlockAt(worldY, height, biome, this._blocks, this._cfg.SurfaceFillerDepth);
|
||||
}
|
||||
}
|
||||
|
||||
this._caves.Carve(chunk, chunkPos);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
// Pure column fill rule, extracted for testability.
|
||||
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks)
|
||||
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks, int fillerDepth)
|
||||
{
|
||||
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)
|
||||
if (worldY >= surfaceHeight - 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