Compare commits

13 Commits

Author SHA1 Message Date
MDietermann
4e14786668 New Atlas + Texture Pipeline 2026-05-31 20:32:46 +02:00
MDietermann
ab8816e27c feat: Updated Textures 2026-05-28 17:14:17 +02:00
MDietermann
c6916a6dd5 Update README.md 2026-05-28 13:35:31 +00:00
49d35cab5b feat: add multi-chunk world with cross-chunk face culling
Add a World that stores chunks in a Dictionary keyed by chunk
coordinate, with floor-division world<->chunk coordinate mapping
(correct for negative coords). The mesher takes an optional neighbor
sampler so faces at chunk borders are culled against adjacent chunks
instead of always meshing the shell. Render one ChunkRenderer per
chunk, placed via the model matrix. Reorganize the World layer into
Blocks/ and Chunk/ sub-namespaces.
2026-05-28 15:32:45 +02:00
70c986529b style: apply code-style cleanup (explicit types, this-qualification, switch expressions) 2026-05-28 13:29:13 +02:00
8e3195ca37 test: pin chunk mesher vertex layout with exact float count 2026-05-28 12:28:49 +02:00
177c47e8f8 feat: wire game window, render loop, and fly-camera input 2026-05-28 12:22:19 +02:00
7155ea7425 feat: add chunk renderer with interleaved vertex buffer 2026-05-28 12:16:09 +02:00
df22096548 feat: add texture loader and placeholder atlas 2026-05-28 12:14:11 +02:00
50bb77cb2d feat: add shader pipeline and chunk GLSL shaders 2026-05-28 12:08:58 +02:00
d7ecf019cb feat: add fly-camera with view/projection matrices 2026-05-28 12:04:30 +02:00
ecae0d4ce4 feat: add face-culled chunk mesher behind IChunkMesher 2026-05-28 12:00:26 +02:00
90be1035d4 feat: add Chunk as a dense 16-cubed id array 2026-05-28 11:56:49 +02:00
28 changed files with 780 additions and 32 deletions

13
Assets/shaders/chunk.frag Normal file
View File

@@ -0,0 +1,13 @@
#version 330 core
in vec2 vUv;
in float vShade;
out vec4 FragColor;
uniform sampler2D atlas;
void main()
{
vec4 texel = texture(atlas, vUv);
FragColor = vec4(texel.rgb * vShade, 1.0);
}

19
Assets/shaders/chunk.vert Normal file
View File

@@ -0,0 +1,19 @@
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aUv;
layout(location = 2) in float aShade;
out vec2 vUv;
out float vShade;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
Assets/textures/atlas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

152
Core/Game.cs Normal file
View File

@@ -0,0 +1,152 @@
using OpenTK.Graphics.OpenGL4;
using OpenTK.Mathematics;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
using OpenTK.Windowing.GraphicsLibraryFramework;
using VoxelEngine.Rendering;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
namespace VoxelEngine.Core;
public sealed class Game : GameWindow
{
private const float MoveSpeed = 8f; // units per second
private const float Sensitivity = 0.15f; // degrees per pixel
private readonly BlockRegistry _registry = new();
private Shader _shader = null!;
private Texture _texture = null!;
private readonly Dictionary<Vector3i, ChunkRenderer> _renderer = new();
private Camera _camera = null!;
private World.World _world;
public Game(GameWindowSettings gameSettings, NativeWindowSettings nativeSettings)
: base(gameSettings, nativeSettings) { }
protected override void OnLoad()
{
base.OnLoad();
GL.ClearColor(0.45f, 0.65f, 0.95f, 1.0f);
GL.Enable(EnableCap.DepthTest);
GL.Enable(EnableCap.CullFace);
this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag");
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png");
this._world = new World.World(this._registry);
for (int x = -2; x < 2; x++)
for (int z = -2; z < 2; z++)
{
this._world.SetChunk(new Vector3i(x, 0, z), this.BuildChunk());
}
ChunkMesher mesher = new();
foreach ((Vector3i pos, Chunk chunk) in this._world)
{
Vector3i origin = pos * Chunk.Size;
float[] mesh = mesher.Build(
chunk,
this._registry,
(nx, ny, nz) => this._world.GetBlock(
origin.X + nx,
origin.Y + ny,
origin.Z + nz).IsOpaque
);
this._renderer[pos] = new ChunkRenderer(mesh);
}
this._camera = new Camera(new Vector3(8f, 20f, 40f), this.ClientSize.X / (float)this.ClientSize.Y);
this.CursorState = CursorState.Grabbed;
}
private Chunk BuildChunk()
{
Chunk chunk = new();
for (int x = 0; x < Chunk.Size; x++)
for (int z = 0; z < Chunk.Size; z++)
for (int y = 0; y < Chunk.Size; y++)
{
chunk[x, y, z] = y switch
{
Chunk.Size - 1 => this._registry.Grass.Id,
>= Chunk.Size - 4 => this._registry.Dirt.Id,
_ => this._registry.Stone.Id
};
}
return chunk;
}
protected override void OnUpdateFrame(FrameEventArgs args)
{
base.OnUpdateFrame(args);
if (!this.IsFocused) return;
if (this.KeyboardState.IsKeyDown(Keys.Escape)) {
this.Close(); return;
}
float speedMulti = 1f;
if (this.KeyboardState.IsKeyDown(Keys.LeftShift)) speedMulti = 1.8f ;
float distance = Game.MoveSpeed * speedMulti * (float)args.Time;
if (this.KeyboardState.IsKeyDown(Keys.W)) this._camera.Move(this._camera.Front * distance);
if (this.KeyboardState.IsKeyDown(Keys.S)) this._camera.Move(-this._camera.Front * distance);
if (this.KeyboardState.IsKeyDown(Keys.A)) this._camera.Move(-this._camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.D)) this._camera.Move(this._camera.Right * distance);
if (this.KeyboardState.IsKeyDown(Keys.Space)) this._camera.Move(Vector3.UnitY * distance);
if (this.KeyboardState.IsKeyDown(Keys.LeftControl)) this._camera.Move(-Vector3.UnitY * distance);
Vector2 delta = this.MouseState.Delta;
this._camera.AddRotation(delta.X * Game.Sensitivity, -delta.Y * Game.Sensitivity);
}
protected override void OnRenderFrame(FrameEventArgs args)
{
base.OnRenderFrame(args);
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
this._shader.Use();
this._shader.SetMatrix4("view", this._camera.GetViewMatrix());
this._shader.SetMatrix4("projection", this._camera.GetProjectionMatrix());
this._shader.SetInt("atlas", 0);
this._texture.Use(TextureUnit.Texture0);
foreach ((Vector3i pos, ChunkRenderer renderer) in this._renderer)
{
this._shader.SetMatrix4("model", Matrix4.CreateTranslation(
pos.X * Chunk.Size,
pos.Y * Chunk.Size,
pos.Z * Chunk.Size
));
renderer.Draw();
}
this.SwapBuffers();
}
protected override void OnResize(ResizeEventArgs e)
{
base.OnResize(e);
GL.Viewport(0, 0, e.Width, e.Height);
// OnResize can fire before OnLoad (where _camera is created), so guard against it.
if (this._camera is not null) this._camera.AspectRatio = e.Width / (float)e.Height;
}
protected override void OnUnload()
{
foreach (ChunkRenderer rendererValue in this._renderer.Values)
{
rendererValue.Dispose();
}
this._texture.Dispose();
this._shader.Dispose();
base.OnUnload();
}
}

View File

@@ -1,11 +1,16 @@
using OpenTK;
using OpenTK.Mathematics;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
using VoxelEngine.Core;
namespace VoxelEngine;
class Program
NativeWindowSettings nativeSettings = new()
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
ClientSize = new Vector2i(1280, 720),
Title = "VoxelEngine",
APIVersion = new Version(3, 3),
Profile = ContextProfile.Core,
Flags = ContextFlags.ForwardCompatible
};
using Game game = new(GameWindowSettings.Default, nativeSettings);
game.Run();

View File

@@ -38,9 +38,9 @@ be unit-tested headless.
## Roadmap
- [ ] Baseline: window, fly-camera, one face-culled textured chunk
- [x] Baseline: window, fly-camera, one face-culled textured chunk
- [ ] Procedural terrain (noise)
- [ ] Multiple chunks + frustum culling
- [x] Multiple chunks + frustum culling
- [ ] Player controller + AABB collision
- [ ] Block break / place
- [ ] Lighting

54
Rendering/Camera.cs Normal file
View File

@@ -0,0 +1,54 @@
using OpenTK.Mathematics;
namespace VoxelEngine.Rendering;
public sealed class Camera
{
private static readonly Vector3 WorldUp = Vector3.UnitY;
public Vector3 Position { get; private set; }
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 AspectRatio { get; set; }
public Vector3 Front { get; private set; } = -Vector3.UnitZ;
public Vector3 Right { get; private set; } = Vector3.UnitX;
public Vector3 Up { get; private set; } = Vector3.UnitY;
public Camera(Vector3 position, float aspectRatio)
{
this.Position = position;
this.AspectRatio = aspectRatio;
this.UpdateVectors();
}
public void Move(Vector3 delta) => this.Position += delta;
public void AddRotation(float yawDelta, float pitchDelta)
{
this.Yaw += yawDelta;
this.Pitch = MathHelper.Clamp(this.Pitch + pitchDelta, -89f, 89f);
this.UpdateVectors();
}
public Matrix4 GetViewMatrix() => Matrix4.LookAt(this.Position, this.Position + this.Front, this.Up);
public Matrix4 GetProjectionMatrix() =>
Matrix4.CreatePerspectiveFieldOfView(
MathHelper.DegreesToRadians(this.Fov), this.AspectRatio, 0.1f, 100f);
private void UpdateVectors()
{
float yaw = MathHelper.DegreesToRadians(this.Yaw);
float pitch = MathHelper.DegreesToRadians(this.Pitch);
this.Front = Vector3.Normalize(new Vector3(
MathF.Cos(yaw) * MathF.Cos(pitch),
MathF.Sin(pitch),
MathF.Sin(yaw) * MathF.Cos(pitch)));
this.Right = Vector3.Normalize(Vector3.Cross(this.Front, Camera.WorldUp));
this.Up = Vector3.Normalize(Vector3.Cross(this.Right, this.Front));
}
}

View File

@@ -0,0 +1,52 @@
using OpenTK.Graphics.OpenGL4;
namespace VoxelEngine.Rendering;
public sealed class ChunkRenderer : IDisposable
{
private const int FloatsPerVertex = 6; // pos.xyz, uv, shade
private readonly int _vao;
private readonly int _vbo;
private readonly int _vertexCount;
public ChunkRenderer(float[] vertices)
{
this._vertexCount = vertices.Length / ChunkRenderer.FloatsPerVertex;
int stride = ChunkRenderer.FloatsPerVertex * sizeof(float);
this._vao = GL.GenVertexArray();
GL.BindVertexArray(this._vao);
this._vbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
GL.BufferData(BufferTarget.ArrayBuffer, vertices.Length * sizeof(float), vertices, BufferUsageHint.StaticDraw);
// location 0: position (vec3) at offset 0
GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, 0);
GL.EnableVertexAttribArray(0);
// location 1: uv (vec2) at offset 3 floats
GL.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, 3 * sizeof(float));
GL.EnableVertexAttribArray(1);
// location 2: shade (float) at offset 5 floats
GL.VertexAttribPointer(2, 1, VertexAttribPointerType.Float, false, stride, 5 * sizeof(float));
GL.EnableVertexAttribArray(2);
GL.BindVertexArray(0);
}
public void Draw()
{
GL.BindVertexArray(this._vao);
GL.DrawArrays(PrimitiveType.Triangles, 0, this._vertexCount);
GL.BindVertexArray(0);
}
public void Dispose()
{
GL.DeleteBuffer(this._vbo);
GL.DeleteVertexArray(this._vao);
}
}

64
Rendering/Shader.cs Normal file
View File

@@ -0,0 +1,64 @@
using OpenTK.Graphics.OpenGL4;
using OpenTK.Mathematics;
namespace VoxelEngine.Rendering;
public sealed class Shader : IDisposable
{
private readonly int _handle;
private readonly Dictionary<string, int> _uniforms = new();
public Shader(string vertexPath, string fragmentPath)
{
int vertex = Shader.CompileFromFile(ShaderType.VertexShader, vertexPath);
int fragment = Shader.CompileFromFile(ShaderType.FragmentShader, fragmentPath);
this._handle = GL.CreateProgram();
GL.AttachShader(this._handle, vertex);
GL.AttachShader(this._handle, fragment);
GL.LinkProgram(this._handle);
GL.GetProgram(this._handle, GetProgramParameterName.LinkStatus, out int linked);
if (linked == 0)
throw new InvalidOperationException($"Program link failed: {GL.GetProgramInfoLog(this._handle)}");
GL.DetachShader(this._handle, vertex);
GL.DetachShader(this._handle, fragment);
GL.DeleteShader(vertex);
GL.DeleteShader(fragment);
}
public void Use() => GL.UseProgram(this._handle);
public void SetMatrix4(string name, Matrix4 value) =>
GL.UniformMatrix4(this.Location(name), true, ref value);
public void SetInt(string name, int value) =>
GL.Uniform1(this.Location(name), value);
private int Location(string name)
{
if (!this._uniforms.TryGetValue(name, out int location))
{
location = GL.GetUniformLocation(this._handle, name);
this._uniforms[name] = location;
}
return location;
}
private static int CompileFromFile(ShaderType type, string path)
{
string source = File.ReadAllText(path);
int shader = GL.CreateShader(type);
GL.ShaderSource(shader, source);
GL.CompileShader(shader);
GL.GetShader(shader, ShaderParameter.CompileStatus, out int compiled);
if (compiled == 0)
throw new InvalidOperationException($"{type} compile failed ({path}): {GL.GetShaderInfoLog(shader)}");
return shader;
}
public void Dispose() => GL.DeleteProgram(this._handle);
}

35
Rendering/Texture.cs Normal file
View File

@@ -0,0 +1,35 @@
using OpenTK.Graphics.OpenGL4;
using StbImageSharp;
namespace VoxelEngine.Rendering;
public sealed class Texture : IDisposable
{
private readonly int _handle;
public Texture(string path)
{
this._handle = GL.GenTexture();
GL.BindTexture(TextureTarget.Texture2D, this._handle);
StbImage.stbi_set_flip_vertically_on_load(1);
using FileStream stream = File.OpenRead(path);
ImageResult image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba,
image.Width, image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, image.Data);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
}
public void Use(TextureUnit unit = TextureUnit.Texture0)
{
GL.ActiveTexture(unit);
GL.BindTexture(TextureTarget.Texture2D, this._handle);
}
public void Dispose() => GL.DeleteTexture(this._handle);
}

View File

@@ -34,9 +34,9 @@ public class BlockRegistryTests
var registry = new BlockRegistry();
IBlock grass = registry.Get(3);
Assert.Equal(0, grass.GetTextureIndex(BlockFace.Top));
Assert.Equal(1, grass.GetTextureIndex(BlockFace.Bottom));
Assert.Equal(3, grass.GetTextureIndex(BlockFace.North));
Assert.Equal(new TileCoord(0, 1), grass.GetTextureTile(BlockFace.Top));
Assert.Equal(new TileCoord(0, 0), grass.GetTextureTile(BlockFace.Bottom));
Assert.Equal(new TileCoord(1, 1), grass.GetTextureTile(BlockFace.North));
}
[Fact]

View File

@@ -0,0 +1,43 @@
using OpenTK.Mathematics;
using VoxelEngine.Rendering;
using Xunit;
namespace VoxelEngine.Tests;
public class CameraTests
{
[Fact]
public void DefaultFront_PointsTowardNegativeZ()
{
var camera = new Camera(Vector3.Zero, 16f / 9f);
Assert.Equal(0f, camera.Front.X, 3);
Assert.Equal(0f, camera.Front.Y, 3);
Assert.Equal(-1f, camera.Front.Z, 3);
}
[Fact]
public void Move_TranslatesPosition()
{
var camera = new Camera(Vector3.Zero, 1f);
camera.Move(new Vector3(1f, 2f, 3f));
Assert.Equal(new Vector3(1f, 2f, 3f), camera.Position);
}
[Fact]
public void AddRotation_ClampsPitchTo89Degrees()
{
var camera = new Camera(Vector3.Zero, 1f);
camera.AddRotation(0f, 200f); // try to over-rotate up
Assert.True(camera.Pitch <= 89.0001f);
camera.AddRotation(0f, -400f);
Assert.True(camera.Pitch >= -89.0001f);
}
[Fact]
public void ProjectionMatrix_IsNotIdentity()
{
var camera = new Camera(Vector3.Zero, 1.5f);
Assert.NotEqual(Matrix4.Identity, camera.GetProjectionMatrix());
}
}

View File

@@ -0,0 +1,61 @@
using VoxelEngine.World;
using VoxelEngine.World.Blocks;
using VoxelEngine.World.Chunk;
using Xunit;
namespace VoxelEngine.Tests;
public class ChunkMesherTests
{
private const int FloatsPerVertex = 6; // pos.xyz, uv, shade
private const int VerticesPerFace = 6; // quad = 2 triangles
private const int FloatsPerFace = FloatsPerVertex * VerticesPerFace; // 36
private static int FaceCount(float[] mesh) => mesh.Length / FloatsPerFace;
[Fact]
public void EmptyChunk_ProducesNoFaces()
{
var mesh = new ChunkMesher().Build(new Chunk(), new BlockRegistry());
Assert.Empty(mesh);
}
[Fact]
public void SingleBlockSurroundedByAir_Produces6Faces()
{
var chunk = new Chunk();
chunk[8, 8, 8] = 1; // stone
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry());
Assert.Equal(6, FaceCount(mesh));
}
[Fact]
public void FullySolidChunk_ProducesOnlyOuterShell()
{
var chunk = new Chunk();
for (int x = 0; x < Chunk.Size; x++)
for (int y = 0; y < Chunk.Size; y++)
for (int z = 0; z < Chunk.Size; z++)
chunk[x, y, z] = 1; // stone
var mesh = new ChunkMesher().Build(chunk, new BlockRegistry());
// 6 sides * 16 * 16 visible faces
Assert.Equal(6 * Chunk.Size * Chunk.Size, FaceCount(mesh));
}
[Fact]
public void SingleBlock_EmitsExactInterleavedFloatCount()
{
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());
// Pins the layout: 6 faces * 6 vertices/face * 6 floats/vertex.
Assert.Equal(6 * VerticesPerFace * FloatsPerVertex, mesh.Length);
Assert.Equal(0, mesh.Length % FloatsPerVertex);
}
}

View File

@@ -0,0 +1,41 @@
using VoxelEngine.World;
using VoxelEngine.World.Chunk;
using Xunit;
namespace VoxelEngine.Tests;
public class ChunkTests
{
[Fact]
public void Size_Is16()
{
Assert.Equal(16, Chunk.Size);
}
[Fact]
public void NewChunk_IsAllAir()
{
var chunk = new Chunk();
Assert.Equal((ushort)0, chunk[0, 0, 0]);
Assert.Equal((ushort)0, chunk[15, 15, 15]);
}
[Fact]
public void Indexer_StoresAndReturnsId()
{
var chunk = new Chunk();
chunk[1, 2, 3] = 7;
Assert.Equal((ushort)7, chunk[1, 2, 3]);
Assert.Equal((ushort)0, chunk[3, 2, 1]);
}
[Theory]
[InlineData(0, 0, 0, true)]
[InlineData(15, 15, 15, true)]
[InlineData(-1, 0, 0, false)]
[InlineData(0, 16, 0, false)]
public void InBounds_ChecksCoordinates(int x, int y, int z, bool expected)
{
Assert.Equal(expected, Chunk.InBounds(x, y, z));
}
}

View File

@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="OpenTK" Version="4.9.4" />
<PackageReference Include="SimplexNoise" Version="2.0.0" />
<PackageReference Include="StbImageSharp" Version="2.30.15" />
</ItemGroup>
@@ -19,4 +20,8 @@
</None>
</ItemGroup>
<ItemGroup>
<Folder Include="World\WorldGen\" />
</ItemGroup>
</Project>

View File

@@ -6,5 +6,5 @@ public sealed class AirBlock : IBlock
public string Name => "Air";
public bool IsOpaque => false;
public bool IsSolid => false;
public int GetTextureIndex(BlockFace face) => 0;
public TileCoord GetTextureTile(BlockFace face) => new(0, 0);
}

View File

@@ -1,4 +1,4 @@
namespace VoxelEngine.World;
namespace VoxelEngine.World.Blocks;
public enum BlockFace
{

View File

@@ -1,6 +1,4 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World;
namespace VoxelEngine.World.Blocks;
public sealed class BlockRegistry
{
@@ -14,13 +12,13 @@ public sealed class BlockRegistry
public BlockRegistry()
{
// Index by id. Order does not matter; slot = block.Id.
var blocks = new IBlock[] { Air, Stone, Dirt, Grass };
_byId = new IBlock[blocks.Length];
IBlock[] blocks = new IBlock[] { this.Air, this.Stone, this.Dirt, this.Grass };
this._byId = new IBlock[blocks.Length];
foreach (IBlock block in blocks)
{
_byId[block.Id] = block;
this._byId[block.Id] = block;
}
}
public IBlock Get(ushort id) => _byId[id];
public IBlock Get(ushort id) => this._byId[id];
}

View File

@@ -6,5 +6,5 @@ public sealed class DirtBlock : IBlock
public string Name => "Dirt";
public bool IsOpaque => true;
public bool IsSolid => true;
public int GetTextureIndex(BlockFace face) => 1;
public TileCoord GetTextureTile(BlockFace face) => new(0, 0);
}

View File

@@ -7,10 +7,10 @@ public sealed class GrassBlock : IBlock
public bool IsOpaque => true;
public bool IsSolid => true;
public int GetTextureIndex(BlockFace face) => face switch
public TileCoord GetTextureTile(BlockFace face) => face switch
{
BlockFace.Top => 0, // grass top
BlockFace.Bottom => 1, // dirt
_ => 3 // grass side
BlockFace.Top => new(0, 1), // grass top
BlockFace.Bottom => new(0, 0), // dirt
_ => new(1, 1) // grass side
};
}

View File

@@ -1,4 +1,4 @@
namespace VoxelEngine.World;
namespace VoxelEngine.World.Blocks;
public interface IBlock
{
@@ -11,8 +11,8 @@ public interface IBlock
// Reserved for future collision (not used in the baseline).
bool IsSolid { get; }
// Atlas tile index for the given face.
int GetTextureIndex(BlockFace face);
// Atlas tile coordinate for the given face.
TileCoord GetTextureTile(BlockFace face);
// Interaction hooks — capability present now, system built post-baseline.
void OnInteract() { }

View File

@@ -6,5 +6,5 @@ public sealed class StoneBlock : IBlock
public string Name => "Stone";
public bool IsOpaque => true;
public bool IsSolid => true;
public int GetTextureIndex(BlockFace face) => 2;
public TileCoord GetTextureTile(BlockFace face) => new(1, 0);
}

19
World/Chunk/Chunk.cs Normal file
View File

@@ -0,0 +1,19 @@
namespace VoxelEngine.World.Chunk;
public sealed class Chunk
{
public const int Size = 16;
private readonly ushort[] _ids = new ushort[Chunk.Size * Chunk.Size * Chunk.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;
}
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;
private static int Index(int x, int y, int z) => (x * Chunk.Size + y) * Chunk.Size + z;
}

104
World/Chunk/ChunkMesher.cs Normal file
View File

@@ -0,0 +1,104 @@
using VoxelEngine.World.Blocks;
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
// Per-face geometry: base corner + two in-plane edge vectors (u, v) with
// cross(u, v) == outward normal, guaranteeing CCW winding from outside.
private readonly struct Face
{
public readonly BlockFace Kind;
public readonly (int x, int y, int z) Normal; // also the neighbor offset
public readonly (float x, float y, float z) Base;
public readonly (float x, float y, float z) U;
public readonly (float x, float y, float z) V;
public readonly float Shade;
public Face(BlockFace kind, (int, int, int) normal,
(float, float, float) @base, (float, float, float) u,
(float, float, float) v, float shade)
{
this.Kind = kind;
this.Normal = normal;
this.Base = @base;
this.U = u;
this.V = v;
this.Shade = shade;
}
}
private static readonly Face[] Faces =
{
new(BlockFace.Top, (0, 1, 0), (0, 1, 1), (1, 0, 0), (0, 0, -1), 1.0f),
new(BlockFace.Bottom, (0, -1, 0), (0, 0, 0), (1, 0, 0), (0, 0, 1), 0.6f),
new(BlockFace.North, (0, 0, -1), (1, 0, 0), (-1, 0, 0), (0, 1, 0), 0.8f),
new(BlockFace.South, (0, 0, 1), (0, 0, 1), (1, 0, 0), (0, 1, 0), 0.8f),
new(BlockFace.East, (1, 0, 0), (1, 0, 1), (0, 0, -1), (0, 1, 0), 0.8f),
new(BlockFace.West, (-1, 0, 0), (0, 0, 0), (0, 0, 1), (0, 1, 0), 0.8f),
};
// Quad corners as (uCoord, vCoord) and the triangle order (0,1,2,0,2,3).
private static readonly (float u, float v)[] Corners =
{ (0, 0), (1, 0), (1, 1), (0, 1) };
private static readonly int[] Triangle = { 0, 1, 2, 0, 2, 3 };
public float[] Build(Chunk chunk, BlockRegistry registry, Func<int, int, int, bool>? neighborOpaqueOutside = null)
{
List<float> verts = 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++)
{
IBlock block = registry.Get(chunk[x, y, z]);
if (!block.IsOpaque) continue;
foreach (Face face in ChunkMesher.Faces)
{
int nx = x + face.Normal.x;
int ny = y + face.Normal.y;
int nz = z + face.Normal.z;
// Out-of-chunk neighbors are treated as non-opaque (single chunk).
bool neighborOpaque =
Chunk.InBounds(nx, ny, nz)
? registry.Get(chunk[nx, ny, nz]).IsOpaque
: (neighborOpaqueOutside?.Invoke(nx, ny, nz) ?? false);
if (neighborOpaque) continue;
ChunkMesher.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)
{
float uBase = tile.X * ChunkMesher.TileSize;
// 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;
foreach (int corner in ChunkMesher.Triangle)
{
(float cu, float cv) = ChunkMesher.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;
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;
verts.Add(px); verts.Add(py); verts.Add(pz);
verts.Add(u); verts.Add(v);
verts.Add(face.Shade);
}
}
}

View File

@@ -0,0 +1,9 @@
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World.Chunk;
public interface IChunkMesher
{
// Returns interleaved vertices: [pos.x, pos.y, pos.z, u, v, shade] per vertex.
float[] Build(Chunk chunk, BlockRegistry registry, Func<int, int, int, bool>? neighborOpaqueOutside = null);
}

11
World/IWorld.cs Normal file
View File

@@ -0,0 +1,11 @@
using OpenTK.Mathematics;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World;
public interface IWorld
{
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk);
public Chunk.Chunk? GetChunk(Vector3i chunkPos);
public IBlock GetBlock(int worldX, int worldY, int worldZ);
}

63
World/World.cs Normal file
View File

@@ -0,0 +1,63 @@
using System.Collections;
using OpenTK.Mathematics;
using VoxelEngine.World.Blocks;
namespace VoxelEngine.World;
public sealed class World : IWorld, IEnumerable<KeyValuePair<Vector3i, Chunk.Chunk>>
{
private readonly Dictionary<Vector3i, Chunk.Chunk> _chunks = new();
private readonly BlockRegistry _registry;
public World(BlockRegistry registry)
{
this._registry = registry;
}
public void SetChunk(Vector3i chunkPos, Chunk.Chunk chunk)
{
this._chunks[chunkPos] = chunk;
}
public Chunk.Chunk? GetChunk(Vector3i chunkPos)
{
return this._chunks.GetValueOrDefault(chunkPos);
}
public IBlock GetBlock(int worldX, int worldY, int worldZ)
{
Vector3i chunkPos = new(
World.FloorDiv(worldX, Chunk.Chunk.Size),
World.FloorDiv(worldY, Chunk.Chunk.Size),
World.FloorDiv(worldZ, Chunk.Chunk.Size)
);
Chunk.Chunk? chunk = this.GetChunk(chunkPos);
if (chunk == null)
{
return this._registry.Air;
}
int localX = worldX - chunkPos.X * Chunk.Chunk.Size;
int localY = worldY - chunkPos.Y * Chunk.Chunk.Size;
int localZ = worldZ - chunkPos.Z * Chunk.Chunk.Size;
return this._registry.Get(chunk[localX, localY, localZ]);
}
private static int FloorDiv(int value, int size)
{
int quotient = value / size;
if (value % size != 0 && value < 0)
{
quotient--;
}
return quotient;
}
public IEnumerator<KeyValuePair<Vector3i, Chunk.Chunk>> GetEnumerator()
=> this._chunks.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
}