diff --git a/src/Engine.Graphics.Vulkan/Shaders/shadow.frag b/src/Engine.Graphics.Vulkan/Shaders/shadow.frag index f0bcb49..0e4f049 100644 --- a/src/Engine.Graphics.Vulkan/Shaders/shadow.frag +++ b/src/Engine.Graphics.Vulkan/Shaders/shadow.frag @@ -1,4 +1,17 @@ #version 450 +layout(location = 0) in vec3 fragPos; +layout(location = 0) out float outDepth; + +layout(push_constant) uniform PC { + mat4 model; + vec4 lightPos; + vec4 lightColor; + mat4 lightViewProj; +} pc; + void main() { + vec3 toLight = fragPos - pc.lightPos.xyz; + float dist = length(toLight); + outDepth = dist / 60.0; } diff --git a/src/Engine.Graphics.Vulkan/Shaders/shadow.frag.spv b/src/Engine.Graphics.Vulkan/Shaders/shadow.frag.spv index 757dd51..cea493a 100644 Binary files a/src/Engine.Graphics.Vulkan/Shaders/shadow.frag.spv and b/src/Engine.Graphics.Vulkan/Shaders/shadow.frag.spv differ diff --git a/src/Engine.Graphics.Vulkan/Shaders/shadow.vert b/src/Engine.Graphics.Vulkan/Shaders/shadow.vert index 1f7ca77..764078e 100644 --- a/src/Engine.Graphics.Vulkan/Shaders/shadow.vert +++ b/src/Engine.Graphics.Vulkan/Shaders/shadow.vert @@ -9,6 +9,10 @@ layout(push_constant) uniform PC { mat4 lightViewProj; } pc; +layout(location = 0) out vec3 fragPos; + void main() { - gl_Position = pc.lightViewProj * pc.model * vec4(inPosition, 1.0); + vec4 worldPos = pc.model * vec4(inPosition, 1.0); + fragPos = worldPos.xyz; + gl_Position = pc.lightViewProj * worldPos; } diff --git a/src/Engine.Graphics.Vulkan/Shaders/shadow.vert.spv b/src/Engine.Graphics.Vulkan/Shaders/shadow.vert.spv index 15e1809..a8c8bec 100644 Binary files a/src/Engine.Graphics.Vulkan/Shaders/shadow.vert.spv and b/src/Engine.Graphics.Vulkan/Shaders/shadow.vert.spv differ diff --git a/src/Engine.Graphics.Vulkan/Shaders/triangle.frag b/src/Engine.Graphics.Vulkan/Shaders/triangle.frag index 39794f0..8f42354 100644 --- a/src/Engine.Graphics.Vulkan/Shaders/triangle.frag +++ b/src/Engine.Graphics.Vulkan/Shaders/triangle.frag @@ -21,6 +21,7 @@ layout(push_constant) uniform PC { const vec3 AMBIENT = vec3(0.01, 0.01, 0.02); const float PI = 3.14159265359; +const float FAR_PLANE = 60.0; float distributionGGX(vec3 N, vec3 H, float roughness) { @@ -67,14 +68,12 @@ float calcShadow(vec3 worldPos, vec3 lightPos, vec3 N, vec3 L) { vec3 dir = worldPos - lightPos; float dist = length(dir); - vec3 dirNorm = dir / max(dist, 0.001); + vec3 dirNorm = normalize(dir); - // Sample cubemap: direction from light to fragment float closestDepth = texture(shadowCube, dirNorm).r; - // closestDepth is in [0,1], map to world distance - float mappedDepth = closestDepth * 60.0; + float mappedDepth = closestDepth * FAR_PLANE; - float bias = max(0.05 * (1.0 - dot(N, L)), 0.005); + float bias = 0.05; return dist - bias < mappedDepth ? 1.0 : 0.0; } @@ -98,7 +97,6 @@ void main() float attenuation = pow(clamp(1.0 - dist / max(lightRange, 0.001), 0.0, 1.0), 2.0); vec3 radiance = lightColor * lightIntensity * attenuation; - // Shadow from cubemap float shadow = calcShadow(fragWorldPos, lightPos, N, L); vec3 H = normalize(V + L); diff --git a/src/Engine.Graphics.Vulkan/Shaders/triangle.frag.spv b/src/Engine.Graphics.Vulkan/Shaders/triangle.frag.spv index e2858f0..d28af58 100644 Binary files a/src/Engine.Graphics.Vulkan/Shaders/triangle.frag.spv and b/src/Engine.Graphics.Vulkan/Shaders/triangle.frag.spv differ diff --git a/src/Engine.Graphics.Vulkan/VulkanEnums.cs b/src/Engine.Graphics.Vulkan/VulkanEnums.cs index 6717849..028b6b0 100644 --- a/src/Engine.Graphics.Vulkan/VulkanEnums.cs +++ b/src/Engine.Graphics.Vulkan/VulkanEnums.cs @@ -89,6 +89,7 @@ public enum VkFormat : int R8G8B8A8Srgb = 43, B8G8R8A8Srgb = 50, R32G32Sfloat = 103, + R32Sfloat = 100, R32G32B32Sfloat = 106, R32G32B32A32Sfloat = 109, D32Sfloat = 126, diff --git a/src/Engine.Graphics.Vulkan/VulkanRenderer.cs b/src/Engine.Graphics.Vulkan/VulkanRenderer.cs index 45ecaf0..b8ad9c2 100644 --- a/src/Engine.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Engine.Graphics.Vulkan/VulkanRenderer.cs @@ -43,7 +43,7 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen _shadowMap = new VulkanShadowMap(ctx.Device, ctx, _pipeline.DescriptorSetLayout); for (int i = 0; i < VulkanFrameResources.MaxFramesInFlight; i++) - _frameResources.UpdateShadowDescriptor(i, _shadowMap.ShadowSampler, _shadowMap.CubeImageView); + _frameResources.UpdateShadowDescriptor(i, _shadowMap.ShadowSampler, _shadowMap.CubeColorView); _imGui = new VulkanImGui(ctx, _frameResources.CommandPool, swapchain.Format, swapchain.DepthFormat); } @@ -175,6 +175,11 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen Vk.vkBeginCommandBuffer(cmd, &beginInfo); // === SHADOW CUBEMAP PASSES (6 faces) === + // Transition color cube: Undefined → ColorAttachmentOptimal + TransitionImageLayout(cmd, _shadowMap.ColorImage, + VkImageLayout.Undefined, VkImageLayout.ColorAttachmentOptimal, + 0, 0, 0x400, 0x100, 6); + // Transition depth cube: Undefined → DepthStencilAttachmentOptimal TransitionImageLayoutDepth(cmd, _shadowMap.DepthImage, VkImageLayout.Undefined, VkImageLayout.DepthStencilAttachmentOptimal, 0, 0, 0x100, 0x200, 6); @@ -186,6 +191,21 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen var (faceView, faceProj) = VulkanShadowMap.GetFaceViewProj(lightPosVec, face); var faceViewProj = faceView * faceProj; + var colorClear = new VkClearValue + { + Color = new VkClearColorValue { Float0 = 1.0f, Float1 = 0, Float2 = 0, Float3 = 0 }, + }; + + var shadowColorAttachment = new VkRenderingAttachmentInfo + { + sType = VkStructureType.RenderingAttachmentInfo, + imageView = _shadowMap.FaceColorViews[face], + imageLayout = VkImageLayout.ColorAttachmentOptimal, + loadOp = VkAttachmentLoadOp.Clear, + storeOp = VkAttachmentStoreOp.Store, + clearValue = colorClear, + }; + var shadowDepthClear = new VkClearValue { DepthStencil = new VkClearDepthStencilValue { Depth = 1.0f, Stencil = 0 }, @@ -194,7 +214,7 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen var shadowDepthAttachment = new VkRenderingAttachmentInfo { sType = VkStructureType.RenderingAttachmentInfo, - imageView = _shadowMap.FaceImageViews[face], + imageView = _shadowMap.FaceDepthViews[face], imageLayout = VkImageLayout.DepthStencilAttachmentOptimal, loadOp = VkAttachmentLoadOp.Clear, storeOp = VkAttachmentStoreOp.Store, @@ -210,8 +230,8 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen Extent = new VkExtent2D { Width = VulkanShadowMap.ShadowMapSize, Height = VulkanShadowMap.ShadowMapSize }, }, layerCount = 1, - colorAttachmentCount = 0, - pColorAttachments = null, + colorAttachmentCount = 1, + pColorAttachments = &shadowColorAttachment, pDepthAttachment = &shadowDepthAttachment, }; @@ -264,10 +284,10 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen Vk.vkCmdEndRendering(cmd); } - // Transition shadow cubemap: depth attachment → shader read - TransitionImageLayoutDepth(cmd, _shadowMap.DepthImage, - VkImageLayout.DepthStencilAttachmentOptimal, VkImageLayout.ShaderReadOnlyOptimal, - 0x100, 0x200, 0x8, 0x20, 6); + // Transition shadow color cube: ColorAttachmentOptimal → ShaderReadOnlyOptimal + TransitionImageLayout(cmd, _shadowMap.ColorImage, + VkImageLayout.ColorAttachmentOptimal, VkImageLayout.ShaderReadOnlyOptimal, + 0x400, 0x100, 0x8, 0x20, 6); // === MAIN PASS === TransitionImageLayout(cmd, _swapchain.Images[imageIndex], @@ -445,7 +465,7 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen private static void TransitionImageLayout(VkCommandBuffer cmd, VkImage image, VkImageLayout oldLayout, VkImageLayout newLayout, ulong srcStage, ulong srcAccess, - ulong dstStage, ulong dstAccess) + ulong dstStage, ulong dstAccess, uint layerCount = 1) { var barrier = new VkImageMemoryBarrier2 { @@ -461,7 +481,7 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen { AspectMask = VkImageAspectFlags.Color, LevelCount = 1, - LayerCount = 1, + LayerCount = layerCount, }, }; diff --git a/src/Engine.Graphics.Vulkan/VulkanShadowMap.cs b/src/Engine.Graphics.Vulkan/VulkanShadowMap.cs index 17c0197..157a3b6 100644 --- a/src/Engine.Graphics.Vulkan/VulkanShadowMap.cs +++ b/src/Engine.Graphics.Vulkan/VulkanShadowMap.cs @@ -6,11 +6,17 @@ namespace Engine.Graphics.Vulkan; internal sealed unsafe class VulkanShadowMap : IDisposable { public const uint ShadowMapSize = 1024; + public const float FarPlane = 60.0f; + + public VkImage ColorImage; + public VkDeviceMemory ColorImageMemory; + public VkImageView CubeColorView; + public VkImageView[] FaceColorViews = new VkImageView[6]; public VkImage DepthImage; public VkDeviceMemory DepthImageMemory; - public VkImageView CubeImageView; - public VkImageView[] FaceImageViews = new VkImageView[6]; + public VkImageView[] FaceDepthViews = new VkImageView[6]; + public VkSampler ShadowSampler; public VkPipeline Pipeline; public VkPipelineLayout PipelineLayout; @@ -25,16 +31,78 @@ internal sealed unsafe class VulkanShadowMap : IDisposable _device = device; _ctx = ctx; - CreateShadowCubeImage(); + CreateShadowImages(); CreateShadowSampler(); CreateShadowPipeline(); - Console.WriteLine("[Vulkan] Shadow cubemap created (1024x1024x6 D32_SFLOAT)"); + Console.WriteLine("[Vulkan] Shadow cubemap created (1024x1024x6, R32_SFLOAT + D32_SFLOAT)"); } - private void CreateShadowCubeImage() + private void CreateShadowImages() { - var imageInfo = new VkImageCreateInfo + // Color cube (R32_SFLOAT) — stores linear distance + var colorInfo = new VkImageCreateInfo + { + sType = VkStructureType.ImageCreateInfo, + flags = (uint)VkImageCreateFlags.CubeCompatible, + imageType = VkImageType.Type2D, + format = VkFormat.R32Sfloat, + extent = new VkExtent3D { Width = ShadowMapSize, Height = ShadowMapSize, Depth = 1 }, + mipLevels = 1, + arrayLayers = 6, + samples = VkSampleCountFlags.Count1, + tiling = VkImageTiling.Optimal, + usage = VkImageUsageFlags.ColorAttachment | VkImageUsageFlags.Sampled, + sharingMode = VkSharingMode.Exclusive, + initialLayout = VkImageLayout.Undefined, + }; + + var colorImg = VkImage.Null; + Vk.vkCreateImage(_device, &colorInfo, 0, &colorImg); + ColorImage = colorImg; + + var colorReqs = new VkMemoryRequirements(); + Vk.vkGetImageMemoryRequirements(_device, ColorImage, &colorReqs); + var colorMemType = _ctx.FindMemoryType(colorReqs.memoryTypeBits, VkMemoryPropertyFlags.DeviceLocal); + var colorAlloc = new VkMemoryAllocateInfo { sType = VkStructureType.MemoryAllocateInfo, allocationSize = colorReqs.size, memoryTypeIndex = colorMemType }; + var colorMem = VkDeviceMemory.Null; + Vk.vkAllocateMemory(_device, &colorAlloc, 0, &colorMem); + ColorImageMemory = colorMem; + Vk.vkBindImageMemory(_device, ColorImage, ColorImageMemory, 0); + + // Cube color view for sampling + var cubeColorViewInfo = new VkImageViewCreateInfo + { + sType = VkStructureType.ImageViewCreateInfo, + image = ColorImage, + viewType = VkImageViewType.TypeCube, + format = VkFormat.R32Sfloat, + components = new VkComponentMapping { R = VkComponentSwizzle.Identity, G = VkComponentSwizzle.Identity, B = VkComponentSwizzle.Identity, A = VkComponentSwizzle.Identity }, + subresourceRange = new VkImageSubresourceRange { AspectMask = VkImageAspectFlags.Color, BaseMipLevel = 0, LevelCount = 1, BaseArrayLayer = 0, LayerCount = 6 }, + }; + var cubeCV = VkImageView.Null; + Vk.vkCreateImageView(_device, &cubeColorViewInfo, 0, &cubeCV); + CubeColorView = cubeCV; + + // 6 face color views + for (int i = 0; i < 6; i++) + { + var faceViewInfo = new VkImageViewCreateInfo + { + sType = VkStructureType.ImageViewCreateInfo, + image = ColorImage, + viewType = VkImageViewType.Type2D, + format = VkFormat.R32Sfloat, + components = new VkComponentMapping { R = VkComponentSwizzle.Identity, G = VkComponentSwizzle.Identity, B = VkComponentSwizzle.Identity, A = VkComponentSwizzle.Identity }, + subresourceRange = new VkImageSubresourceRange { AspectMask = VkImageAspectFlags.Color, BaseMipLevel = 0, LevelCount = 1, BaseArrayLayer = (uint)i, LayerCount = 1 }, + }; + var fv = VkImageView.Null; + Vk.vkCreateImageView(_device, &faceViewInfo, 0, &fv); + FaceColorViews[i] = fv; + } + + // Depth cube (D32_SFLOAT) — for depth testing during shadow render + var depthInfo = new VkImageCreateInfo { sType = VkStructureType.ImageCreateInfo, flags = (uint)VkImageCreateFlags.CubeCompatible, @@ -45,50 +113,28 @@ internal sealed unsafe class VulkanShadowMap : IDisposable arrayLayers = 6, samples = VkSampleCountFlags.Count1, tiling = VkImageTiling.Optimal, - usage = VkImageUsageFlags.DepthStencilAttachment | VkImageUsageFlags.Sampled, + usage = VkImageUsageFlags.DepthStencilAttachment, sharingMode = VkSharingMode.Exclusive, initialLayout = VkImageLayout.Undefined, }; - var img = VkImage.Null; - Vk.vkCreateImage(_device, &imageInfo, 0, &img); - DepthImage = img; + var depthImg = VkImage.Null; + Vk.vkCreateImage(_device, &depthInfo, 0, &depthImg); + DepthImage = depthImg; - var reqs = new VkMemoryRequirements(); - Vk.vkGetImageMemoryRequirements(_device, DepthImage, &reqs); - var memTypeIndex = _ctx.FindMemoryType(reqs.memoryTypeBits, VkMemoryPropertyFlags.DeviceLocal); - - var allocInfo = new VkMemoryAllocateInfo - { - sType = VkStructureType.MemoryAllocateInfo, - allocationSize = reqs.size, - memoryTypeIndex = memTypeIndex, - }; - - var mem = VkDeviceMemory.Null; - Vk.vkAllocateMemory(_device, &allocInfo, 0, &mem); - DepthImageMemory = mem; + var depthReqs = new VkMemoryRequirements(); + Vk.vkGetImageMemoryRequirements(_device, DepthImage, &depthReqs); + var depthMemType = _ctx.FindMemoryType(depthReqs.memoryTypeBits, VkMemoryPropertyFlags.DeviceLocal); + var depthAlloc = new VkMemoryAllocateInfo { sType = VkStructureType.MemoryAllocateInfo, allocationSize = depthReqs.size, memoryTypeIndex = depthMemType }; + var depthMem = VkDeviceMemory.Null; + Vk.vkAllocateMemory(_device, &depthAlloc, 0, &depthMem); + DepthImageMemory = depthMem; Vk.vkBindImageMemory(_device, DepthImage, DepthImageMemory, 0); - // Cube view for sampling - var cubeViewInfo = new VkImageViewCreateInfo - { - sType = VkStructureType.ImageViewCreateInfo, - image = DepthImage, - viewType = VkImageViewType.TypeCube, - format = VkFormat.D32Sfloat, - components = new VkComponentMapping { R = VkComponentSwizzle.Identity, G = VkComponentSwizzle.Identity, B = VkComponentSwizzle.Identity, A = VkComponentSwizzle.Identity }, - subresourceRange = new VkImageSubresourceRange { AspectMask = VkImageAspectFlags.Depth, BaseMipLevel = 0, LevelCount = 1, BaseArrayLayer = 0, LayerCount = 6 }, - }; - - var cubeView = VkImageView.Null; - Vk.vkCreateImageView(_device, &cubeViewInfo, 0, &cubeView); - CubeImageView = cubeView; - - // 6 face views for rendering + // 6 face depth views for (int i = 0; i < 6; i++) { - var faceViewInfo = new VkImageViewCreateInfo + var faceDepthViewInfo = new VkImageViewCreateInfo { sType = VkStructureType.ImageViewCreateInfo, image = DepthImage, @@ -97,10 +143,9 @@ internal sealed unsafe class VulkanShadowMap : IDisposable components = new VkComponentMapping { R = VkComponentSwizzle.Identity, G = VkComponentSwizzle.Identity, B = VkComponentSwizzle.Identity, A = VkComponentSwizzle.Identity }, subresourceRange = new VkImageSubresourceRange { AspectMask = VkImageAspectFlags.Depth, BaseMipLevel = 0, LevelCount = 1, BaseArrayLayer = (uint)i, LayerCount = 1 }, }; - - var faceView = VkImageView.Null; - Vk.vkCreateImageView(_device, &faceViewInfo, 0, &faceView); - FaceImageViews[i] = faceView; + var fdv = VkImageView.Null; + Vk.vkCreateImageView(_device, &faceDepthViewInfo, 0, &fdv); + FaceDepthViews[i] = fdv; } } @@ -233,12 +278,18 @@ internal sealed unsafe class VulkanShadowMap : IDisposable stencilTestEnable = VkBool32.False, }; + var blendAttachment = new VkPipelineColorBlendAttachmentState + { + blendEnable = VkBool32.False, + colorWriteMask = VkColorComponentFlags.R, + }; + var colorBlendState = new VkPipelineColorBlendStateCreateInfo { sType = VkStructureType.PipelineColorBlendStateCreateInfo, logicOpEnable = VkBool32.False, - attachmentCount = 0, - pAttachments = null, + attachmentCount = 1, + pAttachments = &blendAttachment, }; var dynamicStates = stackalloc VkDynamicState[3]; @@ -267,12 +318,13 @@ internal sealed unsafe class VulkanShadowMap : IDisposable Vk.vkCreatePipelineLayout(_device, &layoutInfo, 0, &pl); PipelineLayout = pl; + var colorFormat = VkFormat.R32Sfloat; var depthFormat = VkFormat.D32Sfloat; var renderingInfo = new VkPipelineRenderingCreateInfo { sType = VkStructureType.PipelineRenderingCreateInfo, - colorAttachmentCount = 0, - pColorAttachmentFormats = null, + colorAttachmentCount = 1, + pColorAttachmentFormats = &colorFormat, depthAttachmentFormat = depthFormat, }; @@ -344,9 +396,14 @@ internal sealed unsafe class VulkanShadowMap : IDisposable if (PipelineLayout.Handle != 0) Vk.vkDestroyPipelineLayout(_device, PipelineLayout, 0); if (VertModule.Handle != 0) Vk.vkDestroyShaderModule(_device, VertModule, 0); if (ShadowSampler.Handle != 0) Vk.vkDestroySampler(_device, ShadowSampler, 0); - if (CubeImageView.Handle != 0) Vk.vkDestroyImageView(_device, CubeImageView, 0); + if (CubeColorView.Handle != 0) Vk.vkDestroyImageView(_device, CubeColorView, 0); for (int i = 0; i < 6; i++) - if (FaceImageViews[i].Handle != 0) Vk.vkDestroyImageView(_device, FaceImageViews[i], 0); + { + if (FaceColorViews[i].Handle != 0) Vk.vkDestroyImageView(_device, FaceColorViews[i], 0); + if (FaceDepthViews[i].Handle != 0) Vk.vkDestroyImageView(_device, FaceDepthViews[i], 0); + } + if (ColorImage.Handle != 0) Vk.vkDestroyImage(_device, ColorImage, 0); + if (ColorImageMemory.Handle != 0) Vk.vkFreeMemory(_device, ColorImageMemory, 0); if (DepthImage.Handle != 0) Vk.vkDestroyImage(_device, DepthImage, 0); if (DepthImageMemory.Handle != 0) Vk.vkFreeMemory(_device, DepthImageMemory, 0); } diff --git a/tests/Engine.Tests/ShadowMapTests.cs b/tests/Engine.Tests/ShadowMapTests.cs new file mode 100644 index 0000000..f71ef07 --- /dev/null +++ b/tests/Engine.Tests/ShadowMapTests.cs @@ -0,0 +1,114 @@ +using System.Numerics; + +namespace Engine.Tests; + +public class ShadowMapFaceDirectionTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public void All_Six_Faces_Produce_Valid_View_Matrices(int face) + { + // Test the face direction logic directly (without VulkanShadowMap class) + var lightPos = new Vector3(3, 7, -2); + var (view, proj) = ComputeFaceViewProj(lightPos, face); + + Assert.True(!float.IsNaN(view.M11)); + Assert.True(!float.IsNaN(proj.M11)); + Assert.True(!float.IsInfinity(view.M11)); + Assert.True(!float.IsInfinity(proj.M11)); + } + + [Theory] + [InlineData(0, 1, 0, 0)] + [InlineData(1, -1, 0, 0)] + [InlineData(2, 0, 1, 0)] + [InlineData(3, 0, -1, 0)] + [InlineData(4, 0, 0, 1)] + [InlineData(5, 0, 0, -1)] + public void Face_Target_Is_LightPos_Plus_Direction(int face, float dx, float dy, float dz) + { + var lightPos = new Vector3(5, 10, 3); + var (view, _) = ComputeFaceViewProj(lightPos, face); + + // View matrix transforms lightPos to origin + var origin = Vector3.Transform(lightPos, view); + Assert.Equal(0f, origin.X, 0.001f); + Assert.Equal(0f, origin.Y, 0.001f); + Assert.Equal(0f, origin.Z, 0.001f); + } + + [Fact] + public void All_Faces_Have_90_Degrees_FOV() + { + for (int face = 0; face < 6; face++) + { + var (_, proj) = ComputeFaceViewProj(Vector3.Zero, face); + // FOV=90°, aspect=1 → M22 = 1/tan(45°) = 1, then *= -1 → -1... wait + // CreatePerspectiveFieldOfView(PI/2, 1, n, f) → M22 = 1/tan(PI/4) = 1 + // Then M22 *= -1 → M22 = -1 + Assert.Equal(-1f, proj.M22, 0.001f); + } + } + + [Fact] + public void Face_2_Uses_Negative_Z_Up() + { + var lightPos = new Vector3(0, 5, 0); + var (view, _) = ComputeFaceViewProj(lightPos, 2); + // +Y face: up = -Z + var negZ = Vector3.Transform(new Vector3(0, 0, -1), view); + // Should have positive Y in view space (up direction) + Assert.True(negZ.Y > 0, $"Face 2 up should map -Z to +Y view space, got {negZ}"); + } + + [Fact] + public void Face_3_Uses_Positive_Z_Up() + { + var lightPos = new Vector3(0, 5, 0); + var (view, _) = ComputeFaceViewProj(lightPos, 3); + // -Y face: up = +Z + var posZ = Vector3.Transform(new Vector3(0, 0, 1), view); + Assert.True(posZ.Y > 0, $"Face 3 up should map +Z to +Y view space, got {posZ}"); + } + + [Fact] + public void FarPlane_Matches_Between_Projection_And_Shader() + { + // The shader hardcodes FAR_PLANE = 60.0 and divides by it + // The projection must use the same far plane + var (_, proj) = ComputeFaceViewProj(Vector3.Zero, 0); + // M33 for perspective with M22 *= -1: should be negative + Assert.True(proj.M33 < 0, $"M33 should be negative for Vulkan projection, got {proj.M33}"); + } + + static (Matrix4x4 view, Matrix4x4 proj) ComputeFaceViewProj(Vector3 lightPos, int face) + { + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1.0f, 0.1f, 60f); + proj.M22 *= -1; + + var target = lightPos; + var up = Vector3.UnitY; + + target += face switch + { + 0 => Vector3.UnitX, + 1 => -Vector3.UnitX, + 2 => Vector3.UnitY, + 3 => -Vector3.UnitY, + 4 => Vector3.UnitZ, + 5 => -Vector3.UnitZ, + _ => Vector3.UnitZ, + }; + + if (face == 2) up = -Vector3.UnitZ; + else if (face == 3) up = Vector3.UnitZ; + + var view = Matrix4x4.CreateLookAt(lightPos, target, up); + return (view, proj); + } +} diff --git a/tests/Engine.Tests/ShadowShaderTests.cs b/tests/Engine.Tests/ShadowShaderTests.cs new file mode 100644 index 0000000..27bce88 --- /dev/null +++ b/tests/Engine.Tests/ShadowShaderTests.cs @@ -0,0 +1,44 @@ +using System.IO; +using Engine.Graphics.Vulkan; + +namespace Engine.Tests; + +public class ShadowShaderTests +{ + [Fact] + public void Shadow_Vertex_Shader_Exists() + { + Assert.True(File.Exists("Shaders/shadow.vert.spv") || + File.Exists(Path.Combine(AppContext.BaseDirectory, "Shaders/shadow.vert.spv"))); + } + + [Fact] + public void Shadow_Fragment_Shader_Exists() + { + Assert.True(File.Exists("Shaders/shadow.frag.spv") || + File.Exists(Path.Combine(AppContext.BaseDirectory, "Shaders/shadow.frag.spv"))); + } + + [Fact] + public void Main_Vertex_Shader_Exists() + { + Assert.True(File.Exists("Shaders/triangle.vert.spv") || + File.Exists(Path.Combine(AppContext.BaseDirectory, "Shaders/triangle.vert.spv"))); + } + + [Fact] + public void Main_Fragment_Shader_Exists() + { + Assert.True(File.Exists("Shaders/triangle.frag.spv") || + File.Exists(Path.Combine(AppContext.BaseDirectory, "Shaders/triangle.frag.spv"))); + } + + [Fact] + public void ImGui_Shaders_Exist() + { + Assert.True(File.Exists("Shaders/imgui.vert.spv") || + File.Exists(Path.Combine(AppContext.BaseDirectory, "Shaders/imgui.vert.spv"))); + Assert.True(File.Exists("Shaders/imgui.frag.spv") || + File.Exists(Path.Combine(AppContext.BaseDirectory, "Shaders/imgui.frag.spv"))); + } +}