Files
AIGame/AIClient/Assets/Editor/M3/SetupM3Character.cs
T
udxandCursor 10d9b98637 Fix MaleExplorer setup with shadow material and editor diagnostics.
Improve FBX/prefab import setup, CharacterSpawner, and add M3 inspect/rebuild helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 17:23:40 +08:00

358 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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