From 5e1d9f12f28980b770caf418b0692bde46684d49 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Mon, 17 Aug 2026 20:58:27 -0400 Subject: [PATCH] Add configurable shadow focus target for directional cascades Directional-light shadow cascades were always centered on the rendering camera's position, which assumes a first-person view. In a third-person game the player character is a distant focal point, so the character lands in a low-resolution far cascade (or falls out of the cascade range entirely) while the resolution is spent on empty ground in front of the camera. Add an optional per-camera Camera.ShadowFocus transform that cascades center on instead. It falls back to the camera's own position when unset or when the target's GameObject has been destroyed, so existing scenes and the editor's scene view (which builds a fresh temporary Camera) are unaffected. The CPU side places cascades around the focus point in DirectionalLight.GetShadowMatrix, while two shader sites select a cascade by distance: SampleDirectionalShadow in Lighting.glsl and VolDirShadow in VolumetricFog.shader. Both have to move together or selection disagrees with placement and cascade seams appear, so the resolved point is uploaded as a new _ShadowFocusPos uniform and both sites measure from it. The uniform is written before the no-directional-light early-out so it stays fresh every frame. Camera-relative values (view direction, fog distance, specular) keep using _WorldSpaceCameraPos. The point is resolved once per render into CameraSnapshot, so mutating the target mid-render can't desync placement from selection. Closest-N local shadow caster selection in SceneLightSystem.Reconcile uses the same point. Known limitation, unchanged by this: the cascade ortho depth range is a fixed +/- cascadeDistance * 0.5 slab around the snapped focus point rather than a fit to the scene's casters, so tall occluders toward the light can clip out of the map. This relocates that behaviour with the focus point; it does not fix it. Tests cover the resolver's fallbacks, that it tracks a moving target, that the cross-object Transform reference survives a scene save/load via Echo's intra-graph rewiring, that a cascade lands centered on its focus point to within half a texel, and that sub-texel movement of the focus produces an identical view matrix (the texel snapping that keeps shadow edges from shimmering). The BananaMan sample now demonstrates it: a player stand-in with props sits 25 units out on a large ground plane, the camera is third-person, and F toggles the focus between the player and the camera. --- Prowl.Runtime.Test/CameraShadowFocusTests.cs | 93 +++++++++++++++++++ .../DirectionalLightShadowMatrixTests.cs | 81 ++++++++++++++++ Prowl.Runtime/Assets/Defaults/Lighting.glsl | 9 +- .../Assets/Defaults/VolumetricFog.shader | 2 +- Prowl.Runtime/Components/Camera.cs | 21 +++++ .../Components/Lights/DirectionalLight.cs | 17 ++-- Prowl.Runtime/Components/Lights/Light.cs | 16 ++-- Prowl.Runtime/Components/Lights/PointLight.cs | 2 +- Prowl.Runtime/Components/Lights/SpotLight.cs | 2 +- .../Rendering/DefaultRenderPipeline.cs | 6 +- Prowl.Runtime/Rendering/RenderPipeline.cs | 5 + Prowl.Runtime/Rendering/SceneLightSystem.cs | 38 +++++--- Samples/BananaMan/Program.cs | 63 ++++++++++--- 13 files changed, 308 insertions(+), 47 deletions(-) create mode 100644 Prowl.Runtime.Test/CameraShadowFocusTests.cs create mode 100644 Prowl.Runtime.Test/DirectionalLightShadowMatrixTests.cs diff --git a/Prowl.Runtime.Test/CameraShadowFocusTests.cs b/Prowl.Runtime.Test/CameraShadowFocusTests.cs new file mode 100644 index 000000000..462d3f04d --- /dev/null +++ b/Prowl.Runtime.Test/CameraShadowFocusTests.cs @@ -0,0 +1,93 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Echo; +using Prowl.Runtime.Resources; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +/// +/// Tests for , the optional transform directional shadow cascades +/// center on instead of the camera. Covers the resolver's fallbacks (no target, destroyed target), +/// that it tracks a live target, and that the reference survives a scene save/load - the field is +/// a cross-object Transform reference, so Echo has to rewire it to the deserialized instance rather +/// than clone a detached copy. +/// +public class CameraShadowFocusTests : RuntimeTestBase +{ + private Camera CreateCamera(Float3 position, string name = "Camera") + { + GameObject go = CreateGameObject(name); + go.Transform.Position = position; + return go.AddComponent(); + } + + [Fact] + public void Camera_GetShadowFocusPosition_NoTarget_ReturnsCameraPosition() + { + Camera camera = CreateCamera(new Float3(3f, 5f, -7f)); + + Assert.Null(camera.ShadowFocus); + Assert.Equal(new Float3(3f, 5f, -7f), camera.GetShadowFocusPosition()); + } + + [Fact] + public void Camera_GetShadowFocusPosition_WithTarget_ReturnsTargetPosition() + { + Camera camera = CreateCamera(new Float3(0f, 6f, -10f)); + GameObject player = CreateGameObject("Player"); + player.Transform.Position = new Float3(0f, 0f, 25f); + + camera.ShadowFocus = player.Transform; + + Assert.Equal(new Float3(0f, 0f, 25f), camera.GetShadowFocusPosition()); + + // The focus point has to follow the target, not latch onto where it was when assigned. + player.Transform.Position = new Float3(12f, 1f, 30f); + Assert.Equal(new Float3(12f, 1f, 30f), camera.GetShadowFocusPosition()); + } + + [Fact] + public void Camera_GetShadowFocusPosition_TargetDestroyed_FallsBackToCameraPosition() + { + Camera camera = CreateCamera(new Float3(-2f, 4f, 9f)); + GameObject player = CreateGameObject("Player"); + player.Transform.Position = new Float3(50f, 0f, 50f); + camera.ShadowFocus = player.Transform; + + player.Destroy(); + EngineObject.ProcessDestroyed(); + + // A stale Transform on a destroyed GameObject must not be read - and must not throw. + Assert.Equal(new Float3(-2f, 4f, 9f), camera.GetShadowFocusPosition()); + } + + [Fact] + public void Camera_ShadowFocus_SceneRoundTrip_RewiresReference() + { + Scene scene = CreateScene(); + Camera camera = CreateCamera(new Float3(0f, 6f, -10f), "Main Camera"); + GameObject player = CreateGameObject("Player"); + player.Transform.Position = new Float3(0f, 0f, 25f); + camera.ShadowFocus = player.Transform; + + // Separate roots, so the reference crosses object boundaries inside the scene graph. + scene.Add(camera.GameObject); + scene.Add(player); + + Scene clone = Serializer.Deserialize(Serializer.Serialize(scene)); + + GameObject clonedCameraGO = Assert.Single(clone.AllObjects, g => g.Name == "Main Camera"); + GameObject clonedPlayer = Assert.Single(clone.AllObjects, g => g.Name == "Player"); + Camera? clonedCamera = clonedCameraGO.GetComponent(); + + Assert.NotNull(clonedCamera); + Assert.NotNull(clonedCamera!.ShadowFocus); + // Must be the deserialized player's own Transform, not a detached copy of it. + Assert.Same(clonedPlayer.Transform, clonedCamera.ShadowFocus); + Assert.Equal(new Float3(0f, 0f, 25f), clonedCamera.GetShadowFocusPosition()); + } +} diff --git a/Prowl.Runtime.Test/DirectionalLightShadowMatrixTests.cs b/Prowl.Runtime.Test/DirectionalLightShadowMatrixTests.cs new file mode 100644 index 000000000..0ad38d490 --- /dev/null +++ b/Prowl.Runtime.Test/DirectionalLightShadowMatrixTests.cs @@ -0,0 +1,81 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +/// +/// Pure-math tests (no GPU) for : a cascade lands +/// centered on the focus point it is handed - the rendering camera, or the camera's +/// target - and the light-space texel snapping that keeps shadow +/// edges from shimmering survives sub-texel movement of that point. +/// +public class DirectionalLightShadowMatrixTests : RuntimeTestBase +{ + private const int Resolution = 2048; + private const float CascadeDistance = 35f; + private const float TexelSize = (CascadeDistance * 2f) / Resolution; + + /// Creates an angled directional light, so the light-space axes are nothing like the + /// world axes and a mistake in the basis math can't accidentally cancel out. + private DirectionalLight CreateAngledLight() + { + GameObject go = CreateGameObject("Directional Light"); + go.Transform.LocalEulerAngles = new Float3(-50f, 30f, 0f); + return go.AddComponent(); + } + + /// The orthonormal light-space basis GetShadowMatrix builds internally. + private static (Float3 right, Float3 up, Float3 forward) LightBasis(DirectionalLight light) + { + Float3 forward = -light.Transform.Forward; + Float3 up = Float3.Normalize(light.Transform.Up); + Float3 right = Float3.Normalize(Float3.Cross(up, forward)); + up = Float3.Normalize(Float3.Cross(forward, right)); + return (right, up, forward); + } + + [Fact] + public void DirectionalLight_GetShadowMatrix_CentersOrthoOnFocusWithinTexel() + { + DirectionalLight light = CreateAngledLight(); + Float3 focus = new(37.4f, 2.6f, -18.9f); + + light.GetShadowMatrix(focus, Resolution, CascadeDistance, out Float4x4 view, out _); + + // In light view space the focus point should sit at the origin, off only by the texel + // snapping applied to X and Y (at most half a texel each). If placement drifted further the + // focal point would no longer be in the middle of the cascade it was built for. + Float3 focusInLightSpace = Float4x4.TransformPoint(focus, view); + float tolerance = (TexelSize * 0.5f) + 1e-4f; + + Assert.True(MathF.Abs(focusInLightSpace.X) <= tolerance, + $"Focus X in light space was {focusInLightSpace.X}, expected within {tolerance}."); + Assert.True(MathF.Abs(focusInLightSpace.Y) <= tolerance, + $"Focus Y in light space was {focusInLightSpace.Y}, expected within {tolerance}."); + } + + [Fact] + public void DirectionalLight_GetShadowMatrix_SubTexelMovement_ProducesIdenticalView() + { + DirectionalLight light = CreateAngledLight(); + (Float3 right, Float3 up, Float3 forward) = LightBasis(light); + + // Start exactly on the texel grid so a fifth-of-a-texel step can't straddle a rounding + // boundary and legitimately land on the next grid point. + Float3 focus = (right * (14f * TexelSize)) + (up * (-9f * TexelSize)) + (forward * 6.5f); + Float3 nudged = focus + (right * (TexelSize * 0.2f)); + + light.GetShadowMatrix(focus, Resolution, CascadeDistance, out Float4x4 view, out _); + light.GetShadowMatrix(nudged, Resolution, CascadeDistance, out Float4x4 nudgedView, out _); + + // Identical, not merely close: a shadow map that slides by a fraction of a texel each frame + // is what makes shadow edges crawl, and this is exactly the case a moving player hits. + Assert.Equal(view.ToArray(), nudgedView.ToArray()); + } +} diff --git a/Prowl.Runtime/Assets/Defaults/Lighting.glsl b/Prowl.Runtime/Assets/Defaults/Lighting.glsl index e7c4cca92..f9a6f8fc9 100644 --- a/Prowl.Runtime/Assets/Defaults/Lighting.glsl +++ b/Prowl.Runtime/Assets/Defaults/Lighting.glsl @@ -51,6 +51,11 @@ uniform vec4 _CascadeAtlasParams1; uniform vec4 _CascadeAtlasParams2; uniform vec4 _CascadeAtlasParams3; +// World-space point this frame's cascades were centered on (the camera position, or the +// camera's shadow focus target when set). Cascade selection must measure distance from the +// same point the cascade boxes were built around, or selection disagrees with placement. +uniform vec3 _ShadowFocusPos; + // Point shadows (6 faces per light). A point light occupying slot s uses indices [s*6 .. s*6+5]. uniform mat4 _PointShadowMatrices[MAX_SHADOW_CASTERS * 6]; uniform vec4 _PointShadowFaceParams[MAX_SHADOW_CASTERS * 6]; // xy: atlasPos, z: faceSize, w: farPlane @@ -105,8 +110,8 @@ float SampleDirectionalShadow(vec3 worldPos, vec3 worldNormal) // Compare squared distance against squared cascade splits to avoid the per-fragment sqrt. // worldDistance was distance(...) * 2.0, so the squared form is dot(d,d) * 4.0. - vec3 toCamera = worldPos - _WorldSpaceCameraPos.xyz; - float worldDistSq = dot(toCamera, toCamera) * 4.0; + vec3 toFocus = worldPos - _ShadowFocusPos; + float worldDistSq = dot(toFocus, toFocus) * 4.0; mat4 cascadeMatrix; vec4 cascadeParams; diff --git a/Prowl.Runtime/Assets/Defaults/VolumetricFog.shader b/Prowl.Runtime/Assets/Defaults/VolumetricFog.shader index ec16375ef..35cf9e524 100644 --- a/Prowl.Runtime/Assets/Defaults/VolumetricFog.shader +++ b/Prowl.Runtime/Assets/Defaults/VolumetricFog.shader @@ -119,7 +119,7 @@ Pass "FogMarch" { if (_CascadeCount == 0) return 0.0; - float worldDistance = distance(worldPos, _WorldSpaceCameraPos.xyz) * 2.0; + float worldDistance = distance(worldPos, _ShadowFocusPos) * 2.0; mat4 cascadeMatrix; vec4 cascadeParams; diff --git a/Prowl.Runtime/Components/Camera.cs b/Prowl.Runtime/Components/Camera.cs index e794bb62e..f60604f2a 100644 --- a/Prowl.Runtime/Components/Camera.cs +++ b/Prowl.Runtime/Components/Camera.cs @@ -91,6 +91,14 @@ public enum ProjectionType { Perspective, Orthographic } public bool HDR = false; public float RenderScale = 1.0f; + /// + /// Optional transform that directional-light shadow cascades center on when this camera renders. + /// Intended for third-person games where the player character, not the camera, should sit in the + /// highest-resolution cascade. When null, or when its GameObject has been destroyed, cascades + /// center on the camera's own position. + /// + public Transform? ShadowFocus; + public bool IsOrthographic => ProjectionMode == ProjectionType.Orthographic; private float _aspect; @@ -370,6 +378,19 @@ public Ray ScreenPointToRay(Float2 screenPoint, Float2 screenSize) return new Ray(rayOrigin, rayDirection); } + /// + /// Resolves the world-space point directional shadow cascades center on for this camera: + /// the position of when it is set and belongs to a live GameObject, + /// otherwise the camera's own position. + /// + public Float3 GetShadowFocusPosition() + { + Transform? focus = ShadowFocus; + if (focus == null || focus.GameObject.IsNotValid()) + return Transform.Position; + return focus.Position; + } + public Float4x4 GetViewMatrix(bool applyPosition = true) { Float3 position = applyPosition ? Transform.Position : Float3.Zero; diff --git a/Prowl.Runtime/Components/Lights/DirectionalLight.cs b/Prowl.Runtime/Components/Lights/DirectionalLight.cs index bff223e3b..0de06f3a8 100644 --- a/Prowl.Runtime/Components/Lights/DirectionalLight.cs +++ b/Prowl.Runtime/Components/Lights/DirectionalLight.cs @@ -79,9 +79,12 @@ public override void DrawGizmos() public override LightType GetLightType() => LightType.Directional; - private void GetShadowMatrix(Float3 cameraPosition, int shadowResolution, float cascadeDistance, out Float4x4 view, out Float4x4 projection) + internal void GetShadowMatrix(Float3 focusPosition, int shadowResolution, float cascadeDistance, out Float4x4 view, out Float4x4 projection) { Float3 forward = -Transform.Forward; + // Depth range is a fixed +/- cascadeDistance * 0.5 slab around the (snapped) focus point, not a + // fit to the scene's casters. Occluders further toward the light than half a cascade get clipped + // out of the map and stop casting into it. projection = Float4x4.CreateOrtho(cascadeDistance, cascadeDistance, -cascadeDistance * 0.5f, cascadeDistance * 0.5f); // Calculate texel size in world units @@ -92,10 +95,10 @@ private void GetShadowMatrix(Float3 cameraPosition, int shadowResolution, float Float3 lightRight = Float3.Normalize(Float3.Cross(lightUp, forward)); lightUp = Float3.Normalize(Float3.Cross(forward, lightRight)); // Recompute to ensure orthogonality - // Project camera position onto light space axes - float x = Float3.Dot(cameraPosition, lightRight); - float y = Float3.Dot(cameraPosition, lightUp); - float z = Float3.Dot(cameraPosition, forward); // KEEP the Z component! god damnit lost so much time to this + // Project the focus position onto light space axes + float x = Float3.Dot(focusPosition, lightRight); + float y = Float3.Dot(focusPosition, lightUp); + float z = Float3.Dot(focusPosition, forward); // KEEP the Z component! god damnit lost so much time to this // Snap only X and Y to texel grid in light space x = Maths.Round(x / texelSize) * texelSize; @@ -108,7 +111,7 @@ private void GetShadowMatrix(Float3 cameraPosition, int shadowResolution, float view = Float4x4.CreateLookTo(snappedPosition, forward, Transform.Up); } - public override void RenderShadows(RenderPipeline pipeline, Float3 cameraPosition, System.Collections.Generic.IReadOnlyList renderables) + public override void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, System.Collections.Generic.IReadOnlyList renderables) { if (!DoCastShadows()) { @@ -151,7 +154,7 @@ public override void RenderShadows(RenderPipeline pipeline, Float3 cameraPositio int atlasX = slot.Value.X; int atlasY = slot.Value.Y; - GetShadowMatrix(cameraPosition, res, cascadeDistance, out Float4x4 view, out Float4x4 proj); + GetShadowMatrix(shadowFocusPosition, res, cascadeDistance, out Float4x4 view, out Float4x4 proj); Frustum frustum = Frustum.FromMatrix(proj * view); diff --git a/Prowl.Runtime/Components/Lights/Light.cs b/Prowl.Runtime/Components/Lights/Light.cs index d45a50f83..b11d58025 100644 --- a/Prowl.Runtime/Components/Lights/Light.cs +++ b/Prowl.Runtime/Components/Lights/Light.cs @@ -69,13 +69,8 @@ public override void OnRenderCollect(Camera camera, List renderable public virtual bool DoCastShadows() => CastShadows; /// - /// Renders this light's shadow map into the shadow atlas. - /// Called by the render pipeline during shadow pass. - /// - /// The current render pipeline - /// Position of the camera in world space - /// List of all renderables that could cast shadows - /// Render this light's shadow map(s) into the shared shadow atlas. + /// Render this light's shadow map(s) into the shared shadow atlas. + /// Called by the render pipeline during the shadow pass. /// /// /// Implementations rent and submit their own per face @@ -91,7 +86,12 @@ public override void OnRenderCollect(Camera camera, List renderable /// separate setup CB before this method runs. /// /// - public abstract void RenderShadows(RenderPipeline pipeline, Float3 cameraPosition, System.Collections.Generic.IReadOnlyList renderables); + /// The current render pipeline. + /// World-space point shadows are prioritized around: the + /// rendering camera's position, or the camera's position when + /// set. Directional lights center their cascades on it; point and spot lights ignore it. + /// List of all renderables that could cast shadows. + public abstract void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, System.Collections.Generic.IReadOnlyList renderables); public abstract ForwardLightData GetForwardLightData(); } diff --git a/Prowl.Runtime/Components/Lights/PointLight.cs b/Prowl.Runtime/Components/Lights/PointLight.cs index 15e417733..2a6eda765 100644 --- a/Prowl.Runtime/Components/Lights/PointLight.cs +++ b/Prowl.Runtime/Components/Lights/PointLight.cs @@ -46,7 +46,7 @@ public override void DrawGizmosSelected() public override LightType GetLightType() => LightType.Point; - public override void RenderShadows(RenderPipeline pipeline, Float3 cameraPosition, System.Collections.Generic.IReadOnlyList renderables) + public override void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, System.Collections.Generic.IReadOnlyList renderables) { if (!DoCastShadows()) { diff --git a/Prowl.Runtime/Components/Lights/SpotLight.cs b/Prowl.Runtime/Components/Lights/SpotLight.cs index 916acf4f7..daa1f1ebd 100644 --- a/Prowl.Runtime/Components/Lights/SpotLight.cs +++ b/Prowl.Runtime/Components/Lights/SpotLight.cs @@ -81,7 +81,7 @@ private void GetShadowMatrix(out Float4x4 view, out Float4x4 projection) view = Float4x4.CreateLookTo(position, forward, Transform.Up); } - public override void RenderShadows(RenderPipeline pipeline, Float3 cameraPosition, System.Collections.Generic.IReadOnlyList renderables) + public override void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, System.Collections.Generic.IReadOnlyList renderables) { if (!DoCastShadows()) { diff --git a/Prowl.Runtime/Rendering/DefaultRenderPipeline.cs b/Prowl.Runtime/Rendering/DefaultRenderPipeline.cs index 896f52fd4..761565a02 100644 --- a/Prowl.Runtime/Rendering/DefaultRenderPipeline.cs +++ b/Prowl.Runtime/Rendering/DefaultRenderPipeline.cs @@ -239,7 +239,7 @@ private void Internal_Render(Camera camera, in RenderingData data) // is rendered for only the directional and the closest-N point/spot, and finally the // BVH textures + directional + shadow uniforms are pushed to the GPU. SceneLightSystem lightSystem = GetOrCreateLightSystem(css.Scene); - lightSystem.Reconcile(lights, css.CameraPosition, css.CullingMask); + lightSystem.Reconcile(lights, css.ShadowFocusPosition, css.CullingMask); // ─── Shadow atlas setup (clear) ─── // Done in its own CB and submitted before the lights start so the depth/stencil @@ -254,11 +254,11 @@ private void Internal_Render(Camera camera, in RenderingData data) } RenderStats.BeginShadowPass(); - lightSystem.RenderShadows(this, css.CameraPosition, renderables); + lightSystem.RenderShadows(this, css.ShadowFocusPosition, renderables); RenderStats.EndShadowPass(); AssignCameraMatrices(css.View, css.Projection); - lightSystem.UploadGlobalUniforms(); + lightSystem.UploadGlobalUniforms(css.ShadowFocusPosition); UploadFogUniforms(css.Scene); UploadAmbientUniforms(css.Scene); diff --git a/Prowl.Runtime/Rendering/RenderPipeline.cs b/Prowl.Runtime/Rendering/RenderPipeline.cs index ec61aae62..7d64bc7b5 100644 --- a/Prowl.Runtime/Rendering/RenderPipeline.cs +++ b/Prowl.Runtime/Rendering/RenderPipeline.cs @@ -158,6 +158,11 @@ public struct CameraSnapshot(Camera camera) public Scene Scene = camera.Scene; public Float3 CameraPosition = camera.Transform.Position; + + /// World-space center for directional shadow cascades this render: the camera's + /// position when set, otherwise . + public Float3 ShadowFocusPosition = camera.GetShadowFocusPosition(); + public Float3 CameraRight = camera.Transform.Right; public Float3 CameraUp = camera.Transform.Up; public Float3 CameraForward = camera.Transform.Forward; diff --git a/Prowl.Runtime/Rendering/SceneLightSystem.cs b/Prowl.Runtime/Rendering/SceneLightSystem.cs index 5a6393e3f..6047f6e7a 100644 --- a/Prowl.Runtime/Rendering/SceneLightSystem.cs +++ b/Prowl.Runtime/Rendering/SceneLightSystem.cs @@ -20,8 +20,10 @@ namespace Prowl.Runtime.Rendering; /// /// /// -/// A bounded number of point + spot lights win shadow atlas slots each frame, picked by camera -/// distance. Lights that miss the cut still light surfaces; they just sample as unshadowed. +/// A bounded number of point + spot lights win shadow atlas slots each frame, picked by distance +/// to the frame's shadow focus point (the camera position, or the camera's +/// position when one is set). Lights that miss the cut still light +/// surfaces; they just sample as unshadowed. /// /// public sealed class SceneLightSystem : IDisposable @@ -63,7 +65,9 @@ private enum Membership { Static, Dynamic } /// /// Walk this frame's lights, register / unregister with the appropriate BVH, refit dynamics, /// pick the directional + closest-N shadow casters, and upload only the dirty rows of each - /// texture. Cheap when nothing changed. + /// texture. Cheap when nothing changed. Shadow casters are picked by distance to + /// , the frame's shadow focus point (the camera position, or + /// the camera's position when one is set). /// /// /// Note on : per-camera light filtering by layer is not @@ -73,7 +77,7 @@ private enum Membership { Static, Dynamic } /// affects every camera. The argument is kept for forward compatibility. /// /// - public void Reconcile(IReadOnlyList lights, Float3 cameraPos, LayerMask cullingMask) + public void Reconcile(IReadOnlyList lights, Float3 shadowFocusPos, LayerMask cullingMask) { _ = cullingMask; // see remark above _seenThisFrame.Clear(); @@ -143,7 +147,7 @@ public void Reconcile(IReadOnlyList lights, Float3 cameraPos, // Track for shadow-caster selection. if (light.DoCastShadows()) { - float dSq = (float)Float3.DistanceSquared(cameraPos, light.GetLightPosition()); + float dSq = (float)Float3.DistanceSquared(shadowFocusPos, light.GetLightPosition()); localCandidates.Add((light, dSq, true)); } } @@ -211,19 +215,24 @@ private static bool IsStaticLight(IRenderableLight light) /// shadow casters into the shared shadow atlas. The pipeline calls this after binding the /// shadow framebuffer. /// - public void RenderShadows(RenderPipeline pipeline, Float3 cameraPosition, IReadOnlyList renderables) + /// The current render pipeline. + /// World-space point the directional light centers its + /// cascades on: the rendering camera's position, or its + /// position when one is set. + /// Everything that could cast a shadow this frame. + public void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, IReadOnlyList renderables) { // Each light manages its own CommandBuffer(s) internally point lights submit // one per face, directional submits one per cascade, spot submits a single CB // so per-face matrix uploads via AssignCameraMatrices are ordered correctly // against that face's draws. if (_directional is Light dl) - dl.RenderShadows(pipeline, cameraPosition, renderables); + dl.RenderShadows(pipeline, shadowFocusPosition, renderables); for (int i = 0; i < _shadowCasters.Count; i++) { if (_shadowCasters[i] is Light sc) - sc.RenderShadows(pipeline, cameraPosition, renderables); + sc.RenderShadows(pipeline, shadowFocusPosition, renderables); } } @@ -233,14 +242,17 @@ public void RenderShadows(RenderPipeline pipeline, Float3 cameraPosition, IReadO /// arrays for the selected closest-N point + spot lights. Call after /// and , before any forward draws. /// - public void UploadGlobalUniforms() + /// The point this frame's cascades were centered on. Uploaded + /// as _ShadowFocusPos so shader-side cascade selection measures distance from the same + /// point the cascades were built around. + public void UploadGlobalUniforms(Float3 shadowFocusPosition) { // All of these are global-uniform writes. Routing each through its own one-op // CommandBuffer (the PropertyState.SetGlobalX helpers) meant ~80-100 rent/submit // cycles per camera per frame. Encode them all into a single buffer and submit once. using var cmd = Graphics.GetCommandBuffer("LightUniforms"); UploadBVHTextures(cmd); - UploadDirectionalLight(cmd); + UploadDirectionalLight(cmd, shadowFocusPosition); UploadLocalShadowSlots(cmd); Graphics.Submit(cmd); } @@ -281,8 +293,12 @@ private static int Log2(int size) return n; } - private void UploadDirectionalLight(CommandBuffer cmd) + private void UploadDirectionalLight(CommandBuffer cmd, Float3 shadowFocusPosition) { + // Written before the early-out: cascade selection in the shader reads this every frame, + // so it has to stay fresh even on frames with no directional light at all. + cmd.SetGlobalVector("_ShadowFocusPos", shadowFocusPosition); + if (_directional == null) { cmd.SetGlobalInt("_DirectionalLightEnabled", 0); diff --git a/Samples/BananaMan/Program.cs b/Samples/BananaMan/Program.cs index 1eb0b2ab5..f1089c3f7 100644 --- a/Samples/BananaMan/Program.cs +++ b/Samples/BananaMan/Program.cs @@ -9,6 +9,7 @@ // Fly Up: E // Fly Down: Q // Sprint: Left Shift +// Shadow Focus Toggle: F (cascades follow the player vs. the camera) // using Prowl.Runtime; @@ -31,6 +32,8 @@ static void Main(string[] args) public sealed class MyGame : Game { private GameObject? cameraGO; + private Camera? camera; + private GameObject? playerGO; private Scene? scene; // Input Actions @@ -52,14 +55,30 @@ public override void Initialize() GameObject lightGO = new("Directional Light"); DirectionalLight light = lightGO.AddComponent(); light.Color = Color.White; + // Four cascades over a large ground plane: the resolution difference between the near and far + // cascade is what the shadow focus toggle below makes visible. + light.Cascades = DirectionalLight.CascadeCount.Four; lightGO.Transform.LocalEulerAngles = new Float3(-45, 45, 0); scene.Add(lightGO); - // Create camera + // Create ground plane + AddCube("Ground", new Float3(0, -1, 0), new Float3(80, 0.1f, 80), new Color(0.5f, 0.5f, 0.5f, 1.0f)); + + // The player stand-in, far enough from the camera to land in a coarse far cascade when the + // cascades are centered on the camera instead of on it. + playerGO = AddCube("Player", new Float3(0, 0, 25), new Float3(1, 2, 1), new Color(0.9f, 0.7f, 0.2f, 1.0f)); + + // Props beside the player, to show shadow detail (or the lack of it) on more than one edge. + AddCube("Prop A", new Float3(2.0f, -0.6f, 24.0f), new Float3(0.8f, 0.8f, 0.8f), new Color(0.8f, 0.3f, 0.3f, 1.0f)); + AddCube("Prop B", new Float3(-2.2f, -0.45f, 26.0f), new Float3(1.1f, 1.1f, 1.1f), new Color(0.3f, 0.6f, 0.8f, 1.0f)); + AddCube("Prop C", new Float3(-1.0f, -0.75f, 23.0f), new Float3(0.5f, 0.5f, 0.5f), new Color(0.4f, 0.8f, 0.4f, 1.0f)); + + // Create camera, third-person: behind and above the player, looking at it. cameraGO = new("Main Camera"); cameraGO.Tag = "Main Camera"; - cameraGO.Transform.Position = new(0, 1.5f, -5); - Camera camera = cameraGO.AddComponent(); + cameraGO.Transform.Position = new(0, 6, -10); + cameraGO.Transform.LookAt(playerGO.Transform.Position); + camera = cameraGO.AddComponent(); camera.Depth = -1; camera.HDR = true; camera.Effects = @@ -68,18 +87,11 @@ public override void Initialize() new BloomEffect(), new TonemapperEffect(), ]; + // Centre the cascades on the player rather than on the camera, so the character sits in the + // highest-resolution cascade instead of a distant coarse one. Press F to compare. + camera.ShadowFocus = playerGO.Transform; scene.Add(cameraGO); - // Create ground plane - GameObject groundGO = new("Ground"); - MeshRenderer mr = groundGO.AddComponent(); - mr.Mesh = Mesh.CreateCube(Float3.One); - mr.Material = new Material(Shader.LoadDefault(DefaultShader.Standard)); - mr.Material.Res?.SetColor("_MainColor", new Color(0.5f, 0.5f, 0.5f, 1.0f)); - groundGO.Transform.Position = new(0, -1, 0); - groundGO.Transform.LocalScale = new(20, 0.1f, 20); - scene.Add(groundGO); - // Load and create BananaMan CreateBananaMan(); @@ -87,6 +99,20 @@ public override void Initialize() Scene.Load(scene); } + /// Adds a coloured unit cube to the scene, scaled and positioned as given. + private GameObject AddCube(string name, Float3 position, Float3 scale, Color color) + { + GameObject go = new(name); + MeshRenderer mr = go.AddComponent(); + mr.Mesh = Mesh.CreateCube(Float3.One); + mr.Material = new Material(Shader.LoadDefault(DefaultShader.Standard)); + mr.Material.Res?.SetColor("_MainColor", color); + go.Transform.Position = position; + go.Transform.LocalScale = scale; + scene!.Add(go); + return go; + } + private void CreateBananaMan() { // Try to load BananaMan.fbx @@ -206,6 +232,17 @@ public override void BeginUpdate() cameraGO.Transform.LocalEulerAngles += new Float3(lookInput.Y, lookInput.X, 0); } + // Flip the shadow cascades between centring on the player and centring on the camera. + // With focus on, the player's shadows stay crisp however far the camera flies away; with it + // off, the resolution follows the camera and the player's shadows go blocky. + if (Input.GetKeyDown(KeyCode.F)) + { + camera.ShadowFocus = camera.ShadowFocus == null ? playerGO.Transform : null; + Debug.Log(camera.ShadowFocus == null + ? "Shadow focus: Camera (default)" + : "Shadow focus: Player"); + } + if (Input.GetKeyDown(KeyCode.Escape)) Input.UnlockCursor(); }