feat: modular HAL, Raylib backend, PBR shading, textures, 60 unit tests

- Replace hardcoded SDL3 windowing with IWindow/IInputState/Key abstractions
- Each render backend owns its window (Raylib GLFW, SDL3 for Vulkan)
- Raylib backend: DrawModelEx, custom GLSL shader with Fresnel, ACES
  tonemapping, gamma correction, hemisphere ambient
- Fix backface culling, mesh memory (NativeMemory.Alloc), texture loading
- Camera controllers use backend-agnostic Key enum (inverted yaw/strafe)
- Demo scene: 8 cubes, 7 spheres, torus knot OBJ with checker texture
- Extract ProceduralMesh + MeshMath from Program.cs to Engine.Graphics
- Vulkan backend deferred (compiles, untested, IWindow-compatible)
- 60 unit tests: ObjLoader, camera controllers, AiCommandProcessor,
  RenderBackendFactory, Timing, ProceduralMesh, MeshMath, Transform
- AGENTS.md for opencode integration
This commit is contained in:
emil28092005
2026-06-17 13:49:12 +03:00
parent 61f8c7065e
commit fb6e26a268
62 changed files with 22259 additions and 395 deletions
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>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'" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Engine.Graphics\Engine.Graphics.csproj" />
<ProjectReference Include="..\Engine.Core\Engine.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
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() { }
}
@@ -0,0 +1,156 @@
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,
};
}
@@ -0,0 +1,28 @@
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();
}
@@ -0,0 +1,503 @@
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 readonly Shader _shader;
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 float[] _lightDirs = new float[12]; // 4 lights * 3 floats
private readonly float[] _lightIntensities = new float[4];
private readonly float[] _lightColors = new float[12]; // 4 lights * 3 floats
private ScreenshotRequest? _pendingScreenshot;
private int _frameCount;
private bool _disposed;
public RaylibRenderer()
{
_shader = LoadShader();
_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");
}
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);
Raylib.BeginDrawing();
Raylib.ClearBackground(new Color(25, 30, 40, 255));
Raylib.BeginMode3D(ToRaylib(camera));
Rlgl.DisableBackfaceCulling();
// Frame-level uniforms: SetShaderValue calls glUseProgram internally,
// so these don't need BeginShaderMode. DrawModelEx rebinds the same shader
// (set on the model's material), so the values persist for the draw call.
CollectLights(world);
SetFrameLights();
Raylib.SetShaderValue(_shader, _viewPosLoc, new float[] { camera.Position.X, camera.Position.Y, camera.Position.Z }, ShaderUniformDataType.Vec3);
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 = Vector3.UnitY;
var angle = 0.0f;
var q = new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W);
if (MathF.Abs(q.W) < 0.9999999f)
{
angle = 2.0f * MathF.Acos(Math.Clamp(q.W, -1.0f, 1.0f));
var s = MathF.Sqrt(1.0f - q.W * q.W);
if (s > 0.0001f)
axis = new Vector3(q.X / s, q.Y / s, q.Z / s);
else
axis = new Vector3(q.X, q.Y, q.Z);
}
// Set per-entity uniforms right before the draw.
// DrawModelEx binds the model's material shader (= _shader) and
// immediately issues the draw, so these values are live during rendering.
SetMaterialUniforms(material, model);
Raylib.DrawModelEx(model, position, axis, angle * 180.0f / MathF.PI, scale, Color.White);
}
});
Rlgl.EnableBackfaceCulling();
Raylib.DrawGrid(20, 1.0f);
Raylib.EndMode3D();
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 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;
_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;
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;
count = 1;
}
for (var i = count; i < 4; i++)
{
_lightDirs[i * 3 + 0] = 0;
_lightDirs[i * 3 + 1] = 0;
_lightDirs[i * 3 + 2] = 0;
_lightIntensities[i] = 0.0f;
_lightColors[i * 3 + 0] = 0;
_lightColors[i * 3 + 1] = 0;
_lightColors[i * 3 + 2] = 0;
}
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);
}
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 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 float lightIntensities[4];
uniform vec3 lightColors[4];
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);
}
void main()
{
vec3 normal = normalize(vNormal);
vec3 albedo = vColor.rgb * materialColor.rgb;
if (useTexture != 0)
{
vec2 uv = vTexCoord * 4.0;
albedo *= texture(texture0, uv).rgb;
}
vec3 viewDir = normalize(viewPos - vWorldPos);
float rough = clamp(roughness, 0.05, 1.0);
float metal = clamp(metallic, 0.0, 1.0);
// Hemisphere ambient: low ambient for visible shading contrast
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;
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 = normalize(-lightDirs[i]);
vec3 H = normalize(L + viewDir);
float NdotL = max(dot(normal, L), 0.0);
float NdotH = max(dot(normal, H), 0.0);
float NdotV = max(dot(normal, viewDir), 0.0);
float HdotV = max(dot(H, viewDir), 0.0);
float diff = NdotL;
float spec = pow(NdotH, shininess);
// Schlick Fresnel
float fresnel = F0.x + (1.0 - F0.x) * pow(1.0 - HdotV, 5.0);
vec3 specularColor = mix(vec3(fresnel), albedo * fresnel, metal);
vec3 diffuse = albedo * lightColors[i] * diff * lightIntensities[i] * 1.5;
vec3 specular = specularColor * spec * lightIntensities[i];
// Energy conservation
diffuse *= (1.0 - fresnel * (1.0 - metal * 0.5));
result += diffuse + specular;
}
// ACES tonemapping + gamma correction
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(_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);
}
}
@@ -0,0 +1,54 @@
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();
}
}