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:
co-authored by
Claude Opus 4.8
parent
d0104df304
commit
79d406a9f4
@@ -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,
|
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
|
||||||
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
|
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
|
||||||
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
|
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
|
||||||
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; a sampleable
|
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; plus a per-cell
|
||||||
`DayNight.SampleAt` for the simulation — point lights and shadows land here later). → `Core`.
|
**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`
|
- **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume`
|
||||||
(one knob for effects + music). → `Core`.
|
(one knob for effects + music). → `Core`.
|
||||||
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
|
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
|
||||||
|
|||||||
@@ -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.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.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.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 |
|
| `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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user