From 8ea5c3882e2507c302b1e1b2e350db073d7c6487 Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Wed, 26 Aug 2026 17:04:37 +0300 Subject: [PATCH] fix: wire complete ImGui window input --- src/CortexEngine.App/Program.cs | 165 ++++++++++++++++-- src/Engine.Core/IInputState.cs | 6 + src/Engine.Core/IWindow.cs | 13 ++ src/Engine.Core/InputEvent.cs | 25 +++ src/Engine.Core/InputMapping.cs | 42 ++++- src/Engine.Core/Sdl3Window.cs | 35 ++++ src/Engine.Graphics.Vulkan/VulkanImGui.cs | 22 ++- .../VulkanRenderContext.cs | 2 +- src/Engine.Graphics.Vulkan/VulkanRenderer.cs | 12 +- src/Engine.Graphics/IRenderer.cs | 5 +- 10 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 src/Engine.Core/InputEvent.cs diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs index ce1fb7c..86f2c74 100644 --- a/src/CortexEngine.App/Program.cs +++ b/src/CortexEngine.App/Program.cs @@ -83,6 +83,8 @@ class Program var lastWidth = window.Width; var lastHeight = window.Height; + var lastDrawableWidth = window.DrawableWidth; + var lastDrawableHeight = window.DrawableHeight; var frames = 0; var lastFpsTime = 0.0; var timing = new Timing(); @@ -132,13 +134,17 @@ class Program } #endif - if (window.Width != lastWidth || window.Height != lastHeight) + if (window.Width != lastWidth || window.Height != lastHeight || + window.DrawableWidth != lastDrawableWidth || window.DrawableHeight != lastDrawableHeight) { lastWidth = window.Width; lastHeight = window.Height; - if (lastWidth > 0 && lastHeight > 0) + lastDrawableWidth = window.DrawableWidth; + lastDrawableHeight = window.DrawableHeight; + if (lastWidth > 0 && lastHeight > 0 && + lastDrawableWidth > 0 && lastDrawableHeight > 0) { - renderContext.Resize(lastWidth, lastHeight); + renderContext.Resize(lastDrawableWidth, lastDrawableHeight); ref var cam = ref cameraEntity.Ensure(); cam.AspectRatio = (float)lastWidth / lastHeight; cameraEntity.Set(cam); @@ -215,14 +221,21 @@ class Program if (hasImGui) { + var displayWidth = Math.Max(window.Width, 1); + var displayHeight = Math.Max(window.Height, 1); + var drawableWidth = Math.Max(window.DrawableWidth, 1); + var drawableHeight = Math.Max(window.DrawableHeight, 1); + var framebufferScaleX = (float)drawableWidth / displayWidth; + var framebufferScaleY = (float)drawableHeight / displayHeight; var io = ImGui.GetIO(); - io.DisplaySize = new System.Numerics.Vector2(window.Width, window.Height); - io.MousePos = new System.Numerics.Vector2(input.MouseX, input.MouseY); - io.MouseDown[0] = input.MouseLeft; - io.MouseDown[1] = input.MouseRight; - io.MouseDown[2] = input.MouseMiddle; + UpdateImGuiInput(io, input); - renderer.BeginImGuiFrame(); + renderer.BeginImGuiFrame( + displayWidth, + displayHeight, + framebufferScaleX, + framebufferScaleY, + (float)timing.DeltaTime); ImGui.Begin("Cortex Engine Debug"); ImGui.Text($"FPS: {frames}"); @@ -388,7 +401,7 @@ class Program } var paramsText = sb.ToString(); - ImGui.SetClipboardText(paramsText); + window.SetClipboardText(paramsText); Console.WriteLine("[App] Parameters copied to clipboard:"); Console.WriteLine(paramsText); } @@ -406,8 +419,8 @@ class Program recordingPath = $"Videos/cortex_{timestamp}.mp4"; Directory.CreateDirectory("Videos"); - var w = window.Width; - var h = window.Height; + var w = window.DrawableWidth; + var h = window.DrawableHeight; var outputPath = recordingPath!; // ArgumentList avoids shell-specific quoting rules and works @@ -540,6 +553,134 @@ class Program await Task.CompletedTask; } + private static void UpdateImGuiInput(ImGuiIOPtr io, IInputState input) + { + io.AddMousePosEvent(input.MouseX, input.MouseY); + + foreach (var inputEvent in input.Events) + { + switch (inputEvent.Type) + { + case InputEventType.KeyDown: + case InputEventType.KeyUp: + if (TryMapImGuiKey(inputEvent.Key, out var imguiKey)) + io.AddKeyEvent(imguiKey, inputEvent.Type == InputEventType.KeyDown); + break; + + case InputEventType.TextInput: + if (!string.IsNullOrEmpty(inputEvent.Text)) + io.AddInputCharactersUTF8(inputEvent.Text); + break; + + case InputEventType.MouseButtonDown: + case InputEventType.MouseButtonUp: + io.AddMouseButtonEvent( + inputEvent.MouseButton, + inputEvent.Type == InputEventType.MouseButtonDown); + break; + + case InputEventType.MouseWheel: + io.AddMouseWheelEvent(inputEvent.WheelX, inputEvent.WheelY); + break; + + case InputEventType.FocusGained: + io.AddFocusEvent(true); + break; + + case InputEventType.FocusLost: + io.AddFocusEvent(false); + break; + } + } + + // ImGui uses separate virtual modifier keys for shortcuts and navigation. + io.AddKeyEvent(ImGuiKey.ModCtrl, + input.IsKeyDown(Key.LeftControl) || input.IsKeyDown(Key.RightControl)); + io.AddKeyEvent(ImGuiKey.ModShift, + input.IsKeyDown(Key.LeftShift) || input.IsKeyDown(Key.RightShift)); + io.AddKeyEvent(ImGuiKey.ModAlt, + input.IsKeyDown(Key.LeftAlt) || input.IsKeyDown(Key.RightAlt)); + } + + private static bool TryMapImGuiKey(Key key, out ImGuiKey imguiKey) + { + imguiKey = key switch + { + Key.Space => ImGuiKey.Space, + Key.Escape => ImGuiKey.Escape, + Key.Enter => ImGuiKey.Enter, + Key.Tab => ImGuiKey.Tab, + Key.Backspace => ImGuiKey.Backspace, + Key.Insert => ImGuiKey.Insert, + Key.Delete => ImGuiKey.Delete, + Key.Home => ImGuiKey.Home, + Key.End => ImGuiKey.End, + Key.PageUp => ImGuiKey.PageUp, + Key.PageDown => ImGuiKey.PageDown, + Key.Left => ImGuiKey.LeftArrow, + Key.Right => ImGuiKey.RightArrow, + Key.Up => ImGuiKey.UpArrow, + Key.Down => ImGuiKey.DownArrow, + Key.A => ImGuiKey.A, + Key.B => ImGuiKey.B, + Key.C => ImGuiKey.C, + Key.D => ImGuiKey.D, + Key.E => ImGuiKey.E, + Key.F => ImGuiKey.F, + Key.G => ImGuiKey.G, + Key.H => ImGuiKey.H, + Key.I => ImGuiKey.I, + Key.J => ImGuiKey.J, + Key.K => ImGuiKey.K, + Key.L => ImGuiKey.L, + Key.M => ImGuiKey.M, + Key.N => ImGuiKey.N, + Key.O => ImGuiKey.O, + Key.P => ImGuiKey.P, + Key.Q => ImGuiKey.Q, + Key.R => ImGuiKey.R, + Key.S => ImGuiKey.S, + Key.T => ImGuiKey.T, + Key.U => ImGuiKey.U, + Key.V => ImGuiKey.V, + Key.W => ImGuiKey.W, + Key.X => ImGuiKey.X, + Key.Y => ImGuiKey.Y, + Key.Z => ImGuiKey.Z, + Key.Zero => ImGuiKey._0, + Key.One => ImGuiKey._1, + Key.Two => ImGuiKey._2, + Key.Three => ImGuiKey._3, + Key.Four => ImGuiKey._4, + Key.Five => ImGuiKey._5, + Key.Six => ImGuiKey._6, + Key.Seven => ImGuiKey._7, + Key.Eight => ImGuiKey._8, + Key.Nine => ImGuiKey._9, + Key.F1 => ImGuiKey.F1, + Key.F2 => ImGuiKey.F2, + Key.F3 => ImGuiKey.F3, + Key.F4 => ImGuiKey.F4, + Key.F5 => ImGuiKey.F5, + Key.F6 => ImGuiKey.F6, + Key.F7 => ImGuiKey.F7, + Key.F8 => ImGuiKey.F8, + Key.F9 => ImGuiKey.F9, + Key.F10 => ImGuiKey.F10, + Key.F11 => ImGuiKey.F11, + Key.F12 => ImGuiKey.F12, + Key.LeftShift => ImGuiKey.LeftShift, + Key.RightShift => ImGuiKey.RightShift, + Key.LeftControl => ImGuiKey.LeftCtrl, + Key.RightControl => ImGuiKey.RightCtrl, + Key.LeftAlt => ImGuiKey.LeftAlt, + Key.RightAlt => ImGuiKey.RightAlt, + _ => ImGuiKey.None, + }; + + return imguiKey != ImGuiKey.None; + } + static int ParseMcpPort(string[] args) { for (var i = 0; i < args.Length; i++) diff --git a/src/Engine.Core/IInputState.cs b/src/Engine.Core/IInputState.cs index 382a581..51e25db 100644 --- a/src/Engine.Core/IInputState.cs +++ b/src/Engine.Core/IInputState.cs @@ -13,6 +13,12 @@ public interface IInputState bool MouseMiddle { get; } float MouseWheelDelta { get; } + /// + /// Events received since the last call. + /// Backends that do not expose an event queue may return an empty list. + /// + IReadOnlyList Events => Array.Empty(); + bool IsKeyDown(Key key); bool IsKeyPressed(Key key); bool IsKeyReleased(Key key); diff --git a/src/Engine.Core/IWindow.cs b/src/Engine.Core/IWindow.cs index 9f1b7b4..58d5338 100644 --- a/src/Engine.Core/IWindow.cs +++ b/src/Engine.Core/IWindow.cs @@ -9,6 +9,14 @@ public interface IWindow : IDisposable { int Width { get; } int Height { get; } + + /// + /// Size of the drawable Vulkan framebuffer in physical pixels. This can + /// differ from / on high-DPI displays. + /// + int DrawableWidth => Width; + int DrawableHeight => Height; + bool ShouldClose { get; } /// @@ -27,6 +35,11 @@ public interface IWindow : IDisposable /// void Close(); + /// + /// Places UTF-8 text on the platform clipboard when supported. + /// + void SetClipboardText(string text) { } + /// /// Native window handle (e.g. SDL_Window*). Used by backends that need /// the raw OS handle for surface creation. Returns 0 if not applicable. diff --git a/src/Engine.Core/InputEvent.cs b/src/Engine.Core/InputEvent.cs new file mode 100644 index 0000000..4d11422 --- /dev/null +++ b/src/Engine.Core/InputEvent.cs @@ -0,0 +1,25 @@ +namespace Engine.Core; + +/// +/// Backend-agnostic input event captured during the current frame. +/// +public readonly record struct InputEvent( + InputEventType Type, + Key Key = Key.Unknown, + bool IsRepeat = false, + int MouseButton = -1, + float WheelX = 0, + float WheelY = 0, + string? Text = null); + +public enum InputEventType +{ + KeyDown, + KeyUp, + TextInput, + MouseButtonDown, + MouseButtonUp, + MouseWheel, + FocusGained, + FocusLost, +} diff --git a/src/Engine.Core/InputMapping.cs b/src/Engine.Core/InputMapping.cs index 45c2328..f8c48c2 100644 --- a/src/Engine.Core/InputMapping.cs +++ b/src/Engine.Core/InputMapping.cs @@ -7,11 +7,12 @@ namespace Engine.Core; /// SDL3-backed implementation of . /// Populated by polling SDL events via once per frame. /// -public sealed class InputMapping : IInputState +public sealed unsafe class InputMapping : IInputState { private readonly HashSet _keysPressed = new(); private readonly HashSet _keysDown = new(); private readonly HashSet _keysReleased = new(); + private readonly List _events = new(); public int MouseX { get; private set; } public int MouseY { get; private set; } @@ -19,11 +20,13 @@ public sealed class InputMapping : IInputState public bool MouseRight { get; private set; } public bool MouseMiddle { get; private set; } public float MouseWheelDelta { get; private set; } + public IReadOnlyList Events => _events; public void BeginFrame() { _keysPressed.Clear(); _keysReleased.Clear(); + _events.Clear(); MouseWheelDelta = 0; } @@ -38,6 +41,10 @@ public sealed class InputMapping : IInputState if (!_keysDown.Contains(key)) _keysPressed.Add(key); _keysDown.Add(key); + _events.Add(new InputEvent( + InputEventType.KeyDown, + Key: key, + IsRepeat: evt.key.repeat)); break; } @@ -47,6 +54,7 @@ public sealed class InputMapping : IInputState if (key == Key.Unknown) break; _keysDown.Remove(key); _keysReleased.Add(key); + _events.Add(new InputEvent(InputEventType.KeyUp, Key: key)); break; } @@ -57,14 +65,46 @@ public sealed class InputMapping : IInputState case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_DOWN: SetMouseButton(evt.button.button, true); + if (evt.button.button is >= 1 and <= 3) + _events.Add(new InputEvent( + InputEventType.MouseButtonDown, + MouseButton: evt.button.button - 1)); break; case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_UP: SetMouseButton(evt.button.button, false); + if (evt.button.button is >= 1 and <= 3) + _events.Add(new InputEvent( + InputEventType.MouseButtonUp, + MouseButton: evt.button.button - 1)); break; case SDL_EventType.SDL_EVENT_MOUSE_WHEEL: MouseWheelDelta += evt.wheel.y; + _events.Add(new InputEvent( + InputEventType.MouseWheel, + WheelX: evt.wheel.x, + WheelY: evt.wheel.y)); + break; + + case SDL_EventType.SDL_EVENT_TEXT_INPUT: + { + var text = SDL3.PtrToStringUTF8(evt.text.text); + if (!string.IsNullOrEmpty(text)) + _events.Add(new InputEvent(InputEventType.TextInput, Text: text)); + break; + } + + case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_GAINED: + _events.Add(new InputEvent(InputEventType.FocusGained)); + break; + + case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_LOST: + _keysDown.Clear(); + MouseLeft = false; + MouseRight = false; + MouseMiddle = false; + _events.Add(new InputEvent(InputEventType.FocusLost)); break; } } diff --git a/src/Engine.Core/Sdl3Window.cs b/src/Engine.Core/Sdl3Window.cs index 27f6f5b..7af003a 100644 --- a/src/Engine.Core/Sdl3Window.cs +++ b/src/Engine.Core/Sdl3Window.cs @@ -18,16 +18,25 @@ public sealed unsafe class Sdl3Window : IWindow public int Width { get; private set; } public int Height { get; private set; } + public int DrawableWidth { get; private set; } + public int DrawableHeight { get; private set; } public bool ShouldClose { get; private set; } public IInputState Input => _input; public nint Handle => (nint)_window; public void Close() => ShouldClose = true; + public void SetClipboardText(string text) + { + SDL3.SDL_SetClipboardText(text); + } + public Sdl3Window(string title, int width, int height, bool vulkanSurface = true) { Width = width; Height = height; + DrawableWidth = width; + DrawableHeight = height; if (!SDL3.SDL_Init(SDL_InitFlags.SDL_INIT_VIDEO)) throw new InvalidOperationException($"SDL_Init failed: {SDL3.SDL_GetError()}"); @@ -49,6 +58,8 @@ public sealed unsafe class Sdl3Window : IWindow SDL3.SDL_ShowWindow(_window); SDL3.SDL_RaiseWindow(_window); + UpdateDrawableSize(); + SDL3.SDL_StartTextInput(_window); // On Wayland, the compositor needs an event round-trip before mapping // the window. Pump events to flush the show request without blocking. @@ -80,6 +91,16 @@ public sealed unsafe class Sdl3Window : IWindow case SDL_EventType.SDL_EVENT_WINDOW_RESIZED: Width = evt.window.data1; Height = evt.window.data2; + UpdateDrawableSize(); + break; + + case SDL_EventType.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + DrawableWidth = evt.window.data1; + DrawableHeight = evt.window.data2; + break; + + case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + UpdateDrawableSize(); break; case SDL_EventType.SDL_EVENT_KEY_DOWN: @@ -104,11 +125,25 @@ public sealed unsafe class Sdl3Window : IWindow return result; } + private void UpdateDrawableSize() + { + if (_window == null) return; + + var pixelWidth = Width; + var pixelHeight = Height; + if (SDL3.SDL_GetWindowSizeInPixels(_window, &pixelWidth, &pixelHeight)) + { + DrawableWidth = pixelWidth; + DrawableHeight = pixelHeight; + } + } + public void Dispose() { if (_disposed) return; _disposed = true; + SDL3.SDL_StopTextInput(_window); SDL3.SDL_DestroyWindow(_window); SDL3.SDL_Quit(); } diff --git a/src/Engine.Graphics.Vulkan/VulkanImGui.cs b/src/Engine.Graphics.Vulkan/VulkanImGui.cs index fe8f6cf..8b8bc59 100644 --- a/src/Engine.Graphics.Vulkan/VulkanImGui.cs +++ b/src/Engine.Graphics.Vulkan/VulkanImGui.cs @@ -447,11 +447,13 @@ internal sealed unsafe class VulkanImGui : IDisposable _indexMemory = im; } - public void NewFrame() + public void NewFrame(float displayWidth, float displayHeight, + float framebufferScaleX, float framebufferScaleY, float deltaTime) { var io = ImGui.GetIO(); - io.DisplaySize = new System.Numerics.Vector2(1280, 720); - io.DeltaTime = 0.016f; + io.DisplaySize = new Vector2(displayWidth, displayHeight); + io.DisplayFramebufferScale = new Vector2(framebufferScaleX, framebufferScaleY); + io.DeltaTime = MathF.Max(deltaTime, 1f / 1000f); ImGui.NewFrame(); } @@ -520,13 +522,21 @@ internal sealed unsafe class VulkanImGui : IDisposable { var pcmd = list.CmdBuffer[i]; var clip = pcmd.ClipRect; + var framebufferScale = io.DisplayFramebufferScale; + var clipMinX = MathF.Max(clip.X * framebufferScale.X, 0f); + var clipMinY = MathF.Max(clip.Y * framebufferScale.Y, 0f); + var clipMaxX = MathF.Min(clip.Z * framebufferScale.X, width); + var clipMaxY = MathF.Min(clip.W * framebufferScale.Y, height); + if (clipMaxX <= clipMinX || clipMaxY <= clipMinY) + continue; + var scissor = new VkRect2D { - Offset = new VkOffset2D { X = (int)clip.X, Y = (int)clip.Y }, + Offset = new VkOffset2D { X = (int)clipMinX, Y = (int)clipMinY }, Extent = new VkExtent2D { - Width = (uint)(clip.Z - clip.X), - Height = (uint)(clip.W - clip.Y), + Width = (uint)(clipMaxX - clipMinX), + Height = (uint)(clipMaxY - clipMinY), }, }; Vk.vkCmdSetScissor(cmd, 0, 1, &scissor); diff --git a/src/Engine.Graphics.Vulkan/VulkanRenderContext.cs b/src/Engine.Graphics.Vulkan/VulkanRenderContext.cs index 0b745e5..993152c 100644 --- a/src/Engine.Graphics.Vulkan/VulkanRenderContext.cs +++ b/src/Engine.Graphics.Vulkan/VulkanRenderContext.cs @@ -23,7 +23,7 @@ internal sealed class VulkanRenderContext : IRenderContext{ }; _swapchain = new VulkanSwapchain(_ctx.Device, _ctx.PhysicalDevice, _ctx.Surface, - surfaceFormat, window.Width, window.Height, _ctx); + surfaceFormat, window.DrawableWidth, window.DrawableHeight, _ctx); } public IRenderer CreateRenderer() diff --git a/src/Engine.Graphics.Vulkan/VulkanRenderer.cs b/src/Engine.Graphics.Vulkan/VulkanRenderer.cs index c842bc1..35edc5e 100644 --- a/src/Engine.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Engine.Graphics.Vulkan/VulkanRenderer.cs @@ -179,9 +179,11 @@ public sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreensh return result; } - public void BeginImGuiFrame() + public void BeginImGuiFrame(float displayWidth, float displayHeight, + float framebufferScaleX, float framebufferScaleY, float deltaTime) { - _imGui?.NewFrame(); + _imGui?.NewFrame(displayWidth, displayHeight, + framebufferScaleX, framebufferScaleY, deltaTime); } public void EndImGuiFrame() @@ -296,7 +298,7 @@ public sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreensh // A minimized window has no drawable surface. Keep the frame fence signaled and wait // for the window event loop to provide a non-zero extent before trying to acquire. - if (_window.Width <= 0 || _window.Height <= 0) + if (_window.DrawableWidth <= 0 || _window.DrawableHeight <= 0) return; // Read captured frame from previous render (GPU has finished by now) @@ -324,7 +326,7 @@ public sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreensh // from this frame would leave the next WaitForFrame blocked forever. if (acquireResult == VkResult.ErrorOutOfDateKHR) { - RecreateSwapchain(_window.Width, _window.Height); + RecreateSwapchain(_window.DrawableWidth, _window.DrawableHeight); return; } @@ -657,7 +659,7 @@ public sealed unsafe class VulkanRenderer : IRenderer, Engine.Graphics.IScreensh if (presentResult == VkResult.ErrorOutOfDateKHR || presentResult == VkResult.SuboptimalKHR) { - RecreateSwapchain(_window.Width, _window.Height); + RecreateSwapchain(_window.DrawableWidth, _window.DrawableHeight); } _frameIndex = (_frameIndex + 1) % VulkanFrameResources.MaxFramesInFlight; diff --git a/src/Engine.Graphics/IRenderer.cs b/src/Engine.Graphics/IRenderer.cs index 04d59e8..db5f6d5 100644 --- a/src/Engine.Graphics/IRenderer.cs +++ b/src/Engine.Graphics/IRenderer.cs @@ -17,8 +17,11 @@ public interface IRenderer : IDisposable /// /// Called before RenderWorld to begin a new ImGui frame. /// Null if the backend doesn't support ImGui. + /// and are + /// logical window coordinates; framebuffer scale maps them to pixels. /// - void BeginImGuiFrame() { } + void BeginImGuiFrame(float displayWidth, float displayHeight, + float framebufferScaleX, float framebufferScaleY, float deltaTime) { } /// /// Called after RenderWorld to render ImGui draw data.