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();
+ }
+}