vibecoded inspector

This commit is contained in:
bird_egop
2026-06-09 01:46:57 +03:00
parent 70a5d0ef69
commit 325970374a
25 changed files with 1944 additions and 649 deletions
@@ -0,0 +1,269 @@
using System.Numerics;
using MshLib;
using NResLib;
using NResUI.Rendering.Inspection;
using NResUI.Rendering.Materials;
using NResUI.Rendering.Viewport.Msh;
namespace NResUI.Rendering.Import.Msh;
public static class MshMeshDocumentImporter
{
private const int DefaultModelState = 0;
private const int DefaultLod = 0;
/// <summary>
/// Читает MSH/NRes с диска и строит CPU-документ для инспектора.
/// OpenGL здесь намеренно не используется: этот слой должен быть проверяемым без viewport.
/// </summary>
public static MeshDocumentLoadResult LoadFromFile(string path)
{
var warnings = new List<string>();
try
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var parseResult = NResParser.ReadFile(fs);
if (parseResult.Archive == null)
return MeshDocumentLoadResult.Failure(parseResult.Error ?? "Failed to parse MSH/NRes file.", path);
var archive = parseResult.Archive;
var meshType = MshConverter.DetectMeshType(archive);
if (meshType != MshType.Model)
{
return MeshDocumentLoadResult.Failure(
$"Only normal MSH models are supported by the mesh inspector. Detected: {meshType}.",
path);
}
fs.Seek(0, SeekOrigin.Begin);
var document = LoadModelDocument(fs, archive, path, warnings);
return MeshDocumentLoadResult.Success(document);
}
catch (Exception ex)
{
return MeshDocumentLoadResult.Failure(ex.Message, path);
}
}
private static MeshDocument LoadModelDocument(
FileStream fs,
NResArchive archive,
string path,
List<string> warnings)
{
var nodes = Msh0x01.ReadComponent(fs, archive);
var geometry = Msh0x02.ReadComponent(fs, archive);
var positions = Msh0x03.ReadComponent(fs, archive);
var normals = TryReadNormals(fs, archive, warnings);
var uvs = TryReadUvs(fs, archive, warnings);
var indices = Msh0x06.ReadComponent(fs, archive);
var batches = Msh0x0D.ReadComponent(fs, archive);
var animationDescriptors = Msh0x08.ReadComponent(fs, archive);
var restPoses = MshRestPoseBuilder.BuildRestPose(nodes, animationDescriptors);
var names = TryReadNames(fs, archive, warnings);
var weaMatch = WeaResourceResolver.TryLoadMatchingWeaForMsh(path, warnings);
if (weaMatch != null)
warnings.Add($"Loaded WEA '{Path.GetFileName(weaMatch.Path)}' ({weaMatch.Reason})");
var materialNamesById = weaMatch?.File.Materials.ToDictionary(x => x.Id, x => x.Name)
?? new Dictionary<int, string>();
var pieces = new List<MeshPieceInfo>(nodes.Nodes.Count);
for (var nodeIndex = 0; nodeIndex < nodes.Nodes.Count; nodeIndex++)
{
var node = nodes.Nodes[nodeIndex];
var restPose = restPoses[nodeIndex];
var activeSlotIndex = node.ResolveSlotIndex(DefaultModelState, DefaultLod);
var boundsMin = Vector3.Zero;
var boundsMax = Vector3.Zero;
if (activeSlotIndex != ushort.MaxValue && activeSlotIndex < geometry.Slots.Count)
{
var slot = geometry.Slots[activeSlotIndex];
boundsMin = ToNumericsVector3(slot.LocalMinimum);
boundsMax = ToNumericsVector3(slot.LocalMaximum);
}
pieces.Add(new MeshPieceInfo
{
Id = nodeIndex,
Name = ResolvePieceName(names, nodeIndex),
ParentId = restPose.ParentIndex,
Flags = node.Flags,
GeometrySlotsByStateAndLod = node.Msh02SlotIndicesByStateAndLOD.ToArray(),
Batches = BuildPieceBatchInfo(node, geometry, batches, indices.Count, materialNamesById, warnings),
LocalTransform = restPose.LocalTransform,
MeshSpaceTransform = restPose.MeshSpaceTransform,
BoundsMin = boundsMin,
BoundsMax = boundsMax,
FallbackKeyframeIndex = restPose.FallbackKeyframeIndex,
HasRestPose = restPose.HasFallbackPose
});
}
return new MeshDocument
{
SourcePath = path,
MeshType = MshType.Model,
Pieces = pieces,
Materials = materialNamesById
.Select(x => new MeshMaterialInfo { Id = x.Key, Name = x.Value })
.OrderBy(x => x.Id)
.ToList(),
Warnings = warnings,
MshModelGeometry = new MshModelGeometry
{
Nodes = nodes,
GeometrySlots = geometry,
Positions = positions,
Normals = normals,
Uvs = uvs,
Indices = indices,
Batches = batches
}
};
}
/// <summary>
/// Собирает batch metadata для всех state/LOD слотов piece.
/// Дубликаты batch убираются, потому что разные состояния могут ссылаться на один и тот же geometry slot.
/// </summary>
private static IReadOnlyList<MeshBatchInfo> BuildPieceBatchInfo(
Msh0x01.Node node,
Msh0x02.Msh0x02Component geometry,
IReadOnlyList<Msh0x0D.Batch> batches,
int indexCount,
IReadOnlyDictionary<int, string> materialNamesById,
ICollection<string> documentWarnings)
{
var result = new List<MeshBatchInfo>();
var seenBatchIndices = new HashSet<int>();
foreach (var slotIndex in node.Msh02SlotIndicesByStateAndLOD.Distinct())
{
if (slotIndex == ushort.MaxValue)
continue;
if (slotIndex >= geometry.Slots.Count)
{
documentWarnings.Add($"Geometry slot {slotIndex} is out of range.");
continue;
}
var slot = geometry.Slots[slotIndex];
for (var batchIndex = slot.BatchStart0x0D; batchIndex < slot.BatchEndExclusive0x0D; batchIndex++)
{
if (batchIndex < 0 || batchIndex >= batches.Count || !seenBatchIndices.Add(batchIndex))
continue;
var batch = batches[batchIndex];
var warnings = new List<string>();
if (batch.IndexStart0x06 + batch.IndexCount0x06 > indexCount)
warnings.Add("Index range exceeds MSH 0x06 index buffer.");
var materialName = materialNamesById.GetValueOrDefault(batch.MaterialIndexLo);
if (materialName == "B_PLT_06")
{
_ = 5;
}
result.Add(new MeshBatchInfo
{
BatchIndex = batchIndex,
MaterialId = batch.MaterialIndexLo,
MaterialName = materialName,
Flags = batch.Flags,
IndexStart = batch.IndexStart0x06,
IndexCount = batch.IndexCount0x06,
BaseVertex = batch.BaseVertex0x03,
VertexCount = batch.VertexCount0x03,
TriangleCount = batch.IndexCount0x06 / 3,
Warnings = warnings
});
}
}
return result;
}
private static List<string> TryReadNames(FileStream fs, NResArchive archive, ICollection<string> warnings)
{
try
{
return Msh0x0A.ReadComponent(fs, archive);
}
catch (Exception ex)
{
warnings.Add($"MSH 0x0A names are unavailable: {ex.Message}");
return [];
}
}
private static List<Msh04Normal> TryReadNormals(FileStream fs, NResArchive archive, ICollection<string> warnings)
{
try
{
return Msh0x04.ReadComponent(fs, archive);
}
catch (Exception ex)
{
warnings.Add($"MSH 0x04 normals are unavailable: {ex.Message}");
return [];
}
}
private static List<Msh05Uv> TryReadUvs(FileStream fs, NResArchive archive, ICollection<string> warnings)
{
try
{
return Msh0x05.ReadComponent(fs, archive);
}
catch (Exception ex)
{
warnings.Add($"MSH 0x05 UVs are unavailable: {ex.Message}");
return [];
}
}
private static string ResolvePieceName(IReadOnlyList<string> 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 ToNumericsVector3(Common.Vector3 position)
{
return new Vector3(position.X, position.Y, position.Z);
}
}
public sealed class MeshDocumentLoadResult
{
public bool IsSuccess { get; }
public string? Error { get; }
public string? SourcePath { get; }
public MeshDocument? Document { get; }
private MeshDocumentLoadResult(bool isSuccess, string? error, string? sourcePath, MeshDocument? document)
{
IsSuccess = isSuccess;
Error = error;
SourcePath = sourcePath;
Document = document;
}
public static MeshDocumentLoadResult Success(MeshDocument document)
{
return new MeshDocumentLoadResult(true, null, document.SourcePath, document);
}
public static MeshDocumentLoadResult Failure(string error, string? sourcePath = null)
{
return new MeshDocumentLoadResult(false, error, sourcePath, null);
}
}
@@ -0,0 +1,325 @@
using System.Numerics;
using MshLib;
using NResUI.Rendering.Inspection;
using NResUI.Rendering.Materials;
using NResUI.Rendering.Viewport;
using NResUI.Rendering.Viewport.Meshes;
using Silk.NET.OpenGL;
namespace NResUI.Rendering.Import.Msh;
public static class MshViewportMeshFactory
{
/// <summary>
/// Строит OpenGL-меши из уже разобранного MeshDocument.
/// State/LOD выбираются отдельно для каждого piece через 0x01, а не через общий список geometry slots.
/// </summary>
public static IReadOnlyList<ViewportPiece> BuildViewportPieces(
GL gl,
MeshDocument document,
MeshRenderState renderState,
ViewportMaterialSet materialSet)
{
if (document.MshModelGeometry == null)
return [];
var result = new List<ViewportPiece>();
var modelGeometry = document.MshModelGeometry;
var mshToViewportTransform = Matrix4x4.CreateRotationX(-MathF.PI * 0.5f);
foreach (var pieceInfo in document.Pieces)
{
if (!renderState.IsPieceVisible(pieceInfo.Id))
continue;
var (pieceState, pieceLod) = ResolvePieceStateLod(document, pieceInfo, renderState);
var slotIndex = pieceInfo.ResolveSlotIndex(pieceState, pieceLod);
if (slotIndex == ushort.MaxValue || slotIndex >= modelGeometry.GeometrySlots.Slots.Count)
{
if (renderState.ShowEmptyPieces)
AddEmptyPieceMarker(gl, result, pieceInfo, mshToViewportTransform, "No geometry slot for selected state/LOD");
continue;
}
var slot = modelGeometry.GeometrySlots.Slots[slotIndex];
if (!slot.HasBatches)
{
if (renderState.ShowEmptyPieces)
AddEmptyPieceMarker(gl, result, pieceInfo, mshToViewportTransform, "Geometry slot has no batches", slotIndex);
continue;
}
var meshBuildResult = BuildPieceMeshes(
gl,
pieceInfo.Id,
slot,
modelGeometry,
renderState,
materialSet);
if (meshBuildResult == null)
{
if (renderState.ShowEmptyPieces)
AddEmptyPieceMarker(gl, result, pieceInfo, mshToViewportTransform, "Geometry slot produced no valid triangles", slotIndex);
continue;
}
result.Add(new ViewportPiece(
id: pieceInfo.Id,
name: pieceInfo.Name,
meshes: meshBuildResult.Meshes,
localTransform: pieceInfo.MeshSpaceTransform * mshToViewportTransform,
boundsMin: meshBuildResult.BoundsMin,
boundsMax: meshBuildResult.BoundsMax,
debugInfo: new ViewportPieceDebugInfo
{
SourceKind = "MSH 0x01 piece",
SourcePieceIndex = pieceInfo.Id,
SourceParentIndex = pieceInfo.ParentId,
GeometrySlotIndex = slotIndex,
Msh01Flags = (uint)pieceInfo.Flags,
BatchCount = meshBuildResult.BatchCount,
TriangleCount = meshBuildResult.TriangleCount,
FallbackKeyframeIndex = pieceInfo.FallbackKeyframeIndex,
HasRestPose = pieceInfo.HasRestPose
},
sourceBatchIndices: meshBuildResult.BatchIndices));
}
return result;
}
private static (int State, int Lod) ResolvePieceStateLod(
MeshDocument document,
MeshPieceInfo pieceInfo,
MeshRenderState renderState)
{
if (renderState.PieceStateOverrides.ContainsKey(pieceInfo.Id) ||
renderState.PieceLodOverrides.ContainsKey(pieceInfo.Id))
{
return (renderState.GetPieceState(pieceInfo.Id), renderState.GetPieceLod(pieceInfo.Id));
}
// Без явного override piece использует первую реально присутствующую пару state/LOD из своей 0x01 таблицы.
for (var state = 0; state < document.ModelStateCount; state++)
{
for (var lod = 0; lod < document.LodCount; lod++)
{
var slot = pieceInfo.ResolveSlotIndex(state, lod);
if (slot != ushort.MaxValue &&
(document.MshModelGeometry == null || slot < document.MshModelGeometry.GeometrySlots.Slots.Count))
{
return (state, lod);
}
}
}
return (0, 0);
}
private static void AddEmptyPieceMarker(
GL gl,
List<ViewportPiece> pieces,
MeshPieceInfo pieceInfo,
Matrix4x4 mshToViewportTransform,
string reason,
int geometrySlotIndex = -1)
{
// Пустые pieces все равно важны: это могут быть сокеты, логические узлы или placeholders поврежденных состояний.
var marker = PrimitiveMeshes.CreateAxes(gl, 0.35f);
pieces.Add(new ViewportPiece(
id: pieceInfo.Id,
name: pieceInfo.Name,
mesh: marker,
localTransform: pieceInfo.MeshSpaceTransform * mshToViewportTransform,
boundsMin: new Vector3(-0.35f, -0.35f, -0.35f),
boundsMax: new Vector3(0.35f, 0.35f, 0.35f),
debugInfo: new ViewportPieceDebugInfo
{
SourceKind = reason,
SourcePieceIndex = pieceInfo.Id,
SourceParentIndex = pieceInfo.ParentId,
GeometrySlotIndex = geometrySlotIndex,
Msh01Flags = (uint)pieceInfo.Flags,
BatchCount = 0,
TriangleCount = 0,
FallbackKeyframeIndex = pieceInfo.FallbackKeyframeIndex,
HasRestPose = pieceInfo.HasRestPose
}));
}
private static PieceMeshBuildResult? BuildPieceMeshes(
GL gl,
int pieceId,
Msh0x02.GeometrySlot slot,
MshModelGeometry modelGeometry,
MeshRenderState renderState,
ViewportMaterialSet materialSet)
{
var meshes = new List<GpuMesh>();
var batchIndices = new List<int>();
var debugColor = PickDebugColor(pieceId);
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 >= modelGeometry.Batches.Count || !renderState.IsBatchVisible(batchIndex))
continue;
var batch = modelGeometry.Batches[batchIndex];
if (batch.IndexStart0x06 + batch.IndexCount0x06 > modelGeometry.Indices.Count)
continue;
var vertices = new List<float>();
var outIndices = new List<uint>();
var batchTriangleCount = 0;
var material = materialSet.FindMaterial(batch.MaterialIndexLo) ?? ViewportMaterial.Untextured;
var vertexColor = material.HasTexture ? Vector3.One : debugColor;
for (var i = 0; i + 2 < batch.IndexCount0x06; i += 3)
{
var indexBase = (int)batch.IndexStart0x06 + i;
var vertexIndex0 = checked((int)batch.BaseVertex0x03 + modelGeometry.Indices[indexBase + 0]);
var vertexIndex1 = checked((int)batch.BaseVertex0x03 + modelGeometry.Indices[indexBase + 1]);
var vertexIndex2 = checked((int)batch.BaseVertex0x03 + modelGeometry.Indices[indexBase + 2]);
if (!IsValidTriangle(modelGeometry.Positions.Count, vertexIndex0, vertexIndex1, vertexIndex2))
continue;
var p0 = ToNumericsVector3(modelGeometry.Positions[vertexIndex0]);
var p1 = ToNumericsVector3(modelGeometry.Positions[vertexIndex1]);
var p2 = ToNumericsVector3(modelGeometry.Positions[vertexIndex2]);
var fallbackNormal = BuildFaceNormal(p0, p1, p2);
AddTriangleVertex(p0, vertexColor, GetNormal(modelGeometry.Normals, vertexIndex0, fallbackNormal, renderState.UseStoredNormals), GetUv(modelGeometry.Uvs, vertexIndex0), vertices, outIndices, ref boundsMin, ref boundsMax);
AddTriangleVertex(p1, vertexColor, GetNormal(modelGeometry.Normals, vertexIndex1, fallbackNormal, renderState.UseStoredNormals), GetUv(modelGeometry.Uvs, vertexIndex1), vertices, outIndices, ref boundsMin, ref boundsMax);
AddTriangleVertex(p2, vertexColor, GetNormal(modelGeometry.Normals, vertexIndex2, fallbackNormal, renderState.UseStoredNormals), GetUv(modelGeometry.Uvs, vertexIndex2), vertices, outIndices, ref boundsMin, ref boundsMax);
batchTriangleCount++;
}
if (batchTriangleCount == 0)
continue;
batchCount++;
batchIndices.Add(batchIndex);
triangleCount += batchTriangleCount;
meshes.Add(PrimitiveMeshes.CreateColoredIndexedMesh(gl, vertices, outIndices, PrimitiveType.Triangles, material));
}
if (triangleCount == 0 || meshes.Count == 0)
return null;
return new PieceMeshBuildResult(meshes, boundsMin, boundsMax, batchCount, triangleCount, batchIndices);
}
private static Vector3 BuildFaceNormal(Vector3 p0, Vector3 p1, Vector3 p2)
{
var normal = Vector3.Cross(p1 - p0, p2 - p0);
return normal.LengthSquared() < 1e-8f ? Vector3.UnitY : Vector3.Normalize(normal);
}
private static Vector3 GetNormal(
IReadOnlyList<Msh04Normal> normals,
int vertexIndex,
Vector3 fallbackNormal,
bool useStoredNormals)
{
if (!useStoredNormals || vertexIndex < 0 || vertexIndex >= normals.Count)
return fallbackNormal;
var packed = normals[vertexIndex];
var normal = new Vector3(
Math.Clamp(packed.X / 127.0f, -1.0f, 1.0f),
Math.Clamp(packed.Y / 127.0f, -1.0f, 1.0f),
Math.Clamp(packed.Z / 127.0f, -1.0f, 1.0f));
// У битых/нулевых normal entries оставляем face normal, чтобы освещение не превращалось в NaN.
return normal.LengthSquared() < 1e-8f ? fallbackNormal : Vector3.Normalize(normal);
}
private static Vector2 GetUv(IReadOnlyList<Msh05Uv> uvs, int vertexIndex)
{
if (vertexIndex < 0 || vertexIndex >= uvs.Count)
return Vector2.Zero;
var uv = uvs[vertexIndex];
return new Vector2(uv.U / 1024.0f, uv.V / 1024.0f);
}
private static Vector3 ToNumericsVector3(Common.Vector3 position)
{
return new Vector3(position.X, position.Y, position.Z);
}
private static void AddTriangleVertex(
Vector3 position,
Vector3 color,
Vector3 normal,
Vector2 uv,
List<float> vertices,
List<uint> indices,
ref Vector3 boundsMin,
ref Vector3 boundsMax)
{
var vertexIndex = (uint)(vertices.Count / PrimitiveMeshes.FloatsPerVertex);
vertices.Add(position.X);
vertices.Add(position.Y);
vertices.Add(position.Z);
vertices.Add(color.X);
vertices.Add(color.Y);
vertices.Add(color.Z);
vertices.Add(normal.X);
vertices.Add(normal.Y);
vertices.Add(normal.Z);
vertices.Add(uv.X);
vertices.Add(uv.Y);
indices.Add(vertexIndex);
boundsMin = Vector3.Min(boundsMin, position);
boundsMax = Vector3.Max(boundsMax, position);
}
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 Vector3 PickDebugColor(int index)
{
ReadOnlySpan<Vector3> 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(
IReadOnlyList<GpuMesh> Meshes,
Vector3 BoundsMin,
Vector3 BoundsMax,
int BatchCount,
int TriangleCount,
IReadOnlyList<int> BatchIndices);
}
@@ -0,0 +1,93 @@
using System.Numerics;
using MshLib;
namespace NResUI.Rendering.Inspection;
/// <summary>
/// CPU-документ меша: общая модель данных для viewport и inspector до загрузки в OpenGL.
/// Здесь хранятся ссылки на исходные MSH-компоненты и уже удобные для UI metadata.
/// </summary>
public sealed class MeshDocument
{
/// <summary>Путь к MSH/NRes ресурсу, из которого построен документ.</summary>
public required string SourcePath { get; init; }
/// <summary>Тип MSH по набору компонентов. Сейчас inspector поддерживает только обычный Model.</summary>
public required MshType MeshType { get; init; }
/// <summary>Список pieces из 0x01. Каждый piece сам выбирает slot для state/LOD.</summary>
public IReadOnlyList<MeshPieceInfo> Pieces { get; init; } = [];
/// <summary>Материалы, пришедшие из WEA. Текстуры резолвятся отдельно, потому что это уже UI/OpenGL слой.</summary>
public IReadOnlyList<MeshMaterialInfo> Materials { get; init; } = [];
/// <summary>Нефатальные проблемы загрузки: отсутствующие optional-компоненты, WEA/material/texture misses.</summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>Количество известных model states в 0x01 таблице.</summary>
public int ModelStateCount { get; init; } = Msh0x01.StateCount;
/// <summary>Количество LOD-уровней на каждый state в 0x01 таблице.</summary>
public int LodCount { get; init; } = Msh0x01.MaxLodCount;
/// <summary>Сырые MSH-потоки, нужные для перестройки GPU-мешей при смене state/LOD.</summary>
public MshModelGeometry? MshModelGeometry { get; init; }
}
/// <summary>
/// Информация об одном piece/node из 0x01.
/// Важно: state/LOD не глобальны, поэтому этот объект хранит собственную slot-таблицу.
/// </summary>
public sealed class MeshPieceInfo
{
public required int Id { get; init; }
public required string Name { get; init; }
public required int ParentId { get; init; }
public required NodeFlags Flags { get; init; }
public required IReadOnlyList<ushort> GeometrySlotsByStateAndLod { get; init; }
public required IReadOnlyList<MeshBatchInfo> Batches { get; init; }
public required Matrix4x4 LocalTransform { get; init; }
public required Matrix4x4 MeshSpaceTransform { get; init; }
public required Vector3 BoundsMin { get; init; }
public required Vector3 BoundsMax { get; init; }
public int FallbackKeyframeIndex { get; init; } = -1;
public bool HasRestPose { get; init; }
/// <summary>
/// Возвращает geometry slot 0x02 для пары state/LOD именно этого piece.
/// 0xFFFF означает, что piece в такой комбинации не должен рендериться.
/// </summary>
public ushort ResolveSlotIndex(int state, int lod)
{
var index = state * Msh0x01.MaxLodCount + lod;
return index >= 0 && index < GeometrySlotsByStateAndLod.Count
? GeometrySlotsByStateAndLod[index]
: ushort.MaxValue;
}
}
/// <summary>
/// Inspector metadata для batch 0x0D. Геометрия остается в MshModelGeometry, здесь только диапазоны и флаги.
/// </summary>
public sealed class MeshBatchInfo
{
public required int BatchIndex { get; init; }
public required int MaterialId { get; init; }
public string? MaterialName { get; init; }
public required BatchFlags Flags { get; init; }
public required uint IndexStart { get; init; }
public required int IndexCount { get; init; }
public required uint BaseVertex { get; init; }
public required int VertexCount { get; init; }
public required int TriangleCount { get; init; }
public IReadOnlyList<string> Warnings { get; init; } = [];
}
/// <summary>Материал из WEA по id. Это еще не ViewportMaterial и не OpenGL texture.</summary>
public sealed class MeshMaterialInfo
{
public required int Id { get; init; }
public required string Name { get; init; }
public string? TextureName { get; init; }
public bool HasTexture => TextureName != null;
}
@@ -0,0 +1,71 @@
namespace NResUI.Rendering.Inspection;
/// <summary>
/// Состояние просмотра меша. State/LOD задаются только для конкретных pieces,
/// потому что 0x01 хранит таблицу slot indices отдельно для каждого узла.
/// </summary>
public sealed class MeshRenderState
{
public bool UseStoredNormals { get; set; } = true;
public bool ShowEmptyPieces { get; set; } = true;
public int SelectedPieceId { get; set; } = -1;
public int SelectedBatchIndex { get; set; } = -1;
public bool IsolateSelectedPiece { get; set; }
public bool IsolateSelectedBatch { get; set; }
public HashSet<int> HiddenPieceIds { get; } = [];
public Dictionary<int, int> PieceStateOverrides { get; } = [];
public Dictionary<int, int> PieceLodOverrides { get; } = [];
public void ClearSelection()
{
SelectedPieceId = -1;
SelectedBatchIndex = -1;
IsolateSelectedPiece = false;
IsolateSelectedBatch = false;
}
public int GetPieceState(int pieceId)
{
return PieceStateOverrides.GetValueOrDefault(pieceId, 0);
}
public int GetPieceLod(int pieceId)
{
return PieceLodOverrides.GetValueOrDefault(pieceId, 0);
}
public void SetPieceStateOverride(int pieceId, int state)
{
PieceStateOverrides[pieceId] = state;
}
public void SetPieceLodOverride(int pieceId, int lod)
{
PieceLodOverrides[pieceId] = lod;
}
public void ClearPieceOverrides(int pieceId)
{
PieceStateOverrides.Remove(pieceId);
PieceLodOverrides.Remove(pieceId);
}
public void ClearAllPieceOverrides()
{
PieceStateOverrides.Clear();
PieceLodOverrides.Clear();
}
public bool IsPieceVisible(int pieceId)
{
if (HiddenPieceIds.Contains(pieceId))
return false;
return !IsolateSelectedPiece || SelectedPieceId < 0 || pieceId == SelectedPieceId;
}
public bool IsBatchVisible(int batchIndex)
{
return !IsolateSelectedBatch || SelectedBatchIndex < 0 || batchIndex == SelectedBatchIndex;
}
}
@@ -0,0 +1,30 @@
using MshLib;
namespace NResUI.Rendering.Inspection;
/// <summary>
/// Parsed MSH component data retained so state/LOD changes can rebuild GPU meshes without reparsing the file.
/// </summary>
public sealed class MshModelGeometry
{
/// <summary>Таблица узлов 0x01: именно она связывает каждый piece с geometry slot для пары state/LOD.</summary>
public required Msh0x01.Msh0x01Component Nodes { get; init; }
/// <summary>Слоты геометрии 0x02. Один piece выбирает один слот через 0x01.ResolveSlotIndex.</summary>
public required Msh0x02.Msh0x02Component GeometrySlots { get; init; }
/// <summary>Позиции вершин из 0x03.</summary>
public required IReadOnlyList<Common.Vector3> Positions { get; init; }
/// <summary>Нормали из 0x04. Компонент опционален, поэтому фабрика мешей обязана иметь fallback.</summary>
public required IReadOnlyList<Msh04Normal> Normals { get; init; }
/// <summary>UV из 0x05. Компонент опционален для части ресурсов.</summary>
public required IReadOnlyList<Msh05Uv> Uvs { get; init; }
/// <summary>Индексный буфер 0x06 для обычных моделей.</summary>
public required IReadOnlyList<ushort> Indices { get; init; }
/// <summary>Render batches 0x0D: диапазоны индексов, material id и render flags.</summary>
public required IReadOnlyList<Msh0x0D.Batch> Batches { get; init; }
}
@@ -0,0 +1,164 @@
using MaterialLib;
using NResLib;
using NResUI.Abstractions;
using NResUI.Rendering.Viewport;
using NResUI.Rendering.Viewport.OpenGL;
using Silk.NET.OpenGL;
using TexmLib;
namespace NResUI.Rendering.Materials;
/// <summary>Набор материалов, готовых для viewport. Ключ — material id из WEA/MSH batch.</summary>
public sealed class ViewportMaterialSet
{
private readonly Dictionary<int, ViewportMaterial> _materialsById;
public ViewportMaterialSet(Dictionary<int, ViewportMaterial> materialsById)
{
_materialsById = materialsById;
}
public static ViewportMaterialSet Empty { get; } = new([]);
public ViewportMaterial? FindMaterial(int id)
{
return _materialsById.GetValueOrDefault(id);
}
}
/// <summary>
/// Связывает WEA -> Material.lib -> Textures.lib -> OpenGL texture.
/// Этот слой намеренно остается в NResUI, потому что только UI знает GameBasePath и GL context.
/// </summary>
public static class ViewportMaterialResolver
{
/// <summary>
/// Загружает материалы для MSH. Любая ошибка резолва не фатальна: модель должна остаться видимой без текстур.
/// </summary>
public static ViewportMaterialSet LoadForMsh(
GL gl,
string mshPath,
IConfigProvider configProvider,
ICollection<string>? warnings = null)
{
var weaMatch = WeaResourceResolver.TryLoadMatchingWeaForMsh(mshPath, warnings);
if (weaMatch == null || weaMatch.File.Materials.Count == 0)
return ViewportMaterialSet.Empty;
var materialLibPath = Path.Combine(configProvider.GetConfig().GameBasePath, "Material.lib");
if (!File.Exists(materialLibPath))
{
warnings?.Add($"Material.lib was not found at '{materialLibPath}'.");
return ViewportMaterialSet.Empty;
}
using var materialFs = new FileStream(materialLibPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var materialArchiveResult = NResParser.ReadFile(materialFs);
if (materialArchiveResult.Archive == null)
{
warnings?.Add($"Failed to parse Material.lib: {materialArchiveResult.Error}");
return ViewportMaterialSet.Empty;
}
var result = new Dictionary<int, ViewportMaterial>();
foreach (var materialRef in weaMatch.File.Materials)
{
var texture = TryLoadMaterialTexture(
gl,
materialFs,
materialArchiveResult.Archive,
materialRef.Name,
configProvider,
warnings,
out var texm);
result[materialRef.Id] = texture != null
? new ViewportMaterial(materialRef.Name, texture.Value, texm)
: new ViewportMaterial(materialRef.Name);
}
return new ViewportMaterialSet(result);
}
private static uint? TryLoadMaterialTexture(
GL gl,
FileStream materialFs,
NResArchive materialArchive,
string materialName,
IConfigProvider configProvider,
ICollection<string>? warnings,
out TexmFile? texm)
{
texm = null;
var materialEntry = FindEntry(materialArchive, materialName);
if (materialEntry == null)
{
warnings?.Add($"Material '{materialName}' referenced by WEA was not found in Material.lib.");
return null;
}
materialFs.Seek(materialEntry.OffsetInFile, SeekOrigin.Begin);
var materialData = new byte[materialEntry.FileLength];
materialFs.ReadExactly(materialData, 0, materialData.Length);
using var ms = new MemoryStream(materialData, writable: false);
var materialFile = MaterialParser.ReadFromStream(
ms,
materialName,
materialEntry.ElementCount,
materialEntry.Magic1);
var textureName = materialFile.Stages.FirstOrDefault()?.TextureName;
if (string.IsNullOrWhiteSpace(textureName))
return null;
// Текстуры живут в отдельной NRes-библиотеке, поэтому material parsing и texture parsing разделены.
var textureLibPath = Path.Combine(configProvider.GetConfig().GameBasePath, "Textures.lib");
if (!File.Exists(textureLibPath))
{
warnings?.Add($"Textures.lib was not found at '{textureLibPath}'.");
return null;
}
using var textureFs = new FileStream(textureLibPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var textureArchiveResult = NResParser.ReadFile(textureFs);
if (textureArchiveResult.Archive == null)
{
warnings?.Add($"Failed to parse Textures.lib: {textureArchiveResult.Error}");
return null;
}
var textureEntry = FindEntry(textureArchiveResult.Archive, textureName);
if (textureEntry == null)
{
warnings?.Add($"Texture '{textureName}' referenced by material '{materialName}' was not found in Textures.lib.");
return null;
}
textureFs.Seek(textureEntry.OffsetInFile, SeekOrigin.Begin);
var textureData = new byte[textureEntry.FileLength];
textureFs.ReadExactly(textureData, 0, textureData.Length);
using var textureMs = new MemoryStream(textureData, writable: false);
var texmResult = TexmParser.ReadFromStream(textureMs, textureEntry.FileName);
if (texmResult.TexmFile == null)
{
warnings?.Add($"Failed to parse TEXM '{textureName}': {texmResult.Error}");
return null;
}
texm = texmResult.TexmFile;
var rgba = texm.GetRgba32BytesFromMipmap(0, out var width, out var height);
return ViewportTextureLoader.CreateRgbaTexture(gl, rgba, width, height);
}
private static ListMetadataItem? FindEntry(NResArchive archive, string name)
{
static string Normalize(string value) => value.Trim().ToLowerInvariant();
var normalized = Normalize(name);
return archive.Files.FirstOrDefault(x =>
string.Equals(x.FileName, name, StringComparison.OrdinalIgnoreCase) ||
Normalize(x.FileName) == normalized);
}
}
@@ -0,0 +1,147 @@
using WeaLib;
namespace NResUI.Rendering.Materials;
/// <summary>Результат эвристического поиска WEA рядом с MSH.</summary>
public sealed class WeaResourceMatch
{
public required string Path { get; init; }
public required string Reason { get; init; }
public required WeaFile File { get; init; }
}
/// <summary>
/// UI-side resolver для связи MSH-файла с WEA-файлом.
/// Это не часть WeaLib, потому что matching зависит от экспортированных имен и расположения ресурсов на диске.
/// </summary>
public static class WeaResourceResolver
{
/// <summary>
/// Ищет WEA рядом с MSH и сразу парсит его.
/// Если файл не найден или битый, viewport все равно может показать модель без материалов.
/// </summary>
public static WeaResourceMatch? TryLoadMatchingWeaForMsh(string mshPath, ICollection<string>? warnings = null)
{
var match = FindMatchingWeaPath(mshPath, out var reason);
if (match == null)
return null;
var parseResult = WeaParser.ReadFile(match);
if (parseResult.WeaFile == null)
{
warnings?.Add($"Failed to parse WEA '{match}': {parseResult.Error}");
return null;
}
return new WeaResourceMatch
{
Path = match,
Reason = reason,
File = parseResult.WeaFile
};
}
private static string? FindMatchingWeaPath(string mshPath, out string reason)
{
reason = "No matching WEA found.";
var directory = Path.GetDirectoryName(mshPath);
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
return null;
var mshResourceName = GetExportedResourceNameWithoutExtension(mshPath);
var mshObjectStem = ExtractObjectStem(mshResourceName);
var candidates = Directory.EnumerateFiles(directory, "*.wea", SearchOption.TopDirectoryOnly)
.Select(path => new
{
Path = path,
ResourceName = GetExportedResourceNameWithoutExtension(path),
})
.Select(x => new
{
x.Path,
x.ResourceName,
ObjectStem = ExtractObjectStem(x.ResourceName),
})
.ToList();
var exact = candidates.FirstOrDefault(x => string.Equals(x.ObjectStem, mshObjectStem,
StringComparison.OrdinalIgnoreCase));
// Лучший случай: MESH_* и WEAR_* после нормализации указывают на один объект.
if (exact != null)
{
reason = "Object stem matched exactly.";
return exact.Path;
}
var best = candidates
.Select(x => new
{
x.Path,
Score = CommonPrefixTokenCount(
SplitResourceTokens(mshObjectStem),
SplitResourceTokens(x.ObjectStem))
})
.OrderByDescending(x => x.Score)
.FirstOrDefault();
// Fallback нужен для экспортов, где суффиксы MESH/WEAR отличаются, но начало имени объекта совпадает.
if (best?.Score > 0)
{
reason = $"Shared first {best.Score} resource token(s).";
return best.Path;
}
return null;
}
private static string GetExportedResourceNameWithoutExtension(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
var firstUnderscore = name.IndexOf('_');
// Экспортер часто добавляет numeric prefix: 58_MESH_..., 81_WEAR_...
if (firstUnderscore > 0 && name[..firstUnderscore].All(char.IsDigit))
name = name[(firstUnderscore + 1)..];
return name;
}
private static string ExtractObjectStem(string resourceName)
{
var normalized = resourceName;
// Тип ресурса не является частью имени объекта, поэтому MESH_o_x и WEAR_o_x должны совпасть.
foreach (var prefix in new[] { "MESH_", "WEAR_", "TEXT_", "ANIM_" })
{
if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[prefix.Length..];
break;
}
}
var tokens = normalized.Split('_', StringSplitOptions.RemoveEmptyEntries);
return string.Join('_', tokens);
}
private static string[] SplitResourceTokens(string value)
{
return value.Split('_', StringSplitOptions.RemoveEmptyEntries);
}
private static int CommonPrefixTokenCount(string[] a, string[] b)
{
var count = Math.Min(a.Length, b.Length);
var result = 0;
for (var i = 0; i < count; i++)
{
if (!string.Equals(a[i], b[i], StringComparison.OrdinalIgnoreCase))
break;
result++;
}
return result;
}
}
@@ -9,6 +9,10 @@ namespace NResUI.Rendering.Viewport.Msh;
public static class MshRestPoseBuilder
{
/// <summary>
/// Собирает bind/rest pose для pieces из fallback keyframes 0x08.
/// Циклы и битые parent-ссылки не должны ломать viewport, поэтому такие узлы остаются с identity transform.
/// </summary>
public static IReadOnlyList<MshPieceRestPose> BuildRestPose(Msh0x01.Msh0x01Component nodesComponent, List<Msh0x08.AnimationDescriptor> animationDescriptors)
{
var nodeList = nodesComponent.Nodes;
@@ -54,15 +58,12 @@ public static class MshRestPoseBuilder
else
{
localTransform = Matrix4x4.Identity;
Console.WriteLine($"Node {nodeIndex} has no fallback");
}
if (parentIndex == -1)
{
// Root nodes describe object placement in game space; the viewer keeps the model centered.
localTransform.Translation = Vector3.Zero;
Console.WriteLine($"Node {nodeIndex} has no parent");
}
var meshSpaceTransform = localTransform;
@@ -86,15 +87,7 @@ public static class MshRestPoseBuilder
private static int GetParentIndex(Msh0x01.Node node)
{
try
{
return Convert.ToInt32(node.ParentIndexOrLink);
}
catch
{
var rawParent = Convert.ToUInt16(node.ParentIndexOrLink);
return rawParent == ushort.MaxValue ? -1 : rawParent;
}
return node.ParentIndexOrLink == ushort.MaxValue ? -1 : node.ParentIndexOrLink;
}
private static int GetFallbackKeyframeIndex(Msh0x01.Node node)
@@ -1,31 +0,0 @@
namespace NResUI.Rendering.Viewport.Msh;
public sealed class MshViewportLoadResult
{
public bool IsSuccess { get; }
public string? Error { get; }
public string? SourcePath { get; }
public IReadOnlyList<ViewportPiece> Pieces { get; }
private MshViewportLoadResult(
bool isSuccess,
string? error,
string? sourcePath,
IReadOnlyList<ViewportPiece> pieces)
{
IsSuccess = isSuccess;
Error = error;
SourcePath = sourcePath;
Pieces = pieces;
}
public static MshViewportLoadResult Success(string sourcePath, IReadOnlyList<ViewportPiece> 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<ViewportPiece>());
}
}
@@ -1,294 +0,0 @@
using System.Numerics;
using MshLib;
using NResLib;
using NResUI.Abstractions;
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, IConfigProvider configProvider)
{
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, path, configProvider);
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<ViewportPiece> LoadModelPieces(
GL gl, FileStream fs, NResArchive archive, string path, IConfigProvider configProvider
)
{
var nodes = Msh0x01.ReadComponent(fs, archive);
var geometry = Msh0x02.ReadComponent(fs, archive);
var positions = Msh0x03.ReadComponent(fs, archive);
var uvs = TryReadUvs(fs, archive);
var indices = Msh0x06.ReadComponent(fs, archive);
var batches = Msh0x0D.ReadComponent(fs, archive);
var animationDescriptors = Msh0x08.ReadComponent(fs, archive);
var restPoses = MshRestPoseBuilder.BuildRestPose(nodes, animationDescriptors);
var mshToViewportTransform = Matrix4x4.CreateRotationX(-MathF.PI * 0.5f);
var names = TryReadNames(fs, archive);
var materialLibrary = WeaMaterialLibrary.TryLoadForMsh(gl, path, configProvider);
var pieces = new List<ViewportPiece>();
for (var nodeIndex = 0; nodeIndex < nodes.Nodes.Count; nodeIndex++)
{
var node = nodes.Nodes[nodeIndex];
var restPose = restPoses[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 = BuildPieceMeshes(gl, nodeIndex, slot, positions, uvs, indices, batches, materialLibrary);
if (meshBuildResult == null)
continue;
var name = ResolvePieceName(names, nodeIndex);
pieces.Add(new ViewportPiece(
id: nodeIndex,
name: name,
meshes: meshBuildResult.Meshes,
localTransform: restPose.MeshSpaceTransform * mshToViewportTransform,
boundsMin: meshBuildResult.BoundsMin,
boundsMax: meshBuildResult.BoundsMax,
debugInfo: new ViewportPieceDebugInfo
{
SourceKind = "MSH 0x01 piece",
SourcePieceIndex = nodeIndex,
SourceParentIndex = restPose.ParentIndex,
GeometrySlotIndex = slotIndex,
Msh01Flags = (uint)node.Flags,
BatchCount = meshBuildResult.BatchCount,
TriangleCount = meshBuildResult.TriangleCount,
FallbackKeyframeIndex = restPose.FallbackKeyframeIndex,
HasRestPose = restPose.HasFallbackPose
}));
}
return pieces;
}
private static PieceMeshBuildResult? BuildPieceMeshes(
GL gl,
int nodeIndex,
Msh0x02.GeometrySlot slot,
IReadOnlyList<Common.Vector3> positions,
IReadOnlyList<Msh05Uv> uvs,
IReadOnlyList<ushort> indices,
IReadOnlyList<Msh0x0D.Batch> batches,
WeaMaterialLibrary materialLibrary)
{
var meshes = new List<GpuMesh>();
var debugColor = 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;
var vertices = new List<float>();
var outIndices = new List<uint>();
var batchTriangleCount = 0;
var material = materialLibrary.FindMaterial(batch.MaterialIndexLo) ?? ViewportMaterial.Untextured;
var vertexColor = material.HasTexture ? Vector3.One : debugColor;
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;
var p0 = ToNumericsVector3(positions[vertexIndex0]);
var p1 = ToNumericsVector3(positions[vertexIndex1]);
var p2 = ToNumericsVector3(positions[vertexIndex2]);
var normal = Vector3.Cross(p1 - p0, p2 - p0);
if (normal.LengthSquared() < 1e-8f)
normal = Vector3.UnitY;
else
normal = Vector3.Normalize(normal);
AddTriangleVertex(p0, vertexColor, normal, GetUv(uvs, vertexIndex0), vertices, outIndices, ref boundsMin, ref boundsMax);
AddTriangleVertex(p1, vertexColor, normal, GetUv(uvs, vertexIndex1), vertices, outIndices, ref boundsMin, ref boundsMax);
AddTriangleVertex(p2, vertexColor, normal, GetUv(uvs, vertexIndex2), vertices, outIndices, ref boundsMin, ref boundsMax);
batchTriangleCount++;
}
if (batchTriangleCount == 0)
continue;
batchCount++;
triangleCount += batchTriangleCount;
meshes.Add(PrimitiveMeshes.CreateColoredIndexedMesh(gl, vertices, outIndices, PrimitiveType.Triangles, material));
}
if (triangleCount == 0 || meshes.Count == 0)
return null;
return new PieceMeshBuildResult(meshes, boundsMin, boundsMax, batchCount, triangleCount);
}
private static Vector2 GetUv(IReadOnlyList<Msh05Uv> uvs, int vertexIndex)
{
if (vertexIndex < 0 || vertexIndex >= uvs.Count)
return Vector2.Zero;
var uv = uvs[vertexIndex];
return new Vector2(uv.U / 1024.0f, uv.V / 1024.0f);
}
private static Vector3 ToNumericsVector3(Common.Vector3 position)
{
return new Vector3(position.X, position.Y, position.Z);
}
private static void AddTriangleVertex(
Vector3 position,
Vector3 color,
Vector3 normal,
Vector2 uv,
List<float> vertices,
List<uint> indices,
ref Vector3 boundsMin,
ref Vector3 boundsMax)
{
var vertexIndex = (uint)(vertices.Count / PrimitiveMeshes.FloatsPerVertex);
vertices.Add(position.X);
vertices.Add(position.Y);
vertices.Add(position.Z);
vertices.Add(color.X);
vertices.Add(color.Y);
vertices.Add(color.Z);
vertices.Add(normal.X);
vertices.Add(normal.Y);
vertices.Add(normal.Z);
vertices.Add(uv.X);
vertices.Add(uv.Y);
indices.Add(vertexIndex);
boundsMin = Vector3.Min(boundsMin, position);
boundsMax = Vector3.Max(boundsMax, position);
}
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<string> TryReadNames(FileStream fs, NResArchive archive)
{
try
{
return Msh0x0A.ReadComponent(fs, archive);
}
catch
{
return new List<string>();
}
}
private static List<Msh05Uv> TryReadUvs(FileStream fs, NResArchive archive)
{
try
{
return Msh0x05.ReadComponent(fs, archive);
}
catch
{
return new List<Msh05Uv>();
}
}
private static string ResolvePieceName(IReadOnlyList<string> 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<Vector3> 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(
IReadOnlyList<GpuMesh> Meshes,
Vector3 BoundsMin,
Vector3 BoundsMax,
int BatchCount,
int TriangleCount);
}
@@ -1,284 +0,0 @@
using MaterialLib;
using NResLib;
using NResUI.Abstractions;
using NResUI.Rendering.Viewport.OpenGL;
using Silk.NET.OpenGL;
using TexmLib;
namespace NResUI.Rendering.Viewport.Msh;
public sealed class WeaMaterialLibrary
{
private readonly Dictionary<int, ViewportMaterial> _materialsById;
private WeaMaterialLibrary(Dictionary<int, ViewportMaterial> materialsById)
{
_materialsById = materialsById;
}
public static WeaMaterialLibrary Empty { get; } = new(new Dictionary<int, ViewportMaterial>());
public ViewportMaterial? FindMaterial(int id)
{
return _materialsById.TryGetValue(id, out var material)
? material
: null;
}
public static WeaMaterialLibrary TryLoadForMsh(GL gl, string mshPath, IConfigProvider configProvider)
{
var weaPath = FindMatchingWeaPath(mshPath);
if (!File.Exists(weaPath))
return Empty;
var materialRefs = ParseMaterialRefs(weaPath);
if (materialRefs.Count == 0)
return Empty;
var materialLibFs = Path.Combine(configProvider.GetConfig().GameBasePath, "Material.lib");
if (!File.Exists(materialLibFs))
{
return new WeaMaterialLibrary([]);
}
using var materialFs = new FileStream(materialLibFs, FileMode.Open, FileAccess.Read, FileShare.Read);
var parseResult = NResParser.ReadFile(materialFs);
if (parseResult.Archive == null)
return Empty;
var result = new Dictionary<int, ViewportMaterial>();
foreach (var materialRef in materialRefs)
{
var texture = TryLoadMaterialTexture(gl, materialFs, parseResult.Archive, materialRef.Name, configProvider, out var texm);
result[materialRef.Id] = texture != null
? new ViewportMaterial(materialRef.Name, texture.Value, texm)
: new ViewportMaterial(materialRef.Name);
}
return new WeaMaterialLibrary(result);
}
private static string? FindMatchingWeaPath(string mshPath)
{
var directory = Path.GetDirectoryName(mshPath);
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
return null;
var mshResourceName = GetExportedResourceNameWithoutExtension(mshPath);
var mshObjectStem = ExtractObjectStem(mshResourceName);
var candidates = Directory.EnumerateFiles(directory, "*.wea", SearchOption.TopDirectoryOnly)
.Select(path => new
{
Path = path,
ResourceName = GetExportedResourceNameWithoutExtension(path),
})
.Select(x => new
{
x.Path,
x.ResourceName,
ObjectStem = ExtractObjectStem(x.ResourceName),
})
.ToList();
// Best case: exact resource stem match after type prefix normalization.
var exact = candidates.FirstOrDefault(x => string.Equals(x.ObjectStem, mshObjectStem,
StringComparison.OrdinalIgnoreCase));
if (exact != null)
return exact.Path;
// Fallback: choose a WEA whose resource name shares the longest token prefix.
var best = candidates
.Select(x => new
{
x.Path,
Score = CommonPrefixTokenCount(
SplitResourceTokens(mshObjectStem),
SplitResourceTokens(x.ObjectStem))
})
.OrderByDescending(x => x.Score)
.FirstOrDefault();
return best?.Score > 0 ? best.Path : null;
}
private static string GetExportedResourceNameWithoutExtension(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
// Exported files are usually like:
// 58_MESH_o_tur_ba_06
// 81_WEAR_o_tur_ba_02
//
// Strip numeric export prefix.
var firstUnderscore = name.IndexOf('_');
if (firstUnderscore > 0 &&
name[..firstUnderscore].All(char.IsDigit))
{
name = name[(firstUnderscore + 1)..];
}
return name;
}
private static string ExtractObjectStem(string resourceName)
{
// Normalize known resource kind prefixes.
// MESH_o_tur_ba_06 -> o_tur_ba
// WEAR_o_tur_ba_02 -> o_tur_ba
var normalized = resourceName;
foreach (var prefix in new[] { "MESH_", "WEAR_", "TEXT_", "ANIM_" })
{
if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[prefix.Length..];
break;
}
}
var tokens = normalized.Split('_', StringSplitOptions.RemoveEmptyEntries);
return string.Join('_', tokens);
}
private static string[] SplitResourceTokens(string value)
{
return value.Split('_', StringSplitOptions.RemoveEmptyEntries);
}
private static int CommonPrefixTokenCount(string[] a, string[] b)
{
var count = Math.Min(a.Length, b.Length);
var result = 0;
for (var i = 0; i < count; i++)
{
if (!string.Equals(a[i], b[i], StringComparison.OrdinalIgnoreCase))
break;
result++;
}
return result;
}
private static IReadOnlyList<WeaMaterialRef> ParseMaterialRefs(string weaPath)
{
var lines = File.ReadAllLines(weaPath)
.Select(x => x.Trim())
.Where(x => x.Length != 0)
.ToList();
if (lines.Count == 0 || !int.TryParse(lines[0], out var materialCount) || materialCount <= 0)
return [];
var result = new List<WeaMaterialRef>();
for (var i = 0; i < materialCount && i + 1 < lines.Count; i++)
{
var line = lines[i + 1];
if (line.Equals("LIGHTMAPS", StringComparison.OrdinalIgnoreCase))
break;
var parts = line.Split((char[]?)null, 2,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
continue;
if (!int.TryParse(parts[0], out var id))
continue;
result.Add(new WeaMaterialRef(id, parts[1]));
}
return result;
}
private static uint? TryLoadMaterialTexture(
GL gl, FileStream materialFs, NResArchive materialArchive, string materialName, IConfigProvider configProvider,
out TexmFile? texm
)
{
texm = null;
var entry = FindMaterialEntry(materialArchive, materialName);
if (entry == null)
return null;
materialFs.Seek(entry.OffsetInFile, SeekOrigin.Begin);
var materialData = new byte[entry.FileLength];
materialFs.ReadExactly(materialData, 0, materialData.Length);
using var ms = new MemoryStream(materialData, writable: false);
var materialFile = MaterialParser.ReadFromStream(ms, materialName, entry.ElementCount, entry.Magic1);
var texture = materialFile.Stages[0].TextureName;
var textureLibFs = Path.Combine(configProvider.GetConfig().GameBasePath, "Textures.lib");
if (!File.Exists(textureLibFs))
{
return null;
}
using var textureFs = new FileStream(textureLibFs, FileMode.Open, FileAccess.Read, FileShare.Read);
var textureResult = NResParser.ReadFile(textureFs);
if (textureResult.Archive == null)
return 0;
var textureEntry = FindTextureEntry(textureResult.Archive, texture);
if (textureEntry is null)
{
Console.WriteLine($"Didnt find texture {texture}");
return 0;
}
textureFs.Seek(textureEntry.OffsetInFile, SeekOrigin.Begin);
var textureData = new byte[textureEntry.FileLength];
textureFs.ReadExactly(textureData, 0, textureData.Length);
using var textureMs = new MemoryStream(textureData, writable: false);
var texmResult = TexmParser.ReadFromStream(textureMs, entry.FileName);
if (texmResult.TexmFile == null)
return null;
texm = texmResult.TexmFile;
var rgba = texmResult.TexmFile.GetRgba32BytesFromMipmap(0, out var width, out var height);
return ViewportTextureLoader.CreateRgbaTexture(gl, rgba, width, height);
}
private static ListMetadataItem? FindMaterialEntry(NResArchive materialArchive, string materialName)
{
static string Normalize(string value)
{
return value.Trim().ToLowerInvariant();
}
var normalized = Normalize(materialName);
return materialArchive.Files.FirstOrDefault(x =>
string.Equals(x.FileName, materialName, StringComparison.OrdinalIgnoreCase) ||
Normalize(x.FileName) == normalized);
}
private static ListMetadataItem? FindTextureEntry(NResArchive archive, string name)
{
static string Normalize(string value)
{
return value.Trim().ToLowerInvariant();
}
var normalized = Normalize(name);
return archive.Files.FirstOrDefault(x =>
string.Equals(x.FileName, name, StringComparison.OrdinalIgnoreCase) ||
Normalize(x.FileName) == normalized);
}
private readonly record struct WeaMaterialRef(int Id, string Name);
}
+7 -3
View File
@@ -17,6 +17,7 @@ public sealed class ViewportPiece
public Vector3 BoundsMax { get; }
public ViewportPieceDebugInfo? DebugInfo { get; }
public IReadOnlyList<int> SourceBatchIndices { get; }
public ViewportPiece(
int id,
@@ -25,8 +26,9 @@ public sealed class ViewportPiece
Matrix4x4 localTransform,
Vector3 boundsMin,
Vector3 boundsMax,
ViewportPieceDebugInfo? debugInfo = null)
: this(id, name, new[] { mesh }, localTransform, boundsMin, boundsMax, debugInfo)
ViewportPieceDebugInfo? debugInfo = null,
IReadOnlyList<int>? sourceBatchIndices = null)
: this(id, name, new[] { mesh }, localTransform, boundsMin, boundsMax, debugInfo, sourceBatchIndices)
{
}
@@ -37,7 +39,8 @@ public sealed class ViewportPiece
Matrix4x4 localTransform,
Vector3 boundsMin,
Vector3 boundsMax,
ViewportPieceDebugInfo? debugInfo = null)
ViewportPieceDebugInfo? debugInfo = null,
IReadOnlyList<int>? sourceBatchIndices = null)
{
if (meshes.Count == 0)
throw new ArgumentException("A viewport piece must contain at least one mesh.", nameof(meshes));
@@ -49,6 +52,7 @@ public sealed class ViewportPiece
BoundsMin = boundsMin;
BoundsMax = boundsMax;
DebugInfo = debugInfo;
SourceBatchIndices = sourceBatchIndices ?? [];
}
public static ViewportPiece CreateUnitCube(int id, string name, GpuMesh mesh)