From 98a429710a7dcbf8c9cec5a5882f2408467233fa Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Tue, 16 Jun 2026 20:18:20 +0300 Subject: [PATCH] feat: directional lighting with per-face normals - Add Normal to Vertex struct and vertex input pipeline. - Update vertex/fragment shaders with push-constant light data (direction, color, ambient). - Compute per-face normals in ObjLoader and GltfLoader for flat shading. - Add Transform.TransformNormal() using inverse-transpose of the model matrix. - MeshRenderer builds world-space normals and pushes light constants each frame. - Recompile SPIR-V shaders. --- src/Engine.Core/Components/Transform.cs | 21 ++++++++ src/Engine.Core/Vertex.cs | 6 ++- src/Engine.Graphics/Loaders/GltfLoader.cs | 62 ++++++++++++++++----- src/Engine.Graphics/Loaders/ObjLoader.cs | 47 +++++++++++----- src/Engine.Graphics/MeshRenderer.cs | 63 ++++++++++++++++------ src/Engine.Graphics/Shaders/fragment.frag | 23 +++++++- src/Engine.Graphics/Shaders/fragment.spv | Bin 500 -> 1872 bytes src/Engine.Graphics/Shaders/vertex.spv | Bin 1236 -> 1748 bytes src/Engine.Graphics/Shaders/vertex.vert | 11 ++++ src/Engine.Graphics/VulkanPipeline.cs | 13 +++-- 10 files changed, 198 insertions(+), 48 deletions(-) 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 828f1e56b1fa987e9bc5cdb272d43039ab4f4c08..0789ea74595b197e5bbdd97f69a490e040d258cd 100644 GIT binary patch literal 1872 zcmZ9MOH&g;6op$N0VMJedHBLWe4uC)1w>KNLMtp%RxB58TsWEprYgx$nTcC<;ntPE z$Y15gD$h65SEe#mm%iup>D#wYcPNZh#+*ClCf&Gu>auUvjfipLis@LZzpGc{q*>kE zx{qSY6;dIZX*c0cYb3fs*kfW+fpAl?tC*3Vd2OVupz6;3*5XmI`E}q4&Gn*wH|Q`w zn$~LfgTpsDZc3U?czBe&3j0c>D!N&XjX`|0ANArS=q0gmQT5>Yx?hfk$5Kq+w!ZZ_ z2_tn~R28)Jb{sS}G>FUUa@thCA9W(_Xj*!g_Cco2YL5y8-A`ewmw5GwzL@DBqUWr0 zVD)VHvXW-lZW~_pco4Tz{hF|^o#H#f{Z>5aB$-!uj#suZ{&5G<;G`G4;!e=LkNTZv zBf=_IQ$O6>vI2i8518Mah2M{1=2wUC+7RaVgZ`Q7F_skESxHWS z&noEcb;>#STwT!v{7ZF)pNk6a0H3YSlIOqrx)+4utnNkSR+l;Iau#^2aYZ?M8Ro5` z`K5jF@u~t1d`aEHtthC=IhijjsEseoS>kp%Hh$|6WHeNDu**%RZh+Ce!qLl(ZHK#Lpks5hu83r7acr%aG!9d w-%<|u)_e9yIUJvHJIdkk&A7*&<2^QXdfxLB1?$l>&pT#M-Zyi9RbfZ*4}$n~00000 literal 500 zcmYk1y-EX75JtzWn?w`yV?aBJSO$WH20_%qB29_}pCBNLpf1Fu&$IbdHiG9|cg2O- zxpVF}^Ucj_XN`!RXhkyy@%#0o1_@X_<>~BUHvU*YjxR3HRdgatLN(oJ#4g!*TfV$| zu}8Ft9y&P&oHC|HeA1Ld|Lb#zJ;i2yU%VD8j*vXfcv>x=D>$V~-H8G|YkjJEfP)^V z$t}|Ph;tn_B#zO$i?#kk{!Oy4kzbd!9y0a&WcAkSTV$S)xO!`Mkd3I-c8G}W-Gn!| zk6wPx_=4o*8>q7n2>TaMd%Gng{jpxj^r&?Gk~x3Q_U?a3*stDu_qfH?_jvcsmRH0N D$)gy> diff --git a/src/Engine.Graphics/Shaders/vertex.spv b/src/Engine.Graphics/Shaders/vertex.spv index 521947565333f26b62843f7641f942e363673e4d..c3e8f88e36cd110c2bc4c6749dbe3ef179f74f29 100644 GIT binary patch literal 1748 zcmZ9MZEF)j6otnmn^dcs_(lr#Ly$;8zX%$eH67UOhRs&QPyPk} zlE2C?g6EmtNfxKv%$#%2%$&J5X|>X67<0-jngw&$V#;P%~`ju>2o}=G2ooyl44tNL$RZ{rMRQ0>aU{tw>Af+roZ{VK=&;QkD^cg zFnu4US@>O0Y|gVLmrQMDlUQuS>G9!s8)t|1M~KenxJ$WiWGDM}nhoNkaF*j0Js3Ch2$(adzPPXk*XhHKMQRJ|t;0)HU3DpgPh^-u*`VSTp?W zIcf&S>^b(pQ6JB-hNCu|O}+E8=e?jt=*cnk#Wv>^Wv)X)!*kpV_o~EkE?lp~aX(zY z#Nh?*V~N89+~*QUf8bO?d9S&CG__`cw>6hBeE{Q)Jbfr*eoJ6{lBfP2uZo3lt47sA zv#u2$T8a(nsS6nYYx-Q!+)^-h3w&B@?%xtVqZvJ1Q!D!5&mc#%q~kvUcancrV17$* z+_R*)rQlrd0WT{Uo5J*pbMb}G70uks{-$oHuV_5~|BQ!K=&qMWCnjq^L$74U1r+{37ia`B{bBS2hgk^Rh!uhAPzJyGnWE?B=p@ zz!L5^W!~ESG@7aL=Oxg@nI7|1U$#Bc@3f?1G&M1OAdRz9w@Od;?eZ^q3vDr7pn>eW vm`q>NLL-qk(G`Jh%egm!|YYWYj=dpqiek