diff --git a/NResUI/ImGuiUI/ViewportPanel.cs b/NResUI/ImGuiUI/ViewportPanel.cs new file mode 100644 index 0000000..64e1795 --- /dev/null +++ b/NResUI/ImGuiUI/ViewportPanel.cs @@ -0,0 +1,124 @@ +using System.Numerics; +using ImGuiNET; +using NResUI.Abstractions; +using NResUI.Rendering.Viewport; +using NResUI.Rendering.Viewport.Meshes; +using Silk.NET.OpenGL; +using Silk.NET.Windowing; + +namespace NResUI.ImGuiUI; + +public sealed class ViewportPanel : IImGuiPanel +{ + private readonly ViewportRenderer _renderer; + private readonly ViewportScene _scene; + private readonly ViewportCamera _camera = new(); + private readonly ViewportInputController _inputController = new(); + + public ViewportPanel(GL gl, IWindow window) + { + var cubeMesh = PrimitiveMeshes.CreateCube(gl); + var gridMesh = PrimitiveMeshes.CreateWorldGrid(gl); + + _scene = ViewportScene.CreateDefaultCubeScene(cubeMesh, gridMesh); + _renderer = new ViewportRenderer(gl, window); + } + + public void OnImGuiRender() + { + if (!ImGui.Begin("Viewport")) + { + ImGui.End(); + return; + } + + DrawSelectionStatus(); + DrawGridToggle(); + + var imageSize = ImGui.GetContentRegionAvail(); + if (imageSize.X < 32 || imageSize.Y < 32) + { + ImGui.TextDisabled("Viewport is too small."); + ImGui.End(); + return; + } + + var textureId = _renderer.Render( + width: Math.Max(1, (int)imageSize.X), + height: Math.Max(1, (int)imageSize.Y), + scene: _scene, + camera: _camera); + + ImGui.Image( + (IntPtr)textureId, + imageSize, + new Vector2(0, 1), + new Vector2(1, 0)); + + var viewportHovered = ImGui.IsItemHovered(); + var imageMin = ImGui.GetItemRectMin(); + + DrawViewportHelpOverlay(); + + _inputController.Handle( + _scene, + _camera, + viewportHovered, + imageMin, + imageSize); + + ImGui.End(); + } + + private void DrawSelectionStatus() + { + var selectedPiece = _scene.SelectedPiece; + if (selectedPiece != null) + ImGui.Text($"Selected: {selectedPiece.Name}"); + else + ImGui.TextDisabled("Selected: none"); + } + + private void DrawGridToggle() + { + var grid = _scene.Grid; + if (grid == null) + return; + + var isVisible = grid.IsVisible; + if (ImGui.Checkbox("Show grid", ref isVisible)) + grid.IsVisible = isVisible; + } + + private static void DrawViewportHelpOverlay() + { + var drawList = ImGui.GetWindowDrawList(); + + var imageMin = ImGui.GetItemRectMin(); + var imageMax = ImGui.GetItemRectMax(); + + const string helpText = "LMB click: select / deselect\nMMB drag: rotate\nMouse wheel: zoom"; + + var textSize = ImGui.CalcTextSize(helpText); + var padding = new Vector2(8.0f, 6.0f); + + var boxMin = new Vector2( + imageMin.X + 8.0f, + imageMax.Y - textSize.Y - padding.Y * 2.0f - 8.0f); + + var boxMax = new Vector2( + boxMin.X + textSize.X + padding.X * 2.0f, + boxMin.Y + textSize.Y + padding.Y * 2.0f); + + drawList.AddRectFilled( + boxMin, + boxMax, + ImGui.GetColorU32(new Vector4(0.0f, 0.0f, 0.0f, 0.45f)), + 4.0f); + + drawList.AddText( + boxMin + padding, + ImGui.GetColorU32(new Vector4(1.0f, 1.0f, 1.0f, 0.9f)), + helpText); + } +} diff --git a/NResUI/NResUI.csproj b/NResUI/NResUI.csproj index 9df73e6..40e7df5 100644 --- a/NResUI/NResUI.csproj +++ b/NResUI/NResUI.csproj @@ -3,6 +3,7 @@ WinExe Exe + true diff --git a/NResUI/Rendering/Viewport/Meshes/GpuMesh.cs b/NResUI/Rendering/Viewport/Meshes/GpuMesh.cs new file mode 100644 index 0000000..edd01ea --- /dev/null +++ b/NResUI/Rendering/Viewport/Meshes/GpuMesh.cs @@ -0,0 +1,36 @@ +using Silk.NET.OpenGL; + +namespace NResUI.Rendering.Viewport.Meshes; + +public sealed class GpuMesh +{ + private readonly GL _gl; + + public uint VertexArrayObject { get; } + public uint VertexBufferObject { get; } + public uint ElementBufferObject { get; } + public uint IndexCount { get; } + public PrimitiveType PrimitiveType { get; } + + public GpuMesh( + GL gl, + uint vertexArrayObject, + uint vertexBufferObject, + uint elementBufferObject, + uint indexCount, + PrimitiveType primitiveType = PrimitiveType.Triangles) + { + _gl = gl; + VertexArrayObject = vertexArrayObject; + VertexBufferObject = vertexBufferObject; + ElementBufferObject = elementBufferObject; + IndexCount = indexCount; + PrimitiveType = primitiveType; + } + + public void Draw() + { + _gl.BindVertexArray(VertexArrayObject); + _gl.DrawElements(PrimitiveType, IndexCount, DrawElementsType.UnsignedInt, null); + } +} diff --git a/NResUI/Rendering/Viewport/Meshes/PrimitiveMeshes.cs b/NResUI/Rendering/Viewport/Meshes/PrimitiveMeshes.cs new file mode 100644 index 0000000..10f0b20 --- /dev/null +++ b/NResUI/Rendering/Viewport/Meshes/PrimitiveMeshes.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using Silk.NET.OpenGL; + +namespace NResUI.Rendering.Viewport.Meshes; + +public static unsafe class PrimitiveMeshes +{ + public static GpuMesh CreateCube(GL gl) + { + // position.xyz, color.rgb + float[] vertices = + { + // front + -1, -1, 1, 1, 0, 0, + 1, -1, 1, 0, 1, 0, + 1, 1, 1, 0, 0, 1, + -1, 1, 1, 1, 1, 0, + + // back + -1, -1, -1, 1, 0, 1, + 1, -1, -1, 0, 1, 1, + 1, 1, -1, 1, 1, 1, + -1, 1, -1, 0.5f, 0.5f, 0.5f, + }; + + uint[] indices = + { + // front + 0, 1, 2, + 2, 3, 0, + + // right + 1, 5, 6, + 6, 2, 1, + + // back + 5, 4, 7, + 7, 6, 5, + + // left + 4, 0, 3, + 3, 7, 4, + + // top + 3, 2, 6, + 6, 7, 3, + + // bottom + 4, 5, 1, + 1, 0, 4, + }; + + return CreateIndexedMesh(gl, vertices, indices, PrimitiveType.Triangles); + } + + public static GpuMesh CreateWorldGrid( + GL gl, + int halfExtent = 20, + int minorStep = 1, + int majorStep = 5) + { + if (halfExtent <= 0) + throw new ArgumentOutOfRangeException(nameof(halfExtent)); + + if (minorStep <= 0) + throw new ArgumentOutOfRangeException(nameof(minorStep)); + + if (majorStep <= 0) + throw new ArgumentOutOfRangeException(nameof(majorStep)); + + var vertices = new List(); + var indices = new List(); + + void AddVertex(float x, float y, float z, float r, float g, float b) + { + vertices.Add(x); + vertices.Add(y); + vertices.Add(z); + vertices.Add(r); + vertices.Add(g); + vertices.Add(b); + } + + void AddLine( + float x0, float y0, float z0, + float x1, float y1, float z1, + float r, float g, float b) + { + var startIndex = (uint)(vertices.Count / 6); + + AddVertex(x0, y0, z0, r, g, b); + AddVertex(x1, y1, z1, r, g, b); + + indices.Add(startIndex); + indices.Add(startIndex + 1); + } + + const float y = 0.0f; + + for (var i = -halfExtent; i <= halfExtent; i += minorStep) + { + var isAxis = i == 0; + var isMajor = i % majorStep == 0; + + var brightness = isMajor ? 0.38f : 0.24f; + var r = brightness; + var g = brightness; + var b = brightness; + + // Lines parallel to X. The Z axis itself is blue. + if (isAxis) + { + AddLine(-halfExtent, y, i, halfExtent, y, i, 0.25f, 0.45f, 1.0f); + } + else + { + AddLine(-halfExtent, y, i, halfExtent, y, i, r, g, b); + } + + // Lines parallel to Z. The X axis itself is red. + if (isAxis) + { + AddLine(i, y, -halfExtent, i, y, halfExtent, 1.0f, 0.25f, 0.25f); + } + else + { + AddLine(i, y, -halfExtent, i, y, halfExtent, r, g, b); + } + } + + return CreateIndexedMesh(gl, vertices.ToArray(), indices.ToArray(), PrimitiveType.Lines); + } + + private static GpuMesh CreateIndexedMesh( + GL gl, + float[] vertices, + uint[] indices, + PrimitiveType primitiveType) + { + var vao = gl.GenVertexArray(); + var vbo = gl.GenBuffer(); + var ebo = gl.GenBuffer(); + + gl.BindVertexArray(vao); + + gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); + fixed (float* vertexPtr = vertices) + { + gl.BufferData( + BufferTargetARB.ArrayBuffer, + (nuint)(vertices.Length * sizeof(float)), + vertexPtr, + BufferUsageARB.StaticDraw); + } + + gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo); + fixed (uint* indexPtr = indices) + { + gl.BufferData( + BufferTargetARB.ElementArrayBuffer, + (nuint)(indices.Length * sizeof(uint)), + indexPtr, + BufferUsageARB.StaticDraw); + } + + const uint stride = 6 * sizeof(float); + + gl.EnableVertexAttribArray(0); + gl.VertexAttribPointer( + 0, + 3, + VertexAttribPointerType.Float, + false, + stride, + null); + + gl.EnableVertexAttribArray(1); + gl.VertexAttribPointer( + 1, + 3, + VertexAttribPointerType.Float, + false, + stride, + (void*)(3 * sizeof(float))); + + gl.BindVertexArray(0); + + return new GpuMesh(gl, vao, vbo, ebo, (uint)indices.Length, primitiveType); + } +} diff --git a/NResUI/Rendering/Viewport/OpenGL/MsaaFramebuffer.cs b/NResUI/Rendering/Viewport/OpenGL/MsaaFramebuffer.cs new file mode 100644 index 0000000..eb96adb --- /dev/null +++ b/NResUI/Rendering/Viewport/OpenGL/MsaaFramebuffer.cs @@ -0,0 +1,150 @@ +using Silk.NET.OpenGL; + +namespace NResUI.Rendering.Viewport.OpenGL; + +public sealed class MsaaFramebuffer +{ + private readonly GL _gl; + private readonly uint _samples; + + private uint _msaaFramebuffer; + private uint _msaaColorRenderbuffer; + private uint _msaaDepthStencilRenderbuffer; + + private uint _resolveFramebuffer; + private uint _resolveColorTexture; + + private int _width; + private int _height; + + public uint ColorTexture => _resolveColorTexture; + + public MsaaFramebuffer(GL gl, uint samples = 4) + { + _gl = gl; + _samples = samples; + } + + public void EnsureSize(int width, int height) + { + width = Math.Max(1, width); + height = Math.Max(1, height); + + if (_msaaFramebuffer != 0 && _width == width && _height == height) + return; + + _width = width; + _height = height; + + if (_msaaFramebuffer == 0) + { + _msaaFramebuffer = _gl.GenFramebuffer(); + _msaaColorRenderbuffer = _gl.GenRenderbuffer(); + _msaaDepthStencilRenderbuffer = _gl.GenRenderbuffer(); + + _resolveFramebuffer = _gl.GenFramebuffer(); + _resolveColorTexture = _gl.GenTexture(); + } + + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _msaaColorRenderbuffer); + _gl.RenderbufferStorageMultisample( + RenderbufferTarget.Renderbuffer, + _samples, + InternalFormat.Rgba8, + (uint)width, + (uint)height); + + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _msaaDepthStencilRenderbuffer); + _gl.RenderbufferStorageMultisample( + RenderbufferTarget.Renderbuffer, + _samples, + InternalFormat.Depth24Stencil8, + (uint)width, + (uint)height); + + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _msaaFramebuffer); + + _gl.FramebufferRenderbuffer( + FramebufferTarget.Framebuffer, + FramebufferAttachment.ColorAttachment0, + RenderbufferTarget.Renderbuffer, + _msaaColorRenderbuffer); + + _gl.FramebufferRenderbuffer( + FramebufferTarget.Framebuffer, + FramebufferAttachment.DepthStencilAttachment, + RenderbufferTarget.Renderbuffer, + _msaaDepthStencilRenderbuffer); + + var msaaStatus = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); + if (msaaStatus != GLEnum.FramebufferComplete) + throw new InvalidOperationException($"MSAA viewport framebuffer is incomplete: {msaaStatus}"); + + _gl.BindTexture(TextureTarget.Texture2D, _resolveColorTexture); + _gl.TexImage2D( + TextureTarget.Texture2D, + 0, + InternalFormat.Rgba8, + (uint)width, + (uint)height, + 0, + PixelFormat.Rgba, + PixelType.UnsignedByte, + null); + + _gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureMinFilter, + (int)TextureMinFilter.Linear); + + _gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureMagFilter, + (int)TextureMagFilter.Linear); + + _gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureWrapS, + (int)TextureWrapMode.ClampToEdge); + + _gl.TexParameter( + TextureTarget.Texture2D, + TextureParameterName.TextureWrapT, + (int)TextureWrapMode.ClampToEdge); + + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _resolveFramebuffer); + + _gl.FramebufferTexture2D( + FramebufferTarget.Framebuffer, + FramebufferAttachment.ColorAttachment0, + TextureTarget.Texture2D, + _resolveColorTexture, + 0); + + var resolveStatus = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); + if (resolveStatus != GLEnum.FramebufferComplete) + throw new InvalidOperationException($"Resolve viewport framebuffer is incomplete: {resolveStatus}"); + + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); + _gl.BindTexture(TextureTarget.Texture2D, 0); + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0); + } + + public void BindForRender() + { + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _msaaFramebuffer); + _gl.Viewport(0, 0, (uint)_width, (uint)_height); + } + + public void Resolve() + { + _gl.BindFramebuffer(FramebufferTarget.ReadFramebuffer, _msaaFramebuffer); + _gl.BindFramebuffer(FramebufferTarget.DrawFramebuffer, _resolveFramebuffer); + + _gl.BlitFramebuffer( + 0, 0, _width, _height, + 0, 0, _width, _height, + ClearBufferMask.ColorBufferBit, + BlitFramebufferFilter.Nearest); + } +} diff --git a/NResUI/Rendering/Viewport/OpenGL/ShaderProgram.cs b/NResUI/Rendering/Viewport/OpenGL/ShaderProgram.cs new file mode 100644 index 0000000..7472a6c --- /dev/null +++ b/NResUI/Rendering/Viewport/OpenGL/ShaderProgram.cs @@ -0,0 +1,72 @@ +using System.Numerics; +using Silk.NET.OpenGL; + +namespace NResUI.Rendering.Viewport.OpenGL; + +public sealed unsafe class ShaderProgram +{ + private readonly GL _gl; + + public uint Handle { get; } + + public ShaderProgram(GL gl, string vertexShaderSource, string fragmentShaderSource, string debugName) + { + _gl = gl; + + var vertexShader = CompileShader(ShaderType.VertexShader, vertexShaderSource, debugName); + var fragmentShader = CompileShader(ShaderType.FragmentShader, fragmentShaderSource, debugName); + + Handle = _gl.CreateProgram(); + _gl.AttachShader(Handle, vertexShader); + _gl.AttachShader(Handle, fragmentShader); + _gl.LinkProgram(Handle); + + _gl.GetProgram(Handle, ProgramPropertyARB.LinkStatus, out var linked); + if (linked == 0) + { + var log = _gl.GetProgramInfoLog(Handle); + throw new InvalidOperationException($"{debugName} shader link failed: {log}"); + } + + _gl.DetachShader(Handle, vertexShader); + _gl.DetachShader(Handle, fragmentShader); + _gl.DeleteShader(vertexShader); + _gl.DeleteShader(fragmentShader); + } + + public void Use() + { + _gl.UseProgram(Handle); + } + + public int GetUniformLocation(string name) + { + return _gl.GetUniformLocation(Handle, name); + } + + public void SetMatrix4(int location, Matrix4x4 matrix) + { + _gl.UniformMatrix4(location, 1, false, (float*)&matrix); + } + + public void SetVector4(int location, Vector4 value) + { + _gl.Uniform4(location, value.X, value.Y, value.Z, value.W); + } + + private uint CompileShader(ShaderType type, string source, string debugName) + { + var shader = _gl.CreateShader(type); + _gl.ShaderSource(shader, source); + _gl.CompileShader(shader); + + _gl.GetShader(shader, ShaderParameterName.CompileStatus, out var compiled); + if (compiled == 0) + { + var log = _gl.GetShaderInfoLog(shader); + throw new InvalidOperationException($"{debugName} {type} compile failed: {log}"); + } + + return shader; + } +} diff --git a/NResUI/Rendering/Viewport/ViewportCamera.cs b/NResUI/Rendering/Viewport/ViewportCamera.cs new file mode 100644 index 0000000..f9c3de1 --- /dev/null +++ b/NResUI/Rendering/Viewport/ViewportCamera.cs @@ -0,0 +1,38 @@ +using System.Numerics; + +namespace NResUI.Rendering.Viewport; + +public sealed class ViewportCamera +{ + public float YawDegrees { get; set; } = 35.0f; + public float PitchDegrees { get; set; } = 25.0f; + public float Distance { get; set; } = 4.0f; + + public Matrix4x4 GetSceneRotationMatrix() + { + return Matrix4x4.CreateRotationY(ToRadians(YawDegrees)) * + Matrix4x4.CreateRotationX(ToRadians(PitchDegrees)); + } + + public Matrix4x4 GetViewMatrix() + { + return Matrix4x4.CreateLookAt( + new Vector3(0.0f, 0.0f, Distance), + Vector3.Zero, + Vector3.UnitY); + } + + public Matrix4x4 GetProjectionMatrix(float aspectRatio) + { + return Matrix4x4.CreatePerspectiveFieldOfView( + ToRadians(60.0f), + aspectRatio, + 0.01f, + 100.0f); + } + + public static float ToRadians(float degrees) + { + return degrees * MathF.PI / 180.0f; + } +} diff --git a/NResUI/Rendering/Viewport/ViewportGrid.cs b/NResUI/Rendering/Viewport/ViewportGrid.cs new file mode 100644 index 0000000..71e1948 --- /dev/null +++ b/NResUI/Rendering/Viewport/ViewportGrid.cs @@ -0,0 +1,16 @@ +using System.Numerics; +using NResUI.Rendering.Viewport.Meshes; + +namespace NResUI.Rendering.Viewport; + +public sealed class ViewportGrid +{ + public GpuMesh Mesh { get; } + public bool IsVisible { get; set; } = true; + public Matrix4x4 LocalTransform { get; set; } = Matrix4x4.Identity; + + public ViewportGrid(GpuMesh mesh) + { + Mesh = mesh; + } +} diff --git a/NResUI/Rendering/Viewport/ViewportInputController.cs b/NResUI/Rendering/Viewport/ViewportInputController.cs new file mode 100644 index 0000000..8e957a6 --- /dev/null +++ b/NResUI/Rendering/Viewport/ViewportInputController.cs @@ -0,0 +1,53 @@ +using System.Numerics; +using ImGuiNET; + +namespace NResUI.Rendering.Viewport; + +public sealed class ViewportInputController +{ + private const float MouseRotationSpeed = 0.25f; + private const float MouseZoomSpeed = 0.35f; + + private bool _isRotatingViewport; + + public void Handle( + ViewportScene scene, + ViewportCamera camera, + bool viewportHovered, + Vector2 imageMin, + Vector2 imageSize) + { + var io = ImGui.GetIO(); + + if (viewportHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Middle)) + _isRotatingViewport = true; + + if (!ImGui.IsMouseDown(ImGuiMouseButton.Middle)) + _isRotatingViewport = false; + + if (_isRotatingViewport) + { + camera.YawDegrees += io.MouseDelta.X * MouseRotationSpeed; + camera.PitchDegrees += io.MouseDelta.Y * MouseRotationSpeed; + camera.PitchDegrees = Math.Clamp(camera.PitchDegrees, -89.0f, 89.0f); + } + + if (viewportHovered && Math.Abs(io.MouseWheel) > float.Epsilon) + { + camera.Distance -= io.MouseWheel * MouseZoomSpeed; + camera.Distance = Math.Clamp(camera.Distance, 1.5f, 40.0f); + } + + if (viewportHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + var mouse = ImGui.GetMousePos(); + var localMouse = mouse - imageMin; + + scene.SelectedPieceId = ViewportSelection.PickPiece( + scene, + camera, + localMouse, + imageSize); + } + } +} diff --git a/NResUI/Rendering/Viewport/ViewportPiece.cs b/NResUI/Rendering/Viewport/ViewportPiece.cs new file mode 100644 index 0000000..2b17203 --- /dev/null +++ b/NResUI/Rendering/Viewport/ViewportPiece.cs @@ -0,0 +1,43 @@ +using System.Numerics; +using NResUI.Rendering.Viewport.Meshes; + +namespace NResUI.Rendering.Viewport; + +public sealed class ViewportPiece +{ + public int Id { get; } + public string Name { get; } + public GpuMesh Mesh { get; } + + public Matrix4x4 LocalTransform { get; set; } + + public Vector3 BoundsMin { get; } + public Vector3 BoundsMax { get; } + + public ViewportPiece( + int id, + string name, + GpuMesh mesh, + Matrix4x4 localTransform, + Vector3 boundsMin, + Vector3 boundsMax) + { + Id = id; + Name = name; + Mesh = mesh; + LocalTransform = localTransform; + BoundsMin = boundsMin; + BoundsMax = boundsMax; + } + + public static ViewportPiece CreateUnitCube(int id, string name, GpuMesh mesh) + { + return new ViewportPiece( + id, + name, + mesh, + Matrix4x4.Identity, + new Vector3(-1.0f, -1.0f, -1.0f), + new Vector3(1.0f, 1.0f, 1.0f)); + } +} diff --git a/NResUI/Rendering/Viewport/ViewportRenderer.cs b/NResUI/Rendering/Viewport/ViewportRenderer.cs new file mode 100644 index 0000000..b7a2f12 --- /dev/null +++ b/NResUI/Rendering/Viewport/ViewportRenderer.cs @@ -0,0 +1,254 @@ +using System.Numerics; +using NResUI.Rendering.Viewport.OpenGL; +using Silk.NET.OpenGL; +using Silk.NET.Windowing; + +namespace NResUI.Rendering.Viewport; + +public sealed class ViewportRenderer +{ + private readonly GL _gl; + private readonly IWindow _window; + private readonly MsaaFramebuffer _framebuffer; + + private ShaderProgram? _meshShader; + private ShaderProgram? _outlineShader; + + private int _meshMvpLocation; + private int _outlineMvpLocation; + private int _outlineColorLocation; + + private bool _initialized; + + public ViewportRenderer(GL gl, IWindow window) + { + _gl = gl; + _window = window; + _framebuffer = new MsaaFramebuffer(gl, samples: 4); + } + + public uint Render(int width, int height, ViewportScene scene, ViewportCamera camera) + { + EnsureInitialized(); + + width = Math.Max(1, width); + height = Math.Max(1, height); + + _framebuffer.EnsureSize(width, height); + _framebuffer.BindForRender(); + + _gl.Enable(EnableCap.DepthTest); + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Back); + + _gl.ClearColor(0.12f, 0.13f, 0.15f, 1.0f); + _gl.ClearStencil(0); + _gl.Clear((uint)( + ClearBufferMask.ColorBufferBit | + ClearBufferMask.DepthBufferBit | + ClearBufferMask.StencilBufferBit)); + + var aspect = width / (float)Math.Max(1, height); + var sceneRotation = camera.GetSceneRotationMatrix(); + var view = camera.GetViewMatrix(); + var projection = camera.GetProjectionMatrix(aspect); + + DrawGrid(scene, sceneRotation, view, projection); + DrawScene(scene, sceneRotation, view, projection); + + _framebuffer.Resolve(); + + _gl.Disable(EnableCap.CullFace); + _gl.Disable(EnableCap.DepthTest); + _gl.Disable(EnableCap.StencilTest); + _gl.UseProgram(0); + _gl.BindVertexArray(0); + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); + + var framebufferSize = _window.FramebufferSize; + _gl.Viewport(0, 0, (uint)framebufferSize.X, (uint)framebufferSize.Y); + + return _framebuffer.ColorTexture; + } + + + private void DrawGrid( + ViewportScene scene, + Matrix4x4 sceneRotation, + Matrix4x4 view, + Matrix4x4 projection) + { + if (_meshShader == null) + throw new InvalidOperationException("Viewport mesh shader is not initialized."); + + var grid = scene.Grid; + if (grid == null || !grid.IsVisible) + return; + + var model = grid.LocalTransform * sceneRotation; + var mvp = model * view * projection; + + _gl.Disable(EnableCap.StencilTest); + _gl.Disable(EnableCap.CullFace); + + _meshShader.Use(); + _meshShader.SetMatrix4(_meshMvpLocation, mvp); + grid.Mesh.Draw(); + + _gl.Enable(EnableCap.CullFace); + } + + private void DrawScene( + ViewportScene scene, + Matrix4x4 sceneRotation, + Matrix4x4 view, + Matrix4x4 projection) + { + if (_meshShader == null || _outlineShader == null) + throw new InvalidOperationException("Viewport renderer is not initialized."); + + _gl.Disable(EnableCap.StencilTest); + _meshShader.Use(); + + foreach (var piece in scene.Pieces) + { + if (piece.Id == scene.SelectedPieceId) + continue; + + DrawPiece(piece, sceneRotation, view, projection); + } + + var selectedPiece = scene.SelectedPiece; + if (selectedPiece == null) + return; + + _gl.Enable(EnableCap.StencilTest); + _gl.StencilMask(0xFF); + _gl.StencilFunc(StencilFunction.Always, 1, 0xFF); + _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + + _meshShader.Use(); + DrawPiece(selectedPiece, sceneRotation, view, projection); + + _gl.StencilFunc(StencilFunction.Notequal, 1, 0xFF); + _gl.StencilMask(0x00); + _gl.Disable(EnableCap.DepthTest); + + DrawPieceOutline(selectedPiece, sceneRotation, view, projection); + + _gl.Enable(EnableCap.DepthTest); + _gl.StencilMask(0xFF); + _gl.Disable(EnableCap.StencilTest); + } + + private void DrawPiece( + ViewportPiece piece, + Matrix4x4 sceneRotation, + Matrix4x4 view, + Matrix4x4 projection) + { + if (_meshShader == null) + throw new InvalidOperationException("Viewport mesh shader is not initialized."); + + var model = piece.LocalTransform * sceneRotation; + var mvp = model * view * projection; + + _meshShader.Use(); + _meshShader.SetMatrix4(_meshMvpLocation, mvp); + piece.Mesh.Draw(); + } + + private void DrawPieceOutline( + ViewportPiece piece, + Matrix4x4 sceneRotation, + Matrix4x4 view, + Matrix4x4 projection) + { + if (_outlineShader == null) + throw new InvalidOperationException("Viewport outline shader is not initialized."); + + const float outlineScale = 1.06f; + + var model = Matrix4x4.CreateScale(outlineScale) * piece.LocalTransform * sceneRotation; + var mvp = model * view * projection; + + _outlineShader.Use(); + _outlineShader.SetMatrix4(_outlineMvpLocation, mvp); + _outlineShader.SetVector4(_outlineColorLocation, new Vector4(1.0f, 0.82f, 0.15f, 1.0f)); + + piece.Mesh.Draw(); + } + + private void EnsureInitialized() + { + if (_initialized) + return; + + CreateShaders(); + _initialized = true; + } + + private void CreateShaders() + { + const string meshVertexShaderSource = """ + #version 330 core + + layout (location = 0) in vec3 aPosition; + layout (location = 1) in vec3 aColor; + + uniform mat4 uMvp; + + out vec3 vColor; + + void main() + { + vColor = aColor; + gl_Position = uMvp * vec4(aPosition, 1.0); + } + """; + + const string meshFragmentShaderSource = """ + #version 330 core + + in vec3 vColor; + out vec4 FragColor; + + void main() + { + FragColor = vec4(vColor, 1.0); + } + """; + + const string outlineVertexShaderSource = """ + #version 330 core + + layout (location = 0) in vec3 aPosition; + + uniform mat4 uMvp; + + void main() + { + gl_Position = uMvp * vec4(aPosition, 1.0); + } + """; + + const string outlineFragmentShaderSource = """ + #version 330 core + + uniform vec4 uColor; + out vec4 FragColor; + + void main() + { + FragColor = uColor; + } + """; + + _meshShader = new ShaderProgram(_gl, meshVertexShaderSource, meshFragmentShaderSource, "Viewport mesh"); + _outlineShader = new ShaderProgram(_gl, outlineVertexShaderSource, outlineFragmentShaderSource, "Viewport outline"); + + _meshMvpLocation = _meshShader.GetUniformLocation("uMvp"); + _outlineMvpLocation = _outlineShader.GetUniformLocation("uMvp"); + _outlineColorLocation = _outlineShader.GetUniformLocation("uColor"); + } +} diff --git a/NResUI/Rendering/Viewport/ViewportScene.cs b/NResUI/Rendering/Viewport/ViewportScene.cs new file mode 100644 index 0000000..8101c6f --- /dev/null +++ b/NResUI/Rendering/Viewport/ViewportScene.cs @@ -0,0 +1,53 @@ +using NResUI.Rendering.Viewport.Meshes; + +namespace NResUI.Rendering.Viewport; + +public sealed class ViewportScene +{ + private readonly List _pieces = new(); + + public IReadOnlyList Pieces => _pieces; + + public ViewportGrid? Grid { get; set; } + + public int SelectedPieceId { get; set; } = -1; + + public ViewportPiece? SelectedPiece + { + get + { + if (SelectedPieceId < 0) + return null; + + foreach (var piece in _pieces) + { + if (piece.Id == SelectedPieceId) + return piece; + } + + return null; + } + } + + public void AddPiece(ViewportPiece piece) + { + _pieces.Add(piece); + } + + public void ClearSelection() + { + SelectedPieceId = -1; + } + + public static ViewportScene CreateDefaultCubeScene(GpuMesh cubeMesh, GpuMesh gridMesh) + { + var scene = new ViewportScene + { + Grid = new ViewportGrid(gridMesh) + }; + + scene.AddPiece(ViewportPiece.CreateUnitCube(0, "Cube", cubeMesh)); + + return scene; + } +} diff --git a/NResUI/Rendering/Viewport/ViewportSelection.cs b/NResUI/Rendering/Viewport/ViewportSelection.cs new file mode 100644 index 0000000..c4bfaad --- /dev/null +++ b/NResUI/Rendering/Viewport/ViewportSelection.cs @@ -0,0 +1,109 @@ +using System.Numerics; + +namespace NResUI.Rendering.Viewport; + +public static class ViewportSelection +{ + public static int PickPiece( + ViewportScene scene, + ViewportCamera camera, + Vector2 localMouse, + Vector2 viewportSize) + { + if (viewportSize.X <= 1.0f || viewportSize.Y <= 1.0f) + return -1; + + var aspect = viewportSize.X / viewportSize.Y; + var fovRadians = ViewportCamera.ToRadians(60.0f); + var tanHalfFov = MathF.Tan(fovRadians * 0.5f); + + var ndcX = (2.0f * localMouse.X / viewportSize.X) - 1.0f; + var ndcY = 1.0f - (2.0f * localMouse.Y / viewportSize.Y); + + var rayOriginWorld = new Vector3(0.0f, 0.0f, camera.Distance); + var rayDirectionWorld = Vector3.Normalize(new Vector3( + ndcX * aspect * tanHalfFov, + ndcY * tanHalfFov, + -1.0f)); + + var sceneRotation = camera.GetSceneRotationMatrix(); + + var bestPieceId = -1; + var bestDistance = float.PositiveInfinity; + + foreach (var piece in scene.Pieces) + { + var model = piece.LocalTransform * sceneRotation; + + if (!Matrix4x4.Invert(model, out var inverseModel)) + continue; + + var rayOriginLocal = Vector3.Transform(rayOriginWorld, inverseModel); + var rayDirectionLocal = Vector3.Normalize( + Vector3.TransformNormal(rayDirectionWorld, inverseModel)); + + if (IntersectRayAabb( + rayOriginLocal, + rayDirectionLocal, + piece.BoundsMin, + piece.BoundsMax, + out var hitDistance) && + hitDistance < bestDistance) + { + bestDistance = hitDistance; + bestPieceId = piece.Id; + } + } + + return bestPieceId; + } + + private static bool IntersectRayAabb( + Vector3 rayOrigin, + Vector3 rayDirection, + Vector3 boundsMin, + Vector3 boundsMax, + out float hitDistance) + { + hitDistance = 0.0f; + + var tMin = 0.0f; + var tMax = float.PositiveInfinity; + + if (!IntersectSlab(rayOrigin.X, rayDirection.X, boundsMin.X, boundsMax.X, ref tMin, ref tMax) || + !IntersectSlab(rayOrigin.Y, rayDirection.Y, boundsMin.Y, boundsMax.Y, ref tMin, ref tMax) || + !IntersectSlab(rayOrigin.Z, rayDirection.Z, boundsMin.Z, boundsMax.Z, ref tMin, ref tMax)) + { + return false; + } + + hitDistance = tMin; + return true; + } + + private static bool IntersectSlab( + float origin, + float direction, + float min, + float max, + ref float tMin, + ref float tMax) + { + const float epsilon = 1e-6f; + + if (MathF.Abs(direction) < epsilon) + return origin >= min && origin <= max; + + var invDirection = 1.0f / direction; + var t1 = (min - origin) * invDirection; + var t2 = (max - origin) * invDirection; + + if (t1 > t2) + (t1, t2) = (t2, t1); + + tMin = MathF.Max(tMin, t1); + tMax = MathF.Min(tMax, t2); + + return tMin <= tMax; + } +}