Compare commits

...
3 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 46f3931f85 Lighting: lighter night floor (0.18 -> 0.24)
CI / build-test (push) Successful in 1m16s
Night was a touch too dark; raise the ambient moonlight floor a couple points.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:33:20 +03:00
Leonid Pershin 96e19c7c61 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.
2026-06-14 07:04:11 +03:00
Leonid Pershin 38bdfbbb81 Enhance Calendar and Climate systems with start day offsets
CI / build-test (push) Successful in 1m13s
- Added a `startDay` parameter to the `Calendar` class to allow for fractional day offsets at clock initialization, enabling more flexible time settings.
- Updated `TotalDays` calculation to include the `startDay` offset.
- Introduced `StartDayOfYear` in `ClimateSettings` to shift the seasonal phase, allowing worlds to begin at a specific time of year.
- Adjusted `YearProgress` and `Year` calculations in the `Climate` class to account for the new `StartDayOfYear`.
- Added unit tests to verify the functionality of the new start day features in both `Calendar` and `Climate` classes.
2026-06-13 06:55:09 +03:00
9 changed files with 190 additions and 13 deletions
+16 -6
View File
@@ -10,16 +10,21 @@ namespace MrGameEng.Core;
public sealed class Calendar
{
private readonly GameClock _clock;
private readonly double _startDay;
private float _secondsPerDay;
/// <summary>
/// Creates a calendar reading <paramref name="clock"/>; one day spans
/// <paramref name="secondsPerDay"/> seconds of scaled time (must be positive).
/// <paramref name="startDay"/> offsets the calendar by (fractional) days at clock 0 — e.g.
/// <c>7.0 / 24</c> starts the world at 07:00 instead of midnight. It shifts the time of day and
/// the day/night phase that reads <see cref="DayProgress"/>, without touching the clock itself.
/// </summary>
public Calendar(GameClock clock, float secondsPerDay)
public Calendar(GameClock clock, float secondsPerDay, double startDay = 0.0)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
SecondsPerDay = secondsPerDay;
_startDay = startDay;
}
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary>
@@ -36,8 +41,8 @@ public sealed class Calendar
);
}
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4).</summary>
public double TotalDays => _clock.TotalTime / _secondsPerDay;
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4), incl. the start offset.</summary>
public double TotalDays => _clock.TotalTime / _secondsPerDay + _startDay;
/// <summary>The current day number, counting from 1.</summary>
public int Day => (int)TotalDays + 1;
@@ -68,11 +73,16 @@ public static class CalendarEngineExtensions
/// <summary>
/// Creates a <see cref="Calendar"/> bound to the context's clock and registers it as a
/// service. Call once per world. <paramref name="secondsPerDay"/> is the scaled-time length
/// of one in-game day (must be positive).
/// of one in-game day (must be positive); <paramref name="startDay"/> offsets the starting
/// time of day in fractional days (e.g. <c>7.0 / 24</c> begins the world at 07:00).
/// </summary>
public static Calendar UseCalendar(this EngineContext context, float secondsPerDay)
public static Calendar UseCalendar(
this EngineContext context,
float secondsPerDay,
double startDay = 0.0
)
{
var calendar = new Calendar(context.Clock, secondsPerDay);
var calendar = new Calendar(context.Clock, secondsPerDay, startDay);
context.Services.Add(calendar);
return calendar;
}
+12 -2
View File
@@ -37,6 +37,13 @@ public readonly record struct ClimateSettings
/// <summary>Day of the year (0-based) with the highest temperature; defaults to mid-summer.</summary>
public int WarmestDay { get; init; }
/// <summary>
/// Day of the year (0-based) that the calendar's day 0 maps to. Shifts the whole seasonal phase
/// (season and temperature together) so a world can begin in a chosen part of the year — e.g. a
/// warm late spring instead of the cold turn of the year. Defaults to 0 (year begins at day 0).
/// </summary>
public int StartDayOfYear { get; init; }
/// <summary>Temperate defaults: a 60-day year, mean 12°, ±14° seasonal, ±5° daily, warmest mid-summer.</summary>
public static ClimateSettings Default =>
new()
@@ -78,18 +85,21 @@ public sealed class Climate
/// <summary>In-game days per year.</summary>
public int DaysPerYear => _settings.DaysPerYear;
/// <summary>Elapsed days shifted by <see cref="ClimateSettings.StartDayOfYear"/> — the seasonal clock.</summary>
private double YearDays => _calendar.TotalDays + _settings.StartDayOfYear;
/// <summary>Continuous position within the current year in <c>[0, 1)</c>.</summary>
public double YearProgress
{
get
{
var years = _calendar.TotalDays / _settings.DaysPerYear;
var years = YearDays / _settings.DaysPerYear;
return years - Math.Floor(years);
}
}
/// <summary>The current year, counting from 1.</summary>
public int Year => (int)(_calendar.TotalDays / _settings.DaysPerYear) + 1;
public int Year => (int)(YearDays / _settings.DaysPerYear) + 1;
/// <summary>Day within the current year, 0-based.</summary>
public int DayOfYear => (int)(YearProgress * _settings.DaysPerYear);
@@ -52,6 +52,26 @@ public sealed class DayNight
/// <summary>Light intensity at a world point in <c>[0, 1]</c>. Global today; local (with shadows) later.</summary>
public float SampleAt(Vector2 world) => Daylight;
/// <summary>
/// Offset, in grid cells, of the shadow an occluder casts under the current sun: opposite the
/// sun's eastwest position and longest near sunrise/sunset (low sun), shrinking to zero at noon
/// and at night. Feeds the lightmap's directional shadow pass; <paramref name="maxLength"/> caps
/// the dawn/dusk shadow length. Tilted slightly "south" (down) so shadows fall in front of objects.
/// </summary>
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));
}
/// <summary>Ambient tint for the scene: night color at night, day color at noon, eased between.</summary>
public Color Ambient
{
@@ -1,3 +1,5 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Lighting;
/// <summary>One point light projected onto the light grid: a cell position, a radius in cells and an intensity.</summary>
@@ -16,8 +18,10 @@ public static class LightmapBuilder
/// <summary>
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with
/// ambient light, shading occluder cells, then adds each point light with grid-traced occlusion.
/// Values end clamped to <c>[0, 1]</c>.
/// ambient light, shading occluder cells, casts each occluder's directional sun shadow along
/// <paramref name="sunShadow"/> (cells), then adds each point light with grid-traced occlusion.
/// Values end clamped to <c>[0, 1]</c>. A zero <paramref name="sunShadow"/> or
/// <paramref name="sunShadowStrength"/> skips the directional pass (e.g. at night/noon).
/// </summary>
public static void Build(
float[] light,
@@ -25,7 +29,9 @@ public static class LightmapBuilder
int height,
float ambient,
ReadOnlySpan<bool> occluders,
IReadOnlyList<LightSample> lights
IReadOnlyList<LightSample> 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<bool> 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(
@@ -14,7 +14,9 @@ namespace MrGameEng.Lighting;
public sealed class LightmapSystem : BaseSystem
{
private const int RebuildEvery = 6; // ~10 Гц при 60 fps
private const float NightFloor = 0.18f; // ночь тусклая, но не чёрная (лунный свет)
private const float NightFloor = 0.24f; // ночь тусклая, но не чёрная (лунный свет) — чуть светлее, чем было
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();
}
@@ -54,6 +54,20 @@ public class CalendarTests
Assert.Equal(14 * 60 + 30, calendar.MinuteOfDay);
}
[Fact]
public void StartDay_OffsetsTheTimeOfDay()
{
var clock = new GameClock();
var calendar = new Calendar(clock, secondsPerDay: 10f, startDay: 7.0 / 24); // begin at 07:00
Assert.Equal(1, calendar.Day);
Assert.Equal(7, calendar.Hour);
Assert.Equal(0, calendar.Minute);
clock.Advance(5f); // half a day later → 19:00
Assert.Equal(19, calendar.Hour);
}
[Fact]
public void Pause_DoesNotAdvanceTheCalendar()
{
@@ -81,4 +81,15 @@ public class ClimateTests
Assert.Equal(2, climate.Year);
Assert.Equal(0, climate.DayOfYear);
}
[Fact]
public void StartDayOfYear_ShiftsTheSeasonalPhase()
{
// Begin the world already at the warmest day (15): the curve peaks at clock 0.
var (_, climate) = Make(Seasonal with { StartDayOfYear = 15 });
Assert.Equal(30f, climate.Temperature, 2); // mean 10 + amplitude 20, at the peak
Assert.Equal(15, climate.DayOfYear); // day-of-year reflects the offset
Assert.Equal(Season.Summer, climate.Season); // day 15 of a 60-day year = start of summer
}
}
@@ -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()
{
@@ -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()
{