Compare commits
15 Commits
main
...
c1be0f75e5
| Author | SHA1 | Date | |
|---|---|---|---|
| c1be0f75e5 | |||
|
|
a8e94a27b2 | ||
|
|
f5787dc9cc | ||
|
|
03cf0ceb07 | ||
|
|
6196b38cc8 | ||
|
|
9880e4379c | ||
|
|
589904beec | ||
|
|
294f3b82ef | ||
|
|
d0707fde9d | ||
|
|
44f53e8d50 | ||
|
|
99b411bdbd | ||
|
|
c81cd3c561 | ||
|
|
50f2bc4221 | ||
|
|
af7a83300f | ||
|
|
6eb17b6c5d |
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 910 KiB |
41
Core/Game.cs
41
Core/Game.cs
@@ -6,6 +6,7 @@ using OpenTK.Windowing.GraphicsLibraryFramework;
|
||||
using VoxelEngine.Rendering;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Chunk;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
|
||||
namespace VoxelEngine.Core;
|
||||
|
||||
@@ -13,12 +14,15 @@ public sealed class Game : GameWindow
|
||||
{
|
||||
private const float MoveSpeed = 8f; // units per second
|
||||
private const float Sensitivity = 0.15f; // degrees per pixel
|
||||
private const int Seed = 1337;
|
||||
private const bool UseSeed = false;
|
||||
|
||||
private readonly BlockRegistry _registry = new();
|
||||
private Shader _shader = null!;
|
||||
private Texture _texture = null!;
|
||||
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
|
||||
private Camera _camera = null!;
|
||||
private WorldGenerator _generator = null!;
|
||||
|
||||
private World.World _world;
|
||||
|
||||
@@ -38,13 +42,19 @@ public sealed class Game : GameWindow
|
||||
|
||||
this._world = new World.World(this._registry);
|
||||
|
||||
for (int x = -2; x < 2; x++)
|
||||
for (int z = -2; z < 2; z++)
|
||||
int seed = Game.UseSeed ? Game.Seed : new Random().Next();
|
||||
|
||||
this._generator = new WorldGenerator(seed, this._registry);
|
||||
|
||||
for (int cx = -4; cx < 4; cx++)
|
||||
for (int cz = -4; cz < 4; cz++)
|
||||
for (int cy = 0; cy < WorldConstants.WorldHeightChunks; cy++)
|
||||
{
|
||||
this._world.SetChunk(new Vector3i(x, 0, z), this.BuildChunk());
|
||||
Vector3i pos = new(cx, cy, cz);
|
||||
this._world.SetChunk(pos, this._generator.Generate(pos));
|
||||
}
|
||||
|
||||
ChunkMesher mesher = new();
|
||||
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
|
||||
foreach ((Vector3i pos, Chunk chunk) in this._world)
|
||||
{
|
||||
Vector3i origin = pos * Chunk.Size;
|
||||
@@ -59,28 +69,13 @@ public sealed class Game : GameWindow
|
||||
this._renderer[pos] = new ChunkRenderer(mesh);
|
||||
}
|
||||
|
||||
this._camera = new Camera(new Vector3(8f, 20f, 40f), this.ClientSize.X / (float)this.ClientSize.Y);
|
||||
this._camera = new Camera(
|
||||
new Vector3(8f, WorldConstants.SeaLevel + 30f, 40f),
|
||||
this.ClientSize.X / (float)this.ClientSize.Y);
|
||||
|
||||
this.CursorState = CursorState.Grabbed;
|
||||
}
|
||||
|
||||
private Chunk BuildChunk()
|
||||
{
|
||||
Chunk chunk = new();
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
{
|
||||
chunk[x, y, z] = y switch
|
||||
{
|
||||
Chunk.Size - 1 => this._registry.Grass.Id,
|
||||
>= Chunk.Size - 4 => this._registry.Dirt.Id,
|
||||
_ => this._registry.Stone.Id
|
||||
};
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
protected override void OnUpdateFrame(FrameEventArgs args)
|
||||
{
|
||||
base.OnUpdateFrame(args);
|
||||
@@ -116,7 +111,7 @@ public sealed class Game : GameWindow
|
||||
this._shader.SetMatrix4("view", this._camera.GetViewMatrix());
|
||||
this._shader.SetMatrix4("projection", this._camera.GetProjectionMatrix());
|
||||
this._shader.SetInt("atlas", 0);
|
||||
this._texture.Use(TextureUnit.Texture0);
|
||||
this._texture.Use();
|
||||
|
||||
foreach ((Vector3i pos, ChunkRenderer renderer) in this._renderer)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,9 @@ public sealed class Texture : IDisposable
|
||||
{
|
||||
private readonly int _handle;
|
||||
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
|
||||
public Texture(string path)
|
||||
{
|
||||
this._handle = GL.GenTexture();
|
||||
@@ -15,6 +18,8 @@ public sealed class Texture : IDisposable
|
||||
StbImage.stbi_set_flip_vertically_on_load(1);
|
||||
using FileStream stream = File.OpenRead(path);
|
||||
ImageResult image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
|
||||
this.Width = image.Width;
|
||||
this.Height = image.Height;
|
||||
|
||||
GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba,
|
||||
image.Width, image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, image.Data);
|
||||
|
||||
@@ -49,4 +49,26 @@ public class BlockRegistryTests
|
||||
Assert.Equal((ushort)2, registry.Dirt.Id);
|
||||
Assert.Equal((ushort)3, registry.Grass.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SandAndSnow_AreOpaque_WaterIsOpaque()
|
||||
{
|
||||
var registry = new BlockRegistry();
|
||||
Assert.True(registry.Get(4).IsOpaque); // Sand
|
||||
Assert.True(registry.Get(5).IsOpaque); // Snow
|
||||
Assert.True(registry.Get(6).IsOpaque); // Water (single-pass renderer => opaque)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_ResolvesAllSevenBlocks()
|
||||
{
|
||||
var registry = new BlockRegistry();
|
||||
Assert.Equal("Air", registry.Get(0).Name);
|
||||
Assert.Equal("Stone", registry.Get(1).Name);
|
||||
Assert.Equal("Dirt", registry.Get(2).Name);
|
||||
Assert.Equal("Grass", registry.Get(3).Name);
|
||||
Assert.Equal("Sand", registry.Get(4).Name);
|
||||
Assert.Equal("Snow Block", registry.Get(5).Name);
|
||||
Assert.Equal("Water", registry.Get(6).Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public class ChunkMesherTests
|
||||
[Fact]
|
||||
public void EmptyChunk_ProducesNoFaces()
|
||||
{
|
||||
var mesh = new ChunkMesher().Build(new Chunk(), new BlockRegistry());
|
||||
var mesh = new ChunkMesher(128, 128).Build(new Chunk(), new BlockRegistry());
|
||||
Assert.Empty(mesh);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public class ChunkMesherTests
|
||||
var chunk = new Chunk();
|
||||
chunk[8, 8, 8] = 1; // stone
|
||||
|
||||
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry());
|
||||
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
|
||||
|
||||
Assert.Equal(6, FaceCount(mesh));
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public class ChunkMesherTests
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
chunk[x, y, z] = 1; // stone
|
||||
|
||||
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry());
|
||||
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
|
||||
|
||||
// 6 sides * 16 * 16 visible faces
|
||||
Assert.Equal(6 * Chunk.Size * Chunk.Size, FaceCount(mesh));
|
||||
@@ -52,10 +52,32 @@ public class ChunkMesherTests
|
||||
var chunk = new Chunk();
|
||||
chunk[0, 0, 0] = 1; // corner block: all 6 neighbors are air/out-of-bounds -> 6 faces
|
||||
|
||||
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry());
|
||||
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
|
||||
|
||||
// Pins the layout: 6 faces * 6 vertices/face * 6 floats/vertex.
|
||||
Assert.Equal(6 * VerticesPerFace * FloatsPerVertex, mesh.Length);
|
||||
Assert.Equal(0, mesh.Length % FloatsPerVertex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mesher_UsesAtlasDimensions_ForTileSize()
|
||||
{
|
||||
var chunk = new Chunk();
|
||||
chunk[0, 0, 0] = 1; // stone, tile (1,0)
|
||||
|
||||
// 128px atlas, 32px tiles => 4x4 grid, normalized tile size 0.25.
|
||||
var mesh = new ChunkMesher(128, 128).Build(chunk, new BlockRegistry());
|
||||
|
||||
// Gather all U coords (offset 3) and V coords (offset 4); each must be a
|
||||
// multiple of 0.25 within [0,1].
|
||||
for (int i = 0; i < mesh.Length; i += 6)
|
||||
{
|
||||
float u = mesh[i + 3];
|
||||
float v = mesh[i + 4];
|
||||
Assert.InRange(u, 0f, 1f);
|
||||
Assert.InRange(v, 0f, 1f);
|
||||
Assert.True(System.Math.Abs(u * 4f - System.MathF.Round(u * 4f)) < 1e-4f, $"u={u} not on 0.25 grid");
|
||||
Assert.True(System.Math.Abs(v * 4f - System.MathF.Round(v * 4f)) < 1e-4f, $"v={v} not on 0.25 grid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
VoxelEngine.Tests/WorldGen/BiomeMapTests.cs
Normal file
54
VoxelEngine.Tests/WorldGen/BiomeMapTests.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class BiomeMapTests
|
||||
{
|
||||
private static (BiomeMap map, BiomeRegistry biomes) Build(int seed = 42)
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(seed, blocks);
|
||||
var noise = new NoiseSource(seed);
|
||||
var cells = new VoronoiCells(seed, 96f);
|
||||
return (new BiomeMap(noise, cells, biomes), biomes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_HighElevation_IsMountains()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Mountains, BiomeMap.Classify(0f, 0.5f, 0f, biomes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_HotLowElevation_IsDesert()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Desert, BiomeMap.Classify(0.5f, 0f, 0f, biomes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_TemperateWooded_IsForest()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Forest, BiomeMap.Classify(0f, 0f, 0.5f, biomes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_ColdLow_IsPlains()
|
||||
{
|
||||
var (_, biomes) = Build();
|
||||
Assert.Same(biomes.Plains, BiomeMap.Classify(-0.9f, 0f, 0f, biomes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BiomeAt_IsConstantWithinACell()
|
||||
{
|
||||
var (map, _) = Build();
|
||||
IBiome a = map.BiomeAt(500f, 500f);
|
||||
IBiome b = map.BiomeAt(500.01f, 500.01f); // same cell
|
||||
Assert.Same(a, b);
|
||||
}
|
||||
}
|
||||
46
VoxelEngine.Tests/WorldGen/BiomeTests.cs
Normal file
46
VoxelEngine.Tests/WorldGen/BiomeTests.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class BiomeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Desert_TopAndFiller_AreSand()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
Assert.Equal("Sand", biomes.Desert.Surface.Top.Name);
|
||||
Assert.Equal("Sand", biomes.Desert.Surface.Filler.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mountains_UseSnowAboveSnowLine_StoneBelow()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
Assert.Equal("Stone", biomes.Mountains.TopBlock(70).Name);
|
||||
Assert.Equal("Snow Block", biomes.Mountains.TopBlock(120).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plains_TopBlock_IsGrass_RegardlessOfHeight()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
Assert.Equal("Grass", biomes.Plains.TopBlock(40).Name);
|
||||
Assert.Equal("Grass", biomes.Plains.TopBlock(120).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleHeight_IsDeterministic()
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(42, blocks);
|
||||
var noise = new NoiseSource(42);
|
||||
float h1 = biomes.Mountains.SampleHeight(noise, 100f, 200f);
|
||||
float h2 = biomes.Mountains.SampleHeight(noise, 100f, 200f);
|
||||
Assert.Equal(h1, h2);
|
||||
}
|
||||
}
|
||||
29
VoxelEngine.Tests/WorldGen/NoiseSourceTests.cs
Normal file
29
VoxelEngine.Tests/WorldGen/NoiseSourceTests.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class NoiseSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SameSeed_SameCoord_IsDeterministic()
|
||||
{
|
||||
var a = new NoiseSource(42);
|
||||
var b = new NoiseSource(42);
|
||||
Assert.Equal(a.Continent(10f, 20f), b.Continent(10f, 20f));
|
||||
Assert.Equal(a.Temperature(10f, 20f), b.Temperature(10f, 20f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Output_InMinusOneToOneRange()
|
||||
{
|
||||
var n = new NoiseSource(7);
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
float c = n.Continent(i * 3.1f, i * 1.7f);
|
||||
float t = n.Temperature(i * 2.3f, i * 0.9f);
|
||||
Assert.InRange(c, -1.001f, 1.001f);
|
||||
Assert.InRange(t, -1.001f, 1.001f);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
VoxelEngine.Tests/WorldGen/TerrainSamplerTests.cs
Normal file
53
VoxelEngine.Tests/WorldGen/TerrainSamplerTests.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class TerrainSamplerTests
|
||||
{
|
||||
private static TerrainSampler Build(int seed = 42)
|
||||
{
|
||||
var blocks = new BlockRegistry();
|
||||
var biomes = new BiomeRegistry(seed, blocks);
|
||||
var noise = new NoiseSource(seed);
|
||||
var cells = new VoronoiCells(seed, 96f);
|
||||
var map = new BiomeMap(noise, cells, biomes);
|
||||
return new TerrainSampler(noise, map);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Height_StaysWithinWorldBounds()
|
||||
{
|
||||
var sampler = Build();
|
||||
for (int x = -300; x < 300; x += 7)
|
||||
for (int z = -300; z < 300; z += 7)
|
||||
{
|
||||
int h = sampler.HeightAt(x, z);
|
||||
Assert.InRange(h, 0, WorldConstants.WorldHeight - 1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Height_IsDeterministic()
|
||||
{
|
||||
var a = Build();
|
||||
var b = Build();
|
||||
Assert.Equal(a.HeightAt(120, 240), b.HeightAt(120, 240));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentColumns_DoNotCliff()
|
||||
{
|
||||
var sampler = Build();
|
||||
for (int x = -200; x < 200; x += 3)
|
||||
for (int z = -200; z < 200; z += 3)
|
||||
{
|
||||
// The dense blur removes inter-biome cliffs (those would be 25+ blocks).
|
||||
// Remaining deltas are rugged intra-Mountains ridged slopes (~9), which
|
||||
// are intended terrain, not cliffs; 16 separates the two.
|
||||
int dx = System.Math.Abs(sampler.HeightAt(x, z) - sampler.HeightAt(x + 1, z));
|
||||
Assert.True(dx <= 16, $"height jump {dx} at ({x},{z})");
|
||||
}
|
||||
}
|
||||
}
|
||||
36
VoxelEngine.Tests/WorldGen/VoronoiCellsTests.cs
Normal file
36
VoxelEngine.Tests/WorldGen/VoronoiCellsTests.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class VoronoiCellsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Nearest_IsDeterministic()
|
||||
{
|
||||
var a = new VoronoiCells(42, 96f);
|
||||
var b = new VoronoiCells(42, 96f);
|
||||
Assert.Equal(a.Nearest(123.4f, 56.7f), b.Nearest(123.4f, 56.7f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nearest_IsLocallyConstant()
|
||||
{
|
||||
var cells = new VoronoiCells(42, 96f);
|
||||
// Two points a tiny step apart almost always share a cell.
|
||||
var c1 = cells.Nearest(500f, 500f);
|
||||
var c2 = cells.Nearest(500.01f, 500.01f);
|
||||
Assert.Equal(c1.Id, c2.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentSeeds_DifferAtSomePoint()
|
||||
{
|
||||
var a = new VoronoiCells(1, 96f);
|
||||
var b = new VoronoiCells(2, 96f);
|
||||
bool anyDiff = false;
|
||||
for (int i = 0; i < 50 && !anyDiff; i++)
|
||||
anyDiff = a.Nearest(i * 31f, i * 17f).Id != b.Nearest(i * 31f, i * 17f).Id;
|
||||
Assert.True(anyDiff);
|
||||
}
|
||||
}
|
||||
70
VoxelEngine.Tests/WorldGen/WorldGeneratorTests.cs
Normal file
70
VoxelEngine.Tests/WorldGen/WorldGeneratorTests.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.Chunk;
|
||||
using VoxelEngine.World.WorldGen;
|
||||
using Xunit;
|
||||
|
||||
namespace VoxelEngine.Tests.WorldGen;
|
||||
|
||||
public class WorldGeneratorTests
|
||||
{
|
||||
private static BlockRegistry Blocks => new();
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_AboveTerrainAndSea_IsAir()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(80, surfaceHeight: 70, biomes.Plains, blocks);
|
||||
Assert.Equal(blocks.Air.Id, id); // 80 > 70 and 80 > SeaLevel(64)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_AboveTerrainBelowSea_IsWater()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(60, surfaceHeight: 50, biomes.Plains, blocks);
|
||||
Assert.Equal(blocks.Water.Id, id); // 60 > 50 but 60 <= SeaLevel(64)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_Surface_IsBiomeTop()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(70, surfaceHeight: 70, biomes.Desert, blocks);
|
||||
Assert.Equal(blocks.Sand.Id, id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_FillerBand_IsBiomeFiller()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(68, surfaceHeight: 70, biomes.Plains, blocks);
|
||||
Assert.Equal(blocks.Dirt.Id, id); // within 3 of surface
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockAt_Deep_IsStone()
|
||||
{
|
||||
var blocks = Blocks;
|
||||
var biomes = new BiomeRegistry(1, blocks);
|
||||
ushort id = WorldGenerator.BlockAt(40, surfaceHeight: 70, biomes.Plains, blocks);
|
||||
Assert.Equal(blocks.Stone.Id, id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_IsDeterministic()
|
||||
{
|
||||
var gen1 = new WorldGenerator(99, Blocks);
|
||||
var gen2 = new WorldGenerator(99, Blocks);
|
||||
Chunk a = gen1.Generate(new Vector3i(0, 4, 0));
|
||||
Chunk b = gen2.Generate(new Vector3i(0, 4, 0));
|
||||
for (int x = 0; x < Chunk.Size; x++)
|
||||
for (int y = 0; y < Chunk.Size; y++)
|
||||
for (int z = 0; z < Chunk.Size; z++)
|
||||
Assert.Equal(a[x, y, z], b[x, y, z]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTK" Version="4.9.4" />
|
||||
<PackageReference Include="SimplexNoise" Version="2.0.0" />
|
||||
<PackageReference Include="StbImageSharp" Version="2.30.15" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -20,8 +19,4 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="World\WorldGen\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
7
World/Blocks/BlockIds.txt
Normal file
7
World/Blocks/BlockIds.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Air = 0
|
||||
Stone = 1
|
||||
Dirt = 2
|
||||
Grass = 3
|
||||
Sand = 4
|
||||
Snow = 5
|
||||
Water = 6
|
||||
@@ -8,11 +8,15 @@ public sealed class BlockRegistry
|
||||
public StoneBlock Stone { get; } = new();
|
||||
public DirtBlock Dirt { get; } = new();
|
||||
public GrassBlock Grass { get; } = new();
|
||||
public SandBlock Sand { get; } = new();
|
||||
public SnowBlock Snow { get; } = new();
|
||||
public WaterBlock Water { get; } = new();
|
||||
|
||||
public BlockRegistry()
|
||||
{
|
||||
// Index by id. Order does not matter; slot = block.Id.
|
||||
IBlock[] blocks = new IBlock[] { this.Air, this.Stone, this.Dirt, this.Grass };
|
||||
IBlock[] blocks = new IBlock[]
|
||||
{ this.Air, this.Stone, this.Dirt, this.Grass, this.Sand, this.Snow, this.Water };
|
||||
this._byId = new IBlock[blocks.Length];
|
||||
foreach (IBlock block in blocks)
|
||||
{
|
||||
|
||||
@@ -9,8 +9,8 @@ public sealed class GrassBlock : IBlock
|
||||
|
||||
public TileCoord GetTextureTile(BlockFace face) => face switch
|
||||
{
|
||||
BlockFace.Top => new(0, 1), // grass top
|
||||
BlockFace.Bottom => new(0, 0), // dirt
|
||||
_ => new(1, 1) // grass side
|
||||
BlockFace.Top => new TileCoord(0, 1), // grass top
|
||||
BlockFace.Bottom => new TileCoord(0, 0), // dirt
|
||||
_ => new TileCoord(1, 1) // grass side
|
||||
};
|
||||
}
|
||||
|
||||
10
World/Blocks/SandBlock.cs
Normal file
10
World/Blocks/SandBlock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public class SandBlock : IBlock
|
||||
{
|
||||
public ushort Id => 4;
|
||||
public string Name => "Sand";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => true;
|
||||
public TileCoord GetTextureTile(BlockFace face) => new(2, 1);
|
||||
}
|
||||
16
World/Blocks/SnowBlock.cs
Normal file
16
World/Blocks/SnowBlock.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public class SnowBlock : IBlock
|
||||
{
|
||||
public ushort Id => 5;
|
||||
public string Name => "Snow Block";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => true;
|
||||
|
||||
public TileCoord GetTextureTile(BlockFace face) => face switch
|
||||
{
|
||||
BlockFace.Top => new TileCoord(2, 0),
|
||||
BlockFace.Bottom => new TileCoord(0, 0),
|
||||
_ => new TileCoord(3, 0)
|
||||
};
|
||||
}
|
||||
5
World/Blocks/TileCoord.cs
Normal file
5
World/Blocks/TileCoord.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
// Position of a tile within the texture atlas, in tile units.
|
||||
// Origin (0,0) is the top-left tile; X increases right, Y increases down.
|
||||
public readonly record struct TileCoord(int X, int Y);
|
||||
10
World/Blocks/WaterBlock.cs
Normal file
10
World/Blocks/WaterBlock.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public class WaterBlock : IBlock
|
||||
{
|
||||
public ushort Id => 6;
|
||||
public string Name => "Water";
|
||||
public bool IsOpaque => true;
|
||||
public bool IsSolid => false;
|
||||
public TileCoord GetTextureTile(BlockFace face) => new(3, 1);
|
||||
}
|
||||
@@ -5,9 +5,15 @@ namespace VoxelEngine.World.Chunk;
|
||||
public sealed class ChunkMesher : IChunkMesher
|
||||
{
|
||||
private const int TileSizePixels = 32; // each tile is 32x32 pixels
|
||||
private const int AtlasSizePixels = 64; // atlas is 64x64 pixels (2x2 grid)
|
||||
|
||||
private const float TileSize = (float)TileSizePixels / AtlasSizePixels; // normalized tile size
|
||||
private readonly float _tileU; // normalized tile width
|
||||
private readonly float _tileV; // normalized tile height
|
||||
|
||||
public ChunkMesher(int atlasWidthPixels, int atlasHeightPixels)
|
||||
{
|
||||
this._tileU = (float)ChunkMesher.TileSizePixels / atlasWidthPixels;
|
||||
this._tileV = (float)ChunkMesher.TileSizePixels / atlasHeightPixels;
|
||||
}
|
||||
|
||||
// Per-face geometry: base corner + two in-plane edge vectors (u, v) with
|
||||
// cross(u, v) == outward normal, guaranteeing CCW winding from outside.
|
||||
@@ -72,18 +78,18 @@ public sealed class ChunkMesher : IChunkMesher
|
||||
: (neighborOpaqueOutside?.Invoke(nx, ny, nz) ?? false);
|
||||
if (neighborOpaque) continue;
|
||||
|
||||
ChunkMesher.AddFace(verts, x, y, z, face, block.GetTextureTile(face.Kind));
|
||||
this.AddFace(verts, x, y, z, face, block.GetTextureTile(face.Kind));
|
||||
}
|
||||
}
|
||||
|
||||
return verts.ToArray();
|
||||
}
|
||||
|
||||
private static void AddFace(List<float> verts, int x, int y, int z, Face face, TileCoord tile)
|
||||
private void AddFace(List<float> verts, int x, int y, int z, Face face, TileCoord tile)
|
||||
{
|
||||
float uBase = tile.X * ChunkMesher.TileSize;
|
||||
float uBase = tile.X * this._tileU;
|
||||
// Image is flipped on load, so offset from the top to keep tile (0,0) = top-left.
|
||||
float vBase = 1f - (tile.Y + 1) * ChunkMesher.TileSize;
|
||||
float vBase = 1f - (tile.Y + 1) * this._tileV;
|
||||
|
||||
foreach (int corner in ChunkMesher.Triangle)
|
||||
{
|
||||
@@ -93,8 +99,8 @@ public sealed class ChunkMesher : IChunkMesher
|
||||
float py = y + face.Base.y + face.U.y * cu + face.V.y * cv;
|
||||
float pz = z + face.Base.z + face.U.z * cu + face.V.z * cv;
|
||||
|
||||
float u = uBase + cu * ChunkMesher.TileSize;
|
||||
float v = vBase + cv * ChunkMesher.TileSize;
|
||||
float u = uBase + cu * this._tileU;
|
||||
float v = vBase + cv * this._tileV;
|
||||
|
||||
verts.Add(px); verts.Add(py); verts.Add(pz);
|
||||
verts.Add(u); verts.Add(v);
|
||||
|
||||
36
World/WorldGen/BiomeMap.cs
Normal file
36
World/WorldGen/BiomeMap.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class BiomeMap
|
||||
{
|
||||
private readonly NoiseSource _noise;
|
||||
private readonly VoronoiCells _cells;
|
||||
private readonly BiomeRegistry _biomes;
|
||||
|
||||
public BiomeMap(NoiseSource noise, VoronoiCells cells, BiomeRegistry biomes)
|
||||
{
|
||||
this._noise = noise;
|
||||
this._cells = cells;
|
||||
this._biomes = biomes;
|
||||
}
|
||||
|
||||
public int CellId(float worldX, float worldZ) => this._cells.Nearest(worldX, worldZ).Id;
|
||||
|
||||
public IBiome BiomeAt(float worldX, float worldZ)
|
||||
{
|
||||
VoronoiCells.Cell cell = this._cells.Nearest(worldX, worldZ);
|
||||
// Sample climate at the cell center so the biome is constant across the cell.
|
||||
float temperature = this._noise.Temperature(cell.CenterX, cell.CenterZ);
|
||||
float elevation = this._noise.Continent(cell.CenterX, cell.CenterZ);
|
||||
float forest = this._noise.Forest(cell.CenterX, cell.CenterZ);
|
||||
return BiomeMap.Classify(temperature, elevation, forest, this._biomes);
|
||||
}
|
||||
|
||||
// Pure climate -> biome lookup. Thresholds in noise units (~[-1,1]).
|
||||
public static IBiome Classify(float temperature, float elevation, float forest, BiomeRegistry biomes)
|
||||
{
|
||||
if (elevation > 0.25f) return biomes.Mountains; // high ground
|
||||
if (temperature > 0.33f) return biomes.Desert; // hot, low
|
||||
if (temperature > -0.33f && forest > 0f) return biomes.Forest; // temperate, wooded
|
||||
return biomes.Plains; // temperate/cold, low
|
||||
}
|
||||
}
|
||||
20
World/WorldGen/BiomeRegistry.cs
Normal file
20
World/WorldGen/BiomeRegistry.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class BiomeRegistry
|
||||
{
|
||||
public IBiome Plains { get; }
|
||||
public IBiome Desert { get; }
|
||||
public IBiome Mountains { get; }
|
||||
public IBiome Forest { get; }
|
||||
|
||||
public BiomeRegistry(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this.Plains = new PlainsBiome(seed, blocks);
|
||||
this.Desert = new DesertBiome(seed, blocks);
|
||||
this.Mountains = new MountainsBiome(seed, blocks);
|
||||
this.Forest = new ForestBiome(seed, blocks);
|
||||
}
|
||||
}
|
||||
25
World/WorldGen/Biomes/DesertBiome.cs
Normal file
25
World/WorldGen/Biomes/DesertBiome.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
public sealed class DesertBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
|
||||
public string Name => "Desert";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public DesertBiome(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this._detail = new FastNoiseLite(seed + 101);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.015f);
|
||||
this.Surface = new SurfacePalette(blocks.Sand, blocks.Sand, blocks.Stone);
|
||||
}
|
||||
|
||||
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
|
||||
=> WorldConstants.SeaLevel + 4f * noise.Continent(worldX, worldZ)
|
||||
+ 1.5f * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
25
World/WorldGen/Biomes/ForestBiome.cs
Normal file
25
World/WorldGen/Biomes/ForestBiome.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
public sealed class ForestBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
|
||||
public string Name => "Forest";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public ForestBiome(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this._detail = new FastNoiseLite(seed + 103);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.03f);
|
||||
this.Surface = new SurfacePalette(blocks.Grass, blocks.Dirt, blocks.Stone);
|
||||
}
|
||||
|
||||
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
|
||||
=> WorldConstants.SeaLevel + 6f * noise.Continent(worldX, worldZ)
|
||||
+ 4f * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
32
World/WorldGen/Biomes/MountainsBiome.cs
Normal file
32
World/WorldGen/Biomes/MountainsBiome.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
public sealed class MountainsBiome : IBiome
|
||||
{
|
||||
private const int SnowLine = 96;
|
||||
private readonly FastNoiseLite _ridged;
|
||||
private readonly IBlock _stone;
|
||||
private readonly IBlock _snow;
|
||||
|
||||
public string Name => "Mountains";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public MountainsBiome(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this._ridged = new FastNoiseLite(seed + 102);
|
||||
this._ridged.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._ridged.SetFractalType(FastNoiseLite.FractalType.Ridged);
|
||||
this._ridged.SetFractalOctaves(4);
|
||||
this._ridged.SetFrequency(0.01f);
|
||||
this._stone = blocks.Stone;
|
||||
this._snow = blocks.Snow;
|
||||
this.Surface = new SurfacePalette(blocks.Stone, blocks.Stone, blocks.Stone);
|
||||
}
|
||||
|
||||
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
|
||||
=> WorldConstants.SeaLevel + 10f * noise.Continent(worldX, worldZ)
|
||||
+ 45f * this._ridged.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => worldY >= MountainsBiome.SnowLine ? this._snow : this._stone;
|
||||
}
|
||||
25
World/WorldGen/Biomes/PlainsBiome.cs
Normal file
25
World/WorldGen/Biomes/PlainsBiome.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen.Biomes;
|
||||
|
||||
public sealed class PlainsBiome : IBiome
|
||||
{
|
||||
private readonly FastNoiseLite _detail;
|
||||
|
||||
public string Name => "Plains";
|
||||
public SurfacePalette Surface { get; }
|
||||
|
||||
public PlainsBiome(int seed, BlockRegistry blocks)
|
||||
{
|
||||
this._detail = new FastNoiseLite(seed + 100);
|
||||
this._detail.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._detail.SetFrequency(0.02f);
|
||||
this.Surface = new SurfacePalette(blocks.Grass, blocks.Dirt, blocks.Stone);
|
||||
}
|
||||
|
||||
public float SampleHeight(NoiseSource noise, float worldX, float worldZ)
|
||||
=> WorldConstants.SeaLevel + 6f * noise.Continent(worldX, worldZ)
|
||||
+ 2f * this._detail.GetNoise(worldX, worldZ);
|
||||
|
||||
public IBlock TopBlock(int worldY) => this.Surface.Top;
|
||||
}
|
||||
2506
World/WorldGen/FastNoiseLite.cs
Normal file
2506
World/WorldGen/FastNoiseLite.cs
Normal file
File diff suppressed because it is too large
Load Diff
17
World/WorldGen/IBiome.cs
Normal file
17
World/WorldGen/IBiome.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public interface IBiome
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
// Absolute target surface height at the column (continuous, pre-rounding).
|
||||
float SampleHeight(NoiseSource noise, float worldX, float worldZ);
|
||||
|
||||
SurfacePalette Surface { get; }
|
||||
|
||||
// Surface block for a given height; lets biomes vary the top by altitude
|
||||
// (e.g. Mountains use Snow above the snow line). Defaults to Surface.Top.
|
||||
IBlock TopBlock(int worldY);
|
||||
}
|
||||
32
World/WorldGen/NoiseSource.cs
Normal file
32
World/WorldGen/NoiseSource.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// Seeded wrapper over FastNoiseLite. All Get* methods return roughly [-1, 1].
|
||||
public sealed class NoiseSource
|
||||
{
|
||||
private readonly FastNoiseLite _continent;
|
||||
private readonly FastNoiseLite _temperature;
|
||||
private readonly FastNoiseLite _forest;
|
||||
|
||||
public NoiseSource(int seed)
|
||||
{
|
||||
this._continent = new FastNoiseLite(seed);
|
||||
this._continent.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._continent.SetFractalType(FastNoiseLite.FractalType.FBm);
|
||||
this._continent.SetFractalOctaves(4);
|
||||
this._continent.SetFrequency(0.0035f);
|
||||
|
||||
this._temperature = new FastNoiseLite(seed + 1);
|
||||
this._temperature.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._temperature.SetFractalType(FastNoiseLite.FractalType.FBm);
|
||||
this._temperature.SetFractalOctaves(3);
|
||||
this._temperature.SetFrequency(0.0015f);
|
||||
|
||||
this._forest = new FastNoiseLite(seed + 2);
|
||||
this._forest.SetNoiseType(FastNoiseLite.NoiseType.OpenSimplex2);
|
||||
this._forest.SetFrequency(0.02f);
|
||||
}
|
||||
|
||||
public float Continent(float x, float z) => this._continent.GetNoise(x, z);
|
||||
public float Temperature(float x, float z) => this._temperature.GetNoise(x, z);
|
||||
public float Forest(float x, float z) => this._forest.GetNoise(x, z);
|
||||
}
|
||||
7
World/WorldGen/SurfacePalette.cs
Normal file
7
World/WorldGen/SurfacePalette.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using VoxelEngine.World.Blocks;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// Surface block layering for a biome: the top block, the filler band below it,
|
||||
// and the deep "under" block (stone). Filler depth is fixed at 3 blocks.
|
||||
public readonly record struct SurfacePalette(IBlock Top, IBlock Filler, IBlock Under);
|
||||
38
World/WorldGen/TerrainSampler.cs
Normal file
38
World/WorldGen/TerrainSampler.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class TerrainSampler
|
||||
{
|
||||
// Box-blur radius (in blocks) applied to the biome-height field to smooth
|
||||
// biome borders. A full (2R+1)x(2R+1) kernel at 1-block spacing bounds the
|
||||
// per-block height change to ~maxBiomeHeightDiff / (2R+1), preventing cliffs.
|
||||
// A sparse kernel does NOT bound it (axis-aligned borders flip several samples
|
||||
// at once), so the kernel must be dense.
|
||||
private const int BlendRadius = 6;
|
||||
|
||||
private readonly NoiseSource _noise;
|
||||
private readonly BiomeMap _biomes;
|
||||
|
||||
public TerrainSampler(NoiseSource noise, BiomeMap biomes)
|
||||
{
|
||||
this._noise = noise;
|
||||
this._biomes = biomes;
|
||||
}
|
||||
|
||||
public int HeightAt(float worldX, float worldZ)
|
||||
{
|
||||
// For each kernel point find the biome THERE, but evaluate its height AT the
|
||||
// column. Averaging blurs the hard biome borders into smooth transitions.
|
||||
float sum = 0f;
|
||||
int count = 0;
|
||||
for (int dz = -TerrainSampler.BlendRadius; dz <= TerrainSampler.BlendRadius; dz++)
|
||||
for (int dx = -TerrainSampler.BlendRadius; dx <= TerrainSampler.BlendRadius; dx++)
|
||||
{
|
||||
IBiome biome = this._biomes.BiomeAt(worldX + dx, worldZ + dz);
|
||||
sum += biome.SampleHeight(this._noise, worldX, worldZ);
|
||||
count++;
|
||||
}
|
||||
float avg = sum / count;
|
||||
int h = (int)System.MathF.Round(avg);
|
||||
return System.Math.Clamp(h, 0, WorldConstants.WorldHeight - 1);
|
||||
}
|
||||
}
|
||||
60
World/WorldGen/VoronoiCells.cs
Normal file
60
World/WorldGen/VoronoiCells.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
// A jittered-grid Voronoi partition. Pure integer-hash based, fully deterministic.
|
||||
public sealed class VoronoiCells
|
||||
{
|
||||
public readonly record struct Cell(int Id, float CenterX, float CenterZ);
|
||||
|
||||
private readonly int _seed;
|
||||
private readonly float _cellSize;
|
||||
|
||||
public VoronoiCells(int seed, float cellSize)
|
||||
{
|
||||
this._seed = seed;
|
||||
this._cellSize = cellSize;
|
||||
}
|
||||
|
||||
public Cell Nearest(float x, float z)
|
||||
{
|
||||
int gx = (int)System.MathF.Floor(x / this._cellSize);
|
||||
int gz = (int)System.MathF.Floor(z / this._cellSize);
|
||||
|
||||
float bestDist = float.MaxValue;
|
||||
int bestId = 0;
|
||||
float bestCx = 0f, bestCz = 0f;
|
||||
|
||||
for (int oz = -1; oz <= 1; oz++)
|
||||
for (int ox = -1; ox <= 1; ox++)
|
||||
{
|
||||
int cgx = gx + ox;
|
||||
int cgz = gz + oz;
|
||||
float jitterX = VoronoiCells.Jitter(cgx, cgz, this._seed);
|
||||
float jitterZ = VoronoiCells.Jitter(cgx, cgz, this._seed + 9871);
|
||||
float cx = (cgx + jitterX) * this._cellSize;
|
||||
float cz = (cgz + jitterZ) * this._cellSize;
|
||||
float dx = cx - x, dz = cz - z;
|
||||
float dist = dx * dx + dz * dz;
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
bestId = (int)VoronoiCells.Hash(cgx, cgz, this._seed);
|
||||
bestCx = cx;
|
||||
bestCz = cz;
|
||||
}
|
||||
}
|
||||
return new Cell(bestId, bestCx, bestCz);
|
||||
}
|
||||
|
||||
private static float Jitter(int x, int z, int seed)
|
||||
=> (VoronoiCells.Hash(x, z, seed) & 0xFFFFFF) / (float)0x1000000; // [0,1)
|
||||
|
||||
private static uint Hash(int x, int z, int seed)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
uint h = (uint)(x * 374761393 + z * 668265263 + seed * 362437);
|
||||
h = (h ^ (h >> 13)) * 1274126177u;
|
||||
return h ^ (h >> 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
World/WorldGen/WorldConstants.cs
Normal file
10
World/WorldGen/WorldConstants.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public static class WorldConstants
|
||||
{
|
||||
public const int WorldHeightChunks = 8;
|
||||
public const int WorldHeight = WorldConstants.WorldHeightChunks * VoxelChunk.Size; // 128
|
||||
public const int SeaLevel = 64;
|
||||
}
|
||||
61
World/WorldGen/WorldGenerator.cs
Normal file
61
World/WorldGen/WorldGenerator.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using OpenTK.Mathematics;
|
||||
using VoxelEngine.World.Blocks;
|
||||
using VoxelChunk = VoxelEngine.World.Chunk.Chunk;
|
||||
|
||||
namespace VoxelEngine.World.WorldGen;
|
||||
|
||||
public sealed class WorldGenerator
|
||||
{
|
||||
private const int FillerDepth = 3;
|
||||
private const float CellSize = 96f;
|
||||
|
||||
private readonly BlockRegistry _blocks;
|
||||
private readonly BiomeMap _biomeMap;
|
||||
private readonly TerrainSampler _terrain;
|
||||
|
||||
public WorldGenerator(int seed, BlockRegistry blocks)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
public VoxelChunk Generate(Vector3i chunkPos)
|
||||
{
|
||||
VoxelChunk chunk = new();
|
||||
int baseX = chunkPos.X * VoxelChunk.Size;
|
||||
int baseY = chunkPos.Y * VoxelChunk.Size;
|
||||
int baseZ = chunkPos.Z * VoxelChunk.Size;
|
||||
|
||||
for (int x = 0; x < VoxelChunk.Size; x++)
|
||||
for (int z = 0; z < VoxelChunk.Size; z++)
|
||||
{
|
||||
int worldX = baseX + x;
|
||||
int worldZ = baseZ + z;
|
||||
int height = this._terrain.HeightAt(worldX, worldZ);
|
||||
IBiome biome = this._biomeMap.BiomeAt(worldX, worldZ);
|
||||
|
||||
for (int y = 0; y < VoxelChunk.Size; y++)
|
||||
{
|
||||
int worldY = baseY + y;
|
||||
chunk[x, y, z] = WorldGenerator.BlockAt(worldY, height, biome, this._blocks);
|
||||
}
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
// Pure column fill rule, extracted for testability.
|
||||
public static ushort BlockAt(int worldY, int surfaceHeight, IBiome biome, BlockRegistry blocks)
|
||||
{
|
||||
if (worldY > surfaceHeight)
|
||||
return worldY <= WorldConstants.SeaLevel ? blocks.Water.Id : blocks.Air.Id;
|
||||
if (worldY == surfaceHeight)
|
||||
return biome.TopBlock(worldY).Id;
|
||||
if (worldY >= surfaceHeight - WorldGenerator.FillerDepth)
|
||||
return biome.Surface.Filler.Id;
|
||||
return biome.Surface.Under.Id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user