champions-of-luneraria/Assets/Luneraria/Editor/Art/LuneAssetVerifier.cs
Martin Stickelbroeck 3ca38a8443 U2 Batch Asset Import
Alle 86 Modelle der Godot-Referenz laufen durch die Blender→FBX-Pipeline
(nach bestandenem Vertical Function Test); jede Einheit zeigt ihr eigenes
Modell.

- Exportmanifest aus den Godot-Manifesten (tools/blender/make_manifest.py):
  Units, Enemies, Props, Characters, Peoples, Buildings, Environment; die
  drei Vergleichs-GLBs als Aliase. Neu in der Pipeline: Tangenten berechnet
  Unity mit MikkTSpace, Generatoren laufen in einer temporären Kopie, die
  Grüne Kluft wird wie das GLB zusammengefasst, Texturen kommen mit.
  1.616 Blender- und 1.247 Unity-Prüfungen grün, zwei Exportläufe
  byteidentisch; Kontaktbögen je Gruppe.
- Einheitenbibliothek nach Godots AssetLibrary: 70 Einträge der
  Einheitengruppen, Figuren mit Skelett ersetzen das starre Modell ihrer ID.
  Starre Modelle werden zur Laufzeit je Material zusammengefasst; Controller
  werden an Ort und Stelle aktualisiert.
- Tests mit allen Modellen: EditMode 11/11, PlayMode 2/2. Vertical Function
  Test Seed 2 DCE95C96556F3A1A und Seed 9 ab Runde 21 7B8E43AEFC0D60C5,
  beide gleich dem Kern (Nachweise mit LUNE_EVIDENCE=u2a-batch; die Belege
  von U2C und U2D bleiben unverändert).
- macOS-Player mit allen Modellen: 158-160 fps in der Übersicht
  (116 Einheiten), 205-207 fps auf der Mid-Lane; Haupt- und Render-Thread
  etwa doppelt so lang wie mit dem Platzhalter, Present-Ausreißer kürzer.
- Befund iCloud Drive: Konfliktkopien ("Name 2.ext") im Build-Cache ließen
  den Build die Einheitenbibliothek mit altem Feldlayout schreiben; der
  Player meldete sharedassets0.assets als beschädigt. LunePlayerBuild
  entfernt solche Kopien vor dem Build und bricht ab, wenn während des
  Builds neue entstehen. Bereits committete Kopien: Controller-Duplikate und
  World/Generated 2.meta entfernt, drei U2D-Benchmarkläufe als -run1 bis
  -run3 benannt.
- Dokumentation: U2-WORLD (Batch-Import), PORT-STATUS (Entscheidungen:
  Modelle direkt in Git, Projektort außerhalb von iCloud Drive), PORT-QA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:10:35 +02:00

284 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace Luneraria.EditorTools
{
/// <summary>
/// Checks every imported FBX of the Blender pipeline against the facts Blender wrote next to it
/// (U2A): transforms free of axis rotations, mesh names, triangles, bounds, material slots,
/// normals, tangents, UVs, vertex colours, root bone and bone names, clips with loop flags and
/// contact events, forward direction. Batch entry:
/// Unity -batchmode -projectPath . -executeMethod Luneraria.EditorTools.LuneAssetVerifier.VerifyAll
/// Result: docs/port/logs/u2a/unity-verify.json; exit code 0 only if every check passed.
/// </summary>
public static class LuneAssetVerifier
{
const string Root = "Assets/Luneraria/Art";
const float BoundsTolerance = 0.002f;
/// <summary>Re-imports every pipeline model (after import rules or the render pipeline changed).</summary>
public static void ReimportArt()
{
AssetDatabase.ImportAsset(Root, ImportAssetOptions.ImportRecursive | ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
Debug.Log("LUNE_VERIFY reimported " + Root);
EditorApplication.Exit(0);
}
public static void VerifyAll()
{
int code = 1;
try
{
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
var results = new JArray();
bool all = true;
foreach (string model in ExportReport.AllModels(Root).OrderBy(p => p, StringComparer.Ordinal))
{
JObject result = Verify(model);
results.Add(result);
all &= (bool)result["ok"];
var failed = ((JArray)result["checks"]).Where(c => !(bool)c["ok"]).ToList();
Debug.Log(string.Format("LUNE_VERIFY {0} {1}: {2} checks, {3} failed{4}", failed.Count == 0 ? "OK" : "FAIL", result["id"],
((JArray)result["checks"]).Count, failed.Count, string.Concat(failed.Select(c => "\n FAIL " + c["check"] + ": " + c["detail"]))));
}
string logs = Path.Combine(Path.GetDirectoryName(Application.dataPath), "docs/port/logs/u2a");
Directory.CreateDirectory(logs);
var summary = new JObject { ["unity"] = Application.unityVersion, ["ok"] = all, ["assets"] = results };
File.WriteAllText(Path.Combine(logs, "unity-verify.json"), summary.ToString() + "\n");
code = all ? 0 : 2;
}
catch (Exception error)
{
Debug.LogError("LUNE_VERIFY crashed: " + error);
}
EditorApplication.Exit(code);
}
sealed class Checks
{
public readonly JArray List = new JArray();
public void Add(string name, bool ok, object detail = null)
{
List.Add(new JObject { ["check"] = name, ["ok"] = ok, ["detail"] = detail == null ? "" : JToken.FromObject(detail).ToString(Newtonsoft.Json.Formatting.None) });
}
public bool Ok { get { return List.All(c => (bool)c["ok"]); } }
}
public static JObject Verify(string modelPath)
{
ExportReport report = ExportReport.ForModel(modelPath);
var checks = new Checks();
var facts = new JObject();
var root = AssetDatabase.LoadAssetAtPath<GameObject>(modelPath);
checks.Add("blender_checks_passed", report.BlenderOk);
checks.Add("imported", root != null, modelPath);
if (root == null)
return Result(report, checks, facts);
var importer = (ModelImporter)AssetImporter.GetAtPath(modelPath);
checks.Add("import_rules_applied", importer.globalScale == 1f && importer.useFileScale && importer.bakeAxisConversion
&& importer.importNormals == ModelImporterNormals.Import && importer.importTangents == ModelImporterTangents.CalculateMikk);
checks.Add("root_transform_identity", IsIdentity(root.transform), Describe(root.transform));
var bones = new HashSet<Transform>();
foreach (var skinned in root.GetComponentsInChildren<SkinnedMeshRenderer>(true))
foreach (Transform bone in skinned.bones)
bones.Add(bone);
// Blender objects carry no rotation or scale; after the axis bake every node that is not
// a bone must be clean, otherwise the conversion ended up in the hierarchy.
var rotated = root.GetComponentsInChildren<Transform>(true).Where(t => t != root.transform && !bones.Contains(t) && !IsClean(t)).Select(Describe).ToList();
checks.Add("node_transforms_axis_clean", rotated.Count == 0, rotated.Take(8).ToList());
var renderers = root.GetComponentsInChildren<Renderer>(true).Where(r => r is MeshRenderer || r is SkinnedMeshRenderer).ToList();
var names = renderers.Select(r => NameOf(r, root)).OrderBy(n => n, StringComparer.Ordinal).ToList();
var expectedNames = report.Meshes.Properties().Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
checks.Add("mesh_names", names.SequenceEqual(expectedNames), names.Except(expectedNames).Concat(expectedNames.Except(names)).Take(12).ToList());
int triangles = 0;
var wrongTriangles = new List<string>();
var wrongMaterials = new List<string>();
var badNormals = new List<string>();
var badTangents = new List<string>();
var missingUv = new List<string>();
var missingColors = new List<string>();
var low = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
var high = new Vector3(float.MinValue, float.MinValue, float.MinValue);
int withoutTangents = 0;
int dropped = 0;
foreach (Renderer renderer in renderers)
{
Mesh mesh = renderer is SkinnedMeshRenderer skinned ? skinned.sharedMesh : renderer.GetComponent<MeshFilter>().sharedMesh;
string name = NameOf(renderer, root);
var blender = (JObject)report.Meshes[name];
int count = mesh.triangles.Length / 3;
triangles += count;
if (blender != null)
{
// Unity drops triangles without valid normals or area (counted on the Blender side).
int want = (int)blender["triangles"];
int degenerate = blender["degenerate_triangles"] != null ? (int)blender["degenerate_triangles"] : 0;
dropped += want - count;
if (count > want || count < want - degenerate)
wrongTriangles.Add(name + " " + count + "/" + want + " (" + degenerate + " degenerate)");
}
if (blender != null)
{
var want = ((JArray)blender["materials"]).Select(m => (string)m).OrderBy(m => m, StringComparer.Ordinal).ToList();
var got = renderer.sharedMaterials.Select(m => m != null ? m.name : "<none>").OrderBy(m => m, StringComparer.Ordinal).ToList();
if (!want.SequenceEqual(got))
wrongMaterials.Add(name + ": " + string.Join(",", got) + " / " + string.Join(",", want));
bool hasUv = ((JArray)blender["uv_layers"]).Count > 0;
if (hasUv && mesh.uv.Length != mesh.vertexCount)
missingUv.Add(name);
if (((JArray)blender["color_attributes"]).Count > 0 && mesh.colors.Length != mesh.vertexCount)
missingColors.Add(name);
Vector4[] tangents = mesh.tangents;
if (hasUv)
{
if (tangents.Length != mesh.vertexCount || tangents.Any(t => Mathf.Abs(Mathf.Abs(t.w) - 1f) > 1e-3f))
badTangents.Add(name);
}
else if (tangents.Length == 0)
{
withoutTangents++;
}
}
Vector3[] normals = mesh.normals;
if (normals.Length != mesh.vertexCount || normals.Any(n => Mathf.Abs(n.magnitude - 1f) > 1e-3f))
badNormals.Add(name);
Matrix4x4 toRoot = root.transform.worldToLocalMatrix * renderer.transform.localToWorldMatrix;
foreach (Vector3 vertex in mesh.vertices)
{
Vector3 p = toRoot.MultiplyPoint3x4(vertex);
low = Vector3.Min(low, p);
high = Vector3.Max(high, p);
}
}
facts["triangles"] = triangles;
facts["renderers"] = renderers.Count;
facts["meshes_without_uv_and_tangents"] = withoutTangents;
facts["degenerate_triangles_dropped"] = dropped;
checks.Add("triangles", triangles + dropped == (int)report.Data["triangles"] && wrongTriangles.Count == 0,
triangles + " unity / " + report.Data["triangles"] + " blender" + (dropped > 0 ? ", " + dropped + " without valid normals dropped" : "") + " " + string.Join("; ", wrongTriangles.Take(5)));
checks.Add("material_slots", wrongMaterials.Count == 0, wrongMaterials.Take(5).ToList());
checks.Add("normals_unit_length", badNormals.Count == 0, badNormals.Take(8).ToList());
checks.Add("tangents_where_uv", badTangents.Count == 0, badTangents.Take(8).ToList());
checks.Add("uvs_where_blender_has_uv", missingUv.Count == 0, missingUv.Take(8).ToList());
checks.Add("vertex_colors", missingColors.Count == 0, missingColors.Take(8).ToList());
var bounds = (JObject)report.Data["bounds_unity"];
if (bounds != null)
{
var wantLow = ToVector((JArray)bounds["min"]);
var wantHigh = ToVector((JArray)bounds["max"]);
float worst = Mathf.Max(MaxAbs(low - wantLow), MaxAbs(high - wantHigh));
facts["bounds_min"] = new JArray(low.x, low.y, low.z);
facts["bounds_max"] = new JArray(high.x, high.y, high.z);
checks.Add("bounds_axes_scale", worst < BoundsTolerance, string.Format("largest difference {0:0.00000} m; unity {1} {2}", worst, low.ToString("F3"), high.ToString("F3")));
}
JObject empties = (JObject)report.Data["empties"];
JProperty forward = empties?.Properties().FirstOrDefault(p => p.Name.StartsWith("Forward"));
if (forward != null)
{
Transform marker = root.GetComponentsInChildren<Transform>(true).FirstOrDefault(t => t.name.StartsWith("Forward"));
Vector3 at = marker != null ? root.transform.InverseTransformPoint(marker.position) : Vector3.zero;
checks.Add("forward_is_plus_z", marker != null && at.z > 0.1f && Mathf.Abs(at.x) < 1e-3f, at.ToString("F3"));
}
if (report.Skinned)
VerifyRig(modelPath, report, root, checks, facts);
return Result(report, checks, facts);
}
static void VerifyRig(string modelPath, ExportReport report, GameObject root, Checks checks, JObject facts)
{
var skinned = root.GetComponentsInChildren<SkinnedMeshRenderer>(true);
checks.Add("skinned_mesh", skinned.Length >= 1, skinned.Length);
var armature = (JObject)((JObject)report.Data["armatures"]).Properties().First().Value;
var wantBones = ((JArray)armature["bones"]).Select(b => (string)b).OrderBy(b => b, StringComparer.Ordinal).ToList();
string wantRoot = (string)((JArray)armature["roots"])[0];
foreach (SkinnedMeshRenderer renderer in skinned)
{
var got = renderer.bones.Select(b => b.name).Distinct().OrderBy(b => b, StringComparer.Ordinal).ToList();
checks.Add("bone_names " + renderer.name, got.SequenceEqual(wantBones), got.Except(wantBones).Concat(wantBones.Except(got)).ToList());
checks.Add("root_bone " + renderer.name, renderer.rootBone != null && renderer.rootBone.name == wantRoot, renderer.rootBone != null ? renderer.rootBone.name : "none");
}
UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetsAtPath(modelPath);
var avatar = assets.OfType<Avatar>().FirstOrDefault();
checks.Add("generic_avatar", avatar != null && avatar.isValid && !avatar.isHuman, avatar != null ? avatar.name : "none");
var clips = assets.OfType<AnimationClip>().Where(c => !c.name.StartsWith("__preview__")).ToDictionary(c => c.name, c => c);
var wrong = new List<string>();
foreach (JToken clip in report.Clips)
{
string name = (string)clip["name"];
if (!clips.TryGetValue(name, out AnimationClip unity))
{
wrong.Add(name + " missing");
continue;
}
float seconds = (float)clip["seconds"];
float frame = 1f / (float)report.Data["fps"];
if (Mathf.Abs(unity.length - seconds) > frame * 0.5f)
wrong.Add(string.Format("{0} length {1:0.000}/{2:0.000}", name, unity.length, seconds));
if (unity.isLooping != (bool)clip["loop"])
wrong.Add(name + " loop " + unity.isLooping);
double contact = (double)clip["contact_seconds"];
AnimationEvent[] events = AnimationUtility.GetAnimationEvents(unity);
bool hasContact = events.Any(e => e.functionName == LuneModelPostprocessor.ContactEvent && Mathf.Abs(e.time - (float)contact) < frame);
if (contact > 0 && !hasContact)
wrong.Add(name + " contact event");
}
var extra = clips.Keys.Except(report.Clips.Select(c => (string)c["name"])).ToList();
checks.Add("clips", wrong.Count == 0 && extra.Count == 0, wrong.Concat(extra.Select(e => e + " unexpected")).ToList());
facts["clips"] = new JArray(clips.Values.OrderBy(c => c.name).Select(c => new JObject { ["name"] = c.name, ["length"] = c.length, ["loop"] = c.isLooping, ["events"] = AnimationUtility.GetAnimationEvents(c).Length }));
}
/// <summary>
/// Name of a renderer's Blender object. Unity merges the only mesh object of a file into the
/// root GameObject (named after the file); the mesh keeps the object's name.
/// </summary>
static string NameOf(Renderer renderer, GameObject root)
{
if (renderer.gameObject != root)
return renderer.gameObject.name;
Mesh mesh = renderer is SkinnedMeshRenderer skinned ? skinned.sharedMesh : renderer.GetComponent<MeshFilter>().sharedMesh;
return mesh.name;
}
static JObject Result(ExportReport report, Checks checks, JObject facts)
{
return new JObject { ["id"] = report.Id, ["kind"] = report.Kind, ["ok"] = checks.Ok, ["checks"] = checks.List, ["facts"] = facts };
}
static bool IsIdentity(Transform t)
{
return t.localPosition.sqrMagnitude < 1e-10f && IsClean(t);
}
static bool IsClean(Transform t)
{
return Quaternion.Angle(t.localRotation, Quaternion.identity) < 0.01f && (t.localScale - Vector3.one).sqrMagnitude < 1e-8f;
}
static string Describe(Transform t)
{
return string.Format("{0} pos {1} rot {2} scale {3}", t.name, t.localPosition.ToString("F4"), t.localEulerAngles.ToString("F2"), t.localScale.ToString("F4"));
}
static Vector3 ToVector(JArray values)
{
return new Vector3((float)values[0], (float)values[1], (float)values[2]);
}
static float MaxAbs(Vector3 v)
{
return Mathf.Max(Mathf.Abs(v.x), Mathf.Max(Mathf.Abs(v.y), Mathf.Abs(v.z)));
}
}
}