Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions Prowl.Runtime.Test/CameraShadowFocusTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="Camera.ShadowFocus"/>, 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.
/// </summary>
public class CameraShadowFocusTests : RuntimeTestBase
{
private Camera CreateCamera(Float3 position, string name = "Camera")
{
GameObject go = CreateGameObject(name);
go.Transform.Position = position;
return go.AddComponent<Camera>();
}

[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<Scene>(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<Camera>();

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());
}
}
81 changes: 81 additions & 0 deletions Prowl.Runtime.Test/DirectionalLightShadowMatrixTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Pure-math tests (no GPU) for <see cref="DirectionalLight.GetShadowMatrix"/>: a cascade lands
/// centered on the focus point it is handed - the rendering camera, or the camera's
/// <see cref="Camera.ShadowFocus"/> target - and the light-space texel snapping that keeps shadow
/// edges from shimmering survives sub-texel movement of that point.
/// </summary>
public class DirectionalLightShadowMatrixTests : RuntimeTestBase
{
private const int Resolution = 2048;
private const float CascadeDistance = 35f;
private const float TexelSize = (CascadeDistance * 2f) / Resolution;

/// <summary>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.</summary>
private DirectionalLight CreateAngledLight()
{
GameObject go = CreateGameObject("Directional Light");
go.Transform.LocalEulerAngles = new Float3(-50f, 30f, 0f);
return go.AddComponent<DirectionalLight>();
}

/// <summary>The orthonormal light-space basis GetShadowMatrix builds internally.</summary>
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());
}
}
9 changes: 7 additions & 2 deletions Prowl.Runtime/Assets/Defaults/Lighting.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion Prowl.Runtime/Assets/Defaults/VolumetricFog.shader
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
21 changes: 21 additions & 0 deletions Prowl.Runtime/Components/Camera.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ public enum ProjectionType { Perspective, Orthographic }
public bool HDR = false;
public float RenderScale = 1.0f;

/// <summary>
/// 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.
/// </summary>
public Transform? ShadowFocus;

public bool IsOrthographic => ProjectionMode == ProjectionType.Orthographic;

private float _aspect;
Expand Down Expand Up @@ -370,6 +378,19 @@ public Ray ScreenPointToRay(Float2 screenPoint, Float2 screenSize)
return new Ray(rayOrigin, rayDirection);
}

/// <summary>
/// Resolves the world-space point directional shadow cascades center on for this camera:
/// the position of <see cref="ShadowFocus"/> when it is set and belongs to a live GameObject,
/// otherwise the camera's own position.
/// </summary>
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;
Expand Down
17 changes: 10 additions & 7 deletions Prowl.Runtime/Components/Lights/DirectionalLight.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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<IRenderable> renderables)
public override void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, System.Collections.Generic.IReadOnlyList<IRenderable> renderables)
{
if (!DoCastShadows())
{
Expand Down Expand Up @@ -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);

Expand Down
16 changes: 8 additions & 8 deletions Prowl.Runtime/Components/Lights/Light.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,8 @@ public override void OnRenderCollect(Camera camera, List<IRenderable> renderable
public virtual bool DoCastShadows() => CastShadows;

/// <summary>
/// Renders this light's shadow map into the shadow atlas.
/// Called by the render pipeline during shadow pass.
/// </summary>
/// <param name="pipeline">The current render pipeline</param>
/// <param name="cameraPosition">Position of the camera in world space</param>
/// <param name="renderables">List of all renderables that could cast shadows</param>
/// <summary>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.
///
/// <para>
/// Implementations rent and submit their own <see cref="CommandBuffer"/> per face
Expand All @@ -91,7 +86,12 @@ public override void OnRenderCollect(Camera camera, List<IRenderable> renderable
/// separate setup CB before this method runs.
/// </para>
/// </summary>
public abstract void RenderShadows(RenderPipeline pipeline, Float3 cameraPosition, System.Collections.Generic.IReadOnlyList<IRenderable> renderables);
/// <param name="pipeline">The current render pipeline.</param>
/// <param name="shadowFocusPosition">World-space point shadows are prioritized around: the
/// rendering camera's position, or the camera's <see cref="Camera.ShadowFocus"/> position when
/// set. Directional lights center their cascades on it; point and spot lights ignore it.</param>
/// <param name="renderables">List of all renderables that could cast shadows.</param>
public abstract void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, System.Collections.Generic.IReadOnlyList<IRenderable> renderables);

public abstract ForwardLightData GetForwardLightData();
}
2 changes: 1 addition & 1 deletion Prowl.Runtime/Components/Lights/PointLight.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IRenderable> renderables)
public override void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, System.Collections.Generic.IReadOnlyList<IRenderable> renderables)
{
if (!DoCastShadows())
{
Expand Down
2 changes: 1 addition & 1 deletion Prowl.Runtime/Components/Lights/SpotLight.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IRenderable> renderables)
public override void RenderShadows(RenderPipeline pipeline, Float3 shadowFocusPosition, System.Collections.Generic.IReadOnlyList<IRenderable> renderables)
{
if (!DoCastShadows())
{
Expand Down
6 changes: 3 additions & 3 deletions Prowl.Runtime/Rendering/DefaultRenderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions Prowl.Runtime/Rendering/RenderPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ public struct CameraSnapshot(Camera camera)
public Scene Scene = camera.Scene;

public Float3 CameraPosition = camera.Transform.Position;

/// <summary>World-space center for directional shadow cascades this render: the camera's
/// <see cref="Camera.ShadowFocus"/> position when set, otherwise <see cref="CameraPosition"/>.</summary>
public Float3 ShadowFocusPosition = camera.GetShadowFocusPosition();

public Float3 CameraRight = camera.Transform.Right;
public Float3 CameraUp = camera.Transform.Up;
public Float3 CameraForward = camera.Transform.Forward;
Expand Down
Loading