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.
185 lines
6.1 KiB
C#
185 lines
6.1 KiB
C#
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>
|
||
public readonly record struct LightSample(int X, int Y, float Radius, float Intensity);
|
||
|
||
/// <summary>
|
||
/// Builds a per-cell light grid (CPU, GPU-free, testable): an ambient base dimmed under occluders,
|
||
/// plus point lights that attenuate with distance and are blocked by occluders between the source
|
||
/// and the cell (grid-traced shadows). Pure data — a <see cref="Lightmap"/> turns the grid into a
|
||
/// texture and the simulation samples it for local light.
|
||
/// </summary>
|
||
public static class LightmapBuilder
|
||
{
|
||
/// <summary>How much of the ambient light reaches a cell that is itself an occluder (canopy/shade).</summary>
|
||
public const float OccluderShade = 0.35f;
|
||
|
||
/// <summary>
|
||
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with
|
||
/// 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,
|
||
int width,
|
||
int height,
|
||
float ambient,
|
||
ReadOnlySpan<bool> occluders,
|
||
IReadOnlyList<LightSample> lights,
|
||
Vector2 sunShadow = default,
|
||
float sunShadowStrength = 0f
|
||
)
|
||
{
|
||
for (var i = 0; i < light.Length; i++)
|
||
{
|
||
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)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var r = (int)MathF.Ceiling(l.Radius);
|
||
var minX = Math.Max(0, l.X - r);
|
||
var maxX = Math.Min(width - 1, l.X + r);
|
||
var minY = Math.Max(0, l.Y - r);
|
||
var maxY = Math.Min(height - 1, l.Y + r);
|
||
for (var y = minY; y <= maxY; y++)
|
||
{
|
||
for (var x = minX; x <= maxX; x++)
|
||
{
|
||
var dx = x - l.X;
|
||
var dy = y - l.Y;
|
||
var d = MathF.Sqrt(dx * dx + dy * dy);
|
||
if (d > l.Radius)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!Visible(l.X, l.Y, x, y, occluders, width))
|
||
{
|
||
continue; // в тени за препятствием
|
||
}
|
||
|
||
light[y * width + x] += l.Intensity * (1f - d / l.Radius);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (var i = 0; i < light.Length; i++)
|
||
{
|
||
light[i] = Math.Clamp(light[i], 0f, 1f);
|
||
}
|
||
}
|
||
|
||
// Направленная тень от солнца: каждый окклюдер (гора/зрелая крона) отбрасывает тень вдоль
|
||
// вектора 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(
|
||
int x0,
|
||
int y0,
|
||
int x1,
|
||
int y1,
|
||
ReadOnlySpan<bool> occluders,
|
||
int width
|
||
)
|
||
{
|
||
var dx = Math.Abs(x1 - x0);
|
||
var dy = Math.Abs(y1 - y0);
|
||
var sx = x0 < x1 ? 1 : -1;
|
||
var sy = y0 < y1 ? 1 : -1;
|
||
var err = dx - dy;
|
||
var x = x0;
|
||
var y = y0;
|
||
while (true)
|
||
{
|
||
if (x == x1 && y == y1)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
if ((x != x0 || y != y0) && occluders[y * width + x])
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var e2 = 2 * err;
|
||
if (e2 > -dy)
|
||
{
|
||
err -= dy;
|
||
x += sx;
|
||
}
|
||
|
||
if (e2 < dx)
|
||
{
|
||
err += dx;
|
||
y += sy;
|
||
}
|
||
}
|
||
}
|
||
}
|