Files

265 lines
20 KiB
C#

#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Security.Cryptography;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.Animations;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
public static class BuildLittleBear
{
const string FolderPath = "Assets/Bundles/Characters/Human/LittleBear";
const string ModelPath = FolderPath + "/LittleBear_Rigged.fbx";
const string PrefabPath = FolderPath + "/LittleBear.prefab";
const string ControllerPath = FolderPath + "/LittleBear.controller";
const string ScenePath = "Assets/Bundles/Scenes/Review/LittleBearReview.unity";
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
static string Output => Path.Combine(Repo, "Tools/output/littlebear_studio");
static string Review => Path.Combine(Output, "review");
static float[] V(Vector3 v) => new[] { v.x, v.y, v.z };
static string Hash(string p) { using (var s = SHA256.Create()) return BitConverter.ToString(s.ComputeHash(File.ReadAllBytes(p))).Replace("-", "").ToLowerInvariant(); }
static void Folder(string p)
{
if (AssetDatabase.IsValidFolder(p)) return;
var parent = Path.GetDirectoryName(p).Replace('\\', '/');
Folder(parent); AssetDatabase.CreateFolder(parent, Path.GetFileName(p));
}
static void CopyNew(string source, string destination, bool updateOwnRig = false)
{
var full = Path.Combine(Directory.GetParent(Application.dataPath).FullName, destination);
if (File.Exists(full))
{
if (Hash(full) != Hash(source))
{
if (updateOwnRig && destination == ModelPath) File.Copy(source, full, true);
else throw new Exception("Destination exists with different data: " + destination);
}
}
else File.Copy(source, full);
AssetDatabase.ImportAsset(destination, ImportAssetOptions.ForceSynchronousImport);
}
static Bounds BoundsOf(GameObject go)
{
var bounds = new Bounds(); bool first = true;
foreach (var r in go.GetComponentsInChildren<Renderer>()) { if (first) { bounds = r.bounds; first = false; } else bounds.Encapsulate(r.bounds); }
return bounds;
}
static void Place(GameObject go, Scene scene) { SceneManager.MoveGameObjectToScene(go, scene); }
public static string Build()
{
if (EditorApplication.isPlaying) throw new Exception("Requires Edit Mode");
Folder(FolderPath); Directory.CreateDirectory(Review);
foreach (var suffix in new[] { "base", "mix" })
{
string target = FolderPath + "/LittleBear_" + suffix + ".png";
CopyNew(Path.Combine(Output, "xpbr/LittleBear_" + suffix + ".png"), target);
var ti = (TextureImporter)AssetImporter.GetAtPath(target);
ti.sRGBTexture = suffix == "base"; ti.alphaIsTransparency = false; ti.maxTextureSize = 1024; ti.SaveAndReimport();
}
string materialPath = FolderPath + "/LittleBear.mat";
var material = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (!material)
{
var shader = Shader.Find("Test/XPbr"); if (!shader) throw new Exception("XPbr shader missing");
material = new Material(shader); material.name = "LittleBear"; AssetDatabase.CreateAsset(material, materialPath);
}
material.SetTexture("_MainTex", AssetDatabase.LoadAssetAtPath<Texture2D>(FolderPath + "/LittleBear_base.png"));
material.SetTexture("_MixTex", AssetDatabase.LoadAssetAtPath<Texture2D>(FolderPath + "/LittleBear_mix.png"));
material.SetColor("_BaseColor", Color.white); material.SetFloat("_Metallic", 1); material.SetFloat("_Roughness", 1); material.SetFloat("_AO", 1);
EditorUtility.SetDirty(material); AssetDatabase.SaveAssetIfDirty(material);
CopyNew(Path.Combine(Output, "rig_mixamo41/LittleBear_Rigged.fbx"), ModelPath, true);
var importer = (ModelImporter)AssetImporter.GetAtPath(ModelPath);
importer.animationType = ModelImporterAnimationType.Human; importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
importer.importAnimation = false; importer.importCameras = false; importer.importLights = false;
importer.globalScale = 1; importer.useFileScale = true; importer.isReadable = true;
importer.meshCompression = ModelImporterMeshCompression.Off;
importer.skinWeights = ModelImporterSkinWeights.Custom; importer.maxBonesPerVertex = 4; importer.minBoneWeight = 0;
importer.materialImportMode = ModelImporterMaterialImportMode.ImportStandard;
importer.AddRemap(new AssetImporter.SourceAssetIdentifier(typeof(Material), "LittleBear_Body"), material);
importer.SaveAndReimport();
var avatar = AssetDatabase.LoadAllAssetsAtPath(ModelPath).OfType<Avatar>().FirstOrDefault();
if (!avatar || !avatar.isValid || !avatar.isHuman) throw new Exception("Invalid LittleBear Humanoid Avatar");
var shared = AssetDatabase.LoadAssetAtPath<AnimatorController>("Assets/Bundles/Characters/Human/Common/Man.controller");
if (!shared) throw new Exception("Missing approved shared human motions");
var idleClip = shared.layers[0].stateMachine.states.Single(s => s.state.name == "Idle").state.motion;
var walkClip = shared.layers[0].stateMachine.states.Single(s => s.state.name == "walk").state.motion;
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(ControllerPath);
if (!controller)
{
controller = AnimatorController.CreateAnimatorControllerAtPath(ControllerPath);
controller.AddParameter("Motion", AnimatorControllerParameterType.Int);
var sm = controller.layers[0].stateMachine;
var idle = sm.AddState("Idle"); idle.motion = idleClip; idle.iKOnFeet = true; sm.defaultState = idle;
var walk = sm.AddState("Walk"); walk.motion = walkClip; walk.iKOnFeet = true;
var toWalk = idle.AddTransition(walk); toWalk.hasExitTime = false; toWalk.duration = .15f; toWalk.AddCondition(AnimatorConditionMode.Equals, 1, "Motion");
var toIdle = walk.AddTransition(idle); toIdle.hasExitTime = false; toIdle.duration = .15f; toIdle.AddCondition(AnimatorConditionMode.Equals, 0, "Motion");
EditorUtility.SetDirty(controller); AssetDatabase.SaveAssetIfDirty(controller);
}
var preview = EditorSceneManager.NewPreviewScene();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(ModelPath), preview);
go.name = "LittleBear";
var a = go.GetComponent<Animator>(); a.avatar = avatar; a.runtimeAnimatorController = controller;
a.applyRootMotion = false; a.cullingMode = AnimatorCullingMode.AlwaysAnimate;
foreach (var r in go.GetComponentsInChildren<SkinnedMeshRenderer>()) { r.sharedMaterial = material; r.updateWhenOffscreen = true; }
PrefabUtility.SaveAsPrefabAsset(go, PrefabPath);
}
finally { EditorSceneManager.ClosePreviewScene(preview); }
string staticPath = FolderPath + "/LittleBear_Static.fbx";
CopyNew(Path.Combine(Output, "static/LittleBear.fbx"), staticPath);
var si = (ModelImporter)AssetImporter.GetAtPath(staticPath); si.animationType = ModelImporterAnimationType.None;
si.importAnimation = false; si.importCameras = false; si.importLights = false; si.isReadable = true; si.SaveAndReimport();
preview = EditorSceneManager.NewPreviewScene();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(staticPath), preview);
go.name = "LittleBear_Static";
foreach (var r in go.GetComponentsInChildren<Renderer>()) r.sharedMaterial = material;
go.transform.localScale = Vector3.one * (1.2f / BoundsOf(go).size.y);
PrefabUtility.SaveAsPrefabAsset(go, FolderPath + "/LittleBear_Static.prefab");
}
finally { EditorSceneManager.ClosePreviewScene(preview); }
BuildReview();
return Verify();
}
static void BuildReview()
{
if (File.Exists(Path.Combine(Directory.GetParent(Application.dataPath).FullName, ScenePath))) return;
Folder("Assets/Bundles/Scenes/Review");
var previous = SceneManager.GetActiveScene();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
try
{
var bear = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath), scene);
bear.name = "LittleBear";
var floor = GameObject.CreatePrimitive(PrimitiveType.Plane); Place(floor, scene); floor.name = "ReviewGround";
floor.transform.localScale = Vector3.one * .5f;
string groundPath = FolderPath + "/ReviewGround.mat";
var ground = AssetDatabase.LoadAssetAtPath<Material>(groundPath);
if (!ground) { ground = new Material(Shader.Find("Universal Render Pipeline/Lit")); ground.color = new Color(.30f, .39f, .35f); AssetDatabase.CreateAsset(ground, groundPath); }
floor.GetComponent<Renderer>().sharedMaterial = ground;
var light = new GameObject("KeyLight").AddComponent<Light>(); Place(light.gameObject, scene);
light.type = LightType.Directional; light.intensity = 1.4f; light.transform.rotation = Quaternion.Euler(35, -140, 0);
var fill = new GameObject("FillLight").AddComponent<Light>(); Place(fill.gameObject, scene);
fill.type = LightType.Directional; fill.intensity = .6f; fill.transform.rotation = Quaternion.Euler(20, 45, 0);
var camera = new GameObject("ReviewCamera").AddComponent<Camera>(); Place(camera.gameObject, scene);
camera.orthographic = true; camera.orthographicSize = .85f; camera.transform.position = new Vector3(1.6f, 1.0f, 3.0f);
camera.transform.LookAt(new Vector3(0, .6f, 0)); camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(.18f, .23f, .26f);
EditorSceneManager.SaveScene(scene, ScenePath);
}
finally { EditorSceneManager.CloseScene(scene, true); if (previous.IsValid()) SceneManager.SetActiveScene(previous); }
}
public static string Verify()
{
var scene = EditorSceneManager.NewPreviewScene();
Directory.CreateDirectory(Review);
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath), scene);
var a = go.GetComponent<Animator>();
var skin = go.GetComponentInChildren<SkinnedMeshRenderer>();
if (!a || !a.avatar || !a.isHuman || !a.avatar.isValid) throw new Exception("Invalid Humanoid");
var mesh = new Mesh(); skin.BakeMesh(mesh);
var rest = mesh.vertices.Select(p => go.transform.InverseTransformPoint(skin.transform.TransformPoint(p))).ToArray();
var min = rest.Aggregate(Vector3.positiveInfinity, Vector3.Min); var max = rest.Aggregate(Vector3.negativeInfinity, Vector3.Max);
if (Mathf.Abs(max.y - min.y - 1.2f) > .015f || Mathf.Abs(min.y) > .005f) throw new Exception("Wrong height or floor");
var toes = new[] { HumanBodyBones.LeftToes, HumanBodyBones.RightToes }.Select(b => go.transform.InverseTransformPoint(a.GetBoneTransform(b).position)).ToArray();
var ankles = new[] { HumanBodyBones.LeftFoot, HumanBodyBones.RightFoot }.Select(b => go.transform.InverseTransformPoint(a.GetBoneTransform(b).position)).ToArray();
if (toes[0].z <= ankles[0].z || toes[1].z <= ankles[1].z) throw new Exception("Feet must face +Z");
if (skin.sharedMesh.triangles.Length / 3 > 3900 || skin.bones.Length != 41) throw new Exception("Triangle or skeleton budget");
if (skin.sharedMaterial.shader.name != "Test/XPbr") throw new Exception("XPbr required");
var counts = skin.sharedMesh.GetBonesPerVertex(); int influenceCount = 0; foreach (var n in counts) influenceCount = Math.Max(influenceCount, n); counts.Dispose();
if (influenceCount > 4) throw new Exception("More than four influences");
var indices = mesh.triangles; var lengths = new float[indices.Length];
for (int i = 0; i < indices.Length; i++) lengths[i] = Vector3.Distance(rest[indices[i]], rest[indices[(i / 3) * 3 + (i + 1) % 3]]);
var states = new List<object>();
a.Rebind(); a.Update(0);
foreach (var state in new[] { "Idle", "Walk" })
{
float minGap = float.MaxValue, maxGap = float.MinValue, minFloor = float.MaxValue, maxFloor = float.MinValue, maxStretch = 0;
int crossed = 0; float armSwing = 0, legSwing = 0;
int worstEdge = 0; float worstPhase = 0;
Vector3 firstArm = Vector3.zero, firstKnee = Vector3.zero;
for (int i = 0; i < 125; i++)
{
a.Play("Base Layer." + state, 0, i / 124f); a.Update(0);
var left = go.transform.InverseTransformPoint(a.GetBoneTransform(HumanBodyBones.LeftFoot).position);
var right = go.transform.InverseTransformPoint(a.GetBoneTransform(HumanBodyBones.RightFoot).position);
float gap = right.x - left.x; minGap = Mathf.Min(minGap, gap); maxGap = Mathf.Max(maxGap, gap); if (gap < 0) crossed++;
skin.BakeMesh(mesh); var vertices = mesh.vertices;
var floor = vertices.Min(p => go.transform.InverseTransformPoint(skin.transform.TransformPoint(p)).y);
minFloor = Mathf.Min(minFloor, floor); maxFloor = Mathf.Max(maxFloor, floor);
if (vertices.Any(p => float.IsNaN(p.x) || float.IsInfinity(p.x))) throw new Exception("Invalid skin positions");
for (int j = 0; j < indices.Length; j++) if (lengths[j] > .0024f)
{
float stretch = Vector3.Distance(vertices[indices[j]], vertices[indices[(j / 3) * 3 + (j + 1) % 3]]) / lengths[j];
if (stretch > maxStretch) { maxStretch = stretch; worstEdge = j; worstPhase = i / 124f; }
}
var hips = a.GetBoneTransform(HumanBodyBones.Hips).position;
var hand = a.GetBoneTransform(HumanBodyBones.LeftHand).position - hips;
var knee = a.GetBoneTransform(HumanBodyBones.LeftLowerLeg).position - hips;
if (i == 0) { firstArm = hand; firstKnee = knee; }
armSwing = Mathf.Max(armSwing, Vector3.Distance(firstArm, hand)); legSwing = Mathf.Max(legSwing, Vector3.Distance(firstKnee, knee));
}
int edgeA = indices[worstEdge], edgeB = indices[(worstEdge / 3) * 3 + (worstEdge + 1) % 3];
states.Add(new { state, samples = 125, crossedAnkles = crossed, minGap, maxGap, minFloor, maxFloor, maxEdgeStretch = maxStretch, armSwing, legSwing,
worstPhase, worstRestA = V(rest[edgeA]), worstRestB = V(rest[edgeB]) });
a.Play("Base Layer." + state, 0, .25f); a.Update(0);
Capture(scene, state.ToLowerInvariant() + ".png", new Vector3(.8f, .78f, 2.8f));
if (crossed > 0) throw new Exception("Crossed ankles in " + state + ": " + crossed);
if (maxStretch > 2.5f) throw new Exception("Excessive skin stretch in " + state + ": " + maxStretch);
if (state == "Walk" && legSwing < .025f) throw new Exception("No visible walking motion");
}
a.Play("Base Layer.Idle", 0, 0); a.Update(0); a.SetInteger("Motion", 1);
for (int i = 0; i < 30; i++) a.Update(1f / 60);
if (!a.GetCurrentAnimatorStateInfo(0).IsName("Walk")) throw new Exception("Motion=1 transition failed");
a.SetInteger("Motion", 0); for (int i = 0; i < 30; i++) a.Update(1f / 60);
if (!a.GetCurrentAnimatorStateInfo(0).IsName("Idle")) throw new Exception("Motion=0 transition failed");
UnityEngine.Object.DestroyImmediate(mesh);
var report = new { prefab = PrefabPath, reviewScene = ScenePath, avatar = a.avatar.name, humanoidValid = true,
bones = skin.bones.Length, triangles = skin.sharedMesh.triangles.Length / 3, maxInfluences = influenceCount,
rest = new { boundsMin = V(min), boundsMax = V(max), height = max.y - min.y, front = "+Z", rootRotation = V(go.transform.localEulerAngles), rootScale = V(go.transform.localScale) },
shader = skin.sharedMaterial.shader.name, motionTransitionsValid = true, states, limitations = "Verified Idle and Walking only; floor range recorded, zero sliding not asserted" };
string json = JsonConvert.SerializeObject(report, Formatting.Indented); File.WriteAllText(Path.Combine(Review, "unity_validation.json"), json); return json;
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
static void Capture(Scene scene, string filename, Vector3 position)
{
var light = new GameObject("CaptureLight").AddComponent<Light>(); Place(light.gameObject, scene); light.type = LightType.Directional; light.intensity = 1.5f; light.transform.rotation = Quaternion.Euler(30, -140, 0);
var fill = new GameObject("CaptureFill").AddComponent<Light>(); Place(fill.gameObject, scene); fill.type = LightType.Directional; fill.intensity = .5f; fill.transform.rotation = Quaternion.Euler(20, 45, 0);
var camera = new GameObject("CaptureCamera").AddComponent<Camera>(); Place(camera.gameObject, scene); camera.scene = scene;
camera.orthographic = true; camera.orthographicSize = .76f; camera.transform.position = position; camera.transform.LookAt(new Vector3(0, .6f, 0));
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(.18f, .23f, .26f); camera.nearClipPlane = .01f; camera.farClipPlane = 20;
var rt = new RenderTexture(960, 1000, 24); var texture = new Texture2D(960, 1000, TextureFormat.RGB24, false); var previous = RenderTexture.active;
try { camera.targetTexture = rt; camera.Render(); RenderTexture.active = rt; texture.ReadPixels(new Rect(0, 0, 960, 1000), 0, 0); texture.Apply(); File.WriteAllBytes(Path.Combine(Review, filename), texture.EncodeToPNG()); }
finally { RenderTexture.active = previous; camera.targetTexture = null; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); UnityEngine.Object.DestroyImmediate(texture); UnityEngine.Object.DestroyImmediate(camera.gameObject); UnityEngine.Object.DestroyImmediate(light.gameObject); UnityEngine.Object.DestroyImmediate(fill.gameObject); }
}
public static string ValidateRegistration()
{
var manifest = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(Path.Combine(Repo, "Doc/Design/Character/character_unit_review_manifest.json")));
var subjects = manifest["subjects"].Children().Where(s => (string)s["id"] == "LittleBear" || (string)s["id"] == "LittleBear_Static").ToArray();
if (subjects.Length != 2) throw new Exception("Expected two LittleBear registrations");
foreach (var s in subjects)
{
if ((string)s["localForward"] != "+Z" || (int)s["maxTriangles"] != 3000) throw new Exception("Wrong LittleBear convention/budget");
if (!AssetDatabase.LoadAssetAtPath<GameObject>((string)s["prefab"])) throw new Exception("Missing LittleBear prefab");
}
if (!AssetDatabase.LoadAssetAtPath<SceneAsset>(ScenePath)) throw new Exception("Missing LittleBear review scene");
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(ControllerPath);
var result = new { scopedSubjects = 2, reviewSceneValid = true,
clips = controller.layers[0].stateMachine.states.Select(s => new { state = s.state.name, clip = AssetDatabase.GetAssetPath(s.state.motion), footIK = s.state.iKOnFeet }).ToArray() };
string json = JsonConvert.SerializeObject(result, Formatting.Indented); File.WriteAllText(Path.Combine(Review, "registration_validation.json"), json); return json;
}
}
#endif