Compare commits
10 Commits
f4d3f9aab4
...
49d35cab5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/atlas.png
Normal file
BIN
Assets/textures/atlas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 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/atlas.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
|
using Game game = new(GameWindowSettings.Default, nativeSettings);
|
||||||
{
|
game.Run();
|
||||||
static void Main(string[] args)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Hello, World!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace VoxelEngine.World;
|
namespace VoxelEngine.World.Blocks;
|
||||||
|
|
||||||
public enum BlockFace
|
public enum BlockFace
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using VoxelEngine.World.Blocks;
|
namespace VoxelEngine.World.Blocks;
|
||||||
|
|
||||||
namespace VoxelEngine.World;
|
|
||||||
|
|
||||||
public sealed class BlockRegistry
|
public sealed class BlockRegistry
|
||||||
{
|
{
|
||||||
@@ -14,13 +12,13 @@ public sealed class BlockRegistry
|
|||||||
public BlockRegistry()
|
public BlockRegistry()
|
||||||
{
|
{
|
||||||
// Index by id. Order does not matter; slot = block.Id.
|
// Index by id. Order does not matter; slot = block.Id.
|
||||||
var blocks = new IBlock[] { Air, Stone, Dirt, Grass };
|
IBlock[] blocks = new IBlock[] { this.Air, this.Stone, this.Dirt, this.Grass };
|
||||||
_byId = new IBlock[blocks.Length];
|
this._byId = new IBlock[blocks.Length];
|
||||||
foreach (IBlock block in blocks)
|
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];
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace VoxelEngine.World;
|
namespace VoxelEngine.World.Blocks;
|
||||||
|
|
||||||
public interface IBlock
|
public interface IBlock
|
||||||
{
|
{
|
||||||
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;
|
||||||
|
}
|
||||||
99
World/Chunk/ChunkMesher.cs
Normal file
99
World/Chunk/ChunkMesher.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
using VoxelEngine.World.Blocks;
|
||||||
|
|
||||||
|
namespace VoxelEngine.World.Chunk;
|
||||||
|
|
||||||
|
public sealed class ChunkMesher : IChunkMesher
|
||||||
|
{
|
||||||
|
private const int AtlasTiles = 4; // horizontal strip of 4 tiles
|
||||||
|
|
||||||
|
// 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.GetTextureIndex(face.Kind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return verts.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddFace(List<float> verts, int x, int y, int z, Face face, int texIndex)
|
||||||
|
{
|
||||||
|
float tileWidth = 1f / ChunkMesher.AtlasTiles;
|
||||||
|
float uBase = texIndex * tileWidth;
|
||||||
|
|
||||||
|
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 * tileWidth;
|
||||||
|
|
||||||
|
verts.Add(px); verts.Add(py); verts.Add(pz);
|
||||||
|
verts.Add(u); verts.Add(cv);
|
||||||
|
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