#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()) { 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(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(FolderPath + "/LittleBear_base.png")); material.SetTexture("_MixTex", AssetDatabase.LoadAssetAtPath(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().FirstOrDefault(); if (!avatar || !avatar.isValid || !avatar.isHuman) throw new Exception("Invalid LittleBear Humanoid Avatar"); var shared = AssetDatabase.LoadAssetAtPath("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(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(ModelPath), preview); go.name = "LittleBear"; var a = go.GetComponent(); a.avatar = avatar; a.runtimeAnimatorController = controller; a.applyRootMotion = false; a.cullingMode = AnimatorCullingMode.AlwaysAnimate; foreach (var r in go.GetComponentsInChildren()) { 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(staticPath), preview); go.name = "LittleBear_Static"; foreach (var r in go.GetComponentsInChildren()) 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(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(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().sharedMaterial = ground; var light = new GameObject("KeyLight").AddComponent(); 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(); 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(); 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(PrefabPath), scene); var a = go.GetComponent(); var skin = go.GetComponentInChildren(); 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(); 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(); 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(); 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(); 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((string)s["prefab"])) throw new Exception("Missing LittleBear prefab"); } if (!AssetDatabase.LoadAssetAtPath(ScenePath)) throw new Exception("Missing LittleBear review scene"); var controller = AssetDatabase.LoadAssetAtPath(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