using System; using System.Collections.Generic; using System.IO; using System.Linq; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; namespace Luneraria.EditorTools { /// /// 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. /// public static class LuneAssetVerifier { const string Root = "Assets/Luneraria/Art"; const float BoundsTolerance = 0.002f; /// Re-imports every pipeline model (after import rules or the render pipeline changed). 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(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(); foreach (var skinned in root.GetComponentsInChildren(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(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(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(); var wrongMaterials = new List(); var badNormals = new List(); var badTangents = new List(); var missingUv = new List(); var missingColors = new List(); 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().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 : "").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(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(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().FirstOrDefault(); checks.Add("generic_avatar", avatar != null && avatar.isValid && !avatar.isHuman, avatar != null ? avatar.name : "none"); var clips = assets.OfType().Where(c => !c.name.StartsWith("__preview__")).ToDictionary(c => c.name, c => c); var wrong = new List(); 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 })); } /// /// 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. /// 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().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))); } } }