Compare commits
13 Commits
f4d3f9aab4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e14786668 | ||
|
|
ab8816e27c | ||
|
|
c6916a6dd5 | ||
| 49d35cab5b | |||
| 70c986529b | |||
| 8e3195ca37 | |||
| 177c47e8f8 | |||
| 7155ea7425 | |||
| df22096548 | |||
| 50bb77cb2d | |||
| d7ecf019cb | |||
| ecae0d4ce4 | |||
| 90be1035d4 |
13
Assets/shaders/chunk.frag
Normal file
13
Assets/shaders/chunk.frag
Normal 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
19
Assets/shaders/chunk.vert
Normal 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;
|
||||
}
|
||||
BIN
Assets/textures/VoxelShadeAtlas.png
Normal file
BIN
Assets/textures/VoxelShadeAtlas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
Assets/textures/atlas.png
Normal file
BIN
Assets/textures/atlas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 910 KiB |
152
Core/Game.cs
Normal file
152
Core/Game.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
23
Program.cs
23
Program.cs
@@ -1,11 +1,16 @@
|
||||
using OpenTK;
|
||||
using OpenTK.Mathematics;
|
||||
using OpenTK.Windowing.Common;
|
||||
using OpenTK.Windowing.Desktop;
|
||||
using VoxelEngine.Core;
|
||||
|
||||
namespace VoxelEngine;
|
||||
NativeWindowSettings nativeSettings = new()
|
||||
{
|
||||
ClientSize = new Vector2i(1280, 720),
|
||||
Title = "VoxelEngine",
|
||||
APIVersion = new Version(3, 3),
|
||||
Profile = ContextProfile.Core,
|
||||
Flags = ContextFlags.ForwardCompatible
|
||||
};
|
||||
|
||||
class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("Hello, World!");
|
||||
}
|
||||
}
|
||||
using Game game = new(GameWindowSettings.Default, nativeSettings);
|
||||
game.Run();
|
||||
|
||||
@@ -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
54
Rendering/Camera.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
52
Rendering/ChunkRenderer.cs
Normal file
52
Rendering/ChunkRenderer.cs
Normal 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
64
Rendering/Shader.cs
Normal 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
35
Rendering/Texture.cs
Normal 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);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
43
VoxelEngine.Tests/CameraTests.cs
Normal file
43
VoxelEngine.Tests/CameraTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
61
VoxelEngine.Tests/ChunkMesherTests.cs
Normal file
61
VoxelEngine.Tests/ChunkMesherTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
VoxelEngine.Tests/ChunkTests.cs
Normal file
41
VoxelEngine.Tests/ChunkTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace VoxelEngine.World;
|
||||
namespace VoxelEngine.World.Blocks;
|
||||
|
||||
public enum BlockFace
|
||||
{
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() { }
|
||||
@@ -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
19
World/Chunk/Chunk.cs
Normal 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
104
World/Chunk/ChunkMesher.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
World/Chunk/IChunkMesher.cs
Normal file
9
World/Chunk/IChunkMesher.cs
Normal 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
11
World/IWorld.cs
Normal 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
63
World/World.cs
Normal 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();
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user