feat: Step 4 load .obj and glTF models into ECS

- Add Vertex struct and Mesh ECS component (vertices + indices).
- Add Vulkan IndexBuffer for indexed draws.
- Add MeshRenderer that draws Mesh + Transform entities with vkCmdDrawIndexed.
- Add minimal .obj loader (positions, faces) and glTF/glTF-binary loader via SharpGLTF.Core.
- Rewrite shaders to 3D position + color; recompile to SPIR-V.
- Update VulkanPipeline for new vertex format.
- Ship a sample Content/cube.obj and wire Program.cs to load it.
- Remove TriangleRenderer (replaced by MeshRenderer).
This commit is contained in:
emil28092005
2026-06-16 19:15:20 +03:00
parent 608c3c24a8
commit 05703f820f
14 changed files with 475 additions and 73 deletions
@@ -24,4 +24,11 @@
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\Content\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>Content\%(RecursiveDir)%(Filename)%(Extension)</Link>
</Content>
</ItemGroup>
</Project>
+39 -13
View File
@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
using Engine.Graphics;
using Engine.Graphics.Loaders;
using Flecs.NET.Core;
namespace CortexEngine.App;
@@ -11,20 +13,27 @@ class Program
{
static void Main(string[] args)
{
Console.WriteLine("Cortex Engine Step 3 — Starting up...");
Console.WriteLine("Cortex Engine Step 4 — Starting up...");
try
{
using var world = World.Create();
using var window = new Sdl3Window("Cortex Engine — Step 3", 1280, 720);
using var window = new Sdl3Window("Cortex Engine — Step 4", 1280, 720);
var timing = new Timing();
var input = new InputMapping();
using var vulkan = new VulkanContext(window, enableValidation: false);
using var swapchain = new Swapchain(vulkan);
using var renderer = new TriangleRenderer(vulkan, swapchain);
using var renderer = new MeshRenderer(vulkan, swapchain);
var triangle = world.Entity("Triangle")
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f)));
var modelPath = FindModelPath(args);
var mesh = modelPath.EndsWith(".gltf", StringComparison.OrdinalIgnoreCase)
|| modelPath.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)
? GltfLoader.Load(modelPath, new Vector3(0.7f, 0.6f, 0.5f))
: ObjLoader.Load(modelPath, new Vector3(0.7f, 0.6f, 0.5f));
var model = world.Entity("Model")
.Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, new Vector3(0.5f)))
.Set(mesh);
var frames = 0;
var lastFpsTime = 0.0;
@@ -46,14 +55,10 @@ class Program
swapchain.Recreate(lastWidth, lastHeight);
}
// Animate the entity transform.
ref var transform = ref triangle.Ensure<Transform>();
transform.Position = new Vector3(
MathF.Sin((float)timing.TotalTime) * 0.5f,
0.0f,
0.0f);
transform.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, (float)timing.TotalTime);
transform.Scale = Vector3.One * (1.0f + MathF.Sin((float)timing.TotalTime * 2.0f) * 0.2f);
// Slowly rotate the model so we can see it in 3D.
ref var transform = ref model.Ensure<Transform>();
transform.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)timing.TotalTime * 0.5f)
* Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)timing.TotalTime * 0.25f);
renderer.RenderWorld(world);
@@ -74,4 +79,25 @@ class Program
Environment.Exit(1);
}
}
private static string FindModelPath(string[] args)
{
if (args.Length > 0 && File.Exists(args[0]))
return args[0];
var candidates = new[]
{
"Content/cube.obj",
"Models/cube.obj",
"cube.obj"
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
return candidate;
}
throw new FileNotFoundException("No model file found. Pass a .obj/.gltf/.glb path as argument or place Content/cube.obj next to the executable.");
}
}
+19
View File
@@ -0,0 +1,19 @@
using Engine.Core;
namespace Engine.Core.Components;
/// <summary>
/// A mesh component for the ECS.
/// Keeps CPU-side vertex/index data that the renderer uploads to GPU buffers.
/// </summary>
public record struct Mesh
{
public Vertex[] Vertices;
public uint[] Indices;
public Mesh(Vertex[] vertices, uint[] indices)
{
Vertices = vertices;
Indices = indices;
}
}
+19
View File
@@ -0,0 +1,19 @@
using System.Numerics;
namespace Engine.Core;
/// <summary>
/// A simple 3D vertex with position and color.
/// Layout matches the Vulkan vertex input description.
/// </summary>
public struct Vertex
{
public Vector3 Position;
public Vector3 Color;
public Vertex(Vector3 position, Vector3 color)
{
Position = position;
Color = color;
}
}
@@ -22,6 +22,7 @@
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.21.0" />
<PackageReference Include="Flecs.NET.Debug" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Flecs.NET.Release" Version="4.0.4-build.546" Condition="'$(Configuration)' == 'Release'" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.6" />
</ItemGroup>
<ItemGroup>
+113
View File
@@ -0,0 +1,113 @@
using System;
using Silk.NET.Core;
using Silk.NET.Vulkan;
namespace Engine.Graphics;
/// <summary>
/// GPU index buffer for indexed draws.
/// Uses 32-bit indices.
/// </summary>
public sealed unsafe class IndexBuffer : IDisposable
{
private readonly VulkanContext _context;
public Silk.NET.Vulkan.Buffer Buffer { get; }
public DeviceMemory Memory { get; }
public ulong Size { get; }
public uint Count { get; }
public IndexBuffer(VulkanContext context, ReadOnlySpan<byte> data, uint count)
{
_context = context;
Size = (ulong)data.Length;
Count = count;
Buffer = CreateBuffer(Size, BufferUsageFlags.IndexBufferBit);
var memoryRequirements = GetMemoryRequirements(Buffer);
Memory = AllocateMemory(memoryRequirements, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit);
var result = _context.Vk.BindBufferMemory(_context.Device, Buffer, Memory, 0);
if (result != Result.Success)
throw new InvalidOperationException($"vkBindBufferMemory failed: {result}");
CopyData(data);
}
private Silk.NET.Vulkan.Buffer CreateBuffer(ulong size, BufferUsageFlags usage)
{
var createInfo = new BufferCreateInfo
{
SType = StructureType.BufferCreateInfo,
Size = size,
Usage = usage,
SharingMode = SharingMode.Exclusive
};
Silk.NET.Vulkan.Buffer buffer;
var result = _context.Vk.CreateBuffer(_context.Device, &createInfo, null, &buffer);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateBuffer failed: {result}");
return buffer;
}
private MemoryRequirements GetMemoryRequirements(Silk.NET.Vulkan.Buffer buffer)
{
MemoryRequirements requirements;
_context.Vk.GetBufferMemoryRequirements(_context.Device, buffer, &requirements);
return requirements;
}
private DeviceMemory AllocateMemory(MemoryRequirements requirements, MemoryPropertyFlags properties)
{
var memoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, properties);
var allocateInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = memoryTypeIndex
};
DeviceMemory memory;
var result = _context.Vk.AllocateMemory(_context.Device, &allocateInfo, null, &memory);
if (result != Result.Success)
throw new InvalidOperationException($"vkAllocateMemory failed: {result}");
return memory;
}
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 CopyData(ReadOnlySpan<byte> data)
{
void* mappedData;
var result = _context.Vk.MapMemory(_context.Device, Memory, 0, Size, MemoryMapFlags.None, &mappedData);
if (result != Result.Success)
throw new InvalidOperationException($"vkMapMemory failed: {result}");
fixed (byte* src = data)
{
global::System.Buffer.MemoryCopy(src, mappedData, (long)Size, data.Length);
}
_context.Vk.UnmapMemory(_context.Device, Memory);
}
public void Dispose()
{
_context.Vk.DeviceWaitIdle(_context.Device);
_context.Vk.DestroyBuffer(_context.Device, Buffer, null);
_context.Vk.FreeMemory(_context.Device, Memory, null);
}
}
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
using SharpGLTF.Schema2;
namespace Engine.Graphics.Loaders;
/// <summary>
/// glTF/glTF binary loader using SharpGLTF.Core.
/// Loads the first primitive of the first mesh and converts it to a colored Mesh component.
/// </summary>
public static class GltfLoader
{
public static Engine.Core.Components.Mesh Load(string path, Vector3? defaultColor = null)
{
var color = defaultColor ?? new Vector3(0.7f, 0.7f, 0.7f);
var model = ModelRoot.Load(path);
if (model.LogicalMeshes.Count == 0)
throw new InvalidOperationException($"glTF file has no meshes: {path}");
var mesh = model.LogicalMeshes[0];
if (mesh.Primitives.Count == 0)
throw new InvalidOperationException($"glTF mesh has no primitives: {path}");
var primitive = mesh.Primitives[0];
if (!primitive.VertexAccessors.TryGetValue("POSITION", out var positionAccessor))
throw new InvalidOperationException($"glTF primitive has no POSITION accessor: {path}");
var positions = positionAccessor.AsVector3Array();
var vertices = new Vertex[positions.Count];
for (var i = 0; i < positions.Count; i++)
{
vertices[i] = new Vertex(positions[i], color);
}
uint[] indices;
if (primitive.IndexAccessor != null)
{
var idx = primitive.IndexAccessor.AsIndexArray();
indices = new uint[idx.Count];
for (var i = 0; i < idx.Count; i++)
indices[i] = idx[i];
}
else
{
// Non-indexed primitive
indices = new uint[positions.Count];
for (var i = 0; i < positions.Count; i++)
indices[i] = (uint)i;
}
return new Engine.Core.Components.Mesh(vertices, indices);
}
}
+76
View File
@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics.Loaders;
/// <summary>
/// Minimal .obj loader.
/// Supports vertices (v) and faces (f). Ignores normals/UVs for now.
/// Produces a colored Mesh component.
/// </summary>
public static class ObjLoader
{
public static Mesh Load(string path, Vector3? defaultColor = null)
{
var color = defaultColor ?? new Vector3(0.7f, 0.7f, 0.7f);
var positions = new List<Vector3>();
var indices = new List<uint>();
foreach (var rawLine in File.ReadLines(path))
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line) || line.StartsWith("#"))
continue;
var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
continue;
switch (parts[0])
{
case "v" when parts.Length >= 4:
positions.Add(new Vector3(
float.Parse(parts[1]),
float.Parse(parts[2]),
float.Parse(parts[3])));
break;
case "f" when parts.Length >= 4:
// Triangulate the face as a fan. Only the position index is used.
var baseIndex = ParseFaceIndex(parts[1]);
for (var i = 2; i < parts.Length - 1; i++)
{
indices.Add(baseIndex);
indices.Add(ParseFaceIndex(parts[i]));
indices.Add(ParseFaceIndex(parts[i + 1]));
}
break;
}
}
if (positions.Count == 0)
throw new InvalidOperationException($"OBJ file has no vertices: {path}");
var vertices = new Vertex[positions.Count];
for (var i = 0; i < positions.Count; i++)
{
vertices[i] = new Vertex(positions[i], color);
}
return new Mesh(vertices, indices.ToArray());
}
private static uint ParseFaceIndex(string part)
{
// Formats: v, v/vt, v/vt/vn, v//vn
var slashIndex = part.IndexOf('/');
var indexStr = slashIndex == -1 ? part : part.Substring(0, slashIndex);
var index = int.Parse(indexStr);
return (uint)(index - 1); // OBJ indices are 1-based
}
}
@@ -1,22 +1,24 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using Flecs.NET.Core;
using Silk.NET.Core;
using Silk.NET.Vulkan;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics;
/// <summary>
/// Renders a colored triangle using a vertex buffer and a simple graphics pipeline.
/// Uses Silk.NET.Vulkan and reads entity transforms from the ECS world.
/// Renders indexed meshes attached to ECS entities.
/// Uses Silk.NET.Vulkan and reads Mesh + Transform components from the ECS world.
/// </summary>
public sealed unsafe class TriangleRenderer : IDisposable
public sealed unsafe class MeshRenderer : IDisposable
{
private readonly VulkanContext _context;
private readonly Swapchain _swapchain;
private readonly VulkanPipeline _pipeline;
private readonly VertexBuffer _vertexBuffer;
private readonly Dictionary<Entity, MeshBuffers> _buffers = new();
private CommandPool _commandPool;
private CommandBuffer[] _commandBuffers = null!;
private Silk.NET.Vulkan.Semaphore[] _imageAvailableSemaphores = null!;
@@ -24,37 +26,35 @@ public sealed unsafe class TriangleRenderer : IDisposable
private Silk.NET.Vulkan.Fence[] _inFlightFences = null!;
private int _currentFrame;
public TriangleRenderer(VulkanContext context, Swapchain swapchain)
private sealed class MeshBuffers : IDisposable
{
public VertexBuffer VertexBuffer;
public IndexBuffer IndexBuffer;
public MeshBuffers(VertexBuffer vertexBuffer, IndexBuffer indexBuffer)
{
VertexBuffer = vertexBuffer;
IndexBuffer = indexBuffer;
}
public void Dispose()
{
VertexBuffer.Dispose();
IndexBuffer.Dispose();
}
}
public MeshRenderer(VulkanContext context, Swapchain swapchain)
{
_context = context;
_swapchain = swapchain;
_pipeline = new VulkanPipeline(context, swapchain);
_vertexBuffer = CreateTriangleBuffer();
CreateCommandPool();
CreateCommandBuffers();
CreateSyncObjects();
}
private VertexBuffer CreateTriangleBuffer()
{
var vertices = new[]
{
0.0f, -0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f, 0.0f, 0.0f, 1.0f
};
var bytes = new byte[vertices.Length * sizeof(float)];
fixed (byte* p = bytes)
fixed (float* v = vertices)
{
global::System.Buffer.MemoryCopy(v, p, bytes.Length, vertices.Length * sizeof(float));
}
return new VertexBuffer(_context, bytes);
}
private void CreateCommandPool()
{
var createInfo = new CommandPoolCreateInfo
@@ -160,16 +160,23 @@ public sealed unsafe class TriangleRenderer : IDisposable
_context.Vk.CmdSetViewport(cmd, 0, 1, &viewport);
_context.Vk.CmdSetScissor(cmd, 0, 1, &scissor);
var vertexBuffer = _vertexBuffer.Buffer;
var offset = 0ul;
_context.Vk.CmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, &offset);
var drawCmd = cmd;
world.Each((Entity e, ref Transform transform) =>
world.Each((Entity e, ref Mesh mesh, ref Transform transform) =>
{
var bytes = BuildTriangleVertices(transform);
_vertexBuffer.Update(bytes);
_context.Vk.CmdDraw(drawCmd, 3, 1, 0, 0);
if (!_buffers.TryGetValue(e, out var buffers))
{
buffers = CreateMeshBuffers(mesh);
_buffers[e] = buffers;
}
var bytes = BuildMeshVertices(mesh, transform);
buffers.VertexBuffer.Update(bytes);
var vertexBuffer = buffers.VertexBuffer.Buffer;
var offset = 0ul;
_context.Vk.CmdBindVertexBuffers(drawCmd, 0, 1, &vertexBuffer, &offset);
_context.Vk.CmdBindIndexBuffer(drawCmd, buffers.IndexBuffer.Buffer, 0, IndexType.Uint32);
_context.Vk.CmdDrawIndexed(drawCmd, buffers.IndexBuffer.Count, 1, 0, 0, 0);
});
_context.Vk.CmdEndRenderPass(cmd);
@@ -207,34 +214,52 @@ public sealed unsafe class TriangleRenderer : IDisposable
_currentFrame++;
}
private byte[] BuildTriangleVertices(Transform transform)
private MeshBuffers CreateMeshBuffers(Mesh mesh)
{
var vertexBytes = new byte[mesh.Vertices.Length * 6 * sizeof(float)];
fixed (byte* p = vertexBytes)
{
var dst = (float*)p;
for (var i = 0; i < mesh.Vertices.Length; i++)
{
var v = mesh.Vertices[i];
dst[i * 6 + 0] = v.Position.X;
dst[i * 6 + 1] = v.Position.Y;
dst[i * 6 + 2] = v.Position.Z;
dst[i * 6 + 3] = v.Color.X;
dst[i * 6 + 4] = v.Color.Y;
dst[i * 6 + 5] = v.Color.Z;
}
}
var indexBytes = new byte[mesh.Indices.Length * sizeof(uint)];
fixed (byte* p = indexBytes)
fixed (uint* src = mesh.Indices)
{
global::System.Buffer.MemoryCopy(src, p, indexBytes.Length, mesh.Indices.Length * sizeof(uint));
}
return new MeshBuffers(
new VertexBuffer(_context, vertexBytes),
new IndexBuffer(_context, indexBytes, (uint)mesh.Indices.Length));
}
private byte[] BuildMeshVertices(Mesh mesh, Transform transform)
{
var matrix = transform.GetMatrix();
var positions = new Vector3[]
{
new Vector3(0.0f, -0.5f, 0.0f),
new Vector3(0.5f, 0.5f, 0.0f),
new Vector3(-0.5f, 0.5f, 0.0f)
};
var colors = new[]
{
new Vector3(1.0f, 0.0f, 0.0f),
new Vector3(0.0f, 1.0f, 0.0f),
new Vector3(0.0f, 0.0f, 1.0f)
};
var bytes = new byte[3 * 5 * sizeof(float)];
var bytes = new byte[mesh.Vertices.Length * 6 * sizeof(float)];
fixed (byte* p = bytes)
{
var dst = (float*)p;
for (var i = 0; i < 3; i++)
for (var i = 0; i < mesh.Vertices.Length; i++)
{
var transformed = Vector3.Transform(positions[i], matrix);
dst[i * 5 + 0] = transformed.X;
dst[i * 5 + 1] = transformed.Y;
dst[i * 5 + 2] = colors[i].X;
dst[i * 5 + 3] = colors[i].Y;
dst[i * 5 + 4] = colors[i].Z;
var transformed = Vector3.Transform(mesh.Vertices[i].Position, matrix);
dst[i * 6 + 0] = transformed.X;
dst[i * 6 + 1] = transformed.Y;
dst[i * 6 + 2] = transformed.Z;
dst[i * 6 + 3] = mesh.Vertices[i].Color.X;
dst[i * 6 + 4] = mesh.Vertices[i].Color.Y;
dst[i * 6 + 5] = mesh.Vertices[i].Color.Z;
}
}
return bytes;
@@ -244,6 +269,10 @@ public sealed unsafe class TriangleRenderer : IDisposable
{
_context.Vk.DeviceWaitIdle(_context.Device);
foreach (var buffers in _buffers.Values)
buffers.Dispose();
_buffers.Clear();
for (var i = 0; i < 2; i++)
{
_context.Vk.DestroySemaphore(_context.Device, _renderFinishedSemaphores[i], null);
@@ -253,7 +282,6 @@ public sealed unsafe class TriangleRenderer : IDisposable
_context.Vk.DestroyCommandPool(_context.Device, _commandPool, null);
_vertexBuffer.Dispose();
_pipeline.Dispose();
}
}
+10
View File
@@ -0,0 +1,10 @@
#version 450
layout(location = 0) in vec3 fragColor;
layout(location = 0) out vec4 outColor;
void main()
{
outColor = vec4(fragColor, 1.0);
}
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
#version 450
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 0) out vec3 fragColor;
void main()
{
gl_Position = vec4(inPosition, 1.0);
fragColor = inColor;
}
+4 -4
View File
@@ -5,7 +5,7 @@ using Silk.NET.Vulkan;
namespace Engine.Graphics;
/// <summary>
/// Simple graphics pipeline for a triangle with vec2 position + vec3 color.
/// Simple graphics pipeline for indexed meshes with vec3 position + vec3 color.
/// Uses Silk.NET.Vulkan.
/// </summary>
public sealed unsafe class VulkanPipeline : IDisposable
@@ -93,7 +93,7 @@ public sealed unsafe class VulkanPipeline : IDisposable
var bindingDescription = new VertexInputBindingDescription
{
Binding = 0,
Stride = (uint)(5 * sizeof(float)),
Stride = (uint)(6 * sizeof(float)),
InputRate = VertexInputRate.Vertex
};
@@ -103,7 +103,7 @@ public sealed unsafe class VulkanPipeline : IDisposable
{
Binding = 0,
Location = 0,
Format = Format.R32G32Sfloat,
Format = Format.R32G32B32Sfloat,
Offset = 0
},
new VertexInputAttributeDescription
@@ -111,7 +111,7 @@ public sealed unsafe class VulkanPipeline : IDisposable
Binding = 0,
Location = 1,
Format = Format.R32G32B32Sfloat,
Offset = (uint)(2 * sizeof(float))
Offset = (uint)(3 * sizeof(float))
}
};