Compare commits
10 Commits
c81cd3c561
...
a8e94a27b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8e94a27b2 | ||
|
|
f5787dc9cc | ||
|
|
03cf0ceb07 | ||
|
|
6196b38cc8 | ||
|
|
9880e4379c | ||
|
|
589904beec | ||
|
|
294f3b82ef | ||
|
|
d0707fde9d | ||
|
|
44f53e8d50 | ||
|
|
99b411bdbd |
33
Core/Game.cs
33
Core/Game.cs
@@ -6,6 +6,7 @@ using OpenTK.Windowing.GraphicsLibraryFramework;
|
|||||||
using VoxelEngine.Rendering;
|
using VoxelEngine.Rendering;
|
||||||
using VoxelEngine.World.Blocks;
|
using VoxelEngine.World.Blocks;
|
||||||
using VoxelEngine.World.Chunk;
|
using VoxelEngine.World.Chunk;
|
||||||
|
using VoxelEngine.World.WorldGen;
|
||||||
|
|
||||||
namespace VoxelEngine.Core;
|
namespace VoxelEngine.Core;
|
||||||
|
|
||||||
@@ -13,12 +14,14 @@ public sealed class Game : GameWindow
|
|||||||
{
|
{
|
||||||
private const float MoveSpeed = 8f; // units per second
|
private const float MoveSpeed = 8f; // units per second
|
||||||
private const float Sensitivity = 0.15f; // degrees per pixel
|
private const float Sensitivity = 0.15f; // degrees per pixel
|
||||||
|
private const int Seed = 1337;
|
||||||
|
|
||||||
private readonly BlockRegistry _registry = new();
|
private readonly BlockRegistry _registry = new();
|
||||||
private Shader _shader = null!;
|
private Shader _shader = null!;
|
||||||
private Texture _texture = null!;
|
private Texture _texture = null!;
|
||||||
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
|
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
|
||||||
private Camera _camera = null!;
|
private Camera _camera = null!;
|
||||||
|
private WorldGenerator _generator = null!;
|
||||||
|
|
||||||
private World.World _world;
|
private World.World _world;
|
||||||
|
|
||||||
@@ -37,11 +40,14 @@ public sealed class Game : GameWindow
|
|||||||
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png");
|
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png");
|
||||||
|
|
||||||
this._world = new World.World(this._registry);
|
this._world = new World.World(this._registry);
|
||||||
|
this._generator = new WorldGenerator(Game.Seed, this._registry);
|
||||||
|
|
||||||
for (int x = -2; x < 2; x++)
|
for (int cx = -2; cx < 2; cx++)
|
||||||
for (int z = -2; z < 2; z++)
|
for (int cz = -2; cz < 2; 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(this._texture.Width, this._texture.Height);
|
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
|
||||||
@@ -59,28 +65,13 @@ public sealed class Game : GameWindow
|
|||||||
this._renderer[pos] = new ChunkRenderer(mesh);
|
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;
|
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)
|
protected override void OnUpdateFrame(FrameEventArgs args)
|
||||||
{
|
{
|
||||||
base.OnUpdateFrame(args);
|
base.OnUpdateFrame(args);
|
||||||
|
|||||||
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="OpenTK" Version="4.9.4" />
|
<PackageReference Include="OpenTK" Version="4.9.4" />
|
||||||
<PackageReference Include="SimplexNoise" Version="2.0.0" />
|
|
||||||
<PackageReference Include="StbImageSharp" Version="2.30.15" />
|
<PackageReference Include="StbImageSharp" Version="2.30.15" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -20,8 +19,4 @@
|
|||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="World\WorldGen\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ public class WaterBlock : IBlock
|
|||||||
public string Name => "Water";
|
public string Name => "Water";
|
||||||
public bool IsOpaque => true;
|
public bool IsOpaque => true;
|
||||||
public bool IsSolid => false;
|
public bool IsSolid => false;
|
||||||
public TileCoord GetTextureTile(BlockFace face) => new(3, 3);
|
public TileCoord GetTextureTile(BlockFace face) => new(3, 1);
|
||||||
}
|
}
|
||||||
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