chore: remove all graphics backends (Raylib, OpenTK, Vulkan/Silk.NET) — prepare for pure Vulkan P/Invoke rewrite
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
# CORTEX ENGINE — VULKAN RENDERER IMPLEMENTATION PLAN
|
||||
|
||||
## Project State (June 2026)
|
||||
|
||||
### What Exists
|
||||
- **Engine.Core** — Sdl3Window (SDL3, Vulkan surface ready), IWindow, IInputState, Key enum, InputMapping,
|
||||
camera controllers (FreeFly, Orbit), components (Transform, Mesh, Material, Light, Camera, RigidBody),
|
||||
Vertex struct (Position, Color, Normal — 9 floats), Timing, IScreenshotProvider
|
||||
- **Engine.Physics** — JoltPhysicsSharp 2.21.0, PhysicsWorld wrapper, RigidBody component
|
||||
- **Engine.AI** — AiCommandProcessor (7 commands), MCP HTTP + stdio servers, AiCommandQueue
|
||||
- **CortexEngine.App** — main loop (broken, references deleted graphics projects)
|
||||
- **tests/Engine.Tests** — 66 tests (broken, reference Engine.Graphics)
|
||||
- **Content/** — cube.obj, torusknot.obj, checker.png
|
||||
|
||||
### What Was Deleted
|
||||
- Engine.Graphics (interfaces + loaders + factory)
|
||||
- Engine.Graphics.Raylib
|
||||
- Engine.Graphics.OpenTK
|
||||
- Engine.Graphics.Vulkan (Silk.NET version)
|
||||
|
||||
### Environment
|
||||
- .NET 9 SDK at $HOME/.dotnet
|
||||
- Vulkan 1.4.329, NVIDIA RTX 2080 Ti, validation layers available
|
||||
- SDL3 (ppy.SDL3-CS 2026.520.0) — window + Vulkan surface
|
||||
- glslangValidator NOT installed (need: sudo apt install glslang-tools)
|
||||
- Linux (X11), cross-platform target (Windows: vulkan-1.dll, Linux: libvulkan.so.1)
|
||||
|
||||
### Key Architecture Decisions
|
||||
- NO wrapper libraries (no Silk.NET, no Vortice, no OpenTK for Vulkan)
|
||||
- Pure P/Invoke to libvulkan.so.1 / vulkan-1.dll
|
||||
- SDL3 for windowing (Sdl3Window already works, creates Vulkan surface)
|
||||
- ImGui planned (later phase)
|
||||
- Shadow mapping planned (Vulkan gives full control)
|
||||
- Validation layers for debugging
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Restore Engine.Graphics (interfaces + loaders)
|
||||
|
||||
Create `src/Engine.Graphics/` with:
|
||||
- `IRenderContext.cs` — interface: Window, CreateRenderer(), Resize(), Dispose
|
||||
- `IRenderer.cs` — interface: RenderWorld(World), RequestScreenshot, IsScreenshotRequested, ScreenshotProvider, Dispose
|
||||
- `RenderBackendFactory.cs` — static registry: Register(name, factory), Create(name, w, h, validation)
|
||||
- `IScreenshotProvider.cs` already in Engine.Core
|
||||
- `Loaders/ObjLoader.cs` — parse .obj files → Mesh component
|
||||
- `Loaders/GltfLoader.cs` — parse .gltf/.glb → Mesh component
|
||||
- `MeshMath.cs` — ComputeFaceNormal(a, b, c)
|
||||
- `ProceduralMesh.cs` — CreateSphere(), CreateGrid()
|
||||
- `SceneSerializer.cs` — save/load ECS world to JSON
|
||||
|
||||
csproj: references Engine.Core, Flecs.NET, SharpGLTF.Core
|
||||
|
||||
### Phase 2: Vulkan P/Invoke Layer
|
||||
|
||||
Create `src/Engine.Graphics.Vulkan/` with:
|
||||
- `VulkanNative.cs` — all P/Invoke declarations:
|
||||
- Library loading: `const string VulkanLib = OperatingSystem.IsWindows() ? "vulkan-1.dll" : "libvulkan.so.1"`
|
||||
- ~80 Vulkan functions (vkCreateInstance through vkQueuePresentKHR)
|
||||
- ~50 structs (InstanceCreateInfo, DeviceCreateInfo, SwapchainCreateInfoKHR, etc.)
|
||||
- ~20 enums (Result, Format, ImageLayout, PipelineStageFlags, etc.)
|
||||
- Extension function loading via vkGetInstanceProcAddr/vkGetDeviceProcAddr
|
||||
- SDL_Vulkan_CreateSurface via SDL3 (already in Sdl3Window)
|
||||
|
||||
- `VulkanContext.cs` — Instance + PhysicalDevice + Device + Queues + Surface:
|
||||
- CreateInstance with SDL3 extensions + validation layers
|
||||
- PickPhysicalDevice (prefer discrete GPU)
|
||||
- CreateLogicalDevice with VK_KHR_swapchain
|
||||
- CreateSurface via SDL_Vulkan_CreateSurface
|
||||
- Get graphics + present queues
|
||||
|
||||
- `VulkanSwapchain.cs` — Swapchain + image views + depth + render pass + framebuffers:
|
||||
- Query surface capabilities
|
||||
- Create swapchain (format, extent, present mode)
|
||||
- Create image views
|
||||
- Create depth image + view (D32_SFLOAT)
|
||||
- Create render pass (color + depth attachments)
|
||||
- Create framebuffers
|
||||
|
||||
- `VulkanPipeline.cs` — Graphics pipeline:
|
||||
- Load SPIR-V shader modules (vertex + fragment)
|
||||
- Vertex input description (Position vec3, Normal vec3, Color vec4)
|
||||
- Descriptor set layouts (frame UBO + texture sampler)
|
||||
- Pipeline layout + graphics pipeline
|
||||
- Push constants for MVP matrix + material params
|
||||
|
||||
- `VulkanBuffer.cs` — Buffer management:
|
||||
- CreateBuffer (vertex/index/uniform)
|
||||
- AllocateMemory + bind
|
||||
- Map/unmap for writing
|
||||
- FindMemoryType
|
||||
- Staging buffer for copy
|
||||
|
||||
- `VulkanRenderer.cs` — Render loop:
|
||||
- Command pool + command buffers (2 frames in flight)
|
||||
- Semaphores + fences for sync
|
||||
- Descriptor pools + sets
|
||||
- RenderWorld(World):
|
||||
- Get camera from ECS
|
||||
- Collect lights from ECS
|
||||
- Update frame UBO (camera pos, lights)
|
||||
- For each Mesh+Transform entity: upload/cache vertex+index buffers,
|
||||
set push constants (MVP + material), draw indexed
|
||||
- Screenshot capture (copy image to staging buffer → PNG)
|
||||
- Present
|
||||
|
||||
- `VulkanBackendRegistrar.cs` — Register("vulkan", factory)
|
||||
|
||||
csproj: references Engine.Core, Engine.Graphics, Flecs.NET
|
||||
NO external Vulkan packages — pure P/Invoke
|
||||
|
||||
### Phase 3: Shaders
|
||||
|
||||
GLSL → SPIR-V shaders (compiled with glslangValidator):
|
||||
- `Shaders/vertex.vert` — #version 450, position/normal/color inputs, MVP+model uniforms, outputs
|
||||
- `Shaders/fragment.frag` — #version 450, PBR lighting (Fresnel, ACES, gamma), directional + point lights
|
||||
- Compile: `glslangValidator -V vertex.vert -o vertex.spv && glslangValidator -V fragment.frag -o fragment.spv`
|
||||
- Embed .spv files as project resources or copy to output directory
|
||||
|
||||
### Phase 4: App Integration
|
||||
|
||||
- Fix `CortexEngine.App.csproj` — remove deleted project refs, add Engine.Graphics + Engine.Graphics.Vulkan
|
||||
- Fix `Program.cs`:
|
||||
- Remove all Raylib/OpenTK/old OpenGL imports
|
||||
- Remove ImGuiLayer, ObjectManipulator (Raylib-specific, will reimplement later)
|
||||
- Use `RenderBackendFactory.Create("vulkan", 1280, 720, enableValidation: true)`
|
||||
- Keep: physics, camera controllers, AI commands, tour mode, scene setup
|
||||
- Sdl3Window creates Vulkan surface automatically (vulkanSurface: true)
|
||||
|
||||
### Phase 5: Fix Tests
|
||||
|
||||
- Update `Engine.Tests.csproj` — reference restored Engine.Graphics
|
||||
- Tests that reference Engine.Graphics: ObjLoaderTests, RenderBackendFactoryTests,
|
||||
SceneSerializerTests, MeshMathAndProceduralTests
|
||||
- All 66 tests should pass after Engine.Graphics is restored
|
||||
|
||||
### Phase 6: ImGui (later)
|
||||
|
||||
- ImGui.NET NuGet + Vulkan ImGui backend
|
||||
- ImGui_ImplVulkan for rendering
|
||||
- Entity inspector, hierarchy, debug overlay
|
||||
|
||||
### Phase 7: Shadow Mapping (later)
|
||||
|
||||
- Depth-only render pass from light's POV
|
||||
- Shadow image (depth texture, 2048x2048)
|
||||
- Shadow matrix (lightViewProj) in push constants
|
||||
- PCF sampling in fragment shader
|
||||
|
||||
## Cross-Platform Notes
|
||||
|
||||
- Vulkan P/Invoke: only difference is library name (vulkan-1.dll vs libvulkan.so.1)
|
||||
- SDL3: already cross-platform (ppy.SDL3-CS)
|
||||
- SPIR-V: binary format, works everywhere
|
||||
- .NET 9: NativeLibrary.Load for dynamic resolution if needed
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── Engine.Core/ (exists, unchanged)
|
||||
├── Engine.Graphics/ (new — interfaces + loaders)
|
||||
│ ├── Engine.Graphics.csproj
|
||||
│ ├── IRenderContext.cs
|
||||
│ ├── IRenderer.cs
|
||||
│ ├── RenderBackendFactory.cs
|
||||
│ ├── MeshMath.cs
|
||||
│ ├── ProceduralMesh.cs
|
||||
│ ├── SceneSerializer.cs
|
||||
│ └── Loaders/
|
||||
│ ├── ObjLoader.cs
|
||||
│ └── GltfLoader.cs
|
||||
├── Engine.Graphics.Vulkan/ (new — pure Vulkan P/Invoke)
|
||||
│ ├── Engine.Graphics.Vulkan.csproj
|
||||
│ ├── VulkanNative.cs (~800 lines)
|
||||
│ ├── VulkanContext.cs (~300 lines)
|
||||
│ ├── VulkanSwapchain.cs (~250 lines)
|
||||
│ ├── VulkanPipeline.cs (~200 lines)
|
||||
│ ├── VulkanBuffer.cs (~150 lines)
|
||||
│ ├── VulkanRenderer.cs (~400 lines)
|
||||
│ ├── VulkanBackendRegistrar.cs
|
||||
│ └── Shaders/
|
||||
│ ├── vertex.vert
|
||||
│ ├── fragment.frag
|
||||
│ ├── vertex.spv
|
||||
│ └── fragment.spv
|
||||
├── Engine.Physics/ (exists, unchanged)
|
||||
├── Engine.AI/ (exists, unchanged)
|
||||
└── CortexEngine.App/ (fix references)
|
||||
```
|
||||
|
||||
## Solution Update
|
||||
|
||||
Remove from solution:
|
||||
- Engine.Graphics.Raylib (deleted)
|
||||
- Engine.Graphics.OpenTK (deleted)
|
||||
- Engine.Graphics.Vulkan (old Silk.NET, deleted)
|
||||
|
||||
Add to solution:
|
||||
- Engine.Graphics (new)
|
||||
- Engine.Graphics.Vulkan (new, pure P/Invoke)
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
### Matrix Layout
|
||||
- System.Numerics.Matrix4x4 is row-major
|
||||
- Vulkan expects column-major in shaders (layout(row_major) or transpose)
|
||||
- Solution: use `layout(row_major) uniform mat4` in GLSL → no transpose needed
|
||||
- OR transpose in C# before writing to uniform buffer
|
||||
|
||||
### Vertex Layout
|
||||
```
|
||||
Vertex struct (9 floats, 36 bytes):
|
||||
Position: vec3 (offset 0)
|
||||
Color: vec3 (offset 12)
|
||||
Normal: vec3 (offset 24)
|
||||
```
|
||||
|
||||
### Push Constants (96 bytes max)
|
||||
```
|
||||
offset 0: mat4 MVP (64 bytes)
|
||||
offset 64: vec3 materialAlbedo + float roughness (16 bytes)
|
||||
offset 80: float metallic + uint useTexture + uint pad + uint pad (16 bytes)
|
||||
```
|
||||
|
||||
### Frame UBO (224 bytes)
|
||||
```
|
||||
offset 0: vec3 cameraPosition + uint lightCount (16 bytes)
|
||||
offset 16: vec3 ambientColor + float pad (16 bytes)
|
||||
offset 32: Light[4] — each 48 bytes (vec3 direction + float intensity + vec3 color + float pad)
|
||||
```
|
||||
|
||||
### Light Struct (48 bytes)
|
||||
```
|
||||
vec3 direction (12 bytes)
|
||||
float intensity (4 bytes)
|
||||
vec3 color (12 bytes)
|
||||
float padding (4 bytes)
|
||||
```
|
||||
|
||||
### Validation Layers
|
||||
```csharp
|
||||
string[] layers = enableValidation
|
||||
? new[] { "VK_LAYER_KHRONOS_validation" }
|
||||
: Array.Empty<string>();
|
||||
```
|
||||
Validation errors print to stderr — use for debugging.
|
||||
|
||||
### Memory Allocation
|
||||
Simple approach (no VMA):
|
||||
1. vkGetPhysicalDeviceMemoryProperties
|
||||
2. Find memory type with VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | HOST_COHERENT_BIT
|
||||
3. vkAllocateMemory + vkBindBufferMemory
|
||||
4. vkMapMemory for writing, vkUnmapMemory
|
||||
|
||||
For GPU-only buffers (vertex/index):
|
||||
1. Find memory type with DEVICE_LOCAL_BIT
|
||||
2. Use staging buffer (host visible) + vkCmdCopyBuffer
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, ©Region);
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user