From 6397488c61783fc239f36407b04f40d55a73ec3a Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Tue, 16 Jun 2026 17:21:43 +0300 Subject: [PATCH] feat: Step 1 SDL3 window + Vulkan context + clear screen --- .gitignore | 26 ++ CORTEX_ENGINE.sln | 37 +++ src/CortexEngine.App/CortexEngine.App.csproj | 26 ++ src/CortexEngine.App/Program.cs | 58 ++++ src/Engine.Core/Engine.Core.csproj | 24 ++ src/Engine.Core/InputMapping.cs | 71 +++++ src/Engine.Core/Sdl3Window.cs | 98 ++++++ src/Engine.Core/Timing.cs | 49 +++ src/Engine.Graphics/ClearRenderer.cs | 183 +++++++++++ src/Engine.Graphics/Engine.Graphics.csproj | 28 ++ src/Engine.Graphics/Swapchain.cs | 292 ++++++++++++++++++ src/Engine.Graphics/VulkanContext.cs | 308 +++++++++++++++++++ 12 files changed, 1200 insertions(+) create mode 100644 .gitignore create mode 100644 CORTEX_ENGINE.sln create mode 100644 src/CortexEngine.App/CortexEngine.App.csproj create mode 100644 src/CortexEngine.App/Program.cs create mode 100644 src/Engine.Core/Engine.Core.csproj create mode 100644 src/Engine.Core/InputMapping.cs create mode 100644 src/Engine.Core/Sdl3Window.cs create mode 100644 src/Engine.Core/Timing.cs create mode 100644 src/Engine.Graphics/ClearRenderer.cs create mode 100644 src/Engine.Graphics/Engine.Graphics.csproj create mode 100644 src/Engine.Graphics/Swapchain.cs create mode 100644 src/Engine.Graphics/VulkanContext.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c788f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Build outputs +bin/ +obj/ + +# IDE +.vs/ +.vscode/ +*.user +*.suo + +# NuGet +*.nupkg + +# OS +.DS_Store +Thumbs.db + +# dotnet +*.dll +*.exe +*.pdb +*.cache +*.json +!*.csproj +!*.sln +!launchSettings.json diff --git a/CORTEX_ENGINE.sln b/CORTEX_ENGINE.sln new file mode 100644 index 0000000..aa9e55e --- /dev/null +++ b/CORTEX_ENGINE.sln @@ -0,0 +1,37 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Core", "src\Engine.Core\Engine.Core.csproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Graphics", "src\Engine.Graphics\Engine.Graphics.csproj", "{22222222-2222-2222-2222-222222222222}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CortexEngine.App", "src\CortexEngine.App\CortexEngine.App.csproj", "{33333333-3333-3333-3333-333333333333}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + ReleaseAOT|Any CPU = ReleaseAOT|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU + {11111111-1111-1111-1111-111111111111}.ReleaseAOT|Any CPU.ActiveCfg = ReleaseAOT|Any CPU + {11111111-1111-1111-1111-111111111111}.ReleaseAOT|Any CPU.Build.0 = ReleaseAOT|Any CPU + {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU + {22222222-2222-2222-2222-222222222222}.ReleaseAOT|Any CPU.ActiveCfg = ReleaseAOT|Any CPU + {22222222-2222-2222-2222-222222222222}.ReleaseAOT|Any CPU.Build.0 = ReleaseAOT|Any CPU + {33333333-3333-3333-3333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU + {33333333-3333-3333-3333-333333333333}.ReleaseAOT|Any CPU.ActiveCfg = ReleaseAOT|Any CPU + {33333333-3333-3333-3333-333333333333}.ReleaseAOT|Any CPU.Build.0 = ReleaseAOT|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/CortexEngine.App/CortexEngine.App.csproj b/src/CortexEngine.App/CortexEngine.App.csproj new file mode 100644 index 0000000..65c8399 --- /dev/null +++ b/src/CortexEngine.App/CortexEngine.App.csproj @@ -0,0 +1,26 @@ + + + + Exe + net9.0 + enable + enable + true + false + + + + DEV_MODE + + + + RELEASE_AOT + true + + + + + + + + diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs new file mode 100644 index 0000000..49afec4 --- /dev/null +++ b/src/CortexEngine.App/Program.cs @@ -0,0 +1,58 @@ +using System; +using Engine.Core; +using Engine.Graphics; + +namespace CortexEngine.App; + +class Program +{ + static void Main(string[] args) + { + Console.WriteLine("Cortex Engine Step 1 — Starting up..."); + + try + { + using var window = new Sdl3Window("Cortex Engine — Step 1", 1280, 720); + var timing = new Timing(); + var input = new InputMapping(); + using var vulkan = new VulkanContext(window, enableValidation: true); + using var swapchain = new Swapchain(vulkan); + using var renderer = new ClearRenderer(vulkan, swapchain); + + var frames = 0; + var lastFpsTime = 0.0; + + while (!window.ShouldClose) + { + timing.Tick(); + window.PumpEvents(); + input.BeginFrame(); + // Note: SDL events are already polled in PumpEvents. + // In a real engine, the window would expose an event iterator. + + // Animate clear color over time. + var t = (float)timing.TotalTime; + var r = MathF.Sin(t * 0.5f) * 0.5f + 0.5f; + var g = MathF.Sin(t * 0.7f + 2.0f) * 0.5f + 0.5f; + var b = MathF.Sin(t * 0.9f + 4.0f) * 0.5f + 0.5f; + + renderer.RenderFrame(r, g, b); + + frames++; + if (timing.TotalTime - lastFpsTime >= 1.0) + { + Console.WriteLine($"FPS: {frames}, Delta: {timing.DeltaTime * 1000.0:F2} ms"); + frames = 0; + lastFpsTime = timing.TotalTime; + } + } + + Console.WriteLine("Shutting down..."); + } + catch (Exception ex) + { + Console.WriteLine($"Fatal error: {ex}"); + Environment.Exit(1); + } + } +} diff --git a/src/Engine.Core/Engine.Core.csproj b/src/Engine.Core/Engine.Core.csproj new file mode 100644 index 0000000..839b615 --- /dev/null +++ b/src/Engine.Core/Engine.Core.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + true + true + + + + DEV_MODE + + + + RELEASE_AOT + true + + + + + + + diff --git a/src/Engine.Core/InputMapping.cs b/src/Engine.Core/InputMapping.cs new file mode 100644 index 0000000..eaaef52 --- /dev/null +++ b/src/Engine.Core/InputMapping.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using SDL; + +namespace Engine.Core; + +/// +/// Minimal snapshot of current input state. +/// Populated by polling SDL events once per frame. +/// +public sealed class InputMapping +{ + private readonly HashSet _keysPressed = new(); + private readonly HashSet _keysDown = new(); + private readonly HashSet _keysReleased = new(); + + public int MouseX { get; private set; } + public int MouseY { get; private set; } + public bool MouseLeft { get; private set; } + public bool MouseRight { get; private set; } + public bool MouseMiddle { get; private set; } + + public void BeginFrame() + { + _keysPressed.Clear(); + _keysReleased.Clear(); + } + + public void ProcessEvent(SDL_Event evt) + { + switch ((SDL_EventType)evt.Type) + { + case SDL_EventType.SDL_EVENT_KEY_DOWN: + if (!_keysDown.Contains((SDL_Keycode)evt.key.key)) + _keysPressed.Add((SDL_Keycode)evt.key.key); + _keysDown.Add((SDL_Keycode)evt.key.key); + break; + + case SDL_EventType.SDL_EVENT_KEY_UP: + _keysDown.Remove((SDL_Keycode)evt.key.key); + _keysReleased.Add((SDL_Keycode)evt.key.key); + break; + + case SDL_EventType.SDL_EVENT_MOUSE_MOTION: + MouseX = (int)evt.motion.x; + MouseY = (int)evt.motion.y; + break; + + case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_DOWN: + SetMouseButton(evt.button.button, true); + break; + + case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_UP: + SetMouseButton(evt.button.button, false); + break; + } + } + + public bool IsKeyDown(SDL_Keycode key) => _keysDown.Contains(key); + public bool IsKeyPressed(SDL_Keycode key) => _keysPressed.Contains(key); + public bool IsKeyReleased(SDL_Keycode key) => _keysReleased.Contains(key); + + private void SetMouseButton(byte button, bool pressed) + { + switch (button) + { + case 1: MouseLeft = pressed; break; + case 2: MouseMiddle = pressed; break; + case 3: MouseRight = pressed; break; + } + } +} diff --git a/src/Engine.Core/Sdl3Window.cs b/src/Engine.Core/Sdl3Window.cs new file mode 100644 index 0000000..5ecf90a --- /dev/null +++ b/src/Engine.Core/Sdl3Window.cs @@ -0,0 +1,98 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using SDL; + +namespace Engine.Core; + +/// +/// A thin, disposable wrapper around an SDL3 window. +/// Handles creation, Vulkan surface discovery, and event polling. +/// +public sealed unsafe class Sdl3Window : IDisposable +{ + private readonly SDL_Window* _window; + private bool _disposed; + + public int Width { get; private set; } + public int Height { get; private set; } + public nint Handle => (nint)_window; + public bool ShouldClose { get; private set; } + + public Sdl3Window(string title, int width, int height) + { + Width = width; + Height = height; + + if (!SDL3.SDL_Init(SDL_InitFlags.SDL_INIT_VIDEO)) + { + throw new InvalidOperationException($"SDL_Init failed: {SDL3.SDL_GetError()}"); + } + + var titleBytes = Encoding.UTF8.GetBytes(title + '\0'); + fixed (byte* titlePtr = titleBytes) + { + _window = SDL3.SDL_CreateWindow( + titlePtr, + width, + height, + SDL_WindowFlags.SDL_WINDOW_VULKAN | SDL_WindowFlags.SDL_WINDOW_RESIZABLE); + } + + if (_window == null) + { + throw new InvalidOperationException($"SDL_CreateWindow failed: {SDL3.SDL_GetError()}"); + } + } + + public void PumpEvents() + { + SDL_Event evt; + while (SDL3.SDL_PollEvent(&evt)) + { + switch ((SDL_EventType)evt.Type) + { + case SDL_EventType.SDL_EVENT_QUIT: + ShouldClose = true; + break; + + case SDL_EventType.SDL_EVENT_WINDOW_RESIZED: + Width = evt.window.data1; + Height = evt.window.data2; + break; + + case SDL_EventType.SDL_EVENT_KEY_DOWN: + if (evt.key.key == SDL_Keycode.SDLK_ESCAPE) + ShouldClose = true; + break; + } + } + } + + public string[] GetRequiredInstanceExtensions() + { + uint count; + var extensionsPtr = SDL3.SDL_Vulkan_GetInstanceExtensions(&count); + if (extensionsPtr == null) + { + throw new InvalidOperationException($"SDL_Vulkan_GetInstanceExtensions failed: {SDL3.SDL_GetError()}"); + } + + var result = new string[count]; + for (var i = 0; i < count; i++) + { + result[i] = SDL3.PtrToStringUTF8(extensionsPtr[i]) ?? string.Empty; + } + + return result; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + SDL3.SDL_DestroyWindow(_window); + SDL3.SDL_Quit(); + } +} diff --git a/src/Engine.Core/Timing.cs b/src/Engine.Core/Timing.cs new file mode 100644 index 0000000..7d172dc --- /dev/null +++ b/src/Engine.Core/Timing.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics; + +namespace Engine.Core; + +/// +/// Frame timing and fixed-timestep helper. +/// +public sealed class Timing +{ + private readonly Stopwatch _stopwatch; + private double _lastTime; + private double _accumulator; + + public double DeltaTime { get; private set; } + public double TotalTime { get; private set; } + public double FixedTimeStep { get; set; } = 1.0 / 60.0; + public double FixedTimeAccumulator => _accumulator; + + public Timing() + { + _stopwatch = Stopwatch.StartNew(); + _lastTime = 0.0; + } + + public void Tick() + { + var current = _stopwatch.Elapsed.TotalSeconds; + DeltaTime = current - _lastTime; + _lastTime = current; + TotalTime = current; + _accumulator += DeltaTime; + } + + public bool ConsumeFixedStep() + { + if (_accumulator < FixedTimeStep) + return false; + + _accumulator -= FixedTimeStep; + return true; + } + + public void ResetAccumulator() + { + if (_accumulator > FixedTimeStep * 5) + _accumulator = FixedTimeStep * 5; + } +} diff --git a/src/Engine.Graphics/ClearRenderer.cs b/src/Engine.Graphics/ClearRenderer.cs new file mode 100644 index 0000000..f63d90b --- /dev/null +++ b/src/Engine.Graphics/ClearRenderer.cs @@ -0,0 +1,183 @@ +using System; +using Vortice.Vulkan; + +namespace Engine.Graphics; + +/// +/// Minimal renderer that clears the swapchain image to a solid color. +/// Serves as the foundational Step 1 rendering proof-of-concept. +/// +public sealed unsafe class ClearRenderer : IDisposable +{ + private readonly VulkanContext _context; + private readonly Swapchain _swapchain; + private VkCommandPool _commandPool; + private VkCommandBuffer[] _commandBuffers; + private VkSemaphore[] _imageAvailableSemaphores; + private VkSemaphore[] _renderFinishedSemaphores; + private VkFence[] _inFlightFences; + private int _currentFrame; + + public ClearRenderer(VulkanContext context, Swapchain swapchain) + { + _context = context; + _swapchain = swapchain; + CreateCommandPool(); + CreateCommandBuffers(); + CreateSyncObjects(); + } + + private void CreateCommandPool() + { + var createInfo = new VkCommandPoolCreateInfo + { + sType = VkStructureType.CommandPoolCreateInfo, + queueFamilyIndex = _context.GraphicsFamilyIndex, + flags = VkCommandPoolCreateFlags.ResetCommandBuffer + }; + + var result = _context.DeviceApi.vkCreateCommandPool(&createInfo, null, out _commandPool); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkCreateCommandPool failed: {result}"); + } + + private void CreateCommandBuffers() + { + _commandBuffers = new VkCommandBuffer[2]; + for (var i = 0; i < _commandBuffers.Length; i++) + { + var allocInfo = new VkCommandBufferAllocateInfo + { + sType = VkStructureType.CommandBufferAllocateInfo, + commandPool = _commandPool, + level = VkCommandBufferLevel.Primary, + commandBufferCount = 1 + }; + + var result = _context.DeviceApi.vkAllocateCommandBuffer(&allocInfo, out _commandBuffers[i]); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkAllocateCommandBuffers failed: {result}"); + } + } + + private void CreateSyncObjects() + { + _imageAvailableSemaphores = new VkSemaphore[2]; + _renderFinishedSemaphores = new VkSemaphore[2]; + _inFlightFences = new VkFence[2]; + + var semaphoreInfo = new VkSemaphoreCreateInfo { sType = VkStructureType.SemaphoreCreateInfo }; + var fenceInfo = new VkFenceCreateInfo + { + sType = VkStructureType.FenceCreateInfo, + flags = VkFenceCreateFlags.Signaled + }; + + for (var i = 0; i < 2; i++) + { + _context.DeviceApi.vkCreateSemaphore(&semaphoreInfo, null, out _imageAvailableSemaphores[i]); + _context.DeviceApi.vkCreateSemaphore(&semaphoreInfo, null, out _renderFinishedSemaphores[i]); + _context.DeviceApi.vkCreateFence(&fenceInfo, null, out _inFlightFences[i]); + } + } + + public void RenderFrame(float r, float g, float b) + { + var frame = _currentFrame % 2; + + _context.DeviceApi.vkWaitForFences(_inFlightFences[frame], true, ulong.MaxValue); + _context.DeviceApi.vkResetFences(_inFlightFences[frame]); + + var result = _context.DeviceApi.vkAcquireNextImageKHR( + _swapchain.Handle, + ulong.MaxValue, + _imageAvailableSemaphores[frame], + VkFence.Null, + out var imageIndex); + + if (result == VkResult.ErrorOutOfDateKHR) + { + return; + } + + var cmd = _commandBuffers[frame]; + _context.DeviceApi.vkResetCommandBuffer(cmd, VkCommandBufferResetFlags.None); + + var beginInfo = new VkCommandBufferBeginInfo + { + sType = VkStructureType.CommandBufferBeginInfo, + flags = VkCommandBufferUsageFlags.OneTimeSubmit + }; + _context.DeviceApi.vkBeginCommandBuffer(cmd, &beginInfo); + + var clearColor = new VkClearValue(r, g, b, 1.0f); + + var renderPassInfo = new VkRenderPassBeginInfo + { + sType = VkStructureType.RenderPassBeginInfo, + renderPass = _swapchain.RenderPass, + framebuffer = _swapchain.Framebuffers[imageIndex], + renderArea = new VkRect2D(0, 0, _swapchain.Extent.width, _swapchain.Extent.height), + clearValueCount = 1, + pClearValues = &clearColor + }; + + _context.DeviceApi.vkCmdBeginRenderPass(cmd, &renderPassInfo, VkSubpassContents.Inline); + _context.DeviceApi.vkCmdEndRenderPass(cmd); + _context.DeviceApi.vkEndCommandBuffer(cmd); + + var waitSemaphore = _imageAvailableSemaphores[frame]; + var signalSemaphore = _renderFinishedSemaphores[frame]; + var stageMask = VkPipelineStageFlags.ColorAttachmentOutput; + var submitInfo = new VkSubmitInfo + { + sType = VkStructureType.SubmitInfo, + waitSemaphoreCount = 1, + pWaitSemaphores = &waitSemaphore, + pWaitDstStageMask = &stageMask, + commandBufferCount = 1, + pCommandBuffers = &cmd, + signalSemaphoreCount = 1, + pSignalSemaphores = &signalSemaphore + }; + + _context.DeviceApi.vkQueueSubmit(_context.GraphicsQueue, 1, &submitInfo, _inFlightFences[frame]); + + var swapchain = _swapchain.Handle; + var presentInfo = new VkPresentInfoKHR + { + sType = VkStructureType.PresentInfoKHR, + waitSemaphoreCount = 1, + pWaitSemaphores = &signalSemaphore, + swapchainCount = 1, + pSwapchains = &swapchain, + pImageIndices = &imageIndex + }; + + var presentResult = _context.DeviceApi.vkQueuePresentKHR(_context.PresentQueue, &presentInfo); + if (presentResult == VkResult.ErrorOutOfDateKHR || presentResult == VkResult.SuboptimalKHR) + { + // Recreate handled externally. + } + + _currentFrame++; + } + + public void Dispose() + { + _context.DeviceApi.vkDeviceWaitIdle(); + + for (var i = 0; i < 2; i++) + { + if (_renderFinishedSemaphores[i] != VkSemaphore.Null) + _context.DeviceApi.vkDestroySemaphore(_renderFinishedSemaphores[i]); + if (_imageAvailableSemaphores[i] != VkSemaphore.Null) + _context.DeviceApi.vkDestroySemaphore(_imageAvailableSemaphores[i]); + if (_inFlightFences[i] != VkFence.Null) + _context.DeviceApi.vkDestroyFence(_inFlightFences[i]); + } + + if (_commandPool != VkCommandPool.Null) + _context.DeviceApi.vkDestroyCommandPool(_commandPool); + } +} diff --git a/src/Engine.Graphics/Engine.Graphics.csproj b/src/Engine.Graphics/Engine.Graphics.csproj new file mode 100644 index 0000000..19e8ac8 --- /dev/null +++ b/src/Engine.Graphics/Engine.Graphics.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + true + true + + + + DEV_MODE + + + + RELEASE_AOT + true + + + + + + + + + + + diff --git a/src/Engine.Graphics/Swapchain.cs b/src/Engine.Graphics/Swapchain.cs new file mode 100644 index 0000000..60d27ab --- /dev/null +++ b/src/Engine.Graphics/Swapchain.cs @@ -0,0 +1,292 @@ +using System; +using Vortice.Vulkan; + +namespace Engine.Graphics; + +/// +/// Manages the Vulkan swapchain, image views, render pass, and framebuffers. +/// Recreates itself automatically when the window is resized. +/// +public sealed unsafe class Swapchain : IDisposable +{ + private readonly VulkanContext _context; + private VkRenderPass _renderPass; + private VkSwapchainKHR _swapchain; + private VkImage[] _images; + private VkImageView[] _imageViews; + private VkFramebuffer[] _framebuffers; + private VkSurfaceFormatKHR _surfaceFormat; + private VkPresentModeKHR _presentMode; + private VkExtent2D _extent; + + public VkRenderPass RenderPass => _renderPass; + public VkFramebuffer[] Framebuffers => _framebuffers; + public VkExtent2D Extent => _extent; + public VkSwapchainKHR Handle => _swapchain; + public uint ImageCount => (uint)_images.Length; + + public Swapchain(VulkanContext context) + { + _context = context; + _surfaceFormat = ChooseSurfaceFormat(); + CreateRenderPass(); + Recreate(1280, 720); + } + + public void Recreate(int width, int height) + { + _context.DeviceApi.vkDeviceWaitIdle(); + CleanupSwapchain(); + + var capabilities = GetSurfaceCapabilities(); + _surfaceFormat = ChooseSurfaceFormat(); + _presentMode = ChoosePresentMode(); + _extent = ChooseExtent(capabilities, (uint)width, (uint)height); + + var imageCount = capabilities.minImageCount + 1; + if (capabilities.maxImageCount > 0 && imageCount > capabilities.maxImageCount) + imageCount = capabilities.maxImageCount; + + var createInfo = new VkSwapchainCreateInfoKHR + { + sType = VkStructureType.SwapchainCreateInfoKHR, + surface = _context.Surface, + minImageCount = imageCount, + imageFormat = _surfaceFormat.format, + imageColorSpace = _surfaceFormat.colorSpace, + imageExtent = _extent, + imageArrayLayers = 1, + imageUsage = VkImageUsageFlags.ColorAttachment, + imageSharingMode = VkSharingMode.Exclusive, + preTransform = capabilities.currentTransform, + compositeAlpha = VkCompositeAlphaFlagsKHR.Opaque, + presentMode = _presentMode, + clipped = true, + oldSwapchain = _swapchain + }; + + var result = _context.DeviceApi.vkCreateSwapchainKHR(&createInfo, null, out _swapchain); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkCreateSwapchainKHR failed: {result}"); + + _images = GetSwapchainImages(); + _imageViews = new VkImageView[_images.Length]; + _framebuffers = new VkFramebuffer[_images.Length]; + + for (var i = 0; i < _images.Length; i++) + { + _imageViews[i] = CreateImageView(_images[i], _surfaceFormat.format); + _framebuffers[i] = CreateFramebuffer(_imageViews[i]); + } + } + + private VkSurfaceCapabilitiesKHR GetSurfaceCapabilities() + { + var result = _context.InstanceApi.vkGetPhysicalDeviceSurfaceCapabilitiesKHR(_context.PhysicalDevice, _context.Surface, out var capabilities); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkGetPhysicalDeviceSurfaceCapabilitiesKHR failed: {result}"); + return capabilities; + } + + private VkImage[] GetSwapchainImages() + { + uint count = 0; + _context.DeviceApi.vkGetSwapchainImagesKHR(_swapchain, &count, null); + var images = new VkImage[count]; + fixed (VkImage* p = images) + { + var result = _context.DeviceApi.vkGetSwapchainImagesKHR(_swapchain, &count, p); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkGetSwapchainImagesKHR failed: {result}"); + } + return images; + } + + private void CreateRenderPass() + { + var colorAttachment = new VkAttachmentDescription + { + format = _surfaceFormat.format != VkFormat.Undefined ? _surfaceFormat.format : VkFormat.B8G8R8A8Unorm, + samples = VkSampleCountFlags.Count1, + loadOp = VkAttachmentLoadOp.Clear, + storeOp = VkAttachmentStoreOp.Store, + stencilLoadOp = VkAttachmentLoadOp.DontCare, + stencilStoreOp = VkAttachmentStoreOp.DontCare, + initialLayout = VkImageLayout.Undefined, + finalLayout = VkImageLayout.PresentSrcKHR + }; + + var colorAttachmentRef = new VkAttachmentReference + { + attachment = 0, + layout = VkImageLayout.ColorAttachmentOptimal + }; + + var subpass = new VkSubpassDescription + { + pipelineBindPoint = VkPipelineBindPoint.Graphics, + colorAttachmentCount = 1, + pColorAttachments = &colorAttachmentRef + }; + + var dependency = new VkSubpassDependency + { + srcSubpass = Vulkan.VK_SUBPASS_EXTERNAL, + dstSubpass = 0, + srcStageMask = VkPipelineStageFlags.ColorAttachmentOutput, + dstStageMask = VkPipelineStageFlags.ColorAttachmentOutput, + srcAccessMask = VkAccessFlags.None, + dstAccessMask = VkAccessFlags.ColorAttachmentWrite + }; + + var createInfo = new VkRenderPassCreateInfo + { + sType = VkStructureType.RenderPassCreateInfo, + attachmentCount = 1, + pAttachments = &colorAttachment, + subpassCount = 1, + pSubpasses = &subpass, + dependencyCount = 1, + pDependencies = &dependency + }; + + var result = _context.DeviceApi.vkCreateRenderPass(&createInfo, null, out _renderPass); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkCreateRenderPass failed: {result}"); + } + + private VkImageView CreateImageView(VkImage image, VkFormat format) + { + var createInfo = new VkImageViewCreateInfo + { + sType = VkStructureType.ImageViewCreateInfo, + image = image, + viewType = VkImageViewType.Image2D, + format = format, + components = new VkComponentMapping(VkComponentSwizzle.R, VkComponentSwizzle.G, VkComponentSwizzle.B, VkComponentSwizzle.A), + subresourceRange = new VkImageSubresourceRange(VkImageAspectFlags.Color, 0, 1, 0, 1) + }; + + var result = _context.DeviceApi.vkCreateImageView(&createInfo, null, out var imageView); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkCreateImageView failed: {result}"); + + return imageView; + } + + private VkFramebuffer CreateFramebuffer(VkImageView imageView) + { + var createInfo = new VkFramebufferCreateInfo + { + sType = VkStructureType.FramebufferCreateInfo, + renderPass = _renderPass, + attachmentCount = 1, + pAttachments = &imageView, + width = _extent.width, + height = _extent.height, + layers = 1 + }; + + var result = _context.DeviceApi.vkCreateFramebuffer(&createInfo, null, out var framebuffer); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkCreateFramebuffer failed: {result}"); + + return framebuffer; + } + + private VkSurfaceFormatKHR ChooseSurfaceFormat() + { + var formats = GetSurfaceFormats(); + foreach (var format in formats) + { + if (format.format == VkFormat.B8G8R8A8Unorm && format.colorSpace == VkColorSpaceKHR.SrgbNonLinear) + return format; + } + return formats[0]; + } + + private VkSurfaceFormatKHR[] GetSurfaceFormats() + { + uint count = 0; + _context.InstanceApi.vkGetPhysicalDeviceSurfaceFormatsKHR(_context.PhysicalDevice, _context.Surface, &count, null); + var formats = new VkSurfaceFormatKHR[count]; + fixed (VkSurfaceFormatKHR* p = formats) + { + var result = _context.InstanceApi.vkGetPhysicalDeviceSurfaceFormatsKHR(_context.PhysicalDevice, _context.Surface, &count, p); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkGetPhysicalDeviceSurfaceFormatsKHR failed: {result}"); + } + return formats; + } + + private VkPresentModeKHR ChoosePresentMode() + { + var modes = GetSurfacePresentModes(); + if (Array.Exists(modes, m => m == VkPresentModeKHR.Mailbox)) + return VkPresentModeKHR.Mailbox; + return VkPresentModeKHR.Fifo; + } + + private VkPresentModeKHR[] GetSurfacePresentModes() + { + uint count = 0; + _context.InstanceApi.vkGetPhysicalDeviceSurfacePresentModesKHR(_context.PhysicalDevice, _context.Surface, &count, null); + var modes = new VkPresentModeKHR[count]; + fixed (VkPresentModeKHR* p = modes) + { + var result = _context.InstanceApi.vkGetPhysicalDeviceSurfacePresentModesKHR(_context.PhysicalDevice, _context.Surface, &count, p); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkGetPhysicalDeviceSurfacePresentModesKHR failed: {result}"); + } + return modes; + } + + private VkExtent2D ChooseExtent(VkSurfaceCapabilitiesKHR capabilities, uint width, uint height) + { + if (capabilities.currentExtent.width != uint.MaxValue) + return capabilities.currentExtent; + + var extent = new VkExtent2D + { + width = Math.Clamp(width, capabilities.minImageExtent.width, capabilities.maxImageExtent.width), + height = Math.Clamp(height, capabilities.minImageExtent.height, capabilities.maxImageExtent.height) + }; + return extent; + } + + private void CleanupSwapchain() + { + if (_context.Device == VkDevice.Null) + return; + + if (_framebuffers != null) + { + foreach (var fb in _framebuffers) + { + if (fb != VkFramebuffer.Null) + _context.DeviceApi.vkDestroyFramebuffer(fb); + } + } + + if (_imageViews != null) + { + foreach (var view in _imageViews) + { + if (view != VkImageView.Null) + _context.DeviceApi.vkDestroyImageView(view); + } + } + + if (_swapchain != VkSwapchainKHR.Null) + _context.DeviceApi.vkDestroySwapchainKHR(_swapchain); + } + + public void Dispose() + { + _context.DeviceApi.vkDeviceWaitIdle(); + CleanupSwapchain(); + + if (_renderPass != VkRenderPass.Null) + _context.DeviceApi.vkDestroyRenderPass(_renderPass); + } +} diff --git a/src/Engine.Graphics/VulkanContext.cs b/src/Engine.Graphics/VulkanContext.cs new file mode 100644 index 0000000..496f531 --- /dev/null +++ b/src/Engine.Graphics/VulkanContext.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using Engine.Core; +using SDL; +using Vortice.Vulkan; + +namespace Engine.Graphics; + +/// +/// Owns the Vulkan instance, physical device, logical device, queues, and API handles. +/// Created once per application lifetime. +/// +public sealed unsafe class VulkanContext : IDisposable +{ + private bool _disposed; + + public VkInstance Instance { get; private set; } + public VkInstanceApi InstanceApi { get; private set; } + public VkPhysicalDevice PhysicalDevice { get; private set; } + public VkDevice Device { get; private set; } + public VkDeviceApi DeviceApi { get; private set; } + public VkQueue GraphicsQueue { get; private set; } + public VkQueue PresentQueue { get; private set; } + public uint GraphicsFamilyIndex { get; private set; } + public uint PresentFamilyIndex { get; private set; } + public VkSurfaceKHR Surface { get; private set; } + + public VulkanContext(Sdl3Window window, bool enableValidation = true) + { + CreateInstance(window, enableValidation); + InstanceApi = Vulkan.GetApi(Instance); + CreateSurface(window); + PickPhysicalDevice(); + CreateLogicalDevice(); + DeviceApi = Vulkan.GetApi(Instance, Device); + GetQueues(); + } + + private void CreateInstance(Sdl3Window window, bool enableValidation) + { + var requiredExtensions = new List(window.GetRequiredInstanceExtensions()); + if (enableValidation) + { + requiredExtensions.Add("VK_EXT_debug_utils"); + } + + var layerNames = enableValidation + ? new[] { "VK_LAYER_KHRONOS_validation" } + : Array.Empty(); + + var appName = VkStringInterop.ConvertToUnmanaged("Cortex Engine"); + var engineName = VkStringInterop.ConvertToUnmanaged("CortexEngine"); + + var appInfo = new VkApplicationInfo + { + sType = VkStructureType.ApplicationInfo, + pApplicationName = appName, + pEngineName = engineName, + apiVersion = VkVersion.Version_1_3 + }; + + using var extensionPin = new StringArrayPin(requiredExtensions); + using var layerPin = new StringArrayPin(layerNames); + + { + var createInfo = new VkInstanceCreateInfo + { + sType = VkStructureType.InstanceCreateInfo, + pApplicationInfo = &appInfo, + enabledExtensionCount = (uint)requiredExtensions.Count, + ppEnabledExtensionNames = extensionPin.Pointers, + enabledLayerCount = (uint)layerNames.Length, + ppEnabledLayerNames = layerPin.Pointers + }; + + var result = Vulkan.vkCreateInstance(&createInfo, null, out var instance); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkCreateInstance failed: {result}"); + Instance = instance; + } + + VkStringInterop.Free(appName); + VkStringInterop.Free(engineName); + } + + private void CreateSurface(Sdl3Window window) + { + var sdlInstance = (SDL.VkInstance_T*)Instance.Handle; + var sdlSurface = (SDL.VkSurfaceKHR_T*)null; + var result = SDL3.SDL_Vulkan_CreateSurface( + (SDL_Window*)window.Handle, + sdlInstance, + null, + &sdlSurface); + + if (result != true) + throw new InvalidOperationException($"SDL_Vulkan_CreateSurface failed: {SDL3.SDL_GetError()}"); + + Surface = new VkSurfaceKHR((ulong)sdlSurface); + } + + private void PickPhysicalDevice() + { + var devices = EnumeratePhysicalDevices(); + if (devices.Length == 0) + throw new InvalidOperationException("No Vulkan physical devices found."); + + foreach (var device in devices) + { + var properties = InstanceApi.vkGetPhysicalDeviceProperties(device); + var queueFamilies = GetPhysicalDeviceQueueFamilyProperties(device); + + var hasGraphics = false; + var hasPresent = false; + for (var i = 0; i < queueFamilies.Length; i++) + { + if (queueFamilies[i].queueFlags.HasFlag(VkQueueFlags.Graphics)) + hasGraphics = true; + var supportResult = InstanceApi.vkGetPhysicalDeviceSurfaceSupportKHR(device, (uint)i, Surface, out VkBool32 supported); + if (supportResult == VkResult.Success && supported) + hasPresent = true; + } + + if (hasGraphics && hasPresent) + { + PhysicalDevice = device; + if (properties.deviceType == VkPhysicalDeviceType.DiscreteGpu) + break; + } + } + + if (PhysicalDevice == VkPhysicalDevice.Null) + throw new InvalidOperationException("No suitable Vulkan physical device found."); + } + + private VkPhysicalDevice[] EnumeratePhysicalDevices() + { + uint count = 0; + InstanceApi.vkEnumeratePhysicalDevices(&count, null); + if (count == 0) + return Array.Empty(); + + var devices = new VkPhysicalDevice[count]; + fixed (VkPhysicalDevice* p = devices) + { + var result = InstanceApi.vkEnumeratePhysicalDevices(&count, p); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkEnumeratePhysicalDevices failed: {result}"); + } + return devices; + } + + private void CreateLogicalDevice() + { + var queueFamilies = GetPhysicalDeviceQueueFamilyProperties(PhysicalDevice); + GraphicsFamilyIndex = FindQueueFamilyIndex(queueFamilies, VkQueueFlags.Graphics); + PresentFamilyIndex = FindPresentQueueFamilyIndex(queueFamilies); + + var uniqueFamilies = new HashSet { GraphicsFamilyIndex, PresentFamilyIndex }; + var queueCreateInfos = uniqueFamilies.Select(family => new VkDeviceQueueCreateInfo + { + sType = VkStructureType.DeviceQueueCreateInfo, + queueFamilyIndex = family, + queueCount = 1 + }).ToArray(); + + var priorityHandles = new GCHandle[queueCreateInfos.Length]; + var extensionNames = new[] { "VK_KHR_swapchain" }; + using var extensionPin = new StringArrayPin(extensionNames); + + try + { + var deviceFeatures = new VkPhysicalDeviceFeatures(); + + for (var i = 0; i < queueCreateInfos.Length; i++) + { + var priority = new[] { 1.0f }; + var handle = GCHandle.Alloc(priority, GCHandleType.Pinned); + priorityHandles[i] = handle; + queueCreateInfos[i].pQueuePriorities = (float*)handle.AddrOfPinnedObject(); + } + + fixed (VkDeviceQueueCreateInfo* pQueue = queueCreateInfos) + { + var createInfo = new VkDeviceCreateInfo + { + sType = VkStructureType.DeviceCreateInfo, + queueCreateInfoCount = (uint)queueCreateInfos.Length, + pQueueCreateInfos = pQueue, + pEnabledFeatures = &deviceFeatures, + enabledExtensionCount = 1, + ppEnabledExtensionNames = extensionPin.Pointers + }; + + var result = InstanceApi.vkCreateDevice(PhysicalDevice, &createInfo, null, out var device); + if (result != VkResult.Success) + throw new InvalidOperationException($"vkCreateDevice failed: {result}"); + Device = device; + } + } + finally + { + foreach (var handle in priorityHandles) + { + if (handle.IsAllocated) + handle.Free(); + } + } + } + + private void GetQueues() + { + DeviceApi.vkGetDeviceQueue(GraphicsFamilyIndex, 0, out var graphicsQueue); + DeviceApi.vkGetDeviceQueue(PresentFamilyIndex, 0, out var presentQueue); + GraphicsQueue = graphicsQueue; + PresentQueue = presentQueue; + } + + private uint FindQueueFamilyIndex(VkQueueFamilyProperties[] properties, VkQueueFlags flags) + { + for (var i = 0; i < properties.Length; i++) + { + if (properties[i].queueFlags.HasFlag(flags)) + return (uint)i; + } + throw new InvalidOperationException($"No queue family with flags {flags} found."); + } + + private uint FindPresentQueueFamilyIndex(VkQueueFamilyProperties[] properties) + { + for (var i = 0; i < properties.Length; i++) + { + var supportResult = InstanceApi.vkGetPhysicalDeviceSurfaceSupportKHR(PhysicalDevice, (uint)i, Surface, out VkBool32 supported); + if (supportResult == VkResult.Success && supported) + return (uint)i; + } + throw new InvalidOperationException("No present queue family found."); + } + + private VkQueueFamilyProperties[] GetPhysicalDeviceQueueFamilyProperties(VkPhysicalDevice device) + { + uint count = 0; + InstanceApi.vkGetPhysicalDeviceQueueFamilyProperties(device, &count, null); + var properties = new VkQueueFamilyProperties[count]; + fixed (VkQueueFamilyProperties* p = properties) + { + InstanceApi.vkGetPhysicalDeviceQueueFamilyProperties(device, &count, p); + } + return properties; + } + + private sealed unsafe class StringArrayPin : IDisposable + { + public byte** Pointers; + private readonly GCHandle[] _handles; + + public StringArrayPin(IReadOnlyList strings) + { + if (strings.Count == 0) + { + Pointers = null; + _handles = Array.Empty(); + return; + } + + Pointers = (byte**)Marshal.AllocHGlobal(strings.Count * sizeof(byte*)); + _handles = new GCHandle[strings.Count]; + + for (var i = 0; i < strings.Count; i++) + { + var bytes = Encoding.UTF8.GetBytes(strings[i] + '\0'); + _handles[i] = GCHandle.Alloc(bytes, GCHandleType.Pinned); + Pointers[i] = (byte*)_handles[i].AddrOfPinnedObject(); + } + } + + public void Dispose() + { + if (Pointers == null) + return; + + foreach (var handle in _handles) + { + if (handle.IsAllocated) + handle.Free(); + } + + Marshal.FreeHGlobal((nint)Pointers); + Pointers = null; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (Device != VkDevice.Null) + DeviceApi.vkDestroyDevice(); + if (Instance != VkInstance.Null && Surface != VkSurfaceKHR.Null) + InstanceApi.vkDestroySurfaceKHR(Surface); + if (Instance != VkInstance.Null) + InstanceApi.vkDestroyInstance(); + } +}