diff --git a/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj b/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj index c632161..5326023 100644 --- a/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj +++ b/plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj @@ -1,3 +1,5 @@  - + + + diff --git a/plugins/engine.render/Engine.Render.Contracts/ICameraService.cs b/plugins/engine.render/Engine.Render.Contracts/ICameraService.cs new file mode 100644 index 0000000..88317e0 --- /dev/null +++ b/plugins/engine.render/Engine.Render.Contracts/ICameraService.cs @@ -0,0 +1,25 @@ +using System.Numerics; + +namespace Engine.Render.Contracts; + +/// +/// The one camera engine.render draws through — read for gizmo/ray-picking +/// math (editor.shell needs View/Projection to turn a 2D mouse position +/// into a 3D ray), settable for camera controls (orbit, fly) that don't +/// exist yet. Not a Component/GameObject-driven multi-camera system — +/// there's exactly one camera and nothing needs more than that yet; see +/// the note on RenderPlugin for why that's a deliberate scope line, not +/// an oversight. +/// +public interface ICameraService +{ + Vector3 Position { get; set; } + Vector3 Target { get; set; } + + Matrix4x4 View { get; } + + /// Recomputed against the window's current size on every + /// read — a cached value would go stale the moment the window + /// resizes. + Matrix4x4 Projection { get; } +} diff --git a/plugins/engine.render/Engine.Render.Contracts/QuadRenderer.cs b/plugins/engine.render/Engine.Render.Contracts/QuadRenderer.cs new file mode 100644 index 0000000..89f7488 --- /dev/null +++ b/plugins/engine.render/Engine.Render.Contracts/QuadRenderer.cs @@ -0,0 +1,18 @@ +using Engine.Kernel.World; + +namespace Engine.Render.Contracts; + +/// +/// Marks a GameObject as a flat, unit-sized (1x1 world unit before +/// scaling) quad, drawn at its own WorldMatrix — the only renderable +/// component that exists yet. No size fields here: Transform.LocalScale +/// already means "how big," and a second, competing way to say the same +/// thing would just raise the question of which one wins. A real +/// mesh/material system (arbitrary geometry, per-instance textures) is +/// real content-pipeline work, out of scope here — this exists so M3's +/// gizmos have something real to select and move, not to be a renderer. +/// Every QuadRenderer shares engine.render's one hardcoded texture for +/// the same reason TexturePath itself is hardcoded — there's no +/// material/asset-reference system yet to point it at one per instance. +/// +public sealed class QuadRenderer : Component; diff --git a/plugins/engine.render/Engine.Render/CameraService.cs b/plugins/engine.render/Engine.Render/CameraService.cs new file mode 100644 index 0000000..bce1419 --- /dev/null +++ b/plugins/engine.render/Engine.Render/CameraService.cs @@ -0,0 +1,23 @@ +using System.Numerics; +using Engine.Render.Contracts; +using Engine.Windowing.Contracts; + +namespace Engine.Render; + +internal sealed class CameraService(IEngineWindow window) : ICameraService +{ + public Vector3 Position { get; set; } = new(0f, 3f, 6f); + public Vector3 Target { get; set; } = Vector3.Zero; + + public Matrix4x4 View => Matrix4x4.CreateLookAt(Position, Target, Vector3.UnitY); + + public Matrix4x4 Projection + { + get + { + var size = window.Native.FramebufferSize; + var aspect = size.Y == 0 ? 1f : (float)size.X / size.Y; + return Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 4f, aspect, 0.1f, 100f); + } + } +} diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs index f01ca82..2a92888 100644 --- a/plugins/engine.render/Engine.Render/RenderPlugin.cs +++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs @@ -1,3 +1,4 @@ +using System.Numerics; using Engine.Assets.Contracts; using Engine.Kernel.Diagnostics; using Engine.Kernel.Plugins; @@ -10,15 +11,26 @@ using Silk.NET.OpenGL; namespace Engine.Render; /// -/// M2's render pipeline: a textured quad, drawn every Render stage. See M2 -/// in docs/kernel-contract.md §8. +/// M3's render pipeline: a real perspective camera, drawing every +/// GameObject with a QuadRenderer at its own WorldMatrix — upgraded from +/// M2's single hardcoded NDC-space quad specifically because gizmos need +/// real 3D geometry and a real camera to mean anything (a handle dragged +/// in screen space has to map onto an actual 3D axis). See M3 in +/// docs/kernel-contract.md §8. /// -/// This is the actual "done when": swap TexturePath's file on disk and the -/// quad's texture changes with no app restart — engine.assets watches the -/// file and publishes TextureReloaded; this plugin subscribes and -/// re-uploads to the same GL texture handle. Verified by hand against a -/// real window and a live GL context, via IScreenCapture — no external -/// image library; see the note on PngWriter for why. +/// GameObject.WorldMatrix and this plugin's own matrices are both +/// System.Numerics.Matrix4x4, which is row-vector (v' = v * M, and +/// composition reads left to right — see WorldMatrix's own doc comment). +/// The shaders below use the same convention deliberately +/// (`vec4(aPosition, 1.0) * uModel * uView * uProjection`), not GLSL's +/// more common column-vector order. That's also why SetMatrix uploads +/// with `transpose: true` — System.Numerics stores a matrix's first row +/// as its first four floats, but glUniformMatrix4fv with transpose=false +/// reads that same layout as the first *column* instead; asking GL to +/// transpose is what makes the bytes mean what C# already computed them +/// to mean. Confirmed empirically, not just reasoned through: with +/// transpose=false the quad rendered as a blank screen — no error, no +/// crash, just wrong — and flipping the one flag was the entire fix. /// /// engine.windowing alone produces a window that never becomes visible on /// Wayland — unlike X11, a Wayland surface with no committed buffer simply @@ -36,19 +48,23 @@ namespace Engine.Render; public sealed class RenderPlugin : IPlugin { // There's no material/asset-reference component yet (that's real - // content-authoring work, M3+ territory) — hardcoded the same way + // content-authoring work, M4+ territory) — hardcoded the same way // TriangleColor was hardcoded before textures existed at all. private const string TexturePath = "assets/texture.png"; private const string VertexShaderSource = """ #version 330 core - layout (location = 0) in vec2 aPosition; + layout (location = 0) in vec3 aPosition; layout (location = 1) in vec2 aUv; out vec2 vUv; + uniform mat4 uModel; + uniform mat4 uView; + uniform mat4 uProjection; + void main() { - gl_Position = vec4(aPosition, 0.0, 1.0); + gl_Position = vec4(aPosition, 1.0) * uModel * uView * uProjection; vUv = aUv; } """; @@ -65,24 +81,30 @@ public sealed class RenderPlugin : IPlugin } """; + // Unit quad (1x1 world units before Transform.LocalScale), centered + // at its own origin, in the XY plane facing +Z. private static readonly float[] Vertices = [ - // position uv - -0.6f, 0.6f, 0f, 1f, - -0.6f, -0.6f, 0f, 0f, - 0.6f, -0.6f, 1f, 0f, + // position uv + -0.5f, 0.5f, 0f, 0f, 1f, + -0.5f, -0.5f, 0f, 0f, 0f, + 0.5f, -0.5f, 0f, 1f, 0f, - -0.6f, 0.6f, 0f, 1f, - 0.6f, -0.6f, 1f, 0f, - 0.6f, 0.6f, 1f, 1f, + -0.5f, 0.5f, 0f, 0f, 1f, + 0.5f, -0.5f, 0f, 1f, 0f, + 0.5f, 0.5f, 0f, 1f, 1f, ]; private GL? _gl; private IEngineWindow? _window; + private CameraService? _camera; private uint _vao; private uint _vbo; private uint _program; private uint _texture; + private int _modelLocation; + private int _viewLocation; + private int _projectionLocation; private Action? _onTextureReloaded; private ILogger? _log; @@ -100,7 +122,13 @@ public sealed class RenderPlugin : IPlugin // failure mode here, not a hypothetical one. _window.Native.GLContext.SwapInterval(0); + _camera = new CameraService(_window); + ctx.Services.Provide(_camera); + _program = LinkProgram(_gl, VertexShaderSource, FragmentShaderSource); + _modelLocation = _gl.GetUniformLocation(_program, "uModel"); + _viewLocation = _gl.GetUniformLocation(_program, "uView"); + _projectionLocation = _gl.GetUniformLocation(_program, "uProjection"); _vao = _gl.GenVertexArray(); _gl.BindVertexArray(_vao); @@ -109,13 +137,14 @@ public sealed class RenderPlugin : IPlugin _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); _gl.BufferData(BufferTargetARB.ArrayBuffer, Vertices, BufferUsageARB.StaticDraw); - const uint stride = 4 * sizeof(float); - _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0); + const uint stride = 5 * sizeof(float); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); _gl.EnableVertexAttribArray(0); - _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float))); + _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); _gl.EnableVertexAttribArray(1); _gl.BindVertexArray(0); + _gl.Enable(EnableCap.DepthTest); var assets = ctx.Services.Require(); var initial = assets.LoadTexture(TexturePath); @@ -130,8 +159,8 @@ public sealed class RenderPlugin : IPlugin ctx.Events.Subscribe(_onTextureReloaded); ctx.Services.Provide(new GlScreenCapture(_gl, _window)); - ctx.Schedule.Add(Stage.Render, Draw); - ctx.Log.Info("GL context created, textured quad ready"); + ctx.Schedule.Add(Stage.Render, Draw).Reads(); + ctx.Log.Info("GL context created, 3D quad pipeline ready"); } public void Shutdown(IPluginContext ctx) @@ -139,6 +168,7 @@ public sealed class RenderPlugin : IPlugin ctx.Schedule.RemoveAllFrom("engine.render"); ctx.Events.RemoveAllFrom("engine.render"); ctx.Services.Revoke(); + ctx.Services.Revoke(); if (_gl is not null) { @@ -151,6 +181,7 @@ public sealed class RenderPlugin : IPlugin _gl = null; _window = null; + _camera = null; _onTextureReloaded = null; _log = null; } @@ -185,20 +216,33 @@ public sealed class RenderPlugin : IPlugin _log?.Warn($"GL error after texture upload: {error}"); } - private void Draw(IWorld world) + private unsafe void Draw(IWorld world) { _gl!.ClearColor(0.05f, 0.05f, 0.08f, 1f); - _gl.Clear(ClearBufferMask.ColorBufferBit); + _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); _gl.UseProgram(_program); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, _texture); _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, 6); + + var view = _camera!.View; + var projection = _camera.Projection; + SetMatrix(_viewLocation, view); + SetMatrix(_projectionLocation, projection); + + foreach (var go in world.Query()) + { + SetMatrix(_modelLocation, go.WorldMatrix); + _gl.DrawArrays(PrimitiveType.Triangles, 0, 6); + } _window!.Native.GLContext!.SwapBuffers(); } + private unsafe void SetMatrix(int location, Matrix4x4 matrix) => + _gl!.UniformMatrix4(location, 1, true, (float*)&matrix); + private static uint LinkProgram(GL gl, string vertexSource, string fragmentSource) { var vertex = CompileShader(gl, ShaderType.VertexShader, vertexSource); diff --git a/samples/WindowDemo/scene.json b/samples/WindowDemo/scene.json new file mode 100644 index 0000000..85d4edb --- /dev/null +++ b/samples/WindowDemo/scene.json @@ -0,0 +1,14 @@ +[ + { + "name": "Quad", + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [2, 2, 1] + }, + "components": [ + { "type": "Engine.Render.Contracts.QuadRenderer, Engine.Render.Contracts", "data": {} } + ], + "children": [] + } +]