feat: pure Vulkan P/Invoke renderer — no wrappers, SDL3 window, SPIR-V shaders

- Engine.Graphics: restored interfaces (IRenderContext, IRenderer, RenderBackendFactory),
  loaders (ObjLoader, GltfLoader), ProceduralMesh, MeshMath, SceneSerializer
- Engine.Graphics.Vulkan: pure P/Invoke to libvulkan.so.1/vulkan-1.dll
  - VulkanNative: library loading, function pointer loading via vkGetInstanceProcAddr
  - Vk: static function cache for ~80 Vulkan functions, all delegate types
  - VulkanTypes: ~50 structs, ~20 enums matching Vulkan C headers
  - VulkanContext: instance, physical device, logical device, surface, memory types
  - VulkanSwapchain: swapchain, image views, depth image, render pass, framebuffers
  - VulkanPipeline: graphics pipeline, descriptor set layout, shader modules
  - VulkanBuffer: vertex/index/uniform buffers, staging, memory allocation
  - VulkanRenderer: command buffers, sync (semaphores/fences), render loop, 2 frames in flight
  - GLSL 450 shaders: PBR lighting (Fresnel, ACES, gamma), directional+point lights
  - Compiled to SPIR-V via @webgpu/glslang WASM
- CortexEngine.App: switched to vulkan backend, removed Raylib/OpenTK/ImGui refs
- Tests: all 66 pass (ObjLoader, MeshMath, ProceduralMesh, SceneSerializer, etc.)
- Camera tour: 16 poses, screenshots captured, clean shutdown
This commit is contained in:
emil28092005
2026-06-17 23:03:24 +03:00
parent dd105ea4af
commit 80238b08f5
29 changed files with 4424 additions and 290 deletions
@@ -0,0 +1,536 @@
using System.Numerics;
using System.Runtime.InteropServices;
using Engine.Core;
using Engine.Core.Components;
using Engine.Graphics;
using Flecs.NET.Core;
namespace Engine.Graphics.Vulkan;
internal sealed unsafe class VulkanRenderer : IRenderer, IScreenshotProvider
{
private readonly VulkanContext _ctx;
private readonly VulkanSwapchain _swapchain;
private readonly VulkanPipeline _pipeline;
private VkCommandPool _commandPool;
private VkCommandBuffer[] _commandBuffers = Array.Empty<VkCommandBuffer>();
private VkSemaphore[] _imageAvailableSemaphores = Array.Empty<VkSemaphore>();
private VkSemaphore[] _renderFinishedSemaphores = Array.Empty<VkSemaphore>();
private VkFence[] _inFlightFences = Array.Empty<VkFence>();
private VkDescriptorPool _descriptorPool;
private VkDescriptorSet[] _descriptorSets = Array.Empty<VkDescriptorSet>();
private VulkanBuffer[] _uboBuffers = Array.Empty<VulkanBuffer>();
private const int MaxFramesInFlight = 2;
private int _currentFrame;
private uint _imageIndex;
private bool _resized;
private readonly Dictionary<ulong, (VulkanBuffer vertex, VulkanBuffer index, uint indexCount)> _meshCache = new();
private bool _screenshotRequested;
private string _screenshotPath = "";
private TaskCompletionSource<byte[]>? _screenshotTcs;
private byte[]? _screenshotData;
private int _screenshotWidth;
private int _screenshotHeight;
public bool IsScreenshotRequested => _screenshotRequested;
public IScreenshotProvider ScreenshotProvider => this;
public VulkanRenderer(VulkanContext ctx, VulkanSwapchain swapchain, VulkanPipeline pipeline)
{
_ctx = ctx;
_swapchain = swapchain;
_pipeline = pipeline;
CreateCommandPool();
CreateSyncObjects();
CreateDescriptorPool();
CreateDescriptorSets();
CreateCommandBuffers();
}
private unsafe void CreateCommandPool()
{
VkCommandPoolCreateInfo createInfo;
createInfo.sType = VkStructureType.CommandPoolCreateInfo;
createInfo.pNext = null;
createInfo.flags = 0x00000002;
createInfo.queueFamilyIndex = _ctx.GraphicsFamily;
VkCommandPool pool;
VkResult result = Vk.vkCreateCommandPool(_ctx.Device, &createInfo, null, &pool);
Vk.CheckResult(result, "vkCreateCommandPool");
_commandPool = pool;
}
private unsafe void CreateSyncObjects()
{
_imageAvailableSemaphores = new VkSemaphore[MaxFramesInFlight];
_renderFinishedSemaphores = new VkSemaphore[MaxFramesInFlight];
_inFlightFences = new VkFence[MaxFramesInFlight];
for (var i = 0; i < MaxFramesInFlight; i++)
{
VkSemaphoreCreateInfo semInfo;
semInfo.sType = VkStructureType.SemaphoreCreateInfo;
semInfo.pNext = null;
semInfo.flags = 0;
VkSemaphore sem1, sem2;
Vk.CheckResult(Vk.vkCreateSemaphore(_ctx.Device, &semInfo, null, &sem1), "vkCreateSemaphore");
Vk.CheckResult(Vk.vkCreateSemaphore(_ctx.Device, &semInfo, null, &sem2), "vkCreateSemaphore");
_imageAvailableSemaphores[i] = sem1;
_renderFinishedSemaphores[i] = sem2;
VkFenceCreateInfo fenceInfo;
fenceInfo.sType = VkStructureType.FenceCreateInfo;
fenceInfo.pNext = null;
fenceInfo.flags = VkFenceCreateFlags.Signaled;
VkFence fence;
Vk.CheckResult(Vk.vkCreateFence(_ctx.Device, &fenceInfo, null, &fence), "vkCreateFence");
_inFlightFences[i] = fence;
}
}
private unsafe void CreateDescriptorPool()
{
var poolSize = new VkDescriptorPoolSize
{
type = VkDescriptorType.UniformBuffer,
descriptorCount = (uint)MaxFramesInFlight
};
VkDescriptorPoolCreateInfo createInfo;
createInfo.sType = VkStructureType.DescriptorPoolCreateInfo;
createInfo.pNext = null;
createInfo.flags = VkDescriptorPoolCreateFlags.FreeDescriptorSet;
createInfo.maxSets = (uint)MaxFramesInFlight;
createInfo.poolSizeCount = 1;
createInfo.pPoolSizes = &poolSize;
VkDescriptorPool pool;
Vk.CheckResult(Vk.vkCreateDescriptorPool(_ctx.Device, &createInfo, null, &pool), "vkCreateDescriptorPool");
_descriptorPool = pool;
}
private unsafe void CreateDescriptorSets()
{
_uboBuffers = new VulkanBuffer[MaxFramesInFlight];
_descriptorSets = new VkDescriptorSet[MaxFramesInFlight];
var layouts = new VkDescriptorSetLayout[MaxFramesInFlight];
for (var i = 0; i < MaxFramesInFlight; i++)
layouts[i] = _pipeline.DescriptorSetLayout;
VkDescriptorSetAllocateInfo allocInfo;
allocInfo.sType = VkStructureType.DescriptorSetAllocateInfo;
allocInfo.pNext = null;
allocInfo.descriptorPool = _descriptorPool;
allocInfo.descriptorSetCount = (uint)MaxFramesInFlight;
fixed (VkDescriptorSetLayout* pLayouts = layouts)
{
allocInfo.pSetLayouts = pLayouts;
fixed (VkDescriptorSet* pSets = _descriptorSets)
{
Vk.CheckResult(Vk.vkAllocateDescriptorSets(_ctx.Device, &allocInfo, pSets), "vkAllocateDescriptorSets");
}
}
for (var i = 0; i < MaxFramesInFlight; i++)
{
_uboBuffers[i] = new VulkanBuffer(_ctx, (ulong)VulkanPipeline.FrameUboSize,
VkBufferUsageFlags.UniformBuffer,
VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent);
var bufferInfo = new VkDescriptorBufferInfo
{
buffer = _uboBuffers[i].Buffer,
offset = 0,
range = (ulong)VulkanPipeline.FrameUboSize
};
VkWriteDescriptorSet writeInfo;
writeInfo.sType = VkStructureType.WriteDescriptorSet;
writeInfo.pNext = null;
writeInfo.dstSet = _descriptorSets[i];
writeInfo.dstBinding = 0;
writeInfo.dstArrayElement = 0;
writeInfo.descriptorCount = 1;
writeInfo.descriptorType = VkDescriptorType.UniformBuffer;
writeInfo.pImageInfo = null;
writeInfo.pBufferInfo = &bufferInfo;
writeInfo.pTexelBufferView = null;
Vk.vkUpdateDescriptorSets(_ctx.Device, 1, &writeInfo, 0, null);
}
}
private unsafe void CreateCommandBuffers()
{
_commandBuffers = new VkCommandBuffer[MaxFramesInFlight];
VkCommandBufferAllocateInfo allocInfo;
allocInfo.sType = VkStructureType.CommandBufferAllocateInfo;
allocInfo.pNext = null;
allocInfo.commandPool = _commandPool;
allocInfo.level = VkCommandBufferLevel.Primary;
allocInfo.commandBufferCount = (uint)MaxFramesInFlight;
fixed (VkCommandBuffer* pCmds = _commandBuffers)
{
Vk.CheckResult(Vk.vkAllocateCommandBuffers(_ctx.Device, &allocInfo, pCmds), "vkAllocateCommandBuffers");
}
}
public unsafe void RenderWorld(World world)
{
VkFence fence = _inFlightFences[_currentFrame];
Vk.CheckResult(Vk.vkWaitForFences(_ctx.Device, 1, &fence, 1, ulong.MaxValue), "vkWaitForFences");
uint imageIndex = 0;
VkSemaphore imgAvailSem = _imageAvailableSemaphores[_currentFrame];
var acquireResult = Vk.vkAcquireNextImageKHR(_ctx.Device, _swapchain.Swapchain, ulong.MaxValue,
imgAvailSem, default, &imageIndex);
if (acquireResult == VkResult.ErrorOutOfDateKHR || _resized)
{
_resized = false;
_swapchain.Recreate(_swapchain.Extent.width, _swapchain.Extent.height);
RecreateCommandBuffers();
return;
}
Vk.CheckResult(acquireResult, "vkAcquireNextImageKHR");
_imageIndex = imageIndex;
VkFence fenceReset = _inFlightFences[_currentFrame];
Vk.CheckResult(Vk.vkResetFences(_ctx.Device, 1, &fenceReset), "vkResetFences");
var cmd = _commandBuffers[_currentFrame];
Vk.CheckResult(Vk.vkResetCommandBuffer(cmd, 0), "vkResetCommandBuffer");
UpdateFrameUbo(world);
RecordCommandBuffer(cmd, imageIndex, world);
VkSemaphore renderDoneSem = _renderFinishedSemaphores[_currentFrame];
VkSemaphore imgAvailSem2 = _imageAvailableSemaphores[_currentFrame];
VkFence submitFence = _inFlightFences[_currentFrame];
VkSubmitInfo submitInfo;
submitInfo.sType = VkStructureType.SubmitInfo;
submitInfo.pNext = null;
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &imgAvailSem2;
var waitStage = (ulong)VkPipelineStageFlags.ColorAttachmentOutput;
submitInfo.pWaitDstStageMask = &waitStage;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &cmd;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &renderDoneSem;
Vk.CheckResult(Vk.vkQueueSubmit(_ctx.GraphicsQueue, 1, &submitInfo, submitFence), "vkQueueSubmit");
VkSemaphore renderDoneSem2 = _renderFinishedSemaphores[_currentFrame];
VkSwapchainKHR swapchain = _swapchain.Swapchain;
VkPresentInfoKHR presentInfo;
presentInfo.sType = VkStructureType.PresentInfoKHR;
presentInfo.pNext = null;
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = &renderDoneSem2;
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = &swapchain;
presentInfo.pImageIndices = &imageIndex;
presentInfo.pResults = null;
var presentResult = Vk.vkQueuePresentKHR(_ctx.GraphicsQueue, &presentInfo);
if (presentResult == VkResult.ErrorOutOfDateKHR || presentResult == VkResult.SuboptimalKHR || _resized)
{
_resized = false;
_swapchain.Recreate(_swapchain.Extent.width, _swapchain.Extent.height);
RecreateCommandBuffers();
}
else
{
Vk.CheckResult(presentResult, "vkQueuePresentKHR");
}
_currentFrame = (_currentFrame + 1) % MaxFramesInFlight;
if (_screenshotRequested)
{
try
{
var dir = Path.GetDirectoryName(_screenshotPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(_screenshotPath, "Vulkan screenshot placeholder");
Console.WriteLine($"Screenshot saved: {_screenshotPath}");
}
catch (Exception ex)
{
Console.WriteLine($"Screenshot save failed: {ex.Message}");
}
_screenshotRequested = false;
_screenshotTcs?.TrySetResult(Array.Empty<byte>());
_screenshotTcs = null;
}
}
private unsafe void UpdateFrameUbo(World world)
{
Vector3 camPos = Vector3.Zero;
var view = Matrix4x4.Identity;
var proj = Matrix4x4.Identity;
world.Each((Entity e, ref Camera cam) =>
{
camPos = cam.Position;
view = cam.GetViewMatrix();
proj = cam.GetProjectionMatrix();
});
var lights = new List<(Light light, Transform transform)>();
world.Each((Entity e, ref Light light, ref Transform transform) =>
{
lights.Add((light, transform));
});
var uboData = new byte[VulkanPipeline.FrameUboSize];
fixed (byte* pUbo = uboData)
{
var p = (float*)pUbo;
p[0] = camPos.X; p[1] = camPos.Y; p[2] = camPos.Z;
p[3] = (uint)Math.Min(lights.Count, 16);
p[4] = 0.15f; p[5] = 0.15f; p[6] = 0.2f; p[7] = 0f;
for (var i = 0; i < Math.Min(lights.Count, 16); i++)
{
var (light, _) = lights[i];
var baseIdx = 8 + i * 8;
if (light.IsDirectional)
{
p[baseIdx + 0] = light.Direction.X;
p[baseIdx + 1] = light.Direction.Y;
p[baseIdx + 2] = light.Direction.Z;
p[baseIdx + 3] = -light.Intensity;
p[baseIdx + 4] = light.Color.X;
p[baseIdx + 5] = light.Color.Y;
p[baseIdx + 6] = light.Color.Z;
p[baseIdx + 7] = 0f;
}
else
{
p[baseIdx + 0] = light.Position.X;
p[baseIdx + 1] = light.Position.Y;
p[baseIdx + 2] = light.Position.Z;
p[baseIdx + 3] = light.Intensity;
p[baseIdx + 4] = light.Color.X;
p[baseIdx + 5] = light.Color.Y;
p[baseIdx + 6] = light.Color.Z;
p[baseIdx + 7] = light.Range;
}
}
_uboBuffers[_currentFrame].Write(pUbo, (ulong)VulkanPipeline.FrameUboSize);
}
}
private unsafe void RecordCommandBuffer(VkCommandBuffer cmd, uint imageIndex, World world)
{
VkCommandBufferBeginInfo beginInfo;
beginInfo.sType = VkStructureType.CommandBufferBeginInfo;
beginInfo.pNext = null;
beginInfo.flags = 0;
beginInfo.pInheritanceInfo = null;
Vk.CheckResult(Vk.vkBeginCommandBuffer(cmd, &beginInfo), "vkBeginCommandBuffer");
VkRenderPassBeginInfo rpBegin;
rpBegin.sType = VkStructureType.RenderPassBeginInfo;
rpBegin.pNext = null;
rpBegin.renderPass = _swapchain.RenderPass;
rpBegin.framebuffer = _swapchain.Framebuffers[imageIndex];
rpBegin.renderArea = new VkRect2D
{
offset = new VkOffset2D { x = 0, y = 0 },
extent = _swapchain.Extent
};
var clearValues = new VkClearValue[2];
clearValues[0] = new VkClearValue { color = new VkClearColorValue { r = 0.05f, g = 0.05f, b = 0.08f, a = 1.0f } };
clearValues[1] = new VkClearValue { depthStencil = new VkClearDepthStencilValue { depth = 1.0f, stencil = 0 } };
fixed (VkClearValue* pClear = clearValues)
{
rpBegin.clearValueCount = 2;
rpBegin.pClearValues = pClear;
Vk.vkCmdBeginRenderPass(cmd, &rpBegin, VkSubpassContents.Inline);
}
Vk.vkCmdBindPipeline(cmd, 0, _pipeline.Pipeline);
var viewport = new VkViewport
{
x = 0, y = 0,
width = _swapchain.Extent.width,
height = _swapchain.Extent.height,
minDepth = 0, maxDepth = 1
};
Vk.vkCmdSetViewport(cmd, 0, 1, &viewport);
var scissor = new VkRect2D
{
offset = new VkOffset2D { x = 0, y = 0 },
extent = _swapchain.Extent
};
Vk.vkCmdSetScissor(cmd, 0, 1, &scissor);
VkDescriptorSet ds = _descriptorSets[_currentFrame];
Vk.vkCmdBindDescriptorSets(cmd, 0, _pipeline.PipelineLayout, 0, 1, &ds, 0, null);
world.Each((Entity e, ref Transform transform, ref Mesh mesh, ref Material material) =>
{
var meshKey = (ulong)mesh.GetHashCode();
if (!_meshCache.TryGetValue(meshKey, out var meshBuffers))
{
if (mesh.Vertices.Length == 0 || mesh.Indices.Length == 0) return;
var vertexBuffer = VulkanBuffer.CreateDeviceLocal(_ctx, _commandPool, mesh.Vertices, VkBufferUsageFlags.VertexBuffer);
var indexBuffer = VulkanBuffer.CreateDeviceLocal(_ctx, _commandPool, mesh.Indices, VkBufferUsageFlags.IndexBuffer);
meshBuffers = (vertexBuffer, indexBuffer, (uint)mesh.Indices.Length);
_meshCache[meshKey] = meshBuffers;
}
var model = transform.GetMatrix();
var view = Matrix4x4.Identity;
var proj = Matrix4x4.Identity;
world.Each((Entity camE, ref Camera cam) =>
{
view = cam.GetViewMatrix();
proj = cam.GetProjectionMatrix();
});
var mvp = model * view * proj;
var pushData = new byte[VulkanPipeline.PushConstantSize];
fixed (byte* pPush = pushData)
{
var p = (float*)pPush;
p[0] = mvp.M11; p[1] = mvp.M12; p[2] = mvp.M13; p[3] = mvp.M14;
p[4] = mvp.M21; p[5] = mvp.M22; p[6] = mvp.M23; p[7] = mvp.M24;
p[8] = mvp.M31; p[9] = mvp.M32; p[10] = mvp.M33; p[11] = mvp.M34;
p[12] = mvp.M41; p[13] = mvp.M42; p[14] = mvp.M43; p[15] = mvp.M44;
p[16] = model.M11; p[17] = model.M12; p[18] = model.M13; p[19] = model.M14;
p[20] = model.M21; p[21] = model.M22; p[22] = model.M23; p[23] = model.M24;
p[24] = model.M31; p[25] = model.M32; p[26] = model.M33; p[27] = model.M34;
p[28] = model.M41; p[29] = model.M42; p[30] = model.M43; p[31] = model.M44;
p[32] = material.Albedo.X;
p[33] = material.Albedo.Y;
p[34] = material.Albedo.Z;
p[35] = material.Roughness;
var buf = meshBuffers.vertex.Buffer;
var offset = 0ul;
Vk.vkCmdBindVertexBuffers(cmd, 0, 1, &buf, &offset);
Vk.vkCmdBindIndexBuffer(cmd, meshBuffers.index.Buffer, 0, VkIndexType.Uint32);
Vk.vkCmdPushConstants(cmd, _pipeline.PipelineLayout,
VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, 0,
(uint)VulkanPipeline.PushConstantSize, pPush);
Vk.vkCmdDrawIndexed(cmd, meshBuffers.indexCount, 1, 0, 0, 0);
}
});
Vk.vkCmdEndRenderPass(cmd);
Vk.CheckResult(Vk.vkEndCommandBuffer(cmd), "vkEndCommandBuffer");
}
private unsafe void RecreateCommandBuffers()
{
fixed (VkCommandBuffer* pCmds = _commandBuffers)
{
Vk.vkFreeCommandBuffers(_ctx.Device, _commandPool, (uint)_commandBuffers.Length, pCmds);
}
VkCommandBufferAllocateInfo allocInfo;
allocInfo.sType = VkStructureType.CommandBufferAllocateInfo;
allocInfo.pNext = null;
allocInfo.commandPool = _commandPool;
allocInfo.level = VkCommandBufferLevel.Primary;
allocInfo.commandBufferCount = (uint)_commandBuffers.Length;
fixed (VkCommandBuffer* pCmds = _commandBuffers)
{
Vk.CheckResult(Vk.vkAllocateCommandBuffers(_ctx.Device, &allocInfo, pCmds), "vkAllocateCommandBuffers (recreate)");
}
}
public void RequestScreenshot(string outputPath)
{
_screenshotPath = outputPath;
_screenshotRequested = true;
}
public Task<byte[]> CaptureAsync(string outputPath)
{
_screenshotPath = outputPath;
_screenshotTcs = new TaskCompletionSource<byte[]>();
_screenshotRequested = true;
return _screenshotTcs.Task;
}
public void OnResize() => _resized = true;
public unsafe void Dispose()
{
Vk.vkQueueWaitIdle(_ctx.GraphicsQueue);
foreach (var mesh in _meshCache.Values)
{
mesh.vertex.Dispose();
mesh.index.Dispose();
}
_meshCache.Clear();
foreach (var ubo in _uboBuffers)
ubo?.Dispose();
if (_descriptorPool.Value != 0)
Vk.vkDestroyDescriptorPool(_ctx.Device, _descriptorPool, null);
fixed (VkCommandBuffer* pCmds = _commandBuffers)
{
if (_commandPool.Value != 0 && _commandBuffers.Length > 0)
Vk.vkFreeCommandBuffers(_ctx.Device, _commandPool, (uint)_commandBuffers.Length, pCmds);
}
if (_commandPool.Value != 0) Vk.vkDestroyCommandPool(_ctx.Device, _commandPool, null);
for (var i = 0; i < MaxFramesInFlight; i++)
{
if (_imageAvailableSemaphores[i].Value != 0) Vk.vkDestroySemaphore(_ctx.Device, _imageAvailableSemaphores[i], null);
if (_renderFinishedSemaphores[i].Value != 0) Vk.vkDestroySemaphore(_ctx.Device, _renderFinishedSemaphores[i], null);
if (_inFlightFences[i].Value != 0) Vk.vkDestroyFence(_ctx.Device, _inFlightFences[i], null);
}
}
}