Skip to content

feat: clustered light culling for the deferred lighting pass - #214

Open
CanReader wants to merge 1 commit into
feat/cascaded-shadow-mapsfrom
feat/clustered-lights
Open

CanReader wants to merge 1 commit into
feat/cascaded-shadow-mapsfrom
feat/clustered-lights

Conversation

@CanReader

@CanReader CanReader commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #213 (cascaded shadow maps), since both touch the light UBO, LightManager::UpdateShadowData and the lighting shaders. Review that one first; this diff is against its branch.

What this changes

The deferred lighting pass on Vulkan and OpenGL now shades point and spot lights through a clustered light list, so a scene can have hundreds of them. The forward path keeps its 16-entry light block, now filled with the main directional light first and then the nearest lights. LightManager keeps typed light lists instead of running dynamic_cast over every light two to three times a frame, and the 16-light registration cap and the 3-extra-directional-light limit are gone for the deferred path.

Why

Before this, RegisterLight refused anything past MAX_LIGHTS = 16, the lighting UBO packed the first 16 in registration order, and UpdateShadowData did three dynamic_cast scans over all lights per frame (shadow light, fallback light, up to 3 extra directional lights). On Vulkan it was worse: the deferred lighting pass never read the light array at all, it only knew the main light and the 3 extras in ShadowLightUBO, so point and spot lights did nothing. On OpenGL the engine did not even ship lighting_pass_gl.frag, so deferred only worked when the game brought its own.

How it works:

  • RegisterLight sorts a light into m_directionalLights or m_localLights once (the only cast left), and UnregisterLight removes it from the matching list. The main directional light is "first enabled shadow caster, else first enabled", same rule as before, now a walk over the directional list.
  • Every frame the enabled local lights are sorted by distance to the camera. The forward LightCBData.Lights[16] gets the main directional light, the other directional lights, then the nearest local lights. That ordering also means slot 0 is the shadowed light, which is what the forward shaders already assumed.
  • Clusters are built on the CPU: 16x9 screen tiles by 24 exponential depth slices (1 m to the camera far plane). Each light's sphere is projected to a tile and slice range, then tested against the view-space box of every cluster in that range. Pairs go through a counting sort into one flat index list plus an (offset, count) per cluster. Light indices are packed in 10 bits, so the list is capped at MAX_CLUSTERED_LIGHTS = 1024, and the index list at 256K entries (warns once and drops the farthest lights' references past that).
  • The GPU light list is the extra directional lights first (applied everywhere, GlobalLightCount), then the local lights.
  • Local lights use the same windowed inverse-square falloff as the forward GL shader (AttenuateUE4), which reaches exactly zero at Range. That is what makes per-cluster culling exact instead of an approximation. Spots add the cone from SpotInnerCos/SpotOuterCos; area lights are shaded as points for now.
  • Vulkan: new VulkanClusteredLights.cpp with a set-4 layout (params UBO plus three storage buffers) on the lighting pipeline and one set of host-visible VMA buffers per frame in flight. UpdateClusteredLights writes straight into the current frame's buffers, same as the light UBO, which is safe because LightManager::UpdateAndBind runs after BeginRender has waited on that frame's fence. Staging it to the next BeginRender instead would have used last frame's camera for the clusters.
  • OpenGL: params at UBO binding 7, lights/cells/indices at SSBO bindings 0-2, and the engine now has its own lighting_pass_gl.frag. It follows the look of SleakCraft's version (wrap diffuse main light, hemisphere ambient, optional IBL, ACES and fog in the pass) plus the cascades from feat: cascaded shadow maps for the main directional light #213 and the clustered loop. Games that ship their own lighting_pass_gl.frag still override it, so SleakCraft keeps its shader until it opts in.
  • Renderer gets SupportsClusteredLights() and a no-op UpdateClusteredLights, so LightManager only builds clusters for backends that read them.

DX11/DX12: no change. They have no deferred path and their forward shaders read the 16-entry block, which is now the nearest 16 instead of the first 16 registered, so they keep a correct fallback. Clustered shading there would need structured buffers in both backends with nothing to test it on here.

This overlaps #209, which ships the plain GL lighting shader for #144. Both add assets/shaders/lighting_pass_gl.frag; both start from SleakCraft's copy, and this one adds the cascade and cluster loops (and drops the unused GGX helpers), so whichever lands second should keep this version.

Not covered here: per-light shadows, tiled or clustered culling for the forward and transparent passes (water in SleakCraft still only sees the 16 nearest), real area light shading, and SleakCraft's own lighting_pass_gl.frag, which still loops the 16-entry block until the game switches to the engine one or adds the cluster loop.

How it was verified

  • Builds on Linux (SleakCraft verify tree, Debug, Ninja)
  • Builds on Windows (Windows CI on this PR is the check for the DX side)
  • Ran the sample scene on Vulkan
  • Ran the sample scene on OpenGL

Throwaway change in the verify tree (not committed): 300 lights scattered over the spawn area of -world citest -seed 42, every 5th one a spot pointing down, ranges 6-14 m, sun dimmed so they show.

  • Vulkan with --validate under Xvfb: lights render, and the only validation messages are the two vkDestroyDevice ones from killing the process, same as on main. The vertex-attribute warnings at pipeline creation are also on main, same count.
  • OpenGL on llvmpipe with the engine lighting_pass_gl.frag copied over the game's in bin/: same result as Vulkan.
  • Culling correctness, both backends: a debug build of each lighting shader summed the local lights twice, once through the cluster and once brute force over the whole list, and painted any pixel where they differ by more than 0.002 solid red. Zero red pixels on Vulkan and on GL with 300 lights.
  • No lights, default SleakCraft: Vulkan matches feat: cascaded shadow maps for the main directional light #213 within TAA noise, GL with the game's own shader matches main (30 pixels of 518k differ at 3% fuzz), and GL with the new engine shader matches main too (63 pixels). The standard 90 s -r opengl -w 640 -h 360 -world citest -seed 42 -rd 4 run survives (exit 143) with no critical lines.
  • Frame time, Vulkan Debug at 960x540 with --bench: 15.1 ms median with no extra lights and 12.7 ms with 300. That run-to-run noise is bigger than the clustering cost, so all I can say is it did not show up.

Checklist

  • Shader changes updated all four backend variants and the committed .spv (Vulkan lighting_pass.frag + .spv, new GL lighting_pass_gl.frag; DX has no deferred lighting shader)
  • Public headers documented with /// comments
  • No new includes from src/ or include/private/ in public headers
  • No game-specific vocabulary added to engine API

@CanReader
CanReader force-pushed the feat/cascaded-shadow-maps branch from baf21c1 to 2c48a9d Compare October 4, 2026 19:41
@CanReader
CanReader force-pushed the feat/clustered-lights branch from cbbad2b to a20950b Compare October 4, 2026 19:46
@CanReader
CanReader force-pushed the feat/cascaded-shadow-maps branch from 2c48a9d to afef417 Compare October 4, 2026 19:53
LightManager keeps typed directional and local light lists, builds a
16x9x24 froxel grid on the CPU every frame and hands the light list,
per-cluster ranges and index list to the renderer. Vulkan and GL read them
from storage buffers in the lighting pass, so hundreds of point and spot
lights work there. Forward shaders get the main light plus the nearest
lights in the old 16-entry block. The engine now ships its own
lighting_pass_gl.frag.
@CanReader
CanReader force-pushed the feat/clustered-lights branch from a20950b to 49213eb Compare October 4, 2026 19:56

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant