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;
+ }
+}