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>
This commit is contained in:
udx
2026-09-21 17:23:40 +08:00
co-authored by Cursor
parent 6550b8df4b
commit 10d9b98637
18 changed files with 509 additions and 51 deletions
@@ -0,0 +1,55 @@
using System.Globalization;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class FixAndDiagnoseMaleExplorer
{
public static string Diagnose()
{
const string fbx = "Assets/Bundles/Characters/MaleExplorer/MaleExplorer.fbx";
var sb = new StringBuilder();
var importer = AssetImporter.GetAtPath(fbx) as ModelImporter;
if (importer != null)
{
sb.AppendLine($"globalScale={importer.globalScale} useFileScale={importer.useFileScale} " +
$"bakeAxisConversion={importer.bakeAxisConversion} " +
$"meshCompression={importer.meshCompression}");
}
foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(fbx))
{
if (obj is not Mesh mesh) continue;
var vs = mesh.vertices;
var min = vs[0];
var max = vs[0];
for (var i = 1; i < vs.Length; i++)
{
min = Vector3.Min(min, vs[i]);
max = Vector3.Max(max, vs[i]);
}
var size = max - min;
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"mesh={0} verts={1} min=({2:F6},{3:F6},{4:F6}) max=({5:F6},{6:F6},{7:F6}) size=({8:F6},{9:F6},{10:F6})",
mesh.name, vs.Length, min.x, min.y, min.z, max.x, max.y, max.z, size.x, size.y, size.z));
}
return sb.ToString();
}
public static string FixImportAndPrefab()
{
const string fbx = "Assets/Bundles/Characters/MaleExplorer/MaleExplorer.fbx";
var importer = AssetImporter.GetAtPath(fbx) as ModelImporter;
if (importer == null) return "no importer";
// Blender 已按米导出时,关掉 Unity 默认 0.01 厘米换算
importer.globalScale = 1f;
importer.useFileScale = false;
importer.bakeAxisConversion = true;
importer.materialImportMode = ModelImporterMaterialImportMode.None;
importer.SaveAndReimport();
// 重建 Prefab
return SetupM3Character.SetupAll() + " | " + Diagnose();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2cbb60d5fbecac4e8270e0acc5afba7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
using System.Text;
using UnityEditor;
using UnityEngine;
public static class InspectMaleExplorer
{
public static string Execute()
{
const string path = "Assets/Bundles/Characters/MaleExplorer/MaleExplorer.prefab";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (prefab == null) return "prefab null";
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
go.name = "MaleExplorer_Inspect";
go.transform.position = new Vector3(0f, 0f, 2.5f);
var sb = new StringBuilder();
sb.AppendLine($"root scale={go.transform.localScale} pos={go.transform.position}");
foreach (var r in go.GetComponentsInChildren<Renderer>(true))
{
var mat = r.sharedMaterial;
sb.AppendLine(
$"renderer={r.GetType().Name} path={GetPath(r.transform)} enabled={r.enabled} " +
$"bounds={r.bounds} mat={(mat ? mat.name : "null")} " +
$"shader={(mat && mat.shader ? mat.shader.name : "null")}");
}
foreach (var mf in go.GetComponentsInChildren<MeshFilter>(true))
{
sb.AppendLine(
$"meshFilter={GetPath(mf.transform)} mesh={(mf.sharedMesh ? mf.sharedMesh.name : "null")} " +
$"verts={(mf.sharedMesh ? mf.sharedMesh.vertexCount : 0)}");
}
foreach (var smr in go.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
sb.AppendLine(
$"skinned={GetPath(smr.transform)} mesh={(smr.sharedMesh ? smr.sharedMesh.name : "null")} " +
$"verts={(smr.sharedMesh ? smr.sharedMesh.vertexCount : 0)}");
}
return sb.ToString();
}
static string GetPath(Transform t)
{
var p = t.name;
while (t.parent != null)
{
t = t.parent;
p = t.name + "/" + p;
}
return p;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 67665e6ccf39f084db66b0fcb9db296c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,31 @@
using System.Globalization;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class InspectMaleExplorerFinal
{
public static string Execute()
{
const string path = "Assets/Bundles/Characters/MaleExplorer/MaleExplorer.prefab";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
go.transform.position = new Vector3(0f, go.transform.position.y, 2.5f);
var sb = new StringBuilder();
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"root pos={0} rot={1} scale={2}",
go.transform.position, go.transform.eulerAngles, go.transform.localScale));
foreach (var r in go.GetComponentsInChildren<Renderer>(true))
{
if (r.name == "ShadowDisc") continue;
var b = r.bounds;
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0} bounds center=({1:F3},{2:F3},{3:F3}) size=({4:F3},{5:F3},{6:F3}) mat={7} shader={8}",
r.name, b.center.x, b.center.y, b.center.z, b.size.x, b.size.y, b.size.z,
r.sharedMaterial ? r.sharedMaterial.name : "null",
r.sharedMaterial && r.sharedMaterial.shader ? r.sharedMaterial.shader.name : "null"));
}
Object.DestroyImmediate(go);
return sb.ToString();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 738db76e9c64f9f4e8098e561a800597
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,47 @@
using System.Text;
using UnityEditor;
using UnityEngine;
public static class InspectMaleExplorerMesh
{
public static string Execute()
{
const string fbx = "Assets/Bundles/Characters/MaleExplorer/MaleExplorer.fbx";
const string prefabPath = "Assets/Bundles/Characters/MaleExplorer/MaleExplorer.prefab";
var sb = new StringBuilder();
foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(fbx))
{
if (obj is Mesh mesh)
{
sb.AppendLine(
$"assetMesh={mesh.name} verts={mesh.vertexCount} bounds={mesh.bounds} " +
$"tris={mesh.triangles.Length / 3}");
}
}
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
go.transform.position = Vector3.zero;
go.transform.localScale = Vector3.one;
foreach (var mf in go.GetComponentsInChildren<MeshFilter>(true))
{
var m = mf.sharedMesh;
if (m == null) continue;
sb.AppendLine($"filter={mf.name} localScale={mf.transform.lossyScale} meshBounds={m.bounds} worldBounds={mf.GetComponent<Renderer>().bounds}");
// sample a few verts
var vs = m.vertices;
var min = vs[0];
var max = vs[0];
for (int i = 1; i < vs.Length; i++)
{
min = Vector3.Min(min, vs[i]);
max = Vector3.Max(max, vs[i]);
}
sb.AppendLine($"vertMin={min} vertMax={max} size={max - min}");
}
Object.DestroyImmediate(go);
return sb.ToString();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 31f77e98524d70941a004800305764f6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
using UnityEditor;
using UnityEngine;
public static class RebuildMaleExplorerNow
{
public static string Execute()
{
foreach (var go in Object.FindObjectsOfType<GameObject>())
{
if (go != null && go.name.Contains("MaleExplorer"))
Object.DestroyImmediate(go);
}
return SetupM3Character.SetupAll();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f063866d20ac6894f9667aad85ef937a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+87 -26
View File
@@ -64,6 +64,10 @@ public static class SetupM3Character
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
@@ -154,36 +158,93 @@ public static class SetupM3Character
foreach (var renderer in instance.GetComponentsInChildren<Renderer>())
renderer.sharedMaterial = mat;
// 落地参考盘
var ground = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
ground.name = "ShadowDisc";
ground.transform.SetParent(instance.transform, false);
ground.transform.localPosition = new Vector3(0f, 0.02f, 0f);
ground.transform.localScale = new Vector3(0.7f, 0.015f, 0.7f);
Object.DestroyImmediate(ground.GetComponent<Collider>());
var gMat = new Material(urpLit != null ? urpLit : 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);
ground.GetComponent<MeshRenderer>().sharedMaterial = gMat;
ground.GetComponent<MeshRenderer>().shadowCastingMode = ShadowCastingMode.Off;
// Blender/混元 FBX:身高常在本地 Z。转到 Unity Y-up,并面向 -Z(相机常见朝向)。
UprightBlenderMesh(instance);
// 粗略归一化身高约 1.8m
var bounds = new Bounds(instance.transform.position, Vector3.zero);
var renderers = instance.GetComponentsInChildren<Renderer>();
foreach (var r in renderers)
{
if (r.name == "ShadowDisc") continue;
bounds.Encapsulate(r.bounds);
}
if (bounds.size.y > 0.01f)
{
var scale = 1.8f / bounds.size.y;
instance.transform.localScale = Vector3.one * scale;
}
// 落地参考盘(扶正定标后再放)
PlaceShadowDisc(instance, urpLit != null ? urpLit : shader);
PrefabUtility.SaveAsPrefabAsset(instance, PrefabPath);
Object.DestroyImmediate(instance);
return PrefabPath;
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()