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,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<DefineConstants>DEV_MODE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'ReleaseAOT'">
<DefineConstants>RELEASE_AOT</DefineConstants>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<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' OR '$(Configuration)' == 'ReleaseAOT'" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SharpGLTF.Core" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
+10
View File
@@ -0,0 +1,10 @@
using Engine.Core;
namespace Engine.Graphics;
public interface IRenderContext : IDisposable
{
IWindow Window { get; }
IRenderer CreateRenderer();
void Resize(int width, int height);
}
+12
View File
@@ -0,0 +1,12 @@
using Engine.Core;
using Flecs.NET.Core;
namespace Engine.Graphics;
public interface IRenderer : IDisposable
{
void RenderWorld(World world);
void RequestScreenshot(string outputPath);
bool IsScreenshotRequested { get; }
IScreenshotProvider ScreenshotProvider { get; }
}
+67
View File
@@ -0,0 +1,67 @@
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics.Loaders;
public static class GltfLoader
{
public static Mesh Load(string path, Vector3? color = null)
{
var tint = color ?? new Vector3(0.7f, 0.6f, 0.5f);
var modelRoot = SharpGLTF.Schema2.ModelRoot.Load(path);
var vertices = new List<Vertex>();
var indices = new List<uint>();
foreach (var scene in modelRoot.LogicalScenes)
{
foreach (var node in scene.VisualChildren)
{
var mesh = node.Mesh;
if (mesh == null) continue;
foreach (var primitive in mesh.Primitives)
{
var posAccess = primitive.GetVertexAccessor("POSITION");
var normAccess = primitive.GetVertexAccessor("NORMAL");
if (posAccess == null) continue;
var indexAccess = primitive.IndexAccessor;
var baseVertex = (uint)vertices.Count;
for (var i = 0; i < posAccess.Count; i++)
{
var pos = posAccess.AsVector3Array()[i];
var normal = normAccess != null
? normAccess.AsVector3Array()[i]
: Vector3.UnitY;
vertices.Add(new Vertex(pos, tint, normal));
}
if (indexAccess != null)
{
var indexArray = indexAccess.AsIndicesArray();
foreach (var idx in indexArray)
{
indices.Add((uint)idx + baseVertex);
}
}
else
{
for (uint i = 0; i < posAccess.Count; i++)
{
indices.Add(baseVertex + i);
}
}
}
}
}
if (vertices.Count == 0)
throw new InvalidOperationException($"GLTF file '{path}' contains no meshes.");
return new Mesh(vertices.ToArray(), indices.ToArray());
}
}
+109
View File
@@ -0,0 +1,109 @@
using System.Globalization;
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics.Loaders;
public static class ObjLoader
{
private static readonly Vector3 DefaultColor = new(0.7f, 0.6f, 0.5f);
public static Mesh Load(string path, Vector3? color = null)
{
var tint = color ?? DefaultColor;
var lines = File.ReadAllLines(path);
var positions = new List<Vector3>();
var normals = new List<Vector3>();
var vertices = new List<Vertex>();
var indices = new List<uint>();
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
continue;
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
continue;
switch (parts[0])
{
case "v":
positions.Add(new Vector3(
float.Parse(parts[1], CultureInfo.InvariantCulture),
float.Parse(parts[2], CultureInfo.InvariantCulture),
float.Parse(parts[3], CultureInfo.InvariantCulture)));
break;
case "vn":
normals.Add(new Vector3(
float.Parse(parts[1], CultureInfo.InvariantCulture),
float.Parse(parts[2], CultureInfo.InvariantCulture),
float.Parse(parts[3], CultureInfo.InvariantCulture)));
break;
case "f":
ParseFace(parts, positions, normals, vertices, indices, tint);
break;
}
}
if (vertices.Count == 0)
throw new InvalidOperationException($"OBJ file '{path}' contains no faces.");
return new Mesh(vertices.ToArray(), indices.ToArray());
}
private static void ParseFace(
string[] parts,
List<Vector3> positions,
List<Vector3> normals,
List<Vertex> vertices,
List<uint> indices,
Vector3 tint)
{
var faceData = new List<(int posIdx, int normIdx)>();
for (var i = 1; i < parts.Length; i++)
{
var vertexData = parts[i].Split('/');
var posIdx = int.Parse(vertexData[0]) - 1;
var normIdx = vertexData.Length > 2 && !string.IsNullOrEmpty(vertexData[2])
? int.Parse(vertexData[2]) - 1
: -1;
faceData.Add((posIdx, normIdx));
}
if (faceData.Count < 3) return;
for (var i = 1; i < faceData.Count - 1; i++)
{
var d0 = faceData[0];
var d1 = faceData[i];
var d2 = faceData[i + 1];
var p0 = positions[d0.posIdx];
var p1 = positions[d1.posIdx];
var p2 = positions[d2.posIdx];
var normal = d0.normIdx >= 0 && d0.normIdx < normals.Count
? normals[d0.normIdx]
: MeshMath.ComputeFaceNormal(p0, p1, p2);
var i0 = (uint)vertices.Count;
vertices.Add(new Vertex(p0, tint, normal));
var i1 = (uint)vertices.Count;
vertices.Add(new Vertex(p1, tint, normal));
var i2 = (uint)vertices.Count;
vertices.Add(new Vertex(p2, tint, normal));
indices.Add(i0); indices.Add(i1); indices.Add(i2);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Numerics;
namespace Engine.Graphics;
public static class MeshMath
{
public static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
{
var edge1 = b - a;
var edge2 = c - a;
var normal = Vector3.Cross(edge2, edge1);
if (normal.LengthSquared() < 1e-12f)
return Vector3.UnitY;
return Vector3.Normalize(normal);
}
}
+90
View File
@@ -0,0 +1,90 @@
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics;
public static class ProceduralMesh
{
public static Mesh CreateSphere(float radius, int slices, int stacks, Vector3 color)
{
var vertices = new Vertex[(stacks + 1) * (slices + 1)];
var indices = new uint[stacks * slices * 6];
var vi = 0;
for (var i = 0; i <= stacks; i++)
{
var phi = MathF.PI * i / stacks;
var y = radius * MathF.Cos(phi);
var r = radius * MathF.Sin(phi);
for (var j = 0; j <= slices; j++)
{
var theta = 2.0f * MathF.PI * j / slices;
var x = r * MathF.Cos(theta);
var z = r * MathF.Sin(theta);
var pos = new Vector3(x, y, z);
var normal = Vector3.Normalize(pos);
vertices[vi++] = new Vertex(pos, color, normal);
}
}
var ii = 0;
for (var i = 0; i < stacks; i++)
{
for (var j = 0; j < slices; j++)
{
var a = (uint)(i * (slices + 1) + j);
var b = a + 1;
var c = a + (uint)(slices + 1);
var d = c + 1;
indices[ii++] = a; indices[ii++] = c; indices[ii++] = b;
indices[ii++] = b; indices[ii++] = c; indices[ii++] = d;
}
}
return new Mesh(vertices, indices);
}
public static Mesh CreateGrid(int halfSize, float spacing, Vector3 color)
{
var lines = 2 * halfSize + 1;
var vertices = new List<Vertex>(lines * 4 * 2);
var indices = new List<uint>(lines * 4 * 2);
var extent = halfSize * spacing;
for (var i = -halfSize; i <= halfSize; i++)
{
var pos = i * spacing;
var i0 = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(pos, 0, -extent), color, Vector3.UnitY));
var i1 = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(pos, 0, extent), color, Vector3.UnitY));
indices.Add(i0); indices.Add(i1);
var i2 = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(pos, 0, -extent), color, Vector3.UnitY));
var i3 = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(pos, 0, extent), color, Vector3.UnitY));
indices.Add(i2); indices.Add(i3);
var i4 = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(-extent, 0, pos), color, Vector3.UnitY));
var i5 = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(extent, 0, pos), color, Vector3.UnitY));
indices.Add(i4); indices.Add(i5);
var i6 = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(-extent, 0, pos), color, Vector3.UnitY));
var i7 = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(extent, 0, pos), color, Vector3.UnitY));
indices.Add(i6); indices.Add(i7);
}
return new Mesh(vertices.ToArray(), indices.ToArray());
}
}
@@ -0,0 +1,25 @@
using Engine.Core;
namespace Engine.Graphics;
public static class RenderBackendFactory
{
private static readonly Dictionary<string, Func<int, int, bool, IRenderContext>> _backends =
new(StringComparer.OrdinalIgnoreCase);
public static void Register(string name, Func<int, int, bool, IRenderContext> factory)
{
_backends[name] = factory;
}
public static IRenderContext Create(string name, int width, int height, bool enableValidation)
{
if (_backends.TryGetValue(name, out var factory))
return factory(width, height, enableValidation);
throw new NotSupportedException(
$"Unknown render backend '{name}'. Available: {string.Join(", ", _backends.Keys)}");
}
public static bool IsRegistered(string name) => _backends.ContainsKey(name);
}
+204
View File
@@ -0,0 +1,204 @@
using System.Numerics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Engine.Core.Components;
using Flecs.NET.Core;
namespace Engine.Graphics;
public static class SceneSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
public static string SaveToString(World world)
{
var entities = new List<SceneEntityData>();
world.Each((Entity e, ref Transform _) =>
{
var name = e.Name();
if (string.IsNullOrEmpty(name)) return;
var data = new SceneEntityData { Name = name };
if (e.Has<Transform>())
{
var t = e.Get<Transform>();
data.Transform = new TransformData
{
Position = new float[] { t.Position.X, t.Position.Y, t.Position.Z },
Rotation = new float[] { t.Rotation.X, t.Rotation.Y, t.Rotation.Z, t.Rotation.W },
Scale = new float[] { t.Scale.X, t.Scale.Y, t.Scale.Z }
};
}
if (e.Has<Material>())
{
var m = e.Get<Material>();
data.Material = new MaterialData
{
Albedo = new float[] { m.Albedo.X, m.Albedo.Y, m.Albedo.Z },
Roughness = m.Roughness,
Metallic = m.Metallic,
TexturePath = m.TexturePath
};
}
if (e.Has<Light>())
{
var l = e.Get<Light>();
data.Light = new LightData
{
Type = l.Type.ToString(),
Direction = new float[] { l.Direction.X, l.Direction.Y, l.Direction.Z },
Position = new float[] { l.Position.X, l.Position.Y, l.Position.Z },
Color = new float[] { l.Color.X, l.Color.Y, l.Color.Z },
Intensity = l.Intensity,
Range = l.Range
};
}
if (e.Has<Camera>())
{
var c = e.Get<Camera>();
data.Camera = new CameraData
{
Position = new float[] { c.Position.X, c.Position.Y, c.Position.Z },
Target = new float[] { c.Target.X, c.Target.Y, c.Target.Z },
Up = new float[] { c.Up.X, c.Up.Y, c.Up.Z },
FieldOfView = c.FieldOfView,
AspectRatio = c.AspectRatio,
NearPlane = c.NearPlane,
FarPlane = c.FarPlane
};
}
entities.Add(data);
});
return JsonSerializer.Serialize(entities, JsonOptions);
}
public static void SaveToFile(World world, string path)
{
var json = SaveToString(world);
File.WriteAllText(path, json);
}
public static int LoadFromString(World world, string json)
{
var entities = JsonSerializer.Deserialize<List<SceneEntityData>>(json, JsonOptions);
if (entities == null) return 0;
foreach (var data in entities)
{
var entity = world.Entity(data.Name);
if (data.Transform != null)
{
var t = data.Transform;
entity.Set(new Transform(
new Vector3(t.Position[0], t.Position[1], t.Position[2]),
new Quaternion(t.Rotation[0], t.Rotation[1], t.Rotation[2], t.Rotation[3]),
new Vector3(t.Scale[0], t.Scale[1], t.Scale[2])));
}
if (data.Material != null)
{
var m = data.Material;
entity.Set(new Material(
new Vector3(m.Albedo[0], m.Albedo[1], m.Albedo[2]),
m.Roughness, m.Metallic, m.TexturePath));
}
if (data.Light != null)
{
var l = data.Light;
if (l.Type == "Directional")
{
entity.Set(Light.Directional(
new Vector3(l.Direction[0], l.Direction[1], l.Direction[2]),
new Vector3(l.Color[0], l.Color[1], l.Color[2]),
l.Intensity));
}
else
{
entity.Set(Light.Point(
new Vector3(l.Position[0], l.Position[1], l.Position[2]),
new Vector3(l.Color[0], l.Color[1], l.Color[2]),
l.Intensity, l.Range));
}
}
if (data.Camera != null)
{
var c = data.Camera;
entity.Set(new Camera(
new Vector3(c.Position[0], c.Position[1], c.Position[2]),
new Vector3(c.Target[0], c.Target[1], c.Target[2]),
new Vector3(c.Up[0], c.Up[1], c.Up[2]),
c.FieldOfView, c.AspectRatio, c.NearPlane, c.FarPlane));
}
}
return entities.Count;
}
public static int LoadFromFile(World world, string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Scene file not found: {path}", path);
var json = File.ReadAllText(path);
return LoadFromString(world, json);
}
private class SceneEntityData
{
public string Name { get; set; } = "";
public TransformData? Transform { get; set; }
public MaterialData? Material { get; set; }
public LightData? Light { get; set; }
public CameraData? Camera { get; set; }
}
private class TransformData
{
public float[] Position { get; set; } = Array.Empty<float>();
public float[] Rotation { get; set; } = Array.Empty<float>();
public float[] Scale { get; set; } = Array.Empty<float>();
}
private class MaterialData
{
public float[] Albedo { get; set; } = Array.Empty<float>();
public float Roughness { get; set; }
public float Metallic { get; set; }
public string? TexturePath { get; set; }
}
private class LightData
{
public string Type { get; set; } = "";
public float[] Direction { get; set; } = Array.Empty<float>();
public float[] Position { get; set; } = Array.Empty<float>();
public float[] Color { get; set; } = Array.Empty<float>();
public float Intensity { get; set; }
public float Range { get; set; }
}
private class CameraData
{
public float[] Position { get; set; } = Array.Empty<float>();
public float[] Target { get; set; } = Array.Empty<float>();
public float[] Up { get; set; } = Array.Empty<float>();
public float FieldOfView { get; set; }
public float AspectRatio { get; set; }
public float NearPlane { get; set; }
public float FarPlane { get; set; }
}
}