Add 2D lightmap: occlusion shadows and point lights
CI / build-test (push) Successful in 1m14s

Extend the Lighting module with a per-cell lightmap. LightmapBuilder (pure,
tested) fills an ambient base, shades occluder cells, and adds point lights
that attenuate with distance and are blocked by occluders between source and
cell (grid-traced soft shadows). Lightmap holds the grid plus a greyscale
texture (Upload) and a bilinear SampleAt for the simulation; a PointLight
component places lights in the world. LightmapSystem rebuilds the grid a few
times a second (ambient from the day/night cycle with a night floor, occluders
from a game-supplied grid, point lights from the ECS); LightmapRenderSystem
multiplies the lightmap over the world after the sprite flush and under the
HUD. Wired via scene.UseLighting(...), sampled through Lighting.SampleAt. Docs
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 18:40:17 +03:00
co-authored by Claude Opus 4.8
parent d0104df304
commit 79d406a9f4
8 changed files with 524 additions and 3 deletions
+4 -2
View File
@@ -38,8 +38,10 @@ Engine libraries (each feature is a namespaced subfolder of its host):
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; a sampleable
`DayNight.SampleAt` for the simulation point lights and shadows land here later). → `Core`.
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; plus a per-cell
**lightmap**`LightmapBuilder` (ambient × occlusion + point lights with grid-traced shadows),
`PointLight` component, multiplied over the world via `scene.UseLighting(...)`, sampleable with
`Lighting.SampleAt` for the simulation). → `Core`.
- **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume`
(one knob for effects + music). → `Core`.
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
+1 -1
View File
@@ -34,7 +34,7 @@
| Библиотека (сборка) | Фичи (неймспейсы) и ответственность |
|------------------------------|------------------------------------------------------------|
| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps |
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar``Renderer2D.AmbientLight` на World-слоях, `scene.UseDayNight(renderer)`, сэмплируемый `DayNight.SampleAt` для симуляции |
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar``Renderer2D.AmbientLight`, `scene.UseDayNight(renderer)`; по-клеточный лайтмап — `LightmapBuilder` (амбиент × окклюзия + точечные `PointLight` с трассировкой теней), накладывается multiply поверх мира через `scene.UseLighting(...)`, сэмплируется `Lighting.SampleAt` |
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
| `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast |
@@ -0,0 +1,60 @@
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// Scene lighting service: owns the <see cref="Lightmap"/> and exposes a sampleable local light for
/// the simulation. Built by <see cref="SceneLightmapExtensions.UseLighting"/>, which also registers
/// the systems that rebuild the lightmap and multiply it over the scene.
/// </summary>
public sealed class Lighting
{
/// <summary>The light grid rendered over the world and sampled by the simulation.</summary>
public Lightmap Lightmap { get; }
/// <summary>Creates the service over <paramref name="lightmap"/>.</summary>
public Lighting(Lightmap lightmap) => Lightmap = lightmap;
/// <summary>Local light in <c>[0, 1]</c> at a world point (e.g. for plant growth under canopy).</summary>
public float SampleAt(Vector2 world) => Lightmap.SampleAt(world);
}
/// <summary>Wires the 2D lightmap (day/night × occlusion + point lights) into a <see cref="Scene"/>.</summary>
public static class SceneLightmapExtensions
{
/// <summary>
/// Builds a <see cref="Lighting"/> service for a <paramref name="width"/>×<paramref name="height"/>
/// cell world and registers the rebuild and composite systems. Ambient comes from
/// <paramref name="dayNight"/>; <paramref name="occluders"/> supplies the current occluder grid
/// (row-major, length width×height) each rebuild. Call after <c>UseRenderer2D</c>/<c>UseTilemaps</c>
/// and before <c>UseUI</c> so the lightmap composites over the world but under the HUD.
/// </summary>
public static Lighting UseLighting(
this Scene scene,
Renderer2D renderer,
DayNight dayNight,
int width,
int height,
float cellSize,
Vector2 origin,
Func<bool[]> occluders
)
{
var device = scene.Context.GraphicsDevice;
var lightmap = new Lightmap(device, width, height, cellSize, origin);
var lighting = new Lighting(lightmap);
scene.Context.Services.Add(lighting);
RegisterUnloadDispose(scene, lightmap);
scene.UpdateSystems.Add(
new LightmapSystem(scene.Store, lightmap, dayNight, occluders, cellSize, origin)
);
scene.DrawSystems.Add(new LightmapRenderSystem(device, renderer, lightmap));
return lighting;
}
private static void RegisterUnloadDispose(Scene scene, Lightmap lightmap) =>
scene.RegisterUnload(lightmap.Dispose);
}
@@ -0,0 +1,85 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// A per-cell light grid backed by a greyscale <see cref="Texture2D"/>. <see cref="LightmapBuilder"/>
/// fills <see cref="Light"/>; <see cref="Upload"/> pushes it to the texture (the renderer multiplies
/// it over the world for soft, cell-resolution shadows); <see cref="SampleAt"/> reads bilinearly for
/// the simulation (local light at a plant). The grid maps cell (x,y) to world
/// <c>Origin + (x+0.5, y+0.5)·CellSize</c>.
/// </summary>
public sealed class Lightmap : IDisposable
{
/// <summary>Grid width in cells.</summary>
public int Width { get; }
/// <summary>Grid height in cells.</summary>
public int Height { get; }
/// <summary>World size of one cell.</summary>
public float CellSize { get; }
/// <summary>World position of cell (0,0)'s top-left.</summary>
public Vector2 Origin { get; }
/// <summary>Per-cell light in <c>[0, 1]</c>, row-major. Filled by <see cref="LightmapBuilder"/>.</summary>
public float[] Light { get; }
/// <summary>The greyscale light texture (one texel per cell), updated by <see cref="Upload"/>.</summary>
public Texture2D Texture { get; }
private readonly Color[] _pixels;
/// <summary>Creates a lightmap grid and its backing texture on <paramref name="device"/>.</summary>
public Lightmap(GraphicsDevice device, int width, int height, float cellSize, Vector2 origin)
{
Width = width;
Height = height;
CellSize = cellSize;
Origin = origin;
Light = new float[width * height];
_pixels = new Color[width * height];
Texture = new Texture2D(device, width, height);
Array.Fill(Light, 1f);
Upload();
}
/// <summary>Writes the current <see cref="Light"/> grid into the texture as greyscale.</summary>
public void Upload()
{
for (var i = 0; i < Light.Length; i++)
{
var b = (byte)(Math.Clamp(Light[i], 0f, 1f) * 255f);
_pixels[i] = new Color(b, b, b, (byte)255);
}
Texture.SetData(_pixels);
}
/// <summary>Bilinearly samples the light at a world point; clamps at the edges.</summary>
public float SampleAt(Vector2 world)
{
var fx = (world.X - Origin.X) / CellSize - 0.5f;
var fy = (world.Y - Origin.Y) / CellSize - 0.5f;
fx = Math.Clamp(fx, 0f, Width - 1f);
fy = Math.Clamp(fy, 0f, Height - 1f);
var x0 = (int)fx;
var y0 = (int)fy;
var x1 = Math.Min(x0 + 1, Width - 1);
var y1 = Math.Min(y0 + 1, Height - 1);
var tx = fx - x0;
var ty = fy - y0;
var top = Lerp(Light[y0 * Width + x0], Light[y0 * Width + x1], tx);
var bottom = Lerp(Light[y1 * Width + x0], Light[y1 * Width + x1], tx);
return Lerp(top, bottom, ty);
}
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
/// <inheritdoc />
public void Dispose() => Texture.Dispose();
}
@@ -0,0 +1,120 @@
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, then adds each point light with grid-traced occlusion.
/// Values end clamped to <c>[0, 1]</c>.
/// </summary>
public static void Build(
float[] light,
int width,
int height,
float ambient,
ReadOnlySpan<bool> occluders,
IReadOnlyList<LightSample> lights
)
{
for (var i = 0; i < light.Length; i++)
{
light[i] = occluders[i] ? ambient * OccluderShade : ambient;
}
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);
}
}
// Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем
// промежуточные клетки на окклюдер (концы исключены).
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;
}
}
}
}
@@ -0,0 +1,164 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// Rebuilds the lightmap a few times a second: ambient from the day/night cycle, occluder cells from
/// the game-supplied grid and point lights from the ECS, then uploads it to the texture. Throttled by
/// frame skipping — the light changes slowly, so 10 Hz looks smooth and keeps cost low.
/// </summary>
public sealed class LightmapSystem : BaseSystem
{
private const int RebuildEvery = 6; // ~10 Гц при 60 fps
private const float NightFloor = 0.18f; // ночь тусклая, но не чёрная (лунный свет)
private readonly Lightmap _lightmap;
private readonly DayNight _dayNight;
private readonly Func<bool[]> _occluders;
private readonly float _cellSize;
private readonly Vector2 _origin;
private readonly ArchetypeQuery<Transform2D, PointLight> _query;
private readonly List<LightSample> _lights = [];
private int _frame;
internal LightmapSystem(
EntityStore store,
Lightmap lightmap,
DayNight dayNight,
Func<bool[]> occluders,
float cellSize,
Vector2 origin
)
{
_lightmap = lightmap;
_dayNight = dayNight;
_occluders = occluders;
_cellSize = cellSize;
_origin = origin;
_query = store.Query<Transform2D, PointLight>();
}
/// <inheritdoc />
protected override void OnUpdateGroup()
{
if (++_frame < RebuildEvery)
{
return;
}
_frame = 0;
_lights.Clear();
foreach (var (transforms, lights, _) in _query.Chunks)
{
var t = transforms.Span;
var l = lights.Span;
for (var i = 0; i < t.Length; i++)
{
if (l[i].Radius <= 0f || l[i].Intensity <= 0f)
{
continue;
}
var cx = (int)((t[i].Position.X - _origin.X) / _cellSize);
var cy = (int)((t[i].Position.Y - _origin.Y) / _cellSize);
_lights.Add(new LightSample(cx, cy, l[i].Radius / _cellSize, l[i].Intensity));
}
}
var ambient = NightFloor + (1f - NightFloor) * _dayNight.Intensity;
LightmapBuilder.Build(
_lightmap.Light,
_lightmap.Width,
_lightmap.Height,
ambient,
_occluders(),
_lights
);
_lightmap.Upload();
}
}
/// <summary>
/// Draws the lightmap over the world as a single texture quad with multiply blending and linear
/// filtering — the world darkens by the grid and shadows read soft. Registered after the sprite flush
/// (so it lands on the drawn scene) and before the screen-space UI (so the HUD stays at full brightness).
/// </summary>
public sealed class LightmapRenderSystem : BaseSystem
{
private static readonly BlendState Multiply = new()
{
ColorSourceBlend = Blend.DestinationColor,
ColorDestinationBlend = Blend.Zero,
AlphaSourceBlend = Blend.DestinationAlpha,
AlphaDestinationBlend = Blend.Zero,
};
private readonly GraphicsDevice _device;
private readonly Renderer2D _renderer;
private readonly Lightmap _lightmap;
private readonly BasicEffect _effect;
private readonly VertexPositionTexture[] _quad;
internal LightmapRenderSystem(GraphicsDevice device, Renderer2D renderer, Lightmap lightmap)
{
_device = device;
_renderer = renderer;
_lightmap = lightmap;
_effect = new BasicEffect(device)
{
TextureEnabled = true,
VertexColorEnabled = false,
World = Matrix.Identity,
};
var x0 = lightmap.Origin.X;
var y0 = lightmap.Origin.Y;
var x1 = x0 + lightmap.Width * lightmap.CellSize;
var y1 = y0 + lightmap.Height * lightmap.CellSize;
_quad =
[
new VertexPositionTexture(new Vector3(x0, y0, 0f), new Vector2(0f, 0f)),
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
new VertexPositionTexture(new Vector3(x1, y1, 0f), new Vector2(1f, 1f)),
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
];
}
/// <inheritdoc />
protected override void OnUpdateGroup()
{
var camera = _renderer.Camera;
_effect.View = camera.View;
_effect.Projection = camera.Projection;
_effect.Texture = _lightmap.Texture;
var previousViewport = _device.Viewport;
var mapping = camera.Mapping;
_device.Viewport = new Viewport(
(int)MathF.Round(mapping.Offset.X),
(int)MathF.Round(mapping.Offset.Y),
(int)MathF.Round(camera.VirtualWidth * mapping.Scale),
(int)MathF.Round(camera.VirtualHeight * mapping.Scale)
);
_device.BlendState = Multiply;
_device.SamplerStates[0] = SamplerState.LinearClamp;
_device.DepthStencilState = DepthStencilState.None;
_device.RasterizerState = RasterizerState.CullNone;
foreach (var pass in _effect.CurrentTechnique.Passes)
{
pass.Apply();
_device.DrawUserPrimitives(PrimitiveType.TriangleList, _quad, 0, 2);
}
_device.Viewport = previousViewport;
_device.BlendState = BlendState.AlphaBlend;
}
}
@@ -0,0 +1,21 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
namespace MrGameEng.Lighting;
/// <summary>
/// A point light: an entity with this component plus a <see cref="MrGameEng.Graphics.Transform2D"/>
/// adds light around its world position, attenuating to zero at <see cref="Radius"/> and casting
/// grid-traced shadows behind occluders. Picked up by the lighting system into the lightmap.
/// </summary>
public struct PointLight : IComponent
{
/// <summary>Reach of the light in world units (light fades to zero at this distance).</summary>
public float Radius;
/// <summary>Light tint (reserved for colored lights; intensity currently drives brightness).</summary>
public Color Color;
/// <summary>Peak brightness added at the light's centre (0..1+).</summary>
public float Intensity;
}
@@ -0,0 +1,69 @@
using MrGameEng.Lighting;
using Xunit;
namespace MrGameEng.Lighting.Tests;
public class LightmapBuilderTests
{
private static float[] Build(
int width,
int height,
float ambient,
bool[] occluders,
params LightSample[] lights
)
{
var light = new float[width * height];
LightmapBuilder.Build(light, width, height, ambient, occluders, lights);
return light;
}
[Fact]
public void Ambient_WithoutOccluders_IsUniform()
{
var light = Build(4, 4, 0.6f, new bool[16]);
Assert.All(light, v => Assert.Equal(0.6f, v, 5));
}
[Fact]
public void OccluderCell_IsDarkerThanOpenCell()
{
var occ = new bool[9];
occ[4] = true; // centre cell shaded
var light = Build(3, 3, 0.8f, occ);
Assert.True(light[4] < light[0]);
Assert.Equal(0.8f * LightmapBuilder.OccluderShade, light[4], 5);
}
[Fact]
public void PointLight_IsBrighterNearSourceAndFadesToRadius()
{
var light = Build(7, 1, 0f, new bool[7], new LightSample(0, 0, 6f, 1f));
Assert.True(light[0] > light[2] && light[2] > light[5]);
Assert.InRange(light[6], 0f, 0.01f); // на радиусе свет угасает
}
[Fact]
public void Occluder_CastsShadowBehindIt()
{
var occ = new bool[7];
occ[3] = true; // препятствие между источником (0) и дальними клетками
var light = Build(7, 1, 0f, occ, new LightSample(0, 0, 10f, 1f));
Assert.True(light[1] > 0f); // перед препятствием — освещено
Assert.Equal(0f, light[5], 5); // за препятствием — тень
}
[Fact]
public void Values_StayWithinUnitRange()
{
var light = Build(
5,
5,
0.5f,
new bool[25],
new LightSample(2, 2, 4f, 2f) // яркий свет — проверяем клампинг
);
Assert.All(light, v => Assert.InRange(v, 0f, 1f));
}
}