chore: remove all graphics backends (Raylib, OpenTK, Vulkan/Silk.NET) — prepare for pure Vulkan P/Invoke rewrite

This commit is contained in:
emil28092005
2026-06-17 22:12:51 +03:00
parent ff4a3faae9
commit dd105ea4af
40 changed files with 257 additions and 5884 deletions
@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenTK" Version="4.9.4" />
<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>
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
</ItemGroup>
</Project>
@@ -1,16 +0,0 @@
using Engine.Graphics;
namespace Engine.Graphics.OpenTK;
/// <summary>
/// Triggers registration of the OpenTK backend with the HAL factory.
/// </summary>
public static class OpenTKBackendRegistrar
{
static OpenTKBackendRegistrar()
{
RenderBackendFactory.Register("opentk", (width, height, _) => new OpenTKRenderContext(width, height));
}
public static void EnsureRegistered() { }
}
@@ -1,104 +0,0 @@
using System.Collections.Generic;
using Engine.Core;
using OpenTK.Windowing.GraphicsLibraryFramework;
using EngineKey = Engine.Core.Key;
using OpenTKKey = OpenTK.Windowing.GraphicsLibraryFramework.Keys;
namespace Engine.Graphics.OpenTK;
/// <summary>
/// OpenTK input-backed implementation of IInputState.
/// </summary>
public sealed class OpenTKInputState : IInputState
{
private readonly HashSet<EngineKey> _keysDown = new();
private readonly HashSet<EngineKey> _keysPressed = new();
private readonly HashSet<EngineKey> _keysReleased = new();
private readonly HashSet<EngineKey> _prevDown = 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 float MouseWheelDelta { get; private set; }
public void Update(KeyboardState kb, MouseState ms)
{
MouseX = (int)ms.Position.X;
MouseY = (int)ms.Position.Y;
MouseLeft = ms.IsButtonDown(MouseButton.Left);
MouseRight = ms.IsButtonDown(MouseButton.Right);
MouseMiddle = ms.IsButtonDown(MouseButton.Middle);
MouseWheelDelta = (float)ms.ScrollDelta.Y;
// Compute pressed/released edges
_keysPressed.Clear();
_keysReleased.Clear();
var currentKeys = new HashSet<EngineKey>();
foreach (var openTkKey in AllKeys)
{
if (kb.IsKeyDown(openTkKey))
{
var k = MapKey(openTkKey);
if (k == EngineKey.Unknown) continue;
currentKeys.Add(k);
if (!_prevDown.Contains(k))
_keysPressed.Add(k);
}
}
foreach (var k in _prevDown)
if (!currentKeys.Contains(k))
_keysReleased.Add(k);
_keysDown.Clear();
_keysDown.UnionWith(currentKeys);
_prevDown.Clear();
_prevDown.UnionWith(currentKeys);
}
public void BeginFrame()
{
_keysPressed.Clear();
_keysReleased.Clear();
MouseWheelDelta = 0;
}
public bool IsKeyDown(EngineKey key) => _keysDown.Contains(key);
public bool IsKeyPressed(EngineKey key) => _keysPressed.Contains(key);
public bool IsKeyReleased(EngineKey key) => _keysReleased.Contains(key);
private static readonly OpenTKKey[] AllKeys =
{
OpenTKKey.W, OpenTKKey.A, OpenTKKey.S, OpenTKKey.D,
OpenTKKey.Q, OpenTKKey.E, OpenTKKey.F,
OpenTKKey.LeftShift,
OpenTKKey.Space, OpenTKKey.Escape, OpenTKKey.Enter,
OpenTKKey.Tab, OpenTKKey.Backspace,
OpenTKKey.Up, OpenTKKey.Down, OpenTKKey.Left, OpenTKKey.Right,
};
private static EngineKey MapKey(OpenTKKey key) => key switch
{
OpenTKKey.W => EngineKey.W,
OpenTKKey.A => EngineKey.A,
OpenTKKey.S => EngineKey.S,
OpenTKKey.D => EngineKey.D,
OpenTKKey.Q => EngineKey.Q,
OpenTKKey.E => EngineKey.E,
OpenTKKey.F => EngineKey.F,
OpenTKKey.LeftShift => EngineKey.LeftShift,
OpenTKKey.Space => EngineKey.Space,
OpenTKKey.Escape => EngineKey.Escape,
OpenTKKey.Enter => EngineKey.Enter,
OpenTKKey.Tab => EngineKey.Tab,
OpenTKKey.Backspace => EngineKey.Backspace,
OpenTKKey.Up => EngineKey.Up,
OpenTKKey.Down => EngineKey.Down,
OpenTKKey.Left => EngineKey.Left,
OpenTKKey.Right => EngineKey.Right,
_ => EngineKey.Unknown,
};
}
@@ -1,47 +0,0 @@
using Engine.Core;
using Engine.Graphics;
namespace Engine.Graphics.OpenTK;
/// <summary>
/// OpenTK render context — owns the GameWindow and creates the renderer.
/// </summary>
public sealed class OpenTKRenderContext : IRenderContext
{
private readonly OpenTKWindow _window;
private OpenTKRenderer? _renderer;
private bool _disposed;
public IWindow Window => _window;
public OpenTKRenderContext(int width, int height, bool enableValidation = false)
{
_window = new OpenTKWindow("Cortex Engine (OpenTK)", width, height);
// GameWindow creates OpenGL context in constructor — MakeCurrent is called automatically
_window.MakeCurrent();
}
public IRenderer CreateRenderer()
{
_renderer = new OpenTKRenderer();
return _renderer;
}
public void Resize(int width, int height)
{
_renderer?.SetScreenSize(width, height);
}
/// <summary>
/// Swap buffers — called after RenderWorld in the main loop.
/// </summary>
public void SwapBuffers() => _window.SwapBuffers();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_renderer?.Dispose();
_window.Dispose();
}
}
@@ -1,495 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
using Engine.Graphics;
using Flecs.NET.Core;
using OpenTK.Graphics.OpenGL4;
using OTKMatrix = OpenTK.Mathematics.Matrix4;
using OTKVector3 = OpenTK.Mathematics.Vector3;
using EngineMaterial = Engine.Core.Components.Material;
using EngineMesh = Engine.Core.Components.Mesh;
using EngineTransform = Engine.Core.Components.Transform;
namespace Engine.Graphics.OpenTK;
public sealed class OpenTKRenderer : IRenderer
{
private const int ShadowMapSize = 2048;
private int _program;
private int _shadowProgram;
private int _shadowFbo;
private int _shadowTexture;
private readonly Dictionary<Entity, GLMesh> _meshCache = new();
private readonly float[] _matrixBuf = new float[16];
// Uniform locations
private int _uMVP, _uModel, _uViewPos, _uMaterialColor, _uRoughness, _uMetallic;
private int _uAmbient, _uLightCount, _uLightDirs, _uLightIntensities, _uLightColors;
private int _uLightPositions, _uLightTypes, _uLightRanges;
private int _uLightViewProj, _uShadowMap, _uUseTexture;
// Light data
private readonly float[] _lightDirs = new float[12];
private readonly float[] _lightPositions = new float[12];
private readonly float[] _lightIntensities = new float[4];
private readonly float[] _lightColors = new float[12];
private readonly int[] _lightTypes = new int[4];
private readonly float[] _lightRanges = new float[4];
private int _lightCount;
private int _screenW = 1280, _screenH = 720;
private bool _disposed;
public OpenTKRenderer()
{
// Compile shaders
_program = CreateProgram(VertexSrc, FragmentSrc);
_shadowProgram = CreateProgram(ShadowVertSrc, ShadowFragSrc);
// Get uniform locations
_uMVP = GL.GetUniformLocation(_program, "mvp");
_uModel = GL.GetUniformLocation(_program, "model");
_uViewPos = GL.GetUniformLocation(_program, "viewPos");
_uMaterialColor = GL.GetUniformLocation(_program, "materialColor");
_uRoughness = GL.GetUniformLocation(_program, "roughness");
_uMetallic = GL.GetUniformLocation(_program, "metallic");
_uAmbient = GL.GetUniformLocation(_program, "ambientColor");
_uLightCount = GL.GetUniformLocation(_program, "lightCount");
_uLightDirs = GL.GetUniformLocation(_program, "lightDirs");
_uLightIntensities = GL.GetUniformLocation(_program, "lightIntensities");
_uLightColors = GL.GetUniformLocation(_program, "lightColors");
_uLightPositions = GL.GetUniformLocation(_program, "lightPositions");
_uLightTypes = GL.GetUniformLocation(_program, "lightTypes");
_uLightRanges = GL.GetUniformLocation(_program, "lightRanges");
_uLightViewProj = GL.GetUniformLocation(_program, "lightViewProj");
_uShadowMap = GL.GetUniformLocation(_program, "shadowMap");
_uUseTexture = GL.GetUniformLocation(_program, "useTexture");
// Shadow FBO
_shadowFbo = GL.GenFramebuffer();
GL.BindFramebuffer(FramebufferTarget.Framebuffer, _shadowFbo);
_shadowTexture = GL.GenTexture();
GL.BindTexture(TextureTarget.Texture2D, _shadowTexture);
GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.DepthComponent,
ShadowMapSize, ShadowMapSize, 0, PixelFormat.DepthComponent, PixelType.Float, IntPtr.Zero);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
GL.FramebufferTexture2D(FramebufferTarget.Framebuffer, FramebufferAttachment.DepthAttachment,
TextureTarget.Texture2D, _shadowTexture, 0);
GL.DrawBuffer(DrawBufferMode.None);
GL.ReadBuffer(ReadBufferMode.None);
GL.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
// GL state
GL.Enable(EnableCap.DepthTest);
GL.Disable(EnableCap.CullFace);
}
public void SetScreenSize(int w, int h) { _screenW = w; _screenH = h; }
public void RequestScreenshot(string path) { }
public bool IsScreenshotRequested => false;
public IScreenshotProvider ScreenshotProvider => new DummyScreenshotProvider();
public void RenderWorld(World world)
{
var camera = GetCamera(world);
CollectLights(world);
var hasDirLight = _lightCount > 0 && _lightTypes[0] == (int)LightType.Directional;
OTKMatrix lightVP = OTKMatrix.Identity;
// === PASS 1: Shadow ===
if (hasDirLight)
{
var lightDir = new Vector3(_lightDirs[0], _lightDirs[1], _lightDirs[2]);
var center = new Vector3(0, 0.5f, 0);
var lightPos = center - lightDir * 30f;
var up = MathF.Abs(Vector3.Dot(lightDir, Vector3.UnitY)) > 0.99f ? Vector3.UnitZ : Vector3.UnitY;
lightVP = OTKMatrix.CreateOrthographicOffCenter(-15, 15, -15, 15, 1, 80)
* OTKMatrix.LookAt(ToV3(lightPos), ToV3(center), ToV3(up));
GL.Viewport(0, 0, ShadowMapSize, ShadowMapSize);
GL.BindFramebuffer(FramebufferTarget.Framebuffer, _shadowFbo);
GL.Clear(ClearBufferMask.DepthBufferBit);
GL.UseProgram(_shadowProgram);
GL.CullFace(CullFaceMode.Front);
int sMvp = GL.GetUniformLocation(_shadowProgram, "mvp");
world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform t) =>
{
if (e.Name() == "Grid" || e.Name() == "Floor") return;
var gm = GetOrUploadMesh(e, mesh);
var model = ToM4(t.GetMatrix());
var mvp = lightVP * model;
SetUniformMat4(sMvp, mvp);
DrawMesh(gm);
});
GL.CullFace(CullFaceMode.Back);
GL.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
}
// === PASS 2: Main ===
GL.Viewport(0, 0, _screenW, _screenH);
GL.ClearColor(0.098f, 0.118f, 0.157f, 1f);
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
GL.UseProgram(_program);
var view = OTKMatrix.LookAt(ToV3(camera.Position), ToV3(camera.Target), ToV3(camera.Up));
var proj = OTKMatrix.CreatePerspectiveFieldOfView(camera.FieldOfView, camera.AspectRatio, camera.NearPlane, camera.FarPlane);
GL.Uniform3(_uViewPos, camera.Position.X, camera.Position.Y, camera.Position.Z);
GL.Uniform3(_uAmbient, 0.35f, 0.35f, 0.4f);
GL.Uniform1(_uLightCount, _lightCount);
GL.Uniform3(_uLightDirs, 4, _lightDirs);
GL.Uniform1(_uLightIntensities, 4, _lightIntensities);
GL.Uniform3(_uLightColors, 4, _lightColors);
GL.Uniform3(_uLightPositions, 4, _lightPositions);
GL.Uniform1(_uLightTypes, 4, _lightTypes);
GL.Uniform1(_uLightRanges, 4, _lightRanges);
if (hasDirLight)
{
SetUniformMat4(_uLightViewProj, lightVP);
GL.ActiveTexture(TextureUnit.Texture1);
GL.BindTexture(TextureTarget.Texture2D, _shadowTexture);
GL.Uniform1(_uShadowMap, 1);
GL.ActiveTexture(TextureUnit.Texture0);
}
world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform t) =>
{
if (e.Name() == "Grid") return;
var mat = e.Has<EngineMaterial>() ? e.Get<EngineMaterial>() : EngineMaterial.Default;
var gm = GetOrUploadMesh(e, mesh);
var model = ToM4(t.GetMatrix());
var mvp = proj * view * model;
SetUniformMat4(_uMVP, mvp);
SetUniformMat4(_uModel, model);
GL.Uniform4(_uMaterialColor, mat.Albedo.X, mat.Albedo.Y, mat.Albedo.Z, 1f);
GL.Uniform1(_uRoughness, mat.Roughness);
GL.Uniform1(_uMetallic, mat.Metallic);
GL.Uniform1(_uUseTexture, 0);
DrawMesh(gm);
});
}
private void DrawMesh(GLMesh m)
{
GL.BindVertexArray(m.Vao);
GL.DrawElements(PrimitiveType.Triangles, m.Count, DrawElementsType.UnsignedInt, 0);
GL.BindVertexArray(0);
}
private void SetUniformMat4(int loc, OTKMatrix mat)
{
// OpenTK Matrix4 fields: M11,M12,M13,M14, M21..M24, M31..M34, M41..M44
// These are stored in the struct as row-major (M11=row1col1).
// BUT OpenTK's Matrix4 in memory is actually column-major per its design
// (M11,M21,M31,M41 is the first column in memory).
// So we copy field-by-field and pass with transpose=false.
_matrixBuf[0] = mat.M11; _matrixBuf[1] = mat.M21; _matrixBuf[2] = mat.M31; _matrixBuf[3] = mat.M41;
_matrixBuf[4] = mat.M12; _matrixBuf[5] = mat.M22; _matrixBuf[6] = mat.M32; _matrixBuf[7] = mat.M42;
_matrixBuf[8] = mat.M13; _matrixBuf[9] = mat.M23; _matrixBuf[10] = mat.M33; _matrixBuf[11] = mat.M43;
_matrixBuf[12] = mat.M14; _matrixBuf[13] = mat.M24; _matrixBuf[14] = mat.M34; _matrixBuf[15] = mat.M44;
GL.UniformMatrix4(loc, 1, false, _matrixBuf);
}
private GLMesh GetOrUploadMesh(Entity e, EngineMesh mesh)
{
if (_meshCache.TryGetValue(e, out var existing))
return existing;
int vao = GL.GenVertexArray();
GL.BindVertexArray(vao);
// Position
float[] pos = new float[mesh.Vertices.Length * 3];
for (int i = 0; i < mesh.Vertices.Length; i++)
{
pos[i*3] = mesh.Vertices[i].Position.X;
pos[i*3+1] = mesh.Vertices[i].Position.Y;
pos[i*3+2] = mesh.Vertices[i].Position.Z;
}
int vboPos = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, vboPos);
GL.BufferData(BufferTarget.ArrayBuffer, pos.Length * sizeof(float), pos, BufferUsageHint.StaticDraw);
GL.EnableVertexAttribArray(0);
GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 0, 0);
// Normal
float[] nrm = new float[mesh.Vertices.Length * 3];
for (int i = 0; i < mesh.Vertices.Length; i++)
{
nrm[i*3] = mesh.Vertices[i].Normal.X;
nrm[i*3+1] = mesh.Vertices[i].Normal.Y;
nrm[i*3+2] = mesh.Vertices[i].Normal.Z;
}
int vboNrm = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, vboNrm);
GL.BufferData(BufferTarget.ArrayBuffer, nrm.Length * sizeof(float), nrm, BufferUsageHint.StaticDraw);
GL.EnableVertexAttribArray(1);
GL.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 0, 0);
// Color
float[] col = new float[mesh.Vertices.Length * 4];
for (int i = 0; i < mesh.Vertices.Length; i++)
{
col[i*4] = mesh.Vertices[i].Color.X;
col[i*4+1] = mesh.Vertices[i].Color.Y;
col[i*4+2] = mesh.Vertices[i].Color.Z;
col[i*4+3] = 1f;
}
int vboCol = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, vboCol);
GL.BufferData(BufferTarget.ArrayBuffer, col.Length * sizeof(float), col, BufferUsageHint.StaticDraw);
GL.EnableVertexAttribArray(2);
GL.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 0, 0);
// Indices
int ebo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ElementArrayBuffer, ebo);
GL.BufferData(BufferTarget.ElementArrayBuffer, mesh.Indices.Length * sizeof(uint), mesh.Indices, BufferUsageHint.StaticDraw);
GL.BindVertexArray(0);
var gm = new GLMesh(vao, mesh.Indices.Length);
_meshCache[e] = gm;
return gm;
}
private void CollectLights(World world)
{
int count = 0;
world.Each((Entity e, ref Light light) =>
{
if (count >= 4) return;
_lightDirs[count*3] = light.Direction.X;
_lightDirs[count*3+1] = light.Direction.Y;
_lightDirs[count*3+2] = light.Direction.Z;
_lightPositions[count*3] = light.Position.X;
_lightPositions[count*3+1] = light.Position.Y;
_lightPositions[count*3+2] = light.Position.Z;
_lightIntensities[count] = light.Intensity;
_lightColors[count*3] = light.Color.X;
_lightColors[count*3+1] = light.Color.Y;
_lightColors[count*3+2] = light.Color.Z;
_lightTypes[count] = (int)light.Type;
_lightRanges[count] = light.Range;
count++;
});
if (count == 0)
{
_lightDirs[0] = 0.5f; _lightDirs[1] = -1; _lightDirs[2] = -0.5f;
_lightIntensities[0] = 1; _lightColors[0] = 1; _lightColors[1] = 0.95f; _lightColors[2] = 0.8f;
_lightTypes[0] = (int)LightType.Directional; _lightRanges[0] = 20;
count = 1;
}
for (int i = count; i < 4; i++) { _lightIntensities[i] = 0; _lightTypes[i] = 0; }
_lightCount = count;
}
private Camera GetCamera(World world)
{
var cam = new Camera(new Vector3(0, 0.75f, -30), new Vector3(0, 0.5f, 0), Vector3.UnitY, MathF.PI/12, 16f/9f, 0.1f, 100f);
world.Each((Entity e, ref Camera c) => cam = c);
return cam;
}
private static OTKMatrix ToM4(System.Numerics.Matrix4x4 m) => new(
m.M11, m.M12, m.M13, m.M14,
m.M21, m.M22, m.M23, m.M24,
m.M31, m.M32, m.M33, m.M34,
m.M41, m.M42, m.M43, m.M44);
private static OTKVector3 ToV3(Vector3 v) => new(v.X, v.Y, v.Z);
private static int CreateProgram(string vsSrc, string fsSrc)
{
int vs = GL.CreateShader(ShaderType.VertexShader);
GL.ShaderSource(vs, vsSrc);
GL.CompileShader(vs);
GL.GetShader(vs, ShaderParameter.CompileStatus, out int vsOk);
if (vsOk == 0) throw new Exception($"VS compile: {GL.GetShaderInfoLog(vs)}");
int fs = GL.CreateShader(ShaderType.FragmentShader);
GL.ShaderSource(fs, fsSrc);
GL.CompileShader(fs);
GL.GetShader(fs, ShaderParameter.CompileStatus, out int fsOk);
if (fsOk == 0) throw new Exception($"FS compile: {GL.GetShaderInfoLog(fs)}");
int prog = GL.CreateProgram();
GL.AttachShader(prog, vs);
GL.AttachShader(prog, fs);
GL.LinkProgram(prog);
GL.GetProgram(prog, GetProgramParameterName.LinkStatus, out int linkOk);
if (linkOk == 0) throw new Exception($"Link: {GL.GetProgramInfoLog(prog)}");
GL.DeleteShader(vs);
GL.DeleteShader(fs);
return prog;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var m in _meshCache.Values) GL.DeleteVertexArray(m.Vao);
_meshCache.Clear();
GL.DeleteProgram(_program);
GL.DeleteProgram(_shadowProgram);
GL.DeleteFramebuffer(_shadowFbo);
GL.DeleteTexture(_shadowTexture);
}
// === Shaders ===
private const string ShadowVertSrc = @"#version 330 core
layout(location=0) in vec3 aPos;
uniform mat4 mvp;
void main(){ gl_Position = mvp * vec4(aPos, 1.0); }";
private const string ShadowFragSrc = @"#version 330 core
void main(){}";
private const string VertexSrc = @"#version 330 core
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aNormal;
layout(location=2) in vec4 aColor;
uniform mat4 mvp;
uniform mat4 model;
out vec3 vNormal;
out vec3 vWorldPos;
out vec4 vColor;
void main()
{
vec4 wp = model * vec4(aPos, 1.0);
vWorldPos = wp.xyz;
vNormal = mat3(transpose(inverse(model))) * aNormal;
vColor = aColor;
gl_Position = mvp * vec4(aPos, 1.0);
}";
private const string FragmentSrc = @"#version 330 core
in vec3 vNormal;
in vec3 vWorldPos;
in vec4 vColor;
out vec4 finalColor;
uniform vec4 materialColor;
uniform float roughness;
uniform float metallic;
uniform vec3 viewPos;
uniform vec3 ambientColor;
uniform int lightCount;
uniform vec3 lightDirs[4];
uniform vec3 lightPositions[4];
uniform float lightIntensities[4];
uniform vec3 lightColors[4];
uniform int lightTypes[4];
uniform float lightRanges[4];
uniform mat4 lightViewProj;
uniform sampler2D shadowMap;
uniform int useTexture;
vec3 ACESFilm(vec3 x)
{
const float a=2.51, b=0.03, c=2.43, d=0.59, e=0.14;
return clamp((x*(a*x+b))/(x*(c*x+d)+e), 0.0, 1.0);
}
float Attenuation(float dist, float range)
{
float r = max(range, 0.001);
float d = max(dist, 0.001);
float x = d/r, x2 = x*x, x4 = x2*x2;
return clamp(1.0/(1.0+25.0*x4), 0.0, 1.0) * smoothstep(1.0, 0.0, x);
}
float CalculateShadow(vec3 worldPos)
{
vec4 lp = lightViewProj * vec4(worldPos, 1.0);
vec3 ndc = lp.xyz / lp.w;
vec3 uvw = ndc * 0.5 + 0.5;
if (uvw.x < 0.0 || uvw.x > 1.0 || uvw.y < 0.0 || uvw.y > 1.0 || uvw.z > 1.0)
return 1.0;
float bias = 0.005;
vec2 ts = vec2(1.0 / 2048.0);
float s = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
float dpt = texture(shadowMap, uvw.xy + vec2(x, y) * ts).r;
s += (uvw.z - bias > dpt) ? 0.3 : 1.0;
}
}
return s / 9.0;
}
void main()
{
vec3 normal = normalize(vNormal);
vec3 albedo = pow(vColor.rgb * materialColor.rgb, vec3(2.2));
vec3 viewDir = normalize(viewPos - vWorldPos);
float rough = clamp(roughness, 0.05, 1.0);
float metal = clamp(metallic, 0.0, 1.0);
vec3 skyColor = ambientColor;
vec3 groundColor = ambientColor * 0.2;
float hemisphere = 0.5 + 0.5 * normal.y;
vec3 result = albedo * mix(groundColor, skyColor, hemisphere) * 0.4;
float shadow = 1.0;
if (lightCount > 0 && lightTypes[0] == 0)
shadow = CalculateShadow(vWorldPos);
vec3 F0 = mix(vec3(0.04), albedo, metal);
float shininess = mix(8.0, 256.0, 1.0 - rough);
for (int i = 0; i < lightCount; i++)
{
vec3 L;
float atten = 1.0;
if (lightTypes[i] == 1) {
vec3 toLight = lightPositions[i] - vWorldPos;
float dist = length(toLight);
L = toLight / max(dist, 0.001);
atten = Attenuation(dist, lightRanges[i]);
} else {
L = normalize(-lightDirs[i]);
}
float lightShadow = (i == 0 && lightTypes[0] == 0) ? shadow : 1.0;
vec3 H = normalize(L + viewDir);
float NdotL = max(dot(normal, L), 0.0);
float NdotH = max(dot(normal, H), 0.0);
float HdotV = max(dot(H, viewDir), 0.0);
float spec = pow(NdotH, shininess);
vec3 fresnel = F0 + (1.0 - F0) * pow(1.0 - HdotV, 5.0);
vec3 specColor = mix(fresnel, albedo * fresnel, metal);
vec3 diffuse = albedo * lightColors[i] * NdotL * lightIntensities[i] * atten * 1.5 * lightShadow;
vec3 specular = specColor * spec * lightIntensities[i] * atten * lightShadow;
diffuse *= (1.0 - fresnel * (1.0 - metal * 0.5));
result += diffuse + specular;
}
result = ACESFilm(result * 1.2);
result = pow(result, vec3(1.0 / 2.2));
finalColor = vec4(result, 1.0);
}";
private readonly record struct GLMesh(int Vao, int Count);
private sealed class DummyScreenshotProvider : IScreenshotProvider
{
public Task<byte[]> CaptureAsync(string outputPath) => Task.FromResult(Array.Empty<byte>());
}
}
@@ -1,52 +0,0 @@
using System;
using Engine.Core;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
namespace Engine.Graphics.OpenTK;
public sealed class OpenTKWindow : GameWindow, IWindow, IDisposable
{
private readonly OpenTKInputState _input = new();
private bool _shouldClose;
public new int Width => Size.X;
public new int Height => Size.Y;
public bool ShouldClose => _shouldClose || IsExiting;
IInputState IWindow.Input => _input;
public nint Handle => 0;
public OpenTKWindow(string title, int width, int height)
: base(GameWindowSettings.Default, new NativeWindowSettings
{
Title = title,
Size = (width, height),
APIVersion = new Version(3, 3),
Profile = ContextProfile.Core,
Flags = ContextFlags.ForwardCompatible,
Vsync = VSyncMode.Off,
NumberOfSamples = 0,
})
{
// GameWindow creates the GL context in the base constructor.
// MakeCurrent is called automatically by OpenTK on first ProcessEvents.
// We call it explicitly here to ensure it's ready before renderer creation.
MakeCurrent();
}
public void PumpEvents()
{
ProcessEvents(0);
_input.Update(KeyboardState, MouseState);
}
void IWindow.Close() => _shouldClose = true;
public string[] GetRequiredVulkanExtensions() => Array.Empty<string>();
public new void Dispose()
{
Close();
base.Dispose();
}
}
@@ -1,34 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>false</IsAotCompatible>
<AssemblyName>Engine.Graphics.Raylib</AssemblyName>
<RootNamespace>Engine.Graphics.Raylib</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<DefineConstants>DEV_MODE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'ReleaseAOT'">
<DefineConstants>RELEASE_AOT</DefineConstants>
<PublishAot>false</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Raylib-cs" Version="8.0.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' OR '$(Configuration)' == 'ReleaseAOT'" />
<PackageReference Include="rlImgui-cs" Version="3.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
-335
View File
@@ -1,335 +0,0 @@
using System.Numerics;
using Engine.Core;
using Flecs.NET.Core;
using ImGuiNET;
using rlImGui_cs;
using EngineTransform = Engine.Core.Components.Transform;
using EngineMaterial = Engine.Core.Components.Material;
using EngineLight = Engine.Core.Components.Light;
using EngineCamera = Engine.Core.Components.Camera;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Dear ImGui integration for the Raylib backend.
/// Provides an entity inspector, hierarchy panel, and debug overlay.
/// </summary>
public sealed class ImGuiLayer : IDisposable
{
private bool _initialized;
private bool _disposed;
private string _selectedEntity = "";
private float[] _fpsHistory = new float[120];
private int _fpsHistoryIndex;
private World? _world;
private Timing? _timing;
private int _fps;
/// <summary>
/// Set the selected entity name from external (e.g. ObjectManipulator).
/// </summary>
public void SetSelectedEntity(string name) { _selectedEntity = name; }
/// <summary>
/// Get the currently selected entity name.
/// </summary>
public string GetSelectedEntity() => _selectedEntity;
/// <summary>
/// Set per-frame data before calling RenderImGuiUI.
/// </summary>
public void SetFrameData(World world, Timing timing, int fps)
{
_world = world;
_timing = timing;
_fps = fps;
}
/// <summary>
/// Render the ImGui UI. Called internally by RaylibRenderer between Begin() and End().
/// </summary>
internal void RenderImGuiUI()
{
if (!_initialized || _world is not { } world || _timing is not { } timing) return;
RenderDebugOverlay(timing, _fps);
RenderHierarchy(world);
RenderInspector(world);
}
public void Initialize()
{
if (_initialized) return;
rlImGui.Setup(true);
_initialized = true;
}
/// <summary>
/// Call at the start of the frame (after EndMode3D, before EndDrawing).
/// Begins the ImGui render pass.
/// </summary>
public void Begin()
{
if (!_initialized) return;
rlImGui.Begin();
}
/// <summary>
/// Call at the end of the frame (before EndDrawing).
/// Ends the ImGui render pass and renders all ImGui draw data.
/// </summary>
public void End()
{
if (!_initialized) return;
rlImGui.End();
}
private void RenderDebugOverlay(Timing timing, int fps)
{
ImGui.SetNextWindowPos(new Vector2(10, 10), ImGuiCond.Always);
ImGui.SetNextWindowBgAlpha(0.7f);
if (!ImGui.Begin("Debug", ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.AlwaysAutoResize))
{
ImGui.End();
return;
}
ImGui.Text($"FPS: {fps}");
ImGui.Text($"Frame: {timing.DeltaTime * 1000.0f:F2} ms");
ImGui.Text($"Time: {timing.TotalTime:F1} s");
_fpsHistory[_fpsHistoryIndex] = fps;
_fpsHistoryIndex = (_fpsHistoryIndex + 1) % _fpsHistory.Length;
ImGui.PlotLines("##fps", ref _fpsHistory[0], _fpsHistory.Length, _fpsHistoryIndex, "", 0, 200, new Vector2(200, 40));
ImGui.End();
}
private void RenderHierarchy(World world)
{
ImGui.SetNextWindowPos(new Vector2(10, 120), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(250, 400), ImGuiCond.FirstUseEver);
if (!ImGui.Begin("Hierarchy"))
{
ImGui.End();
return;
}
world.Each((Entity e, ref EngineTransform _) =>
{
var name = e.Name();
if (string.IsNullOrEmpty(name))
return;
var isSelected = name == _selectedEntity;
if (ImGui.Selectable(name, isSelected))
_selectedEntity = name;
});
ImGui.End();
}
private void RenderInspector(World world)
{
ImGui.SetNextWindowPos(new Vector2(270, 120), ImGuiCond.FirstUseEver);
ImGui.SetNextWindowSize(new Vector2(300, 400), ImGuiCond.FirstUseEver);
if (!ImGui.Begin("Inspector"))
{
ImGui.End();
return;
}
if (string.IsNullOrEmpty(_selectedEntity))
{
ImGui.TextDisabled("Select an entity from the Hierarchy");
ImGui.End();
return;
}
var entity = world.Lookup(_selectedEntity);
if ((ulong)entity.Id == 0)
{
ImGui.TextDisabled($"Entity '{_selectedEntity}' not found");
ImGui.End();
return;
}
ImGui.Text($"Entity: {_selectedEntity}");
ImGui.Separator();
if (entity.Has<EngineTransform>())
{
var t = entity.Get<EngineTransform>();
if (ImGui.CollapsingHeader("Transform", ImGuiTreeNodeFlags.DefaultOpen))
{
var pos = t.Position;
if (ImGui.DragFloat3("Position", ref pos, 0.1f))
{
t.Position = pos;
entity.Set(t);
}
var scale = t.Scale;
if (ImGui.DragFloat3("Scale", ref scale, 0.1f, 0.01f, 100f))
{
t.Scale = scale;
entity.Set(t);
}
var euler = ToEuler(t.Rotation);
if (ImGui.DragFloat3("Rotation", ref euler, 1.0f, -180f, 180f))
{
t.Rotation = FromEuler(euler);
entity.Set(t);
}
}
}
if (entity.Has<EngineMaterial>())
{
var m = entity.Get<EngineMaterial>();
if (ImGui.CollapsingHeader("Material", ImGuiTreeNodeFlags.DefaultOpen))
{
var albedo = m.Albedo;
if (ImGui.ColorEdit3("Albedo", ref albedo))
{
m.Albedo = albedo;
entity.Set(m);
}
var rough = m.Roughness;
if (ImGui.SliderFloat("Roughness", ref rough, 0.0f, 1.0f))
{
m.Roughness = rough;
entity.Set(m);
}
var metal = m.Metallic;
if (ImGui.SliderFloat("Metallic", ref metal, 0.0f, 1.0f))
{
m.Metallic = metal;
entity.Set(m);
}
if (m.HasTexture)
ImGui.Text($"Texture: {m.TexturePath}");
else
ImGui.TextDisabled("No texture");
}
}
if (entity.Has<EngineLight>())
{
var l = entity.Get<EngineLight>();
if (ImGui.CollapsingHeader("Light", ImGuiTreeNodeFlags.DefaultOpen))
{
var type = (int)l.Type;
if (ImGui.Combo("Type", ref type, "Directional\0Point\0"))
{
l.Type = (Engine.Core.Components.LightType)type;
entity.Set(l);
}
var color = l.Color;
if (ImGui.ColorEdit3("Color", ref color))
{
l.Color = color;
entity.Set(l);
}
var intensity = l.Intensity;
if (ImGui.SliderFloat("Intensity", ref intensity, 0.0f, 10.0f))
{
l.Intensity = intensity;
entity.Set(l);
}
if (l.Type == Engine.Core.Components.LightType.Point)
{
var pos = l.Position;
if (ImGui.DragFloat3("Position", ref pos, 0.1f))
{
l.Position = pos;
entity.Set(l);
}
var range = l.Range;
if (ImGui.DragFloat("Range", ref range, 0.5f, 1f, 100f))
{
l.Range = range;
entity.Set(l);
}
}
else
{
var dir = l.Direction;
if (ImGui.DragFloat3("Direction", ref dir, 0.01f, -1f, 1f))
{
l.Direction = dir;
entity.Set(l);
}
}
}
}
if (entity.Has<EngineCamera>())
{
var c = entity.Get<EngineCamera>();
if (ImGui.CollapsingHeader("Camera"))
{
var pos = c.Position;
if (ImGui.DragFloat3("Position", ref pos, 0.1f))
{
c.Position = pos;
entity.Set(c);
}
var target = c.Target;
if (ImGui.DragFloat3("Target", ref target, 0.1f))
{
c.Target = target;
entity.Set(c);
}
var fov = c.FieldOfView * 180.0f / MathF.PI;
if (ImGui.SliderFloat("FOV", ref fov, 5f, 120f))
{
c.FieldOfView = fov * MathF.PI / 180.0f;
entity.Set(c);
}
}
}
ImGui.End();
}
private static Vector3 ToEuler(Quaternion q)
{
var pitch = MathF.Atan2(2 * (q.W * q.X + q.Y * q.Z), 1 - 2 * (q.X * q.X + q.Y * q.Y));
var yaw = MathF.Asin(Math.Clamp(2 * (q.W * q.Y - q.Z * q.X), -1f, 1f));
var roll = MathF.Atan2(2 * (q.W * q.Z + q.X * q.Y), 1 - 2 * (q.Y * q.Y + q.Z * q.Z));
return new Vector3(pitch * 180f / MathF.PI, yaw * 180f / MathF.PI, roll * 180f / MathF.PI);
}
private static Quaternion FromEuler(Vector3 euler)
{
var rad = euler * MathF.PI / 180f;
return Quaternion.CreateFromYawPitchRoll(rad.Y, rad.X, rad.Z);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_initialized)
rlImGui.Shutdown();
}
}
@@ -1,20 +0,0 @@
using Engine.Graphics;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Triggers registration of the Raylib backend with the HAL factory.
/// </summary>
public static class RaylibBackendRegistrar
{
static RaylibBackendRegistrar()
{
RenderBackendFactory.Register("raylib", (width, height, _) => new RaylibRenderContext(width, height));
}
/// <summary>
/// No-op method that forces the static constructor to run.
/// Call this before using <see cref="RenderBackendFactory.Create"/>.
/// </summary>
public static void EnsureRegistered() { }
}
@@ -1,156 +0,0 @@
using System;
using System.Collections.Generic;
using Engine.Core;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib-backed implementation of <see cref="IInputState"/>.
/// Queries Raylib's input functions directly each frame.
/// </summary>
public sealed class RaylibInputState : IInputState
{
private static readonly Key[] _allKeys = (Key[])Enum.GetValues(typeof(Key));
private readonly HashSet<Key> _keysDown = new();
private readonly HashSet<Key> _keysPressed = new();
private readonly HashSet<Key> _keysReleased = new();
private float _mouseWheelDelta;
private bool _wheelConsumed;
public int MouseX => Raylib.GetMouseX();
public int MouseY => Raylib.GetMouseY();
public bool MouseLeft => Raylib.IsMouseButtonDown(MouseButton.Left);
public bool MouseRight => Raylib.IsMouseButtonDown(MouseButton.Right);
public bool MouseMiddle => Raylib.IsMouseButtonDown(MouseButton.Middle);
public float MouseWheelDelta
{
get
{
if (!_wheelConsumed)
{
_mouseWheelDelta = Raylib.GetMouseWheelMove();
_wheelConsumed = true;
}
return _mouseWheelDelta;
}
}
public void BeginFrame()
{
_keysPressed.Clear();
_keysReleased.Clear();
_mouseWheelDelta = 0;
_wheelConsumed = false;
}
/// <summary>
/// Poll Raylib input and update edge state. Called by <see cref="RaylibWindow.PumpEvents"/>.
/// </summary>
public void Poll()
{
_keysPressed.Clear();
_keysReleased.Clear();
foreach (var key in _allKeys)
{
if (key == Key.Unknown) continue;
var rlKey = ToRaylibKey(key);
if (rlKey == KeyboardKey.Null) continue;
var isDown = Raylib.IsKeyDown(rlKey);
var wasDown = _keysDown.Contains(key);
if (isDown && !wasDown)
_keysPressed.Add(key);
if (!isDown && wasDown)
_keysReleased.Add(key);
if (isDown)
_keysDown.Add(key);
else
_keysDown.Remove(key);
}
}
public bool IsKeyDown(Key key) => _keysDown.Contains(key);
public bool IsKeyPressed(Key key) => _keysPressed.Contains(key);
public bool IsKeyReleased(Key key) => _keysReleased.Contains(key);
private static KeyboardKey ToRaylibKey(Key key) => key switch
{
Key.Space => KeyboardKey.Space,
Key.Escape => KeyboardKey.Escape,
Key.Enter => KeyboardKey.Enter,
Key.Tab => KeyboardKey.Tab,
Key.Backspace => KeyboardKey.Backspace,
Key.Insert => KeyboardKey.Insert,
Key.Delete => KeyboardKey.Delete,
Key.Home => KeyboardKey.Home,
Key.End => KeyboardKey.End,
Key.PageUp => KeyboardKey.PageUp,
Key.PageDown => KeyboardKey.PageDown,
Key.Left => KeyboardKey.Left,
Key.Right => KeyboardKey.Right,
Key.Up => KeyboardKey.Up,
Key.Down => KeyboardKey.Down,
Key.A => KeyboardKey.A,
Key.B => KeyboardKey.B,
Key.C => KeyboardKey.C,
Key.D => KeyboardKey.D,
Key.E => KeyboardKey.E,
Key.F => KeyboardKey.F,
Key.G => KeyboardKey.G,
Key.H => KeyboardKey.H,
Key.I => KeyboardKey.I,
Key.J => KeyboardKey.J,
Key.K => KeyboardKey.K,
Key.L => KeyboardKey.L,
Key.M => KeyboardKey.M,
Key.N => KeyboardKey.N,
Key.O => KeyboardKey.O,
Key.P => KeyboardKey.P,
Key.Q => KeyboardKey.Q,
Key.R => KeyboardKey.R,
Key.S => KeyboardKey.S,
Key.T => KeyboardKey.T,
Key.U => KeyboardKey.U,
Key.V => KeyboardKey.V,
Key.W => KeyboardKey.W,
Key.X => KeyboardKey.X,
Key.Y => KeyboardKey.Y,
Key.Z => KeyboardKey.Z,
Key.Zero => KeyboardKey.Zero,
Key.One => KeyboardKey.One,
Key.Two => KeyboardKey.Two,
Key.Three => KeyboardKey.Three,
Key.Four => KeyboardKey.Four,
Key.Five => KeyboardKey.Five,
Key.Six => KeyboardKey.Six,
Key.Seven => KeyboardKey.Seven,
Key.Eight => KeyboardKey.Eight,
Key.Nine => KeyboardKey.Nine,
Key.F1 => KeyboardKey.F1,
Key.F2 => KeyboardKey.F2,
Key.F3 => KeyboardKey.F3,
Key.F4 => KeyboardKey.F4,
Key.F5 => KeyboardKey.F5,
Key.F6 => KeyboardKey.F6,
Key.F7 => KeyboardKey.F7,
Key.F8 => KeyboardKey.F8,
Key.F9 => KeyboardKey.F9,
Key.F10 => KeyboardKey.F10,
Key.F11 => KeyboardKey.F11,
Key.F12 => KeyboardKey.F12,
Key.LeftShift => KeyboardKey.LeftShift,
Key.LeftControl => KeyboardKey.LeftControl,
Key.LeftAlt => KeyboardKey.LeftAlt,
Key.RightShift => KeyboardKey.RightShift,
Key.RightControl => KeyboardKey.RightControl,
Key.RightAlt => KeyboardKey.RightAlt,
_ => KeyboardKey.Null,
};
}
@@ -1,28 +0,0 @@
using Engine.Core;
using Engine.Graphics;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib implementation of the render HAL context.
/// Creates and owns a <see cref="RaylibWindow"/> (GLFW-based).
/// No SDL3 dependency — the Raylib window handles both rendering and input.
/// </summary>
public sealed class RaylibRenderContext : IRenderContext
{
private readonly RaylibWindow _window;
public IWindow Window => _window;
public RaylibRenderContext(int width, int height, bool enableValidation = false)
{
_window = new RaylibWindow("Cortex Engine", width, height);
}
public IRenderer CreateRenderer() => new RaylibRenderer();
public void Resize(int width, int height) => Raylib.SetWindowSize(width, height);
public void Dispose() => _window.Dispose();
}
@@ -1,723 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Engine.Core;
using Engine.Core.Components;
using EngineMaterial = Engine.Core.Components.Material;
using EngineMesh = Engine.Core.Components.Mesh;
using EngineTransform = Engine.Core.Components.Transform;
using Flecs.NET.Core;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib implementation of the ECS world renderer.
/// Renders Mesh + Transform + Material entities with up to four directional lights.
/// </summary>
public sealed class RaylibRenderer : IRenderer
{
private const int ShadowMapSize = 2048;
private readonly Shader _shader;
private readonly Shader _shadowShader;
private readonly uint _shadowFbo;
private readonly uint _shadowDepthTex;
private readonly Dictionary<Entity, Raylib_cs.Model> _modelCache = new();
private readonly Dictionary<string, Texture2D> _textureCache = new();
private readonly int _materialColorLoc;
private readonly int _useTextureLoc;
private readonly int _roughnessLoc;
private readonly int _metallicLoc;
private readonly int _ambientLoc;
private readonly int _viewPosLoc;
private readonly int _lightCountLoc;
private readonly int _lightDirLoc;
private readonly int _lightIntensityLoc;
private readonly int _lightColorLoc;
private readonly int _lightPosLoc;
private readonly int _lightTypeLoc;
private readonly int _lightRangeLoc;
private readonly int _lightViewProjLoc;
private readonly int _shadowMapLoc;
private readonly float[] _lightDirs = new float[12];
private readonly float[] _lightPositions = new float[12];
private readonly float[] _lightRanges = new float[4];
private readonly int[] _lightTypes = new int[4];
private readonly float[] _lightIntensities = new float[4];
private readonly float[] _lightColors = new float[12];
private int _lightCount;
private ScreenshotRequest? _pendingScreenshot;
private int _frameCount;
private bool _disposed;
/// <summary>
/// ImGui editor layer. Accessible so the app can feed world/timing data.
/// </summary>
public ImGuiLayer? ImGuiLayer { get; set; }
public RaylibRenderer()
{
_shader = LoadShader();
_shadowShader = LoadShadowShader();
// Create depth-only FBO for shadow mapping (per official raylib example)
_shadowFbo = Rlgl.LoadFramebuffer();
_shadowDepthTex = Rlgl.LoadTextureDepth(ShadowMapSize, ShadowMapSize, false);
Rlgl.FramebufferAttach(_shadowFbo, _shadowDepthTex, FramebufferAttachType.Depth, FramebufferAttachTextureType.Texture2D, 0);
Rlgl.FramebufferComplete(_shadowFbo);
// Main shader uniform locations
_materialColorLoc = Raylib.GetShaderLocation(_shader, "materialColor");
_useTextureLoc = Raylib.GetShaderLocation(_shader, "useTexture");
_roughnessLoc = Raylib.GetShaderLocation(_shader, "roughness");
_metallicLoc = Raylib.GetShaderLocation(_shader, "metallic");
_ambientLoc = Raylib.GetShaderLocation(_shader, "ambientColor");
_viewPosLoc = Raylib.GetShaderLocation(_shader, "viewPos");
_lightCountLoc = Raylib.GetShaderLocation(_shader, "lightCount");
_lightDirLoc = Raylib.GetShaderLocation(_shader, "lightDirs");
_lightIntensityLoc = Raylib.GetShaderLocation(_shader, "lightIntensities");
_lightColorLoc = Raylib.GetShaderLocation(_shader, "lightColors");
_lightPosLoc = Raylib.GetShaderLocation(_shader, "lightPositions");
_lightTypeLoc = Raylib.GetShaderLocation(_shader, "lightTypes");
_lightRangeLoc = Raylib.GetShaderLocation(_shader, "lightRanges");
_lightViewProjLoc = Raylib.GetShaderLocation(_shader, "lightViewProj");
_shadowMapLoc = Raylib.GetShaderLocation(_shader, "shadowMap");
}
public void RequestScreenshot(string outputPath)
{
_pendingScreenshot = new ScreenshotRequest(outputPath, null);
}
public bool IsScreenshotRequested => _pendingScreenshot != null;
public IScreenshotProvider ScreenshotProvider => new RaylibScreenshotProvider(this);
public void RenderWorld(World world)
{
var camera = GetCamera(world);
CollectLights(world);
var hasDirectionalLight = _lightCount > 0 && _lightTypes[0] == (int)LightType.Directional;
Matrix4x4 lightViewProj = Matrix4x4.Identity;
// === PASS 1: Shadow map (render depth from light's POV) ===
if (hasDirectionalLight)
{
var lightDir = new Vector3(_lightDirs[0], _lightDirs[1], _lightDirs[2]);
var sceneCenter = new Vector3(0, 0.5f, 0);
var lightPos = sceneCenter - lightDir * 30f;
var up = MathF.Abs(Vector3.Dot(lightDir, Vector3.UnitY)) > 0.99f
? Vector3.UnitZ : Vector3.UnitY;
var shadowCamera = new Camera3D
{
Position = lightPos,
Target = sceneCenter,
Up = up,
FovY = 0,
Projection = CameraProjection.Orthographic
};
// Use BeginTextureMode with our custom depth FBO
Rlgl.EnableFramebuffer(_shadowFbo);
Rlgl.Viewport(0, 0, ShadowMapSize, ShadowMapSize);
Rlgl.ClearColor(255, 255, 255, 255);
Rlgl.ClearScreenBuffers();
Raylib.BeginMode3D(shadowCamera);
// Grab light view/proj matrices AFTER BeginMode3D (like official example)
// SetShaderValueMatrix passes Matrix4x4 as-is to glUniformMatrix4fv(transpose=false)
// which interprets row-major System.Numerics as column-major = effectively transposed.
// So we just multiply directly (no manual transpose needed).
lightViewProj = Rlgl.GetMatrixModelview() * Rlgl.GetMatrixProjection();
// Draw all shadow casters with the simple shadow shader
world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) =>
{
if (e.Name() == "Grid" || e.Name() == "Floor")
return;
var model = GetOrUploadModel(e, mesh);
var modelMatrix = transform.GetMatrix();
if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position))
{
var (axis, angle) = QuaternionToAxisAngle(rotation);
unsafe
{
var origShader = model.Materials[0].Shader;
model.Materials[0].Shader = _shadowShader;
Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White);
model.Materials[0].Shader = origShader;
}
}
});
Raylib.EndMode3D();
Rlgl.DisableFramebuffer();
Rlgl.Viewport(0, 0, Raylib.GetScreenWidth(), Raylib.GetScreenHeight());
}
// === PASS 2: Main render with shadow sampling ===
Raylib.BeginDrawing();
Raylib.ClearBackground(new Color(25, 30, 40, 255));
Raylib.BeginMode3D(ToRaylib(camera));
CollectLights(world);
SetFrameLights();
Raylib.SetShaderValue(_shader, _viewPosLoc, new float[] { camera.Position.X, camera.Position.Y, camera.Position.Z }, ShaderUniformDataType.Vec3);
// Set shadow uniforms on the main shader
if (hasDirectionalLight && _lightViewProjLoc >= 0)
{
Raylib.SetShaderValueMatrix(_shader, _lightViewProjLoc, lightViewProj);
}
Rlgl.DisableBackfaceCulling();
var shadowTex = new Texture2D { Id = _shadowDepthTex, Width = ShadowMapSize, Height = ShadowMapSize, Mipmaps = 1, Format = PixelFormat.UncompressedR16 };
world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) =>
{
if (e.Name() == "Grid")
return;
var material = e.Has<EngineMaterial>() ? e.Get<EngineMaterial>() : EngineMaterial.Default;
var model = GetOrUploadModel(e, mesh);
var modelMatrix = transform.GetMatrix();
if (Matrix4x4.Decompose(modelMatrix, out var scale, out var rotation, out var position))
{
var (axis, angle) = QuaternionToAxisAngle(rotation);
SetMaterialUniforms(material, model);
// Bind shadow map on Emission slot (texture unit 1)
if (hasDirectionalLight && _shadowMapLoc >= 0)
{
unsafe { Raylib.SetMaterialTexture(ref model.Materials[0], MaterialMapIndex.Emission, shadowTex); }
}
// Use BeginShaderMode to lock our shader, then bind shadow map on unit 1
// BEFORE DrawModelEx so it's active during the draw
if (hasDirectionalLight && _shadowMapLoc >= 0)
{
Raylib.BeginShaderMode(_shader);
Rlgl.ActiveTextureSlot(1);
Rlgl.EnableTexture(_shadowDepthTex);
Raylib.SetShaderValue(_shader, _shadowMapLoc, 1, ShaderUniformDataType.Int);
Rlgl.ActiveTextureSlot(0);
}
Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White);
if (hasDirectionalLight && _shadowMapLoc >= 0)
{
Raylib.EndShaderMode();
}
}
});
// Reset texture unit 0 after shadow binding
Rlgl.ActiveTextureSlot(0);
Rlgl.EnableBackfaceCulling();
Raylib.DrawGrid(20, 1.0f);
Raylib.EndMode3D();
// ImGui renders on top of the 3D scene, before EndDrawing.
if (ImGuiLayer != null)
{
ImGuiLayer.Begin();
ImGuiLayer.RenderImGuiUI();
ImGuiLayer.End();
}
Raylib.EndDrawing();
// Defer the first screenshot by a few frames. Raylib may return a blank image
// if the window/GPU has not finished presenting the first frame.
if (_pendingScreenshot is { } request && _frameCount >= 10)
{
CaptureScreenshot(request);
_pendingScreenshot = null;
}
_frameCount++;
}
private static (Vector3 axis, float angle) QuaternionToAxisAngle(Quaternion q)
{
if (MathF.Abs(q.W) > 0.9999999f)
return (Vector3.UnitY, 0.0f);
var angle = 2.0f * MathF.Acos(Math.Clamp(q.W, -1.0f, 1.0f));
var s = MathF.Sqrt(1.0f - q.W * q.W);
var axis = s > 0.0001f
? new Vector3(q.X / s, q.Y / s, q.Z / s)
: new Vector3(q.X, q.Y, q.Z);
return (axis, angle);
}
private Camera3D ToRaylib(Camera camera)
{
return new Camera3D
{
Position = camera.Position,
Target = camera.Target,
Up = camera.Up,
FovY = camera.FieldOfView * 180.0f / MathF.PI,
Projection = CameraProjection.Perspective
};
}
private Camera GetCamera(World world)
{
var width = Raylib.GetScreenWidth();
var height = Raylib.GetScreenHeight();
var aspect = height > 0 ? (float)width / height : 16f / 9f;
var camera = new Camera(
new Vector3(0.0f, 0.75f, -30.0f),
new Vector3(0.0f, 0.5f, 0.0f),
Vector3.UnitY,
MathF.PI / 12.0f,
aspect,
0.1f,
100.0f);
world.Each((Entity e, ref Camera cam) =>
{
camera = cam;
});
camera.AspectRatio = aspect;
return camera;
}
private void CollectLights(World world)
{
var count = 0;
world.Each((Entity e, ref Light light) =>
{
if (count >= 4)
return;
_lightDirs[count * 3 + 0] = light.Direction.X;
_lightDirs[count * 3 + 1] = light.Direction.Y;
_lightDirs[count * 3 + 2] = light.Direction.Z;
_lightPositions[count * 3 + 0] = light.Position.X;
_lightPositions[count * 3 + 1] = light.Position.Y;
_lightPositions[count * 3 + 2] = light.Position.Z;
_lightIntensities[count] = light.Intensity;
_lightColors[count * 3 + 0] = light.Color.X;
_lightColors[count * 3 + 1] = light.Color.Y;
_lightColors[count * 3 + 2] = light.Color.Z;
_lightTypes[count] = (int)light.Type;
_lightRanges[count] = light.Range;
count++;
});
if (count == 0)
{
_lightDirs[0] = 0.5f; _lightDirs[1] = -1.0f; _lightDirs[2] = -0.5f;
_lightIntensities[0] = 1.0f;
_lightColors[0] = 1.0f; _lightColors[1] = 0.95f; _lightColors[2] = 0.8f;
_lightTypes[0] = (int)LightType.Directional;
_lightRanges[0] = 20f;
count = 1;
}
for (var i = count; i < 4; i++)
{
_lightDirs[i * 3 + 0] = 0;
_lightDirs[i * 3 + 1] = 0;
_lightDirs[i * 3 + 2] = 0;
_lightPositions[i * 3 + 0] = 0;
_lightPositions[i * 3 + 1] = 0;
_lightPositions[i * 3 + 2] = 0;
_lightIntensities[i] = 0.0f;
_lightColors[i * 3 + 0] = 0;
_lightColors[i * 3 + 1] = 0;
_lightColors[i * 3 + 2] = 0;
_lightTypes[i] = 0;
_lightRanges[i] = 0;
}
_lightCount = count;
Raylib.SetShaderValue(_shader, _lightCountLoc, count, ShaderUniformDataType.Int);
Raylib.SetShaderValueV(_shader, _lightDirLoc, _lightDirs, ShaderUniformDataType.Vec3, 4);
Raylib.SetShaderValueV(_shader, _lightIntensityLoc, _lightIntensities, ShaderUniformDataType.Float, 4);
Raylib.SetShaderValueV(_shader, _lightColorLoc, _lightColors, ShaderUniformDataType.Vec3, 4);
Raylib.SetShaderValueV(_shader, _lightPosLoc, _lightPositions, ShaderUniformDataType.Vec3, 4);
Raylib.SetShaderValueV(_shader, _lightTypeLoc, _lightTypes, ShaderUniformDataType.Int, 4);
Raylib.SetShaderValueV(_shader, _lightRangeLoc, _lightRanges, ShaderUniformDataType.Float, 4);
}
private void SetFrameLights()
{
Raylib.SetShaderValue(_shader, _ambientLoc, new float[] { 0.35f, 0.35f, 0.4f }, ShaderUniformDataType.Vec3);
}
private unsafe void SetMaterialUniforms(EngineMaterial material, Raylib_cs.Model model)
{
Raylib.SetShaderValue(_shader, _materialColorLoc, new float[] { material.Albedo.X, material.Albedo.Y, material.Albedo.Z, 1.0f }, ShaderUniformDataType.Vec4);
Raylib.SetShaderValue(_shader, _roughnessLoc, material.Roughness, ShaderUniformDataType.Float);
Raylib.SetShaderValue(_shader, _metallicLoc, material.Metallic, ShaderUniformDataType.Float);
if (material.HasTexture && File.Exists(material.TexturePath!))
{
Raylib.SetShaderValue(_shader, _useTextureLoc, 1, ShaderUniformDataType.Int);
var texture = GetOrLoadTexture(material.TexturePath!);
Raylib.SetMaterialTexture(ref model.Materials[0], MaterialMapIndex.Albedo, texture);
}
else
{
Raylib.SetShaderValue(_shader, _useTextureLoc, 0, ShaderUniformDataType.Int);
}
}
private unsafe Raylib_cs.Model GetOrUploadModel(Entity e, EngineMesh mesh)
{
if (_modelCache.TryGetValue(e, out var model))
return model;
// Use Raylib's native mesh generation when possible — the manual UploadMesh
// + LoadModelFromMesh path is unreliable for larger meshes because
// LoadModelFromMesh reads CPU-side vertex pointers after UploadMesh.
// For custom meshes (from OBJ/GLTF loaders), keep the CPU data alive.
var raylibMesh = UploadRaylibMesh(mesh);
model = Raylib.LoadModelFromMesh(raylibMesh);
for (var i = 0; i < model.MaterialCount; i++)
{
model.Materials[i].Shader = _shader;
}
_modelCache[e] = model;
return model;
}
private unsafe Raylib_cs.Mesh UploadRaylibMesh(EngineMesh mesh)
{
var vertexCount = mesh.Vertices.Length;
var triangleCount = mesh.Indices.Length / 3;
var raylibMesh = new Raylib_cs.Mesh
{
VertexCount = vertexCount,
TriangleCount = triangleCount
};
var positionSize = vertexCount * 3 * sizeof(float);
var normalSize = vertexCount * 3 * sizeof(float);
var colorSize = vertexCount * 4;
var texcoordSize = vertexCount * 2 * sizeof(float);
var indexSize = mesh.Indices.Length * sizeof(ushort);
// Use NativeMemory.Alloc so Raylib's UnloadMesh can free with RL_FREE (free).
var positionPtr = (float*)NativeMemory.Alloc((nuint)positionSize, 4);
var normalPtr = (float*)NativeMemory.Alloc((nuint)normalSize, 4);
var colorPtr = (byte*)NativeMemory.Alloc((nuint)colorSize, 1);
var texcoordPtr = (float*)NativeMemory.Alloc((nuint)texcoordSize, 4);
var indexPtr = (ushort*)NativeMemory.Alloc((nuint)indexSize, 2);
for (var i = 0; i < vertexCount; i++)
{
var v = mesh.Vertices[i];
positionPtr[i * 3 + 0] = v.Position.X;
positionPtr[i * 3 + 1] = v.Position.Y;
positionPtr[i * 3 + 2] = v.Position.Z;
normalPtr[i * 3 + 0] = v.Normal.X;
normalPtr[i * 3 + 1] = v.Normal.Y;
normalPtr[i * 3 + 2] = v.Normal.Z;
colorPtr[i * 4 + 0] = (byte)Math.Clamp(v.Color.X * 255.0f, 0.0f, 255.0f);
colorPtr[i * 4 + 1] = (byte)Math.Clamp(v.Color.Y * 255.0f, 0.0f, 255.0f);
colorPtr[i * 4 + 2] = (byte)Math.Clamp(v.Color.Z * 255.0f, 0.0f, 255.0f);
colorPtr[i * 4 + 3] = 255;
texcoordPtr[i * 2 + 0] = v.Position.X;
texcoordPtr[i * 2 + 1] = v.Position.Z;
}
for (var i = 0; i < mesh.Indices.Length; i++)
indexPtr[i] = (ushort)mesh.Indices[i];
raylibMesh.Vertices = positionPtr;
raylibMesh.Normals = normalPtr;
raylibMesh.Colors = colorPtr;
raylibMesh.TexCoords = texcoordPtr;
raylibMesh.Indices = indexPtr;
Raylib.UploadMesh(ref raylibMesh, false);
// Keep CPU-side data alive — LoadModelFromMesh reads these pointers
// to compute the bounding box. They will be freed when the model is unloaded.
return raylibMesh;
}
private Texture2D GetOrLoadTexture(string path)
{
if (_textureCache.TryGetValue(path, out var texture))
return texture;
texture = Raylib.LoadTexture(path);
Raylib.SetTextureWrap(texture, TextureWrap.Repeat);
Raylib.SetTextureFilter(texture, TextureFilter.Trilinear);
_textureCache[path] = texture;
return texture;
}
private unsafe void CaptureScreenshot(ScreenshotRequest request)
{
var image = Raylib.LoadImageFromScreen();
try
{
var directory = Path.GetDirectoryName(request.Path);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
Raylib.ExportImage(image, request.Path);
if (request.Tcs != null)
{
var size = 0;
var fileType = stackalloc byte[] { (byte)'.', (byte)'p', (byte)'n', (byte)'g', 0 };
var data = Raylib.ExportImageToMemory(image, (sbyte*)fileType, &size);
var bytes = new byte[size];
fixed (byte* p = bytes)
{
Buffer.MemoryCopy(data, p, size, size);
}
Raylib.MemFree(data);
request.Tcs.TrySetResult(bytes);
}
Console.WriteLine($"Screenshot saved: {request.Path}");
}
finally
{
Raylib.UnloadImage(image);
}
}
private Task<byte[]> CaptureAsync(string outputPath)
{
var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingScreenshot = new ScreenshotRequest(outputPath, tcs);
return tcs.Task;
}
private static Shader LoadShadowShader()
{
const string VertexSource = @"#version 330 core
in vec3 vertexPosition;
uniform mat4 mvp;
void main()
{
gl_Position = mvp * vec4(vertexPosition, 1.0);
}";
const string FragmentSource = @"#version 330 core
void main() {}";
return Raylib.LoadShaderFromMemory(VertexSource, FragmentSource);
}
private static Shader LoadShader()
{
const string VertexSource = @"#version 330 core
in vec3 vertexPosition;
in vec2 vertexTexCoord;
in vec3 vertexNormal;
in vec4 vertexColor;
uniform mat4 mvp;
uniform mat4 matModel;
out vec3 vNormal;
out vec3 vWorldPos;
out vec4 vColor;
out vec2 vTexCoord;
void main()
{
vec4 worldPos = matModel * vec4(vertexPosition, 1.0);
vWorldPos = worldPos.xyz;
vNormal = mat3(transpose(inverse(matModel))) * vertexNormal;
vColor = vertexColor;
vTexCoord = vertexTexCoord;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}";
const string FragmentSource = @"#version 330 core
in vec3 vNormal;
in vec3 vWorldPos;
in vec4 vColor;
in vec2 vTexCoord;
out vec4 finalColor;
uniform vec4 materialColor;
uniform int useTexture;
uniform sampler2D texture0;
uniform float roughness;
uniform float metallic;
uniform vec3 viewPos;
uniform vec3 ambientColor;
uniform int lightCount;
uniform vec3 lightDirs[4];
uniform vec3 lightPositions[4];
uniform float lightIntensities[4];
uniform vec3 lightColors[4];
uniform int lightTypes[4];
uniform float lightRanges[4];
uniform mat4 lightViewProj;
uniform sampler2D shadowMap;
vec3 ACESFilm(vec3 x)
{
const float a = 2.51; const float b = 0.03; const float c = 2.43; const float d = 0.59; const float e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
float Attenuation(float dist, float range)
{
float r = max(range, 0.001);
float d = max(dist, 0.001);
float x = d / r;
float x2 = x * x;
float x4 = x2 * x2;
return clamp(1.0 / (1.0 + 25.0 * x4), 0.0, 1.0) * smoothstep(1.0, 0.0, x);
}
float CalculateShadow(vec3 worldPos)
{
vec4 lp = lightViewProj * vec4(worldPos, 1.0);
vec3 ndc = lp.xyz / lp.w;
vec3 uvw = ndc * 0.5 + 0.5;
if (uvw.x < 0.0 || uvw.x > 1.0 || uvw.y < 0.0 || uvw.y > 1.0 || uvw.z > 1.0)
return 1.0;
float bias = 0.005;
vec2 ts = vec2(1.0 / 2048.0);
float s = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
float d = texture(shadowMap, uvw.xy + vec2(x, y) * ts).r;
s += (uvw.z - bias > d) ? 0.3 : 1.0;
}
}
return s / 9.0;
}
void main()
{
vec3 normal = normalize(vNormal);
vec3 albedo = pow(vColor.rgb * materialColor.rgb, vec3(2.2));
if (useTexture != 0)
{
vec2 uv = vTexCoord * 4.0;
vec3 texColor = pow(texture(texture0, uv).rgb, vec3(2.2));
albedo *= texColor;
}
vec3 viewDir = normalize(viewPos - vWorldPos);
float rough = clamp(roughness, 0.05, 1.0);
float metal = clamp(metallic, 0.0, 1.0);
vec3 skyColor = ambientColor;
vec3 groundColor = ambientColor * 0.2;
float hemisphere = 0.5 + 0.5 * normal.y;
vec3 result = albedo * mix(groundColor, skyColor, hemisphere) * 0.4;
// Shadow factor for first directional light
float shadow = 1.0;
if (lightCount > 0 && lightTypes[0] == 0)
shadow = CalculateShadow(vWorldPos);
vec3 F0 = mix(vec3(0.04), albedo, metal);
float shininess = mix(8.0, 256.0, 1.0 - rough);
for (int i = 0; i < lightCount; i++)
{
vec3 L;
float atten = 1.0;
if (lightTypes[i] == 1)
{
vec3 toLight = lightPositions[i] - vWorldPos;
float dist = length(toLight);
L = toLight / max(dist, 0.001);
atten = Attenuation(dist, lightRanges[i]);
}
else
{
L = normalize(-lightDirs[i]);
}
float lightShadow = (i == 0 && lightTypes[0] == 0) ? shadow : 1.0;
vec3 H = normalize(L + viewDir);
float NdotL = max(dot(normal, L), 0.0);
float NdotH = max(dot(normal, H), 0.0);
float HdotV = max(dot(H, viewDir), 0.0);
float diff = NdotL;
float spec = pow(NdotH, shininess);
vec3 fresnel = F0 + (1.0 - F0) * pow(1.0 - HdotV, 5.0);
vec3 specularColor = mix(fresnel, albedo * fresnel, metal);
vec3 diffuse = albedo * lightColors[i] * diff * lightIntensities[i] * atten * 1.5 * lightShadow;
vec3 specular = specularColor * spec * lightIntensities[i] * atten * lightShadow;
diffuse *= (1.0 - fresnel * (1.0 - metal * 0.5));
result += diffuse + specular;
}
result = ACESFilm(result * 1.2);
result = pow(result, vec3(1.0 / 2.2));
finalColor = vec4(result, 1.0);
}";
return Raylib.LoadShaderFromMemory(VertexSource, FragmentSource);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var model in _modelCache.Values)
Raylib.UnloadModel(model);
_modelCache.Clear();
foreach (var texture in _textureCache.Values)
Raylib.UnloadTexture(texture);
_textureCache.Clear();
Raylib.UnloadShader(_shadowShader);
Rlgl.UnloadTexture(_shadowDepthTex);
Rlgl.UnloadFramebuffer(_shadowFbo);
Raylib.UnloadShader(_shader);
}
private readonly record struct ScreenshotRequest(string Path, TaskCompletionSource<byte[]>? Tcs);
private sealed class RaylibScreenshotProvider : IScreenshotProvider
{
private readonly RaylibRenderer _renderer;
public RaylibScreenshotProvider(RaylibRenderer renderer)
{
_renderer = renderer;
}
public Task<byte[]> CaptureAsync(string outputPath) => _renderer.CaptureAsync(outputPath);
}
}
@@ -1,54 +0,0 @@
using System;
using Engine.Core;
using Raylib_cs;
namespace Engine.Graphics.RaylibBackend;
/// <summary>
/// Raylib-backed implementation of <see cref="IWindow"/>.
/// Wraps Raylib's GLFW window creation, event polling, and input.
/// </summary>
public sealed class RaylibWindow : IWindow
{
private readonly RaylibInputState _input = new();
private bool _shouldClose;
private bool _disposed;
public int Width => Raylib.GetScreenWidth();
public int Height => Raylib.GetScreenHeight();
public bool ShouldClose => _shouldClose;
public IInputState Input => _input;
public nint Handle => 0;
public RaylibWindow(string title, int width, int height)
{
Raylib.SetConfigFlags(ConfigFlags.VSyncHint);
Raylib.InitWindow(width, height, title);
Raylib.SetTargetFPS(0);
// Present a blank frame so the window is visible immediately.
Raylib.BeginDrawing();
Raylib.ClearBackground(new Color(25, 30, 40, 255));
Raylib.EndDrawing();
}
public void PumpEvents()
{
_input.Poll();
_shouldClose = Raylib.WindowShouldClose() || _shouldClose;
if (Raylib.IsKeyPressed(KeyboardKey.Escape))
_shouldClose = true;
}
public void Close() => _shouldClose = true;
public string[] GetRequiredVulkanExtensions() => Array.Empty<string>();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Raylib.CloseWindow();
}
}
@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>true</IsAotCompatible>
<AssemblyName>Engine.Graphics.Vulkan</AssemblyName>
<RootNamespace>Engine.Graphics.Vulkan</RootNamespace>
</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="Silk.NET.Vulkan" Version="2.21.0" />
<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' OR '$(Configuration)' == 'ReleaseAOT'" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.6" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Shaders\*.spv" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
-113
View File
@@ -1,113 +0,0 @@
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);
}
}
@@ -1,310 +0,0 @@
using System;
using System.IO;
using Engine.Core;
using Silk.NET.Core;
using Silk.NET.Vulkan;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace Engine.Graphics;
/// <summary>
/// Captures the current swapchain image to a PNG file on disk.
/// Used by AI agents to visually inspect the running engine.
/// </summary>
public sealed unsafe class ScreenshotCapture : IDisposable, IScreenshotProvider
{
private readonly VulkanContext _context;
private readonly Swapchain _swapchain;
private Silk.NET.Vulkan.Buffer _stagingBuffer;
private DeviceMemory _stagingMemory;
private ulong _stagingSize;
private bool _requested;
private string _outputPath = string.Empty;
private bool _ready;
private bool _captureToMemory;
private MemoryStream? _memoryOutput;
private TaskCompletionSource<byte[]>? _captureTcs;
public ScreenshotCapture(VulkanContext context, Swapchain swapchain)
{
_context = context;
_swapchain = swapchain;
}
/// <summary>
/// Request a screenshot to be captured on the next frame and saved to disk.
/// </summary>
public void Request(string outputPath)
{
_outputPath = outputPath;
_requested = true;
_ready = false;
_captureToMemory = false;
_memoryOutput = null;
_captureTcs = null;
}
/// <summary>
/// Request a screenshot of the next rendered frame. The returned task completes once the
/// PNG bytes are available. The image is also saved to <paramref name="outputPath"/> on disk.
/// </summary>
public Task<byte[]> CaptureAsync(string outputPath)
{
_outputPath = outputPath;
_requested = true;
_ready = false;
_captureToMemory = true;
_memoryOutput = new MemoryStream();
_captureTcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
return _captureTcs.Task;
}
/// <summary>
/// True if a screenshot has been requested but not yet saved.
/// </summary>
public bool IsRequested => _requested;
/// <summary>
/// Records the image readback commands into the given command buffer.
/// Must be called after the render pass has ended and before the image is presented.
/// </summary>
public void RecordReadback(CommandBuffer cmd, Silk.NET.Vulkan.Image sourceImage, uint width, uint height, Format format)
{
if (!_requested)
return;
var pixelSize = GetPixelSize(format);
var rowPitch = width * pixelSize;
var imageSize = rowPitch * height;
EnsureStagingBuffer(imageSize);
// Transition from present layout to transfer source.
var barrier = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
OldLayout = ImageLayout.PresentSrcKhr,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcAccessMask = AccessFlags.None,
DstAccessMask = AccessFlags.TransferReadBit,
Image = sourceImage,
SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1)
};
_context.Vk.CmdPipelineBarrier(cmd, PipelineStageFlags.TransferBit, PipelineStageFlags.TransferBit, 0, 0, null, 0, null, 1, &barrier);
var copyRegion = new BufferImageCopy
{
BufferOffset = 0,
BufferRowLength = 0,
BufferImageHeight = 0,
ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
ImageOffset = new Offset3D(0, 0, 0),
ImageExtent = new Extent3D(width, height, 1)
};
_context.Vk.CmdCopyImageToBuffer(cmd, sourceImage, ImageLayout.TransferSrcOptimal, _stagingBuffer, 1, &copyRegion);
// Transition back to present layout.
barrier.OldLayout = ImageLayout.TransferSrcOptimal;
barrier.NewLayout = ImageLayout.PresentSrcKhr;
barrier.SrcAccessMask = AccessFlags.TransferReadBit;
barrier.DstAccessMask = AccessFlags.None;
_context.Vk.CmdPipelineBarrier(cmd, PipelineStageFlags.TransferBit, PipelineStageFlags.TransferBit, 0, 0, null, 0, null, 1, &barrier);
_ready = true;
}
/// <summary>
/// Save the captured pixels to disk. Must be called after the command buffer containing the readback has finished.
/// If the request was made with <see cref="CaptureAsync"/> the PNG bytes are also written to memory and the task is completed.
/// </summary>
public void Save(uint width, uint height, Format format)
{
if (!_ready)
return;
var pixelSize = GetPixelSize(format);
var rowPitch = width * pixelSize;
var imageSize = rowPitch * height;
void* mappedData;
var result = _context.Vk.MapMemory(_context.Device, _stagingMemory, 0, imageSize, MemoryMapFlags.None, &mappedData);
if (result != Result.Success)
throw new InvalidOperationException($"vkMapMemory failed: {result}");
try
{
var directory = Path.GetDirectoryName(_outputPath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
SavePixels(mappedData, width, height, rowPitch, format);
}
finally
{
_context.Vk.UnmapMemory(_context.Device, _stagingMemory);
}
Console.WriteLine($"Screenshot saved: {_outputPath}");
_requested = false;
_ready = false;
_captureToMemory = false;
_memoryOutput = null;
_captureTcs = null;
}
private void SavePixels(void* mappedData, uint width, uint height, uint rowPitch, Format format)
{
using var image = CreateImage(mappedData, width, height, rowPitch, format);
image.SaveAsPng(_outputPath);
if (_captureToMemory && _memoryOutput != null)
{
image.SaveAsPng(_memoryOutput);
var bytes = _memoryOutput.ToArray();
_captureTcs?.TrySetResult(bytes);
}
}
private SixLabors.ImageSharp.Image<Rgba32> CreateImage(void* mappedData, uint width, uint height, uint rowPitch, Format format)
{
var image = new SixLabors.ImageSharp.Image<Rgba32>((int)width, (int)height);
var src = (byte*)mappedData;
if (format == Format.B8G8R8A8Unorm || format == Format.B8G8R8A8Srgb)
{
for (var y = 0; y < height; y++)
{
var rowStart = src + y * rowPitch;
for (var x = 0; x < width; x++)
{
var b = rowStart[x * 4 + 0];
var g = rowStart[x * 4 + 1];
var r = rowStart[x * 4 + 2];
var a = rowStart[x * 4 + 3];
image[x, y] = new Rgba32(r, g, b, a);
}
}
return image;
}
if (format == Format.R8G8B8A8Unorm || format == Format.R8G8B8A8Srgb)
{
for (var y = 0; y < height; y++)
{
var rowStart = src + y * rowPitch;
for (var x = 0; x < width; x++)
{
var r = rowStart[x * 4 + 0];
var g = rowStart[x * 4 + 1];
var b = rowStart[x * 4 + 2];
var a = rowStart[x * 4 + 3];
image[x, y] = new Rgba32(r, g, b, a);
}
}
return image;
}
image.Dispose();
throw new NotSupportedException($"Screenshot format {format} is not supported.");
}
private void EnsureStagingBuffer(ulong size)
{
if (_stagingSize >= size)
return;
if (_stagingBuffer.Handle != 0)
{
_context.Vk.DestroyBuffer(_context.Device, _stagingBuffer, null);
_context.Vk.FreeMemory(_context.Device, _stagingMemory, null);
}
_stagingSize = size;
_stagingBuffer = CreateBuffer(size, BufferUsageFlags.TransferDstBit);
var requirements = GetMemoryRequirements(_stagingBuffer);
_stagingMemory = AllocateMemory(requirements, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit);
var result = _context.Vk.BindBufferMemory(_context.Device, _stagingBuffer, _stagingMemory, 0);
if (result != Result.Success)
throw new InvalidOperationException($"vkBindBufferMemory failed: {result}");
}
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 allocInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = memoryTypeIndex
};
DeviceMemory memory;
var result = _context.Vk.AllocateMemory(_context.Device, &allocInfo, 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 static uint GetPixelSize(Format format)
{
return format switch
{
Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb or Format.R8G8B8A8Unorm or Format.R8G8B8A8Srgb => 4,
_ => throw new NotSupportedException($"Format {format} is not supported for screenshots.")
};
}
public void Dispose()
{
if (_stagingBuffer.Handle != 0)
{
_context.Vk.DeviceWaitIdle(_context.Device);
_context.Vk.DestroyBuffer(_context.Device, _stagingBuffer, null);
_context.Vk.FreeMemory(_context.Device, _stagingMemory, null);
}
}
}
@@ -1,24 +0,0 @@
using System;
using System.IO;
using System.Reflection;
namespace Engine.Graphics;
/// <summary>
/// Loads SPIR-V shader bytecode embedded in the assembly.
/// </summary>
public static class ShaderLoader
{
public static byte[] Load(string name)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"Engine.Graphics.Vulkan.Shaders.{name}";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Embedded shader resource not found: {resourceName}");
using var memory = new MemoryStream();
stream.CopyTo(memory);
return memory.ToArray();
}
}
@@ -1,69 +0,0 @@
#version 450
layout(location = 0) in vec3 fragColor;
layout(location = 1) in vec3 fragNormal;
layout(location = 2) in vec3 fragWorldPos;
layout(location = 3) in vec2 fragUv;
layout(location = 0) out vec4 outColor;
struct Light
{
vec3 direction;
float intensity;
vec3 color;
float _pad;
};
layout(set = 0, binding = 0) uniform FrameConstants
{
vec3 cameraPosition;
uint lightCount;
vec3 ambientColor;
float _pad;
Light lights[4];
} frame;
layout(set = 1, binding = 0) uniform sampler2D albedoTexture;
layout(push_constant) uniform PushConstants
{
mat4 mvp;
vec3 materialAlbedo;
float materialRoughness;
float materialMetallic;
uint useTexture;
uint textureIndex;
uint _pad0;
uint _pad1;
} push;
void main()
{
vec3 normal = normalize(fragNormal);
vec3 viewDir = normalize(frame.cameraPosition - fragWorldPos);
vec3 albedo = fragColor * push.materialAlbedo;
if (push.useTexture != 0u)
{
albedo *= texture(albedoTexture, fragUv).rgb;
}
float roughness = clamp(push.materialRoughness, 0.05, 1.0);
float metallic = clamp(push.materialMetallic, 0.0, 1.0);
vec3 result = frame.ambientColor * albedo;
for (uint i = 0u; i < frame.lightCount; i++)
{
vec3 lightDir = normalize(-frame.lights[i].direction);
vec3 halfDir = normalize(lightDir + viewDir);
float diff = max(dot(normal, lightDir), 0.0);
float spec = pow(max(dot(normal, halfDir), 0.0), mix(8.0, 128.0, 1.0 - roughness)) * mix(0.5, 1.0, metallic);
vec3 diffuse = frame.lights[i].color * diff * frame.lights[i].intensity;
vec3 specular = frame.lights[i].color * spec * frame.lights[i].intensity;
result += diffuse * albedo + specular;
}
outColor = vec4(result, 1.0);
}
Binary file not shown.
Binary file not shown.
@@ -1,48 +0,0 @@
#version 450
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 2) in vec3 inNormal;
layout(location = 0) out vec3 fragColor;
layout(location = 1) out vec3 fragNormal;
layout(location = 2) out vec3 fragWorldPos;
layout(location = 3) out vec2 fragUv;
struct Light
{
vec3 direction;
float intensity;
vec3 color;
float _pad;
};
layout(set = 0, binding = 0) uniform FrameConstants
{
vec3 cameraPosition;
uint lightCount;
vec3 ambientColor;
float _pad;
Light lights[4];
} frame;
layout(push_constant) uniform PushConstants
{
mat4 mvp;
vec3 materialAlbedo;
float materialRoughness;
float materialMetallic;
uint useTexture;
uint textureIndex;
uint _pad0;
uint _pad1;
} push;
void main()
{
gl_Position = push.mvp * vec4(inPosition, 1.0);
fragColor = inColor;
fragNormal = inNormal;
fragWorldPos = inPosition;
fragUv = inPosition.xz * 0.5 + 0.5;
}
-446
View File
@@ -1,446 +0,0 @@
using System;
using Silk.NET.Core;
using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.KHR;
namespace Engine.Graphics;
/// <summary>
/// Manages the Vulkan swapchain, image views, render pass, and framebuffers.
/// Uses Silk.NET.Vulkan.
/// </summary>
public sealed unsafe class Swapchain : IDisposable
{
private readonly VulkanContext _context;
private RenderPass _renderPass;
private SwapchainKHR _swapchain;
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;
public RenderPass RenderPass => _renderPass;
public Framebuffer[] Framebuffers => _framebuffers;
public Extent2D Extent => _extent;
public SwapchainKHR Handle => _swapchain;
public uint ImageCount => (uint)_images.Length;
public Format SurfaceFormat => _surfaceFormat.Format;
public Image GetImage(uint index) => _images[index];
public Swapchain(VulkanContext context)
{
_context = context;
_surfaceFormat = ChooseSurfaceFormat();
_depthFormat = FindDepthFormat();
CreateRenderPass();
Recreate(1280, 720);
}
public void Recreate(int width, int height)
{
_context.Vk.DeviceWaitIdle(_context.Device);
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 SwapchainCreateInfoKHR
{
SType = StructureType.SwapchainCreateInfoKhr,
Surface = _context.Surface,
MinImageCount = imageCount,
ImageFormat = _surfaceFormat.Format,
ImageColorSpace = _surfaceFormat.ColorSpace,
ImageExtent = _extent,
ImageArrayLayers = 1,
ImageUsage = ImageUsageFlags.ColorAttachmentBit,
ImageSharingMode = SharingMode.Exclusive,
PreTransform = capabilities.CurrentTransform,
CompositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr,
PresentMode = _presentMode,
Clipped = true,
OldSwapchain = _swapchain
};
SwapchainKHR swapchain;
var result = _context.KhrSwapchain!.CreateSwapchain(_context.Device, &createInfo, null, &swapchain);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateSwapchainKHR failed: {result}");
_swapchain = swapchain;
_images = GetSwapchainImages();
_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);
_framebuffers[i] = CreateFramebuffer(_imageViews[i]);
}
}
private SurfaceCapabilitiesKHR GetSurfaceCapabilities()
{
SurfaceCapabilitiesKHR capabilities;
var result = _context.KhrSurface!.GetPhysicalDeviceSurfaceCapabilities(_context.PhysicalDevice, _context.Surface, &capabilities);
if (result != Result.Success)
throw new InvalidOperationException($"vkGetPhysicalDeviceSurfaceCapabilitiesKHR failed: {result}");
return capabilities;
}
private Image[] GetSwapchainImages()
{
uint count = 0;
_context.KhrSwapchain!.GetSwapchainImages(_context.Device, _swapchain, &count, null);
var images = new Image[count];
fixed (Image* p = images)
{
var result = _context.KhrSwapchain!.GetSwapchainImages(_context.Device, _swapchain, &count, p);
if (result != Result.Success)
throw new InvalidOperationException($"vkGetSwapchainImagesKHR failed: {result}");
}
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
{
Format = _surfaceFormat.Format != Format.Undefined ? _surfaceFormat.Format : Format.B8G8R8A8Unorm,
Samples = SampleCountFlags.Count1Bit,
LoadOp = AttachmentLoadOp.Clear,
StoreOp = AttachmentStoreOp.Store,
StencilLoadOp = AttachmentLoadOp.DontCare,
StencilStoreOp = AttachmentStoreOp.DontCare,
InitialLayout = ImageLayout.Undefined,
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,
PDepthStencilAttachment = &depthAttachmentRef
};
var dependency = new SubpassDependency
{
SrcSubpass = ~0u,
DstSubpass = 0,
SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit,
SrcAccessMask = AccessFlags.None,
DstAccessMask = AccessFlags.ColorAttachmentWriteBit
};
fixed (AttachmentDescription* pAttachments = attachments)
{
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;
}
}
private ImageView CreateImageView(Image image, Format format)
{
var createInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = format,
Components = new ComponentMapping(ComponentSwizzle.R, ComponentSwizzle.G, ComponentSwizzle.B, ComponentSwizzle.A),
SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 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}");
return imageView;
}
private Framebuffer CreateFramebuffer(ImageView imageView)
{
var attachments = new[] { imageView, _depthImageView };
fixed (ImageView* pAttachments = attachments)
{
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;
}
}
private SurfaceFormatKHR ChooseSurfaceFormat()
{
var formats = GetSurfaceFormats();
foreach (var format in formats)
{
if (format.Format == Format.B8G8R8A8Unorm && format.ColorSpace == ColorSpaceKHR.SpaceSrgbNonlinearKhr)
return format;
}
return formats[0];
}
private SurfaceFormatKHR[] GetSurfaceFormats()
{
uint count = 0;
_context.KhrSurface!.GetPhysicalDeviceSurfaceFormats(_context.PhysicalDevice, _context.Surface, &count, null);
var formats = new SurfaceFormatKHR[count];
fixed (SurfaceFormatKHR* p = formats)
{
var result = _context.KhrSurface!.GetPhysicalDeviceSurfaceFormats(_context.PhysicalDevice, _context.Surface, &count, p);
if (result != Result.Success)
throw new InvalidOperationException($"vkGetPhysicalDeviceSurfaceFormatsKHR failed: {result}");
}
return formats;
}
private PresentModeKHR ChoosePresentMode()
{
var modes = GetSurfacePresentModes();
if (Array.Exists(modes, m => m == PresentModeKHR.MailboxKhr))
return PresentModeKHR.MailboxKhr;
return PresentModeKHR.FifoKhr;
}
private PresentModeKHR[] GetSurfacePresentModes()
{
uint count = 0;
_context.KhrSurface!.GetPhysicalDeviceSurfacePresentModes(_context.PhysicalDevice, _context.Surface, &count, null);
var modes = new PresentModeKHR[count];
fixed (PresentModeKHR* p = modes)
{
var result = _context.KhrSurface!.GetPhysicalDeviceSurfacePresentModes(_context.PhysicalDevice, _context.Surface, &count, p);
if (result != Result.Success)
throw new InvalidOperationException($"vkGetPhysicalDeviceSurfacePresentModesKHR failed: {result}");
}
return modes;
}
private Extent2D ChooseExtent(SurfaceCapabilitiesKHR capabilities, uint width, uint height)
{
if (capabilities.CurrentExtent.Width != uint.MaxValue)
return capabilities.CurrentExtent;
var extent = new Extent2D
{
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.Handle == 0)
return;
if (_framebuffers != null)
{
foreach (var fb in _framebuffers)
{
if (fb.Handle != 0)
_context.Vk.DestroyFramebuffer(_context.Device, fb, null);
}
}
CleanupDepthResources();
if (_imageViews != null)
{
foreach (var view in _imageViews)
{
if (view.Handle != 0)
_context.Vk.DestroyImageView(_context.Device, view, null);
}
}
if (_swapchain.Handle != 0)
_context.KhrSwapchain!.DestroySwapchain(_context.Device, _swapchain, null);
}
public void Dispose()
{
_context.Vk.DeviceWaitIdle(_context.Device);
CleanupSwapchain();
if (_renderPass.Handle != 0)
_context.Vk.DestroyRenderPass(_context.Device, _renderPass, null);
}
}
-330
View File
@@ -1,330 +0,0 @@
using System;
using System.Runtime.InteropServices;
using Silk.NET.Vulkan;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace Engine.Graphics;
/// <summary>
/// A Vulkan texture: image, device memory, image view, and sampler.
/// </summary>
public sealed unsafe class Texture : IDisposable
{
private readonly VulkanContext _context;
public Silk.NET.Vulkan.Image Image { get; }
public DeviceMemory Memory { get; }
public ImageView View { get; }
public Sampler Sampler { get; }
public uint Width { get; }
public uint Height { get; }
public Texture(VulkanContext context, string path)
{
_context = context;
using var image = SixLabors.ImageSharp.Image.Load<Rgba32>(path);
Width = (uint)image.Width;
Height = (uint)image.Height;
var pixels = new byte[Width * Height * 4];
image.CopyPixelDataTo(pixels);
Image = CreateImage(Width, Height);
var memoryRequirements = GetImageMemoryRequirements(Image);
Memory = AllocateMemory(memoryRequirements, MemoryPropertyFlags.DeviceLocalBit);
var bindResult = _context.Vk.BindImageMemory(_context.Device, Image, Memory, 0);
if (bindResult != Result.Success)
throw new InvalidOperationException($"vkBindImageMemory failed: {bindResult}");
UploadPixels(pixels);
View = CreateImageView(Image);
Sampler = CreateSampler();
}
private Silk.NET.Vulkan.Image CreateImage(uint width, uint height)
{
var createInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Extent = new Extent3D(width, height, 1),
MipLevels = 1,
ArrayLayers = 1,
Format = Format.R8G8B8A8Srgb,
Tiling = ImageTiling.Optimal,
InitialLayout = ImageLayout.Undefined,
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit,
SharingMode = SharingMode.Exclusive,
Samples = SampleCountFlags.Count1Bit
};
Silk.NET.Vulkan.Image image;
var result = _context.Vk.CreateImage(_context.Device, &createInfo, null, &image);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateImage failed: {result}");
return image;
}
private MemoryRequirements GetImageMemoryRequirements(Silk.NET.Vulkan.Image image)
{
MemoryRequirements requirements;
_context.Vk.GetImageMemoryRequirements(_context.Device, image, &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 for texture.");
}
private void UploadPixels(byte[] pixels)
{
var imageSize = (ulong)pixels.Length;
var stagingBuffer = CreateBuffer(imageSize, BufferUsageFlags.TransferSrcBit);
var stagingMemory = AllocateStagingMemory(stagingBuffer);
var bindResult = _context.Vk.BindBufferMemory(_context.Device, stagingBuffer, stagingMemory, 0);
if (bindResult != Result.Success)
throw new InvalidOperationException($"vkBindBufferMemory for staging failed: {bindResult}");
void* mappedData;
var mapResult = _context.Vk.MapMemory(_context.Device, stagingMemory, 0, imageSize, MemoryMapFlags.None, &mappedData);
if (mapResult != Result.Success)
throw new InvalidOperationException($"vkMapMemory failed: {mapResult}");
fixed (byte* src = pixels)
{
global::System.Buffer.MemoryCopy(src, mappedData, (long)imageSize, pixels.Length);
}
_context.Vk.UnmapMemory(_context.Device, stagingMemory);
ExecuteOneTimeCommand(cmd =>
{
TransitionImageLayout(cmd, Image, ImageLayout.Undefined, ImageLayout.TransferDstOptimal);
var bufferCopy = new BufferImageCopy
{
BufferOffset = 0,
BufferRowLength = 0,
BufferImageHeight = 0,
ImageSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
MipLevel = 0,
BaseArrayLayer = 0,
LayerCount = 1
},
ImageOffset = new Offset3D(0, 0, 0),
ImageExtent = new Extent3D(Width, Height, 1)
};
_context.Vk.CmdCopyBufferToImage(cmd, stagingBuffer, Image, ImageLayout.TransferDstOptimal, 1, &bufferCopy);
TransitionImageLayout(cmd, Image, ImageLayout.TransferDstOptimal, ImageLayout.ShaderReadOnlyOptimal);
});
_context.Vk.FreeMemory(_context.Device, stagingMemory, null);
_context.Vk.DestroyBuffer(_context.Device, stagingBuffer, null);
}
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 DeviceMemory AllocateStagingMemory(Silk.NET.Vulkan.Buffer buffer)
{
var requirements = GetBufferMemoryRequirements(buffer);
return AllocateMemory(requirements, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit);
}
private MemoryRequirements GetBufferMemoryRequirements(Silk.NET.Vulkan.Buffer buffer)
{
MemoryRequirements requirements;
_context.Vk.GetBufferMemoryRequirements(_context.Device, buffer, &requirements);
return requirements;
}
private void TransitionImageLayout(CommandBuffer cmd, Silk.NET.Vulkan.Image image, ImageLayout oldLayout, ImageLayout newLayout)
{
var barrier = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
OldLayout = oldLayout,
NewLayout = newLayout,
SrcQueueFamilyIndex = uint.MaxValue,
DstQueueFamilyIndex = uint.MaxValue,
Image = image,
SubresourceRange = new ImageSubresourceRange
{
AspectMask = ImageAspectFlags.ColorBit,
BaseMipLevel = 0,
LevelCount = 1,
BaseArrayLayer = 0,
LayerCount = 1
}
};
var srcStage = PipelineStageFlags.TopOfPipeBit;
var dstStage = PipelineStageFlags.TransferBit;
AccessFlags srcAccessMask = 0;
AccessFlags dstAccessMask = AccessFlags.TransferWriteBit;
if (oldLayout == ImageLayout.TransferDstOptimal && newLayout == ImageLayout.ShaderReadOnlyOptimal)
{
srcStage = PipelineStageFlags.TransferBit;
dstStage = PipelineStageFlags.FragmentShaderBit;
srcAccessMask = AccessFlags.TransferWriteBit;
dstAccessMask = AccessFlags.ShaderReadBit;
}
barrier.SrcAccessMask = srcAccessMask;
barrier.DstAccessMask = dstAccessMask;
_context.Vk.CmdPipelineBarrier(cmd, srcStage, dstStage, 0, 0, null, 0, null, 1, &barrier);
}
private void ExecuteOneTimeCommand(Action<CommandBuffer> action)
{
var allocInfo = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = _context.CommandPool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = 1
};
CommandBuffer commandBuffer;
var result = _context.Vk.AllocateCommandBuffers(_context.Device, &allocInfo, &commandBuffer);
if (result != Result.Success)
throw new InvalidOperationException($"vkAllocateCommandBuffers failed: {result}");
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit
};
_context.Vk.BeginCommandBuffer(commandBuffer, &beginInfo);
action(commandBuffer);
_context.Vk.EndCommandBuffer(commandBuffer);
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
CommandBufferCount = 1,
PCommandBuffers = &commandBuffer
};
_context.Vk.QueueSubmit(_context.GraphicsQueue, 1, &submitInfo, new Fence());
_context.Vk.QueueWaitIdle(_context.GraphicsQueue);
_context.Vk.FreeCommandBuffers(_context.Device, _context.CommandPool, 1, &commandBuffer);
}
private ImageView CreateImageView(Silk.NET.Vulkan.Image image)
{
var createInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = Format.R8G8B8A8Srgb,
SubresourceRange = new ImageSubresourceRange
{
AspectMask = ImageAspectFlags.ColorBit,
BaseMipLevel = 0,
LevelCount = 1,
BaseArrayLayer = 0,
LayerCount = 1
}
};
ImageView view;
var result = _context.Vk.CreateImageView(_context.Device, &createInfo, null, &view);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateImageView failed: {result}");
return view;
}
private Sampler CreateSampler()
{
var createInfo = new SamplerCreateInfo
{
SType = StructureType.SamplerCreateInfo,
MagFilter = Filter.Linear,
MinFilter = Filter.Linear,
AddressModeU = SamplerAddressMode.Repeat,
AddressModeV = SamplerAddressMode.Repeat,
AddressModeW = SamplerAddressMode.Repeat,
AnisotropyEnable = false,
BorderColor = BorderColor.IntOpaqueBlack,
UnnormalizedCoordinates = false,
CompareEnable = false,
MipmapMode = SamplerMipmapMode.Linear,
MipLodBias = 0.0f,
MinLod = 0.0f,
MaxLod = 1.0f
};
Sampler sampler;
var result = _context.Vk.CreateSampler(_context.Device, &createInfo, null, &sampler);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateSampler failed: {result}");
return sampler;
}
public void Dispose()
{
_context.Vk.DeviceWaitIdle(_context.Device);
_context.Vk.DestroySampler(_context.Device, Sampler, null);
_context.Vk.DestroyImageView(_context.Device, View, null);
_context.Vk.DestroyImage(_context.Device, Image, null);
_context.Vk.FreeMemory(_context.Device, Memory, null);
}
}
-110
View File
@@ -1,110 +0,0 @@
using System;
using Silk.NET.Vulkan;
namespace Engine.Graphics;
/// <summary>
/// A host-visible, coherent Vulkan buffer for uniform data that is updated every frame.
/// </summary>
public sealed unsafe class UniformBuffer : IDisposable
{
private readonly VulkanContext _context;
public Silk.NET.Vulkan.Buffer Buffer { get; }
public DeviceMemory Memory { get; }
public ulong Size { get; }
public UniformBuffer(VulkanContext context, ulong size)
{
_context = context;
Size = size;
Buffer = CreateBuffer(Size, BufferUsageFlags.UniformBufferBit);
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}");
}
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 for uniform buffer.");
}
public void Update(ReadOnlySpan<byte> data)
{
if ((ulong)data.Length != Size)
throw new ArgumentException($"Uniform buffer update size mismatch: {data.Length} != {Size}");
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);
}
}
-119
View File
@@ -1,119 +0,0 @@
using System;
using Silk.NET.Core;
using Silk.NET.Vulkan;
namespace Engine.Graphics;
/// <summary>
/// Interleaved vertex buffer: vec2 position + vec3 color.
/// Uses Silk.NET.Vulkan.
/// </summary>
public sealed unsafe class VertexBuffer : IDisposable
{
private readonly VulkanContext _context;
public Silk.NET.Vulkan.Buffer Buffer { get; }
public DeviceMemory Memory { get; }
public ulong Size { get; }
public VertexBuffer(VulkanContext context, ReadOnlySpan<byte> data)
{
_context = context;
Size = (ulong)data.Length;
Buffer = CreateBuffer(Size, BufferUsageFlags.VertexBufferBit);
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 Update(ReadOnlySpan<byte> data)
{
if ((ulong)data.Length != Size)
throw new ArgumentException($"Vertex buffer update size mismatch: {data.Length} != {Size}");
CopyData(data);
}
public void Dispose()
{
_context.Vk.DeviceWaitIdle(_context.Device);
_context.Vk.DestroyBuffer(_context.Device, Buffer, null);
_context.Vk.FreeMemory(_context.Device, Memory, null);
}
}
@@ -1,20 +0,0 @@
using Engine.Graphics;
namespace Engine.Graphics.Vulkan;
/// <summary>
/// Triggers registration of the Vulkan backend with the HAL factory.
/// </summary>
public static class VulkanBackendRegistrar
{
static VulkanBackendRegistrar()
{
RenderBackendFactory.Register("vulkan", (width, height, enableValidation) => new VulkanRenderContext(width, height, enableValidation));
}
/// <summary>
/// No-op method that forces the static constructor to run.
/// Call this before using <see cref="RenderBackendFactory.Create"/>.
/// </summary>
public static void EnsureRegistered() { }
}
-321
View File
@@ -1,321 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using Engine.Core;
using SDL;
using Silk.NET.Core;
using Silk.NET.Core.Native;
using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.KHR;
namespace Engine.Graphics;
/// <summary>
/// Owns the Vulkan instance, physical device, logical device, queues, and surface.
/// Uses Silk.NET.Vulkan because Vortice.Vulkan's loader segfaulted on this Kubuntu setup.
/// </summary>
public sealed unsafe class VulkanContext : IDisposable
{
private bool _disposed;
public Vk Vk { get; }
public KhrSurface? KhrSurface { get; private set; }
public KhrSwapchain? KhrSwapchain { get; private set; }
public Instance Instance { get; private set; }
public PhysicalDevice PhysicalDevice { get; private set; }
public Device Device { get; private set; }
public Queue GraphicsQueue { get; private set; }
public Queue PresentQueue { get; private set; }
public SurfaceKHR Surface { get; private set; }
public uint GraphicsFamilyIndex { get; private set; }
public uint PresentFamilyIndex { get; private set; }
public CommandPool CommandPool { get; private set; }
public VulkanContext(IWindow window, bool enableValidation = true)
{
Vk = Vk.GetApi();
CreateInstance(window, enableValidation);
LoadInstanceExtensions();
CreateSurface(window);
PickPhysicalDevice();
CreateLogicalDevice();
LoadDeviceExtensions();
GetQueues();
CreateCommandPool();
}
private void CreateCommandPool()
{
var createInfo = new CommandPoolCreateInfo
{
SType = StructureType.CommandPoolCreateInfo,
QueueFamilyIndex = GraphicsFamilyIndex,
Flags = CommandPoolCreateFlags.ResetCommandBufferBit
};
CommandPool commandPool;
var result = Vk.CreateCommandPool(Device, &createInfo, null, &commandPool);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateCommandPool failed: {result}");
CommandPool = commandPool;
}
private void CreateInstance(IWindow window, bool enableValidation)
{
var requiredExtensions = new List<string>(window.GetRequiredVulkanExtensions());
if (enableValidation)
{
requiredExtensions.Add("VK_EXT_debug_utils");
}
var layerNames = enableValidation
? new[] { "VK_LAYER_KHRONOS_validation" }
: Array.Empty<string>();
var appName = SilkMarshal.StringToMemory("Cortex Engine", NativeStringEncoding.UTF8);
var engineName = SilkMarshal.StringToMemory("CortexEngine", NativeStringEncoding.UTF8);
var extensionMemory = SilkMarshal.StringArrayToMemory(requiredExtensions, NativeStringEncoding.UTF8);
var layerMemory = SilkMarshal.StringArrayToMemory(layerNames, NativeStringEncoding.UTF8);
try
{
var appInfo = new ApplicationInfo
{
SType = StructureType.ApplicationInfo,
PApplicationName = (byte*)appName.Handle,
PEngineName = (byte*)engineName.Handle,
ApiVersion = Vk.Version13
};
var createInfo = new InstanceCreateInfo
{
SType = StructureType.InstanceCreateInfo,
PApplicationInfo = &appInfo,
EnabledExtensionCount = (uint)requiredExtensions.Count,
PpEnabledExtensionNames = (byte**)extensionMemory.Handle,
EnabledLayerCount = (uint)layerNames.Length,
PpEnabledLayerNames = (byte**)layerMemory.Handle
};
Instance instance;
var result = Vk.CreateInstance(&createInfo, null, &instance);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateInstance failed: {result}");
Instance = instance;
}
finally
{
appName.Dispose();
engineName.Dispose();
extensionMemory.Dispose();
layerMemory.Dispose();
}
}
private void LoadInstanceExtensions()
{
if (!Vk.TryGetInstanceExtension(Instance, out KhrSurface khrSurface))
throw new InvalidOperationException("VK_KHR_surface not available.");
KhrSurface = khrSurface;
}
private void LoadDeviceExtensions()
{
if (!Vk.TryGetDeviceExtension(Instance, Device, out KhrSwapchain khrSwapchain))
throw new InvalidOperationException("VK_KHR_swapchain not available.");
KhrSwapchain = khrSwapchain;
}
private void CreateSurface(IWindow window)
{
var sdlInstance = (SDL.VkInstance_T*)Instance.Handle;
var sdlSurface = (SDL.VkSurfaceKHR_T*)null;
var sdlResult = SDL3.SDL_Vulkan_CreateSurface(
(SDL_Window*)window.Handle,
sdlInstance,
null,
&sdlSurface);
if (sdlResult != true)
throw new InvalidOperationException($"SDL_Vulkan_CreateSurface failed: {SDL3.SDL_GetError()}");
Surface = new SurfaceKHR((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 = Vk.GetPhysicalDeviceProperties(device);
var queueFamilies = GetPhysicalDeviceQueueFamilyProperties(device);
var hasGraphics = false;
var hasPresent = false;
for (var i = 0; i < queueFamilies.Length; i++)
{
if (queueFamilies[i].QueueFlags.HasFlag(QueueFlags.GraphicsBit))
hasGraphics = true;
Bool32 supported;
KhrSurface!.GetPhysicalDeviceSurfaceSupport(device, (uint)i, Surface, &supported);
if (supported)
hasPresent = true;
}
if (hasGraphics && hasPresent)
{
PhysicalDevice = device;
if (properties.DeviceType == PhysicalDeviceType.DiscreteGpu)
break;
}
}
if (PhysicalDevice.Handle == 0)
throw new InvalidOperationException("No suitable Vulkan physical device found.");
}
private PhysicalDevice[] EnumeratePhysicalDevices()
{
uint count = 0;
Vk.EnumeratePhysicalDevices(Instance, &count, null);
if (count == 0)
return Array.Empty<PhysicalDevice>();
var devices = new PhysicalDevice[count];
fixed (PhysicalDevice* p = devices)
{
var result = Vk.EnumeratePhysicalDevices(Instance, &count, p);
if (result != Result.Success)
throw new InvalidOperationException($"vkEnumeratePhysicalDevices failed: {result}");
}
return devices;
}
private QueueFamilyProperties[] GetPhysicalDeviceQueueFamilyProperties(PhysicalDevice device)
{
uint count = 0;
Vk.GetPhysicalDeviceQueueFamilyProperties(device, &count, null);
var properties = new QueueFamilyProperties[count];
fixed (QueueFamilyProperties* p = properties)
{
Vk.GetPhysicalDeviceQueueFamilyProperties(device, &count, p);
}
return properties;
}
private void CreateLogicalDevice()
{
var queueFamilies = GetPhysicalDeviceQueueFamilyProperties(PhysicalDevice);
GraphicsFamilyIndex = FindQueueFamilyIndex(queueFamilies, QueueFlags.GraphicsBit);
PresentFamilyIndex = FindPresentQueueFamilyIndex(queueFamilies);
var uniqueFamilies = new HashSet<uint> { GraphicsFamilyIndex, PresentFamilyIndex };
var queueCreateInfos = uniqueFamilies.Select(family => new DeviceQueueCreateInfo
{
SType = StructureType.DeviceQueueCreateInfo,
QueueFamilyIndex = family,
QueueCount = 1
}).ToArray();
var extensionNames = new[] { "VK_KHR_swapchain" };
var extensionMemory = SilkMarshal.StringArrayToMemory(extensionNames, NativeStringEncoding.UTF8);
var priorityHandles = new GCHandle[queueCreateInfos.Length];
try
{
var deviceFeatures = new PhysicalDeviceFeatures();
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 (DeviceQueueCreateInfo* pQueue = queueCreateInfos)
{
var createInfo = new DeviceCreateInfo
{
SType = StructureType.DeviceCreateInfo,
QueueCreateInfoCount = (uint)queueCreateInfos.Length,
PQueueCreateInfos = pQueue,
PEnabledFeatures = &deviceFeatures,
EnabledExtensionCount = 1,
PpEnabledExtensionNames = (byte**)extensionMemory.Handle
};
Device device;
var result = Vk.CreateDevice(PhysicalDevice, &createInfo, null, &device);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateDevice failed: {result}");
Device = device;
}
}
finally
{
foreach (var handle in priorityHandles)
{
if (handle.IsAllocated)
handle.Free();
}
extensionMemory.Dispose();
}
}
private void GetQueues()
{
Queue graphicsQueue;
Vk.GetDeviceQueue(Device, GraphicsFamilyIndex, 0, &graphicsQueue);
GraphicsQueue = graphicsQueue;
Queue presentQueue;
Vk.GetDeviceQueue(Device, PresentFamilyIndex, 0, &presentQueue);
PresentQueue = presentQueue;
}
private uint FindQueueFamilyIndex(QueueFamilyProperties[] properties, QueueFlags 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(QueueFamilyProperties[] properties)
{
for (var i = 0; i < properties.Length; i++)
{
Bool32 supported;
KhrSurface!.GetPhysicalDeviceSurfaceSupport(PhysicalDevice, (uint)i, Surface, &supported);
if (supported)
return (uint)i;
}
throw new InvalidOperationException("No present queue family found.");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Vk.DeviceWaitIdle(Device);
if (CommandPool.Handle != 0)
Vk.DestroyCommandPool(Device, CommandPool, null);
if (Device.Handle != 0)
Vk.DestroyDevice(Device, null);
if (Surface.Handle != 0)
KhrSurface?.DestroySurface(Instance, Surface, null);
if (Instance.Handle != 0)
Vk.DestroyInstance(Instance, null);
Vk.Dispose();
}
}
@@ -1,306 +0,0 @@
using System;
using Silk.NET.Core.Native;
using Silk.NET.Vulkan;
namespace Engine.Graphics;
/// <summary>
/// Graphics pipeline for indexed meshes with push-constant MVP and depth testing.
/// Uses Silk.NET.Vulkan.
/// </summary>
public sealed unsafe class VulkanPipeline : IDisposable
{
private readonly VulkanContext _context;
private readonly Swapchain _swapchain;
public Pipeline Handle { get; }
public PipelineLayout Layout { get; }
public DescriptorSetLayout FrameDescriptorSetLayout { get; }
public DescriptorSetLayout TextureDescriptorSetLayout { get; }
private readonly ShaderModule _vertexModule;
private readonly ShaderModule _fragmentModule;
public VulkanPipeline(VulkanContext context, Swapchain swapchain)
{
_context = context;
_swapchain = swapchain;
_vertexModule = CreateShaderModule("vertex.spv");
_fragmentModule = CreateShaderModule("fragment.spv");
FrameDescriptorSetLayout = CreateFrameDescriptorSetLayout();
TextureDescriptorSetLayout = CreateTextureDescriptorSetLayout();
Layout = CreatePipelineLayout();
Handle = CreateGraphicsPipeline();
}
private ShaderModule CreateShaderModule(string resourceName)
{
var code = ShaderLoader.Load(resourceName);
if (code.Length % 4 != 0)
throw new InvalidOperationException($"Shader {resourceName} size is not a multiple of 4.");
fixed (byte* pCode = code)
{
var createInfo = new ShaderModuleCreateInfo
{
SType = StructureType.ShaderModuleCreateInfo,
CodeSize = (nuint)code.Length,
PCode = (uint*)pCode
};
ShaderModule module;
var result = _context.Vk.CreateShaderModule(_context.Device, &createInfo, null, &module);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateShaderModule failed for {resourceName}: {result}");
return module;
}
}
private DescriptorSetLayout CreateFrameDescriptorSetLayout()
{
var binding = new DescriptorSetLayoutBinding
{
Binding = 0,
DescriptorType = DescriptorType.UniformBuffer,
DescriptorCount = 1,
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit
};
var createInfo = new DescriptorSetLayoutCreateInfo
{
SType = StructureType.DescriptorSetLayoutCreateInfo,
BindingCount = 1,
PBindings = &binding
};
DescriptorSetLayout layout;
var result = _context.Vk.CreateDescriptorSetLayout(_context.Device, &createInfo, null, &layout);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateDescriptorSetLayout failed: {result}");
return layout;
}
private DescriptorSetLayout CreateTextureDescriptorSetLayout()
{
var binding = new DescriptorSetLayoutBinding
{
Binding = 0,
DescriptorType = DescriptorType.CombinedImageSampler,
DescriptorCount = 1,
StageFlags = ShaderStageFlags.FragmentBit
};
var createInfo = new DescriptorSetLayoutCreateInfo
{
SType = StructureType.DescriptorSetLayoutCreateInfo,
BindingCount = 1,
PBindings = &binding
};
DescriptorSetLayout layout;
var result = _context.Vk.CreateDescriptorSetLayout(_context.Device, &createInfo, null, &layout);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateDescriptorSetLayout (texture) failed: {result}");
return layout;
}
private PipelineLayout CreatePipelineLayout()
{
var pushConstantRange = new PushConstantRange
{
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
Offset = 0,
Size = 96
};
var setLayouts = stackalloc DescriptorSetLayout[] { FrameDescriptorSetLayout, TextureDescriptorSetLayout };
var createInfo = new PipelineLayoutCreateInfo
{
SType = StructureType.PipelineLayoutCreateInfo,
SetLayoutCount = 2,
PSetLayouts = setLayouts,
PushConstantRangeCount = 1,
PPushConstantRanges = &pushConstantRange
};
PipelineLayout layout;
var result = _context.Vk.CreatePipelineLayout(_context.Device, &createInfo, null, &layout);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreatePipelineLayout failed: {result}");
return layout;
}
private Pipeline CreateGraphicsPipeline()
{
var entryName = SilkMarshal.StringToPtr("main", NativeStringEncoding.UTF8);
var stages = new[]
{
new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.VertexBit,
Module = _vertexModule,
PName = (byte*)entryName
},
new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.FragmentBit,
Module = _fragmentModule,
PName = (byte*)entryName
}
};
var bindingDescription = new VertexInputBindingDescription
{
Binding = 0,
Stride = (uint)(9 * sizeof(float)),
InputRate = VertexInputRate.Vertex
};
var attributeDescriptions = new[]
{
new VertexInputAttributeDescription
{
Binding = 0,
Location = 0,
Format = Format.R32G32B32Sfloat,
Offset = 0
},
new VertexInputAttributeDescription
{
Binding = 0,
Location = 1,
Format = Format.R32G32B32Sfloat,
Offset = (uint)(3 * sizeof(float))
},
new VertexInputAttributeDescription
{
Binding = 0,
Location = 2,
Format = Format.R32G32B32Sfloat,
Offset = (uint)(6 * sizeof(float))
}
};
PipelineVertexInputStateCreateInfo vertexInputInfo;
fixed (VertexInputAttributeDescription* pAttributes = attributeDescriptions)
{
vertexInputInfo = new PipelineVertexInputStateCreateInfo
{
SType = StructureType.PipelineVertexInputStateCreateInfo,
VertexBindingDescriptionCount = 1,
PVertexBindingDescriptions = &bindingDescription,
VertexAttributeDescriptionCount = (uint)attributeDescriptions.Length,
PVertexAttributeDescriptions = pAttributes
};
}
var inputAssembly = new PipelineInputAssemblyStateCreateInfo
{
SType = StructureType.PipelineInputAssemblyStateCreateInfo,
Topology = PrimitiveTopology.TriangleList,
PrimitiveRestartEnable = false
};
var viewportState = new PipelineViewportStateCreateInfo
{
SType = StructureType.PipelineViewportStateCreateInfo,
ViewportCount = 1,
ScissorCount = 1
};
var rasterizer = new PipelineRasterizationStateCreateInfo
{
SType = StructureType.PipelineRasterizationStateCreateInfo,
PolygonMode = PolygonMode.Fill,
CullMode = CullModeFlags.None,
FrontFace = FrontFace.Clockwise,
LineWidth = 1.0f
};
var multisampling = new PipelineMultisampleStateCreateInfo
{
SType = StructureType.PipelineMultisampleStateCreateInfo,
RasterizationSamples = SampleCountFlags.Count1Bit,
SampleShadingEnable = false
};
var colorBlendAttachment = new PipelineColorBlendAttachmentState
{
ColorWriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.BBit | ColorComponentFlags.ABit
};
var colorBlending = new PipelineColorBlendStateCreateInfo
{
SType = StructureType.PipelineColorBlendStateCreateInfo,
AttachmentCount = 1,
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)
{
dynamicState = new PipelineDynamicStateCreateInfo
{
SType = StructureType.PipelineDynamicStateCreateInfo,
DynamicStateCount = (uint)dynamicStates.Length,
PDynamicStates = pDynamic
};
}
Pipeline pipeline;
fixed (PipelineShaderStageCreateInfo* pStages = stages)
{
var createInfo = new GraphicsPipelineCreateInfo
{
SType = StructureType.GraphicsPipelineCreateInfo,
StageCount = (uint)stages.Length,
PStages = pStages,
PVertexInputState = &vertexInputInfo,
PInputAssemblyState = &inputAssembly,
PViewportState = &viewportState,
PRasterizationState = &rasterizer,
PMultisampleState = &multisampling,
PDepthStencilState = &depthStencil,
PColorBlendState = &colorBlending,
PDynamicState = &dynamicState,
Layout = Layout,
RenderPass = _swapchain.RenderPass,
Subpass = 0
};
var result = _context.Vk.CreateGraphicsPipelines(_context.Device, default, 1, &createInfo, null, &pipeline);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateGraphicsPipelines failed: {result}");
}
SilkMarshal.FreeString(entryName, NativeStringEncoding.UTF8);
return pipeline;
}
public void Dispose()
{
_context.Vk.DeviceWaitIdle(_context.Device);
_context.Vk.DestroyPipeline(_context.Device, Handle, null);
_context.Vk.DestroyPipelineLayout(_context.Device, Layout, null);
_context.Vk.DestroyDescriptorSetLayout(_context.Device, FrameDescriptorSetLayout, null);
_context.Vk.DestroyDescriptorSetLayout(_context.Device, TextureDescriptorSetLayout, null);
_context.Vk.DestroyShaderModule(_context.Device, _vertexModule, null);
_context.Vk.DestroyShaderModule(_context.Device, _fragmentModule, null);
}
}
@@ -1,35 +0,0 @@
using Engine.Core;
using Engine.Graphics;
namespace Engine.Graphics.Vulkan;
/// <summary>
/// Vulkan implementation of the render HAL context.
/// Creates and owns an <see cref="Sdl3Window"/> for the Vulkan surface.
/// </summary>
public sealed class VulkanRenderContext : IRenderContext
{
private readonly Sdl3Window _window;
private readonly VulkanContext _context;
private readonly Swapchain _swapchain;
public IWindow Window => _window;
public VulkanRenderContext(int width, int height, bool enableValidation)
{
_window = new Sdl3Window("Cortex Engine", width, height, vulkanSurface: true);
_context = new VulkanContext(_window, enableValidation);
_swapchain = new Swapchain(_context);
}
public IRenderer CreateRenderer() => new VulkanRenderer(_context, _swapchain);
public void Resize(int width, int height) => _swapchain.Recreate(width, height);
public void Dispose()
{
_swapchain.Dispose();
_context.Dispose();
_window.Dispose();
}
}
@@ -1,673 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using Flecs.NET.Core;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Silk.NET.Core;
using Silk.NET.Vulkan;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics;
/// <summary>
/// Vulkan implementation of the ECS world renderer.
/// Uses Silk.NET.Vulkan and reads Mesh + Transform components from the ECS world.
/// </summary>
public sealed unsafe class VulkanRenderer : IRenderer
{
private readonly VulkanContext _context;
private readonly Swapchain _swapchain;
private readonly VulkanPipeline _pipeline;
private readonly ScreenshotCapture _screenshot;
private readonly UniformBuffer _frameConstantsBuffer;
private DescriptorPool _frameDescriptorPool;
private DescriptorSet _frameDescriptorSet;
private DescriptorPool _textureDescriptorPool;
private readonly Dictionary<string, Texture> _textures = new();
private readonly Dictionary<Texture, DescriptorSet> _textureDescriptorSets = new();
private Texture? _defaultTexture;
private readonly Dictionary<Entity, MeshBuffers> _buffers = new();
private CommandPool _commandPool;
private CommandBuffer[] _commandBuffers = null!;
private Silk.NET.Vulkan.Semaphore[] _imageAvailableSemaphores = null!;
private Silk.NET.Vulkan.Semaphore[] _renderFinishedSemaphores = null!;
private Silk.NET.Vulkan.Fence[] _inFlightFences = null!;
private int _currentFrame;
[StructLayout(LayoutKind.Sequential, Size = 96)]
private struct PushConstants
{
public Matrix4x4 Mvp;
public Vector3 MaterialAlbedo;
public float MaterialRoughness;
public float MaterialMetallic;
public uint UseTexture;
public uint TextureIndex;
public uint Pad0;
}
[StructLayout(LayoutKind.Sequential, Size = 48)]
private struct GpuLight
{
public Vector3 Direction;
public float Intensity;
public Vector3 Color;
public float Padding;
}
[StructLayout(LayoutKind.Sequential, Size = 224)]
private struct FrameConstants
{
public Vector3 CameraPosition;
public uint LightCount;
public Vector3 AmbientColor;
public float AmbientPadding;
public GpuLight Light0;
public GpuLight Light1;
public GpuLight Light2;
public GpuLight Light3;
}
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 VulkanRenderer(VulkanContext context, Swapchain swapchain)
{
_context = context;
_swapchain = swapchain;
_screenshot = new ScreenshotCapture(context, swapchain);
_pipeline = new VulkanPipeline(context, swapchain);
_frameConstantsBuffer = new UniformBuffer(context, (ulong)sizeof(FrameConstants));
CreateFrameDescriptorPool();
CreateFrameDescriptorSet();
CreateTextureDescriptorPool();
CreateDefaultTexture();
CreateCommandPool();
CreateCommandBuffers();
CreateSyncObjects();
}
private void CreateCommandPool()
{
var createInfo = new CommandPoolCreateInfo
{
SType = StructureType.CommandPoolCreateInfo,
QueueFamilyIndex = _context.GraphicsFamilyIndex,
Flags = CommandPoolCreateFlags.ResetCommandBufferBit
};
CommandPool commandPool;
var result = _context.Vk.CreateCommandPool(_context.Device, &createInfo, null, &commandPool);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateCommandPool failed: {result}");
_commandPool = commandPool;
}
private void CreateCommandBuffers()
{
_commandBuffers = new CommandBuffer[2];
for (var i = 0; i < _commandBuffers.Length; i++)
{
var allocInfo = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = _commandPool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = 1
};
CommandBuffer cmd;
var result = _context.Vk.AllocateCommandBuffers(_context.Device, &allocInfo, &cmd);
if (result != Result.Success)
throw new InvalidOperationException($"vkAllocateCommandBuffers failed: {result}");
_commandBuffers[i] = cmd;
}
}
private void CreateSyncObjects()
{
_imageAvailableSemaphores = new Silk.NET.Vulkan.Semaphore[2];
_renderFinishedSemaphores = new Silk.NET.Vulkan.Semaphore[2];
_inFlightFences = new Silk.NET.Vulkan.Fence[2];
var semaphoreInfo = new SemaphoreCreateInfo { SType = StructureType.SemaphoreCreateInfo };
var fenceInfo = new FenceCreateInfo
{
SType = StructureType.FenceCreateInfo,
Flags = FenceCreateFlags.SignaledBit
};
for (var i = 0; i < 2; i++)
{
Silk.NET.Vulkan.Semaphore imageAvailable, renderFinished;
Silk.NET.Vulkan.Fence fence;
_context.Vk.CreateSemaphore(_context.Device, &semaphoreInfo, null, &imageAvailable);
_context.Vk.CreateSemaphore(_context.Device, &semaphoreInfo, null, &renderFinished);
_context.Vk.CreateFence(_context.Device, &fenceInfo, null, &fence);
_imageAvailableSemaphores[i] = imageAvailable;
_renderFinishedSemaphores[i] = renderFinished;
_inFlightFences[i] = fence;
}
}
private void CreateFrameDescriptorPool()
{
var poolSize = new DescriptorPoolSize
{
Type = DescriptorType.UniformBuffer,
DescriptorCount = 1
};
var createInfo = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = 1,
PoolSizeCount = 1,
PPoolSizes = &poolSize
};
DescriptorPool descriptorPool;
var result = _context.Vk.CreateDescriptorPool(_context.Device, &createInfo, null, &descriptorPool);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateDescriptorPool failed: {result}");
_frameDescriptorPool = descriptorPool;
}
private void CreateFrameDescriptorSet()
{
var layout = _pipeline.FrameDescriptorSetLayout;
var allocInfo = new DescriptorSetAllocateInfo
{
SType = StructureType.DescriptorSetAllocateInfo,
DescriptorPool = _frameDescriptorPool,
DescriptorSetCount = 1,
PSetLayouts = &layout
};
DescriptorSet descriptorSet;
var result = _context.Vk.AllocateDescriptorSets(_context.Device, &allocInfo, &descriptorSet);
if (result != Result.Success)
throw new InvalidOperationException($"vkAllocateDescriptorSets failed: {result}");
_frameDescriptorSet = descriptorSet;
var bufferInfo = new DescriptorBufferInfo
{
Buffer = _frameConstantsBuffer.Buffer,
Offset = 0,
Range = (ulong)sizeof(FrameConstants)
};
var write = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = _frameDescriptorSet,
DstBinding = 0,
DstArrayElement = 0,
DescriptorType = DescriptorType.UniformBuffer,
DescriptorCount = 1,
PBufferInfo = &bufferInfo
};
_context.Vk.UpdateDescriptorSets(_context.Device, 1, &write, 0, null);
}
private void CreateTextureDescriptorPool()
{
var poolSize = new DescriptorPoolSize
{
Type = DescriptorType.CombinedImageSampler,
DescriptorCount = 16
};
var createInfo = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = 16,
PoolSizeCount = 1,
PPoolSizes = &poolSize
};
DescriptorPool descriptorPool;
var result = _context.Vk.CreateDescriptorPool(_context.Device, &createInfo, null, &descriptorPool);
if (result != Result.Success)
throw new InvalidOperationException($"vkCreateDescriptorPool (texture) failed: {result}");
_textureDescriptorPool = descriptorPool;
}
private void CreateDefaultTexture()
{
var whitePixel = new byte[] { 255, 255, 255, 255 };
_defaultTexture = CreateTextureFromBytes("__default__", whitePixel, 1, 1);
}
private Texture CreateTextureFromBytes(string key, byte[] rgbaPixels, uint width, uint height)
{
var path = $"/tmp/cortex_texture_{key}.png";
System.IO.File.WriteAllBytes(path, EncodePng(rgbaPixels, width, height));
var texture = new Texture(_context, path);
try
{
System.IO.File.Delete(path);
}
catch
{
// Ignore cleanup failure.
}
return texture;
}
private static byte[] EncodePng(byte[] rgbaPixels, uint width, uint height)
{
using var image = SixLabors.ImageSharp.Image.LoadPixelData<Rgba32>(rgbaPixels, (int)width, (int)height);
using var stream = new System.IO.MemoryStream();
image.SaveAsPng(stream);
return stream.ToArray();
}
public void RequestScreenshot(string outputPath) => _screenshot.Request(outputPath);
public bool IsScreenshotRequested => _screenshot.IsRequested;
/// <summary>
/// Provider that can asynchronously capture the current frame to a PNG byte array.
/// </summary>
public IScreenshotProvider ScreenshotProvider => _screenshot;
public void RenderWorld(World world)
{
var frame = _currentFrame % 2;
var fence = _inFlightFences[frame];
_context.Vk.WaitForFences(_context.Device, 1, &fence, true, ulong.MaxValue);
_context.Vk.ResetFences(_context.Device, 1, &fence);
uint imageIndex;
var result = _context.KhrSwapchain!.AcquireNextImage(_context.Device, _swapchain.Handle, ulong.MaxValue, _imageAvailableSemaphores[frame], new Silk.NET.Vulkan.Fence(), &imageIndex);
if (result == Result.ErrorOutOfDateKhr)
return;
var cmd = _commandBuffers[frame];
_context.Vk.ResetCommandBuffer(cmd, CommandBufferResetFlags.None);
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit
};
_context.Vk.BeginCommandBuffer(cmd, &beginInfo);
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 = (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);
var frameDescriptorSet = _frameDescriptorSet;
_context.Vk.CmdBindDescriptorSets(cmd, PipelineBindPoint.Graphics, _pipeline.Layout, 0, 1, &frameDescriptorSet, 0, null);
var viewport = new Viewport(0, 0, _swapchain.Extent.Width, _swapchain.Extent.Height, 0, 1);
var scissor = new Rect2D(new Offset2D(0, 0), _swapchain.Extent);
_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();
// Vulkan NDC Y points down; .NET's projection matrix assumes Y up, so flip Y.
proj.M22 = -proj.M22;
var drawCmd = cmd;
var frameConstants = BuildFrameConstants(world, camera);
var frameConstantsBytes = new byte[sizeof(FrameConstants)];
fixed (byte* p = frameConstantsBytes)
{
*(FrameConstants*)p = frameConstants;
}
_frameConstantsBuffer.Update(frameConstantsBytes);
world.Each((Entity e, ref Mesh mesh, ref Transform transform) =>
{
if (!_buffers.TryGetValue(e, out var buffers))
{
buffers = CreateMeshBuffers(mesh);
_buffers[e] = buffers;
}
var material = e.Has<Material>() ? e.Get<Material>() : Material.Default;
var bytes = BuildMeshVertices(mesh, transform, material);
buffers.VertexBuffer.Update(bytes);
var mvp = Matrix4x4.Transpose(Matrix4x4.Multiply(view, proj));
var texture = GetTexture(material);
var textureDescriptorSet = GetTextureDescriptorSet(texture);
var textureSet = textureDescriptorSet;
_context.Vk.CmdBindDescriptorSets(drawCmd, PipelineBindPoint.Graphics, _pipeline.Layout, 1, 1, &textureSet, 0, null);
var push = new PushConstants
{
Mvp = mvp,
MaterialAlbedo = material.Albedo,
MaterialRoughness = material.Roughness,
MaterialMetallic = material.Metallic,
UseTexture = material.HasTexture ? 1u : 0u,
TextureIndex = 0,
Pad0 = 0
};
var pushSize = (uint)sizeof(PushConstants);
_context.Vk.CmdPushConstants(drawCmd, _pipeline.Layout, ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, 0, pushSize, &push);
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);
var swapchainImage = _swapchain.GetImage(imageIndex);
_screenshot.RecordReadback(cmd, swapchainImage, _swapchain.Extent.Width, _swapchain.Extent.Height, _swapchain.SurfaceFormat);
_context.Vk.EndCommandBuffer(cmd);
var waitSemaphore = _imageAvailableSemaphores[frame];
var signalSemaphore = _renderFinishedSemaphores[frame];
var stageMask = PipelineStageFlags.ColorAttachmentOutputBit;
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
WaitSemaphoreCount = 1,
PWaitSemaphores = &waitSemaphore,
PWaitDstStageMask = &stageMask,
CommandBufferCount = 1,
PCommandBuffers = &cmd,
SignalSemaphoreCount = 1,
PSignalSemaphores = &signalSemaphore
};
_context.Vk.QueueSubmit(_context.GraphicsQueue, 1, &submitInfo, _inFlightFences[frame]);
var swapchain = _swapchain.Handle;
var presentInfo = new PresentInfoKHR
{
SType = StructureType.PresentInfoKhr,
WaitSemaphoreCount = 1,
PWaitSemaphores = &signalSemaphore,
SwapchainCount = 1,
PSwapchains = &swapchain,
PImageIndices = &imageIndex
};
_context.KhrSwapchain!.QueuePresent(_context.PresentQueue, &presentInfo);
// If a screenshot was requested, wait for the GPU to finish the readback and save the file.
if (_screenshot.IsRequested)
{
_context.Vk.WaitForFences(_context.Device, 1, &fence, true, ulong.MaxValue);
_screenshot.Save(_swapchain.Extent.Width, _swapchain.Extent.Height, _swapchain.SurfaceFormat);
}
_currentFrame++;
}
private MeshBuffers CreateMeshBuffers(Mesh mesh)
{
var vertexBytes = new byte[mesh.Vertices.Length * 9 * 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 * 9 + 0] = v.Position.X;
dst[i * 9 + 1] = v.Position.Y;
dst[i * 9 + 2] = v.Position.Z;
dst[i * 9 + 3] = v.Color.X;
dst[i * 9 + 4] = v.Color.Y;
dst[i * 9 + 5] = v.Color.Z;
dst[i * 9 + 6] = v.Normal.X;
dst[i * 9 + 7] = v.Normal.Y;
dst[i * 9 + 8] = v.Normal.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, Material material)
{
var matrix = transform.GetMatrix();
var bytes = new byte[mesh.Vertices.Length * 9 * sizeof(float)];
fixed (byte* p = bytes)
{
var dst = (float*)p;
for (var i = 0; i < mesh.Vertices.Length; i++)
{
var v = mesh.Vertices[i];
var worldPos = Vector3.Transform(v.Position, matrix);
var normal = transform.TransformNormal(v.Normal);
var color = v.Color * material.Albedo;
dst[i * 9 + 0] = worldPos.X;
dst[i * 9 + 1] = worldPos.Y;
dst[i * 9 + 2] = worldPos.Z;
dst[i * 9 + 3] = color.X;
dst[i * 9 + 4] = color.Y;
dst[i * 9 + 5] = color.Z;
dst[i * 9 + 6] = normal.X;
dst[i * 9 + 7] = normal.Y;
dst[i * 9 + 8] = normal.Z;
}
}
return bytes;
}
private Texture GetTexture(Material material)
{
if (!material.HasTexture)
return _defaultTexture!;
if (_textures.TryGetValue(material.TexturePath!, out var texture))
return texture;
if (!System.IO.File.Exists(material.TexturePath!))
return _defaultTexture!;
texture = new Texture(_context, material.TexturePath!);
_textures[material.TexturePath!] = texture;
return texture;
}
private DescriptorSet GetTextureDescriptorSet(Texture texture)
{
if (_textureDescriptorSets.TryGetValue(texture, out var descriptorSet))
return descriptorSet;
var layout = _pipeline.TextureDescriptorSetLayout;
var allocInfo = new DescriptorSetAllocateInfo
{
SType = StructureType.DescriptorSetAllocateInfo,
DescriptorPool = _textureDescriptorPool,
DescriptorSetCount = 1,
PSetLayouts = &layout
};
DescriptorSet set;
var result = _context.Vk.AllocateDescriptorSets(_context.Device, &allocInfo, &set);
if (result != Result.Success)
throw new InvalidOperationException($"vkAllocateDescriptorSets (texture) failed: {result}");
var imageInfo = new DescriptorImageInfo
{
ImageLayout = ImageLayout.ShaderReadOnlyOptimal,
ImageView = texture.View,
Sampler = texture.Sampler
};
var write = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = set,
DstBinding = 0,
DstArrayElement = 0,
DescriptorType = DescriptorType.CombinedImageSampler,
DescriptorCount = 1,
PImageInfo = &imageInfo
};
_context.Vk.UpdateDescriptorSets(_context.Device, 1, &write, 0, null);
_textureDescriptorSets[texture] = set;
return set;
}
private FrameConstants BuildFrameConstants(World world, Camera camera)
{
var frameConstants = new FrameConstants
{
CameraPosition = camera.Position,
LightCount = 0,
AmbientColor = new Vector3(0.4f, 0.4f, 0.45f),
AmbientPadding = 0
};
world.Each((Entity e, ref Light light) =>
{
if (frameConstants.LightCount >= 4)
return;
var index = (int)frameConstants.LightCount;
frameConstants.LightCount++;
SetLight(ref frameConstants, index, new GpuLight
{
Direction = light.Direction,
Intensity = light.Intensity,
Color = light.Color,
Padding = 0
});
});
// Fallback: if no light components exist, add a default directional light.
if (frameConstants.LightCount == 0)
{
frameConstants.LightCount = 1;
SetLight(ref frameConstants, 0, new GpuLight
{
Direction = new Vector3(0.5f, -1.0f, -0.5f),
Intensity = 1.0f,
Color = new Vector3(1.0f, 0.95f, 0.8f),
Padding = 0
});
}
return frameConstants;
}
private static void SetLight(ref FrameConstants frameConstants, int index, GpuLight light)
{
switch (index)
{
case 0: frameConstants.Light0 = light; break;
case 1: frameConstants.Light1 = light; break;
case 2: frameConstants.Light2 = light; break;
case 3: frameConstants.Light3 = light; break;
}
}
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);
_screenshot.Dispose();
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);
_context.Vk.DestroySemaphore(_context.Device, _imageAvailableSemaphores[i], null);
_context.Vk.DestroyFence(_context.Device, _inFlightFences[i], null);
}
_context.Vk.DestroyCommandPool(_context.Device, _commandPool, null);
_context.Vk.DestroyDescriptorPool(_context.Device, _textureDescriptorPool, null);
_context.Vk.DestroyDescriptorPool(_context.Device, _frameDescriptorPool, null);
foreach (var texture in _textures.Values)
texture.Dispose();
_textures.Clear();
_defaultTexture?.Dispose();
_frameConstantsBuffer.Dispose();
_pipeline.Dispose();
}
}
@@ -1,30 +0,0 @@
<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'" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
-27
View File
@@ -1,27 +0,0 @@
using Engine.Core;
namespace Engine.Graphics;
/// <summary>
/// Abstraction over a graphics backend (Vulkan, Raylib, etc.).
/// Each backend owns its window and surface. The application retrieves
/// the window via <see cref="Window"/> for input and event polling.
/// </summary>
public interface IRenderContext : IDisposable
{
/// <summary>
/// The window owned by this backend. The application uses this for
/// input polling, resize detection, and close requests.
/// </summary>
IWindow Window { get; }
/// <summary>
/// Create a renderer that can draw the ECS world using this backend.
/// </summary>
IRenderer CreateRenderer();
/// <summary>
/// Notify the backend that the output surface has been resized.
/// </summary>
void Resize(int width, int height);
}
-31
View File
@@ -1,31 +0,0 @@
using Engine.Core;
using Flecs.NET.Core;
namespace Engine.Graphics;
/// <summary>
/// Renders the ECS world and exposes screenshot capture.
/// Implemented by concrete graphics backends.
/// </summary>
public interface IRenderer : IDisposable
{
/// <summary>
/// Render one frame of the ECS world and present it.
/// </summary>
void RenderWorld(World world);
/// <summary>
/// Request a screenshot of the next rendered frame to be saved to disk.
/// </summary>
void RequestScreenshot(string outputPath);
/// <summary>
/// True if a screenshot has been requested but not yet captured.
/// </summary>
bool IsScreenshotRequested { get; }
/// <summary>
/// Provider that can asynchronously capture the current frame to PNG bytes.
/// </summary>
IScreenshotProvider ScreenshotProvider { get; }
}
-196
View File
@@ -1,196 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using Engine.Core;
using EngineCoreMaterial = Engine.Core.Components.Material;
using EngineMesh = Engine.Core.Components.Mesh;
using SharpGLTF.Schema2;
namespace Engine.Graphics.Loaders;
/// <summary>
/// glTF/glTF binary loader using SharpGLTF.Core.
/// Loads all primitives across all meshes, extracting:
/// - Positions, normals (from file or computed), texcoords
/// - PBR material: albedo, roughness, metallic, base color texture
/// </summary>
public static class GltfLoader
{
/// <summary>
/// Load a glTF/GLB file and return the combined mesh plus extracted materials.
/// </summary>
public static EngineMesh Load(string path, Vector3? defaultColor = null)
{
var (mesh, _) = LoadWithMaterials(path, defaultColor);
return mesh;
}
/// <summary>
/// Load a glTF/GLB file and return the combined mesh plus a list of
/// (primitive index, material) pairs. Textures are extracted to a
/// temp directory next to the source file.
/// </summary>
public static (EngineMesh Mesh, List<EngineCoreMaterial> Materials) LoadWithMaterials(
string path, Vector3? defaultColor = null)
{
var color = defaultColor ?? new Vector3(0.7f, 0.7f, 0.7f);
var textureDir = Path.Combine(Path.GetDirectoryName(path) ?? ".", "extracted_textures");
var model = ModelRoot.Load(path);
if (model.LogicalMeshes.Count == 0)
throw new InvalidOperationException($"glTF file has no meshes: {path}");
var vertices = new List<Vertex>();
var indices = new List<uint>();
var materials = new List<EngineCoreMaterial>();
foreach (var mesh in model.LogicalMeshes)
{
foreach (var primitive in mesh.Primitives)
{
if (!primitive.VertexAccessors.TryGetValue("POSITION", out var positionAccessor))
continue;
var positions = positionAccessor.AsVector3Array();
var normals = primitive.VertexAccessors.TryGetValue("NORMAL", out var normalAccessor)
? normalAccessor.AsVector3Array()
: null;
var uvs = primitive.VertexAccessors.TryGetValue("TEXCOORD_0", out var uvAccessor)
? uvAccessor.AsVector2Array()
: null;
var primIndices = GetIndices(primitive, positions.Count);
var material = ExtractMaterial(primitive, color, textureDir);
materials.Add(material);
var vertexBase = (uint)vertices.Count;
for (var i = 0; i < primIndices.Length; i += 3)
{
var i0 = (int)primIndices[i];
var i1 = (int)primIndices[i + 1];
var i2 = (int)primIndices[i + 2];
var v0 = ToVertex(positions, normals, uvs, i0, color);
var v1 = ToVertex(positions, normals, uvs, i1, color);
var v2 = ToVertex(positions, normals, uvs, i2, color);
if (normals == null)
{
var n = MeshMath.ComputeFaceNormal(v0.Position, v1.Position, v2.Position);
v0.Normal = n;
v1.Normal = n;
v2.Normal = n;
}
indices.Add(vertexBase + (uint)i0);
indices.Add(vertexBase + (uint)i1);
indices.Add(vertexBase + (uint)i2);
if (i == 0)
{
vertices.AddRange(new[] { v0, v1, v2 });
}
}
if (normals != null || uvs != null)
{
for (var i = 0; i < positions.Count; i++)
vertices.Add(ToVertex(positions, normals, uvs, i, color));
}
vertexBase = (uint)vertices.Count;
}
}
return (new EngineMesh(vertices.ToArray(), indices.ToArray()), materials);
}
private static Vertex ToVertex(
IReadOnlyList<Vector3> positions,
IReadOnlyList<Vector3>? normals,
IReadOnlyList<Vector2>? uvs,
int index,
Vector3 color)
{
var pos = new Vector3(positions[index].X, positions[index].Y, positions[index].Z);
var normal = normals != null
? Vector3.Normalize(new Vector3(normals[index].X, normals[index].Y, normals[index].Z))
: Vector3.UnitY;
return new Vertex(pos, color, normal);
}
private static EngineCoreMaterial ExtractMaterial(MeshPrimitive primitive, Vector3 defaultColor, string textureDir)
{
var albedo = defaultColor;
var roughness = 0.5f;
var metallic = 0.0f;
string? texturePath = null;
var gltfMat = primitive.Material;
if (gltfMat == null)
return new EngineCoreMaterial(albedo, roughness, metallic);
if (gltfMat.FindChannel("BaseColor") is { } baseColor)
{
foreach (var param in baseColor.Parameters)
{
if (param.Name == "BaseColorFactor" && param.Value is Vector4 factor)
{
albedo = new Vector3(factor.X, factor.Y, factor.Z);
}
}
if (baseColor.Texture?.PrimaryImage is { } img)
{
var mem = img.Content;
if (!string.IsNullOrEmpty(mem.SourcePath) && File.Exists(mem.SourcePath))
{
texturePath = mem.SourcePath;
}
else if (mem.IsValid)
{
Directory.CreateDirectory(textureDir);
var ext = string.IsNullOrEmpty(mem.FileExtension) ? ".png" : mem.FileExtension;
texturePath = Path.Combine(textureDir, $"tex_{Guid.NewGuid():N}{ext}");
mem.SaveToFile(texturePath);
}
}
}
if (gltfMat.FindChannel("MetallicRoughness") is { } mr)
{
foreach (var param in mr.Parameters)
{
if (param.Name == "MetallicFactor" && param.Value is float mf)
metallic = mf;
if (param.Name == "RoughnessFactor" && param.Value is float rf)
roughness = rf;
}
}
return new EngineCoreMaterial(albedo, roughness, metallic, texturePath);
}
private static uint[] GetIndices(MeshPrimitive primitive, int positionCount)
{
if (primitive.IndexAccessor != null)
{
var idx = primitive.IndexAccessor.AsIndexArray();
var indices = new uint[idx.Count];
for (var i = 0; i < idx.Count; i++)
indices[i] = idx[i];
return indices;
}
var auto = new uint[positionCount];
for (var i = 0; i < positionCount; i++)
auto[i] = (uint)i;
return auto;
}
private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
=> MeshMath.ComputeFaceNormal(a, b, c);
}
-88
View File
@@ -1,88 +0,0 @@
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). Creates per-face normals for flat shading.
/// 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 vertices = new List<Vertex>();
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.
var baseIndex = ParseFaceIndex(parts[1]);
for (var i = 2; i < parts.Length - 1; i++)
{
var i0 = baseIndex;
var i1 = ParseFaceIndex(parts[i]);
var i2 = ParseFaceIndex(parts[i + 1]);
var v0 = positions[(int)i0];
var v1 = positions[(int)i1];
var v2 = positions[(int)i2];
var normal = ComputeFaceNormal(v0, v1, v2);
var vertexBase = (uint)vertices.Count;
indices.Add(vertexBase);
indices.Add(vertexBase + 1);
indices.Add(vertexBase + 2);
vertices.Add(new Vertex(v0, color, normal));
vertices.Add(new Vertex(v1, color, normal));
vertices.Add(new Vertex(v2, color, normal));
}
break;
}
}
if (vertices.Count == 0)
throw new InvalidOperationException($"OBJ file has no vertices: {path}");
return new Mesh(vertices.ToArray(), 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
}
private static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
=> MeshMath.ComputeFaceNormal(a, b, c);
}
-26
View File
@@ -1,26 +0,0 @@
using System.Numerics;
namespace Engine.Graphics;
/// <summary>
/// Shared mesh math utilities used by loaders and procedural generators.
/// </summary>
public static class MeshMath
{
/// <summary>
/// Compute a flat face normal from three vertex positions.
/// Falls back to Vector3.UnitY for degenerate (zero-area) triangles.
/// </summary>
public static Vector3 ComputeFaceNormal(Vector3 a, Vector3 b, Vector3 c)
{
var ab = b - a;
var ac = c - a;
// Cross(ac, ab) instead of Cross(ab, ac) to match CW winding in typical OBJ files
var normal = Vector3.Cross(ac, ab);
if (normal.LengthSquared() > 0.00001f)
normal = Vector3.Normalize(normal);
else
normal = Vector3.UnitY;
return normal;
}
}
-101
View File
@@ -1,101 +0,0 @@
using System.Collections.Generic;
using System.Numerics;
using Engine.Core;
using Engine.Core.Components;
namespace Engine.Graphics;
/// <summary>
/// Procedural mesh generators for common primitive shapes.
/// All methods are pure CPU — no GPU/display dependencies.
/// </summary>
public static class ProceduralMesh
{
/// <summary>
/// Generate a UV sphere mesh.
/// </summary>
/// <param name="radius">Sphere radius.</param>
/// <param name="segments">Longitude segments (around the equator).</param>
/// <param name="rings">Latitude rings (from pole to pole).</param>
/// <param name="color">Vertex color applied to all vertices.</param>
public static Mesh CreateSphere(float radius, int segments, int rings, Vector3 color)
{
var vertices = new List<Vertex>();
var indices = new List<uint>();
for (var ring = 0; ring <= rings; ring++)
{
var phi = MathF.PI * ring / rings;
var sinPhi = MathF.Sin(phi);
var cosPhi = MathF.Cos(phi);
for (var seg = 0; seg <= segments; seg++)
{
var theta = 2.0f * MathF.PI * seg / segments;
var sinTheta = MathF.Sin(theta);
var cosTheta = MathF.Cos(theta);
var x = radius * sinPhi * cosTheta;
var y = radius * cosPhi;
var z = radius * sinPhi * sinTheta;
var normal = Vector3.Normalize(new Vector3(x, y, z));
vertices.Add(new Vertex(new Vector3(x, y, z), color, normal));
}
}
for (var ring = 0; ring < rings; ring++)
{
for (var seg = 0; seg < segments; seg++)
{
var i0 = (uint)(ring * (segments + 1) + seg);
var i1 = i0 + 1;
var i2 = i0 + (uint)(segments + 1);
var i3 = i2 + 1;
indices.Add(i0); indices.Add(i1); indices.Add(i2);
indices.Add(i1); indices.Add(i3); indices.Add(i2);
}
}
return new Mesh(vertices.ToArray(), indices.ToArray());
}
/// <summary>
/// Generate a ground grid mesh at Y=0, consisting of thin quads.
/// </summary>
/// <param name="lines">Number of grid lines on each side of the origin.</param>
/// <param name="spacing">Distance between grid lines.</param>
/// <param name="color">Vertex color applied to all vertices.</param>
public static Mesh CreateGrid(int lines, float spacing, Vector3 color)
{
var vertices = new List<Vertex>();
var indices = new List<uint>();
var extent = lines * spacing;
var normal = Vector3.UnitY;
var halfWidth = 0.02f;
for (var i = -lines; i <= lines; i++)
{
var offset = i * spacing;
var baseIndex = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(-extent, 0, offset - halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(extent, 0, offset - halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(extent, 0, offset + halfWidth), color, normal));
vertices.Add(new Vertex(new Vector3(-extent, 0, offset + halfWidth), color, normal));
indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2);
indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3);
baseIndex = (uint)vertices.Count;
vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, -extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, -extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset + halfWidth, 0, extent), color, normal));
vertices.Add(new Vertex(new Vector3(offset - halfWidth, 0, extent), color, normal));
indices.Add(baseIndex); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2);
indices.Add(baseIndex); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3);
}
return new Mesh(vertices.ToArray(), indices.ToArray());
}
}
@@ -1,36 +0,0 @@
using Engine.Core;
namespace Engine.Graphics;
/// <summary>
/// Factory for creating concrete graphics backends by name.
/// Backends register themselves so the app only depends on the HAL interfaces.
/// Each backend creates and owns its own window.
/// </summary>
public static class RenderBackendFactory
{
private static readonly Dictionary<string, Func<int, int, bool, IRenderContext>> _registry
= new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Register a backend implementation under the given name.
/// The factory receives (width, height, enableValidation) and must create
/// its own window and render context.
/// </summary>
public static void Register(string name, Func<int, int, bool, IRenderContext> factory)
{
_registry[name] = factory;
}
/// <summary>
/// Create a backend instance for the given name.
/// The backend assembly must have registered itself before this is called.
/// </summary>
public static IRenderContext Create(string name, int width, int height, bool enableValidation)
{
if (!_registry.TryGetValue(name, out var factory))
throw new NotSupportedException($"No graphics backend named '{name}' is registered.");
return factory(width, height, enableValidation);
}
}
-300
View File
@@ -1,300 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Engine.Core.Components;
using Flecs.NET.Core;
namespace Engine.Graphics;
/// <summary>
/// Scene serialization — saves and loads the ECS world to/from JSON.
/// Uses manual serialization for named entities with Transform, Material, Light, Camera components.
/// </summary>
public static class SceneSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
Converters =
{
new Vector3JsonConverter(),
new QuaternionJsonConverter()
}
};
/// <summary>
/// Serialize all named entities with their components to a JSON string.
/// </summary>
public static string SaveToString(World world)
{
var entities = new List<SceneEntity>();
var processedNames = new HashSet<string>();
world.Each((Entity e, ref Transform _) =>
{
var name = e.Name();
if (string.IsNullOrEmpty(name))
return;
if (processedNames.Contains(name))
return;
processedNames.Add(name);
var entry = new SceneEntity { Name = name };
if (e.Has<Transform>())
{
var t = e.Get<Transform>();
entry.Transform = new SceneTransform
{
Position = t.Position,
Rotation = t.Rotation,
Scale = t.Scale
};
}
if (e.Has<Material>())
{
var m = e.Get<Material>();
entry.Material = new SceneMaterial
{
Albedo = m.Albedo,
Roughness = m.Roughness,
Metallic = m.Metallic,
TexturePath = m.TexturePath
};
}
if (e.Has<Light>())
{
var l = e.Get<Light>();
entry.Light = new SceneLight
{
Type = l.Type,
Direction = l.Direction,
Position = l.Position,
Color = l.Color,
Intensity = l.Intensity,
Range = l.Range
};
}
if (e.Has<Camera>())
{
var c = e.Get<Camera>();
entry.Camera = new SceneCamera
{
Position = c.Position,
Target = c.Target,
Up = c.Up,
FieldOfView = c.FieldOfView,
NearPlane = c.NearPlane,
FarPlane = c.FarPlane
};
}
entities.Add(entry);
});
return JsonSerializer.Serialize(entities, JsonOptions);
}
/// <summary>
/// Save the world to a JSON file.
/// </summary>
public static void SaveToFile(World world, string path)
{
var json = SaveToString(world);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, json);
}
/// <summary>
/// Load entities from a JSON string into the world.
/// Returns the number of entities loaded.
/// </summary>
public static int LoadFromString(World world, string json)
{
var entities = JsonSerializer.Deserialize<List<SceneEntity>>(json, JsonOptions);
if (entities == null) return 0;
foreach (var entry in entities)
{
var entity = world.Entity(entry.Name);
if (entry.Transform != null)
{
entity.Set(new Transform(
entry.Transform.Position,
entry.Transform.Rotation,
entry.Transform.Scale));
}
if (entry.Material != null)
{
entity.Set(new Material(
entry.Material.Albedo,
entry.Material.Roughness,
entry.Material.Metallic,
entry.Material.TexturePath));
}
if (entry.Light != null)
{
if (entry.Light.Type == LightType.Point)
{
entity.Set(Light.Point(
entry.Light.Position,
entry.Light.Color,
entry.Light.Intensity,
entry.Light.Range));
}
else
{
entity.Set(Light.Directional(
entry.Light.Direction,
entry.Light.Color,
entry.Light.Intensity));
}
}
if (entry.Camera != null)
{
entity.Set(new Camera(
entry.Camera.Position,
entry.Camera.Target,
entry.Camera.Up,
entry.Camera.FieldOfView,
16f / 9f,
entry.Camera.NearPlane,
entry.Camera.FarPlane));
}
}
return entities.Count;
}
/// <summary>
/// Load entities from a JSON file into the world.
/// Returns the number of entities loaded.
/// </summary>
public static int LoadFromFile(World world, string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Scene file not found: {path}");
var json = File.ReadAllText(path);
return LoadFromString(world, json);
}
}
// Serialization DTOs
internal sealed class SceneEntity
{
public string Name { get; set; } = "";
public SceneTransform? Transform { get; set; }
public SceneMaterial? Material { get; set; }
public SceneLight? Light { get; set; }
public SceneCamera? Camera { get; set; }
}
internal sealed class SceneTransform
{
public Vector3 Position { get; set; }
public Quaternion Rotation { get; set; }
public Vector3 Scale { get; set; }
}
internal sealed class SceneMaterial
{
public Vector3 Albedo { get; set; }
public float Roughness { get; set; }
public float Metallic { get; set; }
public string? TexturePath { get; set; }
}
internal sealed class SceneLight
{
public LightType Type { get; set; }
public Vector3 Direction { get; set; }
public Vector3 Position { get; set; }
public Vector3 Color { get; set; }
public float Intensity { get; set; }
public float Range { get; set; }
}
internal sealed class SceneCamera
{
public Vector3 Position { get; set; }
public Vector3 Target { get; set; }
public Vector3 Up { get; set; }
public float FieldOfView { get; set; }
public float NearPlane { get; set; }
public float FarPlane { get; set; }
}
internal sealed class Vector3JsonConverter : JsonConverter<Vector3>
{
public override Vector3 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartArray)
throw new JsonException("Expected array for Vector3");
reader.Read();
var x = reader.GetSingle();
reader.Read();
var y = reader.GetSingle();
reader.Read();
var z = reader.GetSingle();
reader.Read();
if (reader.TokenType != JsonTokenType.EndArray)
throw new JsonException("Expected 3 elements for Vector3");
return new Vector3(x, y, z);
}
public override void Write(Utf8JsonWriter writer, Vector3 value, JsonSerializerOptions options)
{
writer.WriteStartArray();
writer.WriteNumberValue(value.X);
writer.WriteNumberValue(value.Y);
writer.WriteNumberValue(value.Z);
writer.WriteEndArray();
}
}
internal sealed class QuaternionJsonConverter : JsonConverter<Quaternion>
{
public override Quaternion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartArray)
throw new JsonException("Expected array for Quaternion");
reader.Read();
var x = reader.GetSingle();
reader.Read();
var y = reader.GetSingle();
reader.Read();
var z = reader.GetSingle();
reader.Read();
var w = reader.GetSingle();
reader.Read();
if (reader.TokenType != JsonTokenType.EndArray)
throw new JsonException("Expected 4 elements for Quaternion");
return new Quaternion(x, y, z, w);
}
public override void Write(Utf8JsonWriter writer, Quaternion value, JsonSerializerOptions options)
{
writer.WriteStartArray();
writer.WriteNumberValue(value.X);
writer.WriteNumberValue(value.Y);
writer.WriteNumberValue(value.Z);
writer.WriteNumberValue(value.W);
writer.WriteEndArray();
}
}