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
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
MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
Documented exception: Myra renders with its own SpriteBatch internally.
## Commands
@@ -35,7 +38,10 @@ dotnet run --project samples/MrGameEng.Sample
- ECS-first: components are plain data (`struct` implementing `IComponent`),
behavior goes into Friflo systems (`QuerySystem`), wired through `SystemRoot`.
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,
atlas support); `SpriteBatch` is not used in engine code. Draw systems write vertices
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.
- Public engine API requires XML doc comments (English).
- 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="FontStashSharp.MonoGame" Version="1.5.6" />
<PackageVersion Include="NVorbis" Version="0.10.5" />
<PackageVersion Include="Myra" Version="1.6.1" />
<!-- Source generator -->
<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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input.Tests", "tests\MrGameEng.Input.Tests\MrGameEng.Input.Tests.csproj", "{0E0710AB-6132-4E64-9AFC-03B0601F92C6}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -189,5 +231,8 @@ Global
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD} = {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}
{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
EndGlobal
+67 -2
View File
@@ -33,6 +33,8 @@
| `MrGameEng.Audio` | Звуковые эффекты и музыка |
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
@@ -52,6 +54,37 @@ MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
он подключается к проекту игры как `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)
MGCB / Content Pipeline **не используется**. Все ресурсы лежат в папке `Assets/`
@@ -132,11 +165,42 @@ public static partial class GameAssets
- Порядок сортировки: **слой → depth (или Y) → текстура**; спрайты с одной
текстурой сливаются в один draw call (динамический vertex 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` хранит регион атласа,
спрайты одного атласа батчатся автоматически.
- Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе,
0 аллокаций на кадр. Контролируется бенчмарками (BenchmarkDotNet) и
стресс-сценой в Sample.
0 аллокаций на кадр ниже порога параллелизма. Стресс-сцена Sample: 100k сущностей
(~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
@@ -178,4 +242,5 @@ docs/ документация (русский)
| Friflo.Engine.ECS | 3.6.0 | ECS |
| FontStashSharp.MonoGame | 1.5.6 | Шрифты (ttf) в рантайме |
| NVorbis | 0.10.5 | Декодирование ogg |
| Myra | 1.6.1 | Игровой UI |
| dotnet-mgfxc (dotnet tool) | 3.8.4.1 | Компиляция шейдеров при сборке |
+3 -2
View File
@@ -18,7 +18,8 @@
- Physics2D (выбор библиотеки: Aether.Physics2D / своя)
- Tilemap (поддержка Tiled)
- Particles
- UI
- UI: загрузка MML-разметки Myra через AssetManager + хендлы в кодогенераторе
- UI: рендер Myra через наш батчер (`IMyraRenderer`), если UI станет узким местом по draw call'ам
- DevTools-модуль на ImGui.NET: инспектор сущностей, дебаг-панели
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
- Стабильная сортировка спрайтов с равным ключом (сейчас порядок не гарантирован между кадрами)
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
@@ -11,6 +11,8 @@
<ProjectReference Include="..\..\src\MrGameEng.Input\MrGameEng.Input.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.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"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
+1
View File
@@ -9,6 +9,7 @@ using var host = new GameHost(
Width = 1280,
Height = 720,
ClearColor = new Color(24, 26, 32),
VSync = false, // техдемо: показываем реальный FPS, не ограниченный частотой монитора
},
new MainScene());
+76 -13
View File
@@ -12,10 +12,16 @@ namespace MrGameEng.Sample;
/// <summary>Управление игроком (WASD/стрелки) и прыжок-писк на пробел.</summary>
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()
{
if (console.IsOpen)
{
return;
}
ref var transform = ref player.GetComponent<Transform2D>();
var move = new Vector2(
actions.GetAxis(SampleAction.MoveLeft, SampleAction.MoveRight),
@@ -50,16 +56,60 @@ public sealed class CameraControlSystem(Entity cameraEntity, Entity player, Inpu
}
}
/// <summary>Отскок сущностей со скоростью от границ мира.</summary>
/// <summary>
/// Отскок сущностей со скоростью от границ мира. На больших количествах работа режется
/// на сегменты по всем ядрам (чанк Friflo держит весь архетип — сам по себе он слишком крупный).
/// </summary>
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()
{
var delta = Tick.deltaTime;
_chunks.Clear();
var total = 0;
foreach (var (transforms, velocities, _) in Query.Chunks)
{
var t = transforms.Span;
var v = velocities.Span;
_chunks.Add((transforms, velocities));
total += transforms.Length;
}
if (total < ParallelThreshold)
{
foreach (var (transforms, velocities) in _chunks)
{
Move(transforms, velocities, 0, transforms.Length, delta);
}
return;
}
_segments.Clear();
for (var c = 0; c < _chunks.Count; c++)
{
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;
@@ -79,15 +129,19 @@ public sealed class BounceSystem(RectF bounds) : QuerySystem<Transform2D, Veloci
}
}
}
}
}
/// <summary>Пауза (P), музыка (M), переключение сцены (Tab).</summary>
/// <summary>Пауза (P), музыка (M), переключение сцены с переходом (Tab).</summary>
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()
{
if (context.Services.Get<MrGameEng.DevConsole.DevConsole>().IsOpen)
{
return;
}
if (actions.IsPressed(SampleAction.Pause))
{
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>
public sealed class TitleStatsSystem(EngineContext context, Renderer2D renderer, string sceneName) : BaseSystem
/// <summary>FPS и статистика рендера: в HUD-лейбл и в заголовок окна (4 раза в секунду).</summary>
public sealed class StatsSystem(
EngineContext context, Renderer2D renderer, string sceneName, Myra.Graphics2D.UI.Label? hudLabel = null) : BaseSystem
{
private float _accumulated;
private int _frames;
@@ -131,8 +186,16 @@ public sealed class TitleStatsSystem(EngineContext context, Renderer2D renderer,
var fps = _frames / _accumulated;
_accumulated = 0f;
_frames = 0;
context.Services.Get<GameWindow>().Title =
$"MrGameEng Sample — {sceneName} | {fps:F0} FPS | sprites: {renderer.SubmittedSprites} | culled: {renderer.CulledSprites} | draw calls: {renderer.DrawCalls}";
var stats =
$"{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.Audio;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace MrGameEng.Sample.Scenes;
@@ -90,11 +93,50 @@ public sealed class MainScene : Scene
var hud = new Sprite(shapeRegions[3], SampleLayers.Ui);
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 CameraControlSystem(camera, player, input));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene()));
UpdateSystems.Add(new TitleStatsSystem(Context, renderer, "Main"));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene(), Transition.Fade(0.8f)));
UpdateSystems.Add(new StatsSystem(Context, renderer, "Main", statsLabel));
var music = audio.Music;
if (!music.IsPlaying)
+24 -2
View File
@@ -2,8 +2,10 @@ using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Input;
using MrGameEng.UI;
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 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 StressCameraSystem(camera, input));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene()));
UpdateSystems.Add(new TitleStatsSystem(Context, renderer, $"Stress {SpriteCount:N0}"));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue)));
UpdateSystems.Add(new StatsSystem(Context, renderer, $"Stress {SpriteCount:N0}", statsLabel));
}
/// <summary>Только зум колесом — чтобы регулировать число видимых спрайтов.</summary>
+1
View File
@@ -47,6 +47,7 @@ public class GameHost : Game
Window.AllowUserResizing = _options.AllowResizing;
Context.AttachGraphicsDevice(GraphicsDevice);
Context.Services.Add(Window);
Context.Services.Add<Game>(this);
base.Initialize();
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);
}
}
+78 -8
View File
@@ -1,39 +1,103 @@
namespace MrGameEng.Core;
/// <summary>
/// Owns the active <see cref="Scene"/>. Scene switches are deferred to the start of the
/// next update so a scene is never unloaded in the middle of its own frame.
/// Owns the active <see cref="Scene"/>. Plain switches are deferred to the start of the next
/// 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>
public sealed class SceneManager
{
private enum State
{
Idle,
CoveringOut,
RevealingIn,
}
/// <summary>The active scene, or null before the first switch is applied.</summary>
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 Scene? _pending;
private bool _hasPending;
private Transition? _transition;
private State _state;
private float _coverage;
private TransitionRenderer? _renderer;
internal SceneManager(EngineContext context) => _context = context;
/// <summary>
/// Requests a switch to <paramref name="scene"/>. The current scene is unloaded and the new
/// one loaded at the start of the next update tick. Passing null unloads the current scene.
/// Requests a switch to <paramref name="scene"/>. Without a transition the swap happens at
/// 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>
public void Switch(Scene? scene)
public void Switch(Scene? scene, Transition? transition = null)
{
_pending = scene;
_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)
{
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);
}
/// <summary>Draws the active scene. Called by the host.</summary>
public void Draw(GameClock clock) => Current?.Draw(clock);
/// <summary>Draws the active scene and the transition overlay on top. Called by the host.</summary>
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()
{
@@ -47,5 +111,11 @@ public sealed class SceneManager
Current = _pending;
_pending = null;
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 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.
var toCenter = new Vector2(
scaledW / 2f - origin.X * transform.Scale.X,
scaledH / 2f - origin.Y * transform.Scale.Y);
if (transform.Rotation == 0f)
{
return transform.Position + toCenter;
}
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 * 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>
+62 -1
View File
@@ -1,3 +1,4 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
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>
{
private const int SegmentSize = 4096;
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>
public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer;
@@ -41,7 +52,17 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
/// <inheritdoc />
protected override void OnUpdate()
{
_chunks.Clear();
var total = 0;
foreach (var (sprites, transforms, _) in Query.Chunks)
{
_chunks.Add((sprites, transforms));
total += sprites.Length;
}
if (total < _renderer.ParallelThreshold)
{
foreach (var (sprites, transforms) in _chunks)
{
var s = sprites.Span;
var t = transforms.Span;
@@ -50,6 +71,46 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
_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();
}
}
+216 -69
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
@@ -6,12 +7,17 @@ namespace MrGameEng.Graphics;
/// <summary>
/// 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)
/// → <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>.
/// </summary>
public sealed class Renderer2D : IDisposable
{
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>
public LayerRegistry Layers { get; } = new();
@@ -28,6 +34,21 @@ public sealed class Renderer2D : IDisposable
/// <summary>Sprites rejected by culling this frame.</summary>
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 Renderer2DOptions _options;
private readonly SpriteBatcher _batcher;
@@ -37,6 +58,9 @@ public sealed class Renderer2D : IDisposable
private VertexPositionColorTexture[] _vertices;
private CameraState _screenCamera;
private bool _begun;
private long _submitStartTimestamp;
private int _ringCursor;
private int _ringBaseVertex;
/// <summary>Creates the renderer. One instance per game is enough.</summary>
public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null)
@@ -46,7 +70,7 @@ public sealed class Renderer2D : IDisposable
_batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer(
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length, BufferUsage.WriteOnly);
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly);
_effect = new BasicEffect(device)
{
@@ -58,6 +82,8 @@ public sealed class Renderer2D : IDisposable
_indexBuffer = CreateQuadIndexBuffer(device);
}
internal int ParallelThreshold => _options.ParallelThreshold;
/// <summary>Begins a frame with the given camera. Called by <see cref="CameraSystem"/>.</summary>
public void BeginFrame(in Camera camera)
{
@@ -70,6 +96,7 @@ public sealed class Renderer2D : IDisposable
SubmittedSprites = 0;
CulledSprites = 0;
_begun = true;
_submitStartTimestamp = Stopwatch.GetTimestamp();
}
/// <summary>
@@ -86,63 +113,86 @@ public sealed class Renderer2D : IDisposable
/// <summary>Submits one sprite. Invisible sprites (outside the camera) are culled here.</summary>
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?).");
}
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));
case SubmitResult.Visible:
_batcher.Submit(in instance, key);
SubmittedSprites++;
break;
case SubmitResult.Culled:
CulledSprites++;
break;
}
}
/// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary>
public void EndFrame()
{
if (!_begun)
{
throw new InvalidOperationException("EndFrame called without BeginFrame.");
}
EnsureBegun();
_begun = false;
DrawCalls = 0;
var order = _batcher.Sort();
if (order.Length == 0)
var submitEnd = Stopwatch.GetTimestamp();
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;
}
EnsureVertexCapacity(order.Length * 4);
BuildVertices(order);
_vertexBuffer.SetData(_vertices, 0, order.Length * 4, SetDataOptions.Discard);
var order = _batcher.SortedOrder;
EnsureVertexCapacity(count * 4);
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.SamplerStates[0] = _options.Sampler;
@@ -151,7 +201,8 @@ public sealed class Renderer2D : IDisposable
_device.SetVertexBuffer(_vertexBuffer);
_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>
@@ -168,6 +219,88 @@ public sealed class Renderer2D : IDisposable
_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()
{
var viewport = _device.Viewport;
@@ -180,18 +313,15 @@ public sealed class Renderer2D : IDisposable
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y));
}
private void BuildVertices(ReadOnlySpan<int> order)
{
for (var i = 0; i < order.Length; i++)
private void BuildVertex(int[] order, int i)
{
ref readonly var instance = ref _batcher[order[i]];
var bounds = instance.Region.Bounds;
var texture = instance.Region.Texture;
var region = instance.Region;
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;
var u0 = region.U0;
var v0 = region.V0;
var u1 = region.U1;
var v1 = region.V1;
if ((instance.Flip & SpriteFlip.X) != 0)
{
@@ -203,20 +333,28 @@ public sealed class Renderer2D : IDisposable
(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);
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;
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;
ref readonly var first = ref _batcher[order[0]];
@@ -224,11 +362,11 @@ public sealed class Renderer2D : IDisposable
var currentLayer = first.Layer;
ApplyLayerMatrices(currentLayer);
for (var i = 1; i <= order.Length; i++)
for (var i = 1; i <= count; i++)
{
Texture2D? texture = null;
byte layer = 0;
if (i < order.Length)
if (i < count)
{
ref readonly var instance = ref _batcher[order[i]];
texture = instance.Region.Texture;
@@ -242,7 +380,7 @@ public sealed class Renderer2D : IDisposable
DrawRange(currentTexture, batchStart, i - batchStart);
batchStart = i;
if (i < order.Length)
if (i < count)
{
currentTexture = texture!;
if (layer != currentLayer)
@@ -271,7 +409,8 @@ public sealed class Renderer2D : IDisposable
foreach (var pass in _effect.CurrentTechnique.Passes)
{
pass.Apply();
_device.DrawIndexedPrimitives(PrimitiveType.TriangleList, firstQuad * 4, 0, quads * 2);
_device.DrawIndexedPrimitives(
PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2);
DrawCalls++;
}
@@ -282,11 +421,8 @@ public sealed class Renderer2D : IDisposable
private void EnsureVertexCapacity(int vertexCount)
{
if (_vertices.Length >= vertexCount)
if (_vertices.Length < vertexCount)
{
return;
}
var capacity = _vertices.Length;
while (capacity < vertexCount)
{
@@ -294,10 +430,21 @@ public sealed class Renderer2D : IDisposable
}
_vertices = new VertexPositionColorTexture[capacity];
}
// GPU-буфер держим вдвое больше CPU-массива — кольцу нужен запас,
// чтобы NoOverwrite срабатывал чаще, чем Discard.
var wantedBuffer = _vertices.Length * 2;
if (_vertexBuffer.VertexCount < wantedBuffer)
{
_vertexBuffer.Dispose();
_vertexBuffer = new DynamicVertexBuffer(
_device, VertexPositionColorTexture.VertexDeclaration, capacity, BufferUsage.WriteOnly);
_device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly);
_ringCursor = 0;
}
}
private static float ToMs(long timestampDelta) => (float)timestampDelta * 1000f / Stopwatch.Frequency;
private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float 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>
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;
/// <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>
public struct SpriteInstance
{
@@ -29,14 +67,22 @@ public struct SpriteInstance
/// <summary>
/// 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
/// (arrays grow geometrically and are reused across frames).
/// and orders them layer → depth → texture using a stable LSD radix sort — sprites with
/// 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>
public sealed class SpriteBatcher
{
private const int RadixBits = 16;
private const int RadixSize = 1 << RadixBits;
private SpriteInstance[] _instances;
private ulong[] _keys;
private ulong[] _keysTemp;
private int[] _order;
private int[] _orderTemp;
private readonly int[] _histogram = new int[RadixSize];
private int _count;
/// <summary>Creates a batcher with the given initial capacity.</summary>
@@ -44,7 +90,9 @@ public sealed class SpriteBatcher
{
_instances = new SpriteInstance[initialCapacity];
_keys = new ulong[initialCapacity];
_keysTemp = new ulong[initialCapacity];
_order = new int[initialCapacity];
_orderTemp = new int[initialCapacity];
}
/// <summary>Number of sprites submitted this frame.</summary>
@@ -64,31 +112,182 @@ public sealed class SpriteBatcher
}
/// <summary>
/// Sorts all submitted sprites and returns their indices in draw order.
/// Valid until the next <see cref="Clear"/>.
/// Sorts all submitted sprites (stable: equal keys keep submission order) and returns
/// their indices in draw order. Valid until the next <see cref="Clear"/>.
/// </summary>
public ReadOnlySpan<int> Sort()
{
for (var i = 0; i < _count; i++)
var n = _count;
for (var i = 0; i < n; i++)
{
_order[i] = i;
}
Array.Sort(_keys, _order, 0, _count);
return _order.AsSpan(0, _count);
if (n < 2)
{
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>
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>
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()
{
var capacity = _instances.Length * 2;
Array.Resize(ref _instances, capacity);
Array.Resize(ref _keys, capacity);
Array.Resize(ref _keysTemp, 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;
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>
public Texture2DRegion(Texture2D texture, Rectangle bounds)
@@ -30,6 +35,17 @@ public sealed class Texture2DRegion
Texture = texture;
Bounds = bounds;
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>
+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);
}
[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]
[InlineData(50f, 50f, true)] // inside
[InlineData(-4f, 50f, true)] // touching from the left (radius 5)
@@ -39,6 +39,113 @@ public class SpriteBatcherTests
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]
public void Clear_ResetsCount_KeepsWorking()
{