material initial and pose fix

This commit is contained in:
bird_egop
2026-06-06 21:26:56 +03:00
parent 776bb9fcb3
commit 5baf3f324f
13 changed files with 536 additions and 248 deletions
@@ -143,7 +143,10 @@ public static class MshRestPoseBuilder
if (q.LengthSquared() < 1e-8f)
return Quaternion.Identity;
return Quaternion.Normalize(q);
// MSH/game convention is opposite to our System.Numerics/OpenGL transform convention.
// Conjugating converts the stored piece rotation into the viewport convention.
// The MSH keyframe rotation is stored as mesh-to-parent instead of parent-to-mesh, so the viewer needs the inverse.
return Quaternion.Conjugate(q);
}
}
@@ -30,7 +30,7 @@ public static class MshViewportLoader
}
fs.Seek(0, SeekOrigin.Begin);
var pieces = LoadModelPieces(gl, fs, archive);
var pieces = LoadModelPieces(gl, fs, archive, path);
if (pieces.Count == 0)
return MshViewportLoadResult.Failure("MSH was parsed, but no renderable geometry pieces were found.", path);
@@ -42,17 +42,19 @@ public static class MshViewportLoader
}
}
private static List<ViewportPiece> LoadModelPieces(GL gl, FileStream fs, NResArchive archive)
private static List<ViewportPiece> LoadModelPieces(GL gl, FileStream fs, NResArchive archive, string path)
{
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);
var pieces = new List<ViewportPiece>();
@@ -71,7 +73,7 @@ public static class MshViewportLoader
if (!slot.HasBatches)
continue;
var meshBuildResult = BuildPieceMesh(gl, nodeIndex, slot, positions, indices, batches);
var meshBuildResult = BuildPieceMeshes(gl, nodeIndex, slot, positions, uvs, indices, batches, materialLibrary);
if (meshBuildResult == null)
continue;
@@ -79,7 +81,7 @@ public static class MshViewportLoader
pieces.Add(new ViewportPiece(
id: nodeIndex,
name: name,
mesh: meshBuildResult.Mesh,
meshes: meshBuildResult.Meshes,
localTransform: restPose.MeshSpaceTransform * mshToViewportTransform,
boundsMin: meshBuildResult.BoundsMin,
boundsMax: meshBuildResult.BoundsMax,
@@ -100,17 +102,18 @@ public static class MshViewportLoader
return pieces;
}
private static PieceMeshBuildResult? BuildPieceMesh(
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)
IReadOnlyList<Msh0x0D.Batch> batches,
WeaMaterialLibrary materialLibrary)
{
var vertices = new List<float>();
var outIndices = new List<uint>();
var color = PickDebugColor(nodeIndex);
var meshes = new List<GpuMesh>();
var debugColor = PickDebugColor(nodeIndex);
var batchCount = 0;
var triangleCount = 0;
@@ -127,7 +130,12 @@ public static class MshViewportLoader
if (batch.IndexStart0x06 + batch.IndexCount0x06 > indices.Count)
continue;
batchCount++;
var vertices = new List<float>();
var outIndices = new List<uint>();
var batchTriangleCount = 0;
var material = materialLibrary.FindMaterial(batch.MaterialIndex) ?? ViewportMaterial.Untextured;
var vertexColor = material.HasTexture ? Vector3.One : debugColor;
for (var i = 0; i + 2 < batch.IndexCount0x06; i += 3)
{
@@ -149,20 +157,35 @@ public static class MshViewportLoader
else
normal = Vector3.Normalize(normal);
AddTriangleVertex(p0, color, normal, vertices, outIndices, ref boundsMin, ref boundsMax);
AddTriangleVertex(p1, color, normal, vertices, outIndices, ref boundsMin, ref boundsMax);
AddTriangleVertex(p2, color, normal, vertices, outIndices, ref boundsMin, ref boundsMax);
triangleCount++;
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)
if (triangleCount == 0 || meshes.Count == 0)
return null;
var mesh = PrimitiveMeshes.CreateColoredIndexedMesh(gl, vertices, outIndices, PrimitiveType.Triangles);
return new PieceMeshBuildResult(mesh, boundsMin, boundsMax, batchCount, triangleCount);
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, 1.0f - uv.V / 1024.0f);
}
private static Vector3 ToNumericsVector3(Common.Vector3 position)
{
return new Vector3(position.X, position.Y, position.Z);
@@ -172,13 +195,13 @@ public static class MshViewportLoader
Vector3 position,
Vector3 color,
Vector3 normal,
Vector2 uv,
List<float> vertices,
List<uint> indices,
ref Vector3 boundsMin,
ref Vector3 boundsMax
)
ref Vector3 boundsMax)
{
var vertexIndex = (uint)(vertices.Count / 9);
var vertexIndex = (uint)(vertices.Count / PrimitiveMeshes.FloatsPerVertex);
vertices.Add(position.X);
vertices.Add(position.Y);
@@ -189,12 +212,13 @@ public static class MshViewportLoader
vertices.Add(normal.X);
vertices.Add(normal.Y);
vertices.Add(normal.Z);
vertices.Add(uv.X);
vertices.Add(uv.Y);
indices.Add(vertexIndex);
var p = new Vector3(position.X, position.Y, position.Z);
boundsMin = Vector3.Min(boundsMin, p);
boundsMax = Vector3.Max(boundsMax, p);
boundsMin = Vector3.Min(boundsMin, position);
boundsMax = Vector3.Max(boundsMax, position);
}
private static bool IsValidTriangle(int vertexCount, int vertexIndex0, int vertexIndex1, int vertexIndex2)
@@ -221,6 +245,18 @@ public static class MshViewportLoader
}
}
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]))
@@ -247,7 +283,7 @@ public static class MshViewportLoader
}
private sealed record PieceMeshBuildResult(
GpuMesh Mesh,
IReadOnlyList<GpuMesh> Meshes,
Vector3 BoundsMin,
Vector3 BoundsMax,
int BatchCount,
@@ -0,0 +1,293 @@
using MaterialLib;
using NResLib;
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)
{
var weaPath = FindMatchingWeaPath(mshPath);
if (!File.Exists(weaPath))
return Empty;
var materialRefs = ParseMaterialRefs(weaPath);
if (materialRefs.Count == 0)
return Empty;
// TODO: Hardcoded for now
var materialLibFs = "C:\\IronStrategy\\Material.lib";
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);
result[materialRef.Id] = texture != null
? new ViewportMaterial(materialRef.Name, texture.Value)
: 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
)
{
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 = "C:\\IronStrategy\\Textures.lib";
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;
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 static string? FindTexturesLib(string? startDirectory)
{
if (string.IsNullOrWhiteSpace(startDirectory))
return null;
var directory = new DirectoryInfo(startDirectory);
while (directory != null)
{
var candidate = Path.Combine(directory.FullName, "Textures.lib");
if (Directory.Exists(candidate))
return candidate;
candidate = Path.Combine(directory.FullName, "textures.lib");
if (Directory.Exists(candidate))
return candidate;
directory = directory.Parent;
}
return null;
}
private readonly record struct WeaMaterialRef(int Id, string Name);
}