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