feat: OpenTK 4.x OpenGL backend — replaces Silk.NET, default renderer

- Deleted Engine.Graphics.OpenGL (Silk.NET) project
- Created Engine.Graphics.OpenTK with OpenTK 4.9.4
- OpenTKWindow: GameWindow + ProcessEvents() (non-blocking), SwapBuffers()
- OpenTKInputState: KeyboardState/MouseState → Engine.Core.Key
- OpenTKRenderer: raw OpenGL 3.3 Core, managed arrays (no unsafe),
  OpenTK.Mathematics.Matrix4 (column-major, no transpose needed),
  shadow FBO with depth texture, PCF 3x3, full PBR shader
- OpenTKRenderContext + OpenTKBackendRegistrar ('opentk')
- Program.cs: default backend is 'opentk'
- 66/66 tests, 0 errors, 1600 FPS (VSync off)
This commit is contained in:
emil28092005
2026-06-17 21:24:33 +03:00
parent 5687e97a09
commit 270806a189
14 changed files with 684 additions and 737 deletions
+20 -20
View File
@@ -21,7 +21,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics", "src\Engine.Physics\Engine.Physics.csproj", "{3F5F10AA-A4CD-4E37-B4CE-B37BC60C3883}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Graphics.OpenGL", "src\Engine.Graphics.OpenGL\Engine.Graphics.OpenGL.csproj", "{ED34FE48-9D17-4564-82BA-FA8D3852719A}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Graphics.OpenTK", "src\Engine.Graphics.OpenTK\Engine.Graphics.OpenTK.csproj", "{641B69F3-E52C-4713-A97C-DAC1C814DD72}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -178,30 +178,30 @@ Global
{3F5F10AA-A4CD-4E37-B4CE-B37BC60C3883}.ReleaseAOT|x64.Build.0 = Debug|Any CPU
{3F5F10AA-A4CD-4E37-B4CE-B37BC60C3883}.ReleaseAOT|x86.ActiveCfg = Debug|Any CPU
{3F5F10AA-A4CD-4E37-B4CE-B37BC60C3883}.ReleaseAOT|x86.Build.0 = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Debug|x64.ActiveCfg = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Debug|x64.Build.0 = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Debug|x86.ActiveCfg = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Debug|x86.Build.0 = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Release|Any CPU.Build.0 = Release|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Release|x64.ActiveCfg = Release|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Release|x64.Build.0 = Release|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Release|x86.ActiveCfg = Release|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.Release|x86.Build.0 = Release|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.ReleaseAOT|Any CPU.ActiveCfg = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.ReleaseAOT|Any CPU.Build.0 = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.ReleaseAOT|x64.ActiveCfg = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.ReleaseAOT|x64.Build.0 = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.ReleaseAOT|x86.ActiveCfg = Debug|Any CPU
{ED34FE48-9D17-4564-82BA-FA8D3852719A}.ReleaseAOT|x86.Build.0 = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Debug|Any CPU.Build.0 = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Debug|x64.ActiveCfg = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Debug|x64.Build.0 = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Debug|x86.ActiveCfg = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Debug|x86.Build.0 = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Release|Any CPU.ActiveCfg = Release|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Release|Any CPU.Build.0 = Release|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Release|x64.ActiveCfg = Release|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Release|x64.Build.0 = Release|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Release|x86.ActiveCfg = Release|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.Release|x86.Build.0 = Release|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.ReleaseAOT|Any CPU.ActiveCfg = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.ReleaseAOT|Any CPU.Build.0 = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.ReleaseAOT|x64.ActiveCfg = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.ReleaseAOT|x64.Build.0 = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.ReleaseAOT|x86.ActiveCfg = Debug|Any CPU
{641B69F3-E52C-4713-A97C-DAC1C814DD72}.ReleaseAOT|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{3F5F10AA-A4CD-4E37-B4CE-B37BC60C3883} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{ED34FE48-9D17-4564-82BA-FA8D3852719A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{641B69F3-E52C-4713-A97C-DAC1C814DD72} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
+1 -1
View File
@@ -26,7 +26,7 @@
<ProjectReference Include="..\Engine.Graphics.Vulkan\Engine.Graphics.Vulkan.csproj" />
<ProjectReference Include="..\Engine.AI\Engine.AI.csproj" />
<ProjectReference Include="..\Engine.Physics\Engine.Physics.csproj" />
<ProjectReference Include="..\Engine.Graphics.OpenGL\Engine.Graphics.OpenGL.csproj" />
<ProjectReference Include="..\Engine.Graphics.OpenTK\Engine.Graphics.OpenTK.csproj" />
</ItemGroup>
<ItemGroup>
+5 -5
View File
@@ -11,7 +11,7 @@ using Engine.Core;
using Engine.Core.Components;
using Engine.Graphics;
using Engine.Graphics.Loaders;
using Engine.Graphics.OpenGL;
using Engine.Graphics.OpenTK;
using Engine.Graphics.RaylibBackend;
using Engine.Graphics.Vulkan;
using Engine.Physics;
@@ -44,8 +44,8 @@ class Program
RaylibBackendRegistrar.EnsureRegistered();
VulkanBackendRegistrar.EnsureRegistered();
OpenGLBackendRegistrar.EnsureRegistered();
using var renderContext = RenderBackendFactory.Create("raylib", 1280, 720, enableValidation: false);
OpenTKBackendRegistrar.EnsureRegistered();
using var renderContext = RenderBackendFactory.Create("opentk", 1280, 720, enableValidation: false);
var window = renderContext.Window;
var input = window.Input;
using var renderer = renderContext.CreateRenderer();
@@ -296,8 +296,8 @@ class Program
queue.CompletePendingScreenshots();
// Swap buffers for OpenGL backend
if (renderContext is OpenGLRenderContext glCtx2)
glCtx2.SwapBuffers();
if (renderContext is OpenTKRenderContext otkCtx)
otkCtx.SwapBuffers();
frames++;
if (timing.TotalTime - lastFpsTime >= 1.0)
@@ -1,16 +0,0 @@
using Engine.Graphics;
namespace Engine.Graphics.OpenGL;
/// <summary>
/// Triggers registration of the OpenGL backend with the HAL factory.
/// </summary>
public static class OpenGLBackendRegistrar
{
static OpenGLBackendRegistrar()
{
RenderBackendFactory.Register("opengl", (width, height, _) => new OpenGLRenderContext(width, height));
}
public static void EnsureRegistered() { }
}
@@ -1,93 +0,0 @@
using System.Collections.Generic;
using Engine.Core;
using Silk.NET.Input;
using System.Numerics;
namespace Engine.Graphics.OpenGL;
public sealed class OpenGLInputState : IInputState
{
private IKeyboard? _keyboard;
private IMouse? _mouse;
private readonly HashSet<Engine.Core.Key> _keysDown = new();
private readonly HashSet<Engine.Core.Key> _keysPressed = new();
private readonly HashSet<Engine.Core.Key> _keysReleased = new();
private float _mouseWheelDelta;
private bool _mouseLeft, _mouseRight, _mouseMiddle;
public int MouseX { get; private set; }
public int MouseY { get; private set; }
public bool MouseLeft => _mouseLeft;
public bool MouseRight => _mouseRight;
public bool MouseMiddle => _mouseMiddle;
public float MouseWheelDelta => _mouseWheelDelta;
public void Initialize(IInputContext input)
{
if (input.Keyboards.Count > 0)
{
_keyboard = input.Keyboards[0];
_keyboard.KeyDown += (kb, key, scancode) =>
{
var k = SilkToKey(key);
if (k == Engine.Core.Key.Unknown) return;
if (!_keysDown.Contains(k)) _keysPressed.Add(k);
_keysDown.Add(k);
};
_keyboard.KeyUp += (kb, key, scancode) =>
{
var k = SilkToKey(key);
if (k == Engine.Core.Key.Unknown) return;
_keysDown.Remove(k);
_keysReleased.Add(k);
};
}
if (input.Mice.Count > 0)
{
_mouse = input.Mice[0];
_mouse.MouseMove += (m, pos) => { MouseX = (int)pos.X; MouseY = (int)pos.Y; };
_mouse.MouseDown += (m, btn) =>
{
if (btn == MouseButton.Left) _mouseLeft = true;
if (btn == MouseButton.Right) _mouseRight = true;
if (btn == MouseButton.Middle) _mouseMiddle = true;
};
_mouse.MouseUp += (m, btn) =>
{
if (btn == MouseButton.Left) _mouseLeft = false;
if (btn == MouseButton.Right) _mouseRight = false;
if (btn == MouseButton.Middle) _mouseMiddle = false;
};
_mouse.Scroll += (m, wheel) => _mouseWheelDelta += (float)wheel.Y;
}
}
public void Poll() { }
public void BeginFrame()
{
_keysPressed.Clear();
_keysReleased.Clear();
_mouseWheelDelta = 0;
}
public bool IsKeyDown(Engine.Core.Key key) => _keysDown.Contains(key);
public bool IsKeyPressed(Engine.Core.Key key) => _keysPressed.Contains(key);
public bool IsKeyReleased(Engine.Core.Key key) => _keysReleased.Contains(key);
private static Engine.Core.Key SilkToKey(Silk.NET.Input.Key key) => key switch
{
Silk.NET.Input.Key.Space => Engine.Core.Key.Space,
Silk.NET.Input.Key.Escape => Engine.Core.Key.Escape,
Silk.NET.Input.Key.Enter => Engine.Core.Key.Enter,
Silk.NET.Input.Key.W => Engine.Core.Key.W,
Silk.NET.Input.Key.A => Engine.Core.Key.A,
Silk.NET.Input.Key.S => Engine.Core.Key.S,
Silk.NET.Input.Key.D => Engine.Core.Key.D,
Silk.NET.Input.Key.Q => Engine.Core.Key.Q,
Silk.NET.Input.Key.E => Engine.Core.Key.E,
Silk.NET.Input.Key.F => Engine.Core.Key.F,
Silk.NET.Input.Key.ShiftLeft => Engine.Core.Key.LeftShift,
_ => Engine.Core.Key.Unknown,
};
}
@@ -1,46 +0,0 @@
using Engine.Core;
using Engine.Graphics;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
using SilkWindow = Silk.NET.Windowing.IWindow;
using CoreIWindow = Engine.Core.IWindow;
namespace Engine.Graphics.OpenGL;
public sealed class OpenGLRenderContext : IRenderContext
{
private readonly OpenGLWindow _window;
private GL? _gl;
private OpenGLRenderer? _renderer;
private bool _disposed;
public CoreIWindow Window => _window;
public OpenGLRenderContext(int width, int height, bool enableValidation = false)
{
_window = new OpenGLWindow("Cortex Engine (OpenGL)", width, height);
// Initialize() triggers the Load callback which creates the GL context
_window.SilkView.Initialize();
// After Initialize, CreateOpenGL should work
_gl = _window.CreateGL();
_window.OnLoad();
}
public IRenderer CreateRenderer()
{
_renderer = new OpenGLRenderer(_gl!);
return _renderer;
}
public void Resize(int width, int height) => _renderer?.SetScreenSize(width, height);
public void SwapBuffers() => _window.SilkView.SwapBuffers();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_renderer?.Dispose();
_window.Dispose();
}
}
@@ -1,492 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using Engine.Core;
using Engine.Core.Components;
using Engine.Graphics;
using Flecs.NET.Core;
using Silk.NET.OpenGL;
using EngineMaterial = Engine.Core.Components.Material;
using EngineMesh = Engine.Core.Components.Mesh;
using EngineTransform = Engine.Core.Components.Transform;
namespace Engine.Graphics.OpenGL;
public sealed class OpenGLRenderer : IRenderer
{
private readonly GL _gl;
private readonly uint _program;
private readonly uint _shadowProgram;
private readonly uint _shadowFbo;
private readonly uint _shadowTexture;
private const int ShadowMapSize = 2048;
private readonly Dictionary<Entity, GLMesh> _meshCache = new();
private readonly int _uMVP, _uModel, _uViewPos, _uMaterialColor, _uRoughness, _uMetallic;
private readonly int _uAmbient, _uLightCount, _uLightDirs, _uLightIntensities, _uLightColors;
private readonly int _uLightPositions, _uLightTypes, _uLightRanges;
private readonly int _uLightViewProj, _uShadowMap, _uUseTexture;
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 _screenWidth = 1280;
private int _screenHeight = 720;
private bool _disposed;
private readonly float[] _matrixBuffer = new float[16];
public OpenGLRenderer(GL gl)
{
_gl = gl;
_gl.Enable(GLEnum.DepthTest);
_program = CreateProgram(VertexShaderSource, FragmentShaderSource);
_shadowProgram = CreateProgram(ShadowVertexSource, ShadowFragmentSource);
_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 with depth texture
_shadowFbo = _gl.GenFramebuffer();
_gl.BindFramebuffer(GLEnum.Framebuffer, _shadowFbo);
_shadowTexture = _gl.GenTexture();
_gl.BindTexture(GLEnum.Texture2D, _shadowTexture);
unsafe
{
_gl.TexImage2D(GLEnum.Texture2D, 0, (int)GLEnum.DepthComponent, (uint)ShadowMapSize, (uint)ShadowMapSize, 0, GLEnum.DepthComponent, GLEnum.Float, null);
}
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)GLEnum.Nearest);
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)GLEnum.Nearest);
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.ClampToEdge);
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.ClampToEdge);
_gl.FramebufferTexture2D(GLEnum.Framebuffer, GLEnum.DepthAttachment, GLEnum.Texture2D, _shadowTexture, 0);
_gl.DrawBuffer(DrawBufferMode.None);
_gl.ReadBuffer(ReadBufferMode.None);
_gl.BindFramebuffer(GLEnum.Framebuffer, 0);
}
public void RequestScreenshot(string outputPath) { }
public bool IsScreenshotRequested => false;
public IScreenshotProvider ScreenshotProvider => new OpenGLScreenshotProvider();
public void SetScreenSize(int w, int h) { _screenWidth = w; _screenHeight = h; }
public void RenderWorld(World world)
{
var camera = GetCamera(world);
CollectLights(world);
var hasDirLight = _lightCount > 0 && _lightTypes[0] == (int)LightType.Directional;
Matrix4x4 lightViewProj = Matrix4x4.Identity;
// === PASS 1: Shadow map ===
if (hasDirLight)
{
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;
lightViewProj = Matrix4x4.CreateOrthographicOffCenter(-15, 15, -15, 15, 1, 80)
* Matrix4x4.CreateLookAt(lightPos, sceneCenter, up);
_gl.Viewport(0, 0, (uint)ShadowMapSize, (uint)ShadowMapSize);
_gl.BindFramebuffer(GLEnum.Framebuffer, _shadowFbo);
_gl.Clear((uint)GLEnum.DepthBufferBit);
_gl.UseProgram(_shadowProgram);
_gl.CullFace(GLEnum.Front);
var shadowMvpLoc = _gl.GetUniformLocation(_shadowProgram, "mvp");
world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) =>
{
if (e.Name() == "Grid" || e.Name() == "Floor") return;
var glMesh = GetOrUploadMesh(e, mesh);
var model = transform.GetMatrix();
var mvp = lightViewProj * model;
CopyMatrixToBuffer(mvp);
unsafe
{
fixed (float* p = _matrixBuffer)
_gl.UniformMatrix4(shadowMvpLoc, 1, false, p);
}
DrawMeshImmediate(glMesh);
});
_gl.CullFace(GLEnum.Back);
_gl.BindFramebuffer(GLEnum.Framebuffer, 0);
}
// === PASS 2: Main render ===
_gl.Viewport(0, 0, (uint)_screenWidth, (uint)_screenHeight);
_gl.Enable(GLEnum.DepthTest);
_gl.Disable(GLEnum.CullFace);
_gl.ClearColor(0.098f, 0.118f, 0.157f, 1);
_gl.Clear((uint)(GLEnum.ColorBufferBit | GLEnum.DepthBufferBit));
_gl.UseProgram(_program);
var view = Matrix4x4.CreateLookAt(camera.Position, camera.Target, camera.Up);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(camera.FieldOfView, camera.AspectRatio, camera.NearPlane, camera.FarPlane);
// Frame uniforms
_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);
unsafe
{
fixed (float* p = _lightDirs) _gl.Uniform3(_uLightDirs, 4, p);
fixed (float* p = _lightIntensities) _gl.Uniform1(_uLightIntensities, 4, p);
fixed (float* p = _lightColors) _gl.Uniform3(_uLightColors, 4, p);
fixed (float* p = _lightPositions) _gl.Uniform3(_uLightPositions, 4, p);
fixed (int* p = _lightTypes) _gl.Uniform1(_uLightTypes, 4, p);
fixed (float* p = _lightRanges) _gl.Uniform1(_uLightRanges, 4, p);
}
if (hasDirLight)
{
CopyMatrixToBuffer(lightViewProj);
unsafe
{
fixed (float* p = _matrixBuffer)
_gl.UniformMatrix4(_uLightViewProj, 1, false, p);
}
_gl.ActiveTexture(GLEnum.Texture1);
_gl.BindTexture(GLEnum.Texture2D, _shadowTexture);
_gl.Uniform1(_uShadowMap, 1);
_gl.ActiveTexture(GLEnum.Texture0);
}
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 glMesh = GetOrUploadMesh(e, mesh);
var model = transform.GetMatrix();
var mvp = proj * view * model;
CopyMatrixToBuffer(mvp);
unsafe { fixed (float* pmvp = _matrixBuffer) _gl.UniformMatrix4(_uMVP, 1, false, pmvp); }
CopyMatrixToBuffer(model);
unsafe { fixed (float* pmodel = _matrixBuffer) _gl.UniformMatrix4(_uModel, 1, false, pmodel); }
_gl.Uniform4(_uMaterialColor, material.Albedo.X, material.Albedo.Y, material.Albedo.Z, 1.0f);
_gl.Uniform1(_uRoughness, material.Roughness);
_gl.Uniform1(_uMetallic, material.Metallic);
_gl.Uniform1(_uUseTexture, 0);
DrawMeshImmediate(glMesh);
});
}
private void DrawMeshImmediate(GLMesh mesh)
{
_gl.BindVertexArray(mesh.Vao);
unsafe { _gl.DrawElements(GLEnum.Triangles, (uint)mesh.IndexCount, GLEnum.UnsignedInt, null); }
_gl.BindVertexArray(0);
}
private GLMesh GetOrUploadMesh(Entity e, EngineMesh mesh)
{
if (_meshCache.TryGetValue(e, out var existing))
return existing;
var vao = _gl.GenVertexArray();
_gl.BindVertexArray(vao);
// Position (location 0)
var positions = new float[mesh.Vertices.Length * 3];
for (var i = 0; i < mesh.Vertices.Length; i++)
{
positions[i * 3] = mesh.Vertices[i].Position.X;
positions[i * 3 + 1] = mesh.Vertices[i].Position.Y;
positions[i * 3 + 2] = mesh.Vertices[i].Position.Z;
}
var posVbo = _gl.GenBuffer();
_gl.BindBuffer(GLEnum.ArrayBuffer, posVbo);
UploadBufferData(positions);
_gl.EnableVertexAttribArray(0);
unsafe { _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, 3 * sizeof(float), (void*)0); }
// Normal (location 1)
var normals = new float[mesh.Vertices.Length * 3];
for (var i = 0; i < mesh.Vertices.Length; i++)
{
normals[i * 3] = mesh.Vertices[i].Normal.X;
normals[i * 3 + 1] = mesh.Vertices[i].Normal.Y;
normals[i * 3 + 2] = mesh.Vertices[i].Normal.Z;
}
var nrmVbo = _gl.GenBuffer();
_gl.BindBuffer(GLEnum.ArrayBuffer, nrmVbo);
UploadBufferData(normals);
_gl.EnableVertexAttribArray(1);
unsafe { _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, 3 * sizeof(float), (void*)0); }
// Color (location 2)
var colors = new float[mesh.Vertices.Length * 4];
for (var i = 0; i < mesh.Vertices.Length; i++)
{
colors[i * 4] = mesh.Vertices[i].Color.X;
colors[i * 4 + 1] = mesh.Vertices[i].Color.Y;
colors[i * 4 + 2] = mesh.Vertices[i].Color.Z;
colors[i * 4 + 3] = 1.0f;
}
var colVbo = _gl.GenBuffer();
_gl.BindBuffer(GLEnum.ArrayBuffer, colVbo);
UploadBufferData(colors);
_gl.EnableVertexAttribArray(2);
unsafe { _gl.VertexAttribPointer(2, 4, GLEnum.Float, false, 4 * sizeof(float), (void*)0); }
// Indices — use pinned GCHandle
var ebo = _gl.GenBuffer();
_gl.BindBuffer(GLEnum.ElementArrayBuffer, ebo);
var indexHandle = System.Runtime.InteropServices.GCHandle.Alloc(mesh.Indices, System.Runtime.InteropServices.GCHandleType.Pinned);
unsafe
{
_gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(mesh.Indices.Length * sizeof(uint)), (void*)indexHandle.AddrOfPinnedObject(), GLEnum.StaticDraw);
}
indexHandle.Free();
_gl.BindVertexArray(0);
var glMesh = new GLMesh(vao, mesh.Indices.Length);
_meshCache[e] = glMesh;
return glMesh;
}
private void CollectLights(World world)
{
var 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 (var 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 uint CreateProgram(string vs, string fs)
{
var vertex = _gl.CreateShader(GLEnum.VertexShader);
_gl.ShaderSource(vertex, vs);
_gl.CompileShader(vertex);
_gl.GetShader(vertex, ShaderParameterName.CompileStatus, out int vStatus);
if (vStatus == 0) throw new Exception($"VS: {_gl.GetShaderInfoLog(vertex)}");
var fragment = _gl.CreateShader(GLEnum.FragmentShader);
_gl.ShaderSource(fragment, fs);
_gl.CompileShader(fragment);
_gl.GetShader(fragment, ShaderParameterName.CompileStatus, out int fStatus);
if (fStatus == 0) throw new Exception($"FS: {_gl.GetShaderInfoLog(fragment)}");
var program = _gl.CreateProgram();
_gl.AttachShader(program, vertex);
_gl.AttachShader(program, fragment);
_gl.LinkProgram(program);
_gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int lStatus);
if (lStatus == 0) throw new Exception($"Link: {_gl.GetProgramInfoLog(program)}");
_gl.DeleteShader(vertex);
_gl.DeleteShader(fragment);
return program;
}
private unsafe void UploadBufferData(float[] data)
{
var handle = System.Runtime.InteropServices.GCHandle.Alloc(data, System.Runtime.InteropServices.GCHandleType.Pinned);
try
{
_gl.BufferData(GLEnum.ArrayBuffer, (nuint)(data.Length * sizeof(float)), (void*)handle.AddrOfPinnedObject(), GLEnum.StaticDraw);
}
finally
{
handle.Free();
}
}
private void CopyMatrixToBuffer(Matrix4x4 m)
{
// OpenGL expects column-major; System.Numerics is row-major, so transpose during copy
_matrixBuffer[0] = m.M11; _matrixBuffer[1] = m.M21; _matrixBuffer[2] = m.M31; _matrixBuffer[3] = m.M41;
_matrixBuffer[4] = m.M12; _matrixBuffer[5] = m.M22; _matrixBuffer[6] = m.M32; _matrixBuffer[7] = m.M42;
_matrixBuffer[8] = m.M13; _matrixBuffer[9] = m.M23; _matrixBuffer[10] = m.M33; _matrixBuffer[11] = m.M43;
_matrixBuffer[12] = m.M14; _matrixBuffer[13] = m.M24; _matrixBuffer[14] = m.M34; _matrixBuffer[15] = m.M44;
}
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);
}
private const string ShadowVertexSource = @"#version 330 core
layout(location=0) in vec3 aPos;
uniform mat4 mvp;
void main(){gl_Position=mvp*vec4(aPos,1.0);}";
private const string ShadowFragmentSource = @"#version 330 core
void main(){}";
private const string VertexShaderSource = @"#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 FragmentShaderSource = @"#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),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 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));
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(uint Vao, int IndexCount);
private sealed class OpenGLScreenshotProvider : IScreenshotProvider
{
public Task<byte[]> CaptureAsync(string outputPath) => Task.FromResult(Array.Empty<byte>());
}
}
@@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
using Engine.Core;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
using SilkWindow = Silk.NET.Windowing.IWindow;
namespace Engine.Graphics.OpenGL;
public sealed class OpenGLWindow : Engine.Core.IWindow, IDisposable
{
private readonly SilkWindow _silkWindow;
private readonly OpenGLInputState _input = new();
private bool _shouldClose;
private bool _disposed;
public int Width => _silkWindow.Size.X;
public int Height => _silkWindow.Size.Y;
public bool ShouldClose => _shouldClose || _silkWindow.IsClosing;
IInputState Engine.Core.IWindow.Input => _input;
public nint Handle => 0;
public SilkWindow SilkView => _silkWindow;
public OpenGLWindow(string title, int width, int height)
{
var options = WindowOptions.Default;
options.Title = title;
options.Size = new Silk.NET.Maths.Vector2D<int>(width, height);
options.VSync = false;
options.API = new GraphicsAPI(ContextAPI.OpenGL, ContextProfile.Core, ContextFlags.Default, new APIVersion(3, 3));
options.WindowState = WindowState.Normal;
options.ShouldSwapAutomatically = false;
_silkWindow = Window.Create(options);
_silkWindow.Closing += () => _shouldClose = true;
// Initialize without Run() — enables non-blocking event loop
_silkWindow.Initialize();
}
public void OnLoad()
{
var inputContext = _silkWindow.CreateInput();
_input.Initialize(inputContext);
}
public GL CreateGL() => _silkWindow.CreateOpenGL();
public void PumpEvents() => _silkWindow.DoEvents();
public void Close() => _shouldClose = true;
public string[] GetRequiredVulkanExtensions() => Array.Empty<string>();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_silkWindow.Close();
_silkWindow.Dispose();
}
}
@@ -8,12 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.OpenGL" Version="2.21.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.21.0" />
<PackageReference Include="Silk.NET.Input" Version="2.21.0" />
<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'" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,16 @@
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() { }
}
@@ -0,0 +1,104 @@
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,
};
}
@@ -0,0 +1,47 @@
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();
}
}
@@ -0,0 +1,438 @@
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 OTKVector4 = OpenTK.Mathematics.Vector4;
using EngineMaterial = Engine.Core.Components.Material;
using EngineMesh = Engine.Core.Components.Mesh;
using EngineTransform = Engine.Core.Components.Transform;
namespace Engine.Graphics.OpenTK;
/// <summary>
/// OpenTK OpenGL renderer — full control over OpenGL state for shadow mapping.
/// </summary>
public sealed class OpenTKRenderer : IRenderer
{
private const int ShadowMapSize = 2048;
private readonly int _program;
private readonly int _shadowProgram;
private readonly int _shadowFbo;
private readonly int _shadowTexture;
private readonly Dictionary<Entity, GLMesh> _meshCache = new();
// Uniform locations
private readonly int _uMVP, _uModel, _uViewPos, _uMaterialColor, _uRoughness, _uMetallic;
private readonly int _uAmbient, _uLightCount, _uLightDirs, _uLightIntensities, _uLightColors;
private readonly int _uLightPositions, _uLightTypes, _uLightRanges;
private readonly 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 _screenWidth = 1280;
private int _screenHeight = 720;
private bool _disposed;
public OpenTKRenderer()
{
GL.Enable(EnableCap.DepthTest);
_program = CreateProgram(VertexShaderSource, FragmentShaderSource);
_shadowProgram = CreateProgram(ShadowVertexSource, ShadowFragmentSource);
_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 with depth texture
_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);
}
public void SetScreenSize(int w, int h) { _screenWidth = w; _screenHeight = h; }
public void RequestScreenshot(string outputPath) { }
public bool IsScreenshotRequested => false;
public IScreenshotProvider ScreenshotProvider => new OpenTKScreenshotProvider();
public void RenderWorld(World world)
{
var camera = GetCamera(world);
CollectLights(world);
var hasDirLight = _lightCount > 0 && _lightTypes[0] == (int)LightType.Directional;
OTKMatrix lightViewProj = OTKMatrix.Identity;
// === PASS 1: Shadow map ===
if (hasDirLight)
{
var lightDir = new System.Numerics.Vector3(_lightDirs[0], _lightDirs[1], _lightDirs[2]);
var sceneCenter = new System.Numerics.Vector3(0, 0.5f, 0);
var lightPos = sceneCenter - lightDir * 30f;
var up = MathF.Abs(System.Numerics.Vector3.Dot(lightDir, System.Numerics.Vector3.UnitY)) > 0.99f
? System.Numerics.Vector3.UnitZ : System.Numerics.Vector3.UnitY;
lightViewProj = OTKMatrix.CreateOrthographicOffCenter(-15, 15, -15, 15, 1, 80)
* OTKMatrix.LookAt(ToOTK(lightPos), ToOTK(sceneCenter), ToOTK(up));
GL.Viewport(0, 0, ShadowMapSize, ShadowMapSize);
GL.BindFramebuffer(FramebufferTarget.Framebuffer, _shadowFbo);
GL.Clear(ClearBufferMask.DepthBufferBit);
GL.UseProgram(_shadowProgram);
GL.CullFace(CullFaceMode.Front);
var shadowMvpLoc = GL.GetUniformLocation(_shadowProgram, "mvp");
world.Each((Entity e, ref EngineMesh mesh, ref EngineTransform transform) =>
{
if (e.Name() == "Grid" || e.Name() == "Floor") return;
var glMesh = GetOrUploadMesh(e, mesh);
var model = ToOTK(transform.GetMatrix());
var mvp = lightViewProj * model;
GL.UniformMatrix4(shadowMvpLoc, false, ref mvp);
DrawMeshImmediate(glMesh);
});
GL.CullFace(CullFaceMode.Back);
GL.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
}
// === PASS 2: Main render ===
GL.Viewport(0, 0, _screenWidth, _screenHeight);
GL.Enable(EnableCap.DepthTest);
GL.Disable(EnableCap.CullFace);
GL.ClearColor(0.098f, 0.118f, 0.157f, 1);
GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
GL.UseProgram(_program);
var view = OTKMatrix.LookAt(ToOTK(camera.Position), ToOTK(camera.Target), ToOTK(camera.Up));
var proj = OTKMatrix.CreatePerspectiveFieldOfView(camera.FieldOfView, camera.AspectRatio, camera.NearPlane, camera.FarPlane);
// Frame uniforms
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)
{
GL.UniformMatrix4(_uLightViewProj, false, ref lightViewProj);
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 transform) =>
{
if (e.Name() == "Grid") return;
var material = e.Has<EngineMaterial>() ? e.Get<EngineMaterial>() : EngineMaterial.Default;
var glMesh = GetOrUploadMesh(e, mesh);
var model = ToOTK(transform.GetMatrix());
var mvp = proj * view * model;
GL.UniformMatrix4(_uMVP, false, ref mvp);
GL.UniformMatrix4(_uModel, false, ref model);
GL.Uniform4(_uMaterialColor, material.Albedo.X, material.Albedo.Y, material.Albedo.Z, 1.0f);
GL.Uniform1(_uRoughness, material.Roughness);
GL.Uniform1(_uMetallic, material.Metallic);
GL.Uniform1(_uUseTexture, 0);
DrawMeshImmediate(glMesh);
});
}
private void DrawMeshImmediate(GLMesh mesh)
{
GL.BindVertexArray(mesh.Vao);
GL.DrawElements(PrimitiveType.Triangles, mesh.IndexCount, DrawElementsType.UnsignedInt, 0);
GL.BindVertexArray(0);
}
private GLMesh GetOrUploadMesh(Entity e, EngineMesh mesh)
{
if (_meshCache.TryGetValue(e, out var existing))
return existing;
var vao = GL.GenVertexArray();
GL.BindVertexArray(vao);
// Position (location 0)
var positions = new float[mesh.Vertices.Length * 3];
for (var i = 0; i < mesh.Vertices.Length; i++)
{
positions[i * 3] = mesh.Vertices[i].Position.X;
positions[i * 3 + 1] = mesh.Vertices[i].Position.Y;
positions[i * 3 + 2] = mesh.Vertices[i].Position.Z;
}
var posVbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, posVbo);
GL.BufferData(BufferTarget.ArrayBuffer, positions.Length * sizeof(float), positions, BufferUsageHint.StaticDraw);
GL.EnableVertexAttribArray(0);
GL.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), 0);
// Normal (location 1)
var normals = new float[mesh.Vertices.Length * 3];
for (var i = 0; i < mesh.Vertices.Length; i++)
{
normals[i * 3] = mesh.Vertices[i].Normal.X;
normals[i * 3 + 1] = mesh.Vertices[i].Normal.Y;
normals[i * 3 + 2] = mesh.Vertices[i].Normal.Z;
}
var nrmVbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, nrmVbo);
GL.BufferData(BufferTarget.ArrayBuffer, normals.Length * sizeof(float), normals, BufferUsageHint.StaticDraw);
GL.EnableVertexAttribArray(1);
GL.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), 0);
// Color (location 2)
var colors = new float[mesh.Vertices.Length * 4];
for (var i = 0; i < mesh.Vertices.Length; i++)
{
colors[i * 4] = mesh.Vertices[i].Color.X;
colors[i * 4 + 1] = mesh.Vertices[i].Color.Y;
colors[i * 4 + 2] = mesh.Vertices[i].Color.Z;
colors[i * 4 + 3] = 1.0f;
}
var colVbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, colVbo);
GL.BufferData(BufferTarget.ArrayBuffer, colors.Length * sizeof(float), colors, BufferUsageHint.StaticDraw);
GL.EnableVertexAttribArray(2);
GL.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 4 * sizeof(float), 0);
// Indices
var 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 glMesh = new GLMesh(vao, mesh.Indices.Length);
_meshCache[e] = glMesh;
return glMesh;
}
private void CollectLights(World world)
{
var 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 (var i = count; i < 4; i++) { _lightIntensities[i] = 0; _lightTypes[i] = 0; }
_lightCount = count;
}
private Camera GetCamera(World world)
{
var cam = new Camera(new System.Numerics.Vector3(0, 0.75f, -30),
new System.Numerics.Vector3(0, 0.5f, 0), System.Numerics.Vector3.UnitY,
MathF.PI / 12, 16f / 9f, 0.1f, 100f);
world.Each((Entity e, ref Camera c) => cam = c);
return cam;
}
private static OTKMatrix ToOTK(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 ToOTK(System.Numerics.Vector3 v) => new(v.X, v.Y, v.Z);
private static int CreateProgram(string vs, string fs)
{
var vertex = GL.CreateShader(ShaderType.VertexShader);
GL.ShaderSource(vertex, vs);
GL.CompileShader(vertex);
GL.GetShader(vertex, ShaderParameter.CompileStatus, out int vStatus);
if (vStatus == 0) throw new Exception($"VS: {GL.GetShaderInfoLog(vertex)}");
var fragment = GL.CreateShader(ShaderType.FragmentShader);
GL.ShaderSource(fragment, fs);
GL.CompileShader(fragment);
GL.GetShader(fragment, ShaderParameter.CompileStatus, out int fStatus);
if (fStatus == 0) throw new Exception($"FS: {GL.GetShaderInfoLog(fragment)}");
var program = GL.CreateProgram();
GL.AttachShader(program, vertex);
GL.AttachShader(program, fragment);
GL.LinkProgram(program);
GL.GetProgram(program, GetProgramParameterName.LinkStatus, out int lStatus);
if (lStatus == 0) throw new Exception($"Link: {GL.GetProgramInfoLog(program)}");
GL.DeleteShader(vertex);
GL.DeleteShader(fragment);
return program;
}
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 (copied from Silk.NET OpenGL backend — backend-agnostic GLSL 330 core)
private const string ShadowVertexSource = @"#version 330 core
layout(location=0) in vec3 aPos;
uniform mat4 mvp;
void main(){gl_Position=mvp*vec4(aPos,1.0);}";
private const string ShadowFragmentSource = @"#version 330 core
void main(){}";
private const string VertexShaderSource = @"#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 FragmentShaderSource = @"#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),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 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));
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 IndexCount);
private sealed class OpenTKScreenshotProvider : IScreenshotProvider
{
public Task<byte[]> CaptureAsync(string outputPath) => Task.FromResult(Array.Empty<byte>());
}
}
@@ -0,0 +1,52 @@
using System;
using Engine.Core;
using OpenTK.Windowing.Common;
using OpenTK.Windowing.Desktop;
using OpenTK.Windowing.GraphicsLibraryFramework;
namespace Engine.Graphics.OpenTK;
/// <summary>
/// OpenTK GameWindow-backed implementation of IWindow.
/// Uses ProcessEvents() (non-blocking) instead of Run() to integrate with our main loop.
/// </summary>
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,
})
{
}
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();
}
}