Improve FBX/prefab import setup, CharacterSpawner, and add M3 inspect/rebuild helpers. Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
15 KiB
C#
358 lines
15 KiB
C#
#if UNITY_EDITOR
|
||
using System.IO;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using UnityEngine.Rendering;
|
||
using YooAsset.Editor;
|
||
|
||
/// <summary>
|
||
/// M3:角色目录约定 Assets/Bundles/Characters/{ActorName}/ + Art 包收集。
|
||
/// </summary>
|
||
public static class SetupM3Character
|
||
{
|
||
public const string ActorName = "MaleExplorer";
|
||
public const string ActorRoot = "Assets/Bundles/Characters/" + ActorName;
|
||
const string FbxPath = ActorRoot + "/" + ActorName + ".fbx";
|
||
const string BasePath = ActorRoot + "/" + ActorName + "_base.png";
|
||
const string MixPath = ActorRoot + "/" + ActorName + "_mix.png";
|
||
const string ColorPath = ActorRoot + "/" + ActorName + "_color.png";
|
||
const string NormalPath = ActorRoot + "/" + ActorName + "_normal.png";
|
||
const string AoPath = ActorRoot + "/" + ActorName + "_ao.png";
|
||
const string MetallicPath = ActorRoot + "/" + ActorName + "_metallic.png";
|
||
const string RoughnessPath = ActorRoot + "/" + ActorName + "_roughness.png";
|
||
const string ConceptPath = ActorRoot + "/concept.png";
|
||
const string MatPath = ActorRoot + "/" + ActorName + ".mat";
|
||
const string PrefabPath = ActorRoot + "/" + ActorName + ".prefab";
|
||
const string ArtPackage = "Art";
|
||
const string XPbrShaderName = "Test/XPbr";
|
||
|
||
[MenuItem("AIGame/M3/Setup Character Prefab + Art Collector")]
|
||
public static string SetupAll()
|
||
{
|
||
AssetDatabase.Refresh();
|
||
var a = EnsureTextureImports();
|
||
var b = EnsureMaterialAndPrefab();
|
||
var c = EnsureArtCollector();
|
||
CleanupLegacyLayout();
|
||
AssetDatabase.SaveAssets();
|
||
AssetDatabase.Refresh();
|
||
return $"ok textures={a} prefab={b} art={c}";
|
||
}
|
||
|
||
public static string EnsureTextureImports()
|
||
{
|
||
var paths = new[] { BasePath, MixPath, ColorPath, NormalPath, AoPath, MetallicPath, RoughnessPath, ConceptPath };
|
||
var ok = 0;
|
||
foreach (var path in paths)
|
||
{
|
||
if (!File.Exists(path)) continue;
|
||
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
|
||
if (importer == null) continue;
|
||
var isMix = path == MixPath;
|
||
var isNormal = path == NormalPath;
|
||
var isData = path == MetallicPath || path == RoughnessPath || path == AoPath || isMix;
|
||
importer.textureType = isNormal ? TextureImporterType.NormalMap : TextureImporterType.Default;
|
||
importer.sRGBTexture = !isNormal && !isData;
|
||
importer.mipmapEnabled = true;
|
||
importer.alphaIsTransparency = path == ConceptPath || path == BasePath;
|
||
importer.SaveAndReimport();
|
||
ok++;
|
||
}
|
||
|
||
if (File.Exists(FbxPath))
|
||
{
|
||
var model = AssetImporter.GetAtPath(FbxPath) as ModelImporter;
|
||
if (model != null)
|
||
{
|
||
// Blender 按米导出;FBX 文件单位常带 0.01,useFileScale 会把角色缩成 ~1cm
|
||
model.globalScale = 1f;
|
||
model.useFileScale = false;
|
||
model.bakeAxisConversion = true;
|
||
// XPbr:保留 externalObjects 材质重映射;无 .mat 时再 None
|
||
model.materialImportMode = File.Exists(MatPath)
|
||
? ModelImporterMaterialImportMode.ImportViaMaterialDescription
|
||
: ModelImporterMaterialImportMode.None;
|
||
model.meshCompression = ModelImporterMeshCompression.Medium;
|
||
model.isReadable = false;
|
||
model.SaveAndReimport();
|
||
}
|
||
}
|
||
|
||
return $"imported={ok} fbx={(File.Exists(FbxPath) ? "yes" : "no")}";
|
||
}
|
||
|
||
public static string EnsureMaterialAndPrefab()
|
||
{
|
||
if (File.Exists(FbxPath) && (File.Exists(BasePath) || File.Exists(ColorPath)))
|
||
return EnsureMeshPrefab();
|
||
if (File.Exists(ConceptPath))
|
||
return EnsureBillboardPrefab();
|
||
return "missing MaleExplorer mesh/concept under " + ActorRoot;
|
||
}
|
||
|
||
static string EnsureMeshPrefab()
|
||
{
|
||
var baseMap = AssetDatabase.LoadAssetAtPath<Texture2D>(BasePath);
|
||
var mixMap = AssetDatabase.LoadAssetAtPath<Texture2D>(MixPath);
|
||
var color = baseMap != null ? baseMap : AssetDatabase.LoadAssetAtPath<Texture2D>(ColorPath);
|
||
if (color == null) return "base/color texture missing";
|
||
|
||
var xpbr = Shader.Find(XPbrShaderName);
|
||
var urpLit = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
|
||
var useXpbr = xpbr != null && mixMap != null;
|
||
var shader = useXpbr ? xpbr : urpLit;
|
||
|
||
var mat = AssetDatabase.LoadAssetAtPath<Material>(MatPath);
|
||
if (mat == null)
|
||
{
|
||
mat = new Material(shader);
|
||
AssetDatabase.CreateAsset(mat, MatPath);
|
||
}
|
||
else
|
||
{
|
||
mat.shader = shader;
|
||
}
|
||
|
||
if (useXpbr)
|
||
{
|
||
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", color);
|
||
if (mat.HasProperty("_MixTex")) mat.SetTexture("_MixTex", mixMap);
|
||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", Color.white);
|
||
if (mat.HasProperty("_Metallic")) mat.SetFloat("_Metallic", 1f);
|
||
if (mat.HasProperty("_Roughness")) mat.SetFloat("_Roughness", 1f);
|
||
if (mat.HasProperty("_AO")) mat.SetFloat("_AO", 1f);
|
||
if (mat.HasProperty("_AlphaTest")) mat.SetFloat("_AlphaTest", 0f);
|
||
mat.DisableKeyword("_ALPHATEST_ON");
|
||
}
|
||
else
|
||
{
|
||
var normal = AssetDatabase.LoadAssetAtPath<Texture2D>(NormalPath);
|
||
var ao = AssetDatabase.LoadAssetAtPath<Texture2D>(AoPath);
|
||
var metallic = AssetDatabase.LoadAssetAtPath<Texture2D>(MetallicPath);
|
||
var roughness = AssetDatabase.LoadAssetAtPath<Texture2D>(RoughnessPath);
|
||
if (mat.HasProperty("_BaseMap")) mat.SetTexture("_BaseMap", color);
|
||
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", color);
|
||
if (mat.HasProperty("_BumpMap") && normal != null)
|
||
{
|
||
mat.SetTexture("_BumpMap", normal);
|
||
mat.EnableKeyword("_NORMALMAP");
|
||
}
|
||
if (mat.HasProperty("_OcclusionMap") && ao != null)
|
||
mat.SetTexture("_OcclusionMap", ao);
|
||
if (mat.HasProperty("_MetallicGlossMap") && metallic != null)
|
||
{
|
||
mat.SetTexture("_MetallicGlossMap", metallic);
|
||
mat.EnableKeyword("_METALLICSPECGLOSSMAP");
|
||
}
|
||
if (mat.HasProperty("_Smoothness") && roughness != null)
|
||
mat.SetFloat("_Smoothness", 0.45f);
|
||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", Color.white);
|
||
}
|
||
EditorUtility.SetDirty(mat);
|
||
|
||
var modelRoot = AssetDatabase.LoadAssetAtPath<GameObject>(FbxPath);
|
||
if (modelRoot == null) return "fbx not loaded";
|
||
|
||
var instance = (GameObject)PrefabUtility.InstantiatePrefab(modelRoot);
|
||
instance.name = ActorName;
|
||
foreach (var renderer in instance.GetComponentsInChildren<Renderer>())
|
||
renderer.sharedMaterial = mat;
|
||
|
||
// Blender/混元 FBX:身高常在本地 Z。转到 Unity Y-up,并面向 -Z(相机常见朝向)。
|
||
UprightBlenderMesh(instance);
|
||
|
||
// 落地参考盘(扶正定标后再放)
|
||
PlaceShadowDisc(instance, urpLit != null ? urpLit : shader);
|
||
|
||
PrefabUtility.SaveAsPrefabAsset(instance, PrefabPath);
|
||
Object.DestroyImmediate(instance);
|
||
return PrefabPath + (useXpbr ? " (XPbr)" : " (URP Lit)");
|
||
}
|
||
|
||
static void UprightBlenderMesh(GameObject instance)
|
||
{
|
||
instance.transform.localPosition = Vector3.zero;
|
||
instance.transform.localScale = Vector3.one;
|
||
// 混元/Blender:头在 +Z。Unity 用 +90°X 扶正;Y=0 使正面朝向相机(-Z)。
|
||
instance.transform.localRotation = Quaternion.Euler(90f, 0f, 0f);
|
||
|
||
var bounds = EncapsulateMeshBounds(instance);
|
||
if (bounds.size.y < 0.001f)
|
||
{
|
||
if (bounds.size.z >= bounds.size.x)
|
||
instance.transform.localRotation = Quaternion.Euler(90f, 0f, 0f);
|
||
else
|
||
instance.transform.localRotation = Quaternion.Euler(0f, 0f, 90f);
|
||
bounds = EncapsulateMeshBounds(instance);
|
||
}
|
||
|
||
if (bounds.size.y > 0.001f)
|
||
instance.transform.localScale = Vector3.one * (1.8f / bounds.size.y);
|
||
|
||
bounds = EncapsulateMeshBounds(instance);
|
||
instance.transform.position = new Vector3(0f, -bounds.min.y, 0f);
|
||
}
|
||
|
||
static Bounds EncapsulateMeshBounds(GameObject root)
|
||
{
|
||
Bounds bounds = default;
|
||
var has = false;
|
||
foreach (var r in root.GetComponentsInChildren<Renderer>())
|
||
{
|
||
if (r.name == "ShadowDisc") continue;
|
||
if (!has)
|
||
{
|
||
bounds = r.bounds;
|
||
has = true;
|
||
}
|
||
else bounds.Encapsulate(r.bounds);
|
||
}
|
||
return has ? bounds : new Bounds(root.transform.position, Vector3.one * 0.01f);
|
||
}
|
||
|
||
static void PlaceShadowDisc(GameObject instance, Shader shader)
|
||
{
|
||
var existing = instance.transform.Find("ShadowDisc");
|
||
if (existing != null)
|
||
Object.DestroyImmediate(existing.gameObject);
|
||
|
||
const string shadowMatPath = ActorRoot + "/" + ActorName + "_Shadow.mat";
|
||
var gMat = AssetDatabase.LoadAssetAtPath<Material>(shadowMatPath);
|
||
if (gMat == null)
|
||
{
|
||
gMat = new Material(shader);
|
||
AssetDatabase.CreateAsset(gMat, shadowMatPath);
|
||
}
|
||
else gMat.shader = shader;
|
||
if (gMat.HasProperty("_BaseColor")) gMat.SetColor("_BaseColor", new Color(0.12f, 0.12f, 0.14f, 1f));
|
||
if (gMat.HasProperty("_Color")) gMat.color = new Color(0.12f, 0.12f, 0.14f, 1f);
|
||
// 不透明,避免 URP 透明洋红
|
||
if (gMat.HasProperty("_Surface")) gMat.SetFloat("_Surface", 0f);
|
||
gMat.DisableKeyword("_SURFACE_TYPE_TRANSPARENT");
|
||
gMat.renderQueue = -1;
|
||
EditorUtility.SetDirty(gMat);
|
||
|
||
var ground = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
|
||
ground.name = "ShadowDisc";
|
||
ground.transform.SetParent(instance.transform, false);
|
||
var bounds = EncapsulateMeshBounds(instance);
|
||
var localY = instance.transform.InverseTransformPoint(new Vector3(0f, bounds.min.y + 0.02f, 0f)).y;
|
||
ground.transform.localPosition = new Vector3(0f, localY, 0f);
|
||
ground.transform.localRotation = Quaternion.identity;
|
||
ground.transform.localScale = new Vector3(0.55f, 0.012f, 0.55f);
|
||
Object.DestroyImmediate(ground.GetComponent<Collider>());
|
||
var mr = ground.GetComponent<MeshRenderer>();
|
||
mr.sharedMaterial = gMat;
|
||
mr.shadowCastingMode = ShadowCastingMode.Off;
|
||
mr.receiveShadows = false;
|
||
}
|
||
|
||
static string EnsureBillboardPrefab()
|
||
{
|
||
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(ConceptPath);
|
||
if (tex == null) return "concept not loaded";
|
||
|
||
var shader = Shader.Find("Universal Render Pipeline/Unlit");
|
||
if (shader == null) shader = Shader.Find("Unlit/Transparent");
|
||
if (shader == null) shader = Shader.Find("Sprites/Default");
|
||
|
||
var mat = AssetDatabase.LoadAssetAtPath<Material>(MatPath);
|
||
if (mat == null)
|
||
{
|
||
mat = new Material(shader);
|
||
AssetDatabase.CreateAsset(mat, MatPath);
|
||
}
|
||
else mat.shader = shader;
|
||
|
||
if (mat.HasProperty("_BaseMap")) mat.SetTexture("_BaseMap", tex);
|
||
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", tex);
|
||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", Color.white);
|
||
if (mat.HasProperty("_Surface")) mat.SetFloat("_Surface", 1f);
|
||
mat.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
|
||
mat.renderQueue = (int)RenderQueue.Transparent;
|
||
EditorUtility.SetDirty(mat);
|
||
|
||
var root = new GameObject(ActorName);
|
||
root.transform.localScale = new Vector3(1.2f, 2.0f, 1f);
|
||
var quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
|
||
quad.name = "Billboard";
|
||
quad.transform.SetParent(root.transform, false);
|
||
quad.transform.localPosition = new Vector3(0f, 1.0f, 0f);
|
||
Object.DestroyImmediate(quad.GetComponent<MeshCollider>());
|
||
quad.GetComponent<MeshRenderer>().sharedMaterial = mat;
|
||
PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
|
||
Object.DestroyImmediate(root);
|
||
return PrefabPath + " (billboard fallback)";
|
||
}
|
||
|
||
static void CleanupLegacyLayout()
|
||
{
|
||
// 旧 M3 扁平目录,迁移后删除空壳
|
||
var legacy = new[]
|
||
{
|
||
"Assets/Bundles/Characters/Prefabs/MaleExplorer.prefab",
|
||
"Assets/Bundles/Characters/Textures/male_explorer.png",
|
||
"Assets/Bundles/Characters/Textures/MaleExplorer_Billboard.mat",
|
||
};
|
||
foreach (var path in legacy)
|
||
{
|
||
if (File.Exists(path) || AssetDatabase.LoadMainAssetAtPath(path) != null)
|
||
AssetDatabase.DeleteAsset(path);
|
||
}
|
||
}
|
||
|
||
public static string EnsureArtCollector()
|
||
{
|
||
var setting = BundleCollectorSettingData.Setting;
|
||
setting.ShowPackageView = true;
|
||
|
||
var package = setting.Packages.Find(p => p.PackageName == ArtPackage);
|
||
if (package == null)
|
||
package = BundleCollectorSettingData.CreatePackage(ArtPackage);
|
||
|
||
package.PackageDesc = "角色/场景美术";
|
||
package.EnableAddressable = true;
|
||
package.SupportExtensionless = true;
|
||
package.AutoCollectShaders = true;
|
||
package.IgnoreRuleName = nameof(NormalIgnoreRule);
|
||
BundleCollectorSettingData.ModifyPackage(package);
|
||
|
||
var group = package.Groups.Find(g => g.GroupName == "Characters");
|
||
if (group == null)
|
||
group = BundleCollectorSettingData.CreateGroup(package, "Characters");
|
||
group.ActiveRuleName = nameof(EnableGroup);
|
||
group.AssetTags = "art";
|
||
BundleCollectorSettingData.ModifyGroup(package, group);
|
||
|
||
const string collectPath = "Assets/Bundles/Characters";
|
||
var collector = group.Collectors.Find(c => c.CollectPath == collectPath);
|
||
if (collector == null)
|
||
{
|
||
collector = new BundleCollector
|
||
{
|
||
CollectPath = collectPath,
|
||
CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath),
|
||
CollectorType = ECollectorType.MainAssetCollector,
|
||
AddressRuleName = nameof(AddressByFileName),
|
||
PackRuleName = nameof(PackDirectory),
|
||
FilterRuleName = nameof(CollectAll),
|
||
AssetTags = "art",
|
||
};
|
||
BundleCollectorSettingData.CreateCollector(group, collector);
|
||
}
|
||
else
|
||
{
|
||
collector.CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath);
|
||
collector.AddressRuleName = nameof(AddressByFileName);
|
||
collector.PackRuleName = nameof(PackDirectory);
|
||
collector.FilterRuleName = nameof(CollectAll);
|
||
BundleCollectorSettingData.ModifyCollector(group, collector);
|
||
}
|
||
|
||
package.CheckConfigError();
|
||
BundleCollectorSettingData.SaveFile();
|
||
return $"package={ArtPackage} collect={collectPath} pack=PackDirectory";
|
||
}
|
||
}
|
||
#endif
|