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