diff --git a/src/Engine.Core/Components/Transform.cs b/src/Engine.Core/Components/Transform.cs
index 5e8d5d9..c47a978 100644
--- a/src/Engine.Core/Components/Transform.cs
+++ b/src/Engine.Core/Components/Transform.cs
@@ -27,4 +27,25 @@ public record struct Transform
* Matrix4x4.CreateFromQuaternion(Rotation)
* Matrix4x4.CreateTranslation(Position);
}
+
+ ///
+ /// Transform a normal vector from local to world space using the inverse-transpose of the model matrix.
+ ///
+ public Vector3 TransformNormal(Vector3 normal)
+ {
+ var matrix = GetMatrix();
+ if (!Matrix4x4.Invert(matrix, out var inverted))
+ return normal;
+
+ // Use the upper 3x3 of the transposed inverse matrix.
+ var nx = inverted.M11 * normal.X + inverted.M21 * normal.Y + inverted.M31 * normal.Z;
+ var ny = inverted.M12 * normal.X + inverted.M22 * normal.Y + inverted.M32 * normal.Z;
+ var nz = inverted.M13 * normal.X + inverted.M23 * normal.Y + inverted.M33 * normal.Z;
+
+ var result = new Vector3(nx, ny, nz);
+ if (result.LengthSquared() > 0.00001f)
+ result = Vector3.Normalize(result);
+
+ return result;
+ }
}
diff --git a/src/Engine.Core/Vertex.cs b/src/Engine.Core/Vertex.cs
index dc96031..6ed905e 100644
--- a/src/Engine.Core/Vertex.cs
+++ b/src/Engine.Core/Vertex.cs
@@ -3,17 +3,19 @@ using System.Numerics;
namespace Engine.Core;
///
-/// A simple 3D vertex with position and color.
+/// A 3D vertex with position, color, and normal.
/// Layout matches the Vulkan vertex input description.
///
public struct Vertex
{
public Vector3 Position;
public Vector3 Color;
+ public Vector3 Normal;
- public Vertex(Vector3 position, Vector3 color)
+ public Vertex(Vector3 position, Vector3 color, Vector3 normal)
{
Position = position;
Color = color;
+ Normal = normal;
}
}
diff --git a/src/Engine.Graphics/Loaders/GltfLoader.cs b/src/Engine.Graphics/Loaders/GltfLoader.cs
index 685c12d..232885a 100644
--- a/src/Engine.Graphics/Loaders/GltfLoader.cs
+++ b/src/Engine.Graphics/Loaders/GltfLoader.cs
@@ -10,6 +10,7 @@ namespace Engine.Graphics.Loaders;
///
/// glTF/glTF binary loader using SharpGLTF.Core.
/// Loads the first primitive of the first mesh and converts it to a colored Mesh component.
+/// Creates per-face normals for flat shading if the glTF does not provide normals.
///
public static class GltfLoader
{
@@ -31,28 +32,63 @@ public static class GltfLoader
throw new InvalidOperationException($"glTF primitive has no POSITION accessor: {path}");
var positions = positionAccessor.AsVector3Array();
- var vertices = new Vertex[positions.Count];
- for (var i = 0; i < positions.Count; i++)
+
+ var indices = GetIndices(primitive, positions.Count);
+ var vertices = new List();
+ var newIndices = new List();
+
+ for (var i = 0; i < indices.Length; i += 3)
{
- vertices[i] = new Vertex(positions[i], color);
+ var i0 = (int)indices[i];
+ var i1 = (int)indices[i + 1];
+ var i2 = (int)indices[i + 2];
+
+ var v0 = new Vector3(positions[i0].X, positions[i0].Y, positions[i0].Z);
+ var v1 = new Vector3(positions[i1].X, positions[i1].Y, positions[i1].Z);
+ var v2 = new Vector3(positions[i2].X, positions[i2].Y, positions[i2].Z);
+
+ var normal = ComputeFaceNormal(v0, v1, v2);
+
+ var vertexBase = (uint)vertices.Count;
+ newIndices.Add(vertexBase);
+ newIndices.Add(vertexBase + 1);
+ newIndices.Add(vertexBase + 2);
+
+ vertices.Add(new Vertex(v0, color, normal));
+ vertices.Add(new Vertex(v1, color, normal));
+ vertices.Add(new Vertex(v2, color, normal));
}
- uint[] indices;
+ return new Engine.Core.Components.Mesh(vertices.ToArray(), newIndices.ToArray());
+ }
+
+ private static uint[] GetIndices(MeshPrimitive primitive, int positionCount)
+ {
if (primitive.IndexAccessor != null)
{
var idx = primitive.IndexAccessor.AsIndexArray();
- indices = new uint[idx.Count];
+ var indices = new uint[idx.Count];
for (var i = 0; i < idx.Count; i++)
indices[i] = idx[i];
- }
- else
- {
- // Non-indexed primitive
- indices = new uint[positions.Count];
- for (var i = 0; i < positions.Count; i++)
- indices[i] = (uint)i;
+ return indices;
}
- return new Engine.Core.Components.Mesh(vertices, indices);
+ // Non-indexed primitive
+ var auto = new uint[positionCount];
+ for (var i = 0; i < positionCount; i++)
+ auto[i] = (uint)i;
+ return auto;
+ }
+
+ private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
+ {
+ var ab = b - a;
+ var ac = c - a;
+ var normal = Vector3.Cross(ab, ac);
+ if (normal.LengthSquared() > 0.00001f)
+ normal = Vector3.Normalize(normal);
+ else
+ normal = Vector3.UnitY;
+ return normal;
}
}
diff --git a/src/Engine.Graphics/Loaders/ObjLoader.cs b/src/Engine.Graphics/Loaders/ObjLoader.cs
index 6066d86..9112c6e 100644
--- a/src/Engine.Graphics/Loaders/ObjLoader.cs
+++ b/src/Engine.Graphics/Loaders/ObjLoader.cs
@@ -9,7 +9,7 @@ namespace Engine.Graphics.Loaders;
///
/// Minimal .obj loader.
-/// Supports vertices (v) and faces (f). Ignores normals/UVs for now.
+/// Supports vertices (v) and faces (f). Creates per-face normals for flat shading.
/// Produces a colored Mesh component.
///
public static class ObjLoader
@@ -19,6 +19,7 @@ public static class ObjLoader
var color = defaultColor ?? new Vector3(0.7f, 0.7f, 0.7f);
var positions = new List();
+ var vertices = new List();
var indices = new List();
foreach (var rawLine in File.ReadLines(path))
@@ -41,28 +42,36 @@ public static class ObjLoader
break;
case "f" when parts.Length >= 4:
- // Triangulate the face as a fan. Only the position index is used.
+ // Triangulate the face as a fan.
var baseIndex = ParseFaceIndex(parts[1]);
for (var i = 2; i < parts.Length - 1; i++)
{
- indices.Add(baseIndex);
- indices.Add(ParseFaceIndex(parts[i]));
- indices.Add(ParseFaceIndex(parts[i + 1]));
+ var i0 = baseIndex;
+ var i1 = ParseFaceIndex(parts[i]);
+ var i2 = ParseFaceIndex(parts[i + 1]);
+
+ var v0 = positions[(int)i0];
+ var v1 = positions[(int)i1];
+ var v2 = positions[(int)i2];
+ var normal = ComputeFaceNormal(v0, v1, v2);
+
+ var vertexBase = (uint)vertices.Count;
+ indices.Add(vertexBase);
+ indices.Add(vertexBase + 1);
+ indices.Add(vertexBase + 2);
+
+ vertices.Add(new Vertex(v0, color, normal));
+ vertices.Add(new Vertex(v1, color, normal));
+ vertices.Add(new Vertex(v2, color, normal));
}
break;
}
}
- if (positions.Count == 0)
+ if (vertices.Count == 0)
throw new InvalidOperationException($"OBJ file has no vertices: {path}");
- var vertices = new Vertex[positions.Count];
- for (var i = 0; i < positions.Count; i++)
- {
- vertices[i] = new Vertex(positions[i], color);
- }
-
- return new Mesh(vertices, indices.ToArray());
+ return new Mesh(vertices.ToArray(), indices.ToArray());
}
private static uint ParseFaceIndex(string part)
@@ -73,4 +82,16 @@ public static class ObjLoader
var index = int.Parse(indexStr);
return (uint)(index - 1); // OBJ indices are 1-based
}
+
+ private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
+ {
+ var ab = b - a;
+ var ac = c - a;
+ var normal = Vector3.Cross(ab, ac);
+ if (normal.LengthSquared() > 0.00001f)
+ normal = Vector3.Normalize(normal);
+ else
+ normal = Vector3.UnitY;
+ return normal;
+ }
}
diff --git a/src/Engine.Graphics/MeshRenderer.cs b/src/Engine.Graphics/MeshRenderer.cs
index 20ca208..6d87d66 100644
--- a/src/Engine.Graphics/MeshRenderer.cs
+++ b/src/Engine.Graphics/MeshRenderer.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
+using System.Runtime.InteropServices;
using Flecs.NET.Core;
using Silk.NET.Core;
using Silk.NET.Vulkan;
@@ -27,6 +28,18 @@ public sealed unsafe class MeshRenderer : IDisposable
private Silk.NET.Vulkan.Fence[] _inFlightFences = null!;
private int _currentFrame;
+ [StructLayout(LayoutKind.Sequential)]
+ private struct PushConstants
+ {
+ public Matrix4x4 Mvp;
+ public Vector3 LightDirection;
+ public float Pad1;
+ public Vector3 LightColor;
+ public float Pad2;
+ public Vector3 AmbientColor;
+ public float Pad3;
+ }
+
private sealed class MeshBuffers : IDisposable
{
public VertexBuffer VertexBuffer;
@@ -194,7 +207,17 @@ public sealed unsafe class MeshRenderer : IDisposable
var model = transform.GetMatrix();
var mvp = Matrix4x4.Multiply(Matrix4x4.Multiply(model, view), proj);
var mvpT = Matrix4x4.Transpose(mvp);
- _context.Vk.CmdPushConstants(drawCmd, _pipeline.Layout, ShaderStageFlags.VertexBit, 0, 64, &mvpT);
+
+ var push = new PushConstants
+ {
+ Mvp = mvpT,
+ LightDirection = new Vector3(0.5f, -1.0f, -0.5f),
+ LightColor = new Vector3(1.0f, 0.95f, 0.8f),
+ AmbientColor = new Vector3(0.15f, 0.15f, 0.2f)
+ };
+
+ var pushSize = (uint)sizeof(PushConstants);
+ _context.Vk.CmdPushConstants(drawCmd, _pipeline.Layout, ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, 0, pushSize, &push);
var vertexBuffer = buffers.VertexBuffer.Buffer;
var offset = 0ul;
@@ -252,19 +275,22 @@ public sealed unsafe class MeshRenderer : IDisposable
private MeshBuffers CreateMeshBuffers(Mesh mesh)
{
- var vertexBytes = new byte[mesh.Vertices.Length * 6 * sizeof(float)];
+ var vertexBytes = new byte[mesh.Vertices.Length * 9 * sizeof(float)];
fixed (byte* p = vertexBytes)
{
var dst = (float*)p;
for (var i = 0; i < mesh.Vertices.Length; i++)
{
var v = mesh.Vertices[i];
- dst[i * 6 + 0] = v.Position.X;
- dst[i * 6 + 1] = v.Position.Y;
- dst[i * 6 + 2] = v.Position.Z;
- dst[i * 6 + 3] = v.Color.X;
- dst[i * 6 + 4] = v.Color.Y;
- dst[i * 6 + 5] = v.Color.Z;
+ dst[i * 9 + 0] = v.Position.X;
+ dst[i * 9 + 1] = v.Position.Y;
+ dst[i * 9 + 2] = v.Position.Z;
+ dst[i * 9 + 3] = v.Color.X;
+ dst[i * 9 + 4] = v.Color.Y;
+ dst[i * 9 + 5] = v.Color.Z;
+ dst[i * 9 + 6] = v.Normal.X;
+ dst[i * 9 + 7] = v.Normal.Y;
+ dst[i * 9 + 8] = v.Normal.Z;
}
}
@@ -283,19 +309,24 @@ public sealed unsafe class MeshRenderer : IDisposable
private byte[] BuildMeshVertices(Mesh mesh, Transform transform)
{
var matrix = transform.GetMatrix();
- var bytes = new byte[mesh.Vertices.Length * 6 * sizeof(float)];
+ var bytes = new byte[mesh.Vertices.Length * 9 * sizeof(float)];
fixed (byte* p = bytes)
{
var dst = (float*)p;
for (var i = 0; i < mesh.Vertices.Length; i++)
{
- var transformed = Vector3.Transform(mesh.Vertices[i].Position, matrix);
- dst[i * 6 + 0] = transformed.X;
- dst[i * 6 + 1] = transformed.Y;
- dst[i * 6 + 2] = transformed.Z;
- dst[i * 6 + 3] = mesh.Vertices[i].Color.X;
- dst[i * 6 + 4] = mesh.Vertices[i].Color.Y;
- dst[i * 6 + 5] = mesh.Vertices[i].Color.Z;
+ var v = mesh.Vertices[i];
+ var transformed = Vector3.Transform(v.Position, matrix);
+ var normal = transform.TransformNormal(v.Normal);
+ dst[i * 9 + 0] = transformed.X;
+ dst[i * 9 + 1] = transformed.Y;
+ dst[i * 9 + 2] = transformed.Z;
+ dst[i * 9 + 3] = v.Color.X;
+ dst[i * 9 + 4] = v.Color.Y;
+ dst[i * 9 + 5] = v.Color.Z;
+ dst[i * 9 + 6] = normal.X;
+ dst[i * 9 + 7] = normal.Y;
+ dst[i * 9 + 8] = normal.Z;
}
}
return bytes;
diff --git a/src/Engine.Graphics/Shaders/fragment.frag b/src/Engine.Graphics/Shaders/fragment.frag
index 5906065..9a1a4f8 100644
--- a/src/Engine.Graphics/Shaders/fragment.frag
+++ b/src/Engine.Graphics/Shaders/fragment.frag
@@ -1,10 +1,31 @@
#version 450
layout(location = 0) in vec3 fragColor;
+layout(location = 1) in vec3 fragNormal;
+layout(location = 2) in vec3 fragWorldPos;
layout(location = 0) out vec4 outColor;
+layout(push_constant) uniform PushConstants
+{
+ mat4 mvp;
+ vec3 lightDirection;
+ float pad1;
+ vec3 lightColor;
+ float pad2;
+ vec3 ambientColor;
+ float pad3;
+} push;
+
void main()
{
- outColor = vec4(fragColor, 1.0);
+ vec3 normal = normalize(fragNormal);
+ vec3 lightDir = normalize(-push.lightDirection);
+
+ float diff = max(dot(normal, lightDir), 0.0);
+ vec3 diffuse = push.lightColor * diff;
+ vec3 ambient = push.ambientColor;
+
+ vec3 result = (ambient + diffuse) * fragColor;
+ outColor = vec4(result, 1.0);
}
diff --git a/src/Engine.Graphics/Shaders/fragment.spv b/src/Engine.Graphics/Shaders/fragment.spv
index 828f1e5..0789ea7 100644
Binary files a/src/Engine.Graphics/Shaders/fragment.spv and b/src/Engine.Graphics/Shaders/fragment.spv differ
diff --git a/src/Engine.Graphics/Shaders/vertex.spv b/src/Engine.Graphics/Shaders/vertex.spv
index 5219475..c3e8f88 100644
Binary files a/src/Engine.Graphics/Shaders/vertex.spv and b/src/Engine.Graphics/Shaders/vertex.spv differ
diff --git a/src/Engine.Graphics/Shaders/vertex.vert b/src/Engine.Graphics/Shaders/vertex.vert
index 6adc1ff..d779b1b 100644
--- a/src/Engine.Graphics/Shaders/vertex.vert
+++ b/src/Engine.Graphics/Shaders/vertex.vert
@@ -2,16 +2,27 @@
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;
+layout(location = 2) in vec3 inNormal;
layout(location = 0) out vec3 fragColor;
+layout(location = 1) out vec3 fragNormal;
+layout(location = 2) out vec3 fragWorldPos;
layout(push_constant) uniform PushConstants
{
mat4 mvp;
+ vec3 lightDirection;
+ float pad1;
+ vec3 lightColor;
+ float pad2;
+ vec3 ambientColor;
+ float pad3;
} push;
void main()
{
gl_Position = push.mvp * vec4(inPosition, 1.0);
fragColor = inColor;
+ fragNormal = inNormal;
+ fragWorldPos = inPosition;
}
diff --git a/src/Engine.Graphics/VulkanPipeline.cs b/src/Engine.Graphics/VulkanPipeline.cs
index 59e8e6f..40c1def 100644
--- a/src/Engine.Graphics/VulkanPipeline.cs
+++ b/src/Engine.Graphics/VulkanPipeline.cs
@@ -57,9 +57,9 @@ public sealed unsafe class VulkanPipeline : IDisposable
{
var pushConstantRange = new PushConstantRange
{
- StageFlags = ShaderStageFlags.VertexBit,
+ StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
Offset = 0,
- Size = (uint)(16 * sizeof(float))
+ Size = (uint)(28 * sizeof(float))
};
var createInfo = new PipelineLayoutCreateInfo
@@ -101,7 +101,7 @@ public sealed unsafe class VulkanPipeline : IDisposable
var bindingDescription = new VertexInputBindingDescription
{
Binding = 0,
- Stride = (uint)(6 * sizeof(float)),
+ Stride = (uint)(9 * sizeof(float)),
InputRate = VertexInputRate.Vertex
};
@@ -120,6 +120,13 @@ public sealed unsafe class VulkanPipeline : IDisposable
Location = 1,
Format = Format.R32G32B32Sfloat,
Offset = (uint)(3 * sizeof(float))
+ },
+ new VertexInputAttributeDescription
+ {
+ Binding = 0,
+ Location = 2,
+ Format = Format.R32G32B32Sfloat,
+ Offset = (uint)(6 * sizeof(float))
}
};