Compare commits

..
6 Commits
Author SHA1 Message Date
Leonid PershinandClaude Fable 5 8d3f478acd Document echovault memory workflow in CLAUDE.md
CI / build-test (push) Successful in 1m1s
Load memory_context at session start, search before topic work,
save decisions/bugs/gotchas before ending a session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:19:57 +03:00
Leonid PershinandClaude Fable 5 d498c70660 Add in-game developer console (MrGameEng.DevConsole)
Core gains a static Log (Debug/Info/Warning/Error + event); the engine
logs key events like scene switches. The console captures Log output
into a 2048-line ring buffer and executes registered commands with
input history (up/down), Tab prefix completion and scrolling
(PageUp/PageDown/End/wheel). Built-ins: help, clear, echo, timescale,
close, quit; games register their own (sample: stress/main/beep).

Console core is pure logic covered by headless tests; the Myra overlay
renders a single label rebuilt only when the Revision counter moves -
an idle or closed console costs nothing per frame. Toggled with the
backquote key; sample gameplay hotkeys are suppressed while open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:17:08 +03:00
Leonid PershinandClaude Fable 5 e06f24a319 Parallel rendering pipeline, ring vertex buffer, phase timings
Five optimizations measured on the 100k-entity stress scene (Release,
vsync off): 103 FPS baseline -> 297 FPS.

- Sprite submission and vertex building run on all cores above
  Renderer2DOptions.ParallelThreshold (default 8192). Work is sliced
  into 4096-entity segments: a Friflo chunk holds a whole archetype,
  so per-chunk parallelism degenerates to one thread. Segments merge
  in deterministic order, preserving radix sort stability.
- Vertex buffer is ring-written with SetDataOptions.NoOverwrite
  (GPU buffer 2x frame size); Discard only on wrap-around.
- Texture2DRegion precomputes UVs - four float divisions per sprite
  per frame removed.
- Renderer2D exposes per-phase timings (submit/sort/build/upload/draw),
  shown in the sample HUD - all further optimization is data-driven.
- Sample BounceSystem parallelized the same segmented way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:06:55 +03:00
Leonid PershinandClaude Fable 5 af319d1276 Add MrGameEng.UI module integrating Myra
scene.UseUI() creates a per-scene Myra Desktop and registers
UiRenderSystem last in the draw phase (UI on top, window pixels).
GameHost now exposes itself as a Game service (Myra needs the instance;
useful for games too). Myra was picked over Gum/ImGui for the
FontStashSharp ecosystem fit, pipeline-free assets and maturity; its
internal SpriteBatch use is a documented exception to the engine rule.

Sample: on-screen HUD replaces window-title-only stats — live FPS label,
music volume slider and scene-switch buttons on both scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:51:12 +03:00
Leonid PershinandClaude Fable 5 a3e6d3bb0a Stable radix sprite sort and render hot-path optimizations
SpriteBatcher now sorts with a stable LSD radix sort: equal-key sprites
keep submission order across frames (no flicker) and passes over digits
identical in all keys are skipped, making the common single-layer case
nearly free. Hot paths avoid per-sprite trig and square roots: SinCos is
skipped for unrotated sprites and the culling radius comes from the
region's precomputed diagonal.

Stress scene (100k entities, ~61k on screen, Release): 103 -> 124 FPS.
Sample now runs with VSync off to show real frame rates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:39:17 +03:00
Leonid PershinandClaude Fable 5 2b7d4c4fef Add scene transitions (fade, wipe) to SceneManager
Switch(scene, transition) covers the old scene, swaps at full coverage
(hiding slow OnLoad), then reveals the new one. Built-in Fade and Wipe
transitions draw through TransitionRenderer (BasicEffect quad, no
SpriteBatch); custom transitions subclass Transition. Runs on unscaled
time so it works while gameplay is paused; Switch during a transition
replaces the pending target.

Sample: Tab now fades into the stress scene and wipes back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 04:27:54 +03:00
31 changed files with 2076 additions and 154 deletions
+18 -2
View File
@@ -18,9 +18,12 @@ docs/ architecture, conventions, roadmap (Russian)
Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched
renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content
pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles). pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles),
`UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`), `DevConsole`
(in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad).
Dependency rule: every module may depend only on `Core`; `Core` depends only on Dependency rule: every module may depend only on `Core`; `Core` depends only on
MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer. MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
Documented exception: Myra renders with its own SpriteBatch internally.
## Commands ## Commands
@@ -35,7 +38,10 @@ dotnet run --project samples/MrGameEng.Sample
- ECS-first: components are plain data (`struct` implementing `IComponent`), - ECS-first: components are plain data (`struct` implementing `IComponent`),
behavior goes into Friflo systems (`QuerySystem`), wired through `SystemRoot`. behavior goes into Friflo systems (`QuerySystem`), wired through `SystemRoot`.
No `Update()` methods on game objects, no inheritance-based entities. No `Update()` methods on game objects, no inheritance-based entities.
- Hot paths (per-frame systems) must be allocation-free. - Hot paths (per-frame systems) must be allocation-free below the renderer's parallel
threshold; above it Parallel.For scheduler overhead is the accepted trade.
A Friflo chunk holds a whole archetype — parallelize by slicing chunks into segments,
never by chunk alone. Measure in Release only, using the Renderer2D phase timings.
- Rendering: custom batcher in `Graphics` (vertex buffers, layer→depth→texture sort, - Rendering: custom batcher in `Graphics` (vertex buffers, layer→depth→texture sort,
atlas support); `SpriteBatch` is not used in engine code. Draw systems write vertices atlas support); `SpriteBatch` is not used in engine code. Draw systems write vertices
directly from Friflo chunk iteration. Orthographic camera (one active per scene), directly from Friflo chunk iteration. Orthographic camera (one active per scene),
@@ -55,3 +61,13 @@ dotnet run --project samples/MrGameEng.Sample
- Nullable reference types enabled, warnings as errors, file-scoped namespaces. - Nullable reference types enabled, warnings as errors, file-scoped namespaces.
- Public engine API requires XML doc comments (English). - Public engine API requires XML doc comments (English).
- Tests: xUnit, named `Method_Scenario_Expectation`. - Tests: xUnit, named `Method_Scenario_Expectation`.
## Memory (echovault MCP)
- At session start, call `memory_context` to load prior decisions for this project;
call `memory_search` before working on a topic that may have prior context
(e.g. "renderer", "transitions", "generator").
- Before ending a session where you decided, fixed or learned something, save it with
`memory_save`: decisions (X over Y + why), bug root causes, non-obvious gotchas
(e.g. Friflo/Myra API traps). Write for a future agent with zero context.
- Don't save what the repo already records (code, docs/, git history) or trivia.
+1
View File
@@ -6,6 +6,7 @@
<PackageVersion Include="Friflo.Engine.ECS" Version="3.6.0" /> <PackageVersion Include="Friflo.Engine.ECS" Version="3.6.0" />
<PackageVersion Include="FontStashSharp.MonoGame" Version="1.5.6" /> <PackageVersion Include="FontStashSharp.MonoGame" Version="1.5.6" />
<PackageVersion Include="NVorbis" Version="0.10.5" /> <PackageVersion Include="NVorbis" Version="0.10.5" />
<PackageVersion Include="Myra" Version="1.6.1" />
<!-- Source generator --> <!-- Source generator -->
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
+45
View File
@@ -31,6 +31,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio", "src\MrGa
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input.Tests", "tests\MrGameEng.Input.Tests\MrGameEng.Input.Tests.csproj", "{0E0710AB-6132-4E64-9AFC-03B0601F92C6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input.Tests", "tests\MrGameEng.Input.Tests\MrGameEng.Input.Tests.csproj", "{0E0710AB-6132-4E64-9AFC-03B0601F92C6}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI", "src\MrGameEng.UI\MrGameEng.UI.csproj", "{17EB97D5-DCF8-47DF-B810-DA45AE314170}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole", "src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj", "{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole.Tests", "tests\MrGameEng.DevConsole.Tests\MrGameEng.DevConsole.Tests.csproj", "{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -173,6 +179,42 @@ Global
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x64.Build.0 = Release|Any CPU {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x64.Build.0 = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.ActiveCfg = Release|Any CPU {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.ActiveCfg = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.Build.0 = Release|Any CPU {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x64.ActiveCfg = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x64.Build.0 = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x86.ActiveCfg = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x86.Build.0 = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|Any CPU.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x64.ActiveCfg = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x64.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.ActiveCfg = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x64.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x64.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x86.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x86.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|Any CPU.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x64.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x64.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x86.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x86.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x64.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x64.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x86.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x86.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|Any CPU.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -189,5 +231,8 @@ Global
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {D55753A2-4CC0-4BCC-80D5-01E919FF97BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0E0710AB-6132-4E64-9AFC-03B0601F92C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {0E0710AB-6132-4E64-9AFC-03B0601F92C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{17EB97D5-DCF8-47DF-B810-DA45AE314170} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+67 -2
View File
@@ -33,6 +33,8 @@
| `MrGameEng.Audio` | Звуковые эффекты и музыка | | `MrGameEng.Audio` | Звуковые эффекты и музыка |
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` | | `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов | | `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`. Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
@@ -52,6 +54,37 @@ MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит
от других модулей движка. от других модулей движка.
## Консоль разработчика
`MrGameEng.DevConsole` — ингейм-консоль (тогглинг клавишей <code>`</code>):
- Перехватывает всё, что пишется через статический `MrGameEng.Core.Log`
(Debug/Info/Warning/Error; движок логирует ключевые события сам).
- Команды: `console.Register(name, description, handler)`; встроенные —
`help`, `clear`, `echo`, `timescale`, `close`, `quit`; игра добавляет свои.
- История ввода (↑/↓), автодополнение по Tab, скролл (PageUp/PageDown/End/колесо).
- Производительность: кольцевой буфер строк (2048), отрисовка — один Label,
текст которого перестраивается только при изменении (`Revision`); при закрытой
консоли — ноль работы в кадре.
- Ядро (`DevConsole`) — чистая логика без Myra, полностью покрыта headless-тестами;
Myra-обвязка отдельно. `scene.UseDevConsole()` — последним в `OnLoad`, чтобы
консоль рисовалась поверх UI. Сервис общий, живёт между сценами.
## UI (Myra)
Игровой UI — интеграция [Myra](https://github.com/rds1983/Myra) (выбор: экосистема
FontStashSharp, не требует Content Pipeline, зрелая и поддерживаемая):
- `scene.UseUI()` в `OnLoad` **после** `UseRenderer2D()` — создаёт Myra `Desktop`
(свой на сцену) и регистрирует `UiRenderSystem` последней в Draw-фазе.
- UI строится кодом через `desktop.Root`; ввод (мышь/клавиатура) Myra обрабатывает
сама во время рендера, в оконных пикселях.
- Текст рисуется встроенным шрифтом Myra; свои шрифты — `FontSystem` через `AssetManager`.
- **Документированное исключение из правил**: Myra рисует собственным SpriteBatch
(запрет на SpriteBatch относится к коду движка, не к сторонним библиотекам).
Если UI станет узким местом, её рендер можно перевести на наш батчер
через `IMyraRenderer` (бэклог).
## Загрузка ресурсов (без Content Pipeline) ## Загрузка ресурсов (без Content Pipeline)
MGCB / Content Pipeline **не используется**. Все ресурсы лежат в папке `Assets/` MGCB / Content Pipeline **не используется**. Все ресурсы лежат в папке `Assets/`
@@ -132,11 +165,42 @@ public static partial class GameAssets
- Порядок сортировки: **слой → depth (или Y) → текстура**; спрайты с одной - Порядок сортировки: **слой → depth (или Y) → текстура**; спрайты с одной
текстурой сливаются в один draw call (динамический vertex buffer + общий текстурой сливаются в один draw call (динамический vertex buffer + общий
quad index buffer). quad index buffer).
- Сортировка — **стабильный LSD radix sort**: спрайты с равным ключом сохраняют
порядок сабмита между кадрами (нет мерцания), сложность O(n); проходы по
одинаковым у всех ключей разрядам пропускаются.
- Горячий путь без тригонометрии, корней и делений: для спрайтов без поворота
SinCos не вычисляется; радиус culling-окружности и UV-координаты
предрассчитаны в `Texture2DRegion`.
- **Параллелизм**: выше `Renderer2DOptions.ParallelThreshold` (по умолчанию 8192)
подача спрайтов и построение вершин идут на всех ядрах. Работа режется на
сегменты по 4096 сущностей (чанк Friflo держит весь архетип — сам по себе он
слишком крупный для распределения); сегменты сливаются в детерминированном
порядке, поэтому стабильность сортировки сохраняется. Ниже порога — прежний
однопоточный путь без аллокаций.
- Vertex buffer пишется **кольцом** (`SetDataOptions.NoOverwrite`, GPU-буфер
вдвое больше кадра): загрузка не ждёт, пока GPU дорисует предыдущий кадр;
`Discard` — только на перемотке кольца.
- **Тайминги фаз** кадра (submit/sort/build/upload/draw, мс) доступны как свойства
`Renderer2D` — выводятся в HUD стресс-сцены; оптимизации делаются только по ним.
- Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа, - Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа,
спрайты одного атласа батчатся автоматически. спрайты одного атласа батчатся автоматически.
- Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе, - Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе,
0 аллокаций на кадр. Контролируется бенчмарками (BenchmarkDotNet) и 0 аллокаций на кадр ниже порога параллелизма. Стресс-сцена Sample: 100k сущностей
стресс-сценой в Sample. (~61k в кадре) ≈ 297 FPS в Release. **Производительность измеряется только
в Release** — Debug-сборка медленнее в 5–6 раз (нет инлайнинга JIT).
## Сцены и переходы
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
следующего кадра (сцена никогда не выгружается посреди собственного кадра).
- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом:
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)`
(шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill`
в нормализованных координатах экрана).
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
## Интеграция ECS ## Интеграция ECS
@@ -178,4 +242,5 @@ docs/ документация (русский)
| Friflo.Engine.ECS | 3.6.0 | ECS | | Friflo.Engine.ECS | 3.6.0 | ECS |
| FontStashSharp.MonoGame | 1.5.6 | Шрифты (ttf) в рантайме | | FontStashSharp.MonoGame | 1.5.6 | Шрифты (ttf) в рантайме |
| NVorbis | 0.10.5 | Декодирование ogg | | NVorbis | 0.10.5 | Декодирование ogg |
| Myra | 1.6.1 | Игровой UI |
| dotnet-mgfxc (dotnet tool) | 3.8.4.1 | Компиляция шейдеров при сборке | | dotnet-mgfxc (dotnet tool) | 3.8.4.1 | Компиляция шейдеров при сборке |
+3 -2
View File
@@ -18,7 +18,8 @@
- Physics2D (выбор библиотеки: Aether.Physics2D / своя) - Physics2D (выбор библиотеки: Aether.Physics2D / своя)
- Tilemap (поддержка Tiled) - Tilemap (поддержка Tiled)
- Particles - Particles
- UI - UI: загрузка MML-разметки Myra через AssetManager + хендлы в кодогенераторе
- UI: рендер Myra через наш батчер (`IMyraRenderer`), если UI станет узким местом по draw call'ам
- DevTools-модуль на ImGui.NET: инспектор сущностей, дебаг-панели
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой) - Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
- Стабильная сортировка спрайтов с равным ключом (сейчас порядок не гарантирован между кадрами)
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость) - Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
@@ -11,6 +11,8 @@
<ProjectReference Include="..\..\src\MrGameEng.Input\MrGameEng.Input.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.Input\MrGameEng.Input.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" /> <ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" <ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>
+1
View File
@@ -9,6 +9,7 @@ using var host = new GameHost(
Width = 1280, Width = 1280,
Height = 720, Height = 720,
ClearColor = new Color(24, 26, 32), ClearColor = new Color(24, 26, 32),
VSync = false, // техдемо: показываем реальный FPS, не ограниченный частотой монитора
}, },
new MainScene()); new MainScene());
+89 -26
View File
@@ -12,10 +12,16 @@ namespace MrGameEng.Sample;
/// <summary>Управление игроком (WASD/стрелки) и прыжок-писк на пробел.</summary> /// <summary>Управление игроком (WASD/стрелки) и прыжок-писк на пробел.</summary>
public sealed class PlayerControlSystem( public sealed class PlayerControlSystem(
Entity player, ActionMap<SampleAction> actions, AudioManager audio, SoundEffect beep) : BaseSystem Entity player, ActionMap<SampleAction> actions, AudioManager audio, SoundEffect beep,
MrGameEng.DevConsole.DevConsole console) : BaseSystem
{ {
protected override void OnUpdateGroup() protected override void OnUpdateGroup()
{ {
if (console.IsOpen)
{
return;
}
ref var transform = ref player.GetComponent<Transform2D>(); ref var transform = ref player.GetComponent<Transform2D>();
var move = new Vector2( var move = new Vector2(
actions.GetAxis(SampleAction.MoveLeft, SampleAction.MoveRight), actions.GetAxis(SampleAction.MoveLeft, SampleAction.MoveRight),
@@ -50,44 +56,92 @@ public sealed class CameraControlSystem(Entity cameraEntity, Entity player, Inpu
} }
} }
/// <summary>Отскок сущностей со скоростью от границ мира.</summary> /// <summary>
/// Отскок сущностей со скоростью от границ мира. На больших количествах работа режется
/// на сегменты по всем ядрам (чанк Friflo держит весь архетип — сам по себе он слишком крупный).
/// </summary>
public sealed class BounceSystem(RectF bounds) : QuerySystem<Transform2D, Velocity> public sealed class BounceSystem(RectF bounds) : QuerySystem<Transform2D, Velocity>
{ {
private const int ParallelThreshold = 8192;
private const int SegmentSize = 8192;
private readonly List<(Chunk<Transform2D> Transforms, Chunk<Velocity> Velocities)> _chunks = [];
private readonly List<(int Chunk, int Start, int Length)> _segments = [];
protected override void OnUpdate() protected override void OnUpdate()
{ {
var delta = Tick.deltaTime; var delta = Tick.deltaTime;
_chunks.Clear();
var total = 0;
foreach (var (transforms, velocities, _) in Query.Chunks) foreach (var (transforms, velocities, _) in Query.Chunks)
{ {
var t = transforms.Span; _chunks.Add((transforms, velocities));
var v = velocities.Span; total += transforms.Length;
for (var i = 0; i < t.Length; i++) }
if (total < ParallelThreshold)
{
foreach (var (transforms, velocities) in _chunks)
{ {
ref var position = ref t[i].Position; Move(transforms, velocities, 0, transforms.Length, delta);
ref var velocity = ref v[i].Value; }
position += velocity * delta;
if (position.X < bounds.Left || position.X > bounds.Right) return;
{ }
velocity.X = -velocity.X;
position.X = Math.Clamp(position.X, bounds.Left, bounds.Right);
}
if (position.Y < bounds.Top || position.Y > bounds.Bottom) _segments.Clear();
{ for (var c = 0; c < _chunks.Count; c++)
velocity.Y = -velocity.Y; {
position.Y = Math.Clamp(position.Y, bounds.Top, bounds.Bottom); var length = _chunks[c].Transforms.Length;
} for (var start = 0; start < length; start += SegmentSize)
{
_segments.Add((c, start, Math.Min(SegmentSize, length - start)));
}
}
Parallel.For(0, _segments.Count, i =>
{
var (chunk, start, length) = _segments[i];
Move(_chunks[chunk].Transforms, _chunks[chunk].Velocities, start, length, delta);
});
}
private void Move(Chunk<Transform2D> transforms, Chunk<Velocity> velocities, int start, int length, float delta)
{
var t = transforms.Span.Slice(start, length);
var v = velocities.Span.Slice(start, length);
for (var i = 0; i < t.Length; i++)
{
ref var position = ref t[i].Position;
ref var velocity = ref v[i].Value;
position += velocity * delta;
if (position.X < bounds.Left || position.X > bounds.Right)
{
velocity.X = -velocity.X;
position.X = Math.Clamp(position.X, bounds.Left, bounds.Right);
}
if (position.Y < bounds.Top || position.Y > bounds.Bottom)
{
velocity.Y = -velocity.Y;
position.Y = Math.Clamp(position.Y, bounds.Top, bounds.Bottom);
} }
} }
} }
} }
/// <summary>Пауза (P), музыка (M), переключение сцены (Tab).</summary> /// <summary>Пауза (P), музыка (M), переключение сцены с переходом (Tab).</summary>
public sealed class SceneHotkeysSystem( public sealed class SceneHotkeysSystem(
EngineContext context, ActionMap<SampleAction> actions, Func<Scene> nextScene) : BaseSystem EngineContext context, ActionMap<SampleAction> actions, Func<Scene> nextScene, Transition transition) : BaseSystem
{ {
protected override void OnUpdateGroup() protected override void OnUpdateGroup()
{ {
if (context.Services.Get<MrGameEng.DevConsole.DevConsole>().IsOpen)
{
return;
}
if (actions.IsPressed(SampleAction.Pause)) if (actions.IsPressed(SampleAction.Pause))
{ {
context.Clock.TimeScale = context.Clock.TimeScale > 0f ? 0f : 1f; context.Clock.TimeScale = context.Clock.TimeScale > 0f ? 0f : 1f;
@@ -106,15 +160,16 @@ public sealed class SceneHotkeysSystem(
} }
} }
if (actions.IsPressed(SampleAction.SwitchScene)) if (actions.IsPressed(SampleAction.SwitchScene) && !context.Scenes.IsTransitioning)
{ {
context.Scenes.Switch(nextScene()); context.Scenes.Switch(nextScene(), transition);
} }
} }
} }
/// <summary>FPS и статистика рендера в заголовке окна (обновляется 4 раза в секунду).</summary> /// <summary>FPS и статистика рендера: в HUD-лейбл и в заголовок окна (4 раза в секунду).</summary>
public sealed class TitleStatsSystem(EngineContext context, Renderer2D renderer, string sceneName) : BaseSystem public sealed class StatsSystem(
EngineContext context, Renderer2D renderer, string sceneName, Myra.Graphics2D.UI.Label? hudLabel = null) : BaseSystem
{ {
private float _accumulated; private float _accumulated;
private int _frames; private int _frames;
@@ -131,8 +186,16 @@ public sealed class TitleStatsSystem(EngineContext context, Renderer2D renderer,
var fps = _frames / _accumulated; var fps = _frames / _accumulated;
_accumulated = 0f; _accumulated = 0f;
_frames = 0; _frames = 0;
context.Services.Get<GameWindow>().Title = var stats =
$"MrGameEng Sample — {sceneName} | {fps:F0} FPS | sprites: {renderer.SubmittedSprites} | culled: {renderer.CulledSprites} | draw calls: {renderer.DrawCalls}"; $"{fps:F0} FPS | sprites: {renderer.SubmittedSprites} | culled: {renderer.CulledSprites} | draw calls: {renderer.DrawCalls}";
context.Services.Get<GameWindow>().Title = $"MrGameEng Sample — {sceneName} | {stats}";
if (hudLabel is not null)
{
hudLabel.Text =
$"{sceneName}\n{stats}\n" +
$"submit {renderer.SubmitMs:F2} | sort {renderer.SortMs:F2} | build {renderer.BuildMs:F2} | " +
$"upload {renderer.UploadMs:F2} | draw {renderer.DrawMs:F2} (ms)";
}
} }
} }
+45 -3
View File
@@ -3,8 +3,11 @@ using Microsoft.Xna.Framework;
using MrGameEng.Assets; using MrGameEng.Assets;
using MrGameEng.Audio; using MrGameEng.Audio;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
using MrGameEng.Input; using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace MrGameEng.Sample.Scenes; namespace MrGameEng.Sample.Scenes;
@@ -90,11 +93,50 @@ public sealed class MainScene : Scene
var hud = new Sprite(shapeRegions[3], SampleLayers.Ui); var hud = new Sprite(shapeRegions[3], SampleLayers.Ui);
Store.CreateEntity(new Transform2D(new Vector2(16f, 16f)), hud); Store.CreateEntity(new Transform2D(new Vector2(16f, 16f)), hud);
UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep)); // UI (Myra): HUD со статистикой, слайдер громкости, кнопка перехода. После UseRenderer2D!
var desktop = this.UseUI();
var statsLabel = new Label();
var volumeSlider = new HorizontalSlider { Minimum = 0f, Maximum = 1f, Value = audio.Music.Volume, Width = 180 };
volumeSlider.ValueChanged += (_, _) => audio.Music.Volume = volumeSlider.Value;
var switchButton = new Button { Content = new Label { Text = "Stress scene (Tab)" } };
switchButton.Click += (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new StressScene(), Transition.Fade(0.8f));
}
};
var panel = new VerticalStackPanel { Left = 12, Top = 12, Spacing = 6 };
panel.Widgets.Add(statsLabel);
panel.Widgets.Add(new Label { Text = "Music volume (M — pause)" });
panel.Widgets.Add(volumeSlider);
panel.Widgets.Add(switchButton);
desktop.Root = panel;
// Консоль разработчика (клавиша `) — последней, чтобы рисовалась поверх UI.
var console = this.UseDevConsole();
console.Register("stress", "switch to the stress scene", (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new StressScene(), Transition.Fade(0.8f));
}
});
console.Register("main", "switch to the main scene", (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainScene(), Transition.Fade(0.8f));
}
});
console.Register("beep", "play the beep sound", (_, _) => audio.Play(beep));
UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep, console));
UpdateSystems.Add(new BounceSystem(WorldBounds)); UpdateSystems.Add(new BounceSystem(WorldBounds));
UpdateSystems.Add(new CameraControlSystem(camera, player, input)); UpdateSystems.Add(new CameraControlSystem(camera, player, input));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene())); UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene(), Transition.Fade(0.8f)));
UpdateSystems.Add(new TitleStatsSystem(Context, renderer, "Main")); UpdateSystems.Add(new StatsSystem(Context, renderer, "Main", statsLabel));
var music = audio.Music; var music = audio.Music;
if (!music.IsPlaying) if (!music.IsPlaying)
+24 -2
View File
@@ -2,8 +2,10 @@ using Friflo.Engine.ECS;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using MrGameEng.Assets; using MrGameEng.Assets;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics; using MrGameEng.Graphics;
using MrGameEng.Input; using MrGameEng.Input;
using MrGameEng.UI;
namespace MrGameEng.Sample.Scenes; namespace MrGameEng.Sample.Scenes;
@@ -52,10 +54,30 @@ public sealed class StressScene : Scene
var camera = Store.CreateEntity(new Camera(Vector2.Zero, zoom: 0.3f)); var camera = Store.CreateEntity(new Camera(Vector2.Zero, zoom: 0.3f));
var desktop = this.UseUI();
var statsLabel = new Myra.Graphics2D.UI.Label();
var backButton = new Myra.Graphics2D.UI.Button
{
Content = new Myra.Graphics2D.UI.Label { Text = "Back to main (Tab)" },
};
backButton.Click += (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue));
}
};
var panel = new Myra.Graphics2D.UI.VerticalStackPanel { Left = 12, Top = 12, Spacing = 6 };
panel.Widgets.Add(statsLabel);
panel.Widgets.Add(backButton);
desktop.Root = panel;
this.UseDevConsole(); // поверх UI
UpdateSystems.Add(new BounceSystem(WorldBounds)); UpdateSystems.Add(new BounceSystem(WorldBounds));
UpdateSystems.Add(new StressCameraSystem(camera, input)); UpdateSystems.Add(new StressCameraSystem(camera, input));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene())); UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue)));
UpdateSystems.Add(new TitleStatsSystem(Context, renderer, $"Stress {SpriteCount:N0}")); UpdateSystems.Add(new StatsSystem(Context, renderer, $"Stress {SpriteCount:N0}", statsLabel));
} }
/// <summary>Только зум колесом — чтобы регулировать число видимых спрайтов.</summary> /// <summary>Только зум колесом — чтобы регулировать число видимых спрайтов.</summary>
+1
View File
@@ -47,6 +47,7 @@ public class GameHost : Game
Window.AllowUserResizing = _options.AllowResizing; Window.AllowUserResizing = _options.AllowResizing;
Context.AttachGraphicsDevice(GraphicsDevice); Context.AttachGraphicsDevice(GraphicsDevice);
Context.Services.Add(Window); Context.Services.Add(Window);
Context.Services.Add<Game>(this);
base.Initialize(); base.Initialize();
Context.Scenes.Switch(_initialScene); Context.Scenes.Switch(_initialScene);
} }
+45
View File
@@ -0,0 +1,45 @@
namespace MrGameEng.Core;
/// <summary>Severity of a log message.</summary>
public enum LogLevel
{
/// <summary>Verbose diagnostics.</summary>
Debug,
/// <summary>Normal informational message.</summary>
Info,
/// <summary>Something suspicious but recoverable.</summary>
Warning,
/// <summary>An error.</summary>
Error,
}
/// <summary>
/// Engine-wide logger. Messages go to subscribers (the developer console subscribes here)
/// and to the debugger output. Logging allocates — do not log every frame from hot paths.
/// </summary>
public static class Log
{
/// <summary>Raised for every message. May be invoked from any thread.</summary>
public static event Action<LogLevel, string>? MessageLogged;
/// <summary>Logs a debug message.</summary>
public static void Debug(string message) => Write(LogLevel.Debug, message);
/// <summary>Logs an informational message.</summary>
public static void Info(string message) => Write(LogLevel.Info, message);
/// <summary>Logs a warning.</summary>
public static void Warning(string message) => Write(LogLevel.Warning, message);
/// <summary>Logs an error.</summary>
public static void Error(string message) => Write(LogLevel.Error, message);
private static void Write(LogLevel level, string message)
{
System.Diagnostics.Debug.WriteLine($"[{level}] {message}");
MessageLogged?.Invoke(level, message);
}
}
+79 -9
View File
@@ -1,39 +1,103 @@
namespace MrGameEng.Core; namespace MrGameEng.Core;
/// <summary> /// <summary>
/// Owns the active <see cref="Scene"/>. Scene switches are deferred to the start of the /// Owns the active <see cref="Scene"/>. Plain switches are deferred to the start of the next
/// next update so a scene is never unloaded in the middle of its own frame. /// update so a scene is never unloaded in the middle of its own frame. Switches with a
/// <see cref="Transition"/> first cover the old scene, swap at full coverage (hiding even a
/// slow <c>OnLoad</c>), then reveal the new one. Transition time is unscaled, so it works
/// while gameplay is paused.
/// </summary> /// </summary>
public sealed class SceneManager public sealed class SceneManager
{ {
private enum State
{
Idle,
CoveringOut,
RevealingIn,
}
/// <summary>The active scene, or null before the first switch is applied.</summary> /// <summary>The active scene, or null before the first switch is applied.</summary>
public Scene? Current { get; private set; } public Scene? Current { get; private set; }
/// <summary>True while a transition is covering or revealing.</summary>
public bool IsTransitioning => _state != State.Idle;
private readonly EngineContext _context; private readonly EngineContext _context;
private Scene? _pending; private Scene? _pending;
private bool _hasPending; private bool _hasPending;
private Transition? _transition;
private State _state;
private float _coverage;
private TransitionRenderer? _renderer;
internal SceneManager(EngineContext context) => _context = context; internal SceneManager(EngineContext context) => _context = context;
/// <summary> /// <summary>
/// Requests a switch to <paramref name="scene"/>. The current scene is unloaded and the new /// Requests a switch to <paramref name="scene"/>. Without a transition the swap happens at
/// one loaded at the start of the next update tick. Passing null unloads the current scene. /// the start of the next update tick; with one, the old scene is first covered.
/// Passing null unloads the current scene. Calling during an active transition replaces
/// the pending target scene.
/// </summary> /// </summary>
public void Switch(Scene? scene) public void Switch(Scene? scene, Transition? transition = null)
{ {
_pending = scene; _pending = scene;
_hasPending = true; _hasPending = true;
if (_state == State.Idle && transition is not null)
{
_transition = transition;
_state = State.CoveringOut;
_coverage = 0f;
}
} }
/// <summary>Applies a pending switch, then updates the active scene. Called by the host.</summary> /// <summary>Advances a transition and updates the active scene. Called by the host.</summary>
public void Update(GameClock clock) public void Update(GameClock clock)
{ {
ApplyPending(); switch (_state)
{
case State.Idle:
ApplyPending();
break;
case State.CoveringOut:
_coverage = Advance(_coverage, +1f, _transition!.OutDuration, clock.UnscaledDeltaTime);
if (_coverage >= 1f)
{
ApplyPending();
_state = State.RevealingIn;
}
break;
case State.RevealingIn:
_coverage = Advance(_coverage, -1f, _transition!.InDuration, clock.UnscaledDeltaTime);
if (_coverage <= 0f)
{
_state = State.Idle;
_transition = null;
}
break;
}
Current?.Update(clock); Current?.Update(clock);
} }
/// <summary>Draws the active scene. Called by the host.</summary> /// <summary>Draws the active scene and the transition overlay on top. Called by the host.</summary>
public void Draw(GameClock clock) => Current?.Draw(clock); public void Draw(GameClock clock)
{
Current?.Draw(clock);
if (_state == State.Idle || !_context.HasGraphicsDevice)
{
return;
}
_renderer ??= new TransitionRenderer(_context.GraphicsDevice);
var phase = _state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
_transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase);
}
internal void ApplyPending() internal void ApplyPending()
{ {
@@ -47,5 +111,11 @@ public sealed class SceneManager
Current = _pending; Current = _pending;
_pending = null; _pending = null;
Current?.Load(_context); Current?.Load(_context);
Log.Info($"Scene switched to {Current?.GetType().Name ?? "<none>"}");
} }
private static float Advance(float coverage, float direction, float duration, float deltaTime) =>
duration <= 0f
? coverage + direction
: Math.Clamp(coverage + direction * deltaTime / duration, 0f, 1f);
} }
+73
View File
@@ -0,0 +1,73 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core;
/// <summary>Phase of a scene transition.</summary>
public enum TransitionPhase
{
/// <summary>The old scene is being covered (coverage grows 0 → 1).</summary>
Out,
/// <summary>The new scene is being revealed (coverage shrinks 1 → 0).</summary>
In,
}
/// <summary>
/// Visual transition between scenes. The scene switch itself happens at full coverage,
/// so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
/// Transitions are stateless and reusable; progress is tracked by <see cref="SceneManager"/>.
/// Runs on unscaled time, so it works while gameplay is paused.
/// </summary>
public abstract class Transition
{
/// <summary>Seconds the covering phase takes.</summary>
public float OutDuration { get; }
/// <summary>Seconds the revealing phase takes.</summary>
public float InDuration { get; }
/// <summary>Creates a transition with explicit phase durations.</summary>
protected Transition(float outDuration, float inDuration)
{
OutDuration = Math.Max(0f, outDuration);
InDuration = Math.Max(0f, inDuration);
}
/// <summary>
/// Draws the overlay. <paramref name="coverage"/> is 0 (scene fully visible) to
/// 1 (scene fully covered); <paramref name="phase"/> tells which side of the switch this is.
/// </summary>
public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase);
/// <summary>Fade through a solid color (black by default). Total duration is split between out and in.</summary>
public static Transition Fade(float duration = 0.6f, Color? color = null) =>
new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
/// <summary>A curtain wiping across the screen (black by default). Total duration is split between out and in.</summary>
public static Transition Wipe(float duration = 0.6f, Color? color = null) =>
new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase) =>
renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
}
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase)
{
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
if (phase == TransitionPhase.Out)
{
renderer.Fill(0f, 0f, coverage, 1f, color);
}
else
{
renderer.Fill(1f - coverage, 0f, coverage, 1f, color);
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Core;
/// <summary>
/// Minimal overlay renderer handed to <see cref="Transition.Draw"/>: fills rectangles in
/// normalized screen coordinates (0..1 on both axes) over the rendered scene.
/// </summary>
public sealed class TransitionRenderer
{
private readonly GraphicsDevice _device;
private readonly BasicEffect _effect;
private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6];
internal TransitionRenderer(GraphicsDevice device)
{
_device = device;
_effect = new BasicEffect(device)
{
VertexColorEnabled = true,
TextureEnabled = false,
World = Matrix.Identity,
View = Matrix.Identity,
Projection = Matrix.CreateOrthographicOffCenter(0f, 1f, 1f, 0f, 0f, 1f),
};
}
/// <summary>
/// Fills a rectangle given in normalized screen coordinates with <paramref name="color"/>
/// at the given <paramref name="opacity"/> (0 = invisible, 1 = solid).
/// </summary>
public void Fill(float x, float y, float width, float height, Color color, float opacity = 1f)
{
var alpha = (byte)(Math.Clamp(opacity, 0f, 1f) * color.A);
var premultiplied = Color.FromNonPremultiplied(color.R, color.G, color.B, alpha);
var topLeft = new Vector3(x, y, 0f);
var topRight = new Vector3(x + width, y, 0f);
var bottomLeft = new Vector3(x, y + height, 0f);
var bottomRight = new Vector3(x + width, y + height, 0f);
_vertices[0] = new VertexPositionColor(topLeft, premultiplied);
_vertices[1] = new VertexPositionColor(topRight, premultiplied);
_vertices[2] = new VertexPositionColor(bottomLeft, premultiplied);
_vertices[3] = new VertexPositionColor(bottomLeft, premultiplied);
_vertices[4] = new VertexPositionColor(topRight, premultiplied);
_vertices[5] = new VertexPositionColor(bottomRight, premultiplied);
_device.BlendState = BlendState.AlphaBlend;
_device.DepthStencilState = DepthStencilState.None;
_device.RasterizerState = RasterizerState.CullNone;
foreach (var pass in _effect.CurrentTechnique.Passes)
{
pass.Apply();
_device.DrawUserPrimitives(PrimitiveType.TriangleList, _vertices, 0, 2);
}
}
}
+246
View File
@@ -0,0 +1,246 @@
using System.Text;
using MrGameEng.Core;
namespace MrGameEng.DevConsole;
/// <summary>Handler of a console command. <paramref name="args"/> excludes the command name.</summary>
public delegate void ConsoleCommand(DevConsole console, string[] args);
/// <summary>
/// The developer console core: a fixed ring buffer of log lines, a command registry,
/// input history and prefix autocompletion. Pure logic — rendering lives in
/// <see cref="DevConsoleRenderSystem"/>; this class is fully testable headless.
/// Captures everything written through <see cref="Log"/>. Thread-safe for writes.
/// </summary>
public sealed class DevConsole : IDisposable
{
private readonly object _sync = new();
private readonly string[] _lines;
private readonly Dictionary<string, (string Description, ConsoleCommand Handler)> _commands =
new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _history = [];
private int _head;
private int _count;
private int _historyCursor;
private int _scrollOffset;
/// <summary>True while the console overlay is visible.</summary>
public bool IsOpen { get; private set; }
/// <summary>Increments on every visible change — the UI rebuilds its text only when this moves.</summary>
public int Revision { get; private set; }
/// <summary>Lines scrolled up from the bottom of the log.</summary>
public int ScrollOffset => _scrollOffset;
/// <summary>Creates a console holding up to <paramref name="capacity"/> log lines.</summary>
public DevConsole(int capacity = 2048)
{
_lines = new string[capacity];
Register("help", "list available commands", static (console, _) =>
{
foreach (var (name, entry) in console._commands.OrderBy(p => p.Key, StringComparer.Ordinal))
{
console.WriteLine($" {name} — {entry.Description}");
}
});
Register("clear", "clear the log", static (console, _) => console.Clear());
Register("echo", "print the arguments", static (console, args) => console.WriteLine(string.Join(' ', args)));
Log.MessageLogged += OnLogMessage;
}
/// <summary>Stops capturing <see cref="Log"/> messages.</summary>
public void Dispose() => Log.MessageLogged -= OnLogMessage;
/// <summary>Opens or closes the console overlay.</summary>
public void Toggle()
{
IsOpen = !IsOpen;
Revision++;
}
/// <summary>Registers (or replaces) a command. Name matching is case-insensitive.</summary>
public void Register(string name, string description, ConsoleCommand handler) =>
_commands[name] = (description, handler);
/// <summary>Appends a line to the log.</summary>
public void WriteLine(string line)
{
lock (_sync)
{
_lines[(_head + _count) % _lines.Length] = line;
if (_count < _lines.Length)
{
_count++;
}
else
{
_head = (_head + 1) % _lines.Length;
}
Revision++;
}
}
/// <summary>Removes all log lines.</summary>
public void Clear()
{
lock (_sync)
{
_head = 0;
_count = 0;
_scrollOffset = 0;
Revision++;
}
}
/// <summary>
/// Echoes and executes one input line. Unknown commands and handler exceptions
/// are reported into the log, never thrown.
/// </summary>
public void Execute(string input)
{
input = input.Trim();
if (input.Length == 0)
{
return;
}
WriteLine($"> {input}");
if (_history.Count == 0 || _history[^1] != input)
{
_history.Add(input);
}
_historyCursor = _history.Count;
_scrollOffset = 0;
var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (!_commands.TryGetValue(parts[0], out var command))
{
WriteLine($"unknown command '{parts[0]}' — try 'help'");
return;
}
try
{
command.Handler(this, parts[1..]);
}
catch (Exception exception)
{
WriteLine($"[err] {exception.Message}");
}
}
/// <summary>Steps back through input history; null when there is none.</summary>
public string? HistoryPrevious()
{
if (_history.Count == 0)
{
return null;
}
_historyCursor = Math.Max(0, _historyCursor - 1);
return _history[_historyCursor];
}
/// <summary>Steps forward through input history; empty string past the newest entry.</summary>
public string? HistoryNext()
{
if (_history.Count == 0)
{
return null;
}
_historyCursor = Math.Min(_history.Count, _historyCursor + 1);
return _historyCursor == _history.Count ? string.Empty : _history[_historyCursor];
}
/// <summary>
/// Completes a command prefix: returns the longest unambiguous completion and lists
/// the options in the log when several commands match. Returns the input unchanged
/// when nothing matches.
/// </summary>
public string Complete(string prefix)
{
prefix = prefix.TrimStart();
var matches = _commands.Keys
.Where(name => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
switch (matches.Length)
{
case 0:
return prefix;
case 1:
return matches[0] + " ";
default:
WriteLine(string.Join(" ", matches));
var common = matches[0];
foreach (var match in matches[1..])
{
var length = 0;
while (length < common.Length && length < match.Length &&
char.ToLowerInvariant(common[length]) == char.ToLowerInvariant(match[length]))
{
length++;
}
common = common[..length];
}
return common;
}
}
/// <summary>Scrolls the log view; positive = older lines. Clamped to the buffer.</summary>
public void Scroll(int deltaLines)
{
lock (_sync)
{
_scrollOffset = Math.Clamp(_scrollOffset + deltaLines, 0, Math.Max(0, _count - 1));
Revision++;
}
}
/// <summary>
/// Writes the visible window of the log (respecting scroll) into <paramref name="target"/>,
/// most recent at the bottom.
/// </summary>
public void BuildVisibleText(StringBuilder target, int visibleLines)
{
lock (_sync)
{
target.Clear();
var end = _count - _scrollOffset;
var start = Math.Max(0, end - visibleLines);
for (var i = start; i < end; i++)
{
if (i > start)
{
target.Append('\n');
}
target.Append(_lines[(_head + i) % _lines.Length]);
}
if (_scrollOffset > 0)
{
target.Append($"\n— scrolled {_scrollOffset} line(s), End = bottom —");
}
}
}
private void OnLogMessage(LogLevel level, string message) =>
WriteLine(level == LogLevel.Info ? message : $"[{LevelTag(level)}] {message}");
private static string LevelTag(LogLevel level) => level switch
{
LogLevel.Debug => "dbg",
LogLevel.Warning => "warn",
LogLevel.Error => "err",
_ => "info",
};
}
@@ -0,0 +1,231 @@
using System.Text;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using Myra;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
using MrGameEng.Core;
namespace MrGameEng.DevConsole;
/// <summary>Myra overlay of the console: translucent top panel with the log view and an input line.</summary>
internal sealed class DevConsoleUi
{
private const int VisibleLines = 20;
private readonly DevConsole _console;
private readonly Desktop _desktop;
private readonly Label _log;
private readonly TextBox _input;
private readonly StringBuilder _text = new();
private int _lastRevision = -1;
public DevConsoleUi(DevConsole console)
{
_console = console;
_log = new Label
{
Text = string.Empty,
Wrap = false,
};
_input = new TextBox
{
HintText = "command ('help')",
};
_input.TextChanged += (_, _) =>
{
// Клавиша-тогглер (`) не должна попадать в строку ввода.
if (_input.Text?.Contains('`') == true)
{
_input.Text = _input.Text.Replace("`", "");
}
};
_input.KeyDown += (_, args) => OnInputKey(args.Data);
var panel = new VerticalStackPanel
{
Spacing = 4,
Padding = new Thickness(8),
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Top,
Height = 420,
Background = new SolidBrush(new Color(8, 10, 14, 230)),
};
panel.Widgets.Add(_log);
panel.Widgets.Add(new HorizontalSeparator());
panel.Widgets.Add(_input);
_desktop = new Desktop { Root = panel };
}
public void Render()
{
if (!_console.IsOpen)
{
return;
}
if (_console.Revision != _lastRevision)
{
_lastRevision = _console.Revision;
_console.BuildVisibleText(_text, VisibleLines);
_log.Text = _text.ToString();
}
_desktop.Render();
}
public void FocusInput()
{
_input.Text = string.Empty;
_desktop.FocusedKeyboardWidget = _input;
}
private void OnInputKey(Keys key)
{
switch (key)
{
case Keys.Enter:
_console.Execute(_input.Text ?? string.Empty);
_input.Text = string.Empty;
break;
case Keys.Up:
SetInput(_console.HistoryPrevious());
break;
case Keys.Down:
SetInput(_console.HistoryNext());
break;
case Keys.Tab:
SetInput(_console.Complete(_input.Text ?? string.Empty));
break;
case Keys.PageUp:
_console.Scroll(+VisibleLines / 2);
break;
case Keys.PageDown:
_console.Scroll(-VisibleLines / 2);
break;
case Keys.End:
_console.Scroll(int.MinValue / 2);
break;
}
}
private void SetInput(string? text)
{
if (text is null)
{
return;
}
_input.Text = text;
_input.CursorPosition = text.Length;
}
}
/// <summary>
/// Update-phase system: toggles the console with the backquote (`) key and scrolls the log
/// with the mouse wheel while open. Polls the keyboard itself, so it works regardless of
/// the input module.
/// </summary>
public sealed class DevConsoleSystem : BaseSystem
{
private readonly DevConsole _console;
private readonly DevConsoleUi _ui;
private KeyboardState _previousKeyboard;
private int _previousWheel;
internal DevConsoleSystem(DevConsole console, DevConsoleUi ui)
{
_console = console;
_ui = ui;
}
/// <inheritdoc />
protected override void OnUpdateGroup()
{
var keyboard = Keyboard.GetState();
if (keyboard.IsKeyDown(Keys.OemTilde) && _previousKeyboard.IsKeyUp(Keys.OemTilde))
{
_console.Toggle();
if (_console.IsOpen)
{
_ui.FocusInput();
}
}
_previousKeyboard = keyboard;
var wheel = Mouse.GetState().ScrollWheelValue;
if (_console.IsOpen && wheel != _previousWheel)
{
_console.Scroll((wheel - _previousWheel) / 40);
}
_previousWheel = wheel;
}
}
/// <summary>Draw-phase system rendering the console overlay. Registered last — on top of everything.</summary>
public sealed class DevConsoleRenderSystem : BaseSystem
{
private readonly DevConsoleUi _ui;
internal DevConsoleRenderSystem(DevConsoleUi ui) => _ui = ui;
/// <inheritdoc />
protected override void OnUpdateGroup() => _ui.Render();
}
/// <summary>Wires the developer console into a <see cref="Scene"/>.</summary>
public static class SceneDevConsoleExtensions
{
/// <summary>
/// Returns the shared <see cref="DevConsole"/> service (created on first use, together
/// with the built-in engine commands) and registers its systems in this scene.
/// Call from <c>OnLoad</c> <b>after</b> all other UI so the console draws on top.
/// Toggle with the backquote (`) key.
/// </summary>
public static DevConsole UseDevConsole(this Scene scene)
{
var services = scene.Context.Services;
var console = services.GetOrDefault<DevConsole>();
if (console is null)
{
MyraEnvironment.Game = services.Get<Game>();
console = new DevConsole();
services.Add(console);
services.Add(new DevConsoleUi(console));
RegisterEngineCommands(console, scene.Context);
Log.Info("Developer console ready — press ` to toggle, 'help' for commands");
}
var ui = services.Get<DevConsoleUi>();
scene.UpdateSystems.Insert(0, new DevConsoleSystem(console, ui));
scene.DrawSystems.Add(new DevConsoleRenderSystem(ui));
return console;
}
private static void RegisterEngineCommands(DevConsole console, EngineContext context)
{
console.Register("timescale", "timescale [value] — show or set game speed", (c, args) =>
{
if (args.Length == 0)
{
c.WriteLine($"timescale = {context.Clock.TimeScale}");
}
else
{
context.Clock.TimeScale = float.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture);
c.WriteLine($"timescale = {context.Clock.TimeScale}");
}
});
console.Register("close", "close the console", static (c, _) => c.Toggle());
console.Register("quit", "exit the game", (_, _) => context.Services.Get<Game>().Exit());
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Myra" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+25 -4
View File
@@ -14,19 +14,40 @@ public static class CullingMath
{ {
var scaledW = regionWidth * transform.Scale.X; var scaledW = regionWidth * transform.Scale.X;
var scaledH = regionHeight * transform.Scale.Y; var scaledH = regionHeight * transform.Scale.Y;
var center = SpriteCenter(in transform, scaledW, scaledH, origin);
var radius = 0.5f * MathF.Sqrt(scaledW * scaledW + scaledH * scaledH);
return (center, radius);
}
/// <summary>
/// Hot-path variant used by the renderer: the radius comes from the region's precomputed
/// diagonal (no square root per sprite; conservative for non-uniform scale, exact for uniform).
/// </summary>
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
in Transform2D transform, Texture2DRegion region, Vector2 origin)
{
var center = SpriteCenter(
in transform, region.Width * transform.Scale.X, region.Height * transform.Scale.Y, origin);
var maxScale = MathF.Max(MathF.Abs(transform.Scale.X), MathF.Abs(transform.Scale.Y));
return (center, 0.5f * region.Diagonal * maxScale);
}
private static Vector2 SpriteCenter(in Transform2D transform, float scaledW, float scaledH, Vector2 origin)
{
// Offset from the pivot (= transform.Position) to the sprite's geometric center. // Offset from the pivot (= transform.Position) to the sprite's geometric center.
var toCenter = new Vector2( var toCenter = new Vector2(
scaledW / 2f - origin.X * transform.Scale.X, scaledW / 2f - origin.X * transform.Scale.X,
scaledH / 2f - origin.Y * transform.Scale.Y); scaledH / 2f - origin.Y * transform.Scale.Y);
if (transform.Rotation == 0f)
{
return transform.Position + toCenter;
}
var (sin, cos) = MathF.SinCos(transform.Rotation); var (sin, cos) = MathF.SinCos(transform.Rotation);
var center = transform.Position + new Vector2( return transform.Position + new Vector2(
toCenter.X * cos - toCenter.Y * sin, toCenter.X * cos - toCenter.Y * sin,
toCenter.X * sin + toCenter.Y * cos); toCenter.X * sin + toCenter.Y * cos);
var radius = 0.5f * MathF.Sqrt(scaledW * scaledW + scaledH * scaledH);
return (center, radius);
} }
/// <summary>True when the circle overlaps the rectangle.</summary> /// <summary>True when the circle overlaps the rectangle.</summary>
+66 -5
View File
@@ -1,3 +1,4 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems; using Friflo.Engine.ECS.Systems;
namespace MrGameEng.Graphics; namespace MrGameEng.Graphics;
@@ -30,10 +31,20 @@ public sealed class CameraSystem : QuerySystem<Camera>
} }
} }
/// <summary>Submits every entity that has both <see cref="Sprite"/> and <see cref="Transform2D"/>.</summary> /// <summary>
/// Submits every entity that has both <see cref="Sprite"/> and <see cref="Transform2D"/>.
/// Above <see cref="Renderer2DOptions.ParallelThreshold"/> entities, work is split into
/// fixed-size segments processed on all cores (a Friflo chunk holds a whole archetype, so
/// chunks themselves are too coarse); results merge in segment order, preserving sort stability.
/// </summary>
public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D> public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
{ {
private const int SegmentSize = 4096;
private readonly Renderer2D _renderer; private readonly Renderer2D _renderer;
private readonly List<(Chunk<Sprite> Sprites, Chunk<Transform2D> Transforms)> _chunks = [];
private readonly List<(int Chunk, int Start, int Length)> _segments = [];
private int[] _segmentLengths = [];
/// <summary>Creates the system for <paramref name="renderer"/>.</summary> /// <summary>Creates the system for <paramref name="renderer"/>.</summary>
public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer; public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer;
@@ -41,15 +52,65 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
/// <inheritdoc /> /// <inheritdoc />
protected override void OnUpdate() protected override void OnUpdate()
{ {
_chunks.Clear();
var total = 0;
foreach (var (sprites, transforms, _) in Query.Chunks) foreach (var (sprites, transforms, _) in Query.Chunks)
{ {
var s = sprites.Span; _chunks.Add((sprites, transforms));
var t = transforms.Span; total += sprites.Length;
for (var i = 0; i < s.Length; i++) }
if (total < _renderer.ParallelThreshold)
{
foreach (var (sprites, transforms) in _chunks)
{ {
_renderer.Submit(in t[i], in s[i]); var s = sprites.Span;
var t = transforms.Span;
for (var i = 0; i < s.Length; i++)
{
_renderer.Submit(in t[i], in s[i]);
}
}
return;
}
_segments.Clear();
for (var c = 0; c < _chunks.Count; c++)
{
var length = _chunks[c].Sprites.Length;
for (var start = 0; start < length; start += SegmentSize)
{
_segments.Add((c, start, Math.Min(SegmentSize, length - start)));
} }
} }
if (_segmentLengths.Length < _segments.Count)
{
Array.Resize(ref _segmentLengths, _segments.Count);
}
for (var i = 0; i < _segments.Count; i++)
{
_segmentLengths[i] = _segments[i].Length;
}
_renderer.BeginChunkedSubmit(_segmentLengths.AsSpan(0, _segments.Count));
Parallel.For(0, _segments.Count, segmentIndex =>
{
var (chunk, start, length) = _segments[segmentIndex];
var (sprites, transforms) = _chunks[chunk];
var writer = _renderer.GetChunkWriter(segmentIndex);
var s = sprites.Span.Slice(start, length);
var t = transforms.Span.Slice(start, length);
for (var i = 0; i < s.Length; i++)
{
_renderer.SubmitInto(ref writer, in t[i], in s[i]);
}
_renderer.EndChunk(segmentIndex, in writer);
});
_renderer.CommitChunkedSubmit();
} }
} }
+239 -92
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
@@ -6,12 +7,17 @@ namespace MrGameEng.Graphics;
/// <summary> /// <summary>
/// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers. /// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers.
/// Per frame: <see cref="BeginFrame"/> (camera) → <see cref="Submit"/> per sprite (with culling) /// Per frame: <see cref="BeginFrame"/> (camera) → <see cref="Submit"/> per sprite (with culling)
/// → <see cref="EndFrame"/> (sort layer → depth → texture, build vertices, issue draw calls). /// → <see cref="EndFrame"/> (stable sort layer → depth → texture, build vertices, issue draw calls).
/// Above <see cref="Renderer2DOptions.ParallelThreshold"/> sprites, submission and vertex
/// building run on all cores; the vertex buffer is ring-written (NoOverwrite) to avoid GPU stalls.
/// Registered as a service; scenes attach it via <c>scene.UseRenderer2D()</c>. /// Registered as a service; scenes attach it via <c>scene.UseRenderer2D()</c>.
/// </summary> /// </summary>
public sealed class Renderer2D : IDisposable public sealed class Renderer2D : IDisposable
{ {
private const int MaxQuadsPerDraw = 8192; private const int MaxQuadsPerDraw = 8192;
private const int ParallelBlock = 4096;
private static readonly int VertexStride = VertexPositionColorTexture.VertexDeclaration.VertexStride;
/// <summary>Render layer registry. Register layers before the first frame.</summary> /// <summary>Render layer registry. Register layers before the first frame.</summary>
public LayerRegistry Layers { get; } = new(); public LayerRegistry Layers { get; } = new();
@@ -28,6 +34,21 @@ public sealed class Renderer2D : IDisposable
/// <summary>Sprites rejected by culling this frame.</summary> /// <summary>Sprites rejected by culling this frame.</summary>
public int CulledSprites { get; private set; } public int CulledSprites { get; private set; }
/// <summary>Milliseconds spent submitting sprites (BeginFrame → EndFrame) last frame.</summary>
public float SubmitMs { get; private set; }
/// <summary>Milliseconds spent sorting last frame.</summary>
public float SortMs { get; private set; }
/// <summary>Milliseconds spent building vertices last frame.</summary>
public float BuildMs { get; private set; }
/// <summary>Milliseconds spent uploading vertices to the GPU last frame.</summary>
public float UploadMs { get; private set; }
/// <summary>Milliseconds spent issuing draw calls last frame.</summary>
public float DrawMs { get; private set; }
private readonly GraphicsDevice _device; private readonly GraphicsDevice _device;
private readonly Renderer2DOptions _options; private readonly Renderer2DOptions _options;
private readonly SpriteBatcher _batcher; private readonly SpriteBatcher _batcher;
@@ -37,6 +58,9 @@ public sealed class Renderer2D : IDisposable
private VertexPositionColorTexture[] _vertices; private VertexPositionColorTexture[] _vertices;
private CameraState _screenCamera; private CameraState _screenCamera;
private bool _begun; private bool _begun;
private long _submitStartTimestamp;
private int _ringCursor;
private int _ringBaseVertex;
/// <summary>Creates the renderer. One instance per game is enough.</summary> /// <summary>Creates the renderer. One instance per game is enough.</summary>
public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null) public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null)
@@ -46,7 +70,7 @@ public sealed class Renderer2D : IDisposable
_batcher = new SpriteBatcher(_options.InitialCapacity); _batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4]; _vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer( _vertexBuffer = new DynamicVertexBuffer(
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length, BufferUsage.WriteOnly); device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly);
_effect = new BasicEffect(device) _effect = new BasicEffect(device)
{ {
@@ -58,6 +82,8 @@ public sealed class Renderer2D : IDisposable
_indexBuffer = CreateQuadIndexBuffer(device); _indexBuffer = CreateQuadIndexBuffer(device);
} }
internal int ParallelThreshold => _options.ParallelThreshold;
/// <summary>Begins a frame with the given camera. Called by <see cref="CameraSystem"/>.</summary> /// <summary>Begins a frame with the given camera. Called by <see cref="CameraSystem"/>.</summary>
public void BeginFrame(in Camera camera) public void BeginFrame(in Camera camera)
{ {
@@ -70,6 +96,7 @@ public sealed class Renderer2D : IDisposable
SubmittedSprites = 0; SubmittedSprites = 0;
CulledSprites = 0; CulledSprites = 0;
_begun = true; _begun = true;
_submitStartTimestamp = Stopwatch.GetTimestamp();
} }
/// <summary> /// <summary>
@@ -86,63 +113,86 @@ public sealed class Renderer2D : IDisposable
/// <summary>Submits one sprite. Invisible sprites (outside the camera) are culled here.</summary> /// <summary>Submits one sprite. Invisible sprites (outside the camera) are culled here.</summary>
public void Submit(in Transform2D transform, in Sprite sprite) public void Submit(in Transform2D transform, in Sprite sprite)
{ {
if (!_begun) EnsureBegun();
switch (TryBuildInstance(in transform, in sprite, out var instance, out var key))
{ {
throw new InvalidOperationException("Submit called outside BeginFrame/EndFrame (is CameraSystem registered first?)."); case SubmitResult.Visible:
_batcher.Submit(in instance, key);
SubmittedSprites++;
break;
case SubmitResult.Culled:
CulledSprites++;
break;
} }
if (sprite.Region is not { } region)
{
return;
}
var layer = Layers[sprite.Layer];
var (center, radius) = CullingMath.SpriteBoundingCircle(transform, region.Width, region.Height, sprite.Origin);
if (layer.Space == LayerSpace.World &&
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
{
CulledSprites++;
return;
}
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
_batcher.Submit(
new SpriteInstance
{
Region = region,
Center = center,
HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f,
Rotation = transform.Rotation,
Color = sprite.Color,
Flip = sprite.Flip,
Layer = sprite.Layer.Value,
},
SpriteSortKey.Make(sprite.Layer.Value, depth, region.TextureSortKey));
SubmittedSprites++;
} }
/// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary> /// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary>
public void EndFrame() public void EndFrame()
{ {
if (!_begun) EnsureBegun();
{
throw new InvalidOperationException("EndFrame called without BeginFrame.");
}
_begun = false; _begun = false;
DrawCalls = 0; DrawCalls = 0;
var order = _batcher.Sort(); var submitEnd = Stopwatch.GetTimestamp();
if (order.Length == 0) SubmitMs = ToMs(submitEnd - _submitStartTimestamp);
SortMs = 0f;
BuildMs = 0f;
UploadMs = 0f;
DrawMs = 0f;
_batcher.Sort();
var count = _batcher.Count;
var sortEnd = Stopwatch.GetTimestamp();
SortMs = ToMs(sortEnd - submitEnd);
if (count == 0)
{ {
return; return;
} }
EnsureVertexCapacity(order.Length * 4); var order = _batcher.SortedOrder;
BuildVertices(order); EnsureVertexCapacity(count * 4);
_vertexBuffer.SetData(_vertices, 0, order.Length * 4, SetDataOptions.Discard); if (count >= _options.ParallelThreshold)
{
var blocks = (count + ParallelBlock - 1) / ParallelBlock;
Parallel.For(0, blocks, block =>
{
var end = Math.Min((block + 1) * ParallelBlock, count);
for (var i = block * ParallelBlock; i < end; i++)
{
BuildVertex(order, i);
}
});
}
else
{
for (var i = 0; i < count; i++)
{
BuildVertex(order, i);
}
}
var buildEnd = Stopwatch.GetTimestamp();
BuildMs = ToMs(buildEnd - sortEnd);
// Кольцевая запись: NoOverwrite не заставляет GPU ждать предыдущий кадр;
// Discard только на перемотке кольца.
var vertexCount = count * 4;
SetDataOptions hint;
if (_ringCursor + vertexCount <= _vertexBuffer.VertexCount)
{
_ringBaseVertex = _ringCursor;
hint = SetDataOptions.NoOverwrite;
}
else
{
_ringBaseVertex = 0;
hint = SetDataOptions.Discard;
}
_vertexBuffer.SetData(_ringBaseVertex * VertexStride, _vertices, 0, vertexCount, VertexStride, hint);
_ringCursor = _ringBaseVertex + vertexCount;
var uploadEnd = Stopwatch.GetTimestamp();
UploadMs = ToMs(uploadEnd - buildEnd);
_device.BlendState = BlendState.AlphaBlend; _device.BlendState = BlendState.AlphaBlend;
_device.SamplerStates[0] = _options.Sampler; _device.SamplerStates[0] = _options.Sampler;
@@ -151,7 +201,8 @@ public sealed class Renderer2D : IDisposable
_device.SetVertexBuffer(_vertexBuffer); _device.SetVertexBuffer(_vertexBuffer);
_device.Indices = _indexBuffer; _device.Indices = _indexBuffer;
DrawBatches(order); DrawBatches(order, count);
DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
} }
/// <summary>Converts a physical screen point to world coordinates using the current camera.</summary> /// <summary>Converts a physical screen point to world coordinates using the current camera.</summary>
@@ -168,6 +219,88 @@ public sealed class Renderer2D : IDisposable
_indexBuffer.Dispose(); _indexBuffer.Dispose();
} }
// --- Параллельная по-чанковая подача (используется SpriteRenderSystem выше порога) ----
internal void BeginChunkedSubmit(ReadOnlySpan<int> chunkLengths)
{
EnsureBegun();
_batcher.BeginChunks(chunkLengths);
}
internal SpriteChunkWriter GetChunkWriter(int chunkIndex) => _batcher.GetChunkWriter(chunkIndex);
internal void SubmitInto(ref SpriteChunkWriter writer, in Transform2D transform, in Sprite sprite)
{
switch (TryBuildInstance(in transform, in sprite, out var instance, out var key))
{
case SubmitResult.Visible:
writer.Add(in instance, key);
break;
case SubmitResult.Culled:
writer.AddCulled();
break;
}
}
internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) => _batcher.EndChunk(chunkIndex, in writer);
internal void CommitChunkedSubmit()
{
SubmittedSprites += _batcher.CommitChunks();
CulledSprites += _batcher.LastChunkCulled;
}
private enum SubmitResult
{
Skipped,
Culled,
Visible,
}
private SubmitResult TryBuildInstance(
in Transform2D transform, in Sprite sprite, out SpriteInstance instance, out ulong key)
{
instance = default;
key = 0;
if (sprite.Region is not { } region)
{
return SubmitResult.Skipped;
}
var layer = Layers[sprite.Layer];
var (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin);
if (layer.Space == LayerSpace.World &&
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
{
return SubmitResult.Culled;
}
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
instance = new SpriteInstance
{
Region = region,
Center = center,
HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f,
Rotation = transform.Rotation,
Color = sprite.Color,
Flip = sprite.Flip,
Layer = sprite.Layer.Value,
};
key = SpriteSortKey.Make(sprite.Layer.Value, depth, region.TextureSortKey);
return SubmitResult.Visible;
}
private void EnsureBegun()
{
if (!_begun)
{
throw new InvalidOperationException(
"Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?).");
}
}
private (int Width, int Height, ViewportMapping Mapping) ResolveVirtualResolution() private (int Width, int Height, ViewportMapping Mapping) ResolveVirtualResolution()
{ {
var viewport = _device.Viewport; var viewport = _device.Viewport;
@@ -180,43 +313,48 @@ public sealed class Renderer2D : IDisposable
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y)); CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y));
} }
private void BuildVertices(ReadOnlySpan<int> order) private void BuildVertex(int[] order, int i)
{ {
for (var i = 0; i < order.Length; i++) ref readonly var instance = ref _batcher[order[i]];
var region = instance.Region;
var u0 = region.U0;
var v0 = region.V0;
var u1 = region.U1;
var v1 = region.V1;
if ((instance.Flip & SpriteFlip.X) != 0)
{ {
ref readonly var instance = ref _batcher[order[i]]; (u0, u1) = (u1, u0);
var bounds = instance.Region.Bounds;
var texture = instance.Region.Texture;
var u0 = bounds.X / (float)texture.Width;
var v0 = bounds.Y / (float)texture.Height;
var u1 = (bounds.X + bounds.Width) / (float)texture.Width;
var v1 = (bounds.Y + bounds.Height) / (float)texture.Height;
if ((instance.Flip & SpriteFlip.X) != 0)
{
(u0, u1) = (u1, u0);
}
if ((instance.Flip & SpriteFlip.Y) != 0)
{
(v0, v1) = (v1, v0);
}
var (sin, cos) = MathF.SinCos(instance.Rotation);
var rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin);
var ry = new Vector2(-instance.HalfSize.Y * sin, instance.HalfSize.Y * cos);
var center = instance.Center;
var vertex = i * 4;
_vertices[vertex + 0] = Vertex(center - rx - ry, instance.Color, u0, v0);
_vertices[vertex + 1] = Vertex(center + rx - ry, instance.Color, u1, v0);
_vertices[vertex + 2] = Vertex(center - rx + ry, instance.Color, u0, v1);
_vertices[vertex + 3] = Vertex(center + rx + ry, instance.Color, u1, v1);
} }
if ((instance.Flip & SpriteFlip.Y) != 0)
{
(v0, v1) = (v1, v0);
}
Vector2 rx, ry;
if (instance.Rotation == 0f)
{
rx = new Vector2(instance.HalfSize.X, 0f);
ry = new Vector2(0f, instance.HalfSize.Y);
}
else
{
var (sin, cos) = MathF.SinCos(instance.Rotation);
rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin);
ry = new Vector2(-instance.HalfSize.Y * sin, instance.HalfSize.Y * cos);
}
var center = instance.Center;
var vertex = i * 4;
_vertices[vertex + 0] = Vertex(center - rx - ry, instance.Color, u0, v0);
_vertices[vertex + 1] = Vertex(center + rx - ry, instance.Color, u1, v0);
_vertices[vertex + 2] = Vertex(center - rx + ry, instance.Color, u0, v1);
_vertices[vertex + 3] = Vertex(center + rx + ry, instance.Color, u1, v1);
} }
private void DrawBatches(ReadOnlySpan<int> order) private void DrawBatches(int[] order, int count)
{ {
var batchStart = 0; var batchStart = 0;
ref readonly var first = ref _batcher[order[0]]; ref readonly var first = ref _batcher[order[0]];
@@ -224,11 +362,11 @@ public sealed class Renderer2D : IDisposable
var currentLayer = first.Layer; var currentLayer = first.Layer;
ApplyLayerMatrices(currentLayer); ApplyLayerMatrices(currentLayer);
for (var i = 1; i <= order.Length; i++) for (var i = 1; i <= count; i++)
{ {
Texture2D? texture = null; Texture2D? texture = null;
byte layer = 0; byte layer = 0;
if (i < order.Length) if (i < count)
{ {
ref readonly var instance = ref _batcher[order[i]]; ref readonly var instance = ref _batcher[order[i]];
texture = instance.Region.Texture; texture = instance.Region.Texture;
@@ -242,7 +380,7 @@ public sealed class Renderer2D : IDisposable
DrawRange(currentTexture, batchStart, i - batchStart); DrawRange(currentTexture, batchStart, i - batchStart);
batchStart = i; batchStart = i;
if (i < order.Length) if (i < count)
{ {
currentTexture = texture!; currentTexture = texture!;
if (layer != currentLayer) if (layer != currentLayer)
@@ -271,7 +409,8 @@ public sealed class Renderer2D : IDisposable
foreach (var pass in _effect.CurrentTechnique.Passes) foreach (var pass in _effect.CurrentTechnique.Passes)
{ {
pass.Apply(); pass.Apply();
_device.DrawIndexedPrimitives(PrimitiveType.TriangleList, firstQuad * 4, 0, quads * 2); _device.DrawIndexedPrimitives(
PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2);
DrawCalls++; DrawCalls++;
} }
@@ -282,23 +421,31 @@ public sealed class Renderer2D : IDisposable
private void EnsureVertexCapacity(int vertexCount) private void EnsureVertexCapacity(int vertexCount)
{ {
if (_vertices.Length >= vertexCount) if (_vertices.Length < vertexCount)
{ {
return; var capacity = _vertices.Length;
while (capacity < vertexCount)
{
capacity *= 2;
}
_vertices = new VertexPositionColorTexture[capacity];
} }
var capacity = _vertices.Length; // GPU-буфер держим вдвое больше CPU-массива — кольцу нужен запас,
while (capacity < vertexCount) // чтобы NoOverwrite срабатывал чаще, чем Discard.
var wantedBuffer = _vertices.Length * 2;
if (_vertexBuffer.VertexCount < wantedBuffer)
{ {
capacity *= 2; _vertexBuffer.Dispose();
_vertexBuffer = new DynamicVertexBuffer(
_device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly);
_ringCursor = 0;
} }
_vertices = new VertexPositionColorTexture[capacity];
_vertexBuffer.Dispose();
_vertexBuffer = new DynamicVertexBuffer(
_device, VertexPositionColorTexture.VertexDeclaration, capacity, BufferUsage.WriteOnly);
} }
private static float ToMs(long timestampDelta) => (float)timestampDelta * 1000f / Stopwatch.Frequency;
private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) => private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) =>
new(new Vector3(position, 0f), color, new Vector2(u, v)); new(new Vector3(position, 0f), color, new Vector2(u, v));
@@ -17,4 +17,12 @@ public sealed class Renderer2DOptions
/// <summary>Initial sprite capacity of the batcher; grows automatically.</summary> /// <summary>Initial sprite capacity of the batcher; grows automatically.</summary>
public int InitialCapacity { get; set; } = 2048; public int InitialCapacity { get; set; } = 2048;
/// <summary>
/// Sprite count from which submission and vertex building run on all cores.
/// Below the threshold the renderer stays single-threaded and allocation-free;
/// above it, <c>Parallel.For</c> adds a few small scheduler allocations per frame.
/// Set to <see cref="int.MaxValue"/> to disable parallelism.
/// </summary>
public int ParallelThreshold { get; set; } = 8192;
} }
+206 -7
View File
@@ -2,6 +2,44 @@ using Microsoft.Xna.Framework;
namespace MrGameEng.Graphics; namespace MrGameEng.Graphics;
/// <summary>
/// Writer for one ECS chunk during parallel submission: appends accepted sprites compactly
/// into the chunk's scratch range and counts culled ones. Used by exactly one thread.
/// </summary>
public struct SpriteChunkWriter
{
private readonly SpriteInstance[] _instances;
private readonly ulong[] _keys;
private readonly int _offset;
/// <summary>Sprites accepted into this chunk's range.</summary>
public int Count { get; private set; }
/// <summary>Sprites rejected by culling in this chunk.</summary>
public int Culled { get; private set; }
internal SpriteChunkWriter(SpriteInstance[] instances, ulong[] keys, int offset)
{
_instances = instances;
_keys = keys;
_offset = offset;
Count = 0;
Culled = 0;
}
/// <summary>Appends one accepted sprite.</summary>
public void Add(in SpriteInstance instance, ulong sortKey)
{
var index = _offset + Count;
_instances[index] = instance;
_keys[index] = sortKey;
Count++;
}
/// <summary>Counts one culled sprite.</summary>
public void AddCulled() => Culled++;
}
/// <summary>One sprite queued for rendering this frame.</summary> /// <summary>One sprite queued for rendering this frame.</summary>
public struct SpriteInstance public struct SpriteInstance
{ {
@@ -29,14 +67,22 @@ public struct SpriteInstance
/// <summary> /// <summary>
/// CPU side of the renderer: collects <see cref="SpriteInstance"/>s with their sort keys /// CPU side of the renderer: collects <see cref="SpriteInstance"/>s with their sort keys
/// and orders them layer → depth → texture. Allocation-free after warm-up /// and orders them layer → depth → texture using a stable LSD radix sort — sprites with
/// (arrays grow geometrically and are reused across frames). /// equal keys keep their submission order across frames (no flicker), and sorting stays
/// O(n) on large counts. Allocation-free after warm-up (arrays grow geometrically and are
/// reused across frames).
/// </summary> /// </summary>
public sealed class SpriteBatcher public sealed class SpriteBatcher
{ {
private const int RadixBits = 16;
private const int RadixSize = 1 << RadixBits;
private SpriteInstance[] _instances; private SpriteInstance[] _instances;
private ulong[] _keys; private ulong[] _keys;
private ulong[] _keysTemp;
private int[] _order; private int[] _order;
private int[] _orderTemp;
private readonly int[] _histogram = new int[RadixSize];
private int _count; private int _count;
/// <summary>Creates a batcher with the given initial capacity.</summary> /// <summary>Creates a batcher with the given initial capacity.</summary>
@@ -44,7 +90,9 @@ public sealed class SpriteBatcher
{ {
_instances = new SpriteInstance[initialCapacity]; _instances = new SpriteInstance[initialCapacity];
_keys = new ulong[initialCapacity]; _keys = new ulong[initialCapacity];
_keysTemp = new ulong[initialCapacity];
_order = new int[initialCapacity]; _order = new int[initialCapacity];
_orderTemp = new int[initialCapacity];
} }
/// <summary>Number of sprites submitted this frame.</summary> /// <summary>Number of sprites submitted this frame.</summary>
@@ -64,31 +112,182 @@ public sealed class SpriteBatcher
} }
/// <summary> /// <summary>
/// Sorts all submitted sprites and returns their indices in draw order. /// Sorts all submitted sprites (stable: equal keys keep submission order) and returns
/// Valid until the next <see cref="Clear"/>. /// their indices in draw order. Valid until the next <see cref="Clear"/>.
/// </summary> /// </summary>
public ReadOnlySpan<int> Sort() public ReadOnlySpan<int> Sort()
{ {
for (var i = 0; i < _count; i++) var n = _count;
for (var i = 0; i < n; i++)
{ {
_order[i] = i; _order[i] = i;
} }
Array.Sort(_keys, _order, 0, _count); if (n < 2)
return _order.AsSpan(0, _count); {
return _order.AsSpan(0, n);
}
// Биты, различающиеся хотя бы у одной пары ключей: проходы по одинаковым
// разрядам (один слой, одна глубина) пропускаются целиком.
ulong orBits = 0, andBits = ~0UL;
for (var i = 0; i < n; i++)
{
orBits |= _keys[i];
andBits &= _keys[i];
}
var differing = orBits ^ andBits;
var keys = _keys;
var order = _order;
var keysOut = _keysTemp;
var orderOut = _orderTemp;
for (var shift = 0; shift < 64; shift += RadixBits)
{
if ((differing >> shift & (RadixSize - 1)) == 0)
{
continue;
}
Array.Clear(_histogram, 0, RadixSize);
for (var i = 0; i < n; i++)
{
_histogram[(int)(keys[i] >> shift & (RadixSize - 1))]++;
}
var running = 0;
for (var digit = 0; digit < RadixSize; digit++)
{
var bucket = _histogram[digit];
_histogram[digit] = running;
running += bucket;
}
for (var i = 0; i < n; i++)
{
var position = _histogram[(int)(keys[i] >> shift & (RadixSize - 1))]++;
keysOut[position] = keys[i];
orderOut[position] = order[i];
}
(keys, keysOut) = (keysOut, keys);
(order, orderOut) = (orderOut, order);
}
_keys = keys;
_keysTemp = keysOut;
_order = order;
_orderTemp = orderOut;
return _order.AsSpan(0, n);
} }
/// <summary>Returns the instance at <paramref name="index"/> (an index from <see cref="Sort"/>).</summary> /// <summary>Returns the instance at <paramref name="index"/> (an index from <see cref="Sort"/>).</summary>
public ref readonly SpriteInstance this[int index] => ref _instances[index]; public ref readonly SpriteInstance this[int index] => ref _instances[index];
/// <summary>The sorted index array after <see cref="Sort"/> (first <see cref="Count"/> entries are valid).</summary>
internal int[] SortedOrder => _order;
/// <summary>Resets the batcher for the next frame. Keeps allocated capacity.</summary> /// <summary>Resets the batcher for the next frame. Keeps allocated capacity.</summary>
public void Clear() => _count = 0; public void Clear() => _count = 0;
// --- Параллельная по-чанковая подача -------------------------------------------------
// Каждый чанк ECS пишет в свой непересекающийся диапазон scratch-арены, затем диапазоны
// сливаются в порядке чанков — порядок детерминирован, стабильность сортировки сохраняется.
private SpriteInstance[] _scratchInstances = [];
private ulong[] _scratchKeys = [];
private int[] _chunkOffsets = [];
private int[] _chunkVisible = [];
private int[] _chunkCulled = [];
private int _chunkCount;
/// <summary>Total culled count reported by chunk writers in the last <see cref="CommitChunks"/>.</summary>
public int LastChunkCulled { get; private set; }
/// <summary>
/// Prepares the scratch arena for chunked submission. <paramref name="chunkLengths"/> are
/// the entity counts of each ECS chunk, in iteration order.
/// </summary>
public void BeginChunks(ReadOnlySpan<int> chunkLengths)
{
_chunkCount = chunkLengths.Length;
if (_chunkOffsets.Length < _chunkCount)
{
Array.Resize(ref _chunkOffsets, _chunkCount);
Array.Resize(ref _chunkVisible, _chunkCount);
Array.Resize(ref _chunkCulled, _chunkCount);
}
var total = 0;
for (var i = 0; i < _chunkCount; i++)
{
_chunkOffsets[i] = total;
total += chunkLengths[i];
}
if (_scratchInstances.Length < total)
{
_scratchInstances = new SpriteInstance[total];
_scratchKeys = new ulong[total];
}
}
/// <summary>Returns the writer for chunk <paramref name="chunkIndex"/>. Each chunk is written by one thread.</summary>
public SpriteChunkWriter GetChunkWriter(int chunkIndex) =>
new(_scratchInstances, _scratchKeys, _chunkOffsets[chunkIndex]);
/// <summary>Records the writer's results. Called by the same thread that filled the writer.</summary>
public void EndChunk(int chunkIndex, in SpriteChunkWriter writer)
{
_chunkVisible[chunkIndex] = writer.Count;
_chunkCulled[chunkIndex] = writer.Culled;
}
/// <summary>
/// Merges all chunk ranges into the main arrays in chunk order (deterministic, keeps
/// sort stability) and returns the number of accepted sprites.
/// </summary>
public int CommitChunks()
{
var visible = 0;
var culled = 0;
for (var i = 0; i < _chunkCount; i++)
{
visible += _chunkVisible[i];
culled += _chunkCulled[i];
}
while (_count + visible > _instances.Length)
{
Grow();
}
for (var i = 0; i < _chunkCount; i++)
{
var length = _chunkVisible[i];
if (length == 0)
{
continue;
}
Array.Copy(_scratchInstances, _chunkOffsets[i], _instances, _count, length);
Array.Copy(_scratchKeys, _chunkOffsets[i], _keys, _count, length);
_count += length;
}
LastChunkCulled = culled;
_chunkCount = 0;
return visible;
}
private void Grow() private void Grow()
{ {
var capacity = _instances.Length * 2; var capacity = _instances.Length * 2;
Array.Resize(ref _instances, capacity); Array.Resize(ref _instances, capacity);
Array.Resize(ref _keys, capacity); Array.Resize(ref _keys, capacity);
Array.Resize(ref _keysTemp, capacity);
Array.Resize(ref _order, capacity); Array.Resize(ref _order, capacity);
Array.Resize(ref _orderTemp, capacity);
} }
} }
+16
View File
@@ -23,6 +23,11 @@ public sealed class Texture2DRegion
public int Height => Bounds.Height; public int Height => Bounds.Height;
internal readonly int TextureSortKey; internal readonly int TextureSortKey;
internal readonly float Diagonal;
internal readonly float U0;
internal readonly float V0;
internal readonly float U1;
internal readonly float V1;
/// <summary>Creates a region covering part of <paramref name="texture"/>.</summary> /// <summary>Creates a region covering part of <paramref name="texture"/>.</summary>
public Texture2DRegion(Texture2D texture, Rectangle bounds) public Texture2DRegion(Texture2D texture, Rectangle bounds)
@@ -30,6 +35,17 @@ public sealed class Texture2DRegion
Texture = texture; Texture = texture;
Bounds = bounds; Bounds = bounds;
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture); TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
Diagonal = MathF.Sqrt((float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height);
// UV предрассчитаны один раз — в кадре на каждый спрайт экономятся 4 деления.
// texture может быть null только в headless-тестах.
if (texture is not null)
{
U0 = bounds.X / (float)texture.Width;
V0 = bounds.Y / (float)texture.Height;
U1 = (bounds.X + bounds.Width) / (float)texture.Width;
V1 = (bounds.Y + bounds.Height) / (float)texture.Height;
}
} }
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary> /// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Myra" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+49
View File
@@ -0,0 +1,49 @@
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Myra;
using Myra.Graphics2D.UI;
using MrGameEng.Core;
namespace MrGameEng.UI;
/// <summary>
/// Draw system rendering the scene's Myra <see cref="Desktop"/>. Must run after the scene's
/// world rendering (register UI last in the draw phase) — the UI is drawn on top in window
/// pixels and also processes mouse/keyboard interaction during render.
/// </summary>
public sealed class UiRenderSystem : BaseSystem
{
private readonly Desktop _desktop;
/// <summary>Creates the system for <paramref name="desktop"/>.</summary>
public UiRenderSystem(Desktop desktop) => _desktop = desktop;
/// <inheritdoc />
protected override void OnUpdateGroup() => _desktop.Render();
}
/// <summary>Wires the UI module (Myra) into a <see cref="Scene"/>.</summary>
public static class SceneUiExtensions
{
private static bool _environmentInitialized;
/// <summary>
/// Creates a Myra <see cref="Desktop"/> for this scene and registers
/// <see cref="UiRenderSystem"/> in the draw phase. Call from <c>OnLoad</c>
/// <b>after</b> <c>UseRenderer2D()</c> so the UI draws on top of the world.
/// Build the UI by assigning <see cref="Desktop.Root"/>.
/// </summary>
public static Desktop UseUI(this Scene scene)
{
// Геттер MyraEnvironment.Game бросает исключение, пока Game не задан — проверять через ??= нельзя.
if (!_environmentInitialized)
{
MyraEnvironment.Game = scene.Context.Services.Get<Game>();
_environmentInitialized = true;
}
var desktop = new Desktop();
scene.DrawSystems.Add(new UiRenderSystem(desktop));
return desktop;
}
}
@@ -0,0 +1,108 @@
using MrGameEng.Core;
using Xunit;
namespace MrGameEng.Core.Tests;
public class SceneTransitionTests
{
private sealed class TrackingScene : Scene
{
public int LoadCount;
public int UnloadCount;
protected override void OnLoad() => LoadCount++;
protected override void OnUnload() => UnloadCount++;
}
private static void Tick(EngineContext context, float seconds)
{
context.Clock.Advance(seconds);
context.Scenes.Update(context.Clock);
}
[Fact]
public void TransitionSwitch_KeepsOldScene_UntilFullyCovered()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
context.Scenes.Switch(first);
Tick(context, 0.016f);
// Fade(1.0) → фаза закрытия 0.5 c, фаза открытия 0.5 c.
context.Scenes.Switch(second, Transition.Fade(1f));
Tick(context, 0.2f);
Assert.True(context.Scenes.IsTransitioning);
Assert.Same(first, context.Scenes.Current);
Assert.Equal(0, second.LoadCount);
Tick(context, 0.4f); // суммарно 0.6 > 0.5 — экран закрыт, своп произошёл
Assert.Same(second, context.Scenes.Current);
Assert.Equal(1, first.UnloadCount);
Assert.Equal(1, second.LoadCount);
Assert.True(context.Scenes.IsTransitioning); // идёт фаза открытия
Tick(context, 0.6f); // открытие завершено
Assert.False(context.Scenes.IsTransitioning);
Assert.Same(second, context.Scenes.Current);
}
[Fact]
public void Transition_UsesUnscaledTime_WorksWhilePaused()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Clock.TimeScale = 0f; // игра на паузе
context.Scenes.Switch(second, Transition.Fade(0.2f));
Tick(context, 0.15f);
Tick(context, 0.15f);
Assert.Same(second, context.Scenes.Current);
Assert.False(context.Scenes.IsTransitioning);
}
[Fact]
public void SwitchDuringTransition_ReplacesPendingTarget()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
var third = new TrackingScene();
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(1f));
Tick(context, 0.1f);
context.Scenes.Switch(third); // передумали, пока экран закрывается
Tick(context, 0.5f);
Assert.Same(third, context.Scenes.Current);
Assert.Equal(0, second.LoadCount);
}
[Fact]
public void ZeroDurationTransition_SwapsOnNextUpdates()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(0f));
Tick(context, 0.016f);
Tick(context, 0.016f);
Assert.Same(second, context.Scenes.Current);
Assert.False(context.Scenes.IsTransitioning);
}
}
@@ -0,0 +1,147 @@
using System.Text;
using MrGameEng.Core;
using Xunit;
namespace MrGameEng.DevConsole.Tests;
public class DevConsoleTests
{
private static string Visible(DevConsole console, int lines = 50)
{
var sb = new StringBuilder();
console.BuildVisibleText(sb, lines);
return sb.ToString();
}
[Fact]
public void Execute_RunsRegisteredCommand_WithArguments()
{
using var console = new DevConsole();
string[]? received = null;
console.Register("test", "test command", (_, args) => received = args);
console.Execute("test one two");
Assert.NotNull(received);
Assert.Equal(["one", "two"], received!);
Assert.Contains("> test one two", Visible(console));
}
[Fact]
public void Execute_UnknownCommand_ReportsWithoutThrowing()
{
using var console = new DevConsole();
console.Execute("nosuchcommand");
Assert.Contains("unknown command 'nosuchcommand'", Visible(console));
}
[Fact]
public void Execute_HandlerException_IsCaughtAndLogged()
{
using var console = new DevConsole();
console.Register("boom", "throws", (_, _) => throw new InvalidOperationException("kaboom"));
console.Execute("boom");
Assert.Contains("[err] kaboom", Visible(console));
}
[Fact]
public void RingBuffer_OverflowDropsOldestLines()
{
using var console = new DevConsole(capacity: 4);
for (var i = 0; i < 10; i++)
{
console.WriteLine($"line{i}");
}
var visible = Visible(console);
Assert.DoesNotContain("line5", visible);
Assert.Contains("line6", visible);
Assert.Contains("line9", visible);
}
[Fact]
public void BuildVisibleText_RespectsWindowAndScroll()
{
using var console = new DevConsole();
for (var i = 0; i < 10; i++)
{
console.WriteLine($"line{i}");
}
var sb = new StringBuilder();
console.BuildVisibleText(sb, 3);
Assert.Equal("line7\nline8\nline9", sb.ToString());
console.Scroll(+2);
console.BuildVisibleText(sb, 3);
Assert.StartsWith("line5\nline6\nline7", sb.ToString());
}
[Fact]
public void History_NavigatesUpAndDown()
{
using var console = new DevConsole();
console.Register("a", "", (_, _) => { });
console.Execute("a 1");
console.Execute("a 2");
Assert.Equal("a 2", console.HistoryPrevious());
Assert.Equal("a 1", console.HistoryPrevious());
Assert.Equal("a 1", console.HistoryPrevious()); // упёрлись в начало
Assert.Equal("a 2", console.HistoryNext());
Assert.Equal("", console.HistoryNext()); // за последним — пустая строка
}
[Fact]
public void Complete_SingleMatch_CompletesWithTrailingSpace()
{
using var console = new DevConsole();
Assert.Equal("echo ", console.Complete("ec"));
}
[Fact]
public void Complete_MultipleMatches_ReturnsCommonPrefix_AndListsOptions()
{
using var console = new DevConsole();
console.Register("spawn", "", (_, _) => { });
console.Register("spawnall", "", (_, _) => { });
var completed = console.Complete("sp");
Assert.Equal("spawn", completed);
Assert.Contains("spawn spawnall", Visible(console));
}
[Fact]
public void LogMessages_AreCaptured_WithLevelTags()
{
using var console = new DevConsole();
Log.Info("plain info");
Log.Warning("careful");
var visible = Visible(console);
Assert.Contains("plain info", visible);
Assert.Contains("[warn] careful", visible);
}
[Fact]
public void Revision_ChangesOnlyOnVisibleChanges()
{
using var console = new DevConsole();
var before = console.Revision;
console.WriteLine("x");
Assert.NotEqual(before, console.Revision);
before = console.Revision;
_ = Visible(console); // чтение не меняет ревизию
Assert.Equal(before, console.Revision);
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
</ItemGroup>
</Project>
@@ -37,6 +37,33 @@ public class CullingTests
Assert.Equal(0.5f * MathF.Sqrt(800f), radius, 3); Assert.Equal(0.5f * MathF.Sqrt(800f), radius, 3);
} }
[Fact]
public void BoundingCircle_RegionOverload_MatchesSizeOverload_ForUniformScale()
{
var region = new Texture2DRegion(null!, new Rectangle(0, 0, 48, 24));
var transform = new Transform2D(new Vector2(10f, 20f), rotation: 0.6f, scale: new Vector2(1.5f, 1.5f));
var origin = new Vector2(5f, 7f);
var (centerA, radiusA) = CullingMath.SpriteBoundingCircle(transform, 48f, 24f, origin);
var (centerB, radiusB) = CullingMath.SpriteBoundingCircle(in transform, region, origin);
Assert.Equal(centerA.X, centerB.X, 3);
Assert.Equal(centerA.Y, centerB.Y, 3);
Assert.Equal(radiusA, radiusB, 3);
}
[Fact]
public void BoundingCircle_RegionOverload_IsConservative_ForNonUniformScale()
{
var region = new Texture2DRegion(null!, new Rectangle(0, 0, 100, 10));
var transform = new Transform2D(Vector2.Zero, scale: new Vector2(1f, 3f));
var (_, exact) = CullingMath.SpriteBoundingCircle(transform, 100f, 10f, Vector2.Zero);
var (_, conservative) = CullingMath.SpriteBoundingCircle(in transform, region, Vector2.Zero);
Assert.True(conservative >= exact);
}
[Theory] [Theory]
[InlineData(50f, 50f, true)] // inside [InlineData(50f, 50f, true)] // inside
[InlineData(-4f, 50f, true)] // touching from the left (radius 5) [InlineData(-4f, 50f, true)] // touching from the left (radius 5)
@@ -39,6 +39,113 @@ public class SpriteBatcherTests
Assert.Equal(99, order[0]); // последний сабмит имеет наименьший ключ Assert.Equal(99, order[0]); // последний сабмит имеет наименьший ключ
} }
[Fact]
public void Sort_IsStable_EqualKeysKeepSubmissionOrder()
{
var batcher = new SpriteBatcher();
var key = SpriteSortKey.Make(1, 5f, 7);
for (byte i = 0; i < 50; i++)
{
batcher.Submit(Instance(i), key); // одинаковый ключ у всех
}
var order = batcher.Sort();
for (var i = 0; i < 50; i++)
{
Assert.Equal(i, order[i]);
}
}
[Fact]
public void Sort_IsStable_WithinMixedKeys()
{
var batcher = new SpriteBatcher();
var keyA = SpriteSortKey.Make(0, 0f, 1);
var keyB = SpriteSortKey.Make(2, 3f, 9);
// Чередуем два ключа: внутри каждой группы порядок сабмита должен сохраниться.
for (byte i = 0; i < 20; i++)
{
batcher.Submit(Instance(i), i % 2 == 0 ? keyA : keyB);
}
var order = batcher.Sort();
var expectedA = new[] { 0, 2, 4, 6, 8, 10, 12, 14, 16, 18 };
var expectedB = new[] { 1, 3, 5, 7, 9, 11, 13, 15, 17, 19 };
Assert.Equal(expectedA, order[..10].ToArray());
Assert.Equal(expectedB, order[10..].ToArray());
}
[Fact]
public void Sort_LargeRandomSet_FullyOrdered()
{
var batcher = new SpriteBatcher(initialCapacity: 16);
var random = new Random(123);
var keys = new ulong[5000];
for (var i = 0; i < keys.Length; i++)
{
keys[i] = (ulong)random.NextInt64();
batcher.Submit(Instance(0), keys[i]);
}
var order = batcher.Sort();
for (var i = 1; i < order.Length; i++)
{
Assert.True(keys[order[i - 1]] <= keys[order[i]]);
}
}
[Fact]
public void ChunkedSubmit_MergesInChunkOrder_AndCountsCulled()
{
var batcher = new SpriteBatcher();
var key = SpriteSortKey.Make(0, 0f, 0); // одинаковый ключ — порядок задаётся слиянием чанков
batcher.BeginChunks([3, 2]);
var writer1 = batcher.GetChunkWriter(1); // чанки могут заполняться в любом порядке (параллельно)
writer1.Add(Instance(10), key);
writer1.AddCulled();
batcher.EndChunk(1, in writer1);
var writer0 = batcher.GetChunkWriter(0);
writer0.Add(Instance(1), key);
writer0.Add(Instance(2), key);
batcher.EndChunk(0, in writer0);
var accepted = batcher.CommitChunks();
Assert.Equal(3, accepted);
Assert.Equal(1, batcher.LastChunkCulled);
var order = batcher.Sort();
Assert.Equal(1, batcher[order[0]].Layer); // сначала чанк 0...
Assert.Equal(2, batcher[order[1]].Layer);
Assert.Equal(10, batcher[order[2]].Layer); // ...затем чанк 1 — стабильно
}
[Fact]
public void ChunkedSubmit_GrowsMainArrays_WhenNeeded()
{
var batcher = new SpriteBatcher(initialCapacity: 2);
batcher.BeginChunks([10]);
var writer = batcher.GetChunkWriter(0);
for (byte i = 0; i < 10; i++)
{
writer.Add(Instance(i), i);
}
batcher.EndChunk(0, in writer);
Assert.Equal(10, batcher.CommitChunks());
Assert.Equal(10, batcher.Count);
Assert.Equal(0, batcher[batcher.Sort()[0]].Layer);
}
[Fact] [Fact]
public void Clear_ResetsCount_KeepsWorking() public void Clear_ResetsCount_KeepsWorking()
{ {