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
@@ -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;
}
}