265 lines
20 KiB
C#
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
|