From 7e33b64ac9d70d6453fcd6cf0a1b51b7820b5af6 Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Thu, 18 Jun 2026 21:36:30 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20soft=20shadows=20=E2=80=94=2016-tap=20P?= =?UTF-8?q?oisson=20disk=20PCF=20on=20cubemap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 16 precomputed Poisson disk offsets on tangent plane - Tangent basis built from sampling direction (cross product trick) - Sample radius scales with distance: closer = sharper, farther = softer radius = clamp(0.02 * (dist / 20), 0.005, 0.08) - Early exit: skip PCF if clearly lit or clearly shadowed (performance) - Bias increased to 0.15 for cubemap distance comparison - Only triangle.frag changed — no C# or Vulkan resource changes - 227 tests, all pass --- .../Shaders/triangle.frag | 57 +++++++++++++++++- .../Shaders/triangle.frag.spv | Bin 10204 -> 13664 bytes 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Engine.Graphics.Vulkan/Shaders/triangle.frag b/src/Engine.Graphics.Vulkan/Shaders/triangle.frag index 8f42354..df0dba3 100644 --- a/src/Engine.Graphics.Vulkan/Shaders/triangle.frag +++ b/src/Engine.Graphics.Vulkan/Shaders/triangle.frag @@ -23,6 +23,26 @@ const vec3 AMBIENT = vec3(0.01, 0.01, 0.02); const float PI = 3.14159265359; const float FAR_PLANE = 60.0; +// 16-tap Poisson disk samples (normalized offsets on unit sphere tangent) +const vec3 POISSON_DISK[16] = vec3[16]( + vec3( 0.0000, 0.0000, 0.0000), + vec3( 0.1376, 0.0000, 0.0000), + vec3( 0.0971, 0.0971, 0.0000), + vec3( 0.0000, 0.1376, 0.0000), + vec3(-0.0971, 0.0971, 0.0000), + vec3(-0.1376, 0.0000, 0.0000), + vec3(-0.0971, -0.0971, 0.0000), + vec3( 0.0000, -0.1376, 0.0000), + vec3( 0.0971, -0.0971, 0.0000), + vec3( 0.2693, 0.0000, 0.0000), + vec3( 0.1903, 0.1903, 0.0000), + vec3( 0.0000, 0.2693, 0.0000), + vec3(-0.1903, 0.1903, 0.0000), + vec3(-0.2693, 0.0000, 0.0000), + vec3(-0.1903, -0.1903, 0.0000), + vec3( 0.1903, -0.1903, 0.0000) +); + float distributionGGX(vec3 N, vec3 H, float roughness) { float a = roughness * roughness; @@ -64,17 +84,50 @@ vec3 acesTonemap(vec3 color) return clamp((color * (a * color + b)) / (color * (c * color + d) + e), 0.0, 1.0); } +// Build a tangent-space basis for the sampling direction +mat3 buildTangentBasis(vec3 dir) +{ + vec3 absDir = abs(dir); + vec3 up = absDir.x < 0.999 ? vec3(1, 0, 0) : vec3(0, 1, 0); + vec3 tangent = normalize(cross(up, dir)); + vec3 bitangent = cross(dir, tangent); + return mat3(tangent, bitangent, dir); +} + float calcShadow(vec3 worldPos, vec3 lightPos, vec3 N, vec3 L) { vec3 dir = worldPos - lightPos; float dist = length(dir); vec3 dirNorm = normalize(dir); + // Base shadow check (center sample) float closestDepth = texture(shadowCube, dirNorm).r; float mappedDepth = closestDepth * FAR_PLANE; + float bias = 0.15; + float baseResult = dist - bias < mappedDepth ? 1.0 : 0.0; - float bias = 0.05; - return dist - bias < mappedDepth ? 1.0 : 0.0; + // If clearly lit or clearly shadowed, skip PCF + if (dist - bias * 4.0 < mappedDepth) return 1.0; + if (dist + bias * 4.0 > mappedDepth && baseResult > 0.5) return 1.0; + + // Poisson disk PCF — 16 samples + float shadow = 0.0; + mat3 basis = buildTangentBasis(dirNorm); + + // Sample radius scales with distance — softer shadows further from light + float sampleRadius = 0.02 * (dist / 20.0); + sampleRadius = clamp(sampleRadius, 0.005, 0.08); + + for (int i = 0; i < 16; i++) + { + vec3 sampleDir = normalize(dirNorm + basis * (POISSON_DISK[i] * sampleRadius)); + float sampleDepth = texture(shadowCube, sampleDir).r; + float sampleMapped = sampleDepth * FAR_PLANE; + shadow += (dist - bias < sampleMapped) ? 1.0 : 0.0; + } + shadow /= 16.0; + + return shadow; } void main() diff --git a/src/Engine.Graphics.Vulkan/Shaders/triangle.frag.spv b/src/Engine.Graphics.Vulkan/Shaders/triangle.frag.spv index d28af583601fc9c3050261323fad0c40cd79d646..9627afa26666d085015c6346d9cd8afea86e25c9 100644 GIT binary patch literal 13664 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 Ko@ueq5&r=!3N8Eq literal 10204 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 Yzl=Ea&n3Sv&m=l~kiE(NLSujZA2MpDtN;K2