From 49d9e237384b333b8c58de08c94509e7ca56d1ed Mon Sep 17 00:00:00 2001 From: bird_egop Date: Fri, 5 Jun 2026 21:12:25 +0300 Subject: [PATCH] msh viewport initial --- NResUI/ImGuiUI/ViewportPanel.cs | 65 +++++ NResUI/NResUI.csproj | 1 + .../Viewport/Meshes/PrimitiveMeshes.cs | 9 + .../Viewport/Msh/MshViewportLoadResult.cs | 31 +++ .../Viewport/Msh/MshViewportLoader.cs | 231 ++++++++++++++++++ NResUI/Rendering/Viewport/ViewportPiece.cs | 18 +- .../Viewport/ViewportPieceDebugInfo.cs | 13 + NResUI/Rendering/Viewport/ViewportRenderer.cs | 2 + NResUI/Rendering/Viewport/ViewportScene.cs | 13 + 9 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 NResUI/Rendering/Viewport/Msh/MshViewportLoadResult.cs create mode 100644 NResUI/Rendering/Viewport/Msh/MshViewportLoader.cs create mode 100644 NResUI/Rendering/Viewport/ViewportPieceDebugInfo.cs diff --git a/NResUI/ImGuiUI/ViewportPanel.cs b/NResUI/ImGuiUI/ViewportPanel.cs index 405d1fb..523a1b0 100644 --- a/NResUI/ImGuiUI/ViewportPanel.cs +++ b/NResUI/ImGuiUI/ViewportPanel.cs @@ -2,7 +2,9 @@ using System.Numerics; using ImGuiNET; using NResUI.Abstractions; using NResUI.Rendering.Viewport; +using NativeFileDialogSharp; using NResUI.Rendering.Viewport.Meshes; +using NResUI.Rendering.Viewport.Msh; using Silk.NET.OpenGL; using Silk.NET.Windowing; @@ -15,6 +17,9 @@ public sealed class ViewportPanel : IImGuiPanel private readonly ViewportCamera _camera = new(); private readonly ViewportInputController _inputController = new(); + private string? _loadedModelPath; + private string? _loadError; + public ViewportPanel(GL gl, IWindow window) { var cubeMesh = PrimitiveMeshes.CreateCube(gl); @@ -32,6 +37,7 @@ public sealed class ViewportPanel : IImGuiPanel return; } + DrawModelControls(); DrawSelectionStatus(); DrawViewportToolbar(); DrawDebugControls(); @@ -73,13 +79,72 @@ public sealed class ViewportPanel : IImGuiPanel ImGui.End(); } + + private void DrawModelControls() + { + if (ImGui.Button("Open MSH in viewport")) + { + var result = Dialog.FileOpen("msh"); + if (result.IsOk) + LoadMsh(result.Path); + } + + ImGui.SameLine(); + + if (ImGui.Button("Reset cube")) + { + var cubeMesh = PrimitiveMeshes.CreateCube(_renderer.Gl); + _scene.ReplacePieces(new[] { ViewportPiece.CreateUnitCube(0, "Cube", cubeMesh) }); + _loadedModelPath = null; + _loadError = null; + _camera.Reset(); + } + + if (_loadedModelPath != null) + ImGui.TextDisabled($"Model: {Path.GetFileName(_loadedModelPath)}"); + + if (_loadError != null) + ImGui.TextColored(new Vector4(1.0f, 0.35f, 0.25f, 1.0f), $"MSH load failed: {_loadError}"); + } + + private void LoadMsh(string path) + { + var loadResult = MshViewportLoader.LoadFromFile(_renderer.Gl, path); + if (!loadResult.IsSuccess) + { + _loadError = loadResult.Error ?? "Unknown error."; + return; + } + + _scene.ReplacePieces(loadResult.Pieces); + _loadedModelPath = loadResult.SourcePath; + _loadError = null; + + if (_scene.TryGetSceneWorldBounds(out var bounds)) + _camera.FrameBounds(bounds.Min, bounds.Max); + else + _camera.Reset(); + } + private void DrawSelectionStatus() { var selectedPiece = _scene.SelectedPiece; if (selectedPiece != null) + { ImGui.Text($"Selected: {selectedPiece.Name}"); + + var debugInfo = selectedPiece.DebugInfo; + if (debugInfo != null) + { + ImGui.TextDisabled( + $"{debugInfo.SourceKind} | parent {debugInfo.SourceParentIndex} | slot {debugInfo.GeometrySlotIndex} | " + + $"flags 0x{debugInfo.Msh01Flags:X4} | batches {debugInfo.BatchCount} | tris {debugInfo.TriangleCount}"); + } + } else + { ImGui.TextDisabled("Selected: none"); + } } private void DrawViewportToolbar() diff --git a/NResUI/NResUI.csproj b/NResUI/NResUI.csproj index 40e7df5..345f3d7 100644 --- a/NResUI/NResUI.csproj +++ b/NResUI/NResUI.csproj @@ -20,6 +20,7 @@ + diff --git a/NResUI/Rendering/Viewport/Meshes/PrimitiveMeshes.cs b/NResUI/Rendering/Viewport/Meshes/PrimitiveMeshes.cs index 91ee0e7..ca20e1d 100644 --- a/NResUI/Rendering/Viewport/Meshes/PrimitiveMeshes.cs +++ b/NResUI/Rendering/Viewport/Meshes/PrimitiveMeshes.cs @@ -185,6 +185,15 @@ public static unsafe class PrimitiveMeshes return CreateIndexedMesh(gl, vertices, indices, PrimitiveType.Lines); } + public static GpuMesh CreateColoredIndexedMesh( + GL gl, + IReadOnlyList vertices, + IReadOnlyList indices, + PrimitiveType primitiveType = PrimitiveType.Triangles) + { + return CreateIndexedMesh(gl, vertices.ToArray(), indices.ToArray(), primitiveType); + } + private static GpuMesh CreateIndexedMesh( GL gl, float[] vertices, diff --git a/NResUI/Rendering/Viewport/Msh/MshViewportLoadResult.cs b/NResUI/Rendering/Viewport/Msh/MshViewportLoadResult.cs new file mode 100644 index 0000000..8177542 --- /dev/null +++ b/NResUI/Rendering/Viewport/Msh/MshViewportLoadResult.cs @@ -0,0 +1,31 @@ +namespace NResUI.Rendering.Viewport.Msh; + +public sealed class MshViewportLoadResult +{ + public bool IsSuccess { get; } + public string? Error { get; } + public string? SourcePath { get; } + public IReadOnlyList Pieces { get; } + + private MshViewportLoadResult( + bool isSuccess, + string? error, + string? sourcePath, + IReadOnlyList pieces) + { + IsSuccess = isSuccess; + Error = error; + SourcePath = sourcePath; + Pieces = pieces; + } + + public static MshViewportLoadResult Success(string sourcePath, IReadOnlyList pieces) + { + return new MshViewportLoadResult(true, null, sourcePath, pieces); + } + + public static MshViewportLoadResult Failure(string error, string? sourcePath = null) + { + return new MshViewportLoadResult(false, error, sourcePath, Array.Empty()); + } +} diff --git a/NResUI/Rendering/Viewport/Msh/MshViewportLoader.cs b/NResUI/Rendering/Viewport/Msh/MshViewportLoader.cs new file mode 100644 index 0000000..f786e68 --- /dev/null +++ b/NResUI/Rendering/Viewport/Msh/MshViewportLoader.cs @@ -0,0 +1,231 @@ +using System.Numerics; +using MshLib; +using NResLib; +using NResUI.Rendering.Viewport.Meshes; +using Silk.NET.OpenGL; + +namespace NResUI.Rendering.Viewport.Msh; + +public static class MshViewportLoader +{ + private const int DefaultModelState = 0; + private const int DefaultLod = 0; + + public static MshViewportLoadResult LoadFromFile(GL gl, string path) + { + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var parseResult = NResParser.ReadFile(fs); + if (parseResult.Archive == null) + return MshViewportLoadResult.Failure(parseResult.Error ?? "Failed to parse MSH/NRes file.", path); + + var archive = parseResult.Archive; + var meshType = MshConverter.DetectMeshType(archive); + if (meshType != MshType.Model) + { + return MshViewportLoadResult.Failure( + $"Only normal MSH models are supported for this viewport pass. Detected: {meshType}.", + path); + } + + fs.Seek(0, SeekOrigin.Begin); + var pieces = LoadModelPieces(gl, fs, archive); + if (pieces.Count == 0) + return MshViewportLoadResult.Failure("MSH was parsed, but no renderable geometry pieces were found.", path); + + return MshViewportLoadResult.Success(path, pieces); + } + catch (Exception ex) + { + return MshViewportLoadResult.Failure(ex.Message, path); + } + } + + private static List LoadModelPieces(GL gl, FileStream fs, NResArchive archive) + { + var nodes = Msh0x01.ReadComponent(fs, archive); + var geometry = Msh0x02.ReadComponent(fs, archive); + var positions = Msh0x03.ReadComponent(fs, archive); + var indices = Msh0x06.ReadComponent(fs, archive); + var batches = Msh0x0D.ReadComponent(fs, archive); + var names = TryReadNames(fs, archive); + + var pieces = new List(); + + for (var nodeIndex = 0; nodeIndex < nodes.Nodes.Count; nodeIndex++) + { + var node = nodes.Nodes[nodeIndex]; + var slotIndex = node.ResolveSlotIndex(DefaultModelState, DefaultLod); + if (slotIndex == ushort.MaxValue) + continue; + + if (slotIndex >= geometry.Slots.Count) + continue; + + var slot = geometry.Slots[slotIndex]; + if (!slot.HasBatches) + continue; + + var meshBuildResult = BuildPieceMesh(gl, nodeIndex, slot, positions, indices, batches); + if (meshBuildResult == null) + continue; + + var name = ResolvePieceName(names, nodeIndex); + var parentIndex = node.ParentIndexOrMinusOne; + + pieces.Add(new ViewportPiece( + id: nodeIndex, + name: name, + mesh: meshBuildResult.Mesh, + localTransform: Matrix4x4.Identity, + boundsMin: meshBuildResult.BoundsMin, + boundsMax: meshBuildResult.BoundsMax, + debugInfo: new ViewportPieceDebugInfo + { + SourceKind = "MSH 0x01 piece", + SourcePieceIndex = nodeIndex, + SourceParentIndex = parentIndex, + GeometrySlotIndex = slotIndex, + Msh01Flags = (uint)node.Flags, + BatchCount = meshBuildResult.BatchCount, + TriangleCount = meshBuildResult.TriangleCount + })); + } + + return pieces; + } + + private static PieceMeshBuildResult? BuildPieceMesh( + GL gl, + int nodeIndex, + Msh0x02.GeometrySlot slot, + IReadOnlyList positions, + IReadOnlyList indices, + IReadOnlyList batches) + { + var vertices = new List(); + var outIndices = new List(); + var color = PickDebugColor(nodeIndex); + + var batchCount = 0; + var triangleCount = 0; + + var boundsMin = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity); + var boundsMax = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity); + + for (var batchIndex = slot.BatchStart0x0D; batchIndex < slot.BatchEndExclusive0x0D; batchIndex++) + { + if (batchIndex < 0 || batchIndex >= batches.Count) + continue; + + var batch = batches[batchIndex]; + if (batch.IndexStart0x06 + batch.IndexCount0x06 > indices.Count) + continue; + + batchCount++; + + for (var i = 0; i + 2 < batch.IndexCount0x06; i += 3) + { + var indexBase = (int)batch.IndexStart0x06 + i; + var vertexIndex0 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 0]); + var vertexIndex1 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 1]); + var vertexIndex2 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 2]); + + if (!IsValidTriangle(positions.Count, vertexIndex0, vertexIndex1, vertexIndex2)) + continue; + + AddTriangleVertex(positions[vertexIndex0], color, vertices, outIndices, ref boundsMin, ref boundsMax); + AddTriangleVertex(positions[vertexIndex1], color, vertices, outIndices, ref boundsMin, ref boundsMax); + AddTriangleVertex(positions[vertexIndex2], color, vertices, outIndices, ref boundsMin, ref boundsMax); + triangleCount++; + } + } + + if (triangleCount == 0) + return null; + + var mesh = PrimitiveMeshes.CreateColoredIndexedMesh(gl, vertices, outIndices, PrimitiveType.Triangles); + return new PieceMeshBuildResult(mesh, boundsMin, boundsMax, batchCount, triangleCount); + } + + private static void AddTriangleVertex( + Common.Vector3 position, + Vector3 color, + List vertices, + List indices, + ref Vector3 boundsMin, + ref Vector3 boundsMax) + { + var vertexIndex = (uint)(vertices.Count / 6); + + vertices.Add(position.X); + vertices.Add(position.Y); + vertices.Add(position.Z); + vertices.Add(color.X); + vertices.Add(color.Y); + vertices.Add(color.Z); + + indices.Add(vertexIndex); + + var p = new Vector3(position.X, position.Y, position.Z); + boundsMin = Vector3.Min(boundsMin, p); + boundsMax = Vector3.Max(boundsMax, p); + } + + private static bool IsValidTriangle(int vertexCount, int vertexIndex0, int vertexIndex1, int vertexIndex2) + { + return IsValidVertexIndex(vertexCount, vertexIndex0) && + IsValidVertexIndex(vertexCount, vertexIndex1) && + IsValidVertexIndex(vertexCount, vertexIndex2); + } + + private static bool IsValidVertexIndex(int vertexCount, int vertexIndex) + { + return vertexIndex >= 0 && vertexIndex < vertexCount; + } + + private static List TryReadNames(FileStream fs, NResArchive archive) + { + try + { + return Msh0x0A.ReadComponent(fs, archive); + } + catch + { + return new List(); + } + } + + private static string ResolvePieceName(IReadOnlyList names, int nodeIndex) + { + if (nodeIndex >= 0 && nodeIndex < names.Count && !string.IsNullOrWhiteSpace(names[nodeIndex])) + return $"{nodeIndex}: {names[nodeIndex]}"; + + return $"{nodeIndex}: piece_{nodeIndex:D3}"; + } + + private static Vector3 PickDebugColor(int index) + { + ReadOnlySpan palette = + [ + new Vector3(0.90f, 0.36f, 0.30f), + new Vector3(0.30f, 0.68f, 0.95f), + new Vector3(0.42f, 0.82f, 0.42f), + new Vector3(0.95f, 0.78f, 0.32f), + new Vector3(0.78f, 0.48f, 0.95f), + new Vector3(0.36f, 0.86f, 0.78f), + new Vector3(0.95f, 0.55f, 0.78f), + new Vector3(0.72f, 0.72f, 0.72f), + ]; + + return palette[index % palette.Length]; + } + + private sealed record PieceMeshBuildResult( + GpuMesh Mesh, + Vector3 BoundsMin, + Vector3 BoundsMax, + int BatchCount, + int TriangleCount); +} diff --git a/NResUI/Rendering/Viewport/ViewportPiece.cs b/NResUI/Rendering/Viewport/ViewportPiece.cs index 2b17203..f499f80 100644 --- a/NResUI/Rendering/Viewport/ViewportPiece.cs +++ b/NResUI/Rendering/Viewport/ViewportPiece.cs @@ -14,13 +14,16 @@ public sealed class ViewportPiece public Vector3 BoundsMin { get; } public Vector3 BoundsMax { get; } + public ViewportPieceDebugInfo? DebugInfo { get; } + public ViewportPiece( int id, string name, GpuMesh mesh, Matrix4x4 localTransform, Vector3 boundsMin, - Vector3 boundsMax) + Vector3 boundsMax, + ViewportPieceDebugInfo? debugInfo = null) { Id = id; Name = name; @@ -28,6 +31,7 @@ public sealed class ViewportPiece LocalTransform = localTransform; BoundsMin = boundsMin; BoundsMax = boundsMax; + DebugInfo = debugInfo; } public static ViewportPiece CreateUnitCube(int id, string name, GpuMesh mesh) @@ -38,6 +42,16 @@ public sealed class ViewportPiece mesh, Matrix4x4.Identity, new Vector3(-1.0f, -1.0f, -1.0f), - new Vector3(1.0f, 1.0f, 1.0f)); + new Vector3(1.0f, 1.0f, 1.0f), + new ViewportPieceDebugInfo + { + SourceKind = "Debug primitive", + SourcePieceIndex = id, + SourceParentIndex = -1, + GeometrySlotIndex = 0, + Msh01Flags = 0, + BatchCount = 1, + TriangleCount = 12 + }); } } diff --git a/NResUI/Rendering/Viewport/ViewportPieceDebugInfo.cs b/NResUI/Rendering/Viewport/ViewportPieceDebugInfo.cs new file mode 100644 index 0000000..dd1c07c --- /dev/null +++ b/NResUI/Rendering/Viewport/ViewportPieceDebugInfo.cs @@ -0,0 +1,13 @@ +namespace NResUI.Rendering.Viewport; + +public sealed class ViewportPieceDebugInfo +{ + public int SourcePieceIndex { get; init; } + public int SourceParentIndex { get; init; } + public int GeometrySlotIndex { get; init; } + public uint Msh01Flags { get; init; } + public int BatchCount { get; init; } + public int TriangleCount { get; init; } + + public string SourceKind { get; init; } = "MSH"; +} diff --git a/NResUI/Rendering/Viewport/ViewportRenderer.cs b/NResUI/Rendering/Viewport/ViewportRenderer.cs index cb10bdc..913f448 100644 --- a/NResUI/Rendering/Viewport/ViewportRenderer.cs +++ b/NResUI/Rendering/Viewport/ViewportRenderer.cs @@ -12,6 +12,8 @@ public sealed class ViewportRenderer private readonly IWindow _window; private readonly MsaaFramebuffer _framebuffer; + public GL Gl => _gl; + private ShaderProgram? _meshShader; private ShaderProgram? _outlineShader; diff --git a/NResUI/Rendering/Viewport/ViewportScene.cs b/NResUI/Rendering/Viewport/ViewportScene.cs index 8fe8b57..937b8d1 100644 --- a/NResUI/Rendering/Viewport/ViewportScene.cs +++ b/NResUI/Rendering/Viewport/ViewportScene.cs @@ -37,6 +37,19 @@ public sealed class ViewportScene _pieces.Add(piece); } + public void ClearPieces() + { + _pieces.Clear(); + ClearSelection(); + } + + public void ReplacePieces(IEnumerable pieces) + { + _pieces.Clear(); + _pieces.AddRange(pieces); + ClearSelection(); + } + public void ClearSelection() { SelectedPieceId = -1;