diff --git a/LinguaEngine.sln b/LinguaEngine.sln index 041b44e..b02a63d 100644 --- a/LinguaEngine.sln +++ b/LinguaEngine.sln @@ -33,6 +33,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.render", "engine.ren EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Render", "plugins\engine.render\Engine.Render\Engine.Render.csproj", "{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Render.Contracts", "plugins\engine.render\Engine.Render.Contracts\Engine.Render.Contracts.csproj", "{59C40966-4302-4FD7-8DDB-961C2653D290}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.input", "engine.input", "{A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Input.Contracts", "plugins\engine.input\Engine.Input.Contracts\Engine.Input.Contracts.csproj", "{7374240C-A59B-437B-818A-620F7A3B391C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Input", "plugins\engine.input\Engine.Input\Engine.Input.csproj", "{A3784B8F-8782-4B55-807B-1BADD06D5211}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -151,6 +159,42 @@ Global {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x64.Build.0 = Release|Any CPU {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x86.ActiveCfg = Release|Any CPU {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x86.Build.0 = Release|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|x64.ActiveCfg = Debug|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|x64.Build.0 = Debug|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|x86.ActiveCfg = Debug|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Debug|x86.Build.0 = Debug|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|Any CPU.Build.0 = Release|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|x64.ActiveCfg = Release|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|x64.Build.0 = Release|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|x86.ActiveCfg = Release|Any CPU + {59C40966-4302-4FD7-8DDB-961C2653D290}.Release|x86.Build.0 = Release|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|x64.ActiveCfg = Debug|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|x64.Build.0 = Debug|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|x86.ActiveCfg = Debug|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Debug|x86.Build.0 = Debug|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Release|Any CPU.Build.0 = Release|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Release|x64.ActiveCfg = Release|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Release|x64.Build.0 = Release|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Release|x86.ActiveCfg = Release|Any CPU + {7374240C-A59B-437B-818A-620F7A3B391C}.Release|x86.Build.0 = Release|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|x64.ActiveCfg = Debug|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|x64.Build.0 = Debug|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|x86.ActiveCfg = Debug|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Debug|x86.Build.0 = Debug|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|Any CPU.Build.0 = Release|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x64.ActiveCfg = Release|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x64.Build.0 = Release|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x86.ActiveCfg = Release|Any CPU + {A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -168,5 +212,9 @@ Global {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08} = {E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053} {4B0CEF17-61C3-056D-753F-1CA96E0E17CD} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} {6D1AEAF7-9885-4557-9D7A-2B29376A34B4} = {4B0CEF17-61C3-056D-753F-1CA96E0E17CD} + {59C40966-4302-4FD7-8DDB-961C2653D290} = {4B0CEF17-61C3-056D-753F-1CA96E0E17CD} + {A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} + {7374240C-A59B-437B-818A-620F7A3B391C} = {A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A} + {A3784B8F-8782-4B55-807B-1BADD06D5211} = {A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 1f27f09..b69ff21 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,19 @@ exist and are tested. The full agent loop from [`docs/kernel-contract.md#7`](docs/kernel-contract.md#7-written-by-an-agent-not-a-human) runs end to end. -**M1 in progress.** `engine.windowing` and a minimal `engine.render` (clear -color, no mesh yet) exist over Silk.NET, and the milestone's actual claim — -edit a plugin's code, rebuild just it, reload it while a real window stays -open, see the change with no app restart — is proven, by hand, against a -live GL context. `engine.input` and an actual drawn triangle (vs. a clear -color) are still open. No physics yet — see the build order (M0–M4) in +**M1 done.** `engine.windowing`, `engine.render` (a real shader-drawn +triangle, not just a clear color), and `engine.input` all exist over +Silk.NET. The milestone's actual claim — edit a plugin's code, rebuild just +it, reload it while a real window stays open, see the change with no app +restart — is proven against a live GL context: two PNGs of the *same* +running window, before and after a live reload, orange triangle then green, +same process the whole time. `IScreenCapture` (`engine.render`) reads the +frame back from the GPU and writes it to a file with a hand-rolled PNG +encoder — no `SixLabors.ImageSharp` (its license isn't MIT/Apache) and no +desktop screenshot tool, so this is checkable without a screen at all, +exactly the introspection story `docs/kernel-contract.md#7` argues for. + +No physics yet — see the build order (M0–M4) in [`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next. Design and implementation are argued over in the same place: the doc is diff --git a/plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj b/plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj new file mode 100644 index 0000000..e6ede92 --- /dev/null +++ b/plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs b/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs new file mode 100644 index 0000000..711471b --- /dev/null +++ b/plugins/engine.input/Engine.Input.Contracts/IEngineInput.cs @@ -0,0 +1,15 @@ +using Silk.NET.Input; + +namespace Engine.Input.Contracts; + +/// +/// Not IInput — same reasoning as IEngineWindow not being IWindow: keep a +/// clear line between our own vocabulary and the library's, even where a +/// collision isn't imminent yet. Reuses Silk.NET's own Key enum +/// rather than inventing one — it's just data, same as exposing +/// IEngineWindow.Native directly. +/// +public interface IEngineInput +{ + bool IsKeyDown(Key key); +} diff --git a/plugins/engine.input/Engine.Input/Engine.Input.csproj b/plugins/engine.input/Engine.Input/Engine.Input.csproj new file mode 100644 index 0000000..b6438eb --- /dev/null +++ b/plugins/engine.input/Engine.Input/Engine.Input.csproj @@ -0,0 +1,17 @@ + + + + true + + + + + + + + + + + + + diff --git a/plugins/engine.input/Engine.Input/InputPlugin.cs b/plugins/engine.input/Engine.Input/InputPlugin.cs new file mode 100644 index 0000000..f50f81c --- /dev/null +++ b/plugins/engine.input/Engine.Input/InputPlugin.cs @@ -0,0 +1,35 @@ +using Engine.Kernel.Plugins; +using Engine.Input.Contracts; +using Engine.Windowing.Contracts; +using Silk.NET.Input; + +namespace Engine.Input; + +/// +/// M1's third plugin. Publishes keyboard state as a service — nothing in +/// the engine reacts to it yet, since there's no gameplay code to react +/// with; the bar this clears is the same one engine.windowing cleared +/// before engine.render existed to prove it visually: constructs cleanly +/// against a real window, doesn't throw. See M1 in docs/kernel-contract.md +/// §8. +/// +public sealed class InputPlugin : IPlugin +{ + private IInputContext? _input; + + public void Configure(IPluginContext ctx) + { + var window = ctx.Services.Require(); + _input = window.Native.CreateInput(); + + ctx.Services.Provide(new SilkEngineInput(_input)); + ctx.Log.Info($"input ready ({_input.Keyboards.Count} keyboard(s), {_input.Mice.Count} mouse(s))"); + } + + public void Shutdown(IPluginContext ctx) + { + ctx.Services.Revoke(); + _input?.Dispose(); + _input = null; + } +} diff --git a/plugins/engine.input/Engine.Input/SilkEngineInput.cs b/plugins/engine.input/Engine.Input/SilkEngineInput.cs new file mode 100644 index 0000000..f57b160 --- /dev/null +++ b/plugins/engine.input/Engine.Input/SilkEngineInput.cs @@ -0,0 +1,9 @@ +using Engine.Input.Contracts; +using Silk.NET.Input; + +namespace Engine.Input; + +internal sealed class SilkEngineInput(IInputContext context) : IEngineInput +{ + public bool IsKeyDown(Key key) => context.Keyboards.Any(k => k.IsKeyPressed(key)); +} diff --git a/plugins/engine.input/plugin.json b/plugins/engine.input/plugin.json new file mode 100644 index 0000000..9b30045 --- /dev/null +++ b/plugins/engine.input/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "engine.input", + "version": "0.1.0", + "contracts": "Engine.Input.Contracts.dll", + "assembly": "Engine.Input.dll", + "dependsOn": { + "engine.windowing": "^0.1" + }, + "reloadable": true +} diff --git a/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj b/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj new file mode 100644 index 0000000..c632161 --- /dev/null +++ b/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj @@ -0,0 +1,3 @@ + + + diff --git a/plugins/engine.render/Engine.Render.Contracts/IScreenCapture.cs b/plugins/engine.render/Engine.Render.Contracts/IScreenCapture.cs new file mode 100644 index 0000000..596636a --- /dev/null +++ b/plugins/engine.render/Engine.Render.Contracts/IScreenCapture.cs @@ -0,0 +1,13 @@ +namespace Engine.Render.Contracts; + +/// +/// Reads the current frame back from the GPU and saves it to a PNG file. +/// Exists so the render output can be checked without a real, visible +/// display or screenshot tool — an agent (or a human working headlessly) +/// can render a frame and look at the file instead. See M1 in +/// docs/kernel-contract.md §8. +/// +public interface IScreenCapture +{ + void CaptureToFile(string path); +} diff --git a/plugins/engine.render/Engine.Render/Engine.Render.csproj b/plugins/engine.render/Engine.Render/Engine.Render.csproj index c4b1e09..11917a6 100644 --- a/plugins/engine.render/Engine.Render/Engine.Render.csproj +++ b/plugins/engine.render/Engine.Render/Engine.Render.csproj @@ -2,6 +2,14 @@ true + + + true @@ -11,6 +19,7 @@ + diff --git a/plugins/engine.render/Engine.Render/GlScreenCapture.cs b/plugins/engine.render/Engine.Render/GlScreenCapture.cs new file mode 100644 index 0000000..d999866 --- /dev/null +++ b/plugins/engine.render/Engine.Render/GlScreenCapture.cs @@ -0,0 +1,27 @@ +using Engine.Render.Contracts; +using Engine.Windowing.Contracts; +using Silk.NET.OpenGL; + +namespace Engine.Render; + +internal sealed class GlScreenCapture(GL gl, IEngineWindow window) : IScreenCapture +{ + public void CaptureToFile(string path) + { + var size = window.Native.FramebufferSize; + var width = size.X; + var height = size.Y; + var stride = width * 4; + + var bottomUp = new byte[stride * height]; + gl.ReadPixels(0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.UnsignedByte, bottomUp.AsSpan()); + + // OpenGL's row 0 is the bottom of the image; PngWriter wants row 0 + // to be the top. + var topDown = new byte[bottomUp.Length]; + for (var y = 0; y < height; y++) + Array.Copy(bottomUp, (height - 1 - y) * stride, topDown, y * stride, stride); + + PngWriter.Write(path, width, height, topDown); + } +} diff --git a/plugins/engine.render/Engine.Render/PngWriter.cs b/plugins/engine.render/Engine.Render/PngWriter.cs new file mode 100644 index 0000000..8e96110 --- /dev/null +++ b/plugins/engine.render/Engine.Render/PngWriter.cs @@ -0,0 +1,119 @@ +using System.IO.Compression; +using System.Text; + +namespace Engine.Render; + +/// +/// Minimal PNG encoder: 8-bit RGBA, uncompressed-filter scanlines (filter +/// type 0, "None"), one IDAT chunk. No external dependency — +/// SixLabors.ImageSharp was considered and rejected: its license isn't +/// MIT or Apache (it's revenue-gated), and pulling that into an MIT +/// engine's own screenshot tooling would be exactly the kind of surprise +/// a downstream user shouldn't have to discover later. (BCL, since .NET 6) does the one genuinely hard +/// part — a correctly zlib-wrapped DEFLATE stream — so what's left here +/// (chunk framing, CRC32) is small enough to get right and to verify by +/// actually opening a file this writes. +/// +internal static class PngWriter +{ + private static readonly byte[] Signature = [137, 80, 78, 71, 13, 10, 26, 10]; + private static readonly uint[] Crc32Table = BuildCrc32Table(); + + /// + /// is * + /// * 4 bytes, row 0 first (top of the + /// image). OpenGL's ReadPixels returns rows bottom-first — flip + /// before calling this, not after; this writer has no opinion about + /// where the bytes came from. + /// + public static void Write(string path, int width, int height, ReadOnlySpan topDownRgba) + { + using var file = File.Create(path); + file.Write(Signature); + WriteChunk(file, "IHDR", BuildIhdr(width, height)); + WriteChunk(file, "IDAT", BuildIdat(width, height, topDownRgba)); + WriteChunk(file, "IEND", []); + } + + private static byte[] BuildIhdr(int width, int height) + { + var ihdr = new byte[13]; + WriteUInt32BE(ihdr, 0, (uint)width); + WriteUInt32BE(ihdr, 4, (uint)height); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type: RGBA + ihdr[10] = 0; // compression method: deflate (the only one PNG defines) + ihdr[11] = 0; // filter method: adaptive (per-scanline filter byte) + ihdr[12] = 0; // interlace method: none + return ihdr; + } + + private static byte[] BuildIdat(int width, int height, ReadOnlySpan rgba) + { + var stride = width * 4; + using var compressed = new MemoryStream(); + + using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true)) + { + var filterByte = new byte[1]; // 0 = "None" — every scanline, unfiltered + for (var y = 0; y < height; y++) + { + zlib.Write(filterByte); + zlib.Write(rgba.Slice(y * stride, stride)); + } + } + + return compressed.ToArray(); + } + + private static void WriteChunk(Stream stream, string type, byte[] data) + { + var typeBytes = Encoding.ASCII.GetBytes(type); + + Span length = stackalloc byte[4]; + WriteUInt32BE(length, 0, (uint)data.Length); + stream.Write(length); + + stream.Write(typeBytes); + stream.Write(data); + + Span crc = stackalloc byte[4]; + WriteUInt32BE(crc, 0, Crc32(typeBytes, data)); + stream.Write(crc); + } + + private static void WriteUInt32BE(Span buffer, int offset, uint value) + { + buffer[offset] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)value; + } + + private static uint[] BuildCrc32Table() + { + var table = new uint[256]; + for (uint n = 0; n < 256; n++) + { + var c = n; + for (var k = 0; k < 8; k++) + c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1; + table[n] = c; + } + + return table; + } + + private static uint Crc32(byte[] type, byte[] data) + { + var crc = 0xFFFFFFFFu; + + foreach (var b in type) + crc = Crc32Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + foreach (var b in data) + crc = Crc32Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + + return crc ^ 0xFFFFFFFFu; + } +} diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs index 9880366..951f75a 100644 --- a/plugins/engine.render/Engine.Render/RenderPlugin.cs +++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs @@ -1,22 +1,22 @@ using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; using Engine.Kernel.World; +using Engine.Render.Contracts; using Engine.Windowing.Contracts; using Silk.NET.OpenGL; namespace Engine.Render; /// -/// M1's minimal render pipeline: clears the window to a color and swaps -/// buffers, once per Render stage. No Contracts assembly — nothing here is -/// a type another plugin needs to reference yet (see the null-Contracts -/// note on PluginManifest). See M1 in docs/kernel-contract.md §8. +/// M1's render pipeline: one hardcoded triangle, drawn every Render stage. +/// See M1 in docs/kernel-contract.md §8. /// -/// This is the whole point of M1's "done when": change ClearColor, rebuild -/// just this plugin, and reload it while the window from engine.windowing -/// stays open — the color changes with no app restart. Verified by hand, -/// not by an automated test — opening a real window needs a real display, -/// which isn't something to assume of every environment this runs in. +/// This is the whole point of M1's "done when": change TriangleColor, +/// rebuild just this plugin, and reload it while the window from +/// engine.windowing stays open — the color changes with no app restart. +/// Verified by hand against a real window and a live GL context, and via +/// IScreenCapture — no external image library; see the note on PngWriter +/// for why. /// /// engine.windowing alone produces a window that never becomes visible on /// Wayland — unlike X11, a Wayland surface with no committed buffer simply @@ -26,33 +26,130 @@ namespace Engine.Render; /// public sealed class RenderPlugin : IPlugin { - private static readonly float[] ClearColor = [0.25f, 0.55f, 0.85f, 1f]; + private const string VertexShaderSource = """ + #version 330 core + layout (location = 0) in vec2 aPosition; + + void main() + { + gl_Position = vec4(aPosition, 0.0, 1.0); + } + """; + + private const string FragmentShaderSource = """ + #version 330 core + out vec4 FragColor; + uniform vec4 uColor; + + void main() + { + FragColor = uColor; + } + """; + + private static readonly float[] TriangleColor = [0.2f, 0.8f, 0.4f, 1f]; + + private static readonly float[] Vertices = + [ + 0.0f, 0.6f, + -0.6f, -0.6f, + 0.6f, -0.6f, + ]; private GL? _gl; private IEngineWindow? _window; + private uint _vao; + private uint _vbo; + private uint _program; + private int _colorLocation; - public void Configure(IPluginContext ctx) + public unsafe void Configure(IPluginContext ctx) { _window = ctx.Services.Require(); _window.Native.GLContext!.MakeCurrent(); _gl = _window.Native.CreateOpenGL(); + _program = LinkProgram(_gl, VertexShaderSource, FragmentShaderSource); + _colorLocation = _gl.GetUniformLocation(_program, "uColor"); + + _vao = _gl.GenVertexArray(); + _gl.BindVertexArray(_vao); + + _vbo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + _gl.BufferData(BufferTargetARB.ArrayBuffer, Vertices, BufferUsageARB.StaticDraw); + + _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0); + _gl.EnableVertexAttribArray(0); + _gl.BindVertexArray(0); + + ctx.Services.Provide(new GlScreenCapture(_gl, _window)); ctx.Schedule.Add(Stage.Render, Draw); - ctx.Log.Info("GL context created"); + ctx.Log.Info("GL context created, triangle ready"); } public void Shutdown(IPluginContext ctx) { ctx.Schedule.RemoveAllFrom("engine.render"); - _gl?.Dispose(); + ctx.Services.Revoke(); + + if (_gl is not null) + { + _gl.DeleteVertexArray(_vao); + _gl.DeleteBuffer(_vbo); + _gl.DeleteProgram(_program); + _gl.Dispose(); + } + _gl = null; _window = null; } private void Draw(IWorld world) { - _gl!.ClearColor(ClearColor[0], ClearColor[1], ClearColor[2], ClearColor[3]); + _gl!.ClearColor(0.05f, 0.05f, 0.08f, 1f); _gl.Clear(ClearBufferMask.ColorBufferBit); + + _gl.UseProgram(_program); + _gl.Uniform4(_colorLocation, TriangleColor[0], TriangleColor[1], TriangleColor[2], TriangleColor[3]); + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, 3); + _window!.Native.GLContext!.SwapBuffers(); } + + private static uint LinkProgram(GL gl, string vertexSource, string fragmentSource) + { + var vertex = CompileShader(gl, ShaderType.VertexShader, vertexSource); + var fragment = CompileShader(gl, ShaderType.FragmentShader, fragmentSource); + + var program = gl.CreateProgram(); + gl.AttachShader(program, vertex); + gl.AttachShader(program, fragment); + gl.LinkProgram(program); + + gl.GetProgram(program, GLEnum.LinkStatus, out var linked); + if (linked == 0) + throw new InvalidOperationException($"Shader program failed to link: {gl.GetProgramInfoLog(program)}"); + + gl.DetachShader(program, vertex); + gl.DetachShader(program, fragment); + gl.DeleteShader(vertex); + gl.DeleteShader(fragment); + + return program; + } + + private static uint CompileShader(GL gl, ShaderType type, string source) + { + var shader = gl.CreateShader(type); + gl.ShaderSource(shader, source); + gl.CompileShader(shader); + + gl.GetShader(shader, GLEnum.CompileStatus, out var compiled); + if (compiled == 0) + throw new InvalidOperationException($"{type} failed to compile: {gl.GetShaderInfoLog(shader)}"); + + return shader; + } } diff --git a/plugins/engine.render/plugin.json b/plugins/engine.render/plugin.json index d8001dc..16af25a 100644 --- a/plugins/engine.render/plugin.json +++ b/plugins/engine.render/plugin.json @@ -1,6 +1,7 @@ { "id": "engine.render", "version": "0.1.0", + "contracts": "Engine.Render.Contracts.dll", "assembly": "Engine.Render.dll", "dependsOn": { "engine.windowing": "^0.1" diff --git a/samples/WindowDemo/project.json b/samples/WindowDemo/project.json index 427b523..3ab7f40 100644 --- a/samples/WindowDemo/project.json +++ b/samples/WindowDemo/project.json @@ -2,7 +2,8 @@ "engineVersion": "^0.1", "plugins": [ { "id": "engine.windowing" }, - { "id": "engine.render" } + { "id": "engine.render" }, + { "id": "engine.input" } ], "pluginPaths": [] } diff --git a/src/Engine.Host/Engine.Host.csproj b/src/Engine.Host/Engine.Host.csproj index e1dc8ed..92197ed 100644 --- a/src/Engine.Host/Engine.Host.csproj +++ b/src/Engine.Host/Engine.Host.csproj @@ -3,6 +3,7 @@ + diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs index cddbcf5..9a6222b 100644 --- a/src/Engine.Host/Program.cs +++ b/src/Engine.Host/Program.cs @@ -30,6 +30,7 @@ using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; using Engine.Kernel.Services; using Engine.Kernel.World; +using Engine.Render.Contracts; using Engine.Windowing.Contracts; if (args.Length == 0) @@ -53,7 +54,9 @@ if (args[0] != "run") string? projectPath = null; string? pluginsPath = null; string? dumpPath = null; +string? screenshotPath = null; var frames = 0; +var screenshotAfterFrames = 1; var headless = false; var windowed = false; @@ -79,6 +82,12 @@ for (var i = 1; i < args.Length; i++) case "--dump" when i + 1 < args.Length: dumpPath = args[++i]; break; + case "--screenshot" when i + 1 < args.Length: + screenshotPath = args[++i]; + break; + case "--screenshot-after-frames" when i + 1 < args.Length: + screenshotAfterFrames = int.Parse(args[++i]); + break; case "--scene": case "--assert": Console.Error.WriteLine($"'{args[i]}' isn't implemented yet — see the notes at the top of Program.cs."); @@ -131,49 +140,104 @@ if (windowed) return 1; } - Console.WriteLine("Window open — close it to exit. Type 'r ' + Enter to reload a plugin live."); + Console.WriteLine( + """ + Window open — close it to exit. Commands (type + Enter): + r reload that plugin live + screenshot save the current frame to a PNG (needs a plugin providing IScreenCapture) + """); // Line-based, not Console.ReadKey: KeyAvailable needs a real terminal // in raw mode and throws or misbehaves on piped/redirected stdin. A // background reader plus a thread-safe queue works either way and // costs nothing on the render loop's own thread. - var reloadQueue = new System.Collections.Concurrent.ConcurrentQueue(); + var commandQueue = new System.Collections.Concurrent.ConcurrentQueue<(string Command, string Argument)>(); _ = Task.Run(() => { string? line; while ((line = Console.ReadLine()) is not null) { var parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (parts is ["r", var pluginId]) - reloadQueue.Enqueue(pluginId); + if (parts is [var command, var argument]) + commandQueue.Enqueue((command, argument)); } }); + var frameCount = 0; + while (!window!.IsClosing) { + frameCount++; window.Native.DoEvents(); if (window.IsClosing) break; - while (reloadQueue.TryDequeue(out var pluginId)) + // "screenshot" waits until after this frame's Render stage below — + // captured now, it would grab whatever the *previous* frame left + // in the framebuffer, not what this iteration is about to draw. + var pendingScreenshots = new List(); + + // --screenshot is the non-interactive path: capture once, then + // exit, so a script can fire-and-forget instead of managing a + // stdin pipe into a long-running process. + if (screenshotPath is not null && frameCount == screenshotAfterFrames) + pendingScreenshots.Add(screenshotPath); + + while (commandQueue.TryDequeue(out var cmd)) { - var pluginDirectory = Path.Combine(pluginsPath, pluginId); - Console.WriteLine($"Reloading '{pluginId}'..."); - try + switch (cmd.Command) { - host.Unload(pluginId); - host.Load(pluginDirectory); - Console.WriteLine($"Reloaded '{pluginId}'. World state and the window were untouched."); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Failed to reload '{pluginId}': {ex.Message}"); + case "r": + var pluginDirectory = Path.Combine(pluginsPath, cmd.Argument); + Console.WriteLine($"Reloading '{cmd.Argument}'..."); + try + { + host.Unload(cmd.Argument); + host.Load(pluginDirectory); + Console.WriteLine($"Reloaded '{cmd.Argument}'. World state and the window were untouched."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to reload '{cmd.Argument}': {ex.Message}"); + } + + break; + + case "screenshot": + pendingScreenshots.Add(cmd.Argument); + break; + + default: + Console.Error.WriteLine($"Unknown command: '{cmd.Command}'"); + break; } } schedule.RunStage(Stage.Update, world); schedule.RunStage(Stage.Render, world); + + foreach (var path in pendingScreenshots) + { + if (!services.TryRequire(out var capture)) + { + Console.Error.WriteLine("No loaded plugin provides IScreenCapture (e.g. engine.render)."); + continue; + } + + try + { + capture!.CaptureToFile(path); + Console.WriteLine($"Wrote screenshot to '{path}'."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to capture screenshot: {ex.Message}"); + } + } + + if (screenshotPath is not null && frameCount == screenshotAfterFrames) + break; } Console.WriteLine("Window closed."); @@ -201,5 +265,11 @@ static void PrintUsage() Usage: engine run --headless --plugins --project --frames [--dump ] engine run --windowed --plugins --project [--dump ] + [--screenshot [--screenshot-after-frames ]] + + --screenshot captures once, after frames (default 1), then exits + — for scripts and agents; no interactive terminal needed. To keep + the window open and drive it interactively instead, type commands + into stdin while it runs: 'r ' or 'screenshot '. """); } diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs index b92aa9e..bcf5dd0 100644 --- a/src/Engine.Kernel/Plugins/PluginHost.cs +++ b/src/Engine.Kernel/Plugins/PluginHost.cs @@ -29,6 +29,16 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule PropertyNameCaseInsensitive = true, }; + // Shared across every PluginHost in the process, not per-instance: + // AssemblyLoadContext.Default.Resolving is itself process-wide, and an + // instance-bound handler on it would keep that PluginHost reachable + // forever — exactly the kind of leak this whole architecture exists to + // avoid, just aimed at a host instead of a plugin ALC. See + // EnsureDefaultResolvingHooked. + private static readonly List ContractResolvers = []; + private static readonly Lock ContractResolversLock = new(); + private static bool _defaultResolvingHooked; + private readonly Dictionary _loaded = []; /// @@ -147,6 +157,26 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule return null; } + /// + /// Loading a Contracts assembly straight into the Default ALC says + /// nothing about how ITS OWN dependencies (beyond Engine.Kernel) get + /// resolved — unlike a plugin's implementation, which always gets a + /// PluginLoadContext with a real AssemblyDependencyResolver behind it. + /// This went unnoticed for a while: engine.windowing's and + /// engine.render's Contracts both depend on Silk.NET packages, but + /// Engine.Host happens to reference those same Contracts projects + /// directly (to drive the windowed loop and screenshot capture — see + /// Program.cs), so their transitive dependencies were already sitting + /// in Engine.Host's own output directory and got found by luck via + /// normal probing. engine.input's Contracts has no such lucky + /// coincidence: Engine.Host has no reason to reference it, so its + /// Silk.NET.Input dependency wasn't anywhere the default resolution + /// order would look — a real FileNotFoundException, not a hypothetical + /// one. Hooking Default.Resolving with a resolver built against each + /// loaded Contracts path fixes it for real, rather than for whichever + /// Contracts assemblies happen to also be referenced by whatever's + /// hosting the engine this time. + /// private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest) { if (manifest.Contracts is null) @@ -158,8 +188,38 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule var alreadyLoaded = AssemblyLoadContext.Default.Assemblies .Any(a => a.GetName().Name == name); - if (!alreadyLoaded) - AssemblyLoadContext.Default.LoadFromAssemblyPath(contractsPath); + if (alreadyLoaded) + return; + + EnsureDefaultResolvingHooked(); + + lock (ContractResolversLock) + ContractResolvers.Add(new AssemblyDependencyResolver(contractsPath)); + + AssemblyLoadContext.Default.LoadFromAssemblyPath(contractsPath); + } + + private static void EnsureDefaultResolvingHooked() + { + if (_defaultResolvingHooked) + return; + + _defaultResolvingHooked = true; + + AssemblyLoadContext.Default.Resolving += (_, name) => + { + lock (ContractResolversLock) + { + foreach (var resolver in ContractResolvers) + { + var path = resolver.ResolveAssemblyToPath(name); + if (path is not null) + return AssemblyLoadContext.Default.LoadFromAssemblyPath(path); + } + } + + return null; + }; } private static Type FindPluginType(Assembly assembly, string pluginId)