179 lines
18 KiB
C#
179 lines
18 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using Newtonsoft.Json;
|
|
using UnityEditor;
|
|
using UnityEditor.SceneManagement;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
using Object=UnityEngine.Object;
|
|
|
|
/// <summary>
|
|
/// Studio low-poly candidate for any character (generalized from GreatHornStudioTrial): binds the Studio mesh to the
|
|
/// ORIGINAL avatar, controller and finished clips. No animation regeneration. Every entry takes
|
|
/// (actor, kind, originalPrefabFile, height); the candidate lives at Characters/{kind}/{actor}Studio.
|
|
/// </summary>
|
|
public static class StudioTrial
|
|
{
|
|
sealed class Ctx
|
|
{
|
|
public string Actor, Kind, Prefab; public float Height;
|
|
public string Original=>"Assets/Bundles/Characters/"+Kind+"/"+Actor;
|
|
public string Trial=>"Assets/Bundles/Characters/"+Kind+"/"+Actor+"Studio";
|
|
public string Name=>Actor+"Studio";
|
|
public string Animated=>Trial+"/"+Name+"_"+(Kind=="Enemy"?"Enemy":"Companion")+".prefab";
|
|
public string Scene=>"Assets/Bundles/Scenes/Review/Studio_"+Actor+"_Review.unity";
|
|
public string Out=>Path.Combine(Repo,"Tools/output/studio_trial",Actor);
|
|
}
|
|
static Ctx C(string actor,string kind,string prefab,float height)=>new Ctx{Actor=actor,Kind=kind,Prefab=prefab,Height=height};
|
|
static string Repo=>Path.GetFullPath(Path.Combine(Application.dataPath,"../.."));
|
|
static void Check(bool value,string message){if(!value)throw new Exception(message);}
|
|
static void Write(Ctx c,string name,object data){Directory.CreateDirectory(c.Out);File.WriteAllText(Path.Combine(c.Out,name),JsonConvert.SerializeObject(data,Formatting.Indented));}
|
|
static Vector3[] Vertices(GameObject go)
|
|
{
|
|
return go.GetComponentsInChildren<SkinnedMeshRenderer>().SelectMany(s=>{
|
|
var m=s.sharedMesh;var weights=m.boneWeights;var binds=m.bindposes;var matrices=s.bones.Select((b,i)=>b.localToWorldMatrix*binds[i]).ToArray();
|
|
return m.vertices.Select((v,i)=>{var w=weights[i];return matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3;}).ToArray();
|
|
}).ToArray();
|
|
}
|
|
static Bounds Bounds(Vector3[] points){var b=new Bounds(points[0],Vector3.zero);foreach(var p in points)b.Encapsulate(p);return b;}
|
|
static AnimationClip Clip(Ctx c,string name)=>AssetDatabase.LoadAssetAtPath<AnimationClip>(c.Original+"/Clips/"+name+".anim");
|
|
static Animator Reference(Ctx c)=>AssetDatabase.LoadAssetAtPath<GameObject>(c.Original+"/"+c.Prefab).GetComponentInChildren<Animator>();
|
|
|
|
public static string Build(string actor,string kind,string prefab,float height)=>BuildCore(C(actor,kind,prefab,height),true);
|
|
public static string Prepare(string actor,string kind,string prefab,float height)=>BuildCore(C(actor,kind,prefab,height),false);
|
|
static string BuildCore(Ctx c,bool verify)
|
|
{
|
|
Check(!EditorApplication.isPlaying,"Author trial in Edit Mode");
|
|
string fbx=c.Trial+"/Rigged/"+c.Name+"_Rigged.fbx";
|
|
AssetDatabase.ImportAsset(fbx,ImportAssetOptions.ForceUpdate);
|
|
var imp=(ModelImporter)AssetImporter.GetAtPath(fbx);imp.animationType=ModelImporterAnimationType.Generic;imp.avatarSetup=ModelImporterAvatarSetup.CreateFromThisModel;imp.importAnimation=false;imp.globalScale=1;imp.useFileScale=false;imp.bakeAxisConversion=true;imp.materialImportMode=ModelImporterMaterialImportMode.None;imp.meshCompression=ModelImporterMeshCompression.Off;imp.SaveAndReimport();
|
|
var reference=Reference(c);
|
|
var scene=EditorSceneManager.NewPreviewScene();
|
|
try
|
|
{
|
|
var root=new GameObject(Path.GetFileNameWithoutExtension(c.Animated));SceneManager.MoveGameObjectToScene(root,scene);
|
|
var model=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(fbx),scene);model.transform.SetParent(root.transform,false);model.name="Model";
|
|
model.transform.localPosition=reference.transform.localPosition;model.transform.localRotation=reference.transform.localRotation;model.transform.localScale=reference.transform.localScale;
|
|
var anim=model.GetComponent<Animator>();if(anim==null)anim=model.AddComponent<Animator>();anim.avatar=reference.avatar;anim.runtimeAnimatorController=reference.runtimeAnimatorController;anim.applyRootMotion=false;anim.cullingMode=AnimatorCullingMode.AlwaysAnimate;
|
|
var material=AssetDatabase.LoadAssetAtPath<Material>(c.Trial+"/"+c.Name+".mat");foreach(var skin in model.GetComponentsInChildren<SkinnedMeshRenderer>()){skin.sharedMaterial=material;skin.updateWhenOffscreen=true;}
|
|
var expected=reference.GetComponentsInChildren<Transform>().Where(t=>t.name.StartsWith("bone_")).ToDictionary(t=>AnimationUtility.CalculateTransformPath(t,reference.transform));
|
|
var actual=model.GetComponentsInChildren<Transform>().Where(t=>t.name.StartsWith("bone_")).ToDictionary(t=>AnimationUtility.CalculateTransformPath(t,model.transform));
|
|
Check(expected.Count>0&&expected.Keys.OrderBy(x=>x).SequenceEqual(actual.Keys.OrderBy(x=>x)),"Original skeleton paths changed ("+expected.Count+" vs "+actual.Count+")");
|
|
foreach(var entry in expected){var t=actual[entry.Key];Check(Vector3.Distance(t.localPosition,entry.Value.localPosition)<.0001f&&Quaternion.Angle(t.localRotation,entry.Value.localRotation)<.01f&&Vector3.Distance(t.localScale,entry.Value.localScale)<.0001f,"Bind transform changed "+entry.Key);}
|
|
PrefabUtility.SaveAsPrefabAsset(root,c.Animated);
|
|
}
|
|
finally{EditorSceneManager.ClosePreviewScene(scene);}
|
|
AssetDatabase.SaveAssets();return verify?Verify(c):"Prepared original skeleton/controller candidate; sole constraints pending";
|
|
}
|
|
public static string Verify(string actor,string kind,string prefab,float height)=>Verify(C(actor,kind,prefab,height));
|
|
static string Verify(Ctx c)
|
|
{
|
|
var scene=EditorSceneManager.NewPreviewScene();
|
|
try
|
|
{
|
|
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(c.Animated),scene);var anim=go.GetComponentInChildren<Animator>();anim.enabled=false;
|
|
var reference=Reference(c);Check(anim.runtimeAnimatorController==reference.runtimeAnimatorController&&anim.avatar==reference.avatar,"Expected exact original controller/avatar");
|
|
int boneCount=reference.GetComponentsInChildren<Transform>().Count(t=>t.name.StartsWith("bone_"));
|
|
var bind=Vertices(go);var bounds=Bounds(bind);int triangles=go.GetComponentsInChildren<SkinnedMeshRenderer>().Sum(s=>s.sharedMesh.triangles.Length/3);int target=(int?)Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(Path.Combine(c.Out,"trial.json")))["targetTriangles"]??2000;Check(triangles<=target*1.3f,"Studio target tolerance exceeded");Check(Mathf.Abs(bounds.size.y-c.Height)<.003f&&Mathf.Abs(bounds.min.y)<.003f,"Bind ground/height changed "+bounds);
|
|
var staticGo=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(c.Trial+"/"+c.Name+".prefab"),scene);var staticPoints=staticGo.GetComponentsInChildren<MeshFilter>().SelectMany(m=>m.sharedMesh.vertices.Select(v=>m.transform.TransformPoint(v))).ToArray();float bindError=bind.Max(v=>staticPoints.Min(p=>Vector3.Distance(v,p)));Check(bindError<.002f,"Weight transfer changed static geometry "+bindError);
|
|
Check(go.GetComponentsInChildren<SkinnedMeshRenderer>().All(s=>s.sharedMesh.boneWeights.All(w=>Mathf.Abs(w.weight0+w.weight1+w.weight2+w.weight3-1)<.001f)),"Invalid weights");
|
|
var rows=new List<object>();var root=go.GetComponentsInChildren<Transform>().Single(t=>t.name=="bone_0");
|
|
foreach(string name in new[]{"Idle","Walk","Attack"})
|
|
{
|
|
var clip=Clip(c,name);Check(anim.runtimeAnimatorController.animationClips.Contains(clip),"Not original clip "+name);
|
|
foreach(var binding in AnimationUtility.GetCurveBindings(clip))Check(string.IsNullOrEmpty(binding.path)||anim.transform.Find(binding.path)!=null,"Unbound original curve "+binding.path);
|
|
Vector3[] start=null,end=null;float minY=float.MaxValue,maxY=float.MinValue,movement=0,tilt=0;Vector3 rootStart=Vector3.zero;float drift=0;
|
|
for(int frame=0;frame<=120;frame++){clip.SampleAnimation(anim.gameObject,clip.length*frame/120);var points=Vertices(go);Check(points.All(p=>!float.IsNaN(p.x)&&!float.IsInfinity(p.x)),"Invalid mesh");var b=Bounds(points);if(frame==0){start=points;rootStart=root.position;}if(frame==120)end=points;minY=Mathf.Min(minY,b.min.y);maxY=Mathf.Max(maxY,b.min.y);movement=Mathf.Max(movement,points.Select((p,i)=>Vector3.Distance(p,start[i])).Max());tilt=Mathf.Max(tilt,Vector3.Angle(root.up,Vector3.up));drift=Mathf.Max(drift,Vector2.Distance(new Vector2(root.position.x,root.position.z),new Vector2(rootStart.x,rootStart.z)));Check(b.size.y>c.Height*.8f,"Collapsed mesh "+name);}
|
|
float seam=start.Select((p,i)=>Vector3.Distance(p,end[i])).Max();Check(minY>=-.003f&&maxY<=.003f,"Ground contact failed "+name+" "+minY+".."+maxY);Check(tilt<25&&seam<.001f,"Orientation/seam failed "+name);Check(movement>.0002f,"Motion invisible "+name);
|
|
rows.Add(new{name,originalClip=AssetDatabase.GetAssetPath(clip),frames=121,minY,maxY,movement,tilt,drift,seam});
|
|
}
|
|
Write(c,"unity_verification.json",new{status="pass",triangles,bones=boneCount,bindError,bindHeight=bounds.size.y,originalAvatar=true,originalController=true,originalClips=true,rows});
|
|
return "PASS original "+boneCount+"-bone bind, exact original avatar/controller/3 clips, "+triangles+" tris, bindError="+bindError.ToString("F5")+", 121-frame motion samples";
|
|
}
|
|
finally{EditorSceneManager.ClosePreviewScene(scene);}
|
|
}
|
|
/// <summary>Sole/foot vertices with their own four bone influences, sampled over the 363 original action frames.</summary>
|
|
public static string ExportFootConstraints(string actor,string kind,string prefab,float height)
|
|
{
|
|
var c=C(actor,kind,prefab,height);var scene=EditorSceneManager.NewPreviewScene();
|
|
try
|
|
{
|
|
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(c.Animated),scene);var anim=go.GetComponentInChildren<Animator>();anim.enabled=false;var skin=go.GetComponentInChildren<SkinnedMeshRenderer>();var mesh=skin.sharedMesh;var bind=Vertices(go);var bounds=Bounds(bind);var weights=mesh.boneWeights;
|
|
var meshVertices=mesh.vertices;var indices=Enumerable.Range(0,bind.Length).Where(i=>bind[i].y<c.Height*.14f).ToArray();
|
|
var sets=indices.Select(i=>{var w=weights[i];return new[]{w.boneIndex0,w.boneIndex1,w.boneIndex2,w.boneIndex3};}).ToArray();
|
|
var own=indices.Select(i=>{var w=weights[i];return new[]{w.weight0,w.weight1,w.weight2,w.weight3};}).ToArray();
|
|
var frames=new List<float[][]>();
|
|
foreach(string name in new[]{"Idle","Walk","Attack"})for(int f=0;f<=120;f++)
|
|
{
|
|
var clip=Clip(c,name);clip.SampleAnimation(anim.gameObject,clip.length*f/120);var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*mesh.bindposes[i]).ToArray();
|
|
frames.Add(indices.Select((v,j)=>sets[j].Select(b=>matrices[b].MultiplyPoint3x4(meshVertices[v]).y).ToArray()).ToArray());
|
|
}
|
|
Write(c,"foot_constraints.json",new{modelScale=anim.transform.localScale.x,vertices=indices.Select((v,j)=>new{index=v,normalizedBind=new[]{(bind[v].x-bounds.center.x)/bounds.size.y,(bind[v].y-bounds.min.y)/bounds.size.y,(bind[v].z-bounds.center.z)/bounds.size.y},bones=sets[j].Select(b=>skin.bones[b].name).ToArray(),weights=own[j]}).ToArray(),frames});
|
|
return "Exported "+indices.Length+" sole/foot vertices x 363 original action frames";
|
|
}
|
|
finally{EditorSceneManager.ClosePreviewScene(scene);}
|
|
}
|
|
static void Label(Transform parent,string text,Vector3 position,float size)
|
|
{var label=new GameObject("Label_"+text).AddComponent<TextMesh>();label.transform.SetParent(parent,false);label.transform.localPosition=position;label.transform.localRotation=Quaternion.Euler(12,0,0);label.text=text;label.fontSize=60;label.characterSize=size;label.anchor=TextAnchor.MiddleCenter;label.color=new Color(.87f,.93f,1);}
|
|
/// <summary>Two-row comparison (original above, Studio below): Static / Idle / Walk / Attack.</summary>
|
|
public static string BuildReview(string actor,string kind,string prefab,float height)
|
|
{
|
|
var c=C(actor,kind,prefab,height);
|
|
Check(!EditorApplication.isPlaying,"Review authoring requires Edit Mode");var previous=SceneManager.GetActiveScene();var scene=EditorSceneManager.NewScene(NewSceneSetup.EmptyScene,NewSceneMode.Additive);
|
|
// GreatHorn used 1.8m columns at 0.8m height; keep that ratio so the ortho camera frames all four.
|
|
float spacing=c.Height*2.25f;
|
|
try
|
|
{
|
|
SceneManager.SetActiveScene(scene);var root=new GameObject(c.Name+"Comparison");root.transform.position=new Vector3(320,0,0);
|
|
var names=new[]{"Static","Idle","Walk","Attack"};
|
|
for(int row=0;row<2;row++)
|
|
{
|
|
string folder=row==0?c.Original:c.Trial,actor0=row==0?c.Actor:c.Name;
|
|
for(int col=0;col<4;col++)
|
|
{
|
|
string path=col==0?folder+"/"+actor0+".prefab":(row==0?c.Original+"/"+c.Prefab:c.Animated);
|
|
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(path),scene);go.name=(row==0?"Original_":"Studio_")+names[col];go.transform.SetParent(root.transform,false);go.transform.localPosition=new Vector3((col-1.5f)*spacing,0,row==0?spacing*.75f:-spacing*.75f);
|
|
var anim=go.GetComponentInChildren<Animator>();if(anim!=null){anim.enabled=false;Clip(c,names[col]).SampleAnimation(anim.gameObject,Clip(c,names[col]).length*(col==1?.5f:col==2?.25f:.45f));}
|
|
Label(go.transform,(row==0?"Original":"Studio")+" / "+names[col],new Vector3(0,-.22f*c.Height/.8f,-.1f),.025f*c.Height/.8f);
|
|
}
|
|
}
|
|
var floor=GameObject.CreatePrimitive(PrimitiveType.Plane);floor.name="Ground_Y0";floor.transform.SetParent(root.transform,false);floor.transform.localPosition=new Vector3(0,-.002f,0);floor.transform.localScale=new Vector3(spacing*.5f,1,spacing*.32f);floor.GetComponent<Renderer>().sharedMaterial=AssetDatabase.LoadAssetAtPath<Material>("Assets/Bundles/Scenes/Review/EnemyReviewFloor.mat");Object.DestroyImmediate(floor.GetComponent<Collider>());
|
|
var camera=new GameObject(c.Name+"Camera").AddComponent<Camera>();camera.transform.SetParent(root.transform,false);camera.transform.localPosition=new Vector3(0,7*c.Height/.8f,-10*c.Height/.8f);camera.transform.LookAt(root.transform.position+new Vector3(0,c.Height*.56f,0));camera.orthographic=true;camera.orthographicSize=2.05f*c.Height/.8f;camera.aspect=2;camera.nearClipPlane=.1f;camera.farClipPlane=100;camera.cullingMask=1<<27;camera.backgroundColor=new Color(.075f,.105f,.14f);camera.clearFlags=CameraClearFlags.SolidColor;camera.depth=85;camera.enabled=false;
|
|
var light=new GameObject("StudioReviewLight").AddComponent<Light>();light.transform.SetParent(root.transform,false);light.type=LightType.Directional;light.transform.rotation=Quaternion.Euler(40,-35,0);light.intensity=1.1f;light.cullingMask=1<<27;
|
|
foreach(var t in root.GetComponentsInChildren<Transform>(true))t.gameObject.layer=27;
|
|
EditorSceneManager.SaveScene(scene,c.Scene);
|
|
}
|
|
finally{SceneManager.SetActiveScene(previous);EditorSceneManager.CloseScene(scene,true);}
|
|
return c.Scene;
|
|
}
|
|
public static async Task<string> Capture(string actor,string kind,string prefab,float height)
|
|
{
|
|
var c=C(actor,kind,prefab,height);
|
|
Check(!EditorApplication.isPlaying,"Capture in Edit Mode");var scene=EditorSceneManager.OpenScene(c.Scene,OpenSceneMode.Additive);var root=scene.GetRootGameObjects().Single();var camera=root.GetComponentInChildren<Camera>();var rt=new RenderTexture(1800,900,24);var tex=new Texture2D(1800,900,TextureFormat.RGB24,false);var old=RenderTexture.active;
|
|
try
|
|
{
|
|
foreach(var anim in root.GetComponentsInChildren<Animator>()){anim.enabled=false;string name=anim.transform.parent.name.Split('_').Last();var clip=Clip(c,name);clip.SampleAnimation(anim.gameObject,clip.length*(name=="Idle"?.5f:name=="Walk"?.25f:.45f));}
|
|
await Task.Delay(100);camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;tex.ReadPixels(new Rect(0,0,1800,900),0,0);tex.Apply();string asset="Assets/Screenshots/"+c.Actor.ToLowerInvariant()+"_studio_comparison.png";File.WriteAllBytes(Path.Combine(Application.dataPath,"../",asset),tex.EncodeToPNG());AssetDatabase.ImportAsset(asset);return asset;
|
|
}
|
|
finally{RenderTexture.active=old;camera.targetTexture=null;rt.Release();Object.DestroyImmediate(rt);Object.DestroyImmediate(tex);EditorSceneManager.CloseScene(scene,true);}
|
|
}
|
|
public static async Task<string> Play(string actor,string kind,string prefab,float height)
|
|
{
|
|
var c=C(actor,kind,prefab,height);
|
|
Check(EditorApplication.isPlaying,"Enter Play Mode first");var go=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(c.Animated));var anim=go.GetComponentInChildren<Animator>();go.transform.position=new Vector3(320,0,0);var root=go.GetComponentsInChildren<Transform>().Single(t=>t.name=="bone_0");var seen=new HashSet<string>();float maxTilt=0;
|
|
try
|
|
{
|
|
for(int phase=0;phase<3;phase++)
|
|
{
|
|
anim.SetInteger("Motion",phase==1?1:0);if(phase==2)anim.SetTrigger("Attack");var until=DateTime.UtcNow.AddSeconds(2);
|
|
while(DateTime.UtcNow<until){var s=anim.GetCurrentAnimatorStateInfo(0);foreach(var name in new[]{"Idle","Walk","Attack"})if(s.IsName(name))seen.Add(name);maxTilt=Mathf.Max(maxTilt,Vector3.Angle(root.up,Vector3.up));Check(Vector3.Distance(go.transform.position,new Vector3(320,0,0))<.001f,"Root drift");await Task.Delay(50);}
|
|
}
|
|
Check(seen.Count==3&&maxTilt<25,"Live animation missing/wrong orientation");Write(c,"play_verification.json",new{status="pass",seen,maxTilt,originalController=AssetDatabase.GetAssetPath(anim.runtimeAnimatorController),rootPinned=true});return "PASS Play: original Idle/Walk/Attack on Studio mesh; maxTilt="+maxTilt;
|
|
}
|
|
finally{Object.Destroy(go);}
|
|
}
|
|
}
|