Compare commits

4 Commits

Author SHA1 Message Date
MDietermann
c81cd3c561 feat: derive atlas tile size from loaded texture dimensions 2026-05-31 21:43:53 +02:00
MDietermann
50f2bc4221 fix: complete WaterBlock IBlock implementation and BlockIds list 2026-05-31 21:41:25 +02:00
MDietermann
af7a83300f feat: 4x4 atlas with TileCoord-based UVs and grass faces 2026-05-31 21:39:42 +02:00
MDietermann
6eb17b6c5d feat: make Sand/Snow opaque and register Sand/Snow/Water blocks 2026-05-31 21:38:11 +02:00
13 changed files with 124 additions and 17 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -44,7 +44,7 @@ public sealed class Game : GameWindow
this._world.SetChunk(new Vector3i(x, 0, z), this.BuildChunk());
}
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;

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -0,0 +1,7 @@
Air = 0
Stone = 1
Dirt = 2
Grass = 3
Sand = 4
Snow = 5
Water = 6

View File

@@ -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)
{

View File

@@ -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
View 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
View 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)
};
}

View 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);

View 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, 3);
}

View File

@@ -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);