348 lines
16 KiB
C#
348 lines
16 KiB
C#
#if UNITY_EDITOR
|
|
using System.IO;
|
|
using System.Linq;
|
|
using HotUpdate.Gameplay;
|
|
using UnityEditor;
|
|
using UnityEditor.Animations;
|
|
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// Studio mesh + Mixamo TokenRig + Kimodo idle/walk.
|
|
/// unity command run_script --file AgentScripts/SetupCampGuideStudio.cs --entry SetupCampGuideStudio.Run
|
|
/// </summary>
|
|
public static class SetupCampGuideStudio
|
|
{
|
|
const string Root = "Assets/Bundles/Characters/Human/CampGuideStudio";
|
|
const string Actor = "CampGuideStudio";
|
|
const float Height = 1.55f;
|
|
|
|
public static string ClipInfo()
|
|
{
|
|
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>($"{Root}/Clips/Walk.anim");
|
|
var bindings = AnimationUtility.GetCurveBindings(clip);
|
|
var leg = bindings.FirstOrDefault(b => b.path.Contains("LeftUpLeg") && b.propertyName.EndsWith("m_LocalRotation.x"));
|
|
var curve = string.IsNullOrEmpty(leg.path) ? null : AnimationUtility.GetEditorCurve(clip, leg);
|
|
var rig = AssetDatabase.LoadAssetAtPath<GameObject>($"{Root}/{Actor}_Rigged.prefab");
|
|
var animator = rig.GetComponentInChildren<Animator>(true);
|
|
var bone = rig.GetComponentsInChildren<Transform>(true).FirstOrDefault(t => t.name.Contains("LeftUpLeg"));
|
|
string bonePath = bone == null || animator == null
|
|
? "missing"
|
|
: AnimationUtility.CalculateTransformPath(bone, animator.transform);
|
|
var ranges = new System.Text.StringBuilder();
|
|
foreach (var binding in bindings.Where(b => b.path.EndsWith("LeftUpLeg")))
|
|
{
|
|
var keys = AnimationUtility.GetEditorCurve(clip, binding).keys;
|
|
float lo = keys.Min(k => k.value), hi = keys.Max(k => k.value);
|
|
ranges.Append($"{binding.propertyName}:{lo:0.000}..{hi:0.000} ");
|
|
}
|
|
return $"curves={bindings.Length} len={clip.length:0.00} bonePath={bonePath} {ranges}";
|
|
}
|
|
|
|
public static string Dump()
|
|
{
|
|
AssetDatabase.Refresh();
|
|
var rig = AssetDatabase.LoadAssetAtPath<GameObject>($"{Root}/Rigged/{Actor}_Rigged.fbx");
|
|
if (rig == null) return "missing";
|
|
return string.Join(" | ", rig.GetComponentsInChildren<Transform>(true).Select(t => t.name).Take(40));
|
|
}
|
|
|
|
public static string Run()
|
|
{
|
|
AssetDatabase.Refresh();
|
|
var fbx = $"{Root}/Rigged/{Actor}_Rigged.fbx";
|
|
if (!File.Exists(fbx)) throw new FileNotFoundException("missing rig", fbx);
|
|
Configure(fbx, true, null);
|
|
Avatar avatar = null;
|
|
foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(fbx))
|
|
if (obj is Avatar found) { avatar = found; break; }
|
|
if (avatar == null) throw new System.InvalidOperationException("Mixamo avatar missing");
|
|
var rig = AssetDatabase.LoadAssetAtPath<GameObject>(fbx);
|
|
var bones = rig.GetComponentsInChildren<Transform>(true).Count(t => t.name.StartsWith("mixamorig:"));
|
|
if (bones < 30 || bones > 65) throw new System.InvalidOperationException("mixamorig count " + bones);
|
|
foreach (var clip in new[] { "Idle", "Walk" })
|
|
{
|
|
var anim = $"{Root}/Anims/{Actor}_{clip}.fbx";
|
|
Configure(anim, false, avatar);
|
|
var other = AssetDatabase.LoadAssetAtPath<GameObject>(anim);
|
|
var actual = other.GetComponentsInChildren<Transform>(true).Count(t => t.name.StartsWith("mixamorig:"));
|
|
if (actual != bones) throw new System.InvalidOperationException(clip + " skeleton " + actual + " != " + bones);
|
|
}
|
|
var mat = EnsureMaterial();
|
|
var controller = EnsureController();
|
|
var prefab = EnsurePrefab(fbx, controller, mat);
|
|
AssetDatabase.SaveAssets();
|
|
return $"bones={bones} prefab={prefab}";
|
|
}
|
|
|
|
static void Configure(string path, bool character, Avatar source)
|
|
{
|
|
var importer = (ModelImporter)AssetImporter.GetAtPath(path);
|
|
importer.animationType = ModelImporterAnimationType.Generic;
|
|
importer.avatarSetup = character ? ModelImporterAvatarSetup.CreateFromThisModel : ModelImporterAvatarSetup.CopyFromOther;
|
|
if (!character && source != null) importer.sourceAvatar = source;
|
|
importer.importAnimation = !character;
|
|
importer.materialImportMode = ModelImporterMaterialImportMode.None;
|
|
importer.globalScale = 1f;
|
|
importer.useFileScale = false;
|
|
importer.bakeAxisConversion = true;
|
|
importer.SaveAndReimport();
|
|
}
|
|
|
|
static Material EnsureMaterial()
|
|
{
|
|
var basePath = $"{Root}/{Actor}_base.png";
|
|
var mixPath = $"{Root}/{Actor}_mix.png";
|
|
foreach (var path in new[] { basePath, mixPath })
|
|
{
|
|
var tex = (TextureImporter)AssetImporter.GetAtPath(path);
|
|
tex.textureType = TextureImporterType.Default;
|
|
tex.sRGBTexture = !path.EndsWith("_mix.png");
|
|
tex.mipmapEnabled = true;
|
|
tex.SaveAndReimport();
|
|
}
|
|
var shader = Shader.Find("Test/XPbr");
|
|
if (shader == null) throw new System.InvalidOperationException("Test/XPbr missing");
|
|
var matPath = $"{Root}/{Actor}.mat";
|
|
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath) ?? new Material(shader);
|
|
mat.shader = shader;
|
|
var albedo = AssetDatabase.LoadAssetAtPath<Texture2D>(basePath);
|
|
var mix = AssetDatabase.LoadAssetAtPath<Texture2D>(mixPath);
|
|
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", albedo);
|
|
if (mat.HasProperty("_MixTex")) mat.SetTexture("_MixTex", mix);
|
|
if (mat.HasProperty("_BaseMap")) mat.SetTexture("_BaseMap", albedo);
|
|
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");
|
|
if (AssetDatabase.LoadAssetAtPath<Material>(matPath) == null) AssetDatabase.CreateAsset(mat, matPath);
|
|
else EditorUtility.SetDirty(mat);
|
|
return AssetDatabase.LoadAssetAtPath<Material>(matPath);
|
|
}
|
|
|
|
static AnimatorController EnsureController()
|
|
{
|
|
var path = $"{Root}/{Actor}_Animator.controller";
|
|
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(path)
|
|
?? AnimatorController.CreateAnimatorControllerAtPath(path);
|
|
var machine = controller.layers[0].stateMachine;
|
|
foreach (var state in machine.states.ToArray()) machine.RemoveState(state.state);
|
|
foreach (var parameter in controller.parameters.ToArray()) controller.RemoveParameter(parameter);
|
|
controller.AddParameter("Motion", AnimatorControllerParameterType.Int);
|
|
var clipDir = $"{Root}/Clips";
|
|
Directory.CreateDirectory(Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Assets/Bundles/Characters/Human/CampGuideStudio/Clips"));
|
|
var idleClip = CopyClip("Idle", clipDir);
|
|
var walkClip = CopyClip("Walk", clipDir);
|
|
var idle = machine.AddState("Idle");
|
|
idle.motion = idleClip;
|
|
machine.defaultState = idle;
|
|
var walk = machine.AddState("Walk");
|
|
walk.motion = walkClip;
|
|
var toWalk = idle.AddTransition(walk);
|
|
toWalk.hasExitTime = false;
|
|
toWalk.duration = 0.1f;
|
|
toWalk.AddCondition(AnimatorConditionMode.Equals, 1, "Motion");
|
|
var toIdle = walk.AddTransition(idle);
|
|
toIdle.hasExitTime = false;
|
|
toIdle.duration = 0.1f;
|
|
toIdle.AddCondition(AnimatorConditionMode.Equals, 0, "Motion");
|
|
EditorUtility.SetDirty(controller);
|
|
return controller;
|
|
}
|
|
|
|
static AnimationClip CopyClip(string name, string clipDir)
|
|
{
|
|
var src = AssetDatabase.LoadAllAssetsAtPath($"{Root}/Anims/{Actor}_{name}.fbx")
|
|
.OfType<AnimationClip>().First(c => !c.name.StartsWith("__preview__"));
|
|
var copy = Object.Instantiate(src);
|
|
copy.name = name;
|
|
var settings = AnimationUtility.GetAnimationClipSettings(copy);
|
|
settings.loopTime = true;
|
|
AnimationUtility.SetAnimationClipSettings(copy, settings);
|
|
var dest = $"{clipDir}/{name}.anim";
|
|
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(dest);
|
|
if (existing == null) AssetDatabase.CreateAsset(copy, dest);
|
|
else { EditorUtility.CopySerialized(copy, existing); Object.DestroyImmediate(copy); }
|
|
return AssetDatabase.LoadAssetAtPath<AnimationClip>(dest);
|
|
}
|
|
|
|
static string EnsurePrefab(string fbx, RuntimeAnimatorController controller, Material material)
|
|
{
|
|
var modelRoot = AssetDatabase.LoadAssetAtPath<GameObject>(fbx);
|
|
var instance = new GameObject(Actor + "_Rigged");
|
|
var model = (GameObject)PrefabUtility.InstantiatePrefab(modelRoot, instance.transform);
|
|
model.name = "Model";
|
|
var bounds = BindBounds(model);
|
|
float scale = Height / bounds.size.y;
|
|
model.transform.localScale = Vector3.one * scale;
|
|
model.transform.localPosition = -new Vector3(bounds.center.x, bounds.min.y, bounds.center.z) * scale;
|
|
foreach (var renderer in model.GetComponentsInChildren<Renderer>(true)) renderer.sharedMaterial = material;
|
|
var animator = model.GetComponent<Animator>() ?? model.AddComponent<Animator>();
|
|
animator.runtimeAnimatorController = controller;
|
|
animator.applyRootMotion = false;
|
|
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
|
|
if (instance.GetComponent<ExplorerLocomotion>() == null) instance.AddComponent<ExplorerLocomotion>();
|
|
SetLayer(instance.transform);
|
|
var path = $"{Root}/{Actor}_Rigged.prefab";
|
|
PrefabUtility.SaveAsPrefabAsset(instance, path);
|
|
Object.DestroyImmediate(instance);
|
|
return path;
|
|
}
|
|
|
|
static Bounds BindBounds(GameObject model)
|
|
{
|
|
Bounds bounds = default;
|
|
bool has = false;
|
|
foreach (var skin in model.GetComponentsInChildren<SkinnedMeshRenderer>())
|
|
{
|
|
var mesh = new Mesh();
|
|
skin.BakeMesh(mesh);
|
|
foreach (var vertex in mesh.vertices)
|
|
{
|
|
var point = model.transform.InverseTransformPoint(skin.transform.TransformPoint(vertex));
|
|
if (!has) { bounds = new Bounds(point, Vector3.zero); has = true; }
|
|
else bounds.Encapsulate(point);
|
|
}
|
|
Object.DestroyImmediate(mesh);
|
|
}
|
|
if (!has) throw new System.InvalidOperationException("no skinned mesh");
|
|
return bounds;
|
|
}
|
|
|
|
public static string Fit()
|
|
{
|
|
var path = $"{Root}/{Actor}_Rigged.prefab";
|
|
var root = PrefabUtility.LoadPrefabContents(path);
|
|
try
|
|
{
|
|
var model = root.transform.Find("Model");
|
|
var anim = model.GetComponent<Animator>();
|
|
anim.Play("Idle", 0, 0f);
|
|
anim.Update(0f);
|
|
var before = Measure(model.gameObject);
|
|
float factor = Height / Mathf.Max(0.01f, before.max - before.min);
|
|
model.localScale *= factor;
|
|
anim.Update(0f);
|
|
var after = Measure(model.gameObject);
|
|
model.localPosition -= new Vector3(0f, after.min, 0f);
|
|
PrefabUtility.SaveAsPrefabAsset(root, path);
|
|
return $"before={before.min:0.000}..{before.max:0.000} scale={model.localScale.y:0.000}";
|
|
}
|
|
finally
|
|
{
|
|
PrefabUtility.UnloadPrefabContents(root);
|
|
}
|
|
}
|
|
|
|
static (float min, float max) Measure(GameObject model)
|
|
{
|
|
float min = float.MaxValue, max = float.MinValue;
|
|
foreach (var skin in model.GetComponentsInChildren<SkinnedMeshRenderer>())
|
|
{
|
|
var mesh = new Mesh();
|
|
skin.BakeMesh(mesh);
|
|
foreach (var vertex in mesh.vertices)
|
|
{
|
|
float y = skin.transform.TransformPoint(vertex).y;
|
|
if (y < min) min = y;
|
|
if (y > max) max = y;
|
|
}
|
|
Object.DestroyImmediate(mesh);
|
|
}
|
|
return (min, max);
|
|
}
|
|
|
|
public static string Review()
|
|
{
|
|
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>($"{Root}/{Actor}_Rigged.prefab");
|
|
var stage = new GameObject("CampGuideStudioReview");
|
|
var rt = new RenderTexture(480, 720, 16, RenderTextureFormat.ARGB32);
|
|
var tex = new Texture2D(480, 720, TextureFormat.RGB24, false);
|
|
var dir = Path.GetFullPath(Path.Combine(Application.dataPath, "../../Tools/output/campguide_studio/review"));
|
|
Directory.CreateDirectory(dir);
|
|
try
|
|
{
|
|
var actor = (GameObject)PrefabUtility.InstantiatePrefab(prefab, stage.transform);
|
|
var lightGo = new GameObject("Light");
|
|
lightGo.transform.SetParent(stage.transform, false);
|
|
var light = lightGo.AddComponent<Light>();
|
|
light.type = LightType.Directional;
|
|
light.intensity = 1.2f;
|
|
light.transform.rotation = Quaternion.Euler(40f, -30f, 0f);
|
|
var camGo = new GameObject("Camera");
|
|
camGo.transform.SetParent(stage.transform, false);
|
|
var camera = camGo.AddComponent<Camera>();
|
|
camera.clearFlags = CameraClearFlags.SolidColor;
|
|
camera.backgroundColor = new Color(0.85f, 0.85f, 0.85f, 1f);
|
|
camera.orthographic = true;
|
|
camera.orthographicSize = 0.95f;
|
|
camera.nearClipPlane = 0.05f;
|
|
camera.farClipPlane = 20f;
|
|
camera.targetTexture = rt;
|
|
var anim = actor.GetComponentInChildren<Animator>();
|
|
anim.applyRootMotion = false;
|
|
var model = actor.transform.Find("Model").gameObject;
|
|
var skin = actor.GetComponentInChildren<SkinnedMeshRenderer>();
|
|
var notes = new System.Text.StringBuilder();
|
|
foreach (var shot in new[] { "Idle", "Walk" })
|
|
{
|
|
var sampled = AssetDatabase.LoadAssetAtPath<AnimationClip>($"{Root}/Clips/{shot}.anim");
|
|
sampled.SampleAnimation(model, shot == "Walk" ? 0.5f : 0.4f);
|
|
var mesh = new Mesh();
|
|
skin.BakeMesh(mesh);
|
|
var posed = new GameObject("Posed");
|
|
posed.transform.SetParent(skin.transform, false);
|
|
posed.AddComponent<MeshFilter>().sharedMesh = mesh;
|
|
var posedRenderer = posed.AddComponent<MeshRenderer>();
|
|
posedRenderer.sharedMaterial = skin.sharedMaterial;
|
|
skin.enabled = false;
|
|
float minY = float.MaxValue, maxY = float.MinValue;
|
|
foreach (var v in mesh.vertices)
|
|
{
|
|
var p = skin.transform.TransformPoint(v);
|
|
if (p.y < minY) minY = p.y;
|
|
if (p.y > maxY) maxY = p.y;
|
|
}
|
|
notes.Append($"{shot} y={minY:0.000}..{maxY:0.000} scale={actor.transform.Find("Model").localScale.y:0.000} bounds={skin.bounds.size.y:0.000} ");
|
|
foreach (var view in new[] { "front", "side" })
|
|
{
|
|
camera.transform.position = view == "front" ? new Vector3(0f, 0.8f, -2.4f) : new Vector3(2.4f, 0.8f, 0f);
|
|
camera.transform.LookAt(new Vector3(0f, 0.75f, 0f));
|
|
camera.Render();
|
|
RenderTexture.active = rt;
|
|
tex.ReadPixels(new Rect(0, 0, 480, 720), 0, 0);
|
|
tex.Apply();
|
|
RenderTexture.active = null;
|
|
File.WriteAllBytes(Path.Combine(dir, shot.ToLowerInvariant() + "_" + view + ".png"), tex.EncodeToPNG());
|
|
}
|
|
skin.enabled = true;
|
|
Object.DestroyImmediate(posed);
|
|
Object.DestroyImmediate(mesh);
|
|
}
|
|
camera.targetTexture = null;
|
|
return notes + dir;
|
|
}
|
|
finally
|
|
{
|
|
RenderTexture.active = null;
|
|
cameraCleanup(rt, tex, stage);
|
|
}
|
|
}
|
|
|
|
static void cameraCleanup(RenderTexture rt, Texture2D tex, GameObject stage)
|
|
{
|
|
Object.DestroyImmediate(tex);
|
|
rt.Release();
|
|
Object.DestroyImmediate(rt);
|
|
Object.DestroyImmediate(stage);
|
|
}
|
|
|
|
static void SetLayer(Transform transform)
|
|
{
|
|
transform.gameObject.layer = 31;
|
|
foreach (Transform child in transform) SetLayer(child);
|
|
}
|
|
}
|
|
#endif
|