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.
This commit is contained in:
emil28092005
2026-06-17 16:13:46 +03:00
parent f905fe3040
commit c82ff48119
12 changed files with 474 additions and 106 deletions
+1 -1
View File
@@ -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] 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] 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) - [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) ### Long-term (backlog)
+1 -1
View File
@@ -14,7 +14,7 @@ Size=250,398
Collapsed=0 Collapsed=0
[Window][Inspector] [Window][Inspector]
Pos=915,253 Pos=946,253
Size=300,400 Size=300,400
Collapsed=0 Collapsed=0
+7 -13
View File
@@ -74,19 +74,7 @@ class Program
world.Entity("MainLight") world.Entity("MainLight")
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) .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)); .Set(Light.Directional(new Vector3(0.4f, -1.0f, -0.3f), new Vector3(1.0f, 0.95f, 0.85f), 2.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));
ICameraController[] cameraControllers = ICameraController[] cameraControllers =
{ {
@@ -356,6 +344,12 @@ class Program
.Set(mesh) .Set(mesh)
.Set(new Material(new Vector3(0.8f, 0.8f, 0.85f), roughness: 0.4f, metallic: 0.0f, texturePath: "Content/checker.png")); .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") world.Entity("Grid")
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) .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))) .Set(ProceduralMesh.CreateGrid(20, 1.0f, new Vector3(0.5f, 0.5f, 0.55f)))
+10 -1
View File
@@ -224,7 +224,16 @@ public sealed class AiCommandProcessor
{ {
ref var light = ref e.Ensure<Light>(); ref var light = ref e.Ensure<Light>();
writer.WriteStartObject("Light"); 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); WriteVector3(writer, "color", light.Color);
writer.WriteNumber("intensity", light.Intensity); writer.WriteNumber("intensity", light.Intensity);
writer.WriteEndObject(); writer.WriteEndObject();
+48 -7
View File
@@ -3,20 +3,61 @@ using System.Numerics;
namespace Engine.Core.Components; namespace Engine.Core.Components;
/// <summary> /// <summary>
/// A directional light component for the ECS. /// Light type: directional (sun) or point (bulb).
/// </summary>
public enum LightType
{
Directional = 0,
Point = 1,
}
/// <summary>
/// A light component for the ECS.
/// Directional lights use Direction; point lights use Position with distance attenuation.
/// </summary> /// </summary>
public record struct Light public record struct Light
{ {
public LightType Type;
public Vector3 Direction; public Vector3 Direction;
public Vector3 Position;
public Vector3 Color; public Vector3 Color;
public float Intensity; public float Intensity;
public float Range;
public Light(Vector3 direction, Vector3 color, float intensity = 1.0f) /// <summary>
/// Create a directional light. Direction is normalized.
/// </summary>
public static Light Directional(Vector3 direction, Vector3 color, float intensity = 1.0f)
{ {
Direction = direction.LengthSquared() > 0.0001f return new Light
? Vector3.Normalize(direction) {
: Vector3.UnitY; Type = LightType.Directional,
Color = color; Direction = direction.LengthSquared() > 0.0001f
Intensity = intensity; ? Vector3.Normalize(direction)
: Vector3.UnitY,
Position = Vector3.Zero,
Color = color,
Intensity = intensity,
Range = 0
};
} }
/// <summary>
/// Create a point light. Light fades with distance based on Range.
/// </summary>
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;
} }
+31 -5
View File
@@ -220,6 +220,13 @@ public sealed class ImGuiLayer : IDisposable
if (ImGui.CollapsingHeader("Light", ImGuiTreeNodeFlags.DefaultOpen)) 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; var color = l.Color;
if (ImGui.ColorEdit3("Color", ref color)) if (ImGui.ColorEdit3("Color", ref color))
{ {
@@ -228,17 +235,36 @@ public sealed class ImGuiLayer : IDisposable
} }
var intensity = l.Intensity; 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; l.Intensity = intensity;
entity.Set(l); entity.Set(l);
} }
var dir = l.Direction; if (l.Type == Engine.Core.Components.LightType.Point)
if (ImGui.DragFloat3("Direction", ref dir, 0.01f, -1f, 1f))
{ {
l.Direction = dir; var pos = l.Position;
entity.Set(l); 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);
}
} }
} }
} }
+346 -68
View File
@@ -22,7 +22,10 @@ public sealed class RaylibRenderer : IRenderer
{ {
private readonly Shader _shader; private readonly Shader _shader;
private readonly Shader _shadowShader; 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<Entity, Raylib_cs.Model> _modelCache = new(); private readonly Dictionary<Entity, Raylib_cs.Model> _modelCache = new();
private readonly Dictionary<string, Texture2D> _textureCache = new(); private readonly Dictionary<string, Texture2D> _textureCache = new();
private readonly int _materialColorLoc; private readonly int _materialColorLoc;
@@ -35,12 +38,29 @@ public sealed class RaylibRenderer : IRenderer
private readonly int _lightDirLoc; private readonly int _lightDirLoc;
private readonly int _lightIntensityLoc; private readonly int _lightIntensityLoc;
private readonly int _lightColorLoc; private readonly int _lightColorLoc;
private readonly int _lightSpaceMatrixLoc; private readonly int _lightPosLoc;
private readonly int _useShadowLoc; 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[] _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[] _lightIntensities = new float[4];
private readonly float[] _lightColors = new float[12]; private readonly float[] _lightColors = new float[12];
private readonly float[] _lightSpaceMatrixData = new float[16]; private readonly float[] _lightSpaceMatrixData = new float[16];
private int _lightCount;
private ScreenshotRequest? _pendingScreenshot; private ScreenshotRequest? _pendingScreenshot;
private int _frameCount; private int _frameCount;
@@ -55,9 +75,16 @@ public sealed class RaylibRenderer : IRenderer
{ {
_shader = LoadShader(); _shader = LoadShader();
_shadowShader = LoadShadowShader(); _shadowShader = LoadShadowShader();
_shadowMapRT = Raylib.LoadRenderTexture(1024, 1024); _shadowReceiverShader = LoadShadowReceiverShader();
Raylib.SetTextureFilter(_shadowMapRT.Texture, TextureFilter.Trilinear);
// 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"); _materialColorLoc = Raylib.GetShaderLocation(_shader, "materialColor");
_useTextureLoc = Raylib.GetShaderLocation(_shader, "useTexture"); _useTextureLoc = Raylib.GetShaderLocation(_shader, "useTexture");
_roughnessLoc = Raylib.GetShaderLocation(_shader, "roughness"); _roughnessLoc = Raylib.GetShaderLocation(_shader, "roughness");
@@ -68,8 +95,22 @@ public sealed class RaylibRenderer : IRenderer
_lightDirLoc = Raylib.GetShaderLocation(_shader, "lightDirs"); _lightDirLoc = Raylib.GetShaderLocation(_shader, "lightDirs");
_lightIntensityLoc = Raylib.GetShaderLocation(_shader, "lightIntensities"); _lightIntensityLoc = Raylib.GetShaderLocation(_shader, "lightIntensities");
_lightColorLoc = Raylib.GetShaderLocation(_shader, "lightColors"); _lightColorLoc = Raylib.GetShaderLocation(_shader, "lightColors");
_lightSpaceMatrixLoc = Raylib.GetShaderLocation(_shader, "lightSpaceMatrix"); _lightPosLoc = Raylib.GetShaderLocation(_shader, "lightPositions");
_useShadowLoc = Raylib.GetShaderLocation(_shader, "useShadow"); _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) public void RequestScreenshot(string outputPath)
@@ -87,32 +128,32 @@ public sealed class RaylibRenderer : IRenderer
Raylib.BeginDrawing(); Raylib.BeginDrawing();
// --- Shadow pass disabled — needs multi-texture-unit support --- // --- Shadow pass (inside BeginDrawing, before ClearBackground) ---
// RenderShadowPass(world); if (_lightTypes[0] == (int)LightType.Directional)
RenderShadowPass(world);
// --- Main pass (render to screen) --- // --- Main pass ---
Raylib.ClearBackground(new Color(25, 30, 40, 255)); Raylib.ClearBackground(new Color(25, 30, 40, 255));
Raylib.BeginMode3D(ToRaylib(camera)); Raylib.BeginMode3D(ToRaylib(camera));
Rlgl.DisableBackfaceCulling(); // Single CollectLights call — after BeginMode3D so OpenGL context is ready
CollectLights(world); CollectLights(world);
SetFrameLights(); SetFrameLights();
Raylib.SetShaderValue(_shader, _viewPosLoc, new float[] { camera.Position.X, camera.Position.Y, camera.Position.Z }, ShaderUniformDataType.Vec3); Raylib.SetShaderValue(_shader, _viewPosLoc, new float[] { camera.Position.X, camera.Position.Y, camera.Position.Z }, ShaderUniformDataType.Vec3);
// Enable shadows // Disable backface culling — cube.obj has mixed winding order
// Shadows disabled — requires multi-texture-unit support not available through Raylib's DrawModelEx Rlgl.DisableBackfaceCulling();
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);
// 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) => world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) =>
{ {
if (e.Name() == "Grid") if (e.Name() == "Grid" || e.Name() == "Floor")
return; return;
var material = e.Has<EngineMaterial>() ? e.Get<EngineMaterial>() : EngineMaterial.Default; var material = e.Has<EngineMaterial>() ? e.Get<EngineMaterial>() : EngineMaterial.Default;
@@ -121,19 +162,7 @@ public sealed class RaylibRenderer : IRenderer
if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position)) if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position))
{ {
var axis = Vector3.UnitY; var (axis, angle) = QuaternionToAxisAngle(rotation);
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);
}
SetMaterialUniforms(material, model); SetMaterialUniforms(material, model);
Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White); Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White);
} }
@@ -166,6 +195,19 @@ public sealed class RaylibRenderer : IRenderer
_frameCount++; _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) private Camera3D ToRaylib(Camera camera)
{ {
return new Camera3D return new Camera3D
@@ -212,10 +254,15 @@ public sealed class RaylibRenderer : IRenderer
_lightDirs[count * 3 + 0] = light.Direction.X; _lightDirs[count * 3 + 0] = light.Direction.X;
_lightDirs[count * 3 + 1] = light.Direction.Y; _lightDirs[count * 3 + 1] = light.Direction.Y;
_lightDirs[count * 3 + 2] = light.Direction.Z; _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; _lightIntensities[count] = light.Intensity;
_lightColors[count * 3 + 0] = light.Color.X; _lightColors[count * 3 + 0] = light.Color.X;
_lightColors[count * 3 + 1] = light.Color.Y; _lightColors[count * 3 + 1] = light.Color.Y;
_lightColors[count * 3 + 2] = light.Color.Z; _lightColors[count * 3 + 2] = light.Color.Z;
_lightTypes[count] = (int)light.Type;
_lightRanges[count] = light.Range;
count++; count++;
}); });
@@ -224,6 +271,8 @@ public sealed class RaylibRenderer : IRenderer
_lightDirs[0] = 0.5f; _lightDirs[1] = -1.0f; _lightDirs[2] = -0.5f; _lightDirs[0] = 0.5f; _lightDirs[1] = -1.0f; _lightDirs[2] = -0.5f;
_lightIntensities[0] = 1.0f; _lightIntensities[0] = 1.0f;
_lightColors[0] = 1.0f; _lightColors[1] = 0.95f; _lightColors[2] = 0.8f; _lightColors[0] = 1.0f; _lightColors[1] = 0.95f; _lightColors[2] = 0.8f;
_lightTypes[0] = (int)LightType.Directional;
_lightRanges[0] = 20f;
count = 1; count = 1;
} }
@@ -232,16 +281,25 @@ public sealed class RaylibRenderer : IRenderer
_lightDirs[i * 3 + 0] = 0; _lightDirs[i * 3 + 0] = 0;
_lightDirs[i * 3 + 1] = 0; _lightDirs[i * 3 + 1] = 0;
_lightDirs[i * 3 + 2] = 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; _lightIntensities[i] = 0.0f;
_lightColors[i * 3 + 0] = 0; _lightColors[i * 3 + 0] = 0;
_lightColors[i * 3 + 1] = 0; _lightColors[i * 3 + 1] = 0;
_lightColors[i * 3 + 2] = 0; _lightColors[i * 3 + 2] = 0;
_lightTypes[i] = 0;
_lightRanges[i] = 0;
} }
_lightCount = count;
Raylib.SetShaderValue(_shader, _lightCountLoc, count, ShaderUniformDataType.Int); Raylib.SetShaderValue(_shader, _lightCountLoc, count, ShaderUniformDataType.Int);
Raylib.SetShaderValueV(_shader, _lightDirLoc, _lightDirs, ShaderUniformDataType.Vec3, 4); Raylib.SetShaderValueV(_shader, _lightDirLoc, _lightDirs, ShaderUniformDataType.Vec3, 4);
Raylib.SetShaderValueV(_shader, _lightIntensityLoc, _lightIntensities, ShaderUniformDataType.Float, 4); Raylib.SetShaderValueV(_shader, _lightIntensityLoc, _lightIntensities, ShaderUniformDataType.Float, 4);
Raylib.SetShaderValueV(_shader, _lightColorLoc, _lightColors, ShaderUniformDataType.Vec3, 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() private void SetFrameLights()
@@ -399,6 +457,81 @@ public sealed class RaylibRenderer : IRenderer
return tcs.Task; return tcs.Task;
} }
private void DrawFloor(World world, Camera camera)
{
var floorEntity = world.Lookup("Floor");
if ((ulong)floorEntity.Id == 0 || !floorEntity.Has<EngineMesh>())
return;
var floorMesh = floorEntity.Get<EngineMesh>();
var floorTransform = floorEntity.Get<EngineTransform>();
var floorMaterial = floorEntity.Has<EngineMaterial>() ? floorEntity.Get<EngineMaterial>() : 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<EngineMesh>())
return;
var floorMesh = floorEntity.Get<EngineMesh>();
var floorTransform = floorEntity.Get<EngineTransform>();
var floorMaterial = floorEntity.Has<EngineMaterial>() ? floorEntity.Get<EngineMaterial>() : 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) private void RenderShadowPass(World world)
{ {
if (_lightDirs[0] == 0 && _lightDirs[1] == 0 && _lightDirs[2] == 0) 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 sceneCenter = new Vector3(0, 0.5f, 0);
var lightPos = sceneCenter - lightDir * 30f; 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 lightProj = Matrix4x4.CreateOrthographic(25f, 25f, 1f, 80f);
var lightSpace = lightProj * lightView; var lightSpace = lightProj * lightView;
@@ -422,18 +559,23 @@ public sealed class RaylibRenderer : IRenderer
{ {
Position = lightPos, Position = lightPos,
Target = sceneCenter, Target = sceneCenter,
Up = Vector3.UnitY, Up = up,
FovY = 0, FovY = 0,
Projection = CameraProjection.Orthographic Projection = CameraProjection.Orthographic
}; };
Raylib.BeginTextureMode(_shadowMapRT); // Bind custom depth FBO via rlgl
Raylib.ClearBackground(new Color(255, 255, 255, 255)); Rlgl.EnableFramebuffer(_shadowFbo);
Rlgl.Viewport(0, 0, ShadowMapSize, ShadowMapSize);
Rlgl.ClearColor(255, 255, 255, 255);
Rlgl.ClearScreenBuffers();
Raylib.BeginMode3D(shadowCamera); Raylib.BeginMode3D(shadowCamera);
world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) => world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) =>
{ {
if (e.Name() == "Grid") var name = e.Name();
if (name == "Grid" || name == "Floor")
return; return;
var model = GetOrUploadModel(e, mesh); 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)) if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position))
{ {
var axis = Vector3.UnitY; var (axis, angle) = QuaternionToAxisAngle(rotation);
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);
}
// Swap to shadow shader, draw, swap back
unsafe unsafe
{ {
var origShader = model.Materials[0].Shader; var origShader = model.Materials[0].Shader;
@@ -466,7 +596,10 @@ public sealed class RaylibRenderer : IRenderer
}); });
Raylib.EndMode3D(); Raylib.EndMode3D();
Raylib.EndTextureMode();
// Unbind FBO, restore viewport to screen
Rlgl.DisableFramebuffer();
Rlgl.Viewport(0, 0, Raylib.GetScreenWidth(), Raylib.GetScreenHeight());
} }
private static Shader LoadShadowShader() private static Shader LoadShadowShader()
@@ -474,20 +607,130 @@ public sealed class RaylibRenderer : IRenderer
const string VertexSource = @"#version 330 core const string VertexSource = @"#version 330 core
in vec3 vertexPosition; in vec3 vertexPosition;
uniform mat4 mvp; uniform mat4 mvp;
uniform mat4 matModel;
out vec3 vWorldPos;
void main() void main()
{ {
vec4 worldPos = matModel * vec4(vertexPosition, 1.0);
vWorldPos = worldPos.xyz;
gl_Position = mvp * vec4(vertexPosition, 1.0); gl_Position = mvp * vec4(vertexPosition, 1.0);
}"; }";
const string FragmentSource = @"#version 330 core const string FragmentSource = @"#version 330 core
out vec4 fragColor; void main() {}";
return Raylib.LoadShaderFromMemory(VertexSource, FragmentSource);
}
/// <summary>
/// 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.
/// </summary>
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() 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); return Raylib.LoadShaderFromMemory(VertexSource, FragmentSource);
@@ -531,8 +774,11 @@ uniform vec3 viewPos;
uniform vec3 ambientColor; uniform vec3 ambientColor;
uniform int lightCount; uniform int lightCount;
uniform vec3 lightDirs[4]; uniform vec3 lightDirs[4];
uniform vec3 lightPositions[4];
uniform float lightIntensities[4]; uniform float lightIntensities[4];
uniform vec3 lightColors[4]; uniform vec3 lightColors[4];
uniform int lightTypes[4];
uniform float lightRanges[4];
vec3 ACESFilm(vec3 x) 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); 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() void main()
{ {
vec3 normal = normalize(vNormal); 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) if (useTexture != 0)
{ {
vec2 uv = vTexCoord * 4.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); vec3 viewDir = normalize(viewPos - vWorldPos);
@@ -559,22 +819,38 @@ void main()
float hemisphere = 0.5 + 0.5 * normal.y; float hemisphere = 0.5 + 0.5 * normal.y;
vec3 result = albedo * mix(groundColor, skyColor, hemisphere) * 0.4; 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); vec3 F0 = mix(vec3(0.04), albedo, metal);
float shininess = mix(8.0, 256.0, 1.0 - rough); float shininess = mix(8.0, 256.0, 1.0 - rough);
for (int i = 0; i < lightCount; i++) 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); vec3 H = normalize(L + viewDir);
float NdotL = max(dot(normal, L), 0.0); float NdotL = max(dot(normal, L), 0.0);
float NdotH = max(dot(normal, H), 0.0); float NdotH = max(dot(normal, H), 0.0);
float HdotV = max(dot(H, viewDir), 0.0); float HdotV = max(dot(H, viewDir), 0.0);
float diff = NdotL; float diff = NdotL;
float spec = pow(NdotH, shininess); float spec = pow(NdotH, shininess);
float fresnel = F0.x + (1.0 - F0.x) * pow(1.0 - HdotV, 5.0); // Schlick Fresnel using F0.rgb (per-channel)
vec3 specularColor = mix(vec3(fresnel), albedo * fresnel, metal); vec3 fresnel = F0 + (1.0 - F0) * pow(1.0 - HdotV, 5.0);
vec3 diffuse = albedo * lightColors[i] * diff * lightIntensities[i] * 1.5; vec3 specularColor = mix(fresnel, albedo * fresnel, metal);
vec3 specular = specularColor * spec * lightIntensities[i]; 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)); diffuse *= (1.0 - fresnel * (1.0 - metal * 0.5));
result += diffuse + specular; result += diffuse + specular;
} }
@@ -600,8 +876,10 @@ void main()
Raylib.UnloadTexture(texture); Raylib.UnloadTexture(texture);
_textureCache.Clear(); _textureCache.Clear();
Raylib.UnloadRenderTexture(_shadowMapRT); Rlgl.UnloadTexture(_shadowDepthTex);
Rlgl.UnloadFramebuffer(_shadowFbo);
Raylib.UnloadShader(_shadowShader); Raylib.UnloadShader(_shadowShader);
Raylib.UnloadShader(_shadowReceiverShader);
Raylib.UnloadShader(_shader); Raylib.UnloadShader(_shader);
} }
+2 -1
View File
@@ -15,7 +15,8 @@ public static class MeshMath
{ {
var ab = b - a; var ab = b - a;
var ac = c - 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) if (normal.LengthSquared() > 0.00001f)
normal = Vector3.Normalize(normal); normal = Vector3.Normalize(normal);
else else
+22 -5
View File
@@ -74,9 +74,12 @@ public static class SceneSerializer
var l = e.Get<Light>(); var l = e.Get<Light>();
entry.Light = new SceneLight entry.Light = new SceneLight
{ {
Type = l.Type,
Direction = l.Direction, Direction = l.Direction,
Position = l.Position,
Color = l.Color, Color = l.Color,
Intensity = l.Intensity Intensity = l.Intensity,
Range = l.Range
}; };
} }
@@ -144,10 +147,21 @@ public static class SceneSerializer
if (entry.Light != null) if (entry.Light != null)
{ {
entity.Set(new Light( if (entry.Light.Type == LightType.Point)
entry.Light.Direction, {
entry.Light.Color, entity.Set(Light.Point(
entry.Light.Intensity)); 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) if (entry.Camera != null)
@@ -208,9 +222,12 @@ internal sealed class SceneMaterial
internal sealed class SceneLight internal sealed class SceneLight
{ {
public LightType Type { get; set; }
public Vector3 Direction { get; set; } public Vector3 Direction { get; set; }
public Vector3 Position { get; set; }
public Vector3 Color { get; set; } public Vector3 Color { get; set; }
public float Intensity { get; set; } public float Intensity { get; set; }
public float Range { get; set; }
} }
internal sealed class SceneCamera internal sealed class SceneCamera
+2 -2
View File
@@ -25,7 +25,7 @@ public class MeshAndLightTests
[Fact] [Fact]
public void Light_Direction_Is_Normalized() 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); Assert.Equal(1f, light.Direction.Length(), 0.001f);
} }
@@ -33,7 +33,7 @@ public class MeshAndLightTests
[Fact] [Fact]
public void Light_With_Zero_Direction_Defaults_To_UnitY() 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); Assert.Equal(Vector3.UnitY, light.Direction);
} }
@@ -14,9 +14,10 @@ public class MeshMathTests
new Vector3(1, 0, 0), new Vector3(1, 0, 0),
new Vector3(0, 1, 0)); new Vector3(0, 1, 0));
// CW winding (typical OBJ) → normal points -Z
Assert.Equal(0f, n.X, 0.001f); Assert.Equal(0f, n.X, 0.001f);
Assert.Equal(0f, n.Y, 0.001f); Assert.Equal(0f, n.Y, 0.001f);
Assert.Equal(1f, n.Z, 0.001f); Assert.Equal(-1f, n.Z, 0.001f);
} }
[Fact] [Fact]
+2 -1
View File
@@ -97,9 +97,10 @@ public class ObjLoaderTests
var mesh = ObjLoader.Load(path); var mesh = ObjLoader.Load(path);
var normal = mesh.Vertices[0].Normal; var normal = mesh.Vertices[0].Normal;
// CW winding → normal points -Z
Assert.Equal(0f, normal.X, 0.001f); Assert.Equal(0f, normal.X, 0.001f);
Assert.Equal(0f, normal.Y, 0.001f); Assert.Equal(0f, normal.Y, 0.001f);
Assert.Equal(1f, normal.Z, 0.001f); Assert.Equal(-1f, normal.Z, 0.001f);
} }
[Fact] [Fact]