diff --git a/CORTEX_ENGINE_ARCHITECTURE.md b/CORTEX_ENGINE_ARCHITECTURE.md index 4f51fe1..d083551 100644 --- a/CORTEX_ENGINE_ARCHITECTURE.md +++ b/CORTEX_ENGINE_ARCHITECTURE.md @@ -261,6 +261,13 @@ public struct Camera : IComponent public float Far; } +public struct Material : IComponent +{ + public Vector3 Albedo; + public float Roughness; + public float Metallic; +} + public struct SemanticClass : IComponent { public byte ClassId; // 0=environment, 1=enemy, 2=player, 3=interactive, 4=trigger @@ -283,7 +290,17 @@ When the AI generates a C# script, the engine: **Important caveat:** Flecs stores component type metadata in its native C memory. If old C# types are still referenced by Flecs, the old `AssemblyLoadContext` cannot be fully unloaded. The migration step must remove old components and re-add them as the new types. -### 4.5 SystemSlotRegistry +### 4.5 Rendering & Shading + +The renderer uses a simple forward-lit pipeline: + +- **Vertex format**: position, color, normal. +- **Per-entity**: Mesh + Transform + optional Material. +- **Per-frame constants** via push constants: MVP matrix, light direction/color, ambient color, camera position. +- **Lighting model**: directional light with ambient + diffuse + Blinn-Phong specular. +- **Material**: CPU-side tint; `Material.Albedo` multiplies vertex color, `Roughness` and `Metallic` reserved for future PBR extension. + +### 4.6 SystemSlotRegistry ```csharp public class SystemSlotRegistry @@ -490,7 +507,9 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can │ │ ├── EngineApp.cs # Entry point, main loop │ │ ├── Sdl3Window.cs # SDL3 window wrapper │ │ ├── Timing.cs # DeltaTime, fixed timestep -│ │ └── InputMapping.cs # Keyboard, mouse, gamepad input +│ │ ├── InputMapping.cs # Keyboard, mouse, gamepad input +│ │ ├── OrbitCameraController.cs # Mouse orbit camera +│ │ └── Components/ # Transform, Camera, Material, Mesh │ │ │ ├── Engine.Data/ │ │ ├── GameObject.cs # Thin struct facade @@ -657,13 +676,13 @@ Stack: - ModelContextProtocol for AI tool integration Rules: -1. All state lives in ECS components. GameObject is a struct facade. -2. All AI mutations go through AiGateway. +1. All state lives in ECS components (Transform, Camera, Mesh, Material). +2. All AI mutations go through Engine.AI (AiCommandProcessor / MCP tools). 3. All Dev-Mode AI scripts must be AOT-compatible and avoid unsafe, Reflection.Emit, Assembly.Load, File I/O. 4. All systems are registered in SystemSlotRegistry and tagged with [Slot("name")]. 5. Use Roslyn syntax trees for validation before compilation. 6. Prefer Flecs native reflection (ecs_world_to_json) over C# reflection. -7. Keep modules isolated; do not create circular dependencies between Engine.Core, Engine.Data, Engine.Graphics, Engine.Diagnostics, Engine.AiGateway, Engine.Editor. +7. Keep modules isolated; do not create circular dependencies between Engine.Core, Engine.Graphics, Engine.AI. Current file context: [insert path here] ``` diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs index ec6f338..e89a736 100644 --- a/src/CortexEngine.App/Program.cs +++ b/src/CortexEngine.App/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Numerics; using Engine.AI; @@ -17,12 +18,12 @@ class Program { static async Task Main(string[] args) { - Console.WriteLine("Cortex Engine Step 6 — MCP integration..."); + Console.WriteLine("Cortex Engine — Materials, Grid, Lighting, Orbit Camera..."); try { using var world = World.Create(); - using var window = new Sdl3Window("Cortex Engine — Step 6", 1280, 720); + using var window = new Sdl3Window("Cortex Engine", 1280, 720); var timing = new Timing(); var input = new InputMapping(); using var vulkan = new VulkanContext(window, enableValidation: false); @@ -35,13 +36,9 @@ class Program var processor = new AiCommandProcessor(world, LoadModel, path => renderer.RequestScreenshot(path)); var queue = new AiCommandQueue(processor); - var model = world.Entity("Model") - .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, new Vector3(0.5f))) - .Set(mesh); - - var camera = world.Entity("Camera") + var cameraEntity = world.Entity("Camera") .Set(new Camera( - new Vector3(0.0f, 0.0f, -2.0f), + new Vector3(0.0f, 1.5f, -3.0f), Vector3.Zero, Vector3.UnitY, MathF.PI / 4.0f, @@ -49,12 +46,34 @@ class Program 0.1f, 100.0f)); + var orbit = new OrbitCameraController(cameraEntity, Vector3.Zero); + + var model = world.Entity("Model") + .Set(new Transform(new Vector3(0.0f, 0.5f, 0.0f), Quaternion.Identity, new Vector3(0.5f))) + .Set(mesh) + .Set(new Material(new Vector3(0.9f, 0.6f, 0.3f), roughness: 0.4f, metallic: 0.1f)); + + var floor = world.Entity("Floor") + .Set(new Transform(new Vector3(0.0f, 0.0f, 0.0f), Quaternion.Identity, Vector3.One)) + .Set(CreateFloorMesh(20.0f, new Vector3(0.08f, 0.08f, 0.1f))) + .Set(new Material(new Vector3(0.08f, 0.08f, 0.1f), roughness: 0.9f, metallic: 0.0f)); + + var grid = world.Entity("Grid") + .Set(new Transform(new Vector3(0.0f, 0.01f, 0.0f), Quaternion.Identity, Vector3.One)) + .Set(CreateGridMesh(20, 0.5f, new Vector3(0.5f, 0.5f, 0.55f))) + .Set(new Material(new Vector3(0.5f, 0.5f, 0.55f), roughness: 0.9f, metallic: 0.0f)); + // Demo: local AI commands processed on the main thread. Console.WriteLine("AI demo commands:"); Console.WriteLine(processor.Process("""{ "type": "list_entities" }""").Message); Console.WriteLine(processor.Process("""{ "type": "spawn_model", "name": "SecondCube", "modelPath": "Content/cube.obj", "position": [0.8, 0, 0], "scale": [0.3, 0.3, 0.3] }""").Message); - Console.WriteLine(processor.Process("""{ "type": "list_entities" }""").Message); Console.WriteLine(processor.Process("""{ "type": "set_transform", "name": "SecondCube", "position": [0.8, 0.5, 0], "rotation": [0, 0, 0, 1], "scale": [0.3, 0.3, 0.3] }""").Message); + + var secondCube = world.Lookup("SecondCube"); + if ((ulong)secondCube.Id != 0) + secondCube.Set(new Material(new Vector3(0.3f, 0.7f, 0.9f), roughness: 0.3f, metallic: 0.2f)); + + Console.WriteLine(processor.Process("""{ "type": "list_entities" }""").Message); Console.WriteLine(processor.Process("""{ "type": "capture_screenshot", "outputPath": "Screenshots/demo.png" }""").Message); #if !RELEASE_AOT @@ -81,7 +100,7 @@ class Program while (!window.ShouldClose) { timing.Tick(); - window.PumpEvents(); + window.PumpEvents(input); input.BeginFrame(); // Drain any commands that arrived from the MCP server. @@ -94,12 +113,17 @@ class Program lastWidth = window.Width; lastHeight = window.Height; swapchain.Recreate(lastWidth, lastHeight); + ref var camera = ref cameraEntity.Ensure(); + camera.AspectRatio = (float)lastWidth / lastHeight; } + // Update orbit camera from mouse input. + orbit.Update(input, (float)timing.DeltaTime); + // Slowly rotate the model so we can see it in 3D. - ref var transform = ref model.Ensure(); - transform.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)timing.TotalTime * 0.5f) - * Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)timing.TotalTime * 0.25f); + ref var modelTransform = ref model.Ensure(); + modelTransform.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, (float)timing.TotalTime * 0.5f) + * Quaternion.CreateFromAxisAngle(Vector3.UnitX, (float)timing.TotalTime * 0.25f); renderer.RenderWorld(world); @@ -135,6 +159,57 @@ class Program : ObjLoader.Load(path, new Vector3(0.7f, 0.6f, 0.5f)); } + private static Mesh CreateGridMesh(int lines, float spacing, Vector3 color) + { + var vertices = new List(); + var indices = new List(); + var extent = lines * spacing; + var normal = Vector3.UnitY; + var halfWidth = 0.02f; + + for (var i = -lines; i <= lines; i++) + { + var offset = i * spacing; + + // Line parallel to X axis as a thin quad. + 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); + + // Line parallel to Z axis as a thin quad. + 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()); + } + + private static Mesh CreateFloorMesh(float size, Vector3 color) + { + var half = size / 2.0f; + var normal = Vector3.UnitY; + + var vertices = new Vertex[] + { + new(new Vector3(-half, 0, -half), color, normal), + new(new Vector3(half, 0, -half), color, normal), + new(new Vector3(half, 0, half), color, normal), + new(new Vector3(-half, 0, half), color, normal) + }; + + var indices = new uint[] { 0, 1, 2, 0, 2, 3 }; + return new Mesh(vertices, indices); + } + private static (string modelPath, int mcpPort) ParseArgs(string[] args) { var modelPath = FindModelPath(args); diff --git a/src/Engine.Core/Components/Material.cs b/src/Engine.Core/Components/Material.cs new file mode 100644 index 0000000..015970a --- /dev/null +++ b/src/Engine.Core/Components/Material.cs @@ -0,0 +1,23 @@ +using System.Numerics; + +namespace Engine.Core.Components; + +/// +/// Material component for simple PBR-like rendering. +/// Used by the renderer to tint the vertex color and control shading. +/// +public record struct Material +{ + public Vector3 Albedo; + public float Roughness; + public float Metallic; + + public Material(Vector3? albedo = null, float roughness = 0.5f, float metallic = 0.0f) + { + Albedo = albedo ?? new Vector3(0.7f, 0.6f, 0.5f); + Roughness = roughness; + Metallic = metallic; + } + + public static Material Default => new(new Vector3(0.7f, 0.6f, 0.5f), 0.5f, 0.0f); +} diff --git a/src/Engine.Core/InputMapping.cs b/src/Engine.Core/InputMapping.cs index 70d0a21..dd1507e 100644 --- a/src/Engine.Core/InputMapping.cs +++ b/src/Engine.Core/InputMapping.cs @@ -18,11 +18,13 @@ public sealed class InputMapping 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 BeginFrame() { _keysPressed.Clear(); _keysReleased.Clear(); + MouseWheelDelta = 0; } public void ProcessEvent(SDL_Event evt) @@ -52,6 +54,10 @@ public sealed class InputMapping case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_UP: SetMouseButton(evt.button.button, false); break; + + case SDL_EventType.SDL_EVENT_MOUSE_WHEEL: + MouseWheelDelta += evt.wheel.y; + break; } } diff --git a/src/Engine.Core/OrbitCameraController.cs b/src/Engine.Core/OrbitCameraController.cs new file mode 100644 index 0000000..5f512ea --- /dev/null +++ b/src/Engine.Core/OrbitCameraController.cs @@ -0,0 +1,80 @@ +using System; +using System.Numerics; +using Flecs.NET.Core; + +namespace Engine.Core; + +/// +/// Orbit camera controller. Right mouse drag rotates around the target, +/// mouse wheel zooms in/out. +/// +public sealed class OrbitCameraController +{ + private readonly Entity _cameraEntity; + private float _distance; + private float _yaw; + private float _pitch; + private readonly Vector3 _target; + private int _lastMouseX; + private int _lastMouseY; + private bool _isDragging; + + public OrbitCameraController(Entity cameraEntity, Vector3? target = null) + { + _cameraEntity = cameraEntity; + var camera = cameraEntity.Get(); + _target = target ?? Vector3.Zero; + _distance = Vector3.Distance(camera.Position, _target); + + var direction = Vector3.Normalize(camera.Position - _target); + _pitch = MathF.Asin(-direction.Y); + _yaw = MathF.Atan2(direction.X, direction.Z); + } + + public void Update(InputMapping input, float deltaTime) + { + if (input.MouseRight) + { + if (!_isDragging) + { + _isDragging = true; + _lastMouseX = input.MouseX; + _lastMouseY = input.MouseY; + } + else + { + var dx = input.MouseX - _lastMouseX; + var dy = input.MouseY - _lastMouseY; + _yaw -= dx * 0.005f; + _pitch -= dy * 0.005f; + _pitch = Math.Clamp(_pitch, -MathF.PI / 2.0f + 0.1f, MathF.PI / 2.0f - 0.1f); + _lastMouseX = input.MouseX; + _lastMouseY = input.MouseY; + } + } + else + { + _isDragging = false; + } + + if (input.MouseWheelDelta != 0) + { + _distance *= 1.0f - input.MouseWheelDelta * 0.1f; + _distance = Math.Clamp(_distance, 0.5f, 50.0f); + } + + UpdateCamera(); + } + + private void UpdateCamera() + { + var x = _distance * MathF.Cos(_pitch) * MathF.Sin(_yaw); + var y = _distance * MathF.Sin(_pitch); + var z = _distance * MathF.Cos(_pitch) * MathF.Cos(_yaw); + + var camera = _cameraEntity.Get(); + camera.Position = _target + new Vector3(x, y, z); + camera.Target = _target; + _cameraEntity.Set(camera); + } +} diff --git a/src/Engine.Core/Sdl3Window.cs b/src/Engine.Core/Sdl3Window.cs index 78c271e..805fb74 100644 --- a/src/Engine.Core/Sdl3Window.cs +++ b/src/Engine.Core/Sdl3Window.cs @@ -45,11 +45,13 @@ public sealed unsafe class Sdl3Window : IDisposable } } - public void PumpEvents() + public void PumpEvents(InputMapping? input = null) { SDL_Event evt; while (SDL3.SDL_PollEvent(&evt)) { + input?.ProcessEvent(evt); + switch ((SDL_EventType)evt.type) { case SDL_EventType.SDL_EVENT_QUIT: diff --git a/src/Engine.Graphics/MeshRenderer.cs b/src/Engine.Graphics/MeshRenderer.cs index 6d87d66..65408dc 100644 --- a/src/Engine.Graphics/MeshRenderer.cs +++ b/src/Engine.Graphics/MeshRenderer.cs @@ -38,6 +38,8 @@ public sealed unsafe class MeshRenderer : IDisposable public float Pad2; public Vector3 AmbientColor; public float Pad3; + public Vector3 CameraPosition; + public float Pad4; } private sealed class MeshBuffers : IDisposable @@ -201,19 +203,19 @@ public sealed unsafe class MeshRenderer : IDisposable _buffers[e] = buffers; } - var bytes = BuildMeshVertices(mesh, transform); + var material = e.Has() ? e.Get() : Material.Default; + var bytes = BuildMeshVertices(mesh, transform, material); buffers.VertexBuffer.Update(bytes); - var model = transform.GetMatrix(); - var mvp = Matrix4x4.Multiply(Matrix4x4.Multiply(model, view), proj); - var mvpT = Matrix4x4.Transpose(mvp); + var mvp = Matrix4x4.Transpose(Matrix4x4.Multiply(view, proj)); var push = new PushConstants { - Mvp = mvpT, + Mvp = mvp, LightDirection = new Vector3(0.5f, -1.0f, -0.5f), LightColor = new Vector3(1.0f, 0.95f, 0.8f), - AmbientColor = new Vector3(0.15f, 0.15f, 0.2f) + AmbientColor = new Vector3(0.15f, 0.15f, 0.2f), + CameraPosition = camera.Position }; var pushSize = (uint)sizeof(PushConstants); @@ -306,7 +308,7 @@ public sealed unsafe class MeshRenderer : IDisposable new IndexBuffer(_context, indexBytes, (uint)mesh.Indices.Length)); } - private byte[] BuildMeshVertices(Mesh mesh, Transform transform) + private byte[] BuildMeshVertices(Mesh mesh, Transform transform, Material material) { var matrix = transform.GetMatrix(); var bytes = new byte[mesh.Vertices.Length * 9 * sizeof(float)]; @@ -316,14 +318,15 @@ public sealed unsafe class MeshRenderer : IDisposable for (var i = 0; i < mesh.Vertices.Length; i++) { var v = mesh.Vertices[i]; - var transformed = Vector3.Transform(v.Position, matrix); + var worldPos = Vector3.Transform(v.Position, matrix); var normal = transform.TransformNormal(v.Normal); - dst[i * 9 + 0] = transformed.X; - dst[i * 9 + 1] = transformed.Y; - dst[i * 9 + 2] = transformed.Z; - dst[i * 9 + 3] = v.Color.X; - dst[i * 9 + 4] = v.Color.Y; - dst[i * 9 + 5] = v.Color.Z; + 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; diff --git a/src/Engine.Graphics/Shaders/fragment.frag b/src/Engine.Graphics/Shaders/fragment.frag index 9a1a4f8..8c80d96 100644 --- a/src/Engine.Graphics/Shaders/fragment.frag +++ b/src/Engine.Graphics/Shaders/fragment.frag @@ -15,17 +15,24 @@ layout(push_constant) uniform PushConstants float pad2; vec3 ambientColor; float pad3; + vec3 cameraPosition; + float pad4; } push; void main() { vec3 normal = normalize(fragNormal); vec3 lightDir = normalize(-push.lightDirection); + vec3 viewDir = normalize(push.cameraPosition - fragWorldPos); + vec3 halfDir = normalize(lightDir + viewDir); float diff = max(dot(normal, lightDir), 0.0); + float spec = pow(max(dot(normal, halfDir), 0.0), 64.0) * 0.5; + vec3 diffuse = push.lightColor * diff; + vec3 specular = push.lightColor * spec; vec3 ambient = push.ambientColor; - vec3 result = (ambient + diffuse) * fragColor; + vec3 result = (ambient + diffuse + specular) * fragColor; outColor = vec4(result, 1.0); } diff --git a/src/Engine.Graphics/Shaders/fragment.spv b/src/Engine.Graphics/Shaders/fragment.spv index 0789ea7..fc4e2b2 100644 Binary files a/src/Engine.Graphics/Shaders/fragment.spv and b/src/Engine.Graphics/Shaders/fragment.spv differ diff --git a/src/Engine.Graphics/Shaders/vertex.spv b/src/Engine.Graphics/Shaders/vertex.spv index c3e8f88..bb76553 100644 Binary files a/src/Engine.Graphics/Shaders/vertex.spv and b/src/Engine.Graphics/Shaders/vertex.spv differ diff --git a/src/Engine.Graphics/Shaders/vertex.vert b/src/Engine.Graphics/Shaders/vertex.vert index d779b1b..ecb695d 100644 --- a/src/Engine.Graphics/Shaders/vertex.vert +++ b/src/Engine.Graphics/Shaders/vertex.vert @@ -17,6 +17,8 @@ layout(push_constant) uniform PushConstants float pad2; vec3 ambientColor; float pad3; + vec3 cameraPosition; + float pad4; } push; void main() diff --git a/src/Engine.Graphics/VulkanPipeline.cs b/src/Engine.Graphics/VulkanPipeline.cs index 40c1def..3249777 100644 --- a/src/Engine.Graphics/VulkanPipeline.cs +++ b/src/Engine.Graphics/VulkanPipeline.cs @@ -59,7 +59,7 @@ public sealed unsafe class VulkanPipeline : IDisposable { StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, Offset = 0, - Size = (uint)(28 * sizeof(float)) + Size = (uint)(32 * sizeof(float)) }; var createInfo = new PipelineLayoutCreateInfo