From 4c67c2c90549c950731edb40b035f4e1a6ec1095 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 16:17:51 +0300 Subject: [PATCH] =?UTF-8?q?M3:=20editor=20shell=20boots=20=E2=80=94=20ImGu?= =?UTF-8?q?i=20overlay=20drawn=20over=20the=20live=20scene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces engine.editor, a new plugin that renders an ImGui panel on top of whatever the scene is drawing, using Silk.NET.OpenGL.Extensions.ImGui's ImGuiController against the same GL context and window engine.render already owns. Getting the overlay to actually show up in the same frame (not delayed by one, which is what happens if UI draws after SwapBuffers) required splitting engine.render's Draw system: SwapBuffers moves out into its own Present system on a new Stage.Present, run by Engine.Host after every Stage.Render system — this plugin's Draw and, now, engine.editor's ImGui pass — has drawn into the same back buffer. Stage.Present is the second addition to the frame stage set (after FixedUpdate was scoped out for M4); docs/kernel-contract.md already committed to the set being fixed and kernel-defined, not plugin-extensible, and to only adding a stage when there's something real to run in it — SwapBuffers already needed somewhere to live once a second Render-stage system existed to race it. Also extends IEngineInput with a Native IInputContext property, the same escape hatch IEngineWindow.Native already is — ImGuiController's constructor needs the raw Silk.NET input context directly. Verified with a real windowed run: --screenshot after 5 frames shows the "Lingua" ImGui panel (FPS/frame counters) correctly composited over the still-correct 3D checkerboard quad from the M3 camera pipeline, in the same frame. Full suite still green: 65 tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- LinguaEngine.sln | 33 +++++++++ .../Engine.Editor.Contracts.csproj | 5 ++ .../Engine.Editor/EditorPlugin.cs | 69 +++++++++++++++++++ .../Engine.Editor/Engine.Editor.csproj | 23 +++++++ plugins/engine.editor/plugin.json | 12 ++++ .../Engine.Input.Contracts/IEngineInput.cs | 8 +++ .../Engine.Input/SilkEngineInput.cs | 2 + .../Engine.Render/RenderPlugin.cs | 9 ++- samples/WindowDemo/project.json | 3 +- src/Engine.Host/Program.cs | 1 + src/Engine.Kernel/Scheduling/Stage.cs | 13 +++- 11 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 plugins/engine.editor/Engine.Editor.Contracts/Engine.Editor.Contracts.csproj create mode 100644 plugins/engine.editor/Engine.Editor/EditorPlugin.cs create mode 100644 plugins/engine.editor/Engine.Editor/Engine.Editor.csproj create mode 100644 plugins/engine.editor/plugin.json diff --git a/LinguaEngine.sln b/LinguaEngine.sln index 83d01ab..10f9391 100644 --- a/LinguaEngine.sln +++ b/LinguaEngine.sln @@ -49,6 +49,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Assets", "plugins\en EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Assets.Tests", "tests\Engine.Assets.Tests\Engine.Assets.Tests.csproj", "{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.editor", "engine.editor", "{8067C66D-9D49-192B-E4C1-1C024401ACE3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Editor.Contracts", "plugins\engine.editor\Engine.Editor.Contracts\Engine.Editor.Contracts.csproj", "{6A2943F1-1984-470D-BD74-C6E16BB77241}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Editor", "plugins\engine.editor\Engine.Editor\Engine.Editor.csproj", "{B60A3933-923C-4339-98D1-E4C87A76E80B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -239,6 +245,30 @@ Global {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x64.Build.0 = Release|Any CPU {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x86.ActiveCfg = Release|Any CPU {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x86.Build.0 = Release|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Debug|x64.ActiveCfg = Debug|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Debug|x64.Build.0 = Debug|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Debug|x86.ActiveCfg = Debug|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Debug|x86.Build.0 = Debug|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Release|Any CPU.Build.0 = Release|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Release|x64.ActiveCfg = Release|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Release|x64.Build.0 = Release|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Release|x86.ActiveCfg = Release|Any CPU + {6A2943F1-1984-470D-BD74-C6E16BB77241}.Release|x86.Build.0 = Release|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Debug|x64.ActiveCfg = Debug|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Debug|x64.Build.0 = Debug|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Debug|x86.ActiveCfg = Debug|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Debug|x86.Build.0 = Debug|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Release|Any CPU.Build.0 = Release|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Release|x64.ActiveCfg = Release|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Release|x64.Build.0 = Release|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Release|x86.ActiveCfg = Release|Any CPU + {B60A3933-923C-4339-98D1-E4C87A76E80B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -264,5 +294,8 @@ Global {1F6F4B27-5624-42F8-87A7-A4729FB198E3} = {BBD4296A-8D59-75AB-1261-7E1302A19746} {8C2DE8CB-3207-4990-8F6D-87CF31D6CF15} = {BBD4296A-8D59-75AB-1261-7E1302A19746} {AFAB0A32-2A70-4BCA-8561-5F94B195DFAB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {8067C66D-9D49-192B-E4C1-1C024401ACE3} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} + {6A2943F1-1984-470D-BD74-C6E16BB77241} = {8067C66D-9D49-192B-E4C1-1C024401ACE3} + {B60A3933-923C-4339-98D1-E4C87A76E80B} = {8067C66D-9D49-192B-E4C1-1C024401ACE3} EndGlobalSection EndGlobal diff --git a/plugins/engine.editor/Engine.Editor.Contracts/Engine.Editor.Contracts.csproj b/plugins/engine.editor/Engine.Editor.Contracts/Engine.Editor.Contracts.csproj new file mode 100644 index 0000000..bb93c00 --- /dev/null +++ b/plugins/engine.editor/Engine.Editor.Contracts/Engine.Editor.Contracts.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/engine.editor/Engine.Editor/EditorPlugin.cs b/plugins/engine.editor/Engine.Editor/EditorPlugin.cs new file mode 100644 index 0000000..5312b5d --- /dev/null +++ b/plugins/engine.editor/Engine.Editor/EditorPlugin.cs @@ -0,0 +1,69 @@ +using Engine.Input.Contracts; +using Engine.Kernel.Diagnostics; +using Engine.Kernel.Plugins; +using Engine.Kernel.Scheduling; +using Engine.Kernel.World; +using Engine.Windowing.Contracts; +using ImGuiNET; +using Silk.NET.OpenGL; +using Silk.NET.OpenGL.Extensions.ImGui; + +namespace Engine.Editor; + +/// +/// M3's editor shell: an ImGui overlay drawn on top of the running scene. +/// Registers on Stage.Render, after engine.render's own Draw (per +/// project.json's plugin order) so it paints into the same back buffer the +/// scene just drew into — and before Stage.Present's SwapBuffers, so the +/// UI isn't delayed a frame. See Stage.Present's doc comment for why that +/// split exists at all. +/// +/// Nothing here reads or writes a Component through GetComponent/Query — +/// the Hierarchy and Inspector panels (not built yet) will walk +/// IWorld.Roots and GameObject.Components directly instead, which is why +/// this plugin never needs to declare Reads/Writes on its system: those +/// checks only guard the typed accessors, not plain property reads. See +/// GameObject.AddComponent's own doc comment on the same gap. +/// +public sealed class EditorPlugin : IPlugin +{ + private GL? _gl; + private ImGuiController? _controller; + private ITime? _time; + + public void Configure(IPluginContext ctx) + { + var window = ctx.Services.Require(); + var input = ctx.Services.Require(); + _time = ctx.Time; + + window.Native.GLContext!.MakeCurrent(); + _gl = window.Native.CreateOpenGL(); + _controller = new ImGuiController(_gl, window.Native, input.Native); + + ctx.Schedule.Add(Stage.Render, DrawUi); + ctx.Log.Info("editor UI ready (ImGui)"); + } + + public void Shutdown(IPluginContext ctx) + { + ctx.Schedule.RemoveAllFrom("engine.editor"); + _controller?.Dispose(); + _gl?.Dispose(); + _controller = null; + _gl = null; + _time = null; + } + + private void DrawUi(IWorld world) + { + _controller!.Update(_time!.DeltaTime); + + ImGui.Begin("Lingua Editor"); + ImGui.Text($"FPS: {1f / MathF.Max(_time.DeltaTime, 0.0001f):F0}"); + ImGui.Text($"Frame: {_time.FrameCount}"); + ImGui.End(); + + _controller.Render(); + } +} diff --git a/plugins/engine.editor/Engine.Editor/Engine.Editor.csproj b/plugins/engine.editor/Engine.Editor/Engine.Editor.csproj new file mode 100644 index 0000000..749efee --- /dev/null +++ b/plugins/engine.editor/Engine.Editor/Engine.Editor.csproj @@ -0,0 +1,23 @@ + + + + + true + + + + + + + + + + + + + + + diff --git a/plugins/engine.editor/plugin.json b/plugins/engine.editor/plugin.json new file mode 100644 index 0000000..c964ec1 --- /dev/null +++ b/plugins/engine.editor/plugin.json @@ -0,0 +1,12 @@ +{ + "id": "engine.editor", + "version": "0.1.0", + "contracts": "Engine.Editor.Contracts.dll", + "assembly": "Engine.Editor.dll", + "dependsOn": { + "engine.windowing": "^0.1", + "engine.input": "^0.1", + "engine.render": "^0.1" + }, + "reloadable": true +} diff --git a/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs b/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs index 711471b..847d405 100644 --- a/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs +++ b/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs @@ -12,4 +12,12 @@ namespace Engine.Input.Contracts; public interface IEngineInput { bool IsKeyDown(Key key); + + /// + /// The raw Silk.NET input context, same escape hatch as + /// IEngineWindow.Native — needed here specifically so engine.editor can + /// construct Silk.NET.OpenGL.Extensions.ImGui's ImGuiController, which + /// takes an IInputContext directly rather than anything of ours. + /// + IInputContext Native { get; } } diff --git a/plugins/engine.input/Engine.Input/SilkEngineInput.cs b/plugins/engine.input/Engine.Input/SilkEngineInput.cs index f57b160..eab9d09 100644 --- a/plugins/engine.input/Engine.Input/SilkEngineInput.cs +++ b/plugins/engine.input/Engine.Input/SilkEngineInput.cs @@ -6,4 +6,6 @@ namespace Engine.Input; internal sealed class SilkEngineInput(IInputContext context) : IEngineInput { public bool IsKeyDown(Key key) => context.Keyboards.Any(k => k.IsKeyPressed(key)); + + public IInputContext Native => context; } diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs index 2a92888..4ae757f 100644 --- a/plugins/engine.render/Engine.Render/RenderPlugin.cs +++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs @@ -160,6 +160,7 @@ public sealed class RenderPlugin : IPlugin ctx.Services.Provide(new GlScreenCapture(_gl, _window)); ctx.Schedule.Add(Stage.Render, Draw).Reads(); + ctx.Schedule.Add(Stage.Present, Present); ctx.Log.Info("GL context created, 3D quad pipeline ready"); } @@ -236,10 +237,14 @@ public sealed class RenderPlugin : IPlugin SetMatrix(_modelLocation, go.WorldMatrix); _gl.DrawArrays(PrimitiveType.Triangles, 0, 6); } - - _window!.Native.GLContext!.SwapBuffers(); } + // Split from Draw() into its own Stage.Present system so engine.editor + // can draw ImGui on top of the scene from a Stage.Render system of its + // own (registered after this plugin's, per project.json order) without + // racing the swap — see Stage.Present's doc comment. + private void Present(IWorld world) => _window!.Native.GLContext!.SwapBuffers(); + private unsafe void SetMatrix(int location, Matrix4x4 matrix) => _gl!.UniformMatrix4(location, 1, true, (float*)&matrix); diff --git a/samples/WindowDemo/project.json b/samples/WindowDemo/project.json index 2e298c2..cd4cb16 100644 --- a/samples/WindowDemo/project.json +++ b/samples/WindowDemo/project.json @@ -4,7 +4,8 @@ { "id": "engine.windowing" }, { "id": "engine.assets" }, { "id": "engine.render" }, - { "id": "engine.input" } + { "id": "engine.input" }, + { "id": "engine.editor" } ], "pluginPaths": [] } diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs index c69b529..edbe551 100644 --- a/src/Engine.Host/Program.cs +++ b/src/Engine.Host/Program.cs @@ -244,6 +244,7 @@ if (windowed) schedule.RunStage(Stage.Update, world); schedule.RunStage(Stage.Render, world); + schedule.RunStage(Stage.Present, world); foreach (var path in pendingScreenshots) { diff --git a/src/Engine.Kernel/Scheduling/Stage.cs b/src/Engine.Kernel/Scheduling/Stage.cs index 0f54625..bbe54b2 100644 --- a/src/Engine.Kernel/Scheduling/Stage.cs +++ b/src/Engine.Kernel/Scheduling/Stage.cs @@ -1,12 +1,19 @@ namespace Engine.Kernel.Scheduling; /// -/// Open question (see the footer of docs/kernel-contract.md): whether the -/// set of stages is fixed or plugin-extensible. This is only enough to -/// make the §3 example compile — not a decision. +/// Fixed, kernel-defined, not plugin-extensible — see "Frame stages" in +/// docs/kernel-contract.md §8. is the second addition +/// to the original {Update, Render} set (after FixedUpdate was scoped for +/// M4), added for M3's editor: engine.render splits its old single +/// Clear+Draw+SwapBuffers system into a Render-stage draw and a +/// Present-stage swap specifically so engine.editor's ImGui overlay — which +/// has to run after the scene is drawn but before the buffers swap — has +/// somewhere to register that isn't a race against load order. Not a stage +/// added speculatively: SwapBuffers already needed to move somewhere real. /// public enum Stage { Update, Render, + Present, }