From 96e19c7c6151152c29c5c5478d365983d3df4069 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 14 Jun 2026 07:04:11 +0300 Subject: [PATCH] Lighting: directional sun shadows from the day/night sun angle DayNight.SunShadow returns a cell offset opposite the sun's east-west position, longest near sunrise/sunset (low sun) and zero at noon/night, tilted slightly south so shadows fall in front of occluders. LightmapBuilder gains a directional shadow pass (MaxShadowCells length cap, SunShadowStrength darkening); LightmapSystem feeds it the current sun shadow each rebuild. Tests cover SunShadow's day-arc behaviour and the builder's directional darkening. --- src/MrGameEng.Graphics/Lighting/DayNight.cs | 20 ++++++ .../Lighting/LightmapBuilder.cs | 70 ++++++++++++++++++- .../Lighting/LightmapSystems.cs | 6 +- .../MrGameEng.Graphics.Tests/DayNightTests.cs | 20 ++++++ .../LightmapBuilderTests.cs | 24 +++++++ 5 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/MrGameEng.Graphics/Lighting/DayNight.cs b/src/MrGameEng.Graphics/Lighting/DayNight.cs index 8f09853..5792d66 100644 --- a/src/MrGameEng.Graphics/Lighting/DayNight.cs +++ b/src/MrGameEng.Graphics/Lighting/DayNight.cs @@ -52,6 +52,26 @@ public sealed class DayNight /// Light intensity at a world point in [0, 1]. Global today; local (with shadows) later. public float SampleAt(Vector2 world) => Daylight; + /// + /// Offset, in grid cells, of the shadow an occluder casts under the current sun: opposite the + /// sun's east–west position and longest near sunrise/sunset (low sun), shrinking to zero at noon + /// and at night. Feeds the lightmap's directional shadow pass; caps + /// the dawn/dusk shadow length. Tilted slightly "south" (down) so shadows fall in front of objects. + /// + public Vector2 SunShadow(float maxLength) + { + var day = (_calendar.DayProgress - 0.25f) / 0.5f; // daytime fraction over [06:00, 18:00] + if (day <= 0f || day >= 1f) + { + return Vector2.Zero; // night — no sun; the ambient floor handles darkness + } + + var altitude = MathF.Sin(day * MathF.PI); // 0 at dawn/dusk, 1 at noon + var direction = new Vector2(2f * day - 1f, 0.4f); // sun east→west ⇒ shadow west→east, tilted south + direction.Normalize(); + return direction * (maxLength * (1f - altitude)); + } + /// Ambient tint for the scene: night color at night, day color at noon, eased between. public Color Ambient { diff --git a/src/MrGameEng.Graphics/Lighting/LightmapBuilder.cs b/src/MrGameEng.Graphics/Lighting/LightmapBuilder.cs index b0dbdbf..4816e23 100644 --- a/src/MrGameEng.Graphics/Lighting/LightmapBuilder.cs +++ b/src/MrGameEng.Graphics/Lighting/LightmapBuilder.cs @@ -1,3 +1,5 @@ +using Microsoft.Xna.Framework; + namespace MrGameEng.Lighting; /// One point light projected onto the light grid: a cell position, a radius in cells and an intensity. @@ -16,8 +18,10 @@ public static class LightmapBuilder /// /// Fills (length ×) with - /// ambient light, shading occluder cells, then adds each point light with grid-traced occlusion. - /// Values end clamped to [0, 1]. + /// ambient light, shading occluder cells, casts each occluder's directional sun shadow along + /// (cells), then adds each point light with grid-traced occlusion. + /// Values end clamped to [0, 1]. A zero or + /// skips the directional pass (e.g. at night/noon). /// public static void Build( float[] light, @@ -25,7 +29,9 @@ public static class LightmapBuilder int height, float ambient, ReadOnlySpan occluders, - IReadOnlyList lights + IReadOnlyList lights, + Vector2 sunShadow = default, + float sunShadowStrength = 0f ) { for (var i = 0; i < light.Length; i++) @@ -33,6 +39,8 @@ public static class LightmapBuilder light[i] = occluders[i] ? ambient * OccluderShade : ambient; } + CastSunShadows(light, width, height, occluders, sunShadow, sunShadowStrength); + foreach (var l in lights) { if (l.Radius <= 0f || l.Intensity <= 0f) @@ -73,6 +81,62 @@ public static class LightmapBuilder } } + // Направленная тень от солнца: каждый окклюдер (гора/зрелая крона) отбрасывает тень вдоль + // вектора sunShadow (в клетках). Затемнение гуще у основания и тает к концу тени; сами клетки- + // окклюдеры не трогаем (они уже затенены). Окклюдеры разрежены, так что проход дёшев. + private static void CastSunShadows( + float[] light, + int width, + int height, + ReadOnlySpan occluders, + Vector2 sunShadow, + float strength + ) + { + if (strength <= 0f) + { + return; + } + + var steps = (int)MathF.Ceiling(sunShadow.Length()); + if (steps <= 0) + { + return; + } + + var stepX = sunShadow.X / steps; + var stepY = sunShadow.Y / steps; + for (var oy = 0; oy < height; oy++) + { + for (var ox = 0; ox < width; ox++) + { + if (!occluders[oy * width + ox]) + { + continue; + } + + for (var s = 1; s <= steps; s++) + { + var cx = ox + (int)MathF.Round(stepX * s); + var cy = oy + (int)MathF.Round(stepY * s); + if (cx < 0 || cx >= width || cy < 0 || cy >= height) + { + break; + } + + var index = cy * width + cx; + if (occluders[index]) + { + continue; // тень проходит над другими окклюдерами — они и так тёмные + } + + var falloff = 1f - (float)(s - 1) / steps; // гуще у основания тени + light[index] *= 1f - strength * falloff; + } + } + } + } + // Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем // промежуточные клетки на окклюдер (концы исключены). private static bool Visible( diff --git a/src/MrGameEng.Graphics/Lighting/LightmapSystems.cs b/src/MrGameEng.Graphics/Lighting/LightmapSystems.cs index 1fd2679..7fc0855 100644 --- a/src/MrGameEng.Graphics/Lighting/LightmapSystems.cs +++ b/src/MrGameEng.Graphics/Lighting/LightmapSystems.cs @@ -15,6 +15,8 @@ public sealed class LightmapSystem : BaseSystem { private const int RebuildEvery = 6; // ~10 Гц при 60 fps private const float NightFloor = 0.18f; // ночь тусклая, но не чёрная (лунный свет) + private const float MaxShadowCells = 7f; // макс. длина тени от солнца (на рассвете/закате) + private const float SunShadowStrength = 0.5f; // насколько темнеет клетка у основания тени private readonly Lightmap _lightmap; private readonly DayNight _dayNight; @@ -77,7 +79,9 @@ public sealed class LightmapSystem : BaseSystem _lightmap.Height, ambient, _occluders(), - _lights + _lights, + _dayNight.SunShadow(MaxShadowCells), + SunShadowStrength ); _lightmap.Upload(); } diff --git a/tests/MrGameEng.Graphics.Tests/DayNightTests.cs b/tests/MrGameEng.Graphics.Tests/DayNightTests.cs index e21b126..0e50340 100644 --- a/tests/MrGameEng.Graphics.Tests/DayNightTests.cs +++ b/tests/MrGameEng.Graphics.Tests/DayNightTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Xna.Framework; using MrGameEng.Core; using MrGameEng.Lighting; using Xunit; @@ -39,6 +40,25 @@ public class DayNightTests Assert.True(dawn < mid && mid < noon, "daylight should rise from dawn to noon"); } + [Fact] + public void SunShadow_ZeroAtNight_PointsWestInMorning_EastInAfternoon_ShortAtNoon() + { + var (clock, dayNight) = Make(); // 1s = 1 in-game hour + + Assert.Equal(Vector2.Zero, dayNight.SunShadow(8f)); // 00:00 — night, no sun + + clock.Advance(7f); // 07:00 — low morning sun in the east + var morning = dayNight.SunShadow(8f); + Assert.True(morning.X < 0f, "morning shadow points west"); + Assert.True(morning.Length() > 2f, "low sun casts a long shadow"); + + clock.Advance(5f); // 12:00 — sun overhead + Assert.True(dayNight.SunShadow(8f).Length() < 1f, "noon sun casts almost no shadow"); + + clock.Advance(5f); // 17:00 — afternoon sun in the west + Assert.True(dayNight.SunShadow(8f).X > 0f, "afternoon shadow points east"); + } + [Fact] public void Ambient_DarkerAtNightThanAtNoon() { diff --git a/tests/MrGameEng.Graphics.Tests/LightmapBuilderTests.cs b/tests/MrGameEng.Graphics.Tests/LightmapBuilderTests.cs index 02172fa..d9b475d 100644 --- a/tests/MrGameEng.Graphics.Tests/LightmapBuilderTests.cs +++ b/tests/MrGameEng.Graphics.Tests/LightmapBuilderTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Xna.Framework; using MrGameEng.Lighting; using Xunit; @@ -54,6 +55,29 @@ public class LightmapBuilderTests Assert.Equal(0f, light[5], 5); // за препятствием — тень } + [Fact] + public void SunShadow_DarkensCellsInTheShadowDirection_FadingFromTheCaster() + { + var occ = new bool[7]; + occ[3] = true; // occluder in the middle + var light = new float[7]; + LightmapBuilder.Build( + light, + 7, + 1, + 1f, + occ, + [], + sunShadow: new Vector2(3f, 0f), + sunShadowStrength: 0.6f + ); + + Assert.Equal(1f, light[2], 5); // toward the sun (opposite the shadow) — unshadowed + Assert.Equal(LightmapBuilder.OccluderShade, light[3], 5); // occluder cell stays self-shaded + Assert.True(light[4] < 1f); // in shadow + Assert.True(light[4] < light[6]); // darker near the caster, fading along the shadow + } + [Fact] public void Values_StayWithinUnitRange() {