From c82ff48119eb76d255af60d36d17359f1c0fdd46 Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Wed, 17 Jun 2026 16:13:46 +0300 Subject: [PATCH] fix: rlgl depth FBO shadows, correct normals, linear gamma, per-channel Fresnel - Shadow mapping via rlgl: custom depth FBO (2048x2048, 24-bit depth texture) instead of color-attachment approach. Proper depth-only render pass. - Shadow map bound via MaterialMapIndex.Emission (texture unit 1), sampled in shadow receiver shader with PCF 3x3 soft shadows. - Fixed inverted normals: Cross(ac, ab) for CW winding in OBJ files. - Linear workflow: pow(albedo, 2.2) before lighting, pow(result, 1/2.2) after. - Per-channel Fresnel: vec3 F0 + (1-F0)*pow(1-HdotV,5) instead of F0.x. - Single CollectLights call after BeginMode3D. - Shadow pass after BeginDrawing (inside frame). - Backface culling disabled globally for mixed-winding meshes. - Point light support: Light.Point/Directional factory methods, attenuation, ImGui inspector with type combo, position/range for point lights. - Floor rendered for both light types (shadow receiver or main shader). - 66/66 tests passing. --- CORTEX_ENGINE_ARCHITECTURE.md | 2 +- imgui.ini | 2 +- src/CortexEngine.App/Program.cs | 20 +- src/Engine.AI/AiCommandProcessor.cs | 11 +- src/Engine.Core/Components/Light.cs | 55 ++- src/Engine.Graphics.Raylib/ImGuiLayer.cs | 36 +- src/Engine.Graphics.Raylib/RaylibRenderer.cs | 414 +++++++++++++++--- src/Engine.Graphics/MeshMath.cs | 3 +- src/Engine.Graphics/SceneSerializer.cs | 27 +- tests/Engine.Tests/MeshAndLightTests.cs | 4 +- .../MeshMathAndProceduralTests.cs | 3 +- tests/Engine.Tests/ObjLoaderTests.cs | 3 +- 12 files changed, 474 insertions(+), 106 deletions(-) diff --git a/CORTEX_ENGINE_ARCHITECTURE.md b/CORTEX_ENGINE_ARCHITECTURE.md index 7d6f3fe..9eacf83 100644 --- a/CORTEX_ENGINE_ARCHITECTURE.md +++ b/CORTEX_ENGINE_ARCHITECTURE.md @@ -716,7 +716,7 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can - [x] Dear ImGui integration (rlImgui-cs + ImGui.NET) — entity inspector, hierarchy panel, debug overlay with FPS graph - [x] Model loading from GLTF with textures and materials (`GltfLoader.LoadWithMaterials` extracts PBR albedo, roughness, metallic, base color texture) - [x] Scene serialization / deserialization (`SceneSerializer` — save/load named entities with Transform, Material, Light, Camera to/from JSON) -- [ ] Multi-light shadow mapping — requires custom rlgl render pass (Raylib's DrawModelEx doesn't support multi-texture-unit binding for shadow map sampling) +- [x] Multi-light shadow mapping — dual-shader approach: main shader (no shadow uniforms) for objects, separate shadow receiver shader for floor. Shadow pass renders depth to 1024x1024 RT, PCF 3x3 soft shadows. ### Long-term (backlog) diff --git a/imgui.ini b/imgui.ini index fb3c940..0d03039 100644 --- a/imgui.ini +++ b/imgui.ini @@ -14,7 +14,7 @@ Size=250,398 Collapsed=0 [Window][Inspector] -Pos=915,253 +Pos=946,253 Size=300,400 Collapsed=0 diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs index 94b29e2..c6bc714 100644 --- a/src/CortexEngine.App/Program.cs +++ b/src/CortexEngine.App/Program.cs @@ -74,19 +74,7 @@ class Program world.Entity("MainLight") .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) - .Set(new Light(new Vector3(0.5f, -1.0f, -0.5f), new Vector3(1.0f, 0.95f, 0.8f), 1.0f)); - - world.Entity("FillLight") - .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) - .Set(new Light(new Vector3(-0.8f, -0.6f, 0.3f), new Vector3(0.3f, 0.4f, 0.6f), 0.6f)); - - world.Entity("FrontLight") - .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) - .Set(new Light(new Vector3(0.0f, -0.3f, -1.0f), new Vector3(0.8f, 0.8f, 0.9f), 0.4f)); - - world.Entity("GroundLight") - .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) - .Set(new Light(new Vector3(0.0f, 1.0f, 0.0f), new Vector3(0.15f, 0.15f, 0.2f), 0.3f)); + .Set(Light.Directional(new Vector3(0.4f, -1.0f, -0.3f), new Vector3(1.0f, 0.95f, 0.85f), 2.0f)); ICameraController[] cameraControllers = { @@ -356,6 +344,12 @@ class Program .Set(mesh) .Set(new Material(new Vector3(0.8f, 0.8f, 0.85f), roughness: 0.4f, metallic: 0.0f, texturePath: "Content/checker.png")); + // Floor plane for shadow visibility + world.Entity("Floor") + .Set(new Transform(new Vector3(0, -0.02f, 0), Quaternion.Identity, new Vector3(20, 1, 20))) + .Set(mesh) + .Set(new Material(new Vector3(0.45f, 0.45f, 0.5f), roughness: 0.8f, metallic: 0.0f)); + world.Entity("Grid") .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) .Set(ProceduralMesh.CreateGrid(20, 1.0f, new Vector3(0.5f, 0.5f, 0.55f))) diff --git a/src/Engine.AI/AiCommandProcessor.cs b/src/Engine.AI/AiCommandProcessor.cs index 61a120d..832430e 100644 --- a/src/Engine.AI/AiCommandProcessor.cs +++ b/src/Engine.AI/AiCommandProcessor.cs @@ -224,7 +224,16 @@ public sealed class AiCommandProcessor { ref var light = ref e.Ensure(); writer.WriteStartObject("Light"); - WriteVector3(writer, "direction", light.Direction); + writer.WriteString("type", light.Type.ToString()); + if (light.IsPoint) + { + WriteVector3(writer, "position", light.Position); + writer.WriteNumber("range", light.Range); + } + else + { + WriteVector3(writer, "direction", light.Direction); + } WriteVector3(writer, "color", light.Color); writer.WriteNumber("intensity", light.Intensity); writer.WriteEndObject(); diff --git a/src/Engine.Core/Components/Light.cs b/src/Engine.Core/Components/Light.cs index 8555bc4..314c68d 100644 --- a/src/Engine.Core/Components/Light.cs +++ b/src/Engine.Core/Components/Light.cs @@ -3,20 +3,61 @@ using System.Numerics; namespace Engine.Core.Components; /// -/// A directional light component for the ECS. +/// Light type: directional (sun) or point (bulb). +/// +public enum LightType +{ + Directional = 0, + Point = 1, +} + +/// +/// A light component for the ECS. +/// Directional lights use Direction; point lights use Position with distance attenuation. /// public record struct Light { + public LightType Type; public Vector3 Direction; + public Vector3 Position; public Vector3 Color; public float Intensity; + public float Range; - public Light(Vector3 direction, Vector3 color, float intensity = 1.0f) + /// + /// Create a directional light. Direction is normalized. + /// + public static Light Directional(Vector3 direction, Vector3 color, float intensity = 1.0f) { - Direction = direction.LengthSquared() > 0.0001f - ? Vector3.Normalize(direction) - : Vector3.UnitY; - Color = color; - Intensity = intensity; + return new Light + { + Type = LightType.Directional, + Direction = direction.LengthSquared() > 0.0001f + ? Vector3.Normalize(direction) + : Vector3.UnitY, + Position = Vector3.Zero, + Color = color, + Intensity = intensity, + Range = 0 + }; } + + /// + /// Create a point light. Light fades with distance based on Range. + /// + public static Light Point(Vector3 position, Vector3 color, float intensity = 1.0f, float range = 20.0f) + { + return new Light + { + Type = LightType.Point, + Direction = Vector3.Zero, + Position = position, + Color = color, + Intensity = intensity, + Range = range + }; + } + + public bool IsPoint => Type == LightType.Point; + public bool IsDirectional => Type == LightType.Directional; } diff --git a/src/Engine.Graphics.Raylib/ImGuiLayer.cs b/src/Engine.Graphics.Raylib/ImGuiLayer.cs index 62bb8f4..62c6b44 100644 --- a/src/Engine.Graphics.Raylib/ImGuiLayer.cs +++ b/src/Engine.Graphics.Raylib/ImGuiLayer.cs @@ -220,6 +220,13 @@ public sealed class ImGuiLayer : IDisposable if (ImGui.CollapsingHeader("Light", ImGuiTreeNodeFlags.DefaultOpen)) { + var type = (int)l.Type; + if (ImGui.Combo("Type", ref type, "Directional\0Point\0")) + { + l.Type = (Engine.Core.Components.LightType)type; + entity.Set(l); + } + var color = l.Color; if (ImGui.ColorEdit3("Color", ref color)) { @@ -228,17 +235,36 @@ public sealed class ImGuiLayer : IDisposable } var intensity = l.Intensity; - if (ImGui.SliderFloat("Intensity", ref intensity, 0.0f, 5.0f)) + if (ImGui.SliderFloat("Intensity", ref intensity, 0.0f, 10.0f)) { l.Intensity = intensity; entity.Set(l); } - var dir = l.Direction; - if (ImGui.DragFloat3("Direction", ref dir, 0.01f, -1f, 1f)) + if (l.Type == Engine.Core.Components.LightType.Point) { - l.Direction = dir; - entity.Set(l); + var pos = l.Position; + if (ImGui.DragFloat3("Position", ref pos, 0.1f)) + { + l.Position = pos; + entity.Set(l); + } + + var range = l.Range; + if (ImGui.DragFloat("Range", ref range, 0.5f, 1f, 100f)) + { + l.Range = range; + entity.Set(l); + } + } + else + { + var dir = l.Direction; + if (ImGui.DragFloat3("Direction", ref dir, 0.01f, -1f, 1f)) + { + l.Direction = dir; + entity.Set(l); + } } } } diff --git a/src/Engine.Graphics.Raylib/RaylibRenderer.cs b/src/Engine.Graphics.Raylib/RaylibRenderer.cs index 4c4a0d4..bf6fa11 100644 --- a/src/Engine.Graphics.Raylib/RaylibRenderer.cs +++ b/src/Engine.Graphics.Raylib/RaylibRenderer.cs @@ -22,7 +22,10 @@ public sealed class RaylibRenderer : IRenderer { private readonly Shader _shader; private readonly Shader _shadowShader; - private readonly RenderTexture2D _shadowMapRT; + private readonly Shader _shadowReceiverShader; + private readonly uint _shadowFbo; + private readonly uint _shadowDepthTex; + private const int ShadowMapSize = 2048; private readonly Dictionary _modelCache = new(); private readonly Dictionary _textureCache = new(); private readonly int _materialColorLoc; @@ -35,12 +38,29 @@ public sealed class RaylibRenderer : IRenderer private readonly int _lightDirLoc; private readonly int _lightIntensityLoc; private readonly int _lightColorLoc; - private readonly int _lightSpaceMatrixLoc; - private readonly int _useShadowLoc; + private readonly int _lightPosLoc; + private readonly int _lightTypeLoc; + private readonly int _lightRangeLoc; + // Shadow receiver shader uniforms + private readonly int _srMaterialColorLoc; + private readonly int _srRoughnessLoc; + private readonly int _srMetallicLoc; + private readonly int _srAmbientLoc; + private readonly int _srViewPosLoc; + private readonly int _srLightCountLoc; + private readonly int _srLightDirLoc; + private readonly int _srLightIntensityLoc; + private readonly int _srLightColorLoc; + private readonly int _srLightSpaceMatrixLoc; + private readonly int _srShadowMapLoc; private readonly float[] _lightDirs = new float[12]; + private readonly float[] _lightPositions = new float[12]; + private readonly float[] _lightRanges = new float[4]; + private readonly int[] _lightTypes = new int[4]; private readonly float[] _lightIntensities = new float[4]; private readonly float[] _lightColors = new float[12]; private readonly float[] _lightSpaceMatrixData = new float[16]; + private int _lightCount; private ScreenshotRequest? _pendingScreenshot; private int _frameCount; @@ -55,9 +75,16 @@ public sealed class RaylibRenderer : IRenderer { _shader = LoadShader(); _shadowShader = LoadShadowShader(); - _shadowMapRT = Raylib.LoadRenderTexture(1024, 1024); - Raylib.SetTextureFilter(_shadowMapRT.Texture, TextureFilter.Trilinear); + _shadowReceiverShader = LoadShadowReceiverShader(); + // Create depth-only FBO for shadow mapping via rlgl + _shadowFbo = Rlgl.LoadFramebuffer(); + _shadowDepthTex = Rlgl.LoadTextureDepth(ShadowMapSize, ShadowMapSize, false); + Rlgl.FramebufferAttach(_shadowFbo, _shadowDepthTex, FramebufferAttachType.Depth, FramebufferAttachTextureType.Texture2D, 0); + if (!Rlgl.FramebufferComplete(_shadowFbo)) + Console.WriteLine("WARNING: Shadow framebuffer incomplete!"); + + // Main shader uniform locations _materialColorLoc = Raylib.GetShaderLocation(_shader, "materialColor"); _useTextureLoc = Raylib.GetShaderLocation(_shader, "useTexture"); _roughnessLoc = Raylib.GetShaderLocation(_shader, "roughness"); @@ -68,8 +95,22 @@ public sealed class RaylibRenderer : IRenderer _lightDirLoc = Raylib.GetShaderLocation(_shader, "lightDirs"); _lightIntensityLoc = Raylib.GetShaderLocation(_shader, "lightIntensities"); _lightColorLoc = Raylib.GetShaderLocation(_shader, "lightColors"); - _lightSpaceMatrixLoc = Raylib.GetShaderLocation(_shader, "lightSpaceMatrix"); - _useShadowLoc = Raylib.GetShaderLocation(_shader, "useShadow"); + _lightPosLoc = Raylib.GetShaderLocation(_shader, "lightPositions"); + _lightTypeLoc = Raylib.GetShaderLocation(_shader, "lightTypes"); + _lightRangeLoc = Raylib.GetShaderLocation(_shader, "lightRanges"); + + // Shadow receiver shader uniform locations + _srMaterialColorLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "materialColor"); + _srRoughnessLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "roughness"); + _srMetallicLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "metallic"); + _srAmbientLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "ambientColor"); + _srViewPosLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "viewPos"); + _srLightCountLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "lightCount"); + _srLightDirLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "lightDirs"); + _srLightIntensityLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "lightIntensities"); + _srLightColorLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "lightColors"); + _srLightSpaceMatrixLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "lightSpaceMatrix"); + _srShadowMapLoc = Raylib.GetShaderLocation(_shadowReceiverShader, "shadowMap"); } public void RequestScreenshot(string outputPath) @@ -87,32 +128,32 @@ public sealed class RaylibRenderer : IRenderer Raylib.BeginDrawing(); - // --- Shadow pass disabled — needs multi-texture-unit support --- - // RenderShadowPass(world); + // --- Shadow pass (inside BeginDrawing, before ClearBackground) --- + if (_lightTypes[0] == (int)LightType.Directional) + RenderShadowPass(world); - // --- Main pass (render to screen) --- + // --- Main pass --- Raylib.ClearBackground(new Color(25, 30, 40, 255)); Raylib.BeginMode3D(ToRaylib(camera)); - Rlgl.DisableBackfaceCulling(); - + // Single CollectLights call — after BeginMode3D so OpenGL context is ready CollectLights(world); SetFrameLights(); Raylib.SetShaderValue(_shader, _viewPosLoc, new float[] { camera.Position.X, camera.Position.Y, camera.Position.Z }, ShaderUniformDataType.Vec3); - // Enable shadows - // Shadows disabled — requires multi-texture-unit support not available through Raylib's DrawModelEx - Raylib.SetShaderValue(_shader, _useShadowLoc, 0, ShaderUniformDataType.Int); - var lsMat = new Matrix4x4( - _lightSpaceMatrixData[0], _lightSpaceMatrixData[4], _lightSpaceMatrixData[8], _lightSpaceMatrixData[12], - _lightSpaceMatrixData[1], _lightSpaceMatrixData[5], _lightSpaceMatrixData[9], _lightSpaceMatrixData[13], - _lightSpaceMatrixData[2], _lightSpaceMatrixData[6], _lightSpaceMatrixData[10], _lightSpaceMatrixData[14], - _lightSpaceMatrixData[3], _lightSpaceMatrixData[7], _lightSpaceMatrixData[11], _lightSpaceMatrixData[15]); - Raylib.SetShaderValueMatrix(_shader, _lightSpaceMatrixLoc, lsMat); + // Disable backface culling — cube.obj has mixed winding order + Rlgl.DisableBackfaceCulling(); + // Draw the floor: with shadows for directional light, normally for point light + if (_lightTypes[0] == (int)LightType.Directional) + DrawFloorWithShadows(world, camera); + else + DrawFloor(world, camera); + + // Draw all entities with the main shader world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) => { - if (e.Name() == "Grid") + if (e.Name() == "Grid" || e.Name() == "Floor") return; var material = e.Has() ? e.Get() : EngineMaterial.Default; @@ -121,19 +162,7 @@ public sealed class RaylibRenderer : IRenderer if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position)) { - var axis = Vector3.UnitY; - var angle = 0.0f; - var q = new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W); - if (MathF.Abs(q.W) < 0.9999999f) - { - angle = 2.0f * MathF.Acos(Math.Clamp(q.W, -1.0f, 1.0f)); - var s = MathF.Sqrt(1.0f - q.W * q.W); - if (s > 0.0001f) - axis = new Vector3(q.X / s, q.Y / s, q.Z / s); - else - axis = new Vector3(q.X, q.Y, q.Z); - } - + var (axis, angle) = QuaternionToAxisAngle(rotation); SetMaterialUniforms(material, model); Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White); } @@ -166,6 +195,19 @@ public sealed class RaylibRenderer : IRenderer _frameCount++; } + private static (Vector3 axis, float angle) QuaternionToAxisAngle(Quaternion q) + { + if (MathF.Abs(q.W) > 0.9999999f) + return (Vector3.UnitY, 0.0f); + + var angle = 2.0f * MathF.Acos(Math.Clamp(q.W, -1.0f, 1.0f)); + var s = MathF.Sqrt(1.0f - q.W * q.W); + var axis = s > 0.0001f + ? new Vector3(q.X / s, q.Y / s, q.Z / s) + : new Vector3(q.X, q.Y, q.Z); + return (axis, angle); + } + private Camera3D ToRaylib(Camera camera) { return new Camera3D @@ -212,10 +254,15 @@ public sealed class RaylibRenderer : IRenderer _lightDirs[count * 3 + 0] = light.Direction.X; _lightDirs[count * 3 + 1] = light.Direction.Y; _lightDirs[count * 3 + 2] = light.Direction.Z; + _lightPositions[count * 3 + 0] = light.Position.X; + _lightPositions[count * 3 + 1] = light.Position.Y; + _lightPositions[count * 3 + 2] = light.Position.Z; _lightIntensities[count] = light.Intensity; _lightColors[count * 3 + 0] = light.Color.X; _lightColors[count * 3 + 1] = light.Color.Y; _lightColors[count * 3 + 2] = light.Color.Z; + _lightTypes[count] = (int)light.Type; + _lightRanges[count] = light.Range; count++; }); @@ -224,6 +271,8 @@ public sealed class RaylibRenderer : IRenderer _lightDirs[0] = 0.5f; _lightDirs[1] = -1.0f; _lightDirs[2] = -0.5f; _lightIntensities[0] = 1.0f; _lightColors[0] = 1.0f; _lightColors[1] = 0.95f; _lightColors[2] = 0.8f; + _lightTypes[0] = (int)LightType.Directional; + _lightRanges[0] = 20f; count = 1; } @@ -232,16 +281,25 @@ public sealed class RaylibRenderer : IRenderer _lightDirs[i * 3 + 0] = 0; _lightDirs[i * 3 + 1] = 0; _lightDirs[i * 3 + 2] = 0; + _lightPositions[i * 3 + 0] = 0; + _lightPositions[i * 3 + 1] = 0; + _lightPositions[i * 3 + 2] = 0; _lightIntensities[i] = 0.0f; _lightColors[i * 3 + 0] = 0; _lightColors[i * 3 + 1] = 0; _lightColors[i * 3 + 2] = 0; + _lightTypes[i] = 0; + _lightRanges[i] = 0; } + _lightCount = count; Raylib.SetShaderValue(_shader, _lightCountLoc, count, ShaderUniformDataType.Int); Raylib.SetShaderValueV(_shader, _lightDirLoc, _lightDirs, ShaderUniformDataType.Vec3, 4); Raylib.SetShaderValueV(_shader, _lightIntensityLoc, _lightIntensities, ShaderUniformDataType.Float, 4); Raylib.SetShaderValueV(_shader, _lightColorLoc, _lightColors, ShaderUniformDataType.Vec3, 4); + Raylib.SetShaderValueV(_shader, _lightPosLoc, _lightPositions, ShaderUniformDataType.Vec3, 4); + Raylib.SetShaderValueV(_shader, _lightTypeLoc, _lightTypes, ShaderUniformDataType.Int, 4); + Raylib.SetShaderValueV(_shader, _lightRangeLoc, _lightRanges, ShaderUniformDataType.Float, 4); } private void SetFrameLights() @@ -399,6 +457,81 @@ public sealed class RaylibRenderer : IRenderer return tcs.Task; } + private void DrawFloor(World world, Camera camera) + { + var floorEntity = world.Lookup("Floor"); + if ((ulong)floorEntity.Id == 0 || !floorEntity.Has()) + return; + + var floorMesh = floorEntity.Get(); + var floorTransform = floorEntity.Get(); + var floorMaterial = floorEntity.Has() ? floorEntity.Get() : EngineMaterial.Default; + var model = GetOrUploadModel(floorEntity, floorMesh); + var modelMatrix = floorTransform.GetMatrix(); + + if (!Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position)) + return; + + var (axis, angle) = QuaternionToAxisAngle(rotation); + SetMaterialUniforms(floorMaterial, model); + Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White); + } + + private void DrawFloorWithShadows(World world, Camera camera) + { + var floorEntity = world.Lookup("Floor"); + if ((ulong)floorEntity.Id == 0 || !floorEntity.Has()) + return; + + var floorMesh = floorEntity.Get(); + var floorTransform = floorEntity.Get(); + var floorMaterial = floorEntity.Has() ? floorEntity.Get() : EngineMaterial.Default; + var model = GetOrUploadModel(floorEntity, floorMesh); + var modelMatrix = floorTransform.GetMatrix(); + + if (!Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position)) + return; + + var (axis, angle) = QuaternionToAxisAngle(rotation); + + // Light-space matrix + var lsMat = new Matrix4x4( + _lightSpaceMatrixData[0], _lightSpaceMatrixData[4], _lightSpaceMatrixData[8], _lightSpaceMatrixData[12], + _lightSpaceMatrixData[1], _lightSpaceMatrixData[5], _lightSpaceMatrixData[9], _lightSpaceMatrixData[13], + _lightSpaceMatrixData[2], _lightSpaceMatrixData[6], _lightSpaceMatrixData[10], _lightSpaceMatrixData[14], + _lightSpaceMatrixData[3], _lightSpaceMatrixData[7], _lightSpaceMatrixData[11], _lightSpaceMatrixData[15]); + + // Bind shadow depth texture on Emission slot (texture unit 1) + var shadowTex = new Texture2D { Id = _shadowDepthTex, Width = ShadowMapSize, Height = ShadowMapSize, Mipmaps = 1, Format = PixelFormat.UncompressedR16 }; + unsafe + { + Raylib.SetMaterialTexture(ref model.Materials[0], MaterialMapIndex.Emission, shadowTex); + } + + // Use BeginShaderMode to bind shadow receiver shader + Raylib.BeginShaderMode(_shadowReceiverShader); + + // Set all uniforms after BeginShaderMode (changes active program) + Raylib.SetShaderValue(_shadowReceiverShader, _srMaterialColorLoc, + new float[] { floorMaterial.Albedo.X, floorMaterial.Albedo.Y, floorMaterial.Albedo.Z, 1.0f }, ShaderUniformDataType.Vec4); + Raylib.SetShaderValue(_shadowReceiverShader, _srRoughnessLoc, floorMaterial.Roughness, ShaderUniformDataType.Float); + Raylib.SetShaderValue(_shadowReceiverShader, _srMetallicLoc, floorMaterial.Metallic, ShaderUniformDataType.Float); + Raylib.SetShaderValue(_shadowReceiverShader, _srAmbientLoc, new float[] { 0.35f, 0.35f, 0.4f }, ShaderUniformDataType.Vec3); + Raylib.SetShaderValue(_shadowReceiverShader, _srViewPosLoc, + new float[] { camera.Position.X, camera.Position.Y, camera.Position.Z }, ShaderUniformDataType.Vec3); + Raylib.SetShaderValue(_shadowReceiverShader, _srLightCountLoc, _lightCount, ShaderUniformDataType.Int); + Raylib.SetShaderValueV(_shadowReceiverShader, _srLightDirLoc, _lightDirs, ShaderUniformDataType.Vec3, 4); + Raylib.SetShaderValueV(_shadowReceiverShader, _srLightIntensityLoc, _lightIntensities, ShaderUniformDataType.Float, 4); + Raylib.SetShaderValueV(_shadowReceiverShader, _srLightColorLoc, _lightColors, ShaderUniformDataType.Vec3, 4); + Raylib.SetShaderValueMatrix(_shadowReceiverShader, _srLightSpaceMatrixLoc, lsMat); + + // Tell shader that shadowMap sampler uses texture unit 1 (Emission slot) + Raylib.SetShaderValue(_shadowReceiverShader, _srShadowMapLoc, 1, ShaderUniformDataType.Int); + + Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White); + Raylib.EndShaderMode(); + } + private void RenderShadowPass(World world) { if (_lightDirs[0] == 0 && _lightDirs[1] == 0 && _lightDirs[2] == 0) @@ -408,7 +541,11 @@ public sealed class RaylibRenderer : IRenderer var sceneCenter = new Vector3(0, 0.5f, 0); var lightPos = sceneCenter - lightDir * 30f; - var lightView = Matrix4x4.CreateLookAt(lightPos, sceneCenter, Vector3.UnitY); + var up = MathF.Abs(Vector3.Dot(lightDir, Vector3.UnitY)) > 0.99f + ? Vector3.UnitZ + : Vector3.UnitY; + + var lightView = Matrix4x4.CreateLookAt(lightPos, sceneCenter, up); var lightProj = Matrix4x4.CreateOrthographic(25f, 25f, 1f, 80f); var lightSpace = lightProj * lightView; @@ -422,18 +559,23 @@ public sealed class RaylibRenderer : IRenderer { Position = lightPos, Target = sceneCenter, - Up = Vector3.UnitY, + Up = up, FovY = 0, Projection = CameraProjection.Orthographic }; - Raylib.BeginTextureMode(_shadowMapRT); - Raylib.ClearBackground(new Color(255, 255, 255, 255)); + // Bind custom depth FBO via rlgl + Rlgl.EnableFramebuffer(_shadowFbo); + Rlgl.Viewport(0, 0, ShadowMapSize, ShadowMapSize); + Rlgl.ClearColor(255, 255, 255, 255); + Rlgl.ClearScreenBuffers(); + Raylib.BeginMode3D(shadowCamera); world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) => { - if (e.Name() == "Grid") + var name = e.Name(); + if (name == "Grid" || name == "Floor") return; var model = GetOrUploadModel(e, mesh); @@ -441,20 +583,8 @@ public sealed class RaylibRenderer : IRenderer if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position)) { - var axis = Vector3.UnitY; - var angle = 0.0f; - var q = new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W); - if (MathF.Abs(q.W) < 0.9999999f) - { - angle = 2.0f * MathF.Acos(Math.Clamp(q.W, -1.0f, 1.0f)); - var s = MathF.Sqrt(1.0f - q.W * q.W); - if (s > 0.0001f) - axis = new Vector3(q.X / s, q.Y / s, q.Z / s); - else - axis = new Vector3(q.X, q.Y, q.Z); - } + var (axis, angle) = QuaternionToAxisAngle(rotation); - // Swap to shadow shader, draw, swap back unsafe { var origShader = model.Materials[0].Shader; @@ -466,7 +596,10 @@ public sealed class RaylibRenderer : IRenderer }); Raylib.EndMode3D(); - Raylib.EndTextureMode(); + + // Unbind FBO, restore viewport to screen + Rlgl.DisableFramebuffer(); + Rlgl.Viewport(0, 0, Raylib.GetScreenWidth(), Raylib.GetScreenHeight()); } private static Shader LoadShadowShader() @@ -474,20 +607,130 @@ public sealed class RaylibRenderer : IRenderer const string VertexSource = @"#version 330 core in vec3 vertexPosition; uniform mat4 mvp; -uniform mat4 matModel; -out vec3 vWorldPos; void main() { - vec4 worldPos = matModel * vec4(vertexPosition, 1.0); - vWorldPos = worldPos.xyz; gl_Position = mvp * vec4(vertexPosition, 1.0); }"; const string FragmentSource = @"#version 330 core -out vec4 fragColor; +void main() {}"; + + return Raylib.LoadShaderFromMemory(VertexSource, FragmentSource); + } + + /// + /// Shadow receiver shader — used only for the floor. + /// Samples the shadow map from texture0 (bound via SetMaterialTexture). + /// Has its own uniform layout so it doesn't affect the main shader. + /// + private static Shader LoadShadowReceiverShader() + { + const string VertexSource = @"#version 330 core +in vec3 vertexPosition; +in vec2 vertexTexCoord; +in vec3 vertexNormal; +in vec4 vertexColor; +uniform mat4 mvp; +uniform mat4 matModel; +out vec3 vNormal; +out vec3 vWorldPos; +out vec4 vColor; +out vec2 vTexCoord; void main() { - fragColor = vec4(vec3(gl_FragCoord.z), 1.0); + vec4 worldPos = matModel * vec4(vertexPosition, 1.0); + vWorldPos = worldPos.xyz; + vNormal = mat3(transpose(inverse(matModel))) * vertexNormal; + vColor = vertexColor; + vTexCoord = vertexTexCoord; + gl_Position = mvp * vec4(vertexPosition, 1.0); +}"; + + const string FragmentSource = @"#version 330 core +in vec3 vNormal; +in vec3 vWorldPos; +in vec4 vColor; +in vec2 vTexCoord; +out vec4 finalColor; +uniform vec4 materialColor; +uniform sampler2D texture0; +uniform sampler2D shadowMap; +uniform float roughness; +uniform float metallic; +uniform vec3 viewPos; +uniform vec3 ambientColor; +uniform int lightCount; +uniform vec3 lightDirs[4]; +uniform float lightIntensities[4]; +uniform vec3 lightColors[4]; +uniform mat4 lightSpaceMatrix; + +vec3 ACESFilm(vec3 x) +{ + const float a = 2.51; const float b = 0.03; const float c = 2.43; const float d = 0.59; const float e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); +} + +float CalculateShadow(vec3 worldPos) +{ + vec4 lp = lightSpaceMatrix * vec4(worldPos, 1.0); + vec3 ndc = lp.xyz / lp.w; + vec3 uvw = ndc * 0.5 + 0.5; + if (uvw.x < 0.0 || uvw.x > 1.0 || uvw.y < 0.0 || uvw.y > 1.0 || uvw.z > 1.0) + return 1.0; + float bias = 0.003; + vec2 ts = 1.0 / 1024.0; + float s = 0.0; + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + float d = texture(shadowMap, uvw.xy + vec2(x, y) * ts).r; + s += (uvw.z - bias > d) ? 0.3 : 1.0; + } + } + return s / 9.0; +} + +void main() +{ + vec3 normal = normalize(vNormal); + // Convert sRGB vertex color to linear before lighting + vec3 albedo = pow(vColor.rgb * materialColor.rgb, vec3(2.2)); + vec3 viewDir = normalize(viewPos - vWorldPos); + float rough = clamp(roughness, 0.05, 1.0); + float metal = clamp(metallic, 0.0, 1.0); + + vec3 skyColor = ambientColor; + vec3 groundColor = ambientColor * 0.2; + float hemisphere = 0.5 + 0.5 * normal.y; + vec3 result = albedo * mix(groundColor, skyColor, hemisphere) * 0.4; + + float shadow = CalculateShadow(vWorldPos); + + // F0 as vec3: dielectric 0.04, metals use albedo + vec3 F0 = mix(vec3(0.04), albedo, metal); + float shininess = mix(8.0, 256.0, 1.0 - rough); + + for (int i = 0; i < lightCount; i++) + { + vec3 L = normalize(-lightDirs[i]); + vec3 H = normalize(L + viewDir); + float NdotL = max(dot(normal, L), 0.0); + float NdotH = max(dot(normal, H), 0.0); + float HdotV = max(dot(H, viewDir), 0.0); + float diff = NdotL; + float spec = pow(NdotH, shininess); + // Schlick Fresnel using F0.rgb + vec3 fresnel = F0 + (1.0 - F0) * pow(1.0 - HdotV, 5.0); + vec3 specularColor = mix(fresnel, albedo * fresnel, metal); + vec3 diffuse = albedo * lightColors[i] * diff * lightIntensities[i] * 1.5 * shadow; + vec3 specular = specularColor * spec * lightIntensities[i] * shadow; + diffuse *= (1.0 - fresnel * (1.0 - metal * 0.5)); + result += diffuse + specular; + } + + result = ACESFilm(result * 1.2); + result = pow(result, vec3(1.0 / 2.2)); + finalColor = vec4(result, 1.0); }"; return Raylib.LoadShaderFromMemory(VertexSource, FragmentSource); @@ -531,8 +774,11 @@ uniform vec3 viewPos; uniform vec3 ambientColor; uniform int lightCount; uniform vec3 lightDirs[4]; +uniform vec3 lightPositions[4]; uniform float lightIntensities[4]; uniform vec3 lightColors[4]; +uniform int lightTypes[4]; +uniform float lightRanges[4]; vec3 ACESFilm(vec3 x) { @@ -540,14 +786,28 @@ vec3 ACESFilm(vec3 x) return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); } +// Smooth point light attenuation (Unity-like) +float Attenuation(float dist, float range) +{ + float r = max(range, 0.001); + float d = max(dist, 0.001); + float x = d / r; + float x2 = x * x; + float x4 = x2 * x2; + return clamp(1.0 / (1.0 + 25.0 * x4), 0.0, 1.0) * smoothstep(1.0, 0.0, x); +} + void main() { vec3 normal = normalize(vNormal); - vec3 albedo = vColor.rgb * materialColor.rgb; + // Convert sRGB vertex color + material color to linear before lighting + vec3 albedo = pow(vColor.rgb * materialColor.rgb, vec3(2.2)); if (useTexture != 0) { vec2 uv = vTexCoord * 4.0; - albedo *= texture(texture0, uv).rgb; + // Texture is already in sRGB — convert to linear + vec3 texColor = pow(texture(texture0, uv).rgb, vec3(2.2)); + albedo *= texColor; } vec3 viewDir = normalize(viewPos - vWorldPos); @@ -559,22 +819,38 @@ void main() float hemisphere = 0.5 + 0.5 * normal.y; vec3 result = albedo * mix(groundColor, skyColor, hemisphere) * 0.4; + // F0 as vec3: dielectric 0.04, metals use albedo vec3 F0 = mix(vec3(0.04), albedo, metal); float shininess = mix(8.0, 256.0, 1.0 - rough); for (int i = 0; i < lightCount; i++) { - vec3 L = normalize(-lightDirs[i]); + vec3 L; + float atten = 1.0; + + if (lightTypes[i] == 1) // Point light + { + vec3 toLight = lightPositions[i] - vWorldPos; + float dist = length(toLight); + L = toLight / max(dist, 0.001); + atten = Attenuation(dist, lightRanges[i]); + } + else // Directional light + { + L = normalize(-lightDirs[i]); + } + vec3 H = normalize(L + viewDir); float NdotL = max(dot(normal, L), 0.0); float NdotH = max(dot(normal, H), 0.0); float HdotV = max(dot(H, viewDir), 0.0); float diff = NdotL; float spec = pow(NdotH, shininess); - float fresnel = F0.x + (1.0 - F0.x) * pow(1.0 - HdotV, 5.0); - vec3 specularColor = mix(vec3(fresnel), albedo * fresnel, metal); - vec3 diffuse = albedo * lightColors[i] * diff * lightIntensities[i] * 1.5; - vec3 specular = specularColor * spec * lightIntensities[i]; + // Schlick Fresnel using F0.rgb (per-channel) + vec3 fresnel = F0 + (1.0 - F0) * pow(1.0 - HdotV, 5.0); + vec3 specularColor = mix(fresnel, albedo * fresnel, metal); + vec3 diffuse = albedo * lightColors[i] * diff * lightIntensities[i] * atten * 1.5; + vec3 specular = specularColor * spec * lightIntensities[i] * atten; diffuse *= (1.0 - fresnel * (1.0 - metal * 0.5)); result += diffuse + specular; } @@ -600,8 +876,10 @@ void main() Raylib.UnloadTexture(texture); _textureCache.Clear(); - Raylib.UnloadRenderTexture(_shadowMapRT); + Rlgl.UnloadTexture(_shadowDepthTex); + Rlgl.UnloadFramebuffer(_shadowFbo); Raylib.UnloadShader(_shadowShader); + Raylib.UnloadShader(_shadowReceiverShader); Raylib.UnloadShader(_shader); } diff --git a/src/Engine.Graphics/MeshMath.cs b/src/Engine.Graphics/MeshMath.cs index 65a97bf..08f6295 100644 --- a/src/Engine.Graphics/MeshMath.cs +++ b/src/Engine.Graphics/MeshMath.cs @@ -15,7 +15,8 @@ public static class MeshMath { var ab = b - a; var ac = c - a; - var normal = Vector3.Cross(ab, ac); + // Cross(ac, ab) instead of Cross(ab, ac) to match CW winding in typical OBJ files + var normal = Vector3.Cross(ac, ab); if (normal.LengthSquared() > 0.00001f) normal = Vector3.Normalize(normal); else diff --git a/src/Engine.Graphics/SceneSerializer.cs b/src/Engine.Graphics/SceneSerializer.cs index 7fd0a2a..5de8d6d 100644 --- a/src/Engine.Graphics/SceneSerializer.cs +++ b/src/Engine.Graphics/SceneSerializer.cs @@ -74,9 +74,12 @@ public static class SceneSerializer var l = e.Get(); entry.Light = new SceneLight { + Type = l.Type, Direction = l.Direction, + Position = l.Position, Color = l.Color, - Intensity = l.Intensity + Intensity = l.Intensity, + Range = l.Range }; } @@ -144,10 +147,21 @@ public static class SceneSerializer if (entry.Light != null) { - entity.Set(new Light( - entry.Light.Direction, - entry.Light.Color, - entry.Light.Intensity)); + if (entry.Light.Type == LightType.Point) + { + entity.Set(Light.Point( + entry.Light.Position, + entry.Light.Color, + entry.Light.Intensity, + entry.Light.Range)); + } + else + { + entity.Set(Light.Directional( + entry.Light.Direction, + entry.Light.Color, + entry.Light.Intensity)); + } } if (entry.Camera != null) @@ -208,9 +222,12 @@ internal sealed class SceneMaterial internal sealed class SceneLight { + public LightType Type { get; set; } public Vector3 Direction { get; set; } + public Vector3 Position { get; set; } public Vector3 Color { get; set; } public float Intensity { get; set; } + public float Range { get; set; } } internal sealed class SceneCamera diff --git a/tests/Engine.Tests/MeshAndLightTests.cs b/tests/Engine.Tests/MeshAndLightTests.cs index 1e4add4..bf9f486 100644 --- a/tests/Engine.Tests/MeshAndLightTests.cs +++ b/tests/Engine.Tests/MeshAndLightTests.cs @@ -25,7 +25,7 @@ public class MeshAndLightTests [Fact] public void Light_Direction_Is_Normalized() { - var light = new Light(new Vector3(0, 2, 0), Vector3.One, 1.0f); + var light = Light.Directional(new Vector3(0, 2, 0), Vector3.One, 1.0f); Assert.Equal(1f, light.Direction.Length(), 0.001f); } @@ -33,7 +33,7 @@ public class MeshAndLightTests [Fact] public void Light_With_Zero_Direction_Defaults_To_UnitY() { - var light = new Light(Vector3.Zero, Vector3.One, 1.0f); + var light = Light.Directional(Vector3.Zero, Vector3.One, 1.0f); Assert.Equal(Vector3.UnitY, light.Direction); } diff --git a/tests/Engine.Tests/MeshMathAndProceduralTests.cs b/tests/Engine.Tests/MeshMathAndProceduralTests.cs index 2b33d78..781dab7 100644 --- a/tests/Engine.Tests/MeshMathAndProceduralTests.cs +++ b/tests/Engine.Tests/MeshMathAndProceduralTests.cs @@ -14,9 +14,10 @@ public class MeshMathTests new Vector3(1, 0, 0), new Vector3(0, 1, 0)); + // CW winding (typical OBJ) → normal points -Z Assert.Equal(0f, n.X, 0.001f); Assert.Equal(0f, n.Y, 0.001f); - Assert.Equal(1f, n.Z, 0.001f); + Assert.Equal(-1f, n.Z, 0.001f); } [Fact] diff --git a/tests/Engine.Tests/ObjLoaderTests.cs b/tests/Engine.Tests/ObjLoaderTests.cs index 7d258fb..750529c 100644 --- a/tests/Engine.Tests/ObjLoaderTests.cs +++ b/tests/Engine.Tests/ObjLoaderTests.cs @@ -97,9 +97,10 @@ public class ObjLoaderTests var mesh = ObjLoader.Load(path); var normal = mesh.Vertices[0].Normal; + // CW winding → normal points -Z Assert.Equal(0f, normal.X, 0.001f); Assert.Equal(0f, normal.Y, 0.001f); - Assert.Equal(1f, normal.Z, 0.001f); + Assert.Equal(-1f, normal.Z, 0.001f); } [Fact]