diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs
index 120a7b4..7229682 100644
--- a/src/CortexEngine.App/Program.cs
+++ b/src/CortexEngine.App/Program.cs
@@ -35,6 +35,16 @@ class Program
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, new Vector3(0.5f)))
.Set(mesh);
+ var camera = world.Entity("Camera")
+ .Set(new Camera(
+ new Vector3(0.0f, 0.0f, -2.0f),
+ Vector3.Zero,
+ Vector3.UnitY,
+ MathF.PI / 4.0f,
+ 1280.0f / 720.0f,
+ 0.1f,
+ 100.0f));
+
var frames = 0;
var lastFpsTime = 0.0;
var lastWidth = window.Width;
diff --git a/src/Engine.Core/Components/Camera.cs b/src/Engine.Core/Components/Camera.cs
new file mode 100644
index 0000000..5571a43
--- /dev/null
+++ b/src/Engine.Core/Components/Camera.cs
@@ -0,0 +1,46 @@
+using System.Numerics;
+
+namespace Engine.Core.Components;
+
+///
+/// A perspective camera component for the ECS.
+/// Provides view and projection matrices for the renderer.
+///
+public record struct Camera
+{
+ public Vector3 Position;
+ public Vector3 Target;
+ public Vector3 Up;
+ public float FieldOfView;
+ public float AspectRatio;
+ public float NearPlane;
+ public float FarPlane;
+
+ public Camera(
+ Vector3 position,
+ Vector3 target,
+ Vector3 up,
+ float fieldOfView = MathF.PI / 4.0f,
+ float aspectRatio = 16.0f / 9.0f,
+ float nearPlane = 0.1f,
+ float farPlane = 100.0f)
+ {
+ Position = position;
+ Target = target;
+ Up = up;
+ FieldOfView = fieldOfView;
+ AspectRatio = aspectRatio;
+ NearPlane = nearPlane;
+ FarPlane = farPlane;
+ }
+
+ public Matrix4x4 GetViewMatrix()
+ {
+ return Matrix4x4.CreateLookAt(Position, Target, Up);
+ }
+
+ public Matrix4x4 GetProjectionMatrix()
+ {
+ return Matrix4x4.CreatePerspectiveFieldOfView(FieldOfView, AspectRatio, NearPlane, FarPlane);
+ }
+}
diff --git a/src/Engine.Graphics/MeshRenderer.cs b/src/Engine.Graphics/MeshRenderer.cs
index ee59e2b..c034084 100644
--- a/src/Engine.Graphics/MeshRenderer.cs
+++ b/src/Engine.Graphics/MeshRenderer.cs
@@ -141,17 +141,26 @@ public sealed unsafe class MeshRenderer : IDisposable
};
_context.Vk.BeginCommandBuffer(cmd, &beginInfo);
- var clearColor = new ClearValue(new ClearColorValue(0.0f, 0.0f, 0.0f, 1.0f));
+ var clearValues = new[]
+ {
+ new ClearValue(new ClearColorValue(0.0f, 0.0f, 0.0f, 1.0f)),
+ new ClearValue { DepthStencil = new ClearDepthStencilValue(1.0f, 0) }
+ };
+
var renderPassInfo = new RenderPassBeginInfo
{
SType = StructureType.RenderPassBeginInfo,
RenderPass = _swapchain.RenderPass,
Framebuffer = _swapchain.Framebuffers[imageIndex],
RenderArea = new Rect2D(new Offset2D(0, 0), _swapchain.Extent),
- ClearValueCount = 1,
- PClearValues = &clearColor
+ ClearValueCount = (uint)clearValues.Length
};
+ fixed (ClearValue* pClearValues = clearValues)
+ {
+ renderPassInfo.PClearValues = pClearValues;
+ }
+
_context.Vk.CmdBeginRenderPass(cmd, &renderPassInfo, SubpassContents.Inline);
_context.Vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, _pipeline.Handle);
@@ -160,7 +169,11 @@ public sealed unsafe class MeshRenderer : IDisposable
_context.Vk.CmdSetViewport(cmd, 0, 1, &viewport);
_context.Vk.CmdSetScissor(cmd, 0, 1, &scissor);
+ var camera = GetCamera(world);
+ var view = camera.GetViewMatrix();
+ var proj = camera.GetProjectionMatrix();
var drawCmd = cmd;
+
world.Each((Entity e, ref Mesh mesh, ref Transform transform) =>
{
if (!_buffers.TryGetValue(e, out var buffers))
@@ -172,6 +185,11 @@ public sealed unsafe class MeshRenderer : IDisposable
var bytes = BuildMeshVertices(mesh, transform);
buffers.VertexBuffer.Update(bytes);
+ var model = transform.GetMatrix();
+ var mvp = Matrix4x4.Multiply(Matrix4x4.Multiply(model, view), proj);
+ var mvpT = Matrix4x4.Transpose(mvp);
+ _context.Vk.CmdPushConstants(drawCmd, _pipeline.Layout, ShaderStageFlags.VertexBit, 0, 64, &mvpT);
+
var vertexBuffer = buffers.VertexBuffer.Buffer;
var offset = 0ul;
_context.Vk.CmdBindVertexBuffers(drawCmd, 0, 1, &vertexBuffer, &offset);
@@ -265,6 +283,27 @@ public sealed unsafe class MeshRenderer : IDisposable
return bytes;
}
+ private Camera GetCamera(World world)
+ {
+ var camera = new Camera(
+ new Vector3(0.0f, 0.0f, -2.0f),
+ Vector3.Zero,
+ Vector3.UnitY,
+ MathF.PI / 4.0f,
+ (float)_swapchain.Extent.Width / _swapchain.Extent.Height,
+ 0.1f,
+ 100.0f);
+
+ world.Each((Entity e, ref Camera cam) =>
+ {
+ camera = cam;
+ });
+
+ // Always keep the aspect ratio in sync with the swapchain.
+ camera.AspectRatio = (float)_swapchain.Extent.Width / _swapchain.Extent.Height;
+ return camera;
+ }
+
public void Dispose()
{
_context.Vk.DeviceWaitIdle(_context.Device);
diff --git a/src/Engine.Graphics/Shaders/vertex.spv b/src/Engine.Graphics/Shaders/vertex.spv
index 5a0dd52..5219475 100644
Binary files a/src/Engine.Graphics/Shaders/vertex.spv and b/src/Engine.Graphics/Shaders/vertex.spv differ
diff --git a/src/Engine.Graphics/Shaders/vertex.vert b/src/Engine.Graphics/Shaders/vertex.vert
index 0031f34..6adc1ff 100644
--- a/src/Engine.Graphics/Shaders/vertex.vert
+++ b/src/Engine.Graphics/Shaders/vertex.vert
@@ -5,8 +5,13 @@ layout(location = 1) in vec3 inColor;
layout(location = 0) out vec3 fragColor;
+layout(push_constant) uniform PushConstants
+{
+ mat4 mvp;
+} push;
+
void main()
{
- gl_Position = vec4(inPosition, 1.0);
+ gl_Position = push.mvp * vec4(inPosition, 1.0);
fragColor = inColor;
}
diff --git a/src/Engine.Graphics/Swapchain.cs b/src/Engine.Graphics/Swapchain.cs
index 6f4fc3d..b16111e 100644
--- a/src/Engine.Graphics/Swapchain.cs
+++ b/src/Engine.Graphics/Swapchain.cs
@@ -17,6 +17,10 @@ public sealed unsafe class Swapchain : IDisposable
private Image[] _images = null!;
private ImageView[] _imageViews = null!;
private Framebuffer[] _framebuffers = null!;
+ private Image _depthImage;
+ private DeviceMemory _depthMemory;
+ private ImageView _depthImageView;
+ private Format _depthFormat;
private SurfaceFormatKHR _surfaceFormat;
private PresentModeKHR _presentMode;
private Extent2D _extent;
@@ -31,6 +35,7 @@ public sealed unsafe class Swapchain : IDisposable
{
_context = context;
_surfaceFormat = ChooseSurfaceFormat();
+ _depthFormat = FindDepthFormat();
CreateRenderPass();
Recreate(1280, 720);
}
@@ -77,6 +82,8 @@ public sealed unsafe class Swapchain : IDisposable
_imageViews = new ImageView[_images.Length];
_framebuffers = new Framebuffer[_images.Length];
+ CreateDepthResources();
+
for (var i = 0; i < _images.Length; i++)
{
_imageViews[i] = CreateImageView(_images[i], _surfaceFormat.Format);
@@ -107,6 +114,113 @@ public sealed unsafe class Swapchain : IDisposable
return images;
}
+ private Format FindDepthFormat()
+ {
+ var candidates = new[] { Format.D32Sfloat, Format.D32SfloatS8Uint, Format.D24UnormS8Uint };
+ foreach (var format in candidates)
+ {
+ FormatProperties props;
+ _context.Vk.GetPhysicalDeviceFormatProperties(_context.PhysicalDevice, format, &props);
+ if ((props.OptimalTilingFeatures & FormatFeatureFlags.DepthStencilAttachmentBit) != 0)
+ return format;
+ }
+ throw new InvalidOperationException("No supported depth format found.");
+ }
+
+ private uint FindMemoryType(uint typeFilter, MemoryPropertyFlags properties)
+ {
+ PhysicalDeviceMemoryProperties memoryProperties;
+ _context.Vk.GetPhysicalDeviceMemoryProperties(_context.PhysicalDevice, &memoryProperties);
+ for (var i = 0; i < memoryProperties.MemoryTypeCount; i++)
+ {
+ if ((typeFilter & (1u << i)) != 0 &&
+ (memoryProperties.MemoryTypes[i].PropertyFlags & properties) == properties)
+ {
+ return (uint)i;
+ }
+ }
+ throw new InvalidOperationException("Failed to find suitable memory type.");
+ }
+
+ private void CreateDepthResources()
+ {
+ CreateDepthImage();
+ CreateDepthImageView();
+ }
+
+ private void CreateDepthImage()
+ {
+ var createInfo = new ImageCreateInfo
+ {
+ SType = StructureType.ImageCreateInfo,
+ ImageType = ImageType.Type2D,
+ Extent = new Extent3D(_extent.Width, _extent.Height, 1),
+ MipLevels = 1,
+ ArrayLayers = 1,
+ Format = _depthFormat,
+ Tiling = ImageTiling.Optimal,
+ InitialLayout = ImageLayout.Undefined,
+ Usage = ImageUsageFlags.DepthStencilAttachmentBit,
+ Samples = SampleCountFlags.Count1Bit,
+ SharingMode = SharingMode.Exclusive
+ };
+
+ Image image;
+ var result = _context.Vk.CreateImage(_context.Device, &createInfo, null, &image);
+ if (result != Result.Success)
+ throw new InvalidOperationException($"vkCreateImage failed: {result}");
+ _depthImage = image;
+
+ MemoryRequirements memRequirements;
+ _context.Vk.GetImageMemoryRequirements(_context.Device, image, &memRequirements);
+
+ var memoryTypeIndex = FindMemoryType(memRequirements.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit);
+ var allocInfo = new MemoryAllocateInfo
+ {
+ SType = StructureType.MemoryAllocateInfo,
+ AllocationSize = memRequirements.Size,
+ MemoryTypeIndex = memoryTypeIndex
+ };
+
+ DeviceMemory memory;
+ result = _context.Vk.AllocateMemory(_context.Device, &allocInfo, null, &memory);
+ if (result != Result.Success)
+ throw new InvalidOperationException($"vkAllocateMemory failed: {result}");
+ _depthMemory = memory;
+
+ result = _context.Vk.BindImageMemory(_context.Device, image, memory, 0);
+ if (result != Result.Success)
+ throw new InvalidOperationException($"vkBindImageMemory failed: {result}");
+ }
+
+ private void CreateDepthImageView()
+ {
+ var createInfo = new ImageViewCreateInfo
+ {
+ SType = StructureType.ImageViewCreateInfo,
+ Image = _depthImage,
+ ViewType = ImageViewType.Type2D,
+ Format = _depthFormat,
+ SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.DepthBit, 0, 1, 0, 1)
+ };
+
+ ImageView imageView;
+ var result = _context.Vk.CreateImageView(_context.Device, &createInfo, null, &imageView);
+ if (result != Result.Success)
+ throw new InvalidOperationException($"vkCreateImageView failed: {result}");
+ _depthImageView = imageView;
+ }
+
+ private void CleanupDepthResources()
+ {
+ if (_depthImageView.Handle != 0)
+ _context.Vk.DestroyImageView(_context.Device, _depthImageView, null);
+ if (_depthImage.Handle != 0)
+ _context.Vk.DestroyImage(_context.Device, _depthImage, null);
+ if (_depthMemory.Handle != 0)
+ _context.Vk.FreeMemory(_context.Device, _depthMemory, null);
+ }
+
private void CreateRenderPass()
{
var colorAttachment = new AttachmentDescription
@@ -121,17 +235,38 @@ public sealed unsafe class Swapchain : IDisposable
FinalLayout = ImageLayout.PresentSrcKhr
};
+ var depthAttachment = new AttachmentDescription
+ {
+ Format = _depthFormat,
+ Samples = SampleCountFlags.Count1Bit,
+ LoadOp = AttachmentLoadOp.Clear,
+ StoreOp = AttachmentStoreOp.DontCare,
+ StencilLoadOp = AttachmentLoadOp.DontCare,
+ StencilStoreOp = AttachmentStoreOp.DontCare,
+ InitialLayout = ImageLayout.Undefined,
+ FinalLayout = ImageLayout.DepthStencilAttachmentOptimal
+ };
+
+ var attachments = new[] { colorAttachment, depthAttachment };
+
var colorAttachmentRef = new AttachmentReference
{
Attachment = 0,
Layout = ImageLayout.ColorAttachmentOptimal
};
+ var depthAttachmentRef = new AttachmentReference
+ {
+ Attachment = 1,
+ Layout = ImageLayout.DepthStencilAttachmentOptimal
+ };
+
var subpass = new SubpassDescription
{
PipelineBindPoint = PipelineBindPoint.Graphics,
ColorAttachmentCount = 1,
- PColorAttachments = &colorAttachmentRef
+ PColorAttachments = &colorAttachmentRef,
+ PDepthStencilAttachment = &depthAttachmentRef
};
var dependency = new SubpassDependency
@@ -144,22 +279,25 @@ public sealed unsafe class Swapchain : IDisposable
DstAccessMask = AccessFlags.ColorAttachmentWriteBit
};
- var createInfo = new RenderPassCreateInfo
+ fixed (AttachmentDescription* pAttachments = attachments)
{
- SType = StructureType.RenderPassCreateInfo,
- AttachmentCount = 1,
- PAttachments = &colorAttachment,
- SubpassCount = 1,
- PSubpasses = &subpass,
- DependencyCount = 1,
- PDependencies = &dependency
- };
+ var createInfo = new RenderPassCreateInfo
+ {
+ SType = StructureType.RenderPassCreateInfo,
+ AttachmentCount = (uint)attachments.Length,
+ PAttachments = pAttachments,
+ SubpassCount = 1,
+ PSubpasses = &subpass,
+ DependencyCount = 1,
+ PDependencies = &dependency
+ };
- RenderPass renderPass;
- var result = _context.Vk.CreateRenderPass(_context.Device, &createInfo, null, &renderPass);
- if (result != Result.Success)
- throw new InvalidOperationException($"vkCreateRenderPass failed: {result}");
- _renderPass = renderPass;
+ RenderPass renderPass;
+ var result = _context.Vk.CreateRenderPass(_context.Device, &createInfo, null, &renderPass);
+ if (result != Result.Success)
+ throw new InvalidOperationException($"vkCreateRenderPass failed: {result}");
+ _renderPass = renderPass;
+ }
}
private ImageView CreateImageView(Image image, Format format)
@@ -183,22 +321,26 @@ public sealed unsafe class Swapchain : IDisposable
private Framebuffer CreateFramebuffer(ImageView imageView)
{
- var createInfo = new FramebufferCreateInfo
+ var attachments = new[] { imageView, _depthImageView };
+ fixed (ImageView* pAttachments = attachments)
{
- SType = StructureType.FramebufferCreateInfo,
- RenderPass = _renderPass,
- AttachmentCount = 1,
- PAttachments = &imageView,
- Width = _extent.Width,
- Height = _extent.Height,
- Layers = 1
- };
+ var createInfo = new FramebufferCreateInfo
+ {
+ SType = StructureType.FramebufferCreateInfo,
+ RenderPass = _renderPass,
+ AttachmentCount = (uint)attachments.Length,
+ PAttachments = pAttachments,
+ Width = _extent.Width,
+ Height = _extent.Height,
+ Layers = 1
+ };
- Framebuffer framebuffer;
- var result = _context.Vk.CreateFramebuffer(_context.Device, &createInfo, null, &framebuffer);
- if (result != Result.Success)
- throw new InvalidOperationException($"vkCreateFramebuffer failed: {result}");
- return framebuffer;
+ Framebuffer framebuffer;
+ var result = _context.Vk.CreateFramebuffer(_context.Device, &createInfo, null, &framebuffer);
+ if (result != Result.Success)
+ throw new InvalidOperationException($"vkCreateFramebuffer failed: {result}");
+ return framebuffer;
+ }
}
private SurfaceFormatKHR ChooseSurfaceFormat()
@@ -275,6 +417,8 @@ public sealed unsafe class Swapchain : IDisposable
}
}
+ CleanupDepthResources();
+
if (_imageViews != null)
{
foreach (var view in _imageViews)
diff --git a/src/Engine.Graphics/VulkanPipeline.cs b/src/Engine.Graphics/VulkanPipeline.cs
index b4dfb95..59e8e6f 100644
--- a/src/Engine.Graphics/VulkanPipeline.cs
+++ b/src/Engine.Graphics/VulkanPipeline.cs
@@ -5,7 +5,7 @@ using Silk.NET.Vulkan;
namespace Engine.Graphics;
///
-/// Simple graphics pipeline for indexed meshes with vec3 position + vec3 color.
+/// Graphics pipeline for indexed meshes with push-constant MVP and depth testing.
/// Uses Silk.NET.Vulkan.
///
public sealed unsafe class VulkanPipeline : IDisposable
@@ -55,11 +55,19 @@ public sealed unsafe class VulkanPipeline : IDisposable
private PipelineLayout CreatePipelineLayout()
{
+ var pushConstantRange = new PushConstantRange
+ {
+ StageFlags = ShaderStageFlags.VertexBit,
+ Offset = 0,
+ Size = (uint)(16 * sizeof(float))
+ };
+
var createInfo = new PipelineLayoutCreateInfo
{
SType = StructureType.PipelineLayoutCreateInfo,
SetLayoutCount = 0,
- PushConstantRangeCount = 0
+ PushConstantRangeCount = 1,
+ PPushConstantRanges = &pushConstantRange
};
PipelineLayout layout;
@@ -170,6 +178,18 @@ public sealed unsafe class VulkanPipeline : IDisposable
PAttachments = &colorBlendAttachment
};
+ var depthStencil = new PipelineDepthStencilStateCreateInfo
+ {
+ SType = StructureType.PipelineDepthStencilStateCreateInfo,
+ DepthTestEnable = true,
+ DepthWriteEnable = true,
+ DepthCompareOp = CompareOp.Less,
+ DepthBoundsTestEnable = false,
+ StencilTestEnable = false,
+ Back = new StencilOpState(),
+ Front = new StencilOpState()
+ };
+
var dynamicStates = new[] { DynamicState.Viewport, DynamicState.Scissor };
PipelineDynamicStateCreateInfo dynamicState;
fixed (DynamicState* pDynamic = dynamicStates)
@@ -195,6 +215,7 @@ public sealed unsafe class VulkanPipeline : IDisposable
PViewportState = &viewportState,
PRasterizationState = &rasterizer,
PMultisampleState = &multisampling,
+ PDepthStencilState = &depthStencil,
PColorBlendState = &colorBlending,
PDynamicState = &dynamicState,
Layout = Layout,