From a2000b55b58b4296279fdc42d85bbe7a580fd1b9 Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Thu, 18 Jun 2026 21:27:48 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20cubemap=20shadows=20with=20R32=5FSFLOAT?= =?UTF-8?q?=20color=20attachment=20=E2=80=94=20linear=20distance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of broken shadows: depth buffer stores non-linear NDC depth, not linear distance. closestDepth * 60.0 was wrong conversion. Fix: switch from depth-only to R32_SFLOAT color attachment approach: - Shadow vertex shader outputs world position to fragment - Shadow fragment shader writes length(fragPos - lightPos) / farPlane - Main fragment shader samples cubemap, multiplies by FAR_PLANE=60 - Separate color cube (R32_SFLOAT, sampled) + depth cube (D32_SFLOAT, depth test) - Shadow pipeline: 1 color attachment (R) + depth attachment - Color clear = 1.0 (max distance), depth clear = 1.0 Tests: 227 total, all pass - ShadowMapFaceDirectionTests: 6 face directions, 90° FOV, up vectors, valid matrices, far plane consistency - ShadowShaderTests: all 6 SPIR-V shaders exist --- .../Shaders/shadow.frag | 13 ++ .../Shaders/shadow.frag.spv | Bin 180 -> 1096 bytes .../Shaders/shadow.vert | 6 +- .../Shaders/shadow.vert.spv | Bin 1328 -> 1536 bytes .../Shaders/triangle.frag | 10 +- .../Shaders/triangle.frag.spv | Bin 10404 -> 10204 bytes src/Engine.Graphics.Vulkan/VulkanEnums.cs | 1 + src/Engine.Graphics.Vulkan/VulkanRenderer.cs | 40 +++-- src/Engine.Graphics.Vulkan/VulkanShadowMap.cs | 159 ++++++++++++------ tests/Engine.Tests/ShadowMapTests.cs | 114 +++++++++++++ tests/Engine.Tests/ShadowShaderTests.cs | 44 +++++ 11 files changed, 319 insertions(+), 68 deletions(-) create mode 100644 tests/Engine.Tests/ShadowMapTests.cs create mode 100644 tests/Engine.Tests/ShadowShaderTests.cs 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 757dd51bdd669a9f3efd078466cb07f5b583bc95..cea493a38ada11e0c4fb3486c78d087234bc210c 100644 GIT binary patch literal 1096 zcmZ9K+fEZv6o!|KZ9x!0j-If{r3v9ej0u4#n7~C7o0xblu?1?1L)tMu05?1rPsI(1 z-?wKbY1qx`+W)`KYfm<{)rxo&voRg-qCAT+1ru=1q>nq_JKN);xBX#vN00f)lA}J) zq7{$P%H`?ckA_)f16jaNo&wx5vPRqz)PSAz7tdfT^3LG=qDXdgoQ+P;yZIQLQ758% z6m--`cVJxRz5Wp0gV z+v>CD_G--&Z1&}8ASe9S*qtmq;I_b?+Ru69K2LDw zmFp(1%%5?e%rAbJU(UFnh&}s0if4(JG4Cd`>*sxyTNB+z*6=rCku&}&+IZi6o&R~XoOlC^d#O2j@8)$!ejD1&EB1ZlR>05EZNxh7E4)-;`w{PBSI;5t z>LcC-oXAy}(k+KCV9!#T&CmIh5V#P1; zOMDd@B<^WC5J2+=I+9BWQE?)f41im)kc3l05dwf~Vgk!o6(h&iv=X;~HhtQ^S{(@r zB-rrBWj`;pH}xYlFUvy&u)R;ZZ(`(KE5{MKOudGjnBilj6UBM z+=~1K?Wr&$>_&f4HjDGT4_flk*Rpo{qHp5Yv~w1o{P@h(@}XT5ocr|#{ViFXH9bBv zhohe@?Ww?`AN)H4`8MRYw38E#d-Dc1D`J2;_q3CfIDO%7N6zlK6MgiLqD`*ndRyLv z*|NyFXfXC;)s{*t6s59JQJ#yM6-1W$DU(?-) z96e>KO5K|R=g|{q#=n)9*oHuDG_Tb?y~5E4@!JC22i=6eQUMOIs7)=-@;0ep_q;Eg od+rE!&j+&ME&oH=2R~B*aZY3X3j+Ds@Y%)+$DBTqQ;zR=ff{ z5>LejiSM(0qKFesX6B#YGyfU8Gt-?9;X+sp3*ljC&Ptd831D-z@9*vJ?T)LH-6u~U z%UBAXnkZ*E@Hl4{J3GpU##gZ0Scl&X{x`$~#xy6P_ae2^;^QD29b}^_`$h`d;KtU< zabD%+5OlugJF~{~VYQ!s&*a6Lv#9lo{QO-$u8xMs*`%goYhQ|DQcKXMR*&%te$caD zQpe(FtIxVCU@2<{`}ZDsdtqR zCsz%9>h8v#@2MMmqK}wb#PkvK88PQZ%vj9*+gn{t@7c*c6EOMKJigVd*fiF)z=@c1 z#okO|_7xk{SkvG6)BVl=?=R+ahgct_em~ooXBD;f-9fuN<8`7re2Q(rx1YIpT;qQM zpJJXzZGMqh&0FM`@bbmBiQG@@VNFZnUB)}p`inOAn-O!)5m2iC%(F@E4(+Qh z=eXa9#T_{38n{}lJICB_L@f5bPV8LyYi-S5-v1Wf`^5Sdv6{r~XYNj;PPuom5PrP2 z-+dyVRns%xmOECnNBj?n@9-`E&R)mVr>2)M8yyWEIh!rVeMHU;V)0+}@tK?01|}{? zy}oy~et@IS6pPZCUO}tANOELM@z4K4{TDyliUyhpH!2`_qtL9hS M++5yYHuoI+2TOBOQ2+n{ 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 e2858f0334d157b3741cd56818fe434f4f179626..d28af583601fc9c3050261323fad0c40cd79d646 100644 GIT binary patch literal 10204 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 Yzl=Ea&n3Sv&m=l~kiE(NLSujZA2MpDtN;K2 literal 10404 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 OZeKl9o(nb4#s34*1G~Qf 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"))); + } +}