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();
}