From 4d0d1aca1281e11d2944907f385c21087e6a1f9f Mon Sep 17 00:00:00 2001 From: bird_egop Date: Wed, 10 Jun 2026 02:01:41 +0300 Subject: [PATCH] gpu picking and qol --- NResUI/ImGuiUI/MeshInspectorPanel.cs | 35 ++++ NResUI/ImGuiUI/ViewportPanel.cs | 1 + .../Viewport/ViewportInputController.cs | 8 +- NResUI/Rendering/Viewport/ViewportRenderer.cs | 196 ++++++++++++++++++ .../Rendering/Viewport/ViewportSelection.cs | 105 ---------- 5 files changed, 237 insertions(+), 108 deletions(-) delete mode 100644 NResUI/Rendering/Viewport/ViewportSelection.cs diff --git a/NResUI/ImGuiUI/MeshInspectorPanel.cs b/NResUI/ImGuiUI/MeshInspectorPanel.cs index f61d317..62eef40 100644 --- a/NResUI/ImGuiUI/MeshInspectorPanel.cs +++ b/NResUI/ImGuiUI/MeshInspectorPanel.cs @@ -14,6 +14,8 @@ public sealed class MeshInspectorPanel : IImGuiPanel { private readonly MeshViewportViewModel _viewModel; private readonly HashSet _expandedPieceDetails = []; + private readonly HashSet _pieceIdsToAutoOpen = []; + private int _lastSyncedSelectedPieceId = -1; private static readonly string[] ModelStateLabels = ["Обычное", "Collapsed", "Неизвестное 2"]; private static readonly string[] LodLabels = ["LOD 0", "LOD -1", "LOD -2", "LOD -3", "LOD -4"]; @@ -116,6 +118,8 @@ public sealed class MeshInspectorPanel : IImGuiPanel .GroupBy(x => x.ParentId) .ToDictionary(x => x.Key, x => x.OrderBy(p => p.Id).ToList()); + SyncTreeExpansionToSelection(document); + // Parent -1 приходит из 0xFFFF в 0x01 и означает корневой piece. if (childrenByParent.TryGetValue(-1, out var roots)) { @@ -146,6 +150,9 @@ public sealed class MeshInspectorPanel : IImGuiPanel var childMarker = childCount > 0 ? $" | дочерних: {childCount}" : ""; var hiddenMarker = hidden ? " | скрыта" : ""; var label = $"{piece.Name}{childMarker}{hiddenMarker}##piece_{piece.Id}"; + if (_pieceIdsToAutoOpen.Remove(piece.Id)) + ImGui.SetNextItemOpen(true, ImGuiCond.Always); + var open = ImGui.TreeNodeEx(label, flags); var nodeClicked = ImGui.IsItemClicked(); if (nodeClicked) @@ -194,6 +201,34 @@ public sealed class MeshInspectorPanel : IImGuiPanel } } + private void SyncTreeExpansionToSelection(MeshDocument document) + { + var selectedPieceId = _viewModel.RenderState.SelectedPieceId; + if (selectedPieceId == _lastSyncedSelectedPieceId) + return; + + _lastSyncedSelectedPieceId = selectedPieceId; + _pieceIdsToAutoOpen.Clear(); + + if (selectedPieceId < 0) + return; + + var piecesById = document.Pieces.ToDictionary(x => x.Id); + if (!piecesById.TryGetValue(selectedPieceId, out var piece)) + return; + + // ImGui не раскрывает родителей выбранного leaf автоматически, поэтому раскрываем только путь до него. + var visited = new HashSet(); + while (piece.ParentId != -1 && piecesById.TryGetValue(piece.ParentId, out var parent)) + { + if (!visited.Add(parent.Id)) + break; + + _pieceIdsToAutoOpen.Add(parent.Id); + piece = parent; + } + } + private void DrawPieceDetailsToggle(MeshPieceInfo piece) { var detailsOpen = _expandedPieceDetails.Contains(piece.Id); diff --git a/NResUI/ImGuiUI/ViewportPanel.cs b/NResUI/ImGuiUI/ViewportPanel.cs index 1e764f3..07a26c3 100644 --- a/NResUI/ImGuiUI/ViewportPanel.cs +++ b/NResUI/ImGuiUI/ViewportPanel.cs @@ -76,6 +76,7 @@ public sealed class ViewportPanel : IImGuiPanel DrawViewportHelpOverlay(); _inputController.Handle( + _renderer, _scene, _camera, viewportHovered, diff --git a/NResUI/Rendering/Viewport/ViewportInputController.cs b/NResUI/Rendering/Viewport/ViewportInputController.cs index 0e1ab70..e4dee36 100644 --- a/NResUI/Rendering/Viewport/ViewportInputController.cs +++ b/NResUI/Rendering/Viewport/ViewportInputController.cs @@ -12,6 +12,7 @@ public sealed class ViewportInputController private bool _isPanningViewport; public void Handle( + ViewportRenderer renderer, ViewportScene scene, ViewportCamera camera, bool viewportHovered, @@ -59,11 +60,12 @@ public sealed class ViewportInputController var mouse = ImGui.GetMousePos(); var localMouse = mouse - imageMin; - scene.SelectedPieceId = ViewportSelection.PickPiece( + scene.SelectedPieceId = renderer.PickPiece( + Math.Max(1, (int)imageSize.X), + Math.Max(1, (int)imageSize.Y), scene, camera, - localMouse, - imageSize); + localMouse); } if (viewportFocused || viewportHovered) diff --git a/NResUI/Rendering/Viewport/ViewportRenderer.cs b/NResUI/Rendering/Viewport/ViewportRenderer.cs index 22726ca..031ac9d 100644 --- a/NResUI/Rendering/Viewport/ViewportRenderer.cs +++ b/NResUI/Rendering/Viewport/ViewportRenderer.cs @@ -15,16 +15,25 @@ public sealed class ViewportRenderer public GL Gl => _gl; private ShaderProgram? _meshShader; + private ShaderProgram? _pickingShader; private GpuMesh? _unitWireBoxMesh; private GpuMesh? _axesMesh; + private uint _pickingFramebuffer; + private uint _pickingColorTexture; + private uint _pickingDepthRenderbuffer; + private int _pickingWidth; + private int _pickingHeight; + private int _modelLocation; private int _mvpLocation; private int _lightDirectionLocation; private int _useTextureLocation; private int _texture0Location; private int _colorAddLocation; + private int _pickingMvpLocation; + private int _pickingColorLocation; private bool _initialized; @@ -79,6 +88,58 @@ public sealed class ViewportRenderer return _framebuffer.ColorTexture; } + /// + /// Делает GPU picking: рендерит pieces в служебный framebuffer уникальными цветами и читает один пиксель. + /// Так выбор совпадает с видимой геометрией и не требует CPU-копии вершин. + /// + public unsafe int PickPiece(int width, int height, ViewportScene scene, ViewportCamera camera, Vector2 localMouse) + { + EnsureInitialized(); + + if (_pickingShader == null || width <= 1 || height <= 1) + return -1; + + var x = Math.Clamp((int)localMouse.X, 0, width - 1); + var y = Math.Clamp(height - 1 - (int)localMouse.Y, 0, height - 1); + + EnsurePickingFramebuffer(width, height); + + try + { + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _pickingFramebuffer); + _gl.Viewport(0, 0, (uint)width, (uint)height); + _gl.Enable(EnableCap.DepthTest); + _gl.Disable(EnableCap.CullFace); + _gl.Disable(EnableCap.Blend); + _gl.Disable(EnableCap.StencilTest); + _gl.PolygonMode(TriangleFace.FrontAndBack, PolygonMode.Fill); + _gl.ClearColor(0.0f, 0.0f, 0.0f, 1.0f); + _gl.Clear((uint)(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit)); + + var aspect = width / (float)Math.Max(1, height); + var sceneRotation = camera.GetSceneRotationMatrix(); + var view = camera.GetViewMatrix(); + var projection = camera.GetProjectionMatrix(aspect); + + _pickingShader.Use(); + foreach (var piece in scene.Pieces) + DrawPieceForPicking(piece, sceneRotation, view, projection); + + Span pixel = stackalloc byte[4]; + fixed (byte* pixelPtr = pixel) + { + _gl.ReadPixels(x, y, 1, 1, PixelFormat.Rgba, PixelType.UnsignedByte, pixelPtr); + } + + var encoded = pixel[0] | (pixel[1] << 8) | (pixel[2] << 16); + return encoded == 0 ? -1 : encoded - 1; + } + finally + { + RestoreDefaultRenderTargetState(); + } + } + private void DrawGrid( ViewportScene scene, Matrix4x4 sceneRotation, @@ -187,6 +248,27 @@ public sealed class ViewportRenderer DrawMesh(mesh, model, view, projection, colorAdd); } + private void DrawPieceForPicking( + ViewportPiece piece, + Matrix4x4 sceneRotation, + Matrix4x4 view, + Matrix4x4 projection) + { + if (_pickingShader == null) + throw new InvalidOperationException("Viewport picking shader is not initialized."); + + var encodedColor = EncodePickingColor(piece.Id); + var model = piece.LocalTransform * sceneRotation; + var mvp = model * view * projection; + + _pickingShader.SetMatrix4(_pickingMvpLocation, mvp); + _pickingShader.SetVector4(_pickingColorLocation, encodedColor); + + // Рисуем теми же VAO, что и обычный viewport. Depth buffer сам выберет ближайшую piece под курсором. + foreach (var mesh in piece.Meshes) + mesh.Draw(); + } + private void DrawMesh( GpuMesh mesh, Matrix4x4 model, @@ -231,6 +313,90 @@ public sealed class ViewportRenderer return Matrix4x4.CreateScale(size * 0.5f) * Matrix4x4.CreateTranslation(center); } + private unsafe void EnsurePickingFramebuffer(int width, int height) + { + width = Math.Max(1, width); + height = Math.Max(1, height); + + if (_pickingFramebuffer != 0 && _pickingWidth == width && _pickingHeight == height) + return; + + _pickingWidth = width; + _pickingHeight = height; + + if (_pickingFramebuffer == 0) + { + _pickingFramebuffer = _gl.GenFramebuffer(); + _pickingColorTexture = _gl.GenTexture(); + _pickingDepthRenderbuffer = _gl.GenRenderbuffer(); + } + + _gl.BindTexture(TextureTarget.Texture2D, _pickingColorTexture); + _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.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); + + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _pickingDepthRenderbuffer); + _gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer, InternalFormat.DepthComponent24, (uint)width, (uint)height); + + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _pickingFramebuffer); + _gl.FramebufferTexture2D( + FramebufferTarget.Framebuffer, + FramebufferAttachment.ColorAttachment0, + TextureTarget.Texture2D, + _pickingColorTexture, + 0); + _gl.FramebufferRenderbuffer( + FramebufferTarget.Framebuffer, + FramebufferAttachment.DepthAttachment, + RenderbufferTarget.Renderbuffer, + _pickingDepthRenderbuffer); + + var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); + if (status != GLEnum.FramebufferComplete) + throw new InvalidOperationException($"Viewport picking framebuffer is incomplete: {status}"); + + _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); + _gl.BindTexture(TextureTarget.Texture2D, 0); + _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0); + } + + private void RestoreDefaultRenderTargetState() + { + _gl.PolygonMode(TriangleFace.FrontAndBack, PolygonMode.Fill); + _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); + } + + private static Vector4 EncodePickingColor(int pieceId) + { + var encoded = pieceId + 1; + if (encoded <= 0 || encoded > 0x00FFFFFF) + return Vector4.Zero; + + var r = (encoded & 0x0000FF) / 255.0f; + var g = ((encoded >> 8) & 0x0000FF) / 255.0f; + var b = ((encoded >> 16) & 0x0000FF) / 255.0f; + return new Vector4(r, g, b, 1.0f); + } + private void EnsureInitialized() { if (_initialized) @@ -304,7 +470,34 @@ public sealed class ViewportRenderer } """; + const string pickingVertexShaderSource = """ + #version 330 core + + layout (location = 0) in vec3 aPosition; + + uniform mat4 uMvp; + + void main() + { + gl_Position = uMvp * vec4(aPosition, 1.0); + } + """; + + const string pickingFragmentShaderSource = """ + #version 330 core + + uniform vec4 uPickColor; + out vec4 FragColor; + + void main() + { + FragColor = uPickColor; + } + """; + _meshShader = new ShaderProgram(_gl, meshVertexShaderSource, meshFragmentShaderSource, "Viewport mesh"); + _pickingShader = new ShaderProgram(_gl, pickingVertexShaderSource, pickingFragmentShaderSource, + "Viewport picking"); _modelLocation = _meshShader.GetUniformLocation("uModel"); _mvpLocation = _meshShader.GetUniformLocation("uMvp"); @@ -312,5 +505,8 @@ public sealed class ViewportRenderer _useTextureLocation = _meshShader.GetUniformLocation("uUseTexture"); _texture0Location = _meshShader.GetUniformLocation("uTexture0"); _colorAddLocation = _meshShader.GetUniformLocation("uColorAdd"); + + _pickingMvpLocation = _pickingShader.GetUniformLocation("uMvp"); + _pickingColorLocation = _pickingShader.GetUniformLocation("uPickColor"); } } diff --git a/NResUI/Rendering/Viewport/ViewportSelection.cs b/NResUI/Rendering/Viewport/ViewportSelection.cs deleted file mode 100644 index cef6c43..0000000 --- a/NResUI/Rendering/Viewport/ViewportSelection.cs +++ /dev/null @@ -1,105 +0,0 @@ -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(ViewportCamera.FieldOfViewDegrees); - 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 = camera.GetEyePosition(); - var rayDirectionWorld = Vector3.Normalize( - camera.GetForwardDirection() + - camera.GetRightDirection() * (ndcX * aspect * tanHalfFov) + - camera.GetUpDirection() * (ndcY * tanHalfFov)); - - var bestPieceId = -1; - var bestDistance = float.PositiveInfinity; - - foreach (var piece in scene.Pieces) - { - if (!Matrix4x4.Invert(piece.LocalTransform, 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; - } -}