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
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;
}
Binary file not shown.
@@ -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;
}
Binary file not shown.
@@ -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);
Binary file not shown.
@@ -89,6 +89,7 @@ public enum VkFormat : int
R8G8B8A8Srgb = 43,
B8G8R8A8Srgb = 50,
R32G32Sfloat = 103,
R32Sfloat = 100,
R32G32B32Sfloat = 106,
R32G32B32A32Sfloat = 109,
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);
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: ColorAttachmentOptimalShaderReadOnlyOptimal
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,
},
};
+108 -51
View File
@@ -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);
}