diff --git a/CMakeLists.txt b/CMakeLists.txt index 2212728b..15aeb269 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -145,6 +145,7 @@ set(GRAPHICS_OPENGL_SOURCES set(GRAPHICS_VULKAN_SOURCES src/Graphics/Vulkan/VulkanBloom.cpp src/Graphics/Vulkan/VulkanBuffer.cpp + src/Graphics/Vulkan/VulkanClusteredLights.cpp src/Graphics/Vulkan/VulkanCubemapTexture.cpp src/Graphics/Vulkan/VulkanDeferred.cpp src/Graphics/Vulkan/VulkanDescriptors.cpp diff --git a/assets/shaders/lighting_pass.frag b/assets/shaders/lighting_pass.frag index 4a1e9ba6..4165138a 100644 --- a/assets/shaders/lighting_pass.frag +++ b/assets/shaders/lighting_pass.frag @@ -7,6 +7,7 @@ // Set 1: DeferredCB UBO // Set 2: ShadowLightUBO (directional light + shadow params + fog) // Set 3: IBL (irradiance cubemap, prefiltered env map, BRDF LUT, settings) +// Set 4: Clustered lights (grid params, light list, cells, light indices) // ============================================================ layout(location = 0) in vec2 fragUV; @@ -73,6 +74,33 @@ layout(set = 3, binding = 3) uniform IBLSettings { float _iblPad; }; +// -- Set 4: Clustered lights ----------------------------------------- +// lights[] holds uClusterGrid.w extra directional lights, then the local +// lights that cells[] (offset, count) index into via lightIndices[]. +struct GPULight { + vec3 position; uint type; // 0=dir, 1=point, 2=spot, 3=area + vec3 direction; float intensity; + vec3 color; float range; + float spotInnerCos; float spotOuterCos; float areaWidth; float areaHeight; +}; + +layout(set = 4, binding = 0) uniform ClusterParams { + vec4 uViewZ; // view depth = dot(xyz, worldPos) + w + uvec4 uClusterGrid; // xyz = tiles x, tiles y, slices; w = global lights + float uClusterZScale; // slice = log(depth) * scale + bias + float uClusterZBias; + uint uLocalLightCount; +}; +layout(std430, set = 4, binding = 1) readonly buffer ClusterLights { + GPULight lights[]; +}; +layout(std430, set = 4, binding = 2) readonly buffer ClusterCells { + uvec2 cells[]; +}; +layout(std430, set = 4, binding = 3) readonly buffer ClusterLightIndices { + uint lightIndices[]; +}; + layout(location = 0) out vec4 outColor; // ================================================================== @@ -191,6 +219,48 @@ vec3 FresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) { * pow(max(1.0 - cosTheta, 0.0), 5.0); } +// Cook-Torrance response to one light of the given radiance. +vec3 CookTorrance(vec3 N, vec3 V, vec3 L, vec3 radiance, vec3 albedo, + float roughness, float metallic, vec3 F0, float NdotV) { + float NdotL = max(dot(N, L), 0.0); + if (NdotL <= 0.0) return vec3(0.0); + vec3 H = normalize(V + L); + float D = DistributionGGX(N, H, roughness); + float G = GeometrySmith(N, V, L, roughness); + vec3 F = FresnelSchlick(max(dot(H, V), 0.0), F0); + vec3 kD = (vec3(1.0) - F) * (1.0 - metallic); + vec3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 0.0001); + return (kD * albedo / PI + specular) * radiance * NdotL; +} + +// ================================================================== +// Clustered lights +// ================================================================== + +uint ClusterIndex(vec2 ndcXY, vec3 worldPos) { + float viewZ = dot(uViewZ.xyz, worldPos) + uViewZ.w; + float slice = log(max(viewZ, 1e-3)) * uClusterZScale + uClusterZBias; + uint z = uint(clamp(slice, 0.0, float(uClusterGrid.z - 1u))); + vec2 tile = clamp((ndcXY * 0.5 + 0.5) * vec2(uClusterGrid.xy), + vec2(0.0), vec2(uClusterGrid.xy) - 1.0); + return (z * uClusterGrid.y + uint(tile.y)) * uClusterGrid.x + uint(tile.x); +} + +// Windowed inverse-square falloff that reaches zero at the light's range, +// which is what lets the CPU cull it per cluster. Spots add the cone. +vec3 LocalLightRadiance(GPULight light, vec3 worldPos, out vec3 L) { + vec3 toLight = light.position - worldPos; + float dist = length(toLight); + L = toLight / max(dist, 1e-4); + float d = dist / max(light.range, 1e-4); + float window = clamp(1.0 - d * d * d * d, 0.0, 1.0); + float atten = window * window / (dist * dist + 1.0); + if (light.type == 2u) + atten *= smoothstep(light.spotOuterCos, light.spotInnerCos, + dot(-L, normalize(light.direction))); + return light.color * light.intensity * atten; +} + // ================================================================== // Fog // ================================================================== @@ -238,6 +308,7 @@ void main() { vec3 worldPos = ReconstructWorldPos(fragUV, depth); vec4 ndcPos = vec4(fragUV.x * 2.0 - 1.0, 1.0 - 2.0 * fragUV.y, depth, 1.0); + uint cluster = ClusterIndex(ndcPos.xy, worldPos); vec4 albedoAO = texture(gAlbedoAO, fragUV); vec4 normalRg = texture(gNormalRough, fragUV); vec4 metalEmit = texture(gMetalEmit, fragUV); // R16G16B16A16: .r=metallic, .gba=emissive @@ -289,21 +360,19 @@ void main() { Lo = (kD * albedo / PI + specular) * lightColor * NdotL * shadow; } - // Extra directional lights (fill, rim — no shadows) - for (uint li = 0u; li < uNumExtraLights; ++li) { - vec3 Lx = normalize(-uExtraDir[li].xyz); - vec3 Hx = normalize(V + Lx); - float NdotLx = max(dot(N, Lx), 0.0); - if (NdotLx > 0.0) { - float Dx = DistributionGGX(N, Hx, roughness); - float Gx = GeometrySmith(N, V, Lx, roughness); - vec3 Fx = FresnelSchlick(max(dot(Hx, V), 0.0), F0); - vec3 kSx = Fx; - vec3 kDx = (vec3(1.0) - kSx) * (1.0 - metallic); - vec3 specx = (Dx * Gx * Fx) / (4.0 * NdotV * NdotLx + 0.0001); - vec3 lightColorx = uExtraColor[li].rgb * uExtraColor[li].a; - Lo += (kDx * albedo / PI + specx) * lightColorx * NdotLx; - } + // Extra directional lights, then this pixel's cluster + for (uint li = 0u; li < uClusterGrid.w; ++li) { + vec3 Lx = normalize(-lights[li].direction); + Lo += CookTorrance(N, V, Lx, lights[li].color * lights[li].intensity, + albedo, roughness, metallic, F0, NdotV); + } + uvec2 cell = cells[cluster]; + for (uint i = 0u; i < cell.y; ++i) { + vec3 Lx; + vec3 radiance = LocalLightRadiance(lights[lightIndices[cell.x + i]], + worldPos, Lx); + Lo += CookTorrance(N, V, Lx, radiance, albedo, roughness, + metallic, F0, NdotV); } // Indirect: IBL split-sum approximation @@ -333,10 +402,17 @@ void main() { float shad = CalcShadow(worldPos, N); shad *= smoothstep(-0.15, 0.0, rawNdL); vec3 direct = uLightColor.rgb * uLightColor.a * wrapNdL * shad; - for (uint li = 0u; li < uNumExtraLights; ++li) { - vec3 Lx = normalize(-uExtraDir[li].xyz); + for (uint li = 0u; li < uClusterGrid.w; ++li) { + vec3 Lx = normalize(-lights[li].direction); float NdLx = max(dot(N, Lx), 0.0); - direct += uExtraColor[li].rgb * uExtraColor[li].a * NdLx; + direct += lights[li].color * lights[li].intensity * NdLx; + } + uvec2 cell = cells[cluster]; + for (uint i = 0u; i < cell.y; ++i) { + vec3 Lx; + vec3 radiance = LocalLightRadiance(lights[lightIndices[cell.x + i]], + worldPos, Lx); + direct += radiance * max(dot(N, Lx), 0.0); } ambient = albedo * mix(groundAmbient, skyAmbient, hemisphere) * bakedAO; Lo = albedo * direct; diff --git a/assets/shaders/lighting_pass.frag.spv b/assets/shaders/lighting_pass.frag.spv index 89fcbd76..dda24e0b 100644 Binary files a/assets/shaders/lighting_pass.frag.spv and b/assets/shaders/lighting_pass.frag.spv differ diff --git a/assets/shaders/lighting_pass_gl.frag b/assets/shaders/lighting_pass_gl.frag new file mode 100644 index 00000000..3771c257 --- /dev/null +++ b/assets/shaders/lighting_pass_gl.frag @@ -0,0 +1,287 @@ +#version 450 core + +// Deferred lighting pass (OpenGL). +// Reads the GBuffer (units 8-11), reconstructs world position from depth, +// lights it with the main directional light (cascaded shadows), any extra +// directional lights, and the local lights of the pixel's cluster. + +in vec2 fragUV; +out vec4 outColor; + +layout(binding = 8) uniform sampler2D gbAlbedoAO; +layout(binding = 9) uniform sampler2D gbNormalRough; +layout(binding = 10) uniform sampler2D gbMetalEmit; +layout(binding = 11) uniform sampler2D gbDepth; + +// Blurred R8 SSAO, 1.0 means fully lit +layout(binding = 13) uniform sampler2D gbSSAO; +uniform int uSSAOEnabled; + +layout(binding = 14) uniform samplerCube irradianceMap; +layout(binding = 15) uniform samplerCube prefilterMap; +layout(binding = 16) uniform sampler2D brdfLUT; + +// Shadow cascades, compare sampler bound by the shadow pass +layout(binding = 17) uniform sampler2DArrayShadow shadowCascades; + +struct LightData { + vec3 Position; uint Type; + vec3 Direction; float Intensity; + vec3 Color; float Range; + float SpotInnerCos; float SpotOuterCos; + float AreaWidth; float AreaHeight; +}; + +// Lights[0] is the main directional light when one exists +layout(std140, binding = 2) uniform LightUBO { + vec3 CameraPos; + uint NumActiveLights; + vec3 AmbientColor; + float AmbientIntensity; + vec4 FogColor; // horizon + float FogStart; + float FogEnd; + float _lightPad0, _lightPad1; + LightData Lights[16]; + vec4 FogColorZenith; // zenith + float HeightFogTop; + float HeightFogDensity; + float HeightFogFalloff; + float HeightFogEnabled; +}; + +layout(std140, binding = 5) uniform ShadowUBO { + mat4 ShadowLightVP; + float ShadowBias; + float ShadowStrength; + float ShadowTexelSize; + float ShadowLightSize; + uint PCSSEnabled; + uint ShadowMapEnabled; + float _shadowPad0, _shadowPad1; + mat4 NdcToShadow; + mat4 CascadeVP[4]; + vec4 CascadeSplits; // cascade radius around CameraPos + uint CascadeCount; + float CascadeBlend; // blend band, fraction of the radius +}; + +layout(std140, binding = 6) uniform DeferredCB { + mat4 InvViewProj; + float ScreenWidth; + float ScreenHeight; + float NearPlane; + float FarPlane; +}; + +layout(std140, binding = 10) uniform IBLUBO { + uint IBLEnabled; + float IBLIntensity; + float MaxReflectionLOD; + uint _iblPad0; +}; + +// Clustered lights: lights[] holds ClusterGrid.w extra directional lights, +// then the local lights that cells[] (offset, count) index into. +layout(std140, binding = 7) uniform ClusterParams { + vec4 ViewZ; // view depth = dot(xyz, worldPos) + w + uvec4 ClusterGrid; // xyz = tiles x, tiles y, slices; w = globals + float ClusterZScale; // slice = log(depth) * scale + bias + float ClusterZBias; + uint LocalLightCount; +}; +layout(std430, binding = 0) readonly buffer ClusterLights { + LightData lights[]; +}; +layout(std430, binding = 1) readonly buffer ClusterCells { + uvec2 cells[]; +}; +layout(std430, binding = 2) readonly buffer ClusterLightIndices { + uint lightIndices[]; +}; + +const float PI = 3.14159265359; + +const vec2 poissonDisk[16] = vec2[]( + vec2(-0.94201624, -0.39906216), vec2( 0.94558609, -0.76890725), + vec2(-0.09418410, -0.92938870), vec2( 0.34495938, 0.29387760), + vec2(-0.91588581, 0.45771432), vec2(-0.81544232, -0.87912464), + vec2(-0.38277543, 0.27676845), vec2( 0.97484398, 0.75648379), + vec2( 0.44323325, -0.97511554), vec2( 0.53742981, -0.47373420), + vec2(-0.26496911, -0.41893023), vec2( 0.79197514, 0.19090188), + vec2(-0.24188840, 0.99706507), vec2(-0.81409955, 0.91437590), + vec2( 0.19984126, 0.78641367), vec2( 0.14383161, -0.14100790) +); + +vec3 FresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) { + return F0 + (max(vec3(1.0 - roughness), F0) - F0) + * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); +} + +// ---- Shadow ---- +float CascadeShadow(uint c, vec3 worldPos) { + vec4 sc = CascadeVP[c] * vec4(worldPos, 1.0); + vec3 proj = sc.xyz / sc.w; + proj.xy = proj.xy * 0.5 + 0.5; + proj.z = proj.z * 0.5 + 0.5; // [-1,1] -> [0,1] for OpenGL + + if (proj.z > 1.0 || any(lessThan(proj.xy, vec2(0.0))) || + any(greaterThan(proj.xy, vec2(1.0)))) + return 1.0; + + float shadow = 0.0; + for (int i = 0; i < 16; ++i) { + vec2 uv = proj.xy + poissonDisk[i] * ShadowTexelSize * 5.0; + shadow += texture(shadowCascades, + vec4(uv, float(c), proj.z - ShadowBias)); + } + return shadow / 16.0; +} + +// Picks the cascade by distance from the camera and blends into the next one +// (or out to unshadowed after the last) across the band at its outer edge. +float CalcShadow(vec3 worldPos, float NdotL) { + if (ShadowMapEnabled == 0u || CascadeCount == 0u) return 1.0; + float dist = length(worldPos - CameraPos); + uint c = 0u; + while (c + 1u < CascadeCount && dist > CascadeSplits[c]) ++c; + + float outer = CascadeSplits[c]; + if (dist >= outer) return 1.0; + float t = smoothstep(outer * (1.0 - CascadeBlend), outer, dist); + float shadow = CascadeShadow(c, worldPos); + if (t > 0.0) { + float next = (c + 1u < CascadeCount) ? CascadeShadow(c + 1u, worldPos) + : 1.0; + shadow = mix(shadow, next, t); + } + shadow *= smoothstep(0.0, 0.15, NdotL); + return mix(1.0, shadow, ShadowStrength); +} + +// ---- Clustered lights ---- +uint ClusterIndex(vec2 ndcXY, vec3 worldPos) { + float viewZ = dot(ViewZ.xyz, worldPos) + ViewZ.w; + float slice = log(max(viewZ, 1e-3)) * ClusterZScale + ClusterZBias; + uint z = uint(clamp(slice, 0.0, float(ClusterGrid.z - 1u))); + vec2 tile = clamp((ndcXY * 0.5 + 0.5) * vec2(ClusterGrid.xy), + vec2(0.0), vec2(ClusterGrid.xy) - 1.0); + return (z * ClusterGrid.y + uint(tile.y)) * ClusterGrid.x + uint(tile.x); +} + +// Windowed inverse-square falloff that reaches zero at the light's range, +// which is what lets the CPU cull it per cluster. Spots add the cone. +vec3 LocalLightRadiance(LightData light, vec3 worldPos, out vec3 L) { + vec3 toLight = light.Position - worldPos; + float dist = length(toLight); + L = toLight / max(dist, 1e-4); + float d = dist / max(light.Range, 1e-4); + float window = clamp(1.0 - d * d * d * d, 0.0, 1.0); + float atten = window * window / (dist * dist + 1.0); + if (light.Type == 2u) + atten *= smoothstep(light.SpotOuterCos, light.SpotInnerCos, + dot(-L, normalize(light.Direction))); + return light.Color * light.Intensity * atten; +} + +// OpenGL: no vertex Y-flip and [-1,1] clip depth, so NDC.z = 2*depth-1. +vec3 ReconstructWorldPos(vec2 uv, float depth) { + vec4 ndc = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + vec4 world = InvViewProj * ndc; + return world.xyz / world.w; +} + +vec3 ACESFilm(vec3 x) { + return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), + 0.0, 1.0); +} + +vec3 ApplyFog(vec3 color, vec3 worldPos) { + if (FogEnd <= 0.0) return color; + vec3 toFrag = worldPos - CameraPos; + float dist = length(toFrag.xz); + float distFog = clamp((dist - FogStart) / max(FogEnd - FogStart, 1e-4), + 0.0, 1.0); + float heightFog = 0.0; + if (HeightFogEnabled > 0.5) { + float h = max(HeightFogTop - worldPos.y, 0.0); + heightFog = clamp(HeightFogDensity * (1.0 - exp(-h * HeightFogFalloff)), + 0.0, 1.0); + } + float fogAmount = 1.0 - (1.0 - distFog) * (1.0 - heightFog); + vec3 viewDir = normalize(toFrag); + float t = smoothstep(0.0, 1.0, clamp(viewDir.y * 0.5 + 0.5, 0.0, 1.0)); + vec3 fogColor = mix(FogColor.rgb, FogColorZenith.rgb, t); + return mix(color, fogColor, fogAmount); +} + +void main() { + vec4 albedoAO = texture(gbAlbedoAO, fragUV); + vec4 normalRough = texture(gbNormalRough, fragUV); + vec4 metalEmit = texture(gbMetalEmit, fragUV); + float depth = texture(gbDepth, fragUV).r; + + // Sky: leave depth at 1.0 for the skybox pass + if (depth >= 1.0) discard; + + // The forward transparent pass depth-tests against this + gl_FragDepth = depth; + + vec3 albedo = albedoAO.rgb; + float ao = albedoAO.a; + if (uSSAOEnabled != 0) + ao *= 0.5 + 0.5 * texture(gbSSAO, fragUV).r; + vec3 N = normalize(normalRough.rgb * 2.0 - 1.0); + float roughness = normalRough.a; + float metallic = metalEmit.r; + float emitScale = metalEmit.g; + + vec3 worldPos = ReconstructWorldPos(fragUV, depth); + + // Wrap diffuse from the main directional light, plain Lambert elsewhere + vec3 totalDiffuse = vec3(0.0); + if (NumActiveLights > 0u && Lights[0].Type == 0u) { + vec3 L = normalize(-Lights[0].Direction); + float rawNdotL = dot(N, L); + float NdotL = clamp(rawNdotL * 0.85 + 0.15, 0.0, 1.0); + float shadow = CalcShadow(worldPos, max(rawNdotL, 0.0)); + totalDiffuse += Lights[0].Color * Lights[0].Intensity * NdotL * shadow; + } + for (uint i = 0u; i < ClusterGrid.w; ++i) { + vec3 L = normalize(-lights[i].Direction); + totalDiffuse += lights[i].Color * lights[i].Intensity * + max(dot(N, L), 0.0); + } + uvec2 cell = cells[ClusterIndex(fragUV * 2.0 - 1.0, worldPos)]; + for (uint i = 0u; i < cell.y; ++i) { + vec3 L; + vec3 radiance = LocalLightRadiance(lights[lightIndices[cell.x + i]], + worldPos, L); + totalDiffuse += radiance * max(dot(N, L), 0.0); + } + + // Hemisphere ambient, with IBL added on top when it is enabled + vec3 V = normalize(CameraPos - worldPos); + vec3 F0 = mix(vec3(0.04), albedo, metallic); + vec3 skyAmbient = AmbientColor * AmbientIntensity; + vec3 groundAmbient = skyAmbient * vec3(0.55, 0.50, 0.45); + vec3 ambient = albedo * mix(groundAmbient, skyAmbient, N.y * 0.5 + 0.5); + + if (IBLEnabled != 0u) { + float NdotV = max(dot(N, V), 0.0); + vec3 kS = FresnelSchlickRoughness(NdotV, F0, roughness); + vec3 kD = (1.0 - kS) * (1.0 - metallic); + vec3 irradiance = texture(irradianceMap, N).rgb; + vec3 prefiltered = textureLod(prefilterMap, reflect(-V, N), + roughness * MaxReflectionLOD).rgb; + vec2 envBRDF = texture(brdfLUT, vec2(NdotV, roughness)).rg; + ambient += (kD * irradiance * albedo + + prefiltered * (kS * envBRDF.x + envBRDF.y)) * IBLIntensity; + } + ambient *= ao; + + vec3 color = ambient + albedo * totalDiffuse + albedo * emitScale; + color = ACESFilm(color); + color = ApplyFog(color, worldPos); + outColor = vec4(color, 1.0); +} diff --git a/include/private/Graphics/Common/ConstantBuffer.hpp b/include/private/Graphics/Common/ConstantBuffer.hpp index 66d99136..abd78d42 100644 --- a/include/private/Graphics/Common/ConstantBuffer.hpp +++ b/include/private/Graphics/Common/ConstantBuffer.hpp @@ -47,6 +47,16 @@ namespace Sleak { /// Upper bound on directional shadow cascades (array layers). static constexpr uint32_t MAX_SHADOW_CASCADES = 4; + /// Clustered lighting grid (screen tiles x tiles x depth slices). + static constexpr uint32_t CLUSTER_GRID_X = 16; + static constexpr uint32_t CLUSTER_GRID_Y = 9; + static constexpr uint32_t CLUSTER_GRID_Z = 24; + static constexpr uint32_t CLUSTER_COUNT = + CLUSTER_GRID_X * CLUSTER_GRID_Y * CLUSTER_GRID_Z; + /// Capacity of the clustered light list and its per-cluster indices. + static constexpr uint32_t MAX_CLUSTERED_LIGHTS = 1024; + static constexpr uint32_t MAX_CLUSTER_LIGHT_INDICES = 1u << 18; + /// Full per-frame lighting UBO: camera, ambient, fog, and the packed light array. struct alignas(16) LightCBData { // Header Row 0: CameraPos.xyz + NumActiveLights @@ -75,6 +85,19 @@ namespace Sleak { float HeightFogEnabled; // 0 = off, 1 = on }; + /// Lookup parameters for the clustered light grid. The light list + /// holds GlobalLightCount directional lights followed by the local + /// lights the clusters index into. + struct alignas(16) ClusterParamsGPU { + float ViewZ[4]; // view depth = dot(xyz, worldPos) + w + uint32_t GridX, GridY, GridZ; + uint32_t GlobalLightCount; + float ZScale; // slice = log(depth) * ZScale + ZBias + float ZBias; + uint32_t LocalLightCount; + float _pad; + }; + /// Interface for a CPU-side payload that can be uploaded to a GPU constant buffer. struct ConstantBuffer { public: diff --git a/include/private/Graphics/Common/Renderer.hpp b/include/private/Graphics/Common/Renderer.hpp index e4248696..6103d16f 100644 --- a/include/private/Graphics/Common/Renderer.hpp +++ b/include/private/Graphics/Common/Renderer.hpp @@ -135,6 +135,21 @@ class ENGINE_API Renderer { // Shadow mapping support (overridden by VulkanRenderer) virtual void UpdateShadowLightUBO(const void* data, uint32_t size) { (void)data; (void)size; } virtual void SetLightVP(const float* lightVP) { (void)lightVP; } + /// True when the deferred lighting pass reads UpdateClusteredLights data. + virtual bool SupportsClusteredLights() const { return false; } + /// Uploads the light list, per-cluster (offset, count) pairs, and the + /// flat light index list the deferred lighting pass reads. + virtual void UpdateClusteredLights(const ClusterParamsGPU& params, + const LightGPUEntry* lights, + const uint32_t* cells, + const uint32_t* indices, + uint32_t indexCount) { + (void)params; + (void)lights; + (void)cells; + (void)indices; + (void)indexCount; + } /// Stages one light view-projection per cascade for the next shadow pass. virtual void SetShadowCascades(const float* viewProj, uint32_t count) { (void)viewProj; diff --git a/include/private/Graphics/OpenGL/OpenGLRenderer.hpp b/include/private/Graphics/OpenGL/OpenGLRenderer.hpp index a7610b32..e1d5fc85 100644 --- a/include/private/Graphics/OpenGL/OpenGLRenderer.hpp +++ b/include/private/Graphics/OpenGL/OpenGLRenderer.hpp @@ -129,6 +129,13 @@ class ENGINE_API OpenGLRenderer : public Renderer, public RenderContext { void SetLightVP(const float* mat) override; /// Stages the per-cascade light view-projections for the next BeginRender. void SetShadowCascades(const float* viewProj, uint32_t count) override; + bool SupportsClusteredLights() const override { return true; } + /// Uploads the clustered light data: params UBO 7, light SSBOs 0-2. + void UpdateClusteredLights(const ClusterParamsGPU& params, + const LightGPUEntry* lights, + const uint32_t* cells, const uint32_t* indices, + uint32_t indexCount) override; + GLuint m_clusterBuffers[4] = {}; void UpdateShadowLightUBO(const void* data, uint32_t size) override; /// Drops the shadow map so it is rebuilt with the queued settings. void ApplyShadowResolutionChange() override; diff --git a/include/private/Graphics/Vulkan/VulkanRenderer.hpp b/include/private/Graphics/Vulkan/VulkanRenderer.hpp index 7941745e..c92a3057 100644 --- a/include/private/Graphics/Vulkan/VulkanRenderer.hpp +++ b/include/private/Graphics/Vulkan/VulkanRenderer.hpp @@ -184,6 +184,12 @@ class ENGINE_API VulkanRenderer : public Renderer, public RenderContext { void SetLightVP(const float* lightVP) override; /// Stages the per-cascade light view-projections for the next BeginRender. void SetShadowCascades(const float* viewProj, uint32_t count) override; + bool SupportsClusteredLights() const override { return true; } + /// Writes the clustered light data into the current frame's buffers. + void UpdateClusteredLights(const ClusterParamsGPU& params, + const LightGPUEntry* lights, + const uint32_t* cells, const uint32_t* indices, + uint32_t indexCount) override; // Deferred rendering overrides virtual bool IsDeferredEnabled() const override { return m_deferredEnabled && m_gbufferResourcesCreated; } @@ -273,6 +279,12 @@ class ENGINE_API VulkanRenderer : public Renderer, public RenderContext { void WriteShadowSamplerDescriptors(); /// Renders the cached shadow casters into every cascade layer. void RecordShadowPass(); + + // Clustered lights + /// Creates the per-frame cluster buffers and their set-4 descriptors. + bool CreateClusterResources(); + /// Destroys the cluster buffers, layout, and pool. + void CleanupClusterResources(); /// Creates the Vulkan instance with validation layers when available. bool InitVulkan(); /// Creates the SDL-backed Vulkan presentation surface. @@ -527,6 +539,18 @@ class ENGINE_API VulkanRenderer : public Renderer, public RenderContext { uint32_t m_cascadeCount = 0; uint32_t m_pendingCascadeCount = 0; + // Clustered lights (lighting pass set 4): params UBO, light list, + // per-cluster (offset, count) and the flat index list, one set per frame. + struct ClusterFrameBuffers { + VkBuffer buffers[4] = {}; + VmaAllocation allocations[4] = {}; + void* mapped[4] = {}; + }; + VkDescriptorSetLayout m_clusterDSL = VK_NULL_HANDLE; + VkDescriptorPool m_clusterPool = VK_NULL_HANDLE; + std::array m_clusterSets = {}; + std::array m_clusterBuffers = {}; + // Light/Shadow UBO (set 2, binding 0) VkDescriptorSetLayout m_lightUBODescriptorSetLayout = VK_NULL_HANDLE; VkDescriptorPool m_lightUBODescriptorPool = VK_NULL_HANDLE; diff --git a/include/public/Lighting/LightManager.hpp b/include/public/Lighting/LightManager.hpp index 02887700..453043bd 100644 --- a/include/public/Lighting/LightManager.hpp +++ b/include/public/Lighting/LightManager.hpp @@ -9,6 +9,7 @@ namespace Sleak { class Light; + class DirectionalLight; namespace RenderEngine { class BufferBase; @@ -58,6 +59,8 @@ namespace Sleak { public: LightManager(); ~LightManager(); + LightManager(const LightManager&) = delete; + LightManager& operator=(const LightManager&) = delete; /// Allocates the GPU light buffer; call once before the first UpdateAndBind. void Initialize(); @@ -85,6 +88,9 @@ namespace Sleak { return m_lights.GetSize(); } + /// Registered point, spot and area lights (culled per cluster). + size_t GetLocalLightCount() const { return m_localLights.GetSize(); } + void SetFogColor(float r, float g, float b) { m_fogR = r; m_fogG = g; m_fogB = b; } @@ -147,6 +153,19 @@ namespace Sleak { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; bool m_hasPrevLightVP = false; + + struct ClusterScratch; + + /// First enabled shadow-casting directional light, else first enabled. + DirectionalLight* FindMainDirectionalLight(bool& castsShadow) const; + /// Orders the enabled local lights by distance to the camera. + void SortLocalLights(const Math::Vector3D& camPos); + /// Builds the cluster grid over the sorted local lights. + void UpdateClusters(const DirectionalLight* mainLight); + + List m_directionalLights; + List m_localLights; + ClusterScratch* m_clusters = nullptr; // owned }; } // namespace Sleak diff --git a/src/Graphics/OpenGL/OpenGLRenderer.cpp b/src/Graphics/OpenGL/OpenGLRenderer.cpp index 65fa6334..46cbeadb 100644 --- a/src/Graphics/OpenGL/OpenGLRenderer.cpp +++ b/src/Graphics/OpenGL/OpenGLRenderer.cpp @@ -196,6 +196,8 @@ void OpenGLRenderer::Cleanup() { if (m_shadowTransformUBO) { glDeleteBuffers(1, &m_shadowTransformUBO); m_shadowTransformUBO = 0; } if (m_shadowUBO) { glDeleteBuffers(1, &m_shadowUBO); m_shadowUBO = 0; } DestroyShadowMapResources(); + if (m_clusterBuffers[0]) glDeleteBuffers(4, m_clusterBuffers); + std::fill(std::begin(m_clusterBuffers), std::end(m_clusterBuffers), 0u); m_shadowUBOCreated = false; CleanupMSAAFramebuffer(); if (bImInitialized) { @@ -660,6 +662,48 @@ void OpenGLRenderer::SetShadowCascades(const float* viewProj, uint32_t count) { m_pendingCascadeCount = count; } +void OpenGLRenderer::UpdateClusteredLights(const ClusterParamsGPU& params, + const LightGPUEntry* lights, + const uint32_t* cells, + const uint32_t* indices, + uint32_t indexCount) { + const GLsizeiptr sizes[4] = { + sizeof(ClusterParamsGPU), + sizeof(LightGPUEntry) * MAX_CLUSTERED_LIGHTS, + sizeof(uint32_t) * 2 * CLUSTER_COUNT, + sizeof(uint32_t) * MAX_CLUSTER_LIGHT_INDICES, + }; + if (m_clusterBuffers[0] == 0) { + glGenBuffers(4, m_clusterBuffers); + for (int i = 0; i < 4; ++i) { + glBindBuffer(GL_COPY_WRITE_BUFFER, m_clusterBuffers[i]); + glBufferData(GL_COPY_WRITE_BUFFER, sizes[i], nullptr, + GL_DYNAMIC_DRAW); + } + } + + const uint32_t lightCount = std::min( + params.GlobalLightCount + params.LocalLightCount, MAX_CLUSTERED_LIGHTS); + indexCount = std::min(indexCount, MAX_CLUSTER_LIGHT_INDICES); + const GLsizeiptr used[4] = { + sizes[0], + static_cast(sizeof(LightGPUEntry) * lightCount), + sizes[2], + static_cast(sizeof(uint32_t) * indexCount), + }; + const void* data[4] = {¶ms, lights, cells, indices}; + for (int i = 0; i < 4; ++i) { + if (used[i] == 0) continue; + glBindBuffer(GL_COPY_WRITE_BUFFER, m_clusterBuffers[i]); + glBufferSubData(GL_COPY_WRITE_BUFFER, 0, used[i], data[i]); + } + glBindBuffer(GL_COPY_WRITE_BUFFER, 0); + + glBindBufferBase(GL_UNIFORM_BUFFER, 7, m_clusterBuffers[0]); + for (GLuint i = 1; i < 4; ++i) + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, i - 1, m_clusterBuffers[i]); +} + void OpenGLRenderer::ApplyShadowResolutionChange() { m_shadowResChangeRequested = false; if (m_pendingShadowMapResolution == m_shadowMapResolution && diff --git a/src/Graphics/Vulkan/VulkanClusteredLights.cpp b/src/Graphics/Vulkan/VulkanClusteredLights.cpp new file mode 100644 index 00000000..6f93b83c --- /dev/null +++ b/src/Graphics/Vulkan/VulkanClusteredLights.cpp @@ -0,0 +1,175 @@ +#include +#include +#include + +#include "../../include/private/Graphics/Vulkan/VulkanRenderer.hpp" +#include "Core/Logger.hpp" + +namespace Sleak { +namespace RenderEngine { + +namespace { + +enum ClusterBinding : uint32_t { + kParams = 0, + kLights = 1, + kCells = 2, + kIndices = 3, +}; + +constexpr VkDeviceSize kClusterBufferSizes[4] = { + sizeof(ClusterParamsGPU), + sizeof(LightGPUEntry) * MAX_CLUSTERED_LIGHTS, + sizeof(uint32_t) * 2 * CLUSTER_COUNT, + sizeof(uint32_t) * MAX_CLUSTER_LIGHT_INDICES, +}; + +} // namespace + +/// Creates the per-frame cluster buffers and the set-4 layout and descriptors. +bool VulkanRenderer::CreateClusterResources() { + std::array bindings{}; + for (uint32_t b = 0; b < bindings.size(); ++b) { + bindings[b].binding = b; + bindings[b].descriptorType = b == kParams + ? VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER + : VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + bindings[b].descriptorCount = 1; + bindings[b].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + } + + VkDescriptorSetLayoutCreateInfo dslInfo{}; + dslInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + dslInfo.bindingCount = static_cast(bindings.size()); + dslInfo.pBindings = bindings.data(); + if (vkCreateDescriptorSetLayout(device, &dslInfo, nullptr, &m_clusterDSL) != + VK_SUCCESS) { + SLEAK_ERROR("Clustered lights: failed to create descriptor set layout"); + return false; + } + + std::array poolSizes{}; + poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSizes[0].descriptorCount = MAX_FRAMES_IN_FLIGHT; + poolSizes[1].type = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + poolSizes[1].descriptorCount = MAX_FRAMES_IN_FLIGHT * 3; + + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.poolSizeCount = static_cast(poolSizes.size()); + poolInfo.pPoolSizes = poolSizes.data(); + poolInfo.maxSets = MAX_FRAMES_IN_FLIGHT; + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &m_clusterPool) != + VK_SUCCESS) { + SLEAK_ERROR("Clustered lights: failed to create descriptor pool"); + return false; + } + + std::array layouts; + layouts.fill(m_clusterDSL); + VkDescriptorSetAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocInfo.descriptorPool = m_clusterPool; + allocInfo.descriptorSetCount = MAX_FRAMES_IN_FLIGHT; + allocInfo.pSetLayouts = layouts.data(); + if (vkAllocateDescriptorSets(device, &allocInfo, m_clusterSets.data()) != + VK_SUCCESS) { + SLEAK_ERROR("Clustered lights: failed to allocate descriptor sets"); + return false; + } + + for (uint32_t f = 0; f < MAX_FRAMES_IN_FLIGHT; ++f) { + auto& frame = m_clusterBuffers[f]; + std::array infos{}; + std::array writes{}; + for (uint32_t b = 0; b < 4; ++b) { + VkBufferCreateInfo bufferInfo{}; + bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufferInfo.size = kClusterBufferSizes[b]; + bufferInfo.usage = b == kParams + ? VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT + : VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_AUTO; + allocCI.flags = + VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT | + VMA_ALLOCATION_CREATE_MAPPED_BIT; + allocCI.requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + + VmaAllocationInfo info{}; + if (vmaCreateBuffer(VulkanBuffer::GetAllocator(), &bufferInfo, + &allocCI, &frame.buffers[b], + &frame.allocations[b], &info) != VK_SUCCESS) { + SLEAK_ERROR("Clustered lights: failed to create buffer {}", b); + return false; + } + frame.mapped[b] = info.pMappedData; + std::memset(frame.mapped[b], 0, kClusterBufferSizes[b]); + + infos[b].buffer = frame.buffers[b]; + infos[b].range = kClusterBufferSizes[b]; + writes[b].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[b].dstSet = m_clusterSets[f]; + writes[b].dstBinding = b; + writes[b].descriptorCount = 1; + writes[b].descriptorType = bindings[b].descriptorType; + writes[b].pBufferInfo = &infos[b]; + } + vkUpdateDescriptorSets(device, static_cast(writes.size()), + writes.data(), 0, nullptr); + } + + SLEAK_INFO("VulkanRenderer: Clustered light buffers created ({}x{}x{})", + CLUSTER_GRID_X, CLUSTER_GRID_Y, CLUSTER_GRID_Z); + return true; +} + +/// Destroys the cluster buffers, layout, and pool. +void VulkanRenderer::CleanupClusterResources() { + for (auto& frame : m_clusterBuffers) { + for (uint32_t b = 0; b < 4; ++b) { + if (frame.buffers[b]) + vmaDestroyBuffer(VulkanBuffer::GetAllocator(), frame.buffers[b], + frame.allocations[b]); + frame.buffers[b] = VK_NULL_HANDLE; + frame.allocations[b] = VK_NULL_HANDLE; + frame.mapped[b] = nullptr; + } + } + if (m_clusterPool) { + vkDestroyDescriptorPool(device, m_clusterPool, nullptr); + m_clusterPool = VK_NULL_HANDLE; + } + if (m_clusterDSL) { + vkDestroyDescriptorSetLayout(device, m_clusterDSL, nullptr); + m_clusterDSL = VK_NULL_HANDLE; + } +} + +/// Writes the clustered light data into the current frame's buffers. Like the +/// light UBO this runs after BeginRender waited on the frame's fence. +void VulkanRenderer::UpdateClusteredLights(const ClusterParamsGPU& params, + const LightGPUEntry* lights, + const uint32_t* cells, + const uint32_t* indices, + uint32_t indexCount) { + auto& frame = m_clusterBuffers[currentFrame]; + if (!frame.mapped[kParams]) return; + + const uint32_t lightCount = std::min( + params.GlobalLightCount + params.LocalLightCount, MAX_CLUSTERED_LIGHTS); + indexCount = std::min(indexCount, MAX_CLUSTER_LIGHT_INDICES); + + std::memcpy(frame.mapped[kParams], ¶ms, sizeof(params)); + std::memcpy(frame.mapped[kLights], lights, + lightCount * sizeof(LightGPUEntry)); + std::memcpy(frame.mapped[kCells], cells, + CLUSTER_COUNT * 2 * sizeof(uint32_t)); + std::memcpy(frame.mapped[kIndices], indices, indexCount * sizeof(uint32_t)); +} + +} // namespace RenderEngine +} // namespace Sleak diff --git a/src/Graphics/Vulkan/VulkanDeferred.cpp b/src/Graphics/Vulkan/VulkanDeferred.cpp index fed7e7ed..bd93366f 100644 --- a/src/Graphics/Vulkan/VulkanDeferred.cpp +++ b/src/Graphics/Vulkan/VulkanDeferred.cpp @@ -513,12 +513,9 @@ bool VulkanRenderer::CreateLightingPipeline() { // Set 1: m_deferredCBDSL (1 UBO: InvViewProj + screen size) // Set 2: m_lightUBODescriptorSetLayout (1 UBO: directional light + shadow + fog) // Set 3: m_iblDSL (3 samplerCubes + 1 sampler2D + 1 UBO: IBL) - std::array setLayouts = { - m_gbufferSamplerDSL, - m_deferredCBDSL, - m_lightUBODescriptorSetLayout, - m_iblDSL - }; + std::array setLayouts = { + m_gbufferSamplerDSL, m_deferredCBDSL, m_lightUBODescriptorSetLayout, + m_iblDSL, m_clusterDSL}; VkPipelineLayoutCreateInfo layoutInfo{}; layoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; @@ -1180,15 +1177,14 @@ void VulkanRenderer::ExecuteDeferredLightingPass() { // set 1: DeferredCB (InvViewProj + screen size) // set 2: LightUBO (directional light + shadow params + fog) // set 3: IBL (irradiance + prefilter + BRDF LUT + settings) - VkDescriptorSet lightingSets[4] = { - m_gbufferSamplerSets[currentFrame], - m_deferredCBSets[currentFrame], - m_lightUBODescriptorSets[currentFrame], - m_iblSets[currentFrame] - }; + // set 4: clustered lights + VkDescriptorSet lightingSets[5] = { + m_gbufferSamplerSets[currentFrame], m_deferredCBSets[currentFrame], + m_lightUBODescriptorSets[currentFrame], m_iblSets[currentFrame], + m_clusterSets[currentFrame]}; vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, - m_lightingPipelineLayout, 0, 4, - lightingSets, 0, nullptr); + m_lightingPipelineLayout, 0, 5, lightingSets, 0, + nullptr); // 7. Fullscreen triangle draw (3 vertices, no VBO) vkCmdDraw(command, 3, 1, 0, 0); diff --git a/src/Graphics/Vulkan/VulkanRenderer.cpp b/src/Graphics/Vulkan/VulkanRenderer.cpp index 717e32bc..d1f23515 100644 --- a/src/Graphics/Vulkan/VulkanRenderer.cpp +++ b/src/Graphics/Vulkan/VulkanRenderer.cpp @@ -133,6 +133,9 @@ bool VulkanRenderer::Initialize() { if (!CreateShadowResources()) SLEAK_WARN("Failed to create shadow mapping resources — shadows disabled"); + if (!CreateClusterResources()) + SLEAK_RETURN_ERR("Failed to create clustered light buffers!"); + if (!CreateFrameBuffer()) SLEAK_RETURN_ERR("Failed to create framebuffer of renderer!"); @@ -1046,6 +1049,7 @@ void VulkanRenderer::Cleanup() { // Destroy shadow mapping resources CleanupShadowResources(); + CleanupClusterResources(); // Backstop: free any shader modules whose resource-guarded cleanup was // skipped (guard false while shader non-null). Cleanups null after delete, diff --git a/src/Lighting/LightManager.cpp b/src/Lighting/LightManager.cpp index 70de20ff..1920d033 100644 --- a/src/Lighting/LightManager.cpp +++ b/src/Lighting/LightManager.cpp @@ -129,13 +129,42 @@ void BuildShadowCascades(const Sleak::DirectionalLight& light, std::memcpy(outVP[i], &vp(0, 0), sizeof(float) * 16); } } + +/// Projection near and far planes of the main camera. +void MainCameraNearFar(float& nearP, float& farP) { + const auto& P = Sleak::Camera::GetMainProjectionMatrix(); + nearP = MainCameraNear(); + farP = (P(2, 2) > 1.0f) ? P(2, 2) * nearP / (P(2, 2) - 1.0f) : 1000.0f; +} + +/// Inclusive [lo, hi] tile range covered by an NDC interval. +void NdcToTiles(float ndcMin, float ndcMax, uint32_t tiles, uint32_t& lo, + uint32_t& hi) { + auto toTile = [tiles](float ndc) { + float t = (ndc * 0.5f + 0.5f) * static_cast(tiles); + return static_cast( + std::clamp(t, 0.0f, static_cast(tiles - 1))); + }; + lo = toTile(ndcMin); + hi = toTile(ndcMax); +} } // namespace namespace Sleak { -LightManager::LightManager() = default; +/// Per-frame buffers reused by the light sort and the cluster build. +struct LightManager::ClusterScratch { + std::vector> sortedLocal; + std::vector gpuLights; + std::vector pairs; // cluster << 10 | light + std::vector cells; // (offset, count) per cluster + std::vector indices; + bool warnedOverflow = false; +}; + +LightManager::LightManager() : m_clusters(new ClusterScratch()) {} -LightManager::~LightManager() = default; +LightManager::~LightManager() { delete m_clusters; } void LightManager::Initialize() { if (!m_lightBuffer) { @@ -152,23 +181,59 @@ void LightManager::RegisterLight(Light* light) { if (!light) return; if (m_lights.indexOf(light) != -1) return; - if (m_lights.GetSize() >= RenderEngine::MAX_LIGHTS) { - SLEAK_WARN( - "Maximum light count ({}) reached, cannot register " - "light '{}'", - RenderEngine::MAX_LIGHTS, light->GetName()); - return; - } - m_lights.add(light); + if (auto* dir = dynamic_cast(light)) + m_directionalLights.add(dir); + else + m_localLights.add(light); } void LightManager::UnregisterLight(Light* light) { if (!light) return; int index = m_lights.indexOf(light); - if (index != -1) { - m_lights.erase(index); + if (index == -1) return; + m_lights.erase(index); + + for (size_t i = 0; i < m_directionalLights.GetSize(); ++i) { + if (m_directionalLights[i] == light) { + m_directionalLights.erase(static_cast(i)); + return; + } + } + int local = m_localLights.indexOf(light); + if (local != -1) m_localLights.erase(local); +} + +DirectionalLight* LightManager::FindMainDirectionalLight( + bool& castsShadow) const { + DirectionalLight* firstEnabled = nullptr; + for (size_t i = 0; i < m_directionalLights.GetSize(); ++i) { + DirectionalLight* light = m_directionalLights[i]; + if (!light->IsEnabled()) continue; + if (light->GetCastShadows()) { + castsShadow = true; + return light; + } + if (!firstEnabled) firstEnabled = light; } + castsShadow = false; + return firstEnabled; +} + +void LightManager::SortLocalLights(const Math::Vector3D& camPos) { + auto& sorted = m_clusters->sortedLocal; + sorted.clear(); + for (size_t i = 0; i < m_localLights.GetSize(); ++i) { + Light* light = m_localLights[i]; + if (!light->IsEnabled()) continue; + const auto gpu = light->BuildGPUData(); + const float dx = gpu.PositionX - camPos.GetX(); + const float dy = gpu.PositionY - camPos.GetY(); + const float dz = gpu.PositionZ - camPos.GetZ(); + sorted.emplace_back(dx * dx + dy * dy + dz * dz, light); + } + std::sort(sorted.begin(), sorted.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); } void LightManager::UpdateAndBind() { @@ -215,17 +280,23 @@ void LightManager::UpdateAndBind() { cbData.HeightFogEnabled = 0.0f; } - // Collect active lights - uint32_t count = 0; - for (size_t i = 0; - i < m_lights.GetSize() && count < RenderEngine::MAX_LIGHTS; - ++i) { - Light* light = m_lights[i]; - if (!light || !light->IsEnabled()) continue; + // Forward list: the main directional light first (shaders shadow slot + // 0), the other directional lights, then the nearest local lights. + bool mainCastsShadow = false; + DirectionalLight* mainLight = FindMainDirectionalLight(mainCastsShadow); + SortLocalLights(camPos); - cbData.Lights[count] = light->BuildGPUData(); - ++count; + uint32_t count = 0; + auto pushForward = [&](const Light* light) { + if (count < RenderEngine::MAX_LIGHTS) + cbData.Lights[count++] = light->BuildGPUData(); + }; + if (mainLight) pushForward(mainLight); + for (size_t i = 0; i < m_directionalLights.GetSize(); ++i) { + DirectionalLight* light = m_directionalLights[i]; + if (light != mainLight && light->IsEnabled()) pushForward(light); } + for (const auto& entry : m_clusters->sortedLocal) pushForward(entry.second); cbData.NumActiveLights = count; // Update and bind at slot 2 @@ -237,6 +308,8 @@ void LightManager::UpdateAndBind() { // Update deferred CB (InvViewProj + screen size) for the lighting pass UpdateDeferredCB(); + + UpdateClusters(mainLight); } void LightManager::UpdateShadowData() { @@ -245,40 +318,13 @@ void LightManager::UpdateShadowData() { auto* renderer = app->GetRenderer(); if (!renderer) return; - // Find first shadow-casting directional light - DirectionalLight* shadowLight = nullptr; - for (size_t i = 0; i < m_lights.GetSize(); ++i) { - Light* light = m_lights[i]; - if (!light || !light->IsEnabled() || !light->GetCastShadows()) continue; - - auto* dirLight = dynamic_cast(light); - if (dirLight) { - shadowLight = dirLight; - break; - } - } - - // Find first enabled directional light (regardless of shadow casting) - // for populating light/ambient data in the UBO - DirectionalLight* anyDirLight = nullptr; - if (!shadowLight) { - for (size_t i = 0; i < m_lights.GetSize(); ++i) { - Light* light = m_lights[i]; - if (!light || !light->IsEnabled()) continue; - auto* dirLight = dynamic_cast(light); - if (dirLight) { - anyDirLight = dirLight; - break; - } - } - } + bool castsShadow = false; + DirectionalLight* activeLight = FindMainDirectionalLight(castsShadow); + DirectionalLight* shadowLight = castsShadow ? activeLight : nullptr; // Tell the renderer whether the shadow pass should run renderer->SetShadowPassEnabled(shadowLight != nullptr); - // Use shadow light if available, otherwise fall back to any directional light - DirectionalLight* activeLight = shadowLight ? shadowLight : anyDirLight; - if (!activeLight) { static bool warned = false; if (!warned) { SLEAK_WARN("UpdateShadowData: No directional light found!"); warned = true; } @@ -422,12 +468,10 @@ void LightManager::UpdateShadowData() { // Populate extra lights (fill, rim — non-shadow directional lights) ubo.NumExtraLights = 0; - for (size_t i = 0; i < m_lights.GetSize() && ubo.NumExtraLights < 3; ++i) { - Light* light = m_lights[i]; - if (!light || !light->IsEnabled()) continue; - if (light == activeLight) continue; // already in primary slot - auto* dlight = dynamic_cast(light); - if (!dlight) continue; + for (size_t i = 0; + i < m_directionalLights.GetSize() && ubo.NumExtraLights < 3; ++i) { + DirectionalLight* dlight = m_directionalLights[i]; + if (!dlight->IsEnabled() || dlight == activeLight) continue; auto eDir = dlight->GetDirection(); auto eColor = dlight->GetColor(); uint32_t idx = ubo.NumExtraLights; @@ -445,6 +489,155 @@ void LightManager::UpdateShadowData() { renderer->UpdateShadowLightUBO(&ubo, sizeof(ubo)); } +void LightManager::UpdateClusters(const DirectionalLight* mainLight) { + using namespace RenderEngine; + auto* app = Application::GetInstance(); + auto* renderer = app ? app->GetRenderer() : nullptr; + if (!renderer || !renderer->SupportsClusteredLights()) return; + + auto& sc = *m_clusters; + sc.gpuLights.clear(); + for (size_t i = 0; i < m_directionalLights.GetSize(); ++i) { + DirectionalLight* light = m_directionalLights[i]; + if (light != mainLight && light->IsEnabled() && + sc.gpuLights.size() < MAX_CLUSTERED_LIGHTS) + sc.gpuLights.push_back(light->BuildGPUData()); + } + const uint32_t globalCount = static_cast(sc.gpuLights.size()); + for (const auto& entry : sc.sortedLocal) { + if (sc.gpuLights.size() >= MAX_CLUSTERED_LIGHTS) break; + sc.gpuLights.push_back(entry.second->BuildGPUData()); + } + const uint32_t lightCount = static_cast(sc.gpuLights.size()); + + // Exponential depth slices between the camera near plane (at least 1m, + // anything closer lands in slice 0) and the far plane. + float nearP, farP; + MainCameraNearFar(nearP, farP); + const float zNear = std::max(nearP, 1.0f); + const float zFar = std::max(farP, zNear * 2.0f); + const float logRange = std::log(zFar / zNear); + const float zScale = static_cast(CLUSTER_GRID_Z) / logRange; + const float zBias = -zScale * std::log(zNear); + auto sliceOf = [&](float z) { + float k = std::log(std::max(z, 1e-3f)) * zScale + zBias; + return static_cast( + std::clamp(k, 0.0f, static_cast(CLUSTER_GRID_Z - 1))); + }; + auto sliceNear = [&](uint32_t k) { + return k == 0 ? 0.0f : zNear * std::exp(static_cast(k) / zScale); + }; + + const Math::Matrix4& V = Camera::GetMainViewMatrix(); + const Math::Matrix4& P = Camera::GetMainProjectionMatrix(); + const float p00 = P(0, 0), p11 = P(1, 1); + + ClusterParamsGPU params{}; + params.ViewZ[0] = V(0, 2); + params.ViewZ[1] = V(1, 2); + params.ViewZ[2] = V(2, 2); + params.ViewZ[3] = V(3, 2); + params.GridX = CLUSTER_GRID_X; + params.GridY = CLUSTER_GRID_Y; + params.GridZ = CLUSTER_GRID_Z; + params.GlobalLightCount = globalCount; + params.ZScale = zScale; + params.ZBias = zBias; + params.LocalLightCount = lightCount - globalCount; + + // Pass 1: (cluster, light) pairs from each light's sphere against the + // view-space box of every cluster its screen bounds and depth touch. + sc.pairs.clear(); + for (uint32_t li = globalCount; li < lightCount; ++li) { + const LightGPUEntry& l = sc.gpuLights[li]; + const float r = l.Range; + if (r <= 0.0f) continue; + const float px = l.PositionX, py = l.PositionY, pz = l.PositionZ; + const float vx = px * V(0, 0) + py * V(1, 0) + pz * V(2, 0) + V(3, 0); + const float vy = px * V(0, 1) + py * V(1, 1) + pz * V(2, 1) + V(3, 1); + const float vz = px * V(0, 2) + py * V(1, 2) + pz * V(2, 2) + V(3, 2); + if (vz + r <= nearP) continue; + + const float z0 = std::max(vz - r, nearP); + const float z1 = vz + r; + float ndc[4] = {1.0f, -1.0f, 1.0f, -1.0f}; // xmin xmax ymin ymax + for (float z : {z0, z1}) { + for (float x : {vx - r, vx + r}) { + ndc[0] = std::min(ndc[0], p00 * x / z); + ndc[1] = std::max(ndc[1], p00 * x / z); + } + for (float y : {vy - r, vy + r}) { + ndc[2] = std::min(ndc[2], p11 * y / z); + ndc[3] = std::max(ndc[3], p11 * y / z); + } + } + if (ndc[1] < -1.0f || ndc[0] > 1.0f || ndc[3] < -1.0f || ndc[2] > 1.0f) + continue; + + uint32_t tx0, tx1, ty0, ty1; + NdcToTiles(ndc[0], ndc[1], CLUSTER_GRID_X, tx0, tx1); + NdcToTiles(ndc[2], ndc[3], CLUSTER_GRID_Y, ty0, ty1); + const uint32_t k0 = sliceOf(z0), k1 = sliceOf(z1); + + for (uint32_t k = k0; k <= k1; ++k) { + const float cz0 = sliceNear(k); + const float cz1 = (k + 1 == CLUSTER_GRID_Z) ? std::max(z1, cz0) + : sliceNear(k + 1); + const float dz = vz < cz0 ? cz0 - vz : (vz > cz1 ? vz - cz1 : 0.0f); + for (uint32_t ty = ty0; ty <= ty1; ++ty) { + const float ny0 = -1.0f + 2.0f * ty / CLUSTER_GRID_Y; + const float ny1 = -1.0f + 2.0f * (ty + 1) / CLUSTER_GRID_Y; + const float y0 = std::min(ny0 * cz0, ny0 * cz1) / p11; + const float y1 = std::max(ny1 * cz0, ny1 * cz1) / p11; + const float dy = vy < y0 ? y0 - vy : (vy > y1 ? vy - y1 : 0.0f); + for (uint32_t tx = tx0; tx <= tx1; ++tx) { + const float nx0 = -1.0f + 2.0f * tx / CLUSTER_GRID_X; + const float nx1 = -1.0f + 2.0f * (tx + 1) / CLUSTER_GRID_X; + const float x0 = std::min(nx0 * cz0, nx0 * cz1) / p00; + const float x1 = std::max(nx1 * cz0, nx1 * cz1) / p00; + const float dx = + vx < x0 ? x0 - vx : (vx > x1 ? vx - x1 : 0.0f); + if (dx * dx + dy * dy + dz * dz > r * r) continue; + const uint32_t cell = + (k * CLUSTER_GRID_Y + ty) * CLUSTER_GRID_X + tx; + sc.pairs.push_back(cell << 10 | li); + } + } + } + } + + // Pass 2: counting sort the pairs into per-cluster runs. + sc.cells.assign(CLUSTER_COUNT * 2, 0); + size_t total = sc.pairs.size(); + if (total > MAX_CLUSTER_LIGHT_INDICES) { + if (!sc.warnedOverflow) { + SLEAK_WARN( + "Clustered lights: {} light references exceed the {} " + "index budget, dropping the rest", + total, MAX_CLUSTER_LIGHT_INDICES); + sc.warnedOverflow = true; + } + total = MAX_CLUSTER_LIGHT_INDICES; + } + for (size_t i = 0; i < total; ++i) ++sc.cells[(sc.pairs[i] >> 10) * 2 + 1]; + uint32_t offset = 0; + for (uint32_t c = 0; c < CLUSTER_COUNT; ++c) { + sc.cells[c * 2] = offset; + offset += sc.cells[c * 2 + 1]; + sc.cells[c * 2 + 1] = 0; + } + sc.indices.resize(total); + for (size_t i = 0; i < total; ++i) { + const uint32_t cell = sc.pairs[i] >> 10; + uint32_t& n = sc.cells[cell * 2 + 1]; + sc.indices[sc.cells[cell * 2] + n++] = sc.pairs[i] & 1023u; + } + + renderer->UpdateClusteredLights(params, sc.gpuLights.data(), + sc.cells.data(), sc.indices.data(), + static_cast(total)); +} + void LightManager::SetAmbientColor(float r, float g, float b) { m_ambientR = r; m_ambientG = g;