using System.Collections.Generic; using MeshProcess; using UnityEngine; #if UNITY_EDITOR using UnityEditor; using UnityEditor.SceneManagement; #endif /// /// Editor tool that runs V-HACD (approximate convex decomposition via the native /// plugin) on an environment mesh and bakes the resulting convex hulls into SAVED mesh assets + child /// MeshCollider GameObjects — the same output plumbing as , only /// the decomposition backend differs (V-HACD's voxel solver instead of the surface-clustering /// heuristic). Tune the decomposition on the sibling component (its m_parameters), /// then run "Bake VHACD Colliders". /// /// The hulls are real child GameObjects referencing hull-mesh assets under /// Assets/Generated/VHACDColliders, so they survive domain reload / play mode / scene save /// (unlike an in-memory-only bake). /// [DisallowMultipleComponent] [RequireComponent(typeof(VHACD))] public class VHACDColliderBaker : MonoBehaviour { [Tooltip("Optional. If empty, the shared mesh of a MeshFilter on this object (or a child) is used.")] [SerializeField] private Mesh sourceMesh; [Header("Output")] [Tooltip("If true, generated colliders inherit the source object's layer. Otherwise 'Collider Layer' is used.")] [SerializeField] private bool useSourceLayer = true; [SerializeField] private int colliderLayer; [SerializeField, HideInInspector] private List generatedAssetPaths = new List(); private const string ContainerName = "VHACDColliders (generated)"; private const string AssetFolder = "Assets/Generated/VHACDColliders"; #if UNITY_EDITOR [ContextMenu("Bake VHACD Colliders")] public void Bake() { Mesh mesh = sourceMesh; Transform meshSpace = transform; if (!mesh) { MeshFilter mf = GetComponentInChildren(); if (mf && mf.sharedMesh) { mesh = mf.sharedMesh; meshSpace = mf.transform; } } if (!mesh) { Debug.LogError($"[VHACDColliderBaker] '{name}': no source mesh (assign one or add a MeshFilter).", this); return; } VHACD vhacd = GetComponent(); if (!vhacd) vhacd = gameObject.AddComponent(); Clear(); // remove previous container + assets first string baseName = ResolveAssetBaseName(); // V-HACD reads mesh.vertices raw => hulls are in the mesh's LOCAL space, so an identity // container under meshSpace places them correctly (same convention as ConcealmentColliderBaker). List hulls = vhacd.GenerateConvexMeshes(mesh); if (hulls == null || hulls.Count == 0) { Debug.LogWarning($"[VHACDColliderBaker] '{name}': V-HACD produced no convex hulls.", this); return; } EnsureFolder(); Transform container = new GameObject(ContainerName).transform; container.SetParent(meshSpace, false); container.localPosition = Vector3.zero; container.localRotation = Quaternion.identity; container.localScale = Vector3.one; int outLayer = useSourceLayer ? gameObject.layer : colliderLayer; int made = 0; for (int i = 0; i < hulls.Count; i++) { Mesh cm = hulls[i]; if (!cm || cm.vertexCount < 4) { if (cm) DestroyImmediate(cm); continue; } // convex hull needs >= 4 verts cm.name = $"{baseName}_{made}"; cm.RecalculateBounds(); string path = $"{AssetFolder}/{baseName}_{made}.asset"; AssetDatabase.CreateAsset(cm, path); generatedAssetPaths.Add(path); GameObject go = new GameObject($"hull_{made}"); go.transform.SetParent(container, false); go.layer = outLayer; MeshCollider col = go.AddComponent(); col.sharedMesh = cm; col.convex = true; col.isTrigger = false; made++; } AssetDatabase.SaveAssets(); EditorUtility.SetDirty(this); EditorSceneManager.MarkSceneDirty(gameObject.scene); Debug.Log($"[VHACDColliderBaker] '{name}': baked {made} convex collider(s) via V-HACD ({hulls.Count} hull(s) returned). Save the scene to persist.", this); } [ContextMenu("Clear Baked Colliders")] public void Clear() { for (int i = 0; i < generatedAssetPaths.Count; i++) if (!string.IsNullOrEmpty(generatedAssetPaths[i])) AssetDatabase.DeleteAsset(generatedAssetPaths[i]); generatedAssetPaths.Clear(); var all = GetComponentsInChildren(true); for (int i = 0; i < all.Length; i++) if (all[i] && all[i] != transform && all[i].name == ContainerName) DestroyImmediate(all[i].gameObject); EditorUtility.SetDirty(this); if (gameObject.scene.IsValid()) EditorSceneManager.MarkSceneDirty(gameObject.scene); } private static void EnsureFolder() { if (!AssetDatabase.IsValidFolder("Assets/Generated")) AssetDatabase.CreateFolder("Assets", "Generated"); if (!AssetDatabase.IsValidFolder(AssetFolder)) AssetDatabase.CreateFolder("Assets/Generated", "VHACDColliders"); } /// /// Readable, deterministic base name for the generated collider-mesh assets: /// - Prefab stage: "{assetPath without 'Assets/' and '.prefab'}/{path below the prefab root}" /// - Scene: "{sceneName}/{full hierarchy path from the scene root}" /// Sanitized to a filesystem-safe token; on collision with a DIFFERENT baker's assets, "_2", "_3", … is appended. /// private string ResolveAssetBaseName() { string raw; PrefabStage stage = PrefabStageUtility.GetPrefabStage(gameObject); if (stage) { string ap = stage.assetPath; if (ap.StartsWith("Assets/")) ap = ap.Substring("Assets/".Length); if (ap.EndsWith(".prefab")) ap = ap.Substring(0, ap.Length - ".prefab".Length); // Path below the prefab root — the root's name already lives in the asset path. string hier = GetHierarchyPath(transform, stage.prefabContentsRoot.transform); raw = hier.Length == 0 ? ap : ap + "/" + hier; } else { string scene = string.IsNullOrEmpty(gameObject.scene.name) ? "Untitled" : gameObject.scene.name; raw = scene + "/" + GetHierarchyPath(transform, null); } string baseName = SanitizeFileName(raw); if (baseName.Length == 0) baseName = "Bake"; // Clear() already removed THIS baker's assets, so a hit here means another baker resolved to the // same name -> disambiguate. The per-collider "_0" file is the presence marker of a base name. string candidate = baseName; for (int dup = 2; System.IO.File.Exists($"{AssetFolder}/{candidate}_0.asset"); dup++) candidate = $"{baseName}_{dup}"; return candidate; } /// Slash-joined transform names from up to (but excluding) (null = up to the root). private static string GetHierarchyPath(Transform t, Transform stopAt) { var names = new List(); for (Transform cur = t; cur && cur != stopAt; cur = cur.parent) names.Add(cur.name); names.Reverse(); return string.Join("/", names); } /// Collapses every run of non-[A-Za-z0-9-] characters to a single '_' and trims leading/trailing '_'. private static string SanitizeFileName(string s) { var sb = new System.Text.StringBuilder(s.Length); bool pendingSep = false; for (int i = 0; i < s.Length; i++) { char c = s[i]; bool keep = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-'; if (keep) { if (pendingSep && sb.Length > 0) sb.Append('_'); pendingSep = false; sb.Append(c); } else pendingSep = true; } return sb.ToString(); } #endif }