SleakEngine 0.1.0
C++23 multi-backend game engine
Loading...
Searching...
No Matches
LightManager.cpp
Go to the documentation of this file.
1#include <Camera/Camera.hpp>
4#include <Core/Logger.hpp>
5#include <Core/SceneBase.hpp>
6#include <Core/Timer.hpp>
7#include <Core/Window.hpp>
14#include <Lighting/Light.hpp>
16#include <Math/Matrix.hpp>
17#include <cmath>
18#include <cstring>
19
20namespace {
21/// 4x4 row-major matrix inverse via cofactors (Cramer's rule).
22/// Returns false if matrix is singular.
23static bool Invert4x4(const float m[16], float inv[16]) {
24 float inv0 = m[5]*m[10]*m[15] - m[5]*m[11]*m[14] - m[9]*m[6]*m[15] + m[9]*m[7]*m[14] + m[13]*m[6]*m[11] - m[13]*m[7]*m[10];
25 float inv4 = -m[4]*m[10]*m[15] + m[4]*m[11]*m[14] + m[8]*m[6]*m[15] - m[8]*m[7]*m[14] - m[12]*m[6]*m[11] + m[12]*m[7]*m[10];
26 float inv8 = m[4]*m[9] *m[15] - m[4]*m[11]*m[13] - m[8]*m[5]*m[15] + m[8]*m[7]*m[13] + m[12]*m[5]*m[11] - m[12]*m[7]*m[9];
27 float inv12 = -m[4]*m[9] *m[14] + m[4]*m[10]*m[13] + m[8]*m[5]*m[14] - m[8]*m[6]*m[13] - m[12]*m[5]*m[10] + m[12]*m[6]*m[9];
28
29 float det = m[0]*inv0 + m[1]*inv4 + m[2]*inv8 + m[3]*inv12;
30 if (std::fabs(det) < 1e-8f) return false;
31 float id = 1.0f / det;
32
33 inv[0] = inv0 * id;
34 inv[4] = inv4 * id;
35 inv[8] = inv8 * id;
36 inv[12] = inv12 * id;
37
38 inv[1] = (-m[1]*m[10]*m[15] + m[1]*m[11]*m[14] + m[9]*m[2]*m[15] - m[9]*m[3]*m[14] - m[13]*m[2]*m[11] + m[13]*m[3]*m[10]) * id;
39 inv[5] = ( m[0]*m[10]*m[15] - m[0]*m[11]*m[14] - m[8]*m[2]*m[15] + m[8]*m[3]*m[14] + m[12]*m[2]*m[11] - m[12]*m[3]*m[10]) * id;
40 inv[9] = (-m[0]*m[9] *m[15] + m[0]*m[11]*m[13] + m[8]*m[1]*m[15] - m[8]*m[3]*m[13] - m[12]*m[1]*m[11] + m[12]*m[3]*m[9] ) * id;
41 inv[13] = ( m[0]*m[9] *m[14] - m[0]*m[10]*m[13] - m[8]*m[1]*m[14] + m[8]*m[2]*m[13] + m[12]*m[1]*m[10] - m[12]*m[2]*m[9] ) * id;
42
43 inv[2] = ( m[1]*m[6]*m[15] - m[1]*m[7]*m[14] - m[5]*m[2]*m[15] + m[5]*m[3]*m[14] + m[13]*m[2]*m[7] - m[13]*m[3]*m[6]) * id;
44 inv[6] = (-m[0]*m[6]*m[15] + m[0]*m[7]*m[14] + m[4]*m[2]*m[15] - m[4]*m[3]*m[14] - m[12]*m[2]*m[7] + m[12]*m[3]*m[6]) * id;
45 inv[10] = ( m[0]*m[5]*m[15] - m[0]*m[7]*m[13] - m[4]*m[1]*m[15] + m[4]*m[3]*m[13] + m[12]*m[1]*m[7] - m[12]*m[3]*m[5]) * id;
46 inv[14] = (-m[0]*m[5]*m[14] + m[0]*m[6]*m[13] + m[4]*m[1]*m[14] - m[4]*m[2]*m[13] - m[12]*m[1]*m[6] + m[12]*m[2]*m[5]) * id;
47
48 inv[3] = (-m[1]*m[6]*m[11] + m[1]*m[7]*m[10] + m[5]*m[2]*m[11] - m[5]*m[3]*m[10] - m[9]*m[2]*m[7] + m[9]*m[3]*m[6]) * id;
49 inv[7] = ( m[0]*m[6]*m[11] - m[0]*m[7]*m[10] - m[4]*m[2]*m[11] + m[4]*m[3]*m[10] + m[8]*m[2]*m[7] - m[8]*m[3]*m[6]) * id;
50 inv[11] = (-m[0]*m[5]*m[11] + m[0]*m[7]*m[9] + m[4]*m[1]*m[11] - m[4]*m[3]*m[9] - m[8]*m[1]*m[7] + m[8]*m[3]*m[5]) * id;
51 inv[15] = ( m[0]*m[5]*m[10] - m[0]*m[6]*m[9] - m[4]*m[1]*m[10] + m[4]*m[2]*m[9] + m[8]*m[1]*m[6] - m[8]*m[2]*m[5]) * id;
52
53 return true;
54}
55} // namespace
56
57namespace Sleak {
58
60
62
64 if (!m_lightBuffer) {
69 nullptr));
70 m_lightBuffer->SetSlot(2);
71 }
72}
73
75 if (!light) return;
76 if (m_lights.indexOf(light) != -1) return;
77
78 if (m_lights.GetSize() >= RenderEngine::MAX_LIGHTS) {
80 "Maximum light count ({}) reached, cannot register "
81 "light '{}'",
82 RenderEngine::MAX_LIGHTS, light->GetName());
83 return;
84 }
85
86 m_lights.add(light);
87}
88
90 if (!light) return;
91 int index = m_lights.indexOf(light);
92 if (index != -1) {
93 m_lights.erase(index);
94 }
95}
96
98 if (!m_lightBuffer) return;
99
101
102 // Camera position
103 const auto& camPos = Camera::GetMainCameraPosition();
104 cbData.CameraPosX = camPos.GetX();
105 cbData.CameraPosY = camPos.GetY();
106 cbData.CameraPosZ = camPos.GetZ();
107
108 // Ambient
109 cbData.AmbientR = m_ambientR;
110 cbData.AmbientG = m_ambientG;
111 cbData.AmbientB = m_ambientB;
112 cbData.AmbientIntensity = m_ambientIntensity;
113
114 // Fog — horizon color + sky-zenith blend + exponential height fog.
115 // OpenGL deferred lighting reads its fog parameters from this LightCBData
116 // (binding 2). Vulkan/forward shaders read the same data from
117 // ShadowLightUBO. Keep both blocks in sync.
118 if (m_fogEnabled) {
119 cbData.FogColorR = m_fogR;
120 cbData.FogColorG = m_fogG;
121 cbData.FogColorB = m_fogB;
122 cbData.FogColorA = 1.0f;
123 cbData.FogStart = m_fogStart;
124 cbData.FogEnd = m_fogEnd;
125
126 cbData.FogColorZenith[0] = m_fogZenithR;
127 cbData.FogColorZenith[1] = m_fogZenithG;
128 cbData.FogColorZenith[2] = m_fogZenithB;
129 cbData.FogColorZenith[3] = 1.0f;
130
131 cbData.HeightFogTop = m_heightFogTop;
132 cbData.HeightFogDensity = m_heightFogDensity;
133 cbData.HeightFogFalloff = m_heightFogFalloff;
134 cbData.HeightFogEnabled = m_heightFogEnabled ? 1.0f : 0.0f;
135 } else {
136 cbData.FogStart = 0.0f;
137 cbData.FogEnd = 0.0f;
138 cbData.HeightFogEnabled = 0.0f;
139 }
140
141 // Collect active lights
142 uint32_t count = 0;
143 for (size_t i = 0;
144 i < m_lights.GetSize() && count < RenderEngine::MAX_LIGHTS;
145 ++i) {
146 Light* light = m_lights[i];
147 if (!light || !light->IsEnabled()) continue;
148
149 cbData.Lights[count] = light->BuildGPUData();
150 ++count;
151 }
152 cbData.NumActiveLights = count;
153
154 // Update and bind at slot 2
155 m_lightBuffer->Update(&cbData, sizeof(cbData));
156 m_lightBuffer->Update();
157
158 // Update shadow data for Vulkan renderer
160
161 // Update deferred CB (InvViewProj + screen size) for the lighting pass
163}
164
166 auto* app = Application::GetInstance();
167 if (!app) return;
168 auto* renderer = app->GetRenderer();
169 if (!renderer) return;
170
171 // Find first shadow-casting directional light
172 DirectionalLight* shadowLight = nullptr;
173 for (size_t i = 0; i < m_lights.GetSize(); ++i) {
174 Light* light = m_lights[i];
175 if (!light || !light->IsEnabled() || !light->GetCastShadows()) continue;
176
177 auto* dirLight = dynamic_cast<DirectionalLight*>(light);
178 if (dirLight) {
179 shadowLight = dirLight;
180 break;
181 }
182 }
183
184 // Find first enabled directional light (regardless of shadow casting)
185 // for populating light/ambient data in the UBO
186 DirectionalLight* anyDirLight = nullptr;
187 if (!shadowLight) {
188 for (size_t i = 0; i < m_lights.GetSize(); ++i) {
189 Light* light = m_lights[i];
190 if (!light || !light->IsEnabled()) continue;
191 auto* dirLight = dynamic_cast<DirectionalLight*>(light);
192 if (dirLight) {
193 anyDirLight = dirLight;
194 break;
195 }
196 }
197 }
198
199 // Tell the renderer whether the shadow pass should run
200 renderer->SetShadowPassEnabled(shadowLight != nullptr);
201
202 // Use shadow light if available, otherwise fall back to any directional light
203 DirectionalLight* activeLight = shadowLight ? shadowLight : anyDirLight;
204
205 if (!activeLight) {
206 static bool warned = false;
207 if (!warned) { SLEAK_WARN("UpdateShadowData: No directional light found!"); warned = true; }
208 return;
209 }
210
211 auto dir = activeLight->GetDirection();
212 auto color = activeLight->GetColor();
213 float intensity = activeLight->GetIntensity();
214
216
217 if (shadowLight) {
218 // Compute light view-projection matrix from shadow configuration
219 float frustumSize = shadowLight->GetShadowFrustumSize();
220 float shadowDist = shadowLight->GetShadowDistance();
221 float nearP = shadowLight->GetShadowNearPlane();
222 float farP = shadowLight->GetShadowFarPlane();
223
224 // Light position: follow camera XZ but fix Y at world origin.
225 // Anchoring Y prevents the shadow frustum from shifting vertically
226 // when the player jumps/flies, which causes hard Z-plane cutoff flicker.
227 const auto& camPos = Camera::GetMainCameraPosition();
228 Math::Vector3D lightPos = Math::Vector3D(camPos.GetX(), 0.0f, camPos.GetZ())
229 + dir * (-shadowDist);
230
231 // Convert to Vector<float,3> for Matrix methods
232 Math::Vector<float, 3> lp({lightPos.GetX(), lightPos.GetY(), lightPos.GetZ()});
233 Math::Vector<float, 3> ld({dir.GetX(), dir.GetY(), dir.GetZ()});
234
235 // Avoid degenerate LookTo when light direction is nearly vertical
236 // (cross product with (0,1,0) would be zero → NaN matrix)
237 Math::Vector<float, 3> up = (fabsf(dir.GetY()) > 0.999f)
238 ? Math::Vector<float, 3>({0.0f, 0.0f, 1.0f})
239 : Math::Vector<float, 3>({0.0f, 1.0f, 0.0f});
240
241 Math::Matrix4 lightView = Math::Matrix4::LookTo(lp, ld, up);
242
243 // Build Vulkan-compatible orthographic projection (LH, [0,1] depth range)
244 // Engine stores row-major, GLSL reads column-major (transposed) —
245 // translations go in ROW 3 so they end up in GLSL column 3.
246 float left = -frustumSize, right = frustumSize;
247 float bottom = -frustumSize, top = frustumSize;
249 lightProj(0, 0) = 2.0f / (right - left);
250 lightProj(1, 1) = 2.0f / (top - bottom);
251 lightProj(2, 2) = 1.0f / (farP - nearP);
252 lightProj(3, 0) = -(right + left) / (right - left);
253 lightProj(3, 1) = -(top + bottom) / (top - bottom);
254 lightProj(3, 2) = -nearP / (farP - nearP);
255
256 // ---- Texel snap (DirectX SDK standard technique) ----
257 // Project world origin through the raw lightVP, measure its XY in
258 // shadow-map texel space, snap to nearest texel, apply the delta
259 // back to the projection matrix. This guarantees the sampling grid
260 // is aligned to world-space texel cells so sub-texel camera motion
261 // never shifts which texel a world point lands on → no shimmer.
262 //
263 // Using round() (not floor) — floor flips by a full texel when
264 // the fractional part crosses 0 due to float noise.
265 const float shadowMapSize =
266 static_cast<float>(renderer->GetShadowMapResolution());
267 const float halfShadow = shadowMapSize * 0.5f;
268
269 Math::Matrix4 lightVP_raw = lightView * lightProj;
270 // Anchor the snap on the WORLD ORIGIN (fixed point). The camera is a
271 // constant offset from the light frustum (lightPos follows camXZ), so
272 // projecting the camera yields the SAME texel coords every frame —
273 // constant delta, snap no-ops, world texels crawl while moving. The
274 // origin's projected texel position drifts as the frustum follows the
275 // camera; rounding it quantizes frustum motion to whole texels.
276 // Row-vector convention: (0,0,0,1) * M = row 3.
277 float clipX = lightVP_raw(3, 0);
278 float clipY = lightVP_raw(3, 1);
279 float clipW = lightVP_raw(3, 3);
280 if (clipW != 0.0f) {
281 float ndcX = clipX / clipW;
282 float ndcY = clipY / clipW;
283 float texX = ndcX * halfShadow;
284 float texY = ndcY * halfShadow;
285 float roundedX = std::round(texX);
286 float roundedY = std::round(texY);
287 float offsetNdcX = (roundedX - texX) / halfShadow;
288 float offsetNdcY = (roundedY - texY) / halfShadow;
289 lightProj(3, 0) += offsetNdcX;
290 lightProj(3, 1) += offsetNdcY;
291 }
292
293 // LightVP = View * Projection (row-major convention)
294 lightVP = lightView * lightProj;
295 }
296
297 // DIAG --shadowfreeze: latch the first lightVP forever. If shadows still
298 // shimmer with a frozen frustum, the cause is screen-space, not the
299 // frustum-follow chain.
300 {
301 static const bool s_freeze = CommandLine::HasFlag("--shadowfreeze");
302 static bool s_latched = false;
303 static float s_frozenVP[16];
304 if (s_freeze && shadowLight) {
305 if (!s_latched) {
306 std::memcpy(s_frozenVP, &lightVP(0, 0), sizeof(s_frozenVP));
307 s_latched = true;
308 SLEAK_WARN("shadowfreeze: light frustum latched");
309 } else {
310 std::memcpy(&lightVP(0, 0), s_frozenVP, sizeof(s_frozenVP));
311 }
312 }
313 }
314
315 // Set the light VP matrix on the renderer
316 renderer->SetLightVP(&lightVP(0, 0));
317
318 // Build shadow light UBO
319 const auto& camPos = Camera::GetMainCameraPosition();
321
322 ubo.LightDir[0] = dir.GetX();
323 ubo.LightDir[1] = dir.GetY();
324 ubo.LightDir[2] = dir.GetZ();
325 ubo.LightDir[3] = shadowLight ? shadowLight->GetShadowNormalBias() : 0.0f;
326
327 ubo.LightColor[0] = color.GetX();
328 ubo.LightColor[1] = color.GetY();
329 ubo.LightColor[2] = color.GetZ();
330 ubo.LightColor[3] = intensity;
331
332 ubo.Ambient[0] = m_ambientR;
333 ubo.Ambient[1] = m_ambientG;
334 ubo.Ambient[2] = m_ambientB;
335 ubo.Ambient[3] = m_ambientIntensity;
336
337 // CameraPos.w carries a monotonic scene clock, consumed by shaders that
338 // need animation time (e.g. water waves on the Vulkan backend — the engine
339 // has no MaterialUBO slot in its Vulkan pipeline layout, so there is
340 // nowhere else to stash time). Using a static Timer keeps this
341 // self-contained and independent of Application state.
342 static Sleak::Timer s_sceneClock;
343 ubo.CameraPos[0] = camPos.GetX();
344 ubo.CameraPos[1] = camPos.GetY();
345 ubo.CameraPos[2] = camPos.GetZ();
346 ubo.CameraPos[3] = s_sceneClock.Elapsed();
347
348 // CURRENT lightVP — renderers stage SetLightVP and commit at BeginRender,
349 // so this frame's shadow map IS rendered with this matrix. The old
350 // prev-frame copy lagged sampling one frame behind the map (shadow shake
351 // while the camera moved).
352 std::memcpy(ubo.LightVP, &lightVP(0, 0), sizeof(float) * 16);
353
354 // NdcToShadow = InvViewProj * LightVP composed once on CPU so shadow
355 // coords never round-trip through reconstructed world position (that
356 // per-fragment path shimmers under camera rotation). Uses the same
357 // LightVP the UBO carries (prev frame — matches the bound shadow map).
358 {
361 Math::Matrix4 camVP = camV * camP;
362 float invVP[16];
363 if (Invert4x4(&camVP(0, 0), invVP)) {
364 Math::Matrix4 invVPm, lightVPm;
365 std::memcpy(&invVPm(0, 0), invVP, sizeof(float) * 16);
366 std::memcpy(&lightVPm(0, 0), ubo.LightVP, sizeof(float) * 16);
367 Math::Matrix4 comp = invVPm * lightVPm;
368 std::memcpy(ubo.NdcToShadow, &comp(0, 0), sizeof(float) * 16);
369 } else {
370 std::memcpy(ubo.NdcToShadow, ubo.LightVP, sizeof(float) * 16);
371 }
372 }
373
374 ubo.ShadowBias = shadowLight ? shadowLight->GetShadowBias() : 0.0f;
375 ubo.ShadowStrength = shadowLight ? shadowLight->GetShadowStrength() : 0.0f;
376 ubo.ShadowTexelSize =
377 1.0f / static_cast<float>(renderer->GetShadowMapResolution());
378 ubo.LightSize = shadowLight ? shadowLight->GetLightSize() : 0.0f;
379
380 // Fog — distance gradient (horizon + zenith) and exponential height fog
381 if (m_fogEnabled) {
382 ubo.FogColor[0] = m_fogR;
383 ubo.FogColor[1] = m_fogG;
384 ubo.FogColor[2] = m_fogB;
385 ubo.FogColor[3] = 1.0f;
386 ubo.FogStart = m_fogStart;
387 ubo.FogEnd = m_fogEnd;
388
389 ubo.FogColorZenith[0] = m_fogZenithR;
390 ubo.FogColorZenith[1] = m_fogZenithG;
391 ubo.FogColorZenith[2] = m_fogZenithB;
392 ubo.FogColorZenith[3] = 1.0f;
393
394 ubo.HeightFogTop = m_heightFogTop;
395 ubo.HeightFogDensity = m_heightFogDensity;
396 ubo.HeightFogFalloff = m_heightFogFalloff;
397 ubo.HeightFogEnabled = m_heightFogEnabled ? 1.0f : 0.0f;
398 } else {
399 ubo.FogStart = 0.0f;
400 ubo.FogEnd = 0.0f;
401 ubo.HeightFogEnabled = 0.0f;
402 }
403
404 // Populate extra lights (fill, rim — non-shadow directional lights)
405 ubo.NumExtraLights = 0;
406 for (size_t i = 0; i < m_lights.GetSize() && ubo.NumExtraLights < 3; ++i) {
407 Light* light = m_lights[i];
408 if (!light || !light->IsEnabled()) continue;
409 if (light == activeLight) continue; // already in primary slot
410 auto* dlight = dynamic_cast<DirectionalLight*>(light);
411 if (!dlight) continue;
412 auto eDir = dlight->GetDirection();
413 auto eColor = dlight->GetColor();
414 uint32_t idx = ubo.NumExtraLights;
415 ubo.ExtraLightDir[idx][0] = eDir.GetX();
416 ubo.ExtraLightDir[idx][1] = eDir.GetY();
417 ubo.ExtraLightDir[idx][2] = eDir.GetZ();
418 ubo.ExtraLightDir[idx][3] = 0.0f;
419 ubo.ExtraLightColor[idx][0] = eColor.GetX();
420 ubo.ExtraLightColor[idx][1] = eColor.GetY();
421 ubo.ExtraLightColor[idx][2] = eColor.GetZ();
422 ubo.ExtraLightColor[idx][3] = dlight->GetIntensity();
423 ++ubo.NumExtraLights;
424 }
425
426 renderer->UpdateShadowLightUBO(&ubo, sizeof(ubo));
427}
428
429void LightManager::SetAmbientColor(float r, float g, float b) {
430 m_ambientR = r;
431 m_ambientG = g;
432 m_ambientB = b;
433}
434
436 auto* app = Application::GetInstance();
437 if (!app) return;
438 auto* renderer = app->GetRenderer();
439 if (!renderer) return;
440 auto* ctx = renderer->GetContext();
441 if (!ctx || !ctx->IsDeferredEnabled()) return;
442
443 // ViewProj = View * Proj (row-major engine convention)
446 Math::Matrix4 VP = V * P;
447
449 if (!Invert4x4(&VP(0, 0), cb.InvViewProj)) {
450 // Singular matrix — skip update (can happen during initialization)
451 return;
452 }
453
454 // Screen size from Window static state
455 cb.ScreenWidth = static_cast<float>(app->GetWindow().GetWidth());
456 cb.ScreenHeight = static_cast<float>(app->GetWindow().GetHeight());
457 cb.NearPlane = 0.1f;
458 cb.FarPlane = 2000.0f;
459 if (auto* game = app->GetGame(); game && game->GetActiveScene()) {
460 if (auto* cam = game->GetActiveScene()->GetActiveCamera()) {
461 cb.NearPlane = cam->GetNearPlane();
462 cb.FarPlane = cam->GetFarPlane();
463 }
464 }
465
466 ctx->UpdateDeferredCB(&cb, sizeof(cb));
467}
468
469} // namespace Sleak
#define SLEAK_WARN(...)
Definition Logger.hpp:21
static Application * GetInstance()
The one Application for this process, or null before construction.
static const Math::Matrix4 & GetMainProjectionMatrix()
Definition Camera.hpp:107
static const Math::Vector3D & GetMainCameraPosition()
Definition Camera.hpp:111
static const Math::Matrix4 & GetMainViewMatrix()
Definition Camera.hpp:103
static bool HasFlag(const std::string &flag)
True if --flag was present on the command line.
Math::Vector3D GetDirection() const
void SetAmbientColor(float r, float g, float b)
void RegisterLight(Light *light)
void UpdateDeferredCB()
Refreshes the deferred-pass constant buffer (fog, ambient) independent of the per-light data.
void Initialize()
Allocates the GPU light buffer; call once before the first UpdateAndBind.
void UpdateShadowData()
Picks the active shadow-casting light and refreshes its shadow-space matrices.
void UpdateAndBind()
Packs every registered light and the fog/ambient parameters into the light buffer and binds it.
void UnregisterLight(Light *light)
virtual RenderEngine::LightGPUEntry BuildGPUData() const =0
Packs this light's parameters into the GPU-side entry used by the lighting constant buffer.
bool GetCastShadows() const
Definition Light.hpp:40
bool IsEnabled() const
Definition Light.hpp:37
float GetIntensity() const
Definition Light.hpp:34
float GetShadowStrength() const
Definition Light.hpp:48
Math::Vector3D GetColor() const
Definition Light.hpp:29
float GetShadowBias() const
Definition Light.hpp:43
float GetShadowNormalBias() const
Definition Light.hpp:51
float GetLightSize() const
Definition Light.hpp:54
static Matrix< float, Rows, Rows > Identity()
Definition Matrix.hpp:203
static Matrix< float, 4, 4 > LookTo(const Vector< float, 3 > &eye, const Vector< float, 3 > &direction, const Vector< float, 3 > &up)
Definition Matrix.hpp:315
float GetY() const
Definition Vector.hpp:362
float GetX() const
Definition Vector.hpp:361
float GetZ() const
Definition Vector.hpp:363
const std::string & GetName() const
Definition Object.hpp:22
static BufferBase * CreateBuffer(BufferType Type, uint32_t Size, void *Data)
Creates a buffer via the currently registered backend factory.
float Elapsed() const
Seconds since construction or the last Reset().
Definition Timer.cpp:13
Matrix< float, 4, 4 > Matrix4
Definition Matrix.hpp:413
Root namespace for everything the engine exposes.
Definition Camera.hpp:10
Per-frame deferred lighting pass CB: inverse view-projection plus screen/near-far metrics.
Full per-frame lighting UBO: camera, ambient, fog, and the packed light array.
LightGPUEntry Lights[MAX_LIGHTS]
Shadow-pass UBO: light/shadow parameters, fog, and extra fill lights (set 2, binding 0).