Reproduzierbarer Export aus den Blender-Quellen der Godot-Referenz nach FBX, geprüft auf beiden Seiten; sechs repräsentative Assets vollständig in Unity verifiziert (normale Einheit g01, Einheit mit Skelett Funkenlanze, Turm Grenzwacht, Ancient, Schlundkrone, Vegetation/Kartenrequisiten), dazu Bastion, Kernwacht und Bossgrotte für die Szene. - tools/blender: Manifest mit Exportregeln je Asset, Exportskript mit Prüfungen (Einheiten, Maßstab, Achsen, Vorderseite, Mesh-Namen, Dreiecke, Materialslots, Normalen, Tangenten, Vertexfarben, Maße, Root Bone, Bone-Namen, Clips, Blend Shapes) gegen die Manifeste der Godot-GLBs, FBX-Rundreise, byteidentische Ausgabe (feste Uhrzeit, PYTHONHASHSEED). - Regeln wie beim GLB-Export: HIGH-RES-Modifikatoren weglassen, logische Namen, Skin-Teile zusammenfügen; 180°-Drehung, damit die Vorderseite nach Unitys Achsen-Bake auf +Z liegt (Unity = (-x, y, z) der Godot-Koordinaten). - Unity: Importregeln (Postprocessor), Verifier gegen das Blender-Protokoll, Vorschaubilder; URP-Asset, Linear-Farbraum, URP-Settings nach Assets/Luneraria/Settings/Rendering. - Ergebnis: 176/176 Blender-Prüfungen, 128/128 Unity-Prüfungen, Dreiecke gleich den Godot-GLBs, Maße auf 0,05 mm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
15 KiB
C#
262 lines
15 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.Import);
|
|
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 => r.gameObject.name).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;
|
|
foreach (Renderer renderer in renderers)
|
|
{
|
|
Mesh mesh = renderer is SkinnedMeshRenderer skinned ? skinned.sharedMesh : renderer.GetComponent<MeshFilter>().sharedMesh;
|
|
string name = renderer.gameObject.name;
|
|
var blender = (JObject)report.Meshes[name];
|
|
int count = mesh.triangles.Length / 3;
|
|
triangles += count;
|
|
if (blender != null && (int)blender["triangles"] != count)
|
|
wrongTriangles.Add(name + " " + count + "/" + blender["triangles"]);
|
|
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;
|
|
checks.Add("triangles", triangles == (int)report.Data["triangles"] && wrongTriangles.Count == 0, triangles + " unity / " + report.Data["triangles"] + " blender " + 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 }));
|
|
}
|
|
|
|
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)));
|
|
}
|
|
}
|
|
}
|