Compare commits
6
Commits
ff2231a8ab
...
8d3f478acd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d3f478acd | ||
|
|
d498c70660 | ||
|
|
e06f24a319 | ||
|
|
af319d1276 | ||
|
|
a3e6d3bb0a | ||
|
|
2b7d4c4fef |
@@ -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.
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -9,6 +9,7 @@ using var host = new GameHost(
|
||||
Width = 1280,
|
||||
Height = 720,
|
||||
ClearColor = new Color(24, 26, 32),
|
||||
VSync = false, // техдемо: показываем реальный FPS, не ограниченный частотой монитора
|
||||
},
|
||||
new MainScene());
|
||||
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user