From 8513a953854a5a9e93ce028a51352ca978656758 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 07:27:36 +0000 Subject: [PATCH 1/2] Add MeshLabArtifactValidator and document external MeshLab settings Detects three classes of artifacts left by the desktop MeshLab tool when processing FBX before Unity import: - Collapsed UV seams (Merge Close / Remove Duplicate Vertices) - Recalculated normals (UseExistingNormals OFF on FBX export) - Broken or wiped UV1 parameterization (Flat Plane filter) Exposed via Tools/Mesh Lab/Validators/Check Imported MeshLab Artifacts. Operates on selected GameObject, Mesh asset, or imported FBX. Read-only; logs through UvtLog. EXPERIMENTS.md gets a new section listing the safe MeshLab filter and export settings, mapped to the symptoms the validator emits. --- Documentation~/EXPERIMENTS.md | 47 ++++ Editor/MeshLabArtifactValidator.cs | 301 ++++++++++++++++++++++++ Editor/MeshLabArtifactValidator.cs.meta | 11 + 3 files changed, 359 insertions(+) create mode 100644 Editor/MeshLabArtifactValidator.cs create mode 100644 Editor/MeshLabArtifactValidator.cs.meta diff --git a/Documentation~/EXPERIMENTS.md b/Documentation~/EXPERIMENTS.md index 099b5d54..744369c6 100644 --- a/Documentation~/EXPERIMENTS.md +++ b/Documentation~/EXPERIMENTS.md @@ -263,3 +263,50 @@ - `FixColliders()` → `mesh.Clear()` стирает ВСЕ атрибуты. Не стрипать если mesh shared с Renderer. - `FixMeshStripUvs()` — vertex colors `(0,0,0,0)` может быть валидное AO (полная окклюзия). Авто-стрип colors убран. - `SaveAndReimport()` в cleanup — добавлять `bypassPaths` чтобы постпроцессор не вмешался. + +--- + +## External MeshLab (desktop) — Settings to Avoid Artifacts + +> Обновлено: 2026-05-02 +> +> Применимо к FBX, прошедшим через десктопный **MeshLab** (https://www.meshlab.net/) перед импортом в Unity. Не путать с пакетом **UnityMeshLab** — это про внешний инструмент. +> +> Программная проверка симптомов: `MeshLabArtifactValidator` (`Editor/MeshLabArtifactValidator.cs`), +> menu **Tools → Mesh Lab → Validators → Check Imported MeshLab Artifacts** (по выделенному GameObject/asset). + +### Filters — что ломает геометрию + +1. **Merge Close Vertices** — `Distance` ставить очень маленьким (`1e-6`) или отключать filter. + - На дефолтном threshold склеивает per-wedge UV-вершины в одну → **UV seams разъезжаются**, текстура «плывёт» по швам. + - Симптом в Unity: `mesh.vertexCount == уникальных позиций`, при том что в сцене заметны видимые швы. + +2. **Parameterization (UV1)** — выбирать filter с поддержкой существующих UV seams: `Trivial Per-Triangle Parameterization` или `Iso Parametrization`. + - Дефолтный `Parameterization: Flat Plane` ломает развёртку UV1 (весь mesh проецируется в одну плоскость). + - Симптом: UV1 — degenerate triangles или всё в `(0,0)`. + +3. **Remove Duplicate Faces / Remove Duplicate Vertices** — отключать, если есть split UVs. + - В 3ds Max split UVs выглядят как «дубликаты» по позиции (но имеют разные UV/normal). + - MeshLab их склеивает → теряются UV seams и smoothing groups. + +### Export FBX dialog — обязательные настройки + +| Опция | Значение | Зачем | +|-------|----------|-------| +| `WedgeAllChannels` | **ON** | Сохраняет per-wedge UV / normal (т.е. split UVs / smoothing seams). | +| `UseExistingNormals` | **ON** | Не пересчитывает нормали (иначе теряются smoothing groups → flat shading). | + +### Симптомы плохих настроек в Unity (что ловит валидатор) + +- **No UV seams**: `vertexCount` ≈ числу уникальных позиций при наличии нескольких UV-островов → `Merge Close Vertices` или `Remove Duplicate Vertices` склеили wedge. +- **Flat-shaded normals**: > 90% треугольников имеют 3 одинаковых vertex-нормали, совпадающих с face-normal → нормали пересчитаны без сохранения smoothing. +- **Degenerate UV1**: > 5% треугольников UV1 с нулевой площадью — broken parameterization. +- **UV1 all zero**: все UV1 в `(0,0)` — MeshLab стёр UV1 канал. +- **UV1 out of unit**: > 5% UV1-вершин вне `[0..1]` — Flat Plane / Iso без нормализации. + +### Проверка + +Перед импортом FBX из MeshLab в Unity: +1. Прогнать `Tools → Mesh Lab → Validators → Check Imported MeshLab Artifacts` на корне. +2. Если warning — пересохранить из MeshLab с правильными настройками. +3. Если повторяется — отключить проблемный filter и переэкспортировать. diff --git a/Editor/MeshLabArtifactValidator.cs b/Editor/MeshLabArtifactValidator.cs new file mode 100644 index 00000000..975498b0 --- /dev/null +++ b/Editor/MeshLabArtifactValidator.cs @@ -0,0 +1,301 @@ +// MeshLabArtifactValidator.cs — Detect symptoms of bad external MeshLab settings +// in imported meshes. Flags: collapsed UV seams (Merge Close Vertices), +// recalculated normals (UseExistingNormals OFF), broken UV1 parameterization +// (Flat Plane / wiped UV1). See Documentation~/EXPERIMENTS.md, section +// "External MeshLab — Settings to Avoid Artifacts". + +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; + +namespace SashaRX.UnityMeshLab +{ + public static class MeshLabArtifactValidator + { + const float FlatNormalDot = 0.9999f; // cos(0.81°) — vertex normal matches face normal + const float FlatShadedTriThreshold = 0.90f; // ≥90% triangles flat-shaded → suspicious + const float DegenerateAreaEpsilon = 1e-12f; + const float DegenerateUv1Threshold = 0.05f; // ≥5% degenerate UV1 tris → suspicious + const float Uv1OutsideUnitThreshold = 0.05f; // ≥5% UV1 verts outside [0..1] → suspicious + const int MinVertsForSeamCheck = 16; // skip trivial meshes + + public class Report + { + public string meshName; + public int vertexCount; + public int triangleCount; + public int submeshCount; + + // Symptom 1: Merge Close Vertices ate UV seams + public int uniquePositionCount; + public float splitVertexRatio; // (vertexCount - uniquePositionCount) / vertexCount + public bool seamsLikelyMerged; + + // Symptom 2: UseExistingNormals OFF → RecalculateNormals without smoothing + public bool hasNormals; + public int flatShadedTriCount; + public float flatShadedRatio; + public bool normalsLikelyRecalculated; + + // Symptom 3: Bad UV1 parameterization + public bool hasUv1; + public int degenerateUv1TriCount; + public float degenerateUv1Ratio; + public bool uv1AllZero; + public int uv1OutsideUnitCount; + public float uv1OutsideUnitRatio; + + public bool HasIssues => + seamsLikelyMerged + || normalsLikelyRecalculated + || (hasUv1 && (uv1AllZero + || degenerateUv1Ratio >= DegenerateUv1Threshold + || uv1OutsideUnitRatio >= Uv1OutsideUnitThreshold)); + + public string Summary => + $"'{meshName}' verts={vertexCount} tris={triangleCount} sub={submeshCount} | " + + $"uniquePos={uniquePositionCount} splitRatio={splitVertexRatio:P1} | " + + (hasNormals + ? $"flatTri={flatShadedTriCount}/{triangleCount} ({flatShadedRatio:P1}) | " + : "noNormals | ") + + (hasUv1 + ? $"uv1 degen={degenerateUv1TriCount} ({degenerateUv1Ratio:P1}) " + + $"oob={uv1OutsideUnitCount} ({uv1OutsideUnitRatio:P1}) zero={uv1AllZero}" + : "noUv1"); + } + + /// + /// Analyze a mesh for symptoms of bad external MeshLab export settings. + /// Pure read-only inspection — does not mutate the mesh. + /// + public static Report Validate(Mesh mesh) + { + if (mesh == null) return null; + + var report = new Report + { + meshName = mesh.name, + vertexCount = mesh.vertexCount, + submeshCount = mesh.subMeshCount, + }; + + var positions = mesh.vertices; + var normals = mesh.normals; + var triangles = mesh.triangles; + report.triangleCount = triangles.Length / 3; + + var uv1List = new List(); + mesh.GetUVs(1, uv1List); + report.hasUv1 = uv1List.Count == report.vertexCount && report.vertexCount > 0; + + CheckCollapsedSeams(report, positions); + CheckRecalculatedNormals(report, positions, normals, triangles); + if (report.hasUv1) CheckUv1Parameterization(report, uv1List, triangles); + + return report; + } + + /// + /// Validate and emit log lines via UvtLog. Info on clean, Warn on issues. + /// + public static Report ValidateAndLog(Mesh mesh, string contextLabel = null) + { + var report = Validate(mesh); + if (report == null) return null; + string ctx = string.IsNullOrEmpty(contextLabel) ? "" : $"[{contextLabel}] "; + if (report.HasIssues) + { + UvtLog.Warn($"[MeshLabValidator] {ctx}{report.Summary}"); + if (report.seamsLikelyMerged) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV seams likely merged " + + "(MeshLab 'Merge Close Vertices' or 'Remove Duplicate Vertices')."); + if (report.normalsLikelyRecalculated) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': normals likely recalculated " + + "(MeshLab FBX export 'UseExistingNormals' = OFF)."); + if (report.hasUv1 && report.uv1AllZero) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV1 is all zero — " + + "MeshLab parameterization wiped or never written."); + if (report.hasUv1 && report.degenerateUv1Ratio >= DegenerateUv1Threshold) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': UV1 has " + + $"{report.degenerateUv1Ratio:P1} degenerate triangles — likely 'Flat Plane' parameterization."); + if (report.hasUv1 && report.uv1OutsideUnitRatio >= Uv1OutsideUnitThreshold) + UvtLog.Warn($"[MeshLabValidator] {ctx}'{report.meshName}': " + + $"{report.uv1OutsideUnitRatio:P1} of UV1 verts outside [0..1] — parameterization not normalized."); + } + else + { + UvtLog.Info($"[MeshLabValidator] {ctx}{report.Summary} — clean."); + } + return report; + } + + // ── Symptom 1 ───────────────────────────────────────────────────────── + + static void CheckCollapsedSeams(Report report, Vector3[] positions) + { + if (positions == null || positions.Length == 0) return; + + var unique = new HashSet<(int, int, int)>(); + for (int i = 0; i < positions.Length; i++) + unique.Add(QuantizePos(positions[i])); + + report.uniquePositionCount = unique.Count; + report.splitVertexRatio = positions.Length > 0 + ? (positions.Length - unique.Count) / (float)positions.Length + : 0f; + + // Suspicious: no split vertices at all on a non-trivial mesh with + // multiple submeshes or UV1 — almost always means seams got merged. + bool nonTrivial = positions.Length >= MinVertsForSeamCheck; + bool hasSeamSignal = report.submeshCount > 1 || report.hasUv1; + report.seamsLikelyMerged = nonTrivial + && hasSeamSignal + && report.splitVertexRatio < 1e-4f; + } + + // ── Symptom 2 ───────────────────────────────────────────────────────── + + static void CheckRecalculatedNormals(Report report, Vector3[] positions, Vector3[] normals, int[] triangles) + { + report.hasNormals = normals != null && normals.Length == positions.Length && normals.Length > 0; + if (!report.hasNormals || triangles.Length < 3) return; + + int triCount = triangles.Length / 3; + int flatCount = 0; + for (int t = 0; t < triCount; t++) + { + int i0 = triangles[t * 3]; + int i1 = triangles[t * 3 + 1]; + int i2 = triangles[t * 3 + 2]; + if (i0 >= positions.Length || i1 >= positions.Length || i2 >= positions.Length) continue; + + Vector3 faceN = Vector3.Cross(positions[i1] - positions[i0], positions[i2] - positions[i0]); + if (faceN.sqrMagnitude < DegenerateAreaEpsilon) continue; + faceN.Normalize(); + + Vector3 n0 = normals[i0]; + Vector3 n1 = normals[i1]; + Vector3 n2 = normals[i2]; + if (n0.sqrMagnitude < 0.5f || n1.sqrMagnitude < 0.5f || n2.sqrMagnitude < 0.5f) continue; + + float d0 = Vector3.Dot(n0, faceN); + float d1 = Vector3.Dot(n1, faceN); + float d2 = Vector3.Dot(n2, faceN); + if (d0 >= FlatNormalDot && d1 >= FlatNormalDot && d2 >= FlatNormalDot) + flatCount++; + } + + report.flatShadedTriCount = flatCount; + report.flatShadedRatio = triCount > 0 ? flatCount / (float)triCount : 0f; + report.normalsLikelyRecalculated = triCount >= MinVertsForSeamCheck + && report.flatShadedRatio >= FlatShadedTriThreshold; + } + + // ── Symptom 3 ───────────────────────────────────────────────────────── + + static void CheckUv1Parameterization(Report report, List uv1, int[] triangles) + { + int triCount = triangles.Length / 3; + int degenerate = 0; + for (int t = 0; t < triCount; t++) + { + int i0 = triangles[t * 3]; + int i1 = triangles[t * 3 + 1]; + int i2 = triangles[t * 3 + 2]; + if (i0 >= uv1.Count || i1 >= uv1.Count || i2 >= uv1.Count) continue; + + Vector2 a = uv1[i1] - uv1[i0]; + Vector2 b = uv1[i2] - uv1[i0]; + float cross = a.x * b.y - a.y * b.x; + if (cross * cross < DegenerateAreaEpsilon) degenerate++; + } + report.degenerateUv1TriCount = degenerate; + report.degenerateUv1Ratio = triCount > 0 ? degenerate / (float)triCount : 0f; + + int oob = 0; + bool allZero = true; + for (int i = 0; i < uv1.Count; i++) + { + Vector2 uv = uv1[i]; + if (uv.sqrMagnitude > 1e-12f) allZero = false; + if (uv.x < 0f || uv.x > 1f || uv.y < 0f || uv.y > 1f) oob++; + } + report.uv1AllZero = allZero; + report.uv1OutsideUnitCount = oob; + report.uv1OutsideUnitRatio = uv1.Count > 0 ? oob / (float)uv1.Count : 0f; + } + + // ── Utility ─────────────────────────────────────────────────────────── + + static (int, int, int) QuantizePos(Vector3 p) => + (Mathf.RoundToInt(p.x * 100000f), + Mathf.RoundToInt(p.y * 100000f), + Mathf.RoundToInt(p.z * 100000f)); + + // ── Menu integration ────────────────────────────────────────────────── + + const string MenuPath = "Tools/Mesh Lab/Validators/Check Imported MeshLab Artifacts"; + + [MenuItem(MenuPath, true)] + static bool ValidateSelectionMenuValidate() => + CollectMeshes(Selection.objects).Count > 0; + + [MenuItem(MenuPath, false, 200)] + static void ValidateSelectionMenu() + { + var meshes = CollectMeshes(Selection.objects); + if (meshes.Count == 0) + { + UvtLog.Warn("[MeshLabValidator] Selection contains no meshes."); + return; + } + + int issueCount = 0; + foreach (var (mesh, label) in meshes) + { + var r = ValidateAndLog(mesh, label); + if (r != null && r.HasIssues) issueCount++; + } + UvtLog.Info($"[MeshLabValidator] Checked {meshes.Count} mesh(es), {issueCount} with issues."); + } + + static List<(Mesh mesh, string label)> CollectMeshes(Object[] objects) + { + var result = new List<(Mesh, string)>(); + var seen = new HashSet(); + if (objects == null) return result; + + foreach (var obj in objects) + { + if (obj == null) continue; + + if (obj is Mesh m && m != null && seen.Add(m)) + { + result.Add((m, AssetDatabase.GetAssetPath(m))); + continue; + } + + if (obj is GameObject go && go != null) + { + foreach (var mf in go.GetComponentsInChildren(true)) + if (mf != null && mf.sharedMesh != null && seen.Add(mf.sharedMesh)) + result.Add((mf.sharedMesh, mf.gameObject.name)); + foreach (var smr in go.GetComponentsInChildren(true)) + if (smr != null && smr.sharedMesh != null && seen.Add(smr.sharedMesh)) + result.Add((smr.sharedMesh, smr.gameObject.name)); + continue; + } + + string path = AssetDatabase.GetAssetPath(obj); + if (!string.IsNullOrEmpty(path)) + { + foreach (var sub in AssetDatabase.LoadAllAssetsAtPath(path)) + if (sub is Mesh subMesh && subMesh != null && seen.Add(subMesh)) + result.Add((subMesh, $"{System.IO.Path.GetFileName(path)}::{subMesh.name}")); + } + } + + return result; + } + } +} diff --git a/Editor/MeshLabArtifactValidator.cs.meta b/Editor/MeshLabArtifactValidator.cs.meta new file mode 100644 index 00000000..c40bacbe --- /dev/null +++ b/Editor/MeshLabArtifactValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae73dea31d7b46bc81c40f977856f7f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 81b6a329668ef9cc71e1879c88e4d15e6ccfc557 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 07:44:50 +0000 Subject: [PATCH 2/2] Preserve mesh.name in variant FBX export pipeline Object.Instantiate(resultMesh) produced clones named "Original(Clone)" which Unity FBX Exporter writes with empty/auto-generated mesh node names; the FBX SDK then defaults the FbxMesh node to the scene name. On reimport every mesh asset shows up as "Scene", erasing per-LOD and per-renderer identifiers needed by sidecar lookup, scene relinking and the new MeshLabArtifactValidator report. Mirrors the existing fix at CopyVertexDataToClone (in-place overwrite path): assign mesh.name = ResolveExportMeshName(...) before handing the mesh to ExportObjects. --- Editor/Tools/LightmapTransferTool.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Editor/Tools/LightmapTransferTool.cs b/Editor/Tools/LightmapTransferTool.cs index 7351ef56..eb582460 100644 --- a/Editor/Tools/LightmapTransferTool.cs +++ b/Editor/Tools/LightmapTransferTool.cs @@ -1952,7 +1952,14 @@ void ExportFbx(bool overwriteSource) var meshRendererTemplates = new Dictionary(); foreach (var (entry, resultMesh) in entries) { + string meshName = ResolveExportMeshName(entry, resultMesh); var exportMesh = UnityEngine.Object.Instantiate(resultMesh); + // Without an explicit name, Object.Instantiate produces "X(Clone)" + // and Unity's FBX Exporter falls back to the FBX scene name + // ("Scene") when writing the FbxMesh node — every reimported mesh + // ends up named "Scene". Pin the canonical name now so the FBX + // node and post-reimport mesh asset stay aligned with the source. + exportMesh.name = meshName; // Copy UV channels from fbxMesh first (base UVs), // then from originalMesh (has AO and other tool modifications). if (entry.fbxMesh != null) @@ -1974,7 +1981,6 @@ void ExportFbx(bool overwriteSource) if (uv2Donor != null) MergeUvComponentFromDonor(exportMesh, uv2Donor, aoUvChannel, aoUvComponent); } - string meshName = ResolveExportMeshName(entry, resultMesh); meshReplacements[meshName] = exportMesh; if (entry.renderer != null) meshRendererTemplates[meshName] = entry.renderer; @@ -2030,6 +2036,9 @@ void ExportFbx(bool overwriteSource) } var newMf = child.AddComponent(); var exportMesh = UnityEngine.Object.Instantiate(resultMesh); + // See the matching note in the replace-existing branch above: + // empty/Clone names cause Unity FBX Exporter to write "Scene". + exportMesh.name = meshName; if (entry.fbxMesh != null) PreserveUvChannels(exportMesh, entry.fbxMesh); if (entry.originalMesh != null && entry.originalMesh != entry.fbxMesh)