diff --git a/CLAUDE.md b/CLAUDE.md index 1d5e126..ba2e277 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 7bb4c25..39416b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`. **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`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast | diff --git a/src/MrGameEng.Graphics/Lighting/Lighting.cs b/src/MrGameEng.Graphics/Lighting/Lighting.cs new file mode 100644 index 0000000..0c65066 --- /dev/null +++ b/src/MrGameEng.Graphics/Lighting/Lighting.cs @@ -0,0 +1,60 @@ +using Microsoft.Xna.Framework; +using MrGameEng.Core; +using MrGameEng.Graphics; + +namespace MrGameEng.Lighting; + +/// +/// Scene lighting service: owns the and exposes a sampleable local light for +/// the simulation. Built by , which also registers +/// the systems that rebuild the lightmap and multiply it over the scene. +/// +public sealed class Lighting +{ + /// The light grid rendered over the world and sampled by the simulation. + public Lightmap Lightmap { get; } + + /// Creates the service over . + public Lighting(Lightmap lightmap) => Lightmap = lightmap; + + /// Local light in [0, 1] at a world point (e.g. for plant growth under canopy). + public float SampleAt(Vector2 world) => Lightmap.SampleAt(world); +} + +/// Wires the 2D lightmap (day/night × occlusion + point lights) into a . +public static class SceneLightmapExtensions +{ + /// + /// Builds a service for a × + /// cell world and registers the rebuild and composite systems. Ambient comes from + /// ; supplies the current occluder grid + /// (row-major, length width×height) each rebuild. Call after UseRenderer2D/UseTilemaps + /// and before UseUI so the lightmap composites over the world but under the HUD. + /// + public static Lighting UseLighting( + this Scene scene, + Renderer2D renderer, + DayNight dayNight, + int width, + int height, + float cellSize, + Vector2 origin, + Func 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); +} diff --git a/src/MrGameEng.Graphics/Lighting/Lightmap.cs b/src/MrGameEng.Graphics/Lighting/Lightmap.cs new file mode 100644 index 0000000..671a82c --- /dev/null +++ b/src/MrGameEng.Graphics/Lighting/Lightmap.cs @@ -0,0 +1,85 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MrGameEng.Lighting; + +/// +/// A per-cell light grid backed by a greyscale . +/// fills ; pushes it to the texture (the renderer multiplies +/// it over the world for soft, cell-resolution shadows); reads bilinearly for +/// the simulation (local light at a plant). The grid maps cell (x,y) to world +/// Origin + (x+0.5, y+0.5)·CellSize. +/// +public sealed class Lightmap : IDisposable +{ + /// Grid width in cells. + public int Width { get; } + + /// Grid height in cells. + public int Height { get; } + + /// World size of one cell. + public float CellSize { get; } + + /// World position of cell (0,0)'s top-left. + public Vector2 Origin { get; } + + /// Per-cell light in [0, 1], row-major. Filled by . + public float[] Light { get; } + + /// The greyscale light texture (one texel per cell), updated by . + public Texture2D Texture { get; } + + private readonly Color[] _pixels; + + /// Creates a lightmap grid and its backing texture on . + 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(); + } + + /// Writes the current grid into the texture as greyscale. + 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); + } + + /// Bilinearly samples the light at a world point; clamps at the edges. + 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; + + /// + public void Dispose() => Texture.Dispose(); +} diff --git a/src/MrGameEng.Graphics/Lighting/LightmapBuilder.cs b/src/MrGameEng.Graphics/Lighting/LightmapBuilder.cs new file mode 100644 index 0000000..b0dbdbf --- /dev/null +++ b/src/MrGameEng.Graphics/Lighting/LightmapBuilder.cs @@ -0,0 +1,120 @@ +namespace MrGameEng.Lighting; + +/// One point light projected onto the light grid: a cell position, a radius in cells and an intensity. +public readonly record struct LightSample(int X, int Y, float Radius, float Intensity); + +/// +/// 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 turns the grid into a +/// texture and the simulation samples it for local light. +/// +public static class LightmapBuilder +{ + /// How much of the ambient light reaches a cell that is itself an occluder (canopy/shade). + public const float OccluderShade = 0.35f; + + /// + /// Fills (length ×) with + /// ambient light, shading occluder cells, then adds each point light with grid-traced occlusion. + /// Values end clamped to [0, 1]. + /// + public static void Build( + float[] light, + int width, + int height, + float ambient, + ReadOnlySpan occluders, + IReadOnlyList 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 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; + } + } + } +} diff --git a/src/MrGameEng.Graphics/Lighting/LightmapSystems.cs b/src/MrGameEng.Graphics/Lighting/LightmapSystems.cs new file mode 100644 index 0000000..1fd2679 --- /dev/null +++ b/src/MrGameEng.Graphics/Lighting/LightmapSystems.cs @@ -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; + +/// +/// 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. +/// +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 _occluders; + private readonly float _cellSize; + private readonly Vector2 _origin; + private readonly ArchetypeQuery _query; + private readonly List _lights = []; + private int _frame; + + internal LightmapSystem( + EntityStore store, + Lightmap lightmap, + DayNight dayNight, + Func occluders, + float cellSize, + Vector2 origin + ) + { + _lightmap = lightmap; + _dayNight = dayNight; + _occluders = occluders; + _cellSize = cellSize; + _origin = origin; + _query = store.Query(); + } + + /// + 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(); + } +} + +/// +/// 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). +/// +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)), + ]; + } + + /// + 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; + } +} diff --git a/src/MrGameEng.Graphics/Lighting/PointLight.cs b/src/MrGameEng.Graphics/Lighting/PointLight.cs new file mode 100644 index 0000000..bb80b25 --- /dev/null +++ b/src/MrGameEng.Graphics/Lighting/PointLight.cs @@ -0,0 +1,21 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; + +namespace MrGameEng.Lighting; + +/// +/// A point light: an entity with this component plus a +/// adds light around its world position, attenuating to zero at and casting +/// grid-traced shadows behind occluders. Picked up by the lighting system into the lightmap. +/// +public struct PointLight : IComponent +{ + /// Reach of the light in world units (light fades to zero at this distance). + public float Radius; + + /// Light tint (reserved for colored lights; intensity currently drives brightness). + public Color Color; + + /// Peak brightness added at the light's centre (0..1+). + public float Intensity; +} diff --git a/tests/MrGameEng.Graphics.Tests/LightmapBuilderTests.cs b/tests/MrGameEng.Graphics.Tests/LightmapBuilderTests.cs new file mode 100644 index 0000000..02172fa --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/LightmapBuilderTests.cs @@ -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)); + } +}