feat: cubemap shadows with R32_SFLOAT color attachment — linear distance

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
This commit is contained in:
emil28092005
2026-06-18 21:27:48 +03:00
parent a572f9d360
commit a2000b55b5
11 changed files with 319 additions and 68 deletions
@@ -1,4 +1,17 @@
#version 450 #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() { void main() {
vec3 toLight = fragPos - pc.lightPos.xyz;
float dist = length(toLight);
outDepth = dist / 60.0;
} }
Binary file not shown.
@@ -9,6 +9,10 @@ layout(push_constant) uniform PC {
mat4 lightViewProj; mat4 lightViewProj;
} pc; } pc;
layout(location = 0) out vec3 fragPos;
void main() { 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;
} }
Binary file not shown.
@@ -21,6 +21,7 @@ layout(push_constant) uniform PC {
const vec3 AMBIENT = vec3(0.01, 0.01, 0.02); const vec3 AMBIENT = vec3(0.01, 0.01, 0.02);
const float PI = 3.14159265359; const float PI = 3.14159265359;
const float FAR_PLANE = 60.0;
float distributionGGX(vec3 N, vec3 H, float roughness) 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; vec3 dir = worldPos - lightPos;
float dist = length(dir); 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; float closestDepth = texture(shadowCube, dirNorm).r;
// closestDepth is in [0,1], map to world distance float mappedDepth = closestDepth * FAR_PLANE;
float mappedDepth = closestDepth * 60.0;
float bias = max(0.05 * (1.0 - dot(N, L)), 0.005); float bias = 0.05;
return dist - bias < mappedDepth ? 1.0 : 0.0; 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); float attenuation = pow(clamp(1.0 - dist / max(lightRange, 0.001), 0.0, 1.0), 2.0);
vec3 radiance = lightColor * lightIntensity * attenuation; vec3 radiance = lightColor * lightIntensity * attenuation;
// Shadow from cubemap
float shadow = calcShadow(fragWorldPos, lightPos, N, L); float shadow = calcShadow(fragWorldPos, lightPos, N, L);
vec3 H = normalize(V + L); vec3 H = normalize(V + L);
Binary file not shown.
@@ -89,6 +89,7 @@ public enum VkFormat : int
R8G8B8A8Srgb = 43, R8G8B8A8Srgb = 43,
B8G8R8A8Srgb = 50, B8G8R8A8Srgb = 50,
R32G32Sfloat = 103, R32G32Sfloat = 103,
R32Sfloat = 100,
R32G32B32Sfloat = 106, R32G32B32Sfloat = 106,
R32G32B32A32Sfloat = 109, R32G32B32A32Sfloat = 109,
D32Sfloat = 126, D32Sfloat = 126,
+30 -10
View File
@@ -43,7 +43,7 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen
_shadowMap = new VulkanShadowMap(ctx.Device, ctx, _pipeline.DescriptorSetLayout); _shadowMap = new VulkanShadowMap(ctx.Device, ctx, _pipeline.DescriptorSetLayout);
for (int i = 0; i < VulkanFrameResources.MaxFramesInFlight; i++) 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); _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); Vk.vkBeginCommandBuffer(cmd, &beginInfo);
// === SHADOW CUBEMAP PASSES (6 faces) === // === 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, TransitionImageLayoutDepth(cmd, _shadowMap.DepthImage,
VkImageLayout.Undefined, VkImageLayout.DepthStencilAttachmentOptimal, VkImageLayout.Undefined, VkImageLayout.DepthStencilAttachmentOptimal,
0, 0, 0x100, 0x200, 6); 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 (faceView, faceProj) = VulkanShadowMap.GetFaceViewProj(lightPosVec, face);
var faceViewProj = faceView * faceProj; 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 var shadowDepthClear = new VkClearValue
{ {
DepthStencil = new VkClearDepthStencilValue { Depth = 1.0f, Stencil = 0 }, 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 var shadowDepthAttachment = new VkRenderingAttachmentInfo
{ {
sType = VkStructureType.RenderingAttachmentInfo, sType = VkStructureType.RenderingAttachmentInfo,
imageView = _shadowMap.FaceImageViews[face], imageView = _shadowMap.FaceDepthViews[face],
imageLayout = VkImageLayout.DepthStencilAttachmentOptimal, imageLayout = VkImageLayout.DepthStencilAttachmentOptimal,
loadOp = VkAttachmentLoadOp.Clear, loadOp = VkAttachmentLoadOp.Clear,
storeOp = VkAttachmentStoreOp.Store, 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 }, Extent = new VkExtent2D { Width = VulkanShadowMap.ShadowMapSize, Height = VulkanShadowMap.ShadowMapSize },
}, },
layerCount = 1, layerCount = 1,
colorAttachmentCount = 0, colorAttachmentCount = 1,
pColorAttachments = null, pColorAttachments = &shadowColorAttachment,
pDepthAttachment = &shadowDepthAttachment, pDepthAttachment = &shadowDepthAttachment,
}; };
@@ -264,10 +284,10 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen
Vk.vkCmdEndRendering(cmd); Vk.vkCmdEndRendering(cmd);
} }
// Transition shadow cubemap: depth attachment → shader read // Transition shadow color cube: ColorAttachmentOptimalShaderReadOnlyOptimal
TransitionImageLayoutDepth(cmd, _shadowMap.DepthImage, TransitionImageLayout(cmd, _shadowMap.ColorImage,
VkImageLayout.DepthStencilAttachmentOptimal, VkImageLayout.ShaderReadOnlyOptimal, VkImageLayout.ColorAttachmentOptimal, VkImageLayout.ShaderReadOnlyOptimal,
0x100, 0x200, 0x8, 0x20, 6); 0x400, 0x100, 0x8, 0x20, 6);
// === MAIN PASS === // === MAIN PASS ===
TransitionImageLayout(cmd, _swapchain.Images[imageIndex], 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, private static void TransitionImageLayout(VkCommandBuffer cmd, VkImage image,
VkImageLayout oldLayout, VkImageLayout newLayout, VkImageLayout oldLayout, VkImageLayout newLayout,
ulong srcStage, ulong srcAccess, ulong srcStage, ulong srcAccess,
ulong dstStage, ulong dstAccess) ulong dstStage, ulong dstAccess, uint layerCount = 1)
{ {
var barrier = new VkImageMemoryBarrier2 var barrier = new VkImageMemoryBarrier2
{ {
@@ -461,7 +481,7 @@ internal sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreen
{ {
AspectMask = VkImageAspectFlags.Color, AspectMask = VkImageAspectFlags.Color,
LevelCount = 1, LevelCount = 1,
LayerCount = 1, LayerCount = layerCount,
}, },
}; };
+108 -51
View File
@@ -6,11 +6,17 @@ namespace Engine.Graphics.Vulkan;
internal sealed unsafe class VulkanShadowMap : IDisposable internal sealed unsafe class VulkanShadowMap : IDisposable
{ {
public const uint ShadowMapSize = 1024; 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 VkImage DepthImage;
public VkDeviceMemory DepthImageMemory; public VkDeviceMemory DepthImageMemory;
public VkImageView CubeImageView; public VkImageView[] FaceDepthViews = new VkImageView[6];
public VkImageView[] FaceImageViews = new VkImageView[6];
public VkSampler ShadowSampler; public VkSampler ShadowSampler;
public VkPipeline Pipeline; public VkPipeline Pipeline;
public VkPipelineLayout PipelineLayout; public VkPipelineLayout PipelineLayout;
@@ -25,16 +31,78 @@ internal sealed unsafe class VulkanShadowMap : IDisposable
_device = device; _device = device;
_ctx = ctx; _ctx = ctx;
CreateShadowCubeImage(); CreateShadowImages();
CreateShadowSampler(); CreateShadowSampler();
CreateShadowPipeline(); 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, sType = VkStructureType.ImageCreateInfo,
flags = (uint)VkImageCreateFlags.CubeCompatible, flags = (uint)VkImageCreateFlags.CubeCompatible,
@@ -45,50 +113,28 @@ internal sealed unsafe class VulkanShadowMap : IDisposable
arrayLayers = 6, arrayLayers = 6,
samples = VkSampleCountFlags.Count1, samples = VkSampleCountFlags.Count1,
tiling = VkImageTiling.Optimal, tiling = VkImageTiling.Optimal,
usage = VkImageUsageFlags.DepthStencilAttachment | VkImageUsageFlags.Sampled, usage = VkImageUsageFlags.DepthStencilAttachment,
sharingMode = VkSharingMode.Exclusive, sharingMode = VkSharingMode.Exclusive,
initialLayout = VkImageLayout.Undefined, initialLayout = VkImageLayout.Undefined,
}; };
var img = VkImage.Null; var depthImg = VkImage.Null;
Vk.vkCreateImage(_device, &imageInfo, 0, &img); Vk.vkCreateImage(_device, &depthInfo, 0, &depthImg);
DepthImage = img; DepthImage = depthImg;
var reqs = new VkMemoryRequirements(); var depthReqs = new VkMemoryRequirements();
Vk.vkGetImageMemoryRequirements(_device, DepthImage, &reqs); Vk.vkGetImageMemoryRequirements(_device, DepthImage, &depthReqs);
var memTypeIndex = _ctx.FindMemoryType(reqs.memoryTypeBits, VkMemoryPropertyFlags.DeviceLocal); var depthMemType = _ctx.FindMemoryType(depthReqs.memoryTypeBits, VkMemoryPropertyFlags.DeviceLocal);
var depthAlloc = new VkMemoryAllocateInfo { sType = VkStructureType.MemoryAllocateInfo, allocationSize = depthReqs.size, memoryTypeIndex = depthMemType };
var allocInfo = new VkMemoryAllocateInfo var depthMem = VkDeviceMemory.Null;
{ Vk.vkAllocateMemory(_device, &depthAlloc, 0, &depthMem);
sType = VkStructureType.MemoryAllocateInfo, DepthImageMemory = depthMem;
allocationSize = reqs.size,
memoryTypeIndex = memTypeIndex,
};
var mem = VkDeviceMemory.Null;
Vk.vkAllocateMemory(_device, &allocInfo, 0, &mem);
DepthImageMemory = mem;
Vk.vkBindImageMemory(_device, DepthImage, DepthImageMemory, 0); Vk.vkBindImageMemory(_device, DepthImage, DepthImageMemory, 0);
// Cube view for sampling // 6 face depth views
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
for (int i = 0; i < 6; i++) for (int i = 0; i < 6; i++)
{ {
var faceViewInfo = new VkImageViewCreateInfo var faceDepthViewInfo = new VkImageViewCreateInfo
{ {
sType = VkStructureType.ImageViewCreateInfo, sType = VkStructureType.ImageViewCreateInfo,
image = DepthImage, 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 }, 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 }, subresourceRange = new VkImageSubresourceRange { AspectMask = VkImageAspectFlags.Depth, BaseMipLevel = 0, LevelCount = 1, BaseArrayLayer = (uint)i, LayerCount = 1 },
}; };
var fdv = VkImageView.Null;
var faceView = VkImageView.Null; Vk.vkCreateImageView(_device, &faceDepthViewInfo, 0, &fdv);
Vk.vkCreateImageView(_device, &faceViewInfo, 0, &faceView); FaceDepthViews[i] = fdv;
FaceImageViews[i] = faceView;
} }
} }
@@ -233,12 +278,18 @@ internal sealed unsafe class VulkanShadowMap : IDisposable
stencilTestEnable = VkBool32.False, stencilTestEnable = VkBool32.False,
}; };
var blendAttachment = new VkPipelineColorBlendAttachmentState
{
blendEnable = VkBool32.False,
colorWriteMask = VkColorComponentFlags.R,
};
var colorBlendState = new VkPipelineColorBlendStateCreateInfo var colorBlendState = new VkPipelineColorBlendStateCreateInfo
{ {
sType = VkStructureType.PipelineColorBlendStateCreateInfo, sType = VkStructureType.PipelineColorBlendStateCreateInfo,
logicOpEnable = VkBool32.False, logicOpEnable = VkBool32.False,
attachmentCount = 0, attachmentCount = 1,
pAttachments = null, pAttachments = &blendAttachment,
}; };
var dynamicStates = stackalloc VkDynamicState[3]; var dynamicStates = stackalloc VkDynamicState[3];
@@ -267,12 +318,13 @@ internal sealed unsafe class VulkanShadowMap : IDisposable
Vk.vkCreatePipelineLayout(_device, &layoutInfo, 0, &pl); Vk.vkCreatePipelineLayout(_device, &layoutInfo, 0, &pl);
PipelineLayout = pl; PipelineLayout = pl;
var colorFormat = VkFormat.R32Sfloat;
var depthFormat = VkFormat.D32Sfloat; var depthFormat = VkFormat.D32Sfloat;
var renderingInfo = new VkPipelineRenderingCreateInfo var renderingInfo = new VkPipelineRenderingCreateInfo
{ {
sType = VkStructureType.PipelineRenderingCreateInfo, sType = VkStructureType.PipelineRenderingCreateInfo,
colorAttachmentCount = 0, colorAttachmentCount = 1,
pColorAttachmentFormats = null, pColorAttachmentFormats = &colorFormat,
depthAttachmentFormat = depthFormat, depthAttachmentFormat = depthFormat,
}; };
@@ -344,9 +396,14 @@ internal sealed unsafe class VulkanShadowMap : IDisposable
if (PipelineLayout.Handle != 0) Vk.vkDestroyPipelineLayout(_device, PipelineLayout, 0); if (PipelineLayout.Handle != 0) Vk.vkDestroyPipelineLayout(_device, PipelineLayout, 0);
if (VertModule.Handle != 0) Vk.vkDestroyShaderModule(_device, VertModule, 0); if (VertModule.Handle != 0) Vk.vkDestroyShaderModule(_device, VertModule, 0);
if (ShadowSampler.Handle != 0) Vk.vkDestroySampler(_device, ShadowSampler, 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++) 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 (DepthImage.Handle != 0) Vk.vkDestroyImage(_device, DepthImage, 0);
if (DepthImageMemory.Handle != 0) Vk.vkFreeMemory(_device, DepthImageMemory, 0); if (DepthImageMemory.Handle != 0) Vk.vkFreeMemory(_device, DepthImageMemory, 0);
} }
+114
View File
@@ -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);
}
}
+44
View File
@@ -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")));
}
}