Replace TownSquare with HomeCamp art and land humanoid bots, LittleBear, and LmGirl review assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-11-03 20:33:08 +08:00
co-authored by Cursor
parent 45e62132c8
commit 9eec364f30
838 changed files with 57978 additions and 12225 deletions
+18 -5
View File
@@ -18,7 +18,7 @@ public static class AuditCharacterVisual
"Tools/output/campguide_review/idle",
1.55f,
"Assets/Bundles/Characters/Human/CampGuide/Clips/Idle.anim",
0.45f);
0.45f, -1f); // Existing CampGuide asset uses legacy -Z.
}
public static string CaptureCampGuideWalk()
@@ -28,11 +28,12 @@ public static class AuditCharacterVisual
"Tools/output/campguide_review/walk",
1.55f,
"Assets/Bundles/Characters/Human/CampGuide/Clips/Walk.anim",
0.3f);
0.3f, -1f);
}
public static string Capture(string prefabPath,string outputDirectory,float height=.55f,string clipPath="",float phase=0)
public static string Capture(string prefabPath,string outputDirectory,float height=.55f,string clipPath="",float phase=0,float forwardZ=1f)
{
var directions=ViewDirections(forwardZ);
string dir=Path.GetFullPath(Path.Combine(Application.dataPath,"../..",outputDirectory));Directory.CreateDirectory(dir);
var scene=EditorSceneManager.NewPreviewScene();
try
@@ -50,7 +51,7 @@ public static class AuditCharacterVisual
int triangles=filters.Sum(m=>m.sharedMesh.triangles.Length/3);
if(Mathf.Abs(minY)>.002f || Mathf.Abs(actualHeight-height)>.002f)throw new Exception("Static height/bottom invalid");
if(unit.GetComponentsInChildren<Renderer>().Any(r=>r.sharedMaterial==null || r.sharedMaterial.shader.name!="Test/XPbr"))throw new Exception("Static XPbr material missing");
File.WriteAllText(Path.Combine(dir,"static_verification.txt"),$"height={actualHeight:F6} bottom={minY:F6} triangles={triangles} XPbr=True; facing verified in front/side/back views");
File.WriteAllText(Path.Combine(dir,"static_verification.txt"),$"height={actualHeight:F6} bottom={minY:F6} triangles={triangles} XPbr=True; frontAxis={(forwardZ>0?"+Z":"-Z")}; facing requires visual review of front/side/back views");
}
if(!string.IsNullOrEmpty(clipPath))
{
@@ -77,7 +78,6 @@ public static class AuditCharacterVisual
var rt=new RenderTexture(640,640,24);var tex=new Texture2D(640,640,TextureFormat.RGB24,false);var previous=RenderTexture.active;
try
{
var directions=new[]{new Vector3(0,.22f,-1),new Vector3(1,.22f,0),new Vector3(0,.22f,1),new Vector3(.7f,.3f,-1)};
string[] names={"front","side","back","perspective"};
for(int i=0;i<directions.Length;i++)
{
@@ -94,5 +94,18 @@ public static class AuditCharacterVisual
finally {EditorSceneManager.ClosePreviewScene(scene);}
return dir;
}
static Vector3[] ViewDirections(float forwardZ = 1f)
{
if (forwardZ != 1f && forwardZ != -1f) throw new ArgumentException("forwardZ must be +1 or legacy -1");
return new[]{new Vector3(0,.22f,forwardZ),new Vector3(1,.22f,0),new Vector3(0,.22f,-forwardZ),new Vector3(.7f,.3f,forwardZ)};
}
public static string VerifyForwardConvention()
{
if(ViewDirections()[0].z!=1 || ViewDirections()[2].z!=-1 || ViewDirections(-1)[0].z!=-1)
throw new Exception("Front/back or legacy view directions are reversed");
return "PASS +Z default views and explicit legacy -Z views";
}
}
#endif
@@ -0,0 +1,305 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using Newtonsoft.Json;
using AIGame.EditorTools;
using AIGame.Review;
using TMPro;
using UnityEditor;
using UnityEditor.Animations;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.EventSystems;
using UnityEngine.Rendering;
using UnityEngine.UI;
public static class BuildBotAnimationLab
{
public const string Common = "Assets/Bundles/Characters/Human/Common";
public const string Root = "Assets/Review/BotAnimationLab";
public const string ScenePath = Root + "/BotAnimationLab.unity";
static readonly string[] Names = { "XBot", "YBot", "XBot25", "XBot41" };
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
static string Output => Path.Combine(Repo, "Tools/output/bot_animation_lab");
static void Folder(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
Folder(Path.GetDirectoryName(path).Replace('\\', '/'));
AssetDatabase.CreateFolder(Path.GetDirectoryName(path).Replace('\\', '/'), Path.GetFileName(path));
}
static void Report(string name, object report)
{
Directory.CreateDirectory(Output);
File.WriteAllText(Path.Combine(Output, name + ".json"), JsonConvert.SerializeObject(report, Formatting.Indented));
}
public static string Configure()
{
if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Edit Mode required");
Folder(Root); Folder(Common + "/Prefabs");
var reports = new List<object>();
float referenceHeight = 0;
foreach (string name in Names)
{
string path = Common + "/" + name + ".fbx";
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport);
var importer = (ModelImporter)AssetImporter.GetAtPath(path);
if (importer.animationType != ModelImporterAnimationType.Human || importer.avatarSetup != ModelImporterAvatarSetup.CreateFromThisModel)
{
importer.animationType = ModelImporterAnimationType.Human;
importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
importer.SaveAndReimport();
}
var avatar = AssetDatabase.LoadAllAssetsAtPath(path).OfType<Avatar>().Single();
if (!avatar.isValid || !avatar.isHuman) throw new Exception(name + " invalid Humanoid Avatar");
var model = AssetDatabase.LoadAssetAtPath<GameObject>(path);
var bones = model.GetComponentsInChildren<Transform>(true).Where(t => t.name.StartsWith("mixamorig:")).Select(t => t.name).ToArray();
var mapped = importer.humanDescription.human;
string controllerPath = Common + "/" + (name == "YBot" ? "YBot" : "XBot") + ".controller";
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (!controller) throw new Exception("Missing controller " + controllerPath);
var preview = EditorSceneManager.NewPreviewScene();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(model, preview);
go.name = name;
float sourceHeight = BoundsOf(go).size.y;
if (name == "XBot") referenceHeight = sourceHeight;
if (name == "XBot25" || name == "XBot41") go.transform.localScale *= referenceHeight / sourceHeight;
var animator = go.GetComponent<Animator>();
if (!animator) animator = go.AddComponent<Animator>();
animator.avatar = avatar; animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false; animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
foreach (var skin in go.GetComponentsInChildren<SkinnedMeshRenderer>()) skin.updateWhenOffscreen = true;
string prefab = Common + "/Prefabs/" + name + ".prefab";
PrefabUtility.SaveAsPrefabAsset(go, prefab);
reports.Add(new { name, path, bones = bones.Length, boneNames = bones, mapped = mapped.Select(b => new { b.humanName, b.boneName }),
avatar = avatar.name, avatarValid = avatar.isValid, avatarHuman = avatar.isHuman, controller = controllerPath, prefab,
sourceHeight, prefabHeight = BoundsOf(go).size.y, prefabScale = new[] { go.transform.localScale.x, go.transform.localScale.y, go.transform.localScale.z },
renderers = go.GetComponentsInChildren<SkinnedMeshRenderer>().Select(r => new { r.name, skinBones = r.bones.Length, material = r.sharedMaterial ? r.sharedMaterial.name : null }).ToArray() });
}
finally { EditorSceneManager.ClosePreviewScene(preview); }
}
Report("avatar_configuration", reports);
return JsonConvert.SerializeObject(reports);
}
static Material Material(string name, Color color)
{
Folder(Root + "/Generated");
string path = Root + "/Generated/" + name + ".mat";
var material = AssetDatabase.LoadAssetAtPath<Material>(path);
if (!material)
{
var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
material = new Material(shader) { name = name };
AssetDatabase.CreateAsset(material, path);
}
material.color = color;
if (material.HasProperty("_Smoothness")) material.SetFloat("_Smoothness", .35f);
EditorUtility.SetDirty(material); AssetDatabase.SaveAssetIfDirty(material);
return material;
}
static Bounds BoundsOf(GameObject go)
{
var renderers = go.GetComponentsInChildren<Renderer>();
var bounds = renderers[0].bounds;
foreach (var r in renderers.Skip(1)) bounds.Encapsulate(r.bounds);
return bounds;
}
public static string Build()
{
if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Edit Mode required");
for (int i = 0; i < SceneManager.sceneCount; i++)
if (SceneManager.GetSceneAt(i).isDirty) throw new Exception("Unsaved scene: " + SceneManager.GetSceneAt(i).path);
Configure();
string uiPath = JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(Repo, "Doc/UIPrefabCreater/UI_BotAnimationLab.json"));
if (string.IsNullOrEmpty(uiPath)) throw new Exception("UI JSON generation failed");
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var root = new GameObject("BotAnimationLab");
var viewer = root.AddComponent<BotAnimationLab>();
viewer.actors = new BotAnimationLab.Actor[4];
viewer.clayMaterial = Material("Clay", new Color(.65f, .69f, .74f));
Color[] colors = { new Color(.20f,.60f,.90f), new Color(.96f,.55f,.18f), new Color(.28f,.72f,.43f), new Color(.66f,.42f,.85f) };
for (int i = 0; i < Names.Length; i++)
{
string name = Names[i];
var stage = new GameObject("Stage_" + name); stage.transform.SetParent(root.transform, false);
stage.transform.localPosition = new Vector3((1.5f - i) * 20f, 0, 0);
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Common + "/Prefabs/" + name + ".prefab"), stage.transform);
var bounds = BoundsOf(go);
go.transform.localScale *= 1.6f / bounds.size.y;
bounds = BoundsOf(go);
go.transform.position -= new Vector3(bounds.center.x - stage.transform.position.x, bounds.min.y, bounds.center.z);
var a = go.GetComponent<Animator>();
var skins = go.GetComponentsInChildren<SkinnedMeshRenderer>();
var color = Material(name + "Color", colors[i]);
var joints = Material(name + "Joints", colors[i] * .6f);
foreach (var skin in skins) skin.sharedMaterial = skin.name.Contains("Joint") ? joints : color;
var controller = (AnimatorController)a.runtimeAnimatorController;
var states = controller.layers[0].stateMachine.states.Select(s => s.state).ToArray();
viewer.actors[i] = new BotAnimationLab.Actor { label = name, animator = a, stage = stage.transform, skins = skins,
originalMaterials = skins.Select(s => s.sharedMaterial).ToArray(),
clips = BotAnimationLab.StateNames.Select(n => (AnimationClip)states.Single(s => s.name == n).motion).ToArray() };
var pedestal = GameObject.CreatePrimitive(PrimitiveType.Cylinder); pedestal.name = "Pedestal_" + name;
pedestal.transform.SetParent(root.transform, false); pedestal.transform.position = stage.transform.position + new Vector3(0,-.035f,0);
pedestal.transform.localScale = new Vector3(1.25f,.035f,1.25f); pedestal.GetComponent<Renderer>().sharedMaterial = color;
UnityEngine.Object.DestroyImmediate(pedestal.GetComponent<Collider>());
}
var floor = GameObject.CreatePrimitive(PrimitiveType.Plane); floor.name = "ReviewFloor"; floor.transform.SetParent(root.transform, false);
floor.transform.position = new Vector3(0,-.08f,0); floor.transform.localScale = new Vector3(10,1,1);
floor.GetComponent<Renderer>().sharedMaterial = Material("Floor", new Color(.79f,.84f,.87f));
UnityEngine.Object.DestroyImmediate(floor.GetComponent<Collider>());
var background = new GameObject("BackgroundCamera").AddComponent<Camera>(); background.transform.SetParent(root.transform, false);
background.clearFlags = CameraClearFlags.SolidColor; background.backgroundColor = new Color(.82f,.89f,.94f); background.depth = -10; background.cullingMask = 0;
var camera = new GameObject("ReviewCamera").AddComponent<Camera>(); camera.transform.SetParent(root.transform, false);
camera.tag = "MainCamera"; camera.rect = new Rect(0,.335f,1,.54f); camera.orthographic = true;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = background.backgroundColor;
camera.nearClipPlane = .05f; camera.farClipPlane = 40; camera.allowHDR = false; camera.gameObject.AddComponent<AudioListener>();
viewer.reviewCamera = camera;
viewer.comparisonCameras = new Camera[4];
for (int i = 0; i < 4; i++)
{
var cell = new GameObject("CompareCamera_" + Names[i]).AddComponent<Camera>(); cell.transform.SetParent(root.transform, false);
cell.rect = new Rect((i % 2) * .5f, i < 2 ? .605f : .335f, .5f, .27f);
cell.orthographic = true; cell.clearFlags = CameraClearFlags.SolidColor; cell.backgroundColor = background.backgroundColor;
cell.nearClipPlane = .05f; cell.farClipPlane = 40; cell.allowHDR = false;
viewer.comparisonCameras[i] = cell;
}
foreach (var config in new[] { ("KeyLight", new Vector3(35, -40, 0), 1.3f), ("FillLight", new Vector3(30, 140, 0), .6f) })
{
var light = new GameObject(config.Item1).AddComponent<Light>(); light.transform.SetParent(root.transform, false);
light.type = LightType.Directional; light.transform.rotation = Quaternion.Euler(config.Item2); light.intensity = config.Item3;
}
RenderSettings.ambientMode = AmbientMode.Flat; RenderSettings.ambientLight = new Color(.65f,.68f,.73f); RenderSettings.fog = false;
var ui = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(uiPath), root.transform);
viewer.ui = ui.transform; ui.GetComponentInChildren<BotAnimationSeekBar>(true).viewer = viewer;
var events = new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule)); events.transform.SetParent(root.transform, false);
viewer.RefreshView();
EditorSceneManager.MarkSceneDirty(scene); EditorSceneManager.SaveScene(scene, ScenePath);
EditorGUIUtility.PingObject(AssetDatabase.LoadAssetAtPath<SceneAsset>(ScenePath));
return Verify();
}
static void Advance(Animator a, float seconds)
{
a.speed = 1;
for (int i = 0; i < Mathf.CeilToInt(seconds * 60); i++) a.Update(1f / 60);
a.speed = 0;
}
static void Expect(Animator a, string name)
{
if (!a.GetCurrentAnimatorStateInfo(0).IsName("Base Layer." + name) || a.IsInTransition(0)) throw new Exception("Expected " + name);
}
public static string Verify()
{
var reports = new List<object>();
foreach (string name in Names)
{
var scene = EditorSceneManager.NewPreviewScene();
var mesh = new Mesh();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Common + "/Prefabs/" + name + ".prefab"), scene);
var a = go.GetComponent<Animator>();
if (!a.avatar || !a.avatar.isHuman || !a.avatar.isValid) throw new Exception(name + " invalid Avatar");
var controller = (AnimatorController)a.runtimeAnimatorController;
string expected = Common + "/" + (name == "YBot" ? "YBot" : "XBot") + ".controller";
if (AssetDatabase.GetAssetPath(controller) != expected) throw new Exception(name + " wrong controller");
a.Rebind(); a.speed = 0; a.Update(0);
var states = new List<object>();
foreach (var child in controller.layers[0].stateMachine.states)
{
var state = child.state;
float minGap = float.MaxValue, maxGap = float.MinValue, motion = 0;
Vector3 first = Vector3.zero;
for (int i = 0; i < 25; i++)
{
a.Play("Base Layer." + state.name, 0, i / 25f); a.Update(0);
Vector3 hip = a.GetBoneTransform(HumanBodyBones.Hips).position;
Vector3 hand = a.GetBoneTransform(HumanBodyBones.LeftHand).position - hip;
Vector3 foot = a.GetBoneTransform(HumanBodyBones.LeftFoot).position - hip;
if (i == 0) first = hand + foot;
motion = Mathf.Max(motion, Vector3.Distance(first, hand + foot));
float gap = go.transform.InverseTransformPoint(a.GetBoneTransform(HumanBodyBones.RightFoot).position).x - go.transform.InverseTransformPoint(a.GetBoneTransform(HumanBodyBones.LeftFoot).position).x;
minGap = Mathf.Min(minGap, gap); maxGap = Mathf.Max(maxGap, gap);
foreach (var skin in go.GetComponentsInChildren<SkinnedMeshRenderer>())
{
skin.BakeMesh(mesh);
if (mesh.vertices.Any(v => float.IsNaN(v.sqrMagnitude) || float.IsInfinity(v.sqrMagnitude))) throw new Exception(name + " invalid skin");
}
}
if (motion < .00001f) throw new Exception(name + " has no motion in " + state.name);
states.Add(new { state = state.name, motion, minAnkleGap = minGap, maxAnkleGap = maxGap, samples = 25, source = AssetDatabase.GetAssetPath(state.motion) });
}
var checks = new List<string>();
a.SetInteger("Motion", 0); a.Play("Base Layer.Idle", 0, 0); a.Update(0);
a.SetInteger("Motion", 1); Advance(a,.4f); Expect(a,"Walk"); checks.Add("Idle -> Walk");
a.SetInteger("Motion", 0); Advance(a,.4f); Expect(a,"Idle"); checks.Add("Walk -> Idle");
foreach (int locomotion in new[] { 0, 1 }) foreach (string attack in new[] { "Attack01", "Attack02" })
{
string rest = locomotion == 0 ? "Idle" : "Walk";
a.SetInteger("Motion", locomotion); a.Play("Base Layer." + rest, 0, 0); a.Update(0);
a.SetTrigger(attack); Advance(a,.2f); Expect(a,attack);
float length = ((AnimationClip)controller.layers[0].stateMachine.states.Single(s=>s.state.name==attack).state.motion).length;
Advance(a,length+.4f); Expect(a,rest); checks.Add(rest + " -> " + attack + " -> " + rest);
}
reports.Add(new { actor = name, controller = expected, avatar = a.avatar.name, validHumanoid = a.isHuman, states, checks });
}
finally { UnityEngine.Object.DestroyImmediate(mesh); EditorSceneManager.ClosePreviewScene(scene); }
}
var result = new { passed = true, scene = ScenePath, samples = 400, transitionPaths = 24, actors = reports, verification = "Isolated Editor Animator and BakeMesh" };
Report("playback_validation", result);
return JsonConvert.SerializeObject(result);
}
public static string Controls()
{
if (!Application.isPlaying) throw new Exception("Play Mode required");
var v = UnityEngine.Object.FindObjectOfType<BotAnimationLab>();
if (!v || v.actors.Length != 4) throw new Exception("Bot scene not active");
var buttons = v.ui.GetComponentsInChildren<Button>(true).ToDictionary(b => b.name);
void Click(string n) => buttons["Btn_" + n].onClick.Invoke();
void Tick(float seconds) { for (int i = 0; i < Mathf.CeilToInt(seconds * 60); i++) v.Tick(1f / 60); }
void All(string state) { foreach (var actor in v.actors) Expect(actor.animator, state); }
Click("Idle"); v.Seek(0); Click("Walk"); Tick(.4f); All("Walk");
Click("Idle"); Tick(.4f); All("Idle");
foreach (string attack in new[] { "Attack01", "Attack02" })
{
Click(attack); Tick(.2f); All(attack); Tick(6); All("Idle");
}
Click("Walk"); Tick(.4f); Click("Play");
if (v.playing) throw new Exception("Pause failed");
v.Seek(.2f); float time = v.TimePosition;
Click("Next"); if (v.TimePosition <= time) throw new Exception("Next frame failed");
Click("Previous"); if (Mathf.Abs(v.TimePosition - time) > .002f) throw new Exception("Previous frame failed");
var seek = v.ui.GetComponentInChildren<BotAnimationSeekBar>(true);
Canvas.ForceUpdateCanvases(); var corners = new Vector3[4]; ((RectTransform)seek.transform).GetWorldCorners(corners);
seek.OnPointerDown(new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, Vector3.Lerp(corners[0], corners[2], .5f)) });
if (Mathf.Abs(v.TimePosition / v.Duration - .5f) > .002f) throw new Exception("Pointer seek failed");
foreach (string speed in new[] { "Speed025", "Speed05", "Speed1", "Speed2" }) Click(speed);
if (v.speed != 2) throw new Exception("Speed failed"); Click("Speed1");
foreach (int i in Enumerable.Range(0,4))
{
Click("Focus" + (i + 1));
if (v.actors.Where((a,j) => a.skins.Any(s => s.enabled) != (j == i)).Any()) throw new Exception("Focus failed");
}
Click("Detail"); Click("Detail"); if (v.detail != 2) throw new Exception("Hand view failed");
Click("All"); Click("Material");
if (!v.actors.All(a => a.skins.All(s => s.sharedMaterial == v.clayMaterial))) throw new Exception("Clay failed");
Click("Material"); Click("Side"); Click("Back"); Click("Front");
Click("Loop"); if (!v.repeatAttacks) throw new Exception("Loop toggle failed");
Click("Attack01"); Tick(6);
if (v.actors.Any(a=>a.animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer.Idle") && !a.animator.IsInTransition(0))) throw new Exception("Attack repeat failed");
Click("Loop"); Click("Walk"); Tick(.4f); v.Seek(.25f); v.RefreshView();
if (v.ui.GetComponentsInChildren<TMP_Text>(true).Any(t => t.text.Contains("Infinity") || t.text.Contains("NaN"))) throw new Exception("Invalid time display");
var result = new { passed = true, buttons = buttons.Count, checks = new[] { "locomotion controller transitions", "attack triggers and auto return", "pause", "next/previous frame", "pointer seek", "four speeds", "four actor focus", "hand detail", "gray/color", "front/side/back", "repeat attacks" } };
Report("play_controls", result); return JsonConvert.SerializeObject(result);
}
public static string Pose(int motion, int focus, float phase)
{
var v = UnityEngine.Object.FindObjectOfType<BotAnimationLab>();
v.selectedMotion = motion; v.SetFocus(focus); v.Seek(phase); v.RefreshView();
return JsonConvert.SerializeObject(new { v.selectedMotion, v.focus, phase });
}
}
#endif
+22 -5
View File
@@ -16,6 +16,7 @@ using UnityEngine.SceneManagement;
public sealed class CharacterReviewSubject
{
public string id, kind, prefab, notes;
public string localForward = "+Z";
public float expectedHeight = 1.8f, heightTolerance = 0.15f;
public int maxTriangles = 3500;
public bool requireXpbr = true;
@@ -134,6 +135,7 @@ public static class BuildCharacterReview
kind = ExtractString(slice, "kind"),
prefab = ExtractString(slice, "prefab"),
notes = ExtractString(slice, "notes"),
localForward = slice.Contains("\"localForward\"") ? ExtractString(slice, "localForward") : "+Z",
expectedHeight = ExtractFloat(slice, "expectedHeight", 1.8f),
heightTolerance = ExtractFloat(slice, "heightTolerance", 0.15f),
maxTriangles = ExtractInt(slice, "maxTriangles"),
@@ -177,6 +179,8 @@ public static class BuildCharacterReview
{
if (string.IsNullOrEmpty(subject.id) || string.IsNullOrEmpty(subject.prefab))
throw new Exception("Subject missing id/prefab");
if (subject.localForward != "+Z" && subject.localForward != "-Z")
throw new Exception("Subject localForward must be +Z or legacy -Z: " + subject.id);
if (AssetDatabase.LoadAssetAtPath<GameObject>(Norm(subject.prefab)) == null)
throw new Exception("Missing prefab " + subject.prefab);
}
@@ -207,7 +211,7 @@ public static class BuildCharacterReview
instance.name = subject.id;
instance.transform.localPosition = new Vector3(i * data.spacing, 0f, 0f);
instance.transform.localRotation = Quaternion.identity;
AddForwardMarker(instance.transform);
AddForwardMarker(instance.transform, subject.localForward);
AddNameLabel(instance.transform, subject.id + " / " + subject.kind);
EnsureAnimCycle(instance);
}
@@ -295,12 +299,14 @@ public static class BuildCharacterReview
}
}
static void AddForwardMarker(Transform subject)
static void AddForwardMarker(Transform subject, string localForward = "+Z")
{
if (localForward != "+Z" && localForward != "-Z") throw new ArgumentException("Unsupported localForward");
var marker = GameObject.CreatePrimitive(PrimitiveType.Cube);
marker.name = "ForwardMinusZ";
bool legacy = localForward == "-Z";
marker.name = legacy ? "ForwardMinusZ" : "ForwardPlusZ";
marker.transform.SetParent(subject, false);
marker.transform.localPosition = new Vector3(0f, 0.05f, -0.55f);
marker.transform.localPosition = new Vector3(0f, 0.05f, legacy ? -0.55f : 0.55f);
marker.transform.localScale = new Vector3(0.12f, 0.04f, 0.45f);
UnityEngine.Object.DestroyImmediate(marker.GetComponent<Collider>());
var renderer = marker.GetComponent<Renderer>();
@@ -309,6 +315,17 @@ public static class BuildCharacterReview
renderer.sharedMaterial = mat;
}
public static string VerifyForwardConvention()
{
var subjects=ParseSubjects("[{\"id\":\"new\"},{\"id\":\"legacy\",\"localForward\":\"-Z\"}]");
if(subjects.Length!=2 || subjects[0].localForward!="+Z" || subjects[1].localForward!="-Z")
throw new Exception("Manifest forward defaults or explicit legacy override failed");
var manifest=ReadManifest();
if(manifest.subjects.Any(s=>s.localForward!="+Z" && s.localForward!="-Z"))
throw new Exception("Invalid declared forward in current manifest");
return "PASS +Z defaults and legacy -Z overrides; manifest subjects="+manifest.subjects.Length;
}
static void AddNameLabel(Transform subject, string label)
{
var go = new GameObject("NameLabel");
@@ -454,7 +471,7 @@ public static class BuildCharacterReview
var node = root.transform.Find("Subjects/" + subject.id);
if (node == null) throw new Exception("Missing subject node " + subject.id);
var renderers = node.GetComponentsInChildren<Renderer>(true)
.Where(r => r.name != "ForwardMinusZ" && r.name != "NameLabel" && r.GetComponentInParent<TextMesh>() == null && !r.name.StartsWith("Label_", StringComparison.Ordinal))
.Where(r => r.name != "ForwardMinusZ" && r.name != "ForwardPlusZ" && r.name != "NameLabel" && r.GetComponentInParent<TextMesh>() == null && !r.name.StartsWith("Label_", StringComparison.Ordinal))
.ToArray();
if (renderers.Length == 0) throw new Exception(subject.id + " has no mesh renderer");
var bounds = ActualBounds(renderers[0]);
+522 -59
View File
@@ -7,6 +7,7 @@ using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
using Object = UnityEngine.Object;
@@ -14,11 +15,16 @@ using Object = UnityEngine.Object;
public static class BuildHomeCamp
{
const string Root = "Assets/Bundles/Scenes/Town/HomeCamp";
const string Town = "Assets/Bundles/Scenes/Town/TownSquare";
const string Shared = "Assets/Bundles/Scenes/Common";
const string PrefabPath = Root + "/HomeCamp.prefab";
const string ScenePath = Root + "/HomeCamp.unity";
const int Layer = 29;
const float GroundHalfSize = 21;
const float StoneTileMeters = 3.2f;
static readonly string[] Parts = { "ground", "mainhouse", "cottage", "fountain" };
static readonly string[] Props = { "fence", "stall", "flowerpot", "rocks", "steps", "cypress", "bush", "flowercluster", "shadetree" };
// Studio HomeCamp props were exported facing -Z. DGX static meshes use the project +Z front, so the prefab root stays unrotated.
static readonly string[] LegacyMinusZ = { "fence", "stall", "flowerpot", "rocks", "steps", "cypress", "bush" };
static string Repo => Directory.GetParent(Application.dataPath).Parent.FullName;
static string Review => Path.Combine(Repo, "Tools/output/homecamp_review");
[Serializable] class Receipt { public string status = ""; public int triangles = 0; public int targetTriangles = 0; }
@@ -29,6 +35,15 @@ public static class BuildHomeCamp
{ var s = SceneManager.GetSceneAt(i); return s.path + " dirty=" + s.isDirty; }));
}
public static string RenderInfo()
{
var a = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset;
if (!a) return "No URP asset";
return QualitySettings.names[QualitySettings.GetQualityLevel()] + " asset=" + AssetDatabase.GetAssetPath(a)
+ " mainShadows=" + a.supportsMainLightShadows + " distance=" + a.shadowDistance + " res=" + a.mainLightShadowmapResolution
+ " cascades=" + a.shadowCascadeCount + " soft=" + a.supportsSoftShadows + " hdr=" + a.supportsHDR + " msaa=" + a.msaaSampleCount;
}
static void Folder(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
@@ -54,14 +69,15 @@ public static class BuildHomeCamp
File.Copy(source, destination, true);
AssetDatabase.ImportAsset(destination, ImportAssetOptions.ForceSynchronousImport);
}
static void TextureSettings(string path, bool srgb, bool backdrop = false)
static void TextureSettings(string path, bool srgb, bool backdrop = false, bool tile = false, bool isProp = false)
{
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
importer.textureType = TextureImporterType.Default;
importer.sRGBTexture = srgb; importer.alphaIsTransparency = false;
importer.mipmapEnabled = !backdrop; importer.maxTextureSize = 2048;
importer.textureCompression = TextureImporterCompression.Uncompressed;
importer.wrapMode = TextureWrapMode.Clamp;
importer.mipmapEnabled = !backdrop; importer.maxTextureSize = isProp ? 512 : (tile ? 1024 : 2048);
importer.textureCompression = (tile || isProp) ? TextureImporterCompression.Compressed : TextureImporterCompression.Uncompressed;
importer.wrapMode = tile ? TextureWrapMode.Repeat : TextureWrapMode.Clamp;
importer.anisoLevel = tile ? 4 : 1;
importer.SaveAndReimport();
}
static Material SaveMaterial(string path, string shaderName)
@@ -86,8 +102,53 @@ public static class BuildHomeCamp
if (receipt.status != "ready_for_unity" || receipt.triangles * 10 > receipt.targetTriangles * 13)
throw new InvalidOperationException("Unfinished model " + part);
}
foreach (var part in Parts)
foreach (var part in Parts) ImportPart(part);
Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp/backdrop.png"), Root + "/backdrop.png");
TextureSettings(Root + "/backdrop.png", true, true);
var background = SaveMaterial(Root + "/backdrop.mat", "Universal Render Pipeline/Unlit");
background.SetTexture("_BaseMap", AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/backdrop.png"));
background.SetColor("_BaseColor", Color.white);
EditorUtility.SetDirty(background); AssetDatabase.SaveAssetIfDirty(background);
BuildLookDev();
return "Imported four Studio assets + XPbr, authored four component Prefabs, unlit backdrop and lit look-dev materials.";
}
/// <summary>Imports the v2 B/C props whose five-step receipts are ready; returns the imported ids.</summary>
public static string BuildProps()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before authoring.");
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport);
var done = new List<string>(); var skipped = new List<string>();
foreach (var part in Props)
{
var path = Path.Combine(Repo, "Tools/output/homecamp_studio", part, "production.json");
var receipt = File.Exists(path) ? JsonUtility.FromJson<Receipt>(File.ReadAllText(path)) : null;
if (receipt == null || receipt.status != "ready_for_unity" || receipt.triangles * 10 > receipt.targetTriangles * 13)
{ skipped.Add(part); continue; }
ImportPart(part); done.Add(part);
}
return "Imported props: " + string.Join(",", done) + "; not ready: " + string.Join(",", skipped);
}
/// <summary>Reimports cottage when its five-step receipt is ready (v2 awning side house).</summary>
public static string BuildCottage()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before authoring.");
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport);
var path = Path.Combine(Repo, "Tools/output/homecamp_studio/cottage/production.json");
var receipt = File.Exists(path) ? JsonUtility.FromJson<Receipt>(File.ReadAllText(path)) : null;
if (receipt == null || receipt.status != "ready_for_unity" || receipt.triangles * 10 > receipt.targetTriangles * 13)
throw new InvalidOperationException("Cottage not ready_for_unity");
ImportPart("cottage");
// Secondary UV for lightmaps (same as mainhouse/fountain).
var importer = (ModelImporter)AssetImporter.GetAtPath(Root + "/cottage/cottage.fbx");
if (!importer.generateSecondaryUV || importer.secondaryUVPackMargin < 8)
{ importer.generateSecondaryUV = true; importer.secondaryUVPackMargin = 8; importer.SaveAndReimport(); }
return "Imported cottage triangles=" + receipt.triangles;
}
static void ImportPart(string part)
{
var dir = Root + "/" + part;
var source = Path.Combine(Repo, "Tools/output/homecamp_studio", part, "xpbr");
foreach (var suffix in new[] { ".fbx", "_base.png", "_mix.png" })
@@ -99,12 +160,17 @@ public static class BuildHomeCamp
importer.meshCompression = ModelImporterMeshCompression.Off;
importer.isReadable = part == "ground"; importer.addCollider = false;
importer.SaveAndReimport();
TextureSettings(dir + "/" + part + "_base.png", true);
TextureSettings(dir + "/" + part + "_mix.png", false);
bool isProp = Props.Contains(part);
TextureSettings(dir + "/" + part + "_base.png", true, isProp: isProp);
TextureSettings(dir + "/" + part + "_mix.png", false, isProp: isProp);
var mat = SaveMaterial(dir + "/" + part + ".mat", "Test/HomeCampXPbr");
mat.SetTexture("_MainTex", AssetDatabase.LoadAssetAtPath<Texture2D>(dir + "/" + part + "_base.png"));
mat.SetTexture("_MixTex", AssetDatabase.LoadAssetAtPath<Texture2D>(dir + "/" + part + "_mix.png"));
mat.SetColor("_BaseColor", Color.white); mat.SetFloat("_Metallic", 1); mat.SetFloat("_Roughness", 1); mat.SetFloat("_AO", 1);
// Candy plaster: keep albedo readable, kill hot specular (fountain bake A~0.2 → smoothness 0.8 without scale).
mat.SetColor("_BaseColor", new Color(1.08f, 1.06f, 1.03f, 1));
mat.SetFloat("_Metallic", .05f); mat.SetFloat("_Roughness", 1); mat.SetFloat("_SmoothnessScale", .22f); mat.SetFloat("_AO", .65f);
mat.SetFloat("_LightmapShadeGamma", .58f);
mat.enableInstancing = true;
EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat);
var scene = EditorSceneManager.NewPreviewScene();
try
@@ -116,59 +182,174 @@ public static class BuildHomeCamp
if (part == "ground")
foreach (var f in go.GetComponentsInChildren<MeshFilter>())
{ var c = f.gameObject.AddComponent<MeshCollider>(); c.sharedMesh = f.sharedMesh; c.convex = false; }
PrefabUtility.SaveAsPrefabAsset(go, dir + "/" + part + ".prefab");
var root = go;
if (Props.Contains(part))
{
root = new GameObject(part); SceneManager.MoveGameObjectToScene(root, scene);
go.transform.SetParent(root.transform, false); go.name = part + "_mesh";
if (LegacyMinusZ.Contains(part))
go.transform.localRotation = Quaternion.Euler(0, 180, 0);
}
PrefabUtility.SaveAsPrefabAsset(root, dir + "/" + part + ".prefab");
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
/// <summary>Lit foliage cards, tiled XTerrain ground and post Volume; does not reimport Studio models.</summary>
public static string BuildLookDev()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before authoring.");
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport);
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/XTerrain.shader", ImportAssetOptions.ForceSynchronousImport);
BuildCardMaterials();
BuildGroundSurface();
BuildVolumeProfile();
BuildLightingSettings();
TuneXPbrMaterials();
// Lightmap UVs go to UV1 only; Studio UV0 and baked XPbr textures stay untouched.
foreach (var part in new[] { "mainhouse", "fountain", "cottage" })
{
var importer = (ModelImporter)AssetImporter.GetAtPath(Root + "/" + part + "/" + part + ".fbx");
if (!importer) continue;
if (importer.generateSecondaryUV && importer.secondaryUVPackMargin >= 8) continue;
importer.generateSecondaryUV = true; importer.secondaryUVPackMargin = 8; importer.SaveAndReimport();
}
Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp/backdrop.png"), Root + "/backdrop.png");
TextureSettings(Root + "/backdrop.png", true, true);
var background = SaveMaterial(Root + "/backdrop.mat", "Universal Render Pipeline/Unlit");
background.SetTexture("_BaseMap", AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/backdrop.png"));
background.SetColor("_BaseColor", Color.white);
EditorUtility.SetDirty(background); AssetDatabase.SaveAssetIfDirty(background);
return "Authored lit foliage cards, XTerrain ground, HomeCampVolume, lighting settings and lightmap UVs.";
}
/// <summary>Matte candy response: lower metallic/smoothness, slight albedo lift for readable diffuse.</summary>
static void TuneXPbrMaterials()
{
foreach (var path in AssetDatabase.FindAssets("t:Material", new[] { Root })
.Select(AssetDatabase.GUIDToAssetPath)
.Where(p => p.EndsWith(".mat")))
{
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
if (!mat || mat.shader == null || mat.shader.name != "Test/HomeCampXPbr") continue;
mat.SetColor("_BaseColor", new Color(1.08f, 1.06f, 1.03f, 1));
mat.SetFloat("_Metallic", .05f);
mat.SetFloat("_Roughness", 1);
mat.SetFloat("_SmoothnessScale", .22f);
mat.SetFloat("_AO", .65f);
mat.SetFloat("_LightmapShadeGamma", .58f);
mat.enableInstancing = true;
EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat);
}
}
static LightingSettings BuildLightingSettings()
{
var path = Root + "/HomeCampLighting.lighting";
var settings = AssetDatabase.LoadAssetAtPath<LightingSettings>(path);
if (!settings) { settings = new LightingSettings { name = "HomeCampLighting" }; AssetDatabase.CreateAsset(settings, path); }
settings.bakedGI = true; settings.realtimeGI = false;
settings.lightmapper = LightingSettings.Lightmapper.ProgressiveGPU;
// Fully-baked sun stores cast shadows in lightmap RGB (offscreen camera.Render has no realtime shadowmaps).
settings.mixedBakeMode = MixedLightingMode.Subtractive;
settings.lightmapResolution = 16; settings.lightmapPadding = 4; settings.lightmapMaxSize = 1024;
settings.directionalityMode = LightmapsMode.NonDirectional;
// Enough bounces for readable shade walls; keep indirect modest so plaza cast shadows stay dark.
settings.directSampleCount = 48; settings.indirectSampleCount = 512; settings.environmentSampleCount = 128; settings.maxBounces = 3;
settings.ao = true; settings.aoMaxDistance = .75f; settings.aoExponentDirect = .55f; settings.aoExponentIndirect = .7f;
settings.albedoBoost = 1f; settings.indirectScale = 1f;
settings.lightmapCompression = LightmapCompression.NormalQuality; settings.filteringMode = LightingSettings.FilterMode.Auto;
EditorUtility.SetDirty(settings); AssetDatabase.SaveAssetIfDirty(settings);
return settings;
}
static void BuildCardMaterials()
{
foreach (var name in new[] { "tree_card", "flower_card" })
{
Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp", name + ".png"), Root + "/" + name + ".png");
TextureSettings(Root + "/" + name + ".png", true);
var ti = (TextureImporter)AssetImporter.GetAtPath(Root + "/" + name + ".png");
ti.alphaIsTransparency = true; ti.SaveAndReimport();
var card = SaveMaterial(Root + "/" + name + ".mat", "Universal Render Pipeline/Unlit");
card.SetTexture("_BaseMap", AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/" + name + ".png"));
var card = SaveMaterial(Root + "/" + name + ".mat", "Universal Render Pipeline/Simple Lit");
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/" + name + ".png");
// The lightmapper reads cutout transparency from _MainTex/_Cutoff.
card.SetTexture("_BaseMap", texture); card.SetTexture("_MainTex", texture);
card.SetColor("_BaseColor", Color.white); card.SetFloat("_AlphaClip", 1); card.SetFloat("_Cutoff", .55f);
card.EnableKeyword("_ALPHATEST_ON"); card.SetFloat("_Cull", 0); card.renderQueue = 2450;
card.SetFloat("_ReceiveShadows", 1); card.DisableKeyword("_RECEIVE_SHADOWS_OFF");
card.SetFloat("_SpecularHighlights", 0); card.DisableKeyword("_SPECULAR_COLOR");
card.SetOverrideTag("RenderType", "TransparentCutout");
EditorUtility.SetDirty(card); AssetDatabase.SaveAssetIfDirty(card);
}
BuildGroundSurface();
return "Imported four Studio assets + XPbr, authored four component Prefabs and unlit background material.";
}
static Texture2D GroundLayer(string name, bool srgb, bool tile)
{
Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp", name + ".png"), Root + "/" + name + ".png");
TextureSettings(Root + "/" + name + ".png", srgb, false, tile);
return AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/" + name + ".png");
}
static void BuildGroundSurface()
{
Source(Path.Combine(Repo, "Doc/Design/Scene/HomeCamp/ground_surface.png"), Root + "/ground_surface.png");
TextureSettings(Root + "/ground_surface.png", true);
var mat = SaveMaterial(Root + "/ground_surface.mat", "Universal Render Pipeline/Unlit");
mat.SetTexture("_BaseMap", AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/ground_surface.png"));
mat.SetColor("_BaseColor", Color.white);
const float half = GroundHalfSize;
var stone = GroundLayer("ground_tile_stone", true, true);
var grass = GroundLayer("ground_tile_grass", true, true);
var grassDark = GroundLayer("ground_tile_grass_dark", true, true);
var control = GroundLayer("ground_control", false, false);
var mat = SaveMaterial(Root + "/ground_terrain.mat", "XWorld/XTerrain");
mat.SetTexture("_LayerTex0", stone); mat.SetTexture("_LayerTex1", grass);
mat.SetTexture("_LayerTex2", grassDark); mat.SetTexture("_LayerTex3", grassDark);
mat.SetTexture("_Control", control);
mat.SetFloat("_Tiling", half * 2 / StoneTileMeters);
// Softer micro-relief so the lawn reads as soft grass rather than shiny plastic.
// High _Roughness bias → nearly matte ground; metallic off so stone/grass stay diffuse-led.
mat.SetFloat("_BumpScale", 1.6f); mat.SetFloat("_Roughness", 1.15f); mat.SetFloat("_Metallic", 0); mat.SetFloat("_AO", .9f);
// Less GI lift → ~20% deeper shade on busy ground albedo.
mat.SetFloat("_LightmapContrast", .58f);
mat.SetFloat("_LightmapShadeMul", 1f);
mat.SetColor("_LightmapShadeTint", new Color(.88f, .9f, .95f, 1f));
EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat);
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>(Root + "/GroundSurface.asset");
if (!mesh) { mesh = new Mesh(); AssetDatabase.CreateAsset(mesh, Root + "/GroundSurface.asset"); }
mesh.Clear(); mesh.name = "HomeCamp continuous ground surface";
mesh.vertices = new[] {new Vector3(-18,0,-21), new Vector3(-18,0,21), new Vector3(18,0,21), new Vector3(18,0,-21)};
mesh.vertices = new[] {new Vector3(-half,0,-half), new Vector3(-half,0,half), new Vector3(half,0,half), new Vector3(half,0,-half)};
mesh.uv = new[] {new Vector2(0,0),new Vector2(0,1),new Vector2(1,1),new Vector2(1,0)};
mesh.uv2 = mesh.uv;
mesh.triangles = new[] {0,1,2,0,2,3}; mesh.RecalculateNormals(); mesh.RecalculateTangents(); mesh.RecalculateBounds();
EditorUtility.SetDirty(mesh); AssetDatabase.SaveAssetIfDirty(mesh);
var scene = EditorSceneManager.NewPreviewScene();
try
{
var go = new GameObject("GroundSurface"); SceneManager.MoveGameObjectToScene(go, scene);
go.AddComponent<MeshFilter>().sharedMesh = mesh; go.AddComponent<MeshRenderer>().sharedMaterial = mat;
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var renderer = go.AddComponent<MeshRenderer>(); renderer.sharedMaterial = mat;
renderer.shadowCastingMode = ShadowCastingMode.Off; renderer.receiveShadows = true;
go.AddComponent<MeshCollider>().sharedMesh = mesh;
PrefabUtility.SaveAsPrefabAsset(go, Root + "/GroundSurface.prefab");
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
static VolumeProfile BuildVolumeProfile()
{
var path = Root + "/HomeCampVolume.asset";
var profile = AssetDatabase.LoadAssetAtPath<VolumeProfile>(path);
if (!profile) { profile = ScriptableObject.CreateInstance<VolumeProfile>(); AssetDatabase.CreateAsset(profile, path); }
foreach (var old in profile.components.ToArray()) { profile.components.Remove(old); Object.DestroyImmediate(old, true); }
T Add<T>() where T : VolumeComponent
{
var c = profile.Add<T>(true); c.name = typeof(T).Name;
c.hideFlags = HideFlags.HideInInspector | HideFlags.HideInHierarchy;
AssetDatabase.AddObjectToAsset(c, profile); return c;
}
Add<Tonemapping>().mode.value = TonemappingMode.Neutral;
var color = Add<ColorAdjustments>();
// Noon frame + another ~20% brightness (EV += log2(1.2)); mild contrast keeps shade soft-PBR.
color.postExposure.value = .74f; color.contrast.value = 10; color.saturation.value = 5; color.hueShift.value = 0;
color.colorFilter.value = Color.white;
var white = Add<WhiteBalance>(); white.temperature.value = 6; white.tint.value = 0;
var bloom = Add<Bloom>(); bloom.threshold.value = 1.15f; bloom.intensity.value = .12f; bloom.scatter.value = .55f;
var vignette = Add<Vignette>(); vignette.intensity.value = .18f; vignette.smoothness.value = .45f;
EditorUtility.SetDirty(profile); AssetDatabase.SaveAssetIfDirty(profile);
return profile;
}
static GameObject Place(Transform parent, string path, string name, Vector3 pos, float size, bool width = false, bool faceCenter = false)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
@@ -193,7 +374,7 @@ public static class BuildHomeCamp
var name = part == "bush" ? "shrub" : "tree";
return "Assets/Bundles/Scenes/Prototype/Battlefield/A/Components/" + name + "/" + name + ".prefab";
}
return Town + "/" + part + "/" + part + ".prefab";
return Shared + "/" + part + "/" + part + ".prefab";
}
static float GroundY(GameObject ground, float x, float z)
{
@@ -209,34 +390,78 @@ public static class BuildHomeCamp
var go = GameObject.CreatePrimitive(PrimitiveType.Quad); go.name = name;
go.transform.SetParent(parent, false); Object.DestroyImmediate(go.GetComponent<Collider>());
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(Root + "/" + image + ".png");
var rotation = Quaternion.LookRotation(new Vector3(0, 1.8f, 3) - new Vector3(0, 15, -27), Vector3.up);
var rotation = Quaternion.LookRotation(CamTarget - CamPos, Vector3.up);
go.transform.rotation = rotation;
go.transform.position = bottom + (rotation * Vector3.up) * height * .5f;
go.transform.localScale = new Vector3(height * texture.width / texture.height, height, 1);
var renderer = go.GetComponent<Renderer>(); renderer.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(Root + "/" + image + ".mat");
renderer.shadowCastingMode = ShadowCastingMode.Off; renderer.receiveShadows = false;
// Trees cast into the lightmap again (concept art needs canopy shadows); kept behind roofs to avoid plaza ink.
renderer.shadowCastingMode = ShadowCastingMode.On;
renderer.receiveShadows = true;
SetLayer(go); return go;
}
static readonly Vector3 CamPos = new Vector3(0, 14, -25), CamTarget = new Vector3(0, 1.6f, 3);
const float CamFov = 50;
static Camera MakeCamera(Scene scene, Transform parent)
{
var go = new GameObject("HomeCampCamera"); SceneManager.MoveGameObjectToScene(go, scene);
if (parent) go.transform.SetParent(parent, false);
var camera = go.AddComponent<Camera>();
go.transform.position = new Vector3(0, 15, -27);
go.transform.LookAt(new Vector3(0, 1.8f, 3));
camera.fieldOfView = 48; camera.nearClipPlane = .1f; camera.farClipPlane = 180;
go.transform.position = CamPos;
go.transform.LookAt(CamTarget);
camera.fieldOfView = CamFov; camera.nearClipPlane = .1f; camera.farClipPlane = 180;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(.46f, .72f, .88f);
camera.cullingMask = 1 << Layer; camera.scene = scene;
camera.allowHDR = false; camera.allowMSAA = true;
camera.allowHDR = true; camera.allowMSAA = true;
var data = camera.GetUniversalAdditionalCameraData();
data.renderPostProcessing = true; data.volumeLayerMask = 1 << Layer;
return camera;
}
static void Light(Scene scene, Transform parent)
static Light Light(Scene scene, Transform parent)
{
var go = new GameObject("HomeCampSun"); SceneManager.MoveGameObjectToScene(go, scene);
if (parent) go.transform.SetParent(parent, false);
var light = go.AddComponent<Light>(); light.type = LightType.Directional;
light.color = new Color(1, .95f, .84f); light.intensity = 1.05f; light.shadows = LightShadows.Soft;
light.cullingMask = 1 << Layer; go.transform.rotation = Quaternion.Euler(43, -32, 0);
// Key: warm sun. Fill comes from a separate baked skylight so Subtractive maps do not crush backfaces.
// Fully Baked sun: cast shadows live in the lightmap RGB. Mixed/Subtractive still needs
// realtime shadow maps, which offscreen camera.Render does not produce — so review looked shadowless.
// Noon key: high elevation, +30% intensity vs prior look-dev; shade deepened in bake/shaders.
light.color = new Color(1, .98f, .94f); light.intensity = 2.15f; light.shadows = LightShadows.Soft;
light.lightmapBakeType = LightmapBakeType.Baked; light.shadowAngle = 4;
light.shadowStrength = .94f; light.shadowBias = .05f; light.shadowNormalBias = .3f;
light.cullingMask = 1 << Layer; go.transform.rotation = Quaternion.Euler(68, 50, 0);
BakeFillLight(scene, parent);
return light;
}
/// <summary>
/// Baked-only opposite skylight (no shadows). URP ignores a second realtime Directional,
/// but Progressive still bakes this into lightmaps so backfaces stay cream while cast shadows remain.
/// </summary>
static void BakeFillLight(Scene scene, Transform parent)
{
var go = new GameObject("HomeCampBakeFill"); SceneManager.MoveGameObjectToScene(go, scene);
if (parent) go.transform.SetParent(parent, false);
var light = go.AddComponent<Light>(); light.type = LightType.Directional;
// Noon skylight fill — held back another ~20% so cast shade reads deeper.
light.color = new Color(.82f, .88f, 1f); light.intensity = .22f;
light.shadows = LightShadows.None; light.lightmapBakeType = LightmapBakeType.Baked;
light.cullingMask = 1 << Layer;
go.transform.rotation = Quaternion.Euler(75, -30, 0);
}
/// <summary>Grid of probes over the plaza so houses on LightProbes get baked sky fill, not black tetrahedra.</summary>
static void PlaceLightProbes(Transform parent)
{
var go = new GameObject("HomeCampLightProbes");
go.transform.SetParent(parent, false);
var group = go.AddComponent<LightProbeGroup>();
var positions = new System.Collections.Generic.List<Vector3>();
for (int x = -10; x <= 10; x += 4)
for (int z = -12; z <= 12; z += 4)
for (int y = 1; y <= 5; y += 2)
positions.Add(new Vector3(x, y, z));
group.probePositions = positions.ToArray();
}
static void Background(Transform parent, Camera camera)
{
@@ -260,6 +485,13 @@ public static class BuildHomeCamp
if (EditorApplication.isPlaying) throw new InvalidOperationException("Author in Edit Mode.");
var previous = SceneManager.GetActiveScene();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
// An already-open HomeCamp keeps this path. Saving the new scene, then baking the stale one, writes the cards back over the file.
var stale = SceneManager.GetSceneByPath(ScenePath);
if (stale.IsValid() && stale.isLoaded && stale != scene)
{
if (previous == stale) previous = default;
EditorSceneManager.CloseScene(stale, true);
}
try
{
SceneManager.SetActiveScene(scene);
@@ -287,38 +519,153 @@ public static class BuildHomeCamp
placements.Add(name + " groundY=" + y.ToString("F3"));
}
Put("mainhouse", "MainHouse", 0, 7.2f, 6.5f, false, true);
Put("mainhouse", "HouseLeft", -7.5f, 4, 4, false, true);
// Left side uses the awning cottage to match the reference market house; right stays a mirrored mainhouse.
Put("cottage", "HouseLeft", -6.8f, 3.6f, 3.8f, false, true);
Put("mainhouse", "HouseRight", 7.5f, 4, 4, false, true);
// Mirrored copy so the reused house does not read as an identical clone; HomeCampXPbr is two-sided.
var houseRight = root.transform.Find("HouseRight");
houseRight.localScale = Vector3.Scale(houseRight.localScale, new Vector3(-1, 1, 1));
// Sink house bases a few centimeters to hide the mesh/ground seam seen in portrait review.
foreach (var house in new[] { "MainHouse", "HouseLeft", "HouseRight" })
root.transform.Find(house).position += Vector3.down * .08f;
Put("fountain", "Fountain", 0, .8f, 4.4f, true);
Put("tree", "TreeBackLeft", -9, 8, 7, false, false, true);
Put("tree", "TreeBackRight", 9, 9, 6.5f, false, false, true);
Put("tree", "TreeRearLeft", -5.6f, 14, 4.8f, false, false, true);
Put("tree", "TreeRearRight", 5.6f, 14, 4.8f, false, false, true);
Put("tree", "TreeFrontLeft", -10, -4, 5, false, false, true);
// Keep canopy trees behind the roofs so they frame the backdrop without dunking HouseLeft in shadow.
// Nearer canopy slots are placed after the viewer vector, as DGX meshes when ready.
// TreeRearLeft / TreeRearRight stay cards: they sit behind the roofs and another 2000 tris each is not worth it.
Put("tree", "TreeRearLeft", -5.6f, 14.5f, 4.6f, false, false, true);
Put("tree", "TreeRearRight", 5.6f, 14.5f, 4.6f, false, false, true);
Put("lamp", "LampLeft", -5.2f, -.5f, 2.6f, false, false, true);
Put("lamp", "LampRight", 5.2f, 1, 2.6f, false, false, true);
Put("lamp", "LampForeground", 5.5f, -6, 2.7f, false, false, true);
Put("lamp", "LampPath", 3.4f, -8.4f, 2.7f, false, false, true);
Put("bench", "BenchFrontLeft", -4.4f, -4, 2.2f, true, true, true);
for (int i = 0; i < 12; i++)
{
var angle = (i * 30f) * Mathf.Deg2Rad;
Put("bush", "FountainFlowers" + i, Mathf.Sin(angle) * 2.7f, .8f + Mathf.Cos(angle) * 2.7f, .55f, false, false, true);
}
Put("bench", "BenchRight", 6.8f, -2.2f, 2.2f, true, true, true);
// Fountain ring is placed after the viewer vector: six flowercluster meshes, or the old cards if the prefab is missing.
foreach (var p in new[] { new Vector2(-7,-5), new Vector2(-5,-7), new Vector2(6,-6), new Vector2(8,-3), new Vector2(-9,2), new Vector2(9,2) })
Put("bush", "BorderFlowers", p.x, p.y, .9f, false, false, true);
// Flower clusters along the plaza rim and the path so the stone/grass edge is never a bare cut.
var rim = 0;
for (int i = 0; i < 18; i++)
{
var angle = (-80 + i * 20f) * Mathf.Deg2Rad;
var x = Mathf.Sin(angle) * 9.3f; var z = 3.5f + Mathf.Cos(angle) * 6.9f;
if (z < -2 && Mathf.Abs(x) < 2.5f) continue;
Put("bush", "RimFlowers" + rim++, x, z, .7f + (i % 3) * .12f, false, false, true);
}
var pathFlowers = new[] {
new Vector2(-.6f,-7.5f), new Vector2(.3f,-12.5f),
new Vector2(1.3f,-16), new Vector2(4.6f,-15.5f), new Vector2(-3.2f,-14.2f), new Vector2(3.2f,-14.8f),
new Vector2(5.1f,-10.2f), new Vector2(.8f,-18.5f) };
for (int i = 0; i < pathFlowers.Length; i++)
Put("bush", "PathFlowers" + i, pathFlowers[i].x, pathFlowers[i].y, .65f + (i % 3) * .08f, false, false, true);
void Prop(string part, string name, float x, float z, float size, bool width, Vector3 face, bool presentToCamera = false)
{
if (!AssetDatabase.LoadAssetAtPath<GameObject>(PartPath(part))) { placements.Add(name + " skipped (no prefab)"); return; }
var y = GroundY(ground, x, z);
var go = Place(root.transform, PartPath(part), name, new Vector3(x, y, z), size, width);
if (presentToCamera)
go.transform.rotation = Quaternion.LookRotation(CamTarget - CamPos, Vector3.up);
else
{
var toward = face - new Vector3(x, face.y, z);
go.transform.rotation = Quaternion.LookRotation(toward, Vector3.up);
}
go.transform.position += Vector3.up * (y - BoundsOf(go).min.y);
placements.Add(name + " groundY=" + y.ToString("F3"));
}
var plaza = new Vector3(0, 0, 2); var viewer = new Vector3(CamPos.x, 0, CamPos.z);
// Portrait horizontal view is about 29 degrees. Card slots at |x|>=11 fall outside it,
// so the shade trees stand in the lawn beside the roofs instead of clipping the frame edge.
if (AssetDatabase.LoadAssetAtPath<GameObject>(PartPath("shadetree")))
{
Prop("shadetree", "TreeBackLeft", -5.6f, 10.4f, 6.2f, false, viewer);
Prop("shadetree", "TreeBackRight", 6.2f, 9.6f, 5.7f, false, plaza);
}
else
{
Put("tree", "TreeBackLeft", -5.6f, 10.4f, 6.2f, false, false, true);
Put("tree", "TreeBackRight", 6.2f, 9.6f, 5.7f, false, false, true);
}
foreach (var (n, x, z, s) in new[] {
("ClusterNearL", -2.4f, -11.2f, .62f), ("ClusterNearR", 3.6f, -12.2f, .7f),
("ClusterPathL", -4.6f, -8.6f, .55f), ("ClusterPlaza", 2.6f, -6.4f, .5f) })
Prop("flowercluster", n, x, z, s, false, viewer);
if (AssetDatabase.LoadAssetAtPath<GameObject>(PartPath("flowercluster")))
{
// Blossoms are on local +Z. Yaw-only leaves the leafy top toward this high camera, so present the front.
for (int i = 0; i < 6; i++)
{
var angle = (i * 60f) * Mathf.Deg2Rad;
var size = .62f + (i % 3) * .06f;
Prop("flowercluster", "FountainCluster" + i, Mathf.Sin(angle) * 3.25f, .8f + Mathf.Cos(angle) * 3.25f, size, false, viewer, true);
}
}
else
{
for (int i = 0; i < 12; i++)
{
var angle = (i * 30f) * Mathf.Deg2Rad;
Put("bush", "FountainFlowers" + i, Mathf.Sin(angle) * 2.7f, .8f + Mathf.Cos(angle) * 2.7f, .55f, false, false, true);
}
}
// StallLeft removed: left cottage already carries the yellow awning counter.
Prop("stall", "StallRight", 4.5f, 2.8f, 2.4f, false, plaza);
Prop("steps", "StepsRear", 4.3f, 10.2f, 2.6f, true, viewer);
Prop("cypress", "CypressLeft", -4.3f, 9.8f, 5, false, viewer);
Prop("cypress", "CypressRight", 7.8f, 8.6f, 5.6f, false, viewer);
Prop("cypress", "CypressFrontLeft", -6.8f, -3.4f, 4.6f, false, viewer);
// Portrait frustum is only about +-5.4m wide at z=-10, so foreground props stay inside it.
foreach (var (n, x, z) in new[] { ("FenceLeftA", -5.2f, -8.6f), ("FenceLeftB", -3.4f, -11), ("FenceRightA", 5.4f, -9.6f) })
Prop("fence", n, x, z, 2.8f, true, plaza);
// Single-pot prefab scaled into small clusters (three-pot Studio mesh failed UV-preserving reduction).
foreach (var (n, x, z, s) in new[] {
("PotMainL1", -2.9f, 4.8f, .7f), ("PotMainL2", -2.4f, 5.1f, .55f), ("PotMainL3", -2.55f, 4.45f, .48f),
("PotMainR1", 2.9f, 4.9f, .7f), ("PotMainR2", 3.35f, 5.15f, .55f), ("PotMainR3", 3.1f, 4.5f, .48f),
("PotLeft1", -6.5f, 1.4f, .65f), ("PotLeft2", -6.05f, 1.7f, .5f),
("PotRight1", 6.7f, 1.8f, .65f), ("PotRight2", 7.1f, 1.5f, .5f) })
Prop("flowerpot", n, x, z, s, false, viewer);
foreach (var (n, x, z, s) in new[] {
("RockFrontLeft", -1.8f, -13.6f, 1.6f), ("RockFrontRight", 4.6f, -13.4f, 1.3f), ("RockLeft", -6, -6.4f, 1.2f),
("RockNear", -.4f, -15.8f, 1.1f), ("RockPath", 2.8f, -11.2f, .95f) })
Prop("rocks", n, x, z, s, true, viewer);
foreach (var (n, x, z, s) in new[] {
("BushFrontLeft", -4.6f, -12.8f, 1.5f), ("BushFront", -1.4f, -9.6f, 1.1f), ("BushFrontRight", 5.6f, -12.2f, 1.3f),
("BushRight", 6.4f, -6.2f, 1.3f), ("BushLeft", -7.2f, -1.6f, 1.4f), ("BushRearLeft", -5.6f, 7.8f, 1.2f),
("BushRearRight", 6, 8, 1.2f), ("BushNearLeft", -3.6f, -15.2f, 1.25f), ("BushNearRight", 3.8f, -15.6f, 1.15f),
("BushMidLeft", -5.4f, -7.4f, 1.2f) })
Prop("bush", n, x, z, s, true, viewer);
PlaceLightProbes(root.transform);
var camera = MakeCamera(scene, root.transform);
Light(scene, root.transform); Background(root.transform, camera); SetLayer(root);
var sun = Light(scene, root.transform); Background(root.transform, camera);
var volume = new GameObject("PostVolume").AddComponent<Volume>();
volume.transform.SetParent(root.transform, false);
volume.isGlobal = true; volume.priority = 10;
volume.sharedProfile = AssetDatabase.LoadAssetAtPath<VolumeProfile>(Root + "/HomeCampVolume.asset");
if (!volume.sharedProfile) throw new InvalidOperationException("Run BuildLookDev before Build.");
SetLayer(root);
RenderSettings.sun = sun;
RenderSettings.ambientMode = AmbientMode.Trilight;
RenderSettings.ambientSkyColor = new Color(.68f, .75f, .82f);
RenderSettings.ambientEquatorColor = new Color(.55f, .57f, .52f);
RenderSettings.ambientGroundColor = new Color(.35f, .34f, .29f);
// Noon ambient ~+30% vs prior; still cooler in shade so ground cast shadows stay visible.
RenderSettings.ambientIntensity = .9f;
RenderSettings.ambientSkyColor = new Color(.85f, .9f, .98f);
RenderSettings.ambientEquatorColor = new Color(.72f, .7f, .64f);
RenderSettings.ambientGroundColor = new Color(.55f, .52f, .46f);
RenderSettings.fog = true; RenderSettings.fogMode = FogMode.Linear;
RenderSettings.fogColor = new Color(.78f, .88f, .95f);
RenderSettings.fogStartDistance = 45; RenderSettings.fogEndDistance = 220;
MarkStatic(root);
Lightmapping.lightingSettings = AssetDatabase.LoadAssetAtPath<LightingSettings>(Root + "/HomeCampLighting.lighting");
if (!Lightmapping.lightingSettings) throw new InvalidOperationException("Run BuildLookDev before Build.");
// Subtractive fills shadowed texels with this color; keep it close to sunlit grass so maps stay soft.
// Darker than lit plaster so cast shadows read, brighter than ink so walls stay detailed.
RenderSettings.subtractiveShadowColor = new Color(.38f, .44f, .55f);
PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
EditorSceneManager.SaveScene(scene, ScenePath);
Directory.CreateDirectory(Review);
File.WriteAllText(Path.Combine(Review, "assembly.txt"), string.Join("\n", placements));
CaptureCamera(camera, 1080, 1920, "homecamp_portrait.png");
CaptureCamera(camera, 1080, 2400, "homecamp_tall.png");
return "Saved " + PrefabPath + " and " + ScenePath + "; " + placements.Count + " placements; portrait/tall rendered.";
// Review-only 1.6m stand-in; kept in the scene outside the Prefab, disabled for art captures.
ScaleReference(ground).SetActive(false);
EditorSceneManager.SaveScene(scene, ScenePath);
return "Saved " + PrefabPath + " and " + ScenePath + "; " + placements.Count + " placements. Run Bake next.";
}
finally
{
@@ -326,6 +673,101 @@ public static class BuildHomeCamp
EditorSceneManager.CloseScene(scene, true);
}
}
// Ground + buildings lightmapped so Subtractive bake stores visible cast shadows for review captures.
static readonly string[] Lightmapped = { "Ground", "MainHouse", "HouseLeft", "HouseRight", "Fountain" };
static void MarkStatic(GameObject root)
{
foreach (var renderer in root.GetComponentsInChildren<Renderer>(true))
{
var top = renderer.transform;
while (top.parent && top.parent != root.transform) top = top.parent;
var name = top.name;
if (name == "DistantLakeWaterfallCastle" || name.Contains("Flowers")) continue;
GameObjectUtility.SetStaticEditorFlags(renderer.gameObject, StaticEditorFlags.ContributeGI | StaticEditorFlags.BatchingStatic);
// Lamps, benches and tree cards cast into the lightmap but are lit from probes, so shared assets need no lightmap UVs.
if (renderer is MeshRenderer mesh) mesh.receiveGI = Lightmapped.Contains(name) ? ReceiveGI.Lightmaps : ReceiveGI.LightProbes;
}
}
/// <summary>Bakes HomeCamp.unity alone, renders review captures, then restores the previously open scenes.</summary>
public static async System.Threading.Tasks.Task<string> Bake()
{
if (EditorApplication.isPlaying) throw new InvalidOperationException("Stop Play before baking.");
if (Enumerable.Range(0, SceneManager.sceneCount).Any(i => SceneManager.GetSceneAt(i).isDirty))
throw new InvalidOperationException("An open scene has unsaved edits; save or discard it before baking.");
var setup = EditorSceneManager.GetSceneManagerSetup();
try
{
// Bake with HomeCamp as the only open scene so other scenes get no lighting data.
// Drop a stale in-memory copy first; OpenScene will not reload a scene that is already loaded.
var loaded = SceneManager.GetSceneByPath(ScenePath);
if (loaded.IsValid() && loaded.isLoaded)
{
var holder = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);
EditorSceneManager.CloseScene(loaded, true);
EditorSceneManager.CloseScene(holder, true);
}
EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
if (!Lightmapping.BakeAsync()) throw new InvalidOperationException("Lightmap bake could not start.");
var started = DateTime.UtcNow;
while (Lightmapping.isRunning)
{
if ((DateTime.UtcNow - started).TotalMinutes > 25) { Lightmapping.Cancel(); throw new TimeoutException("Lightmap bake exceeded 25 minutes."); }
await System.Threading.Tasks.Task.Delay(500);
}
// Lightmaps are assigned to the scene a few editor ticks after isRunning clears.
for (int i = 0; i < 40 && LightmapSettings.lightmaps.Length == 0; i++) await System.Threading.Tasks.Task.Delay(250);
if (LightmapSettings.lightmaps.Length == 0) throw new InvalidOperationException("Bake produced no lightmaps.");
EditorSceneManager.SaveScene(SceneManager.GetSceneByPath(ScenePath));
return Capture() + " Bake " + (DateTime.UtcNow - started).TotalSeconds.ToString("F0") + "s.";
}
finally
{
if (setup.Length > 0 && !setup.Any(s => s.path == ScenePath && s.isActive && setup.Length == 1))
EditorSceneManager.RestoreSceneManagerSetup(setup);
}
}
public static string Capture()
{
var previous = SceneManager.GetActiveScene();
var scene = SceneManager.GetSceneByPath(ScenePath);
var opened = !scene.IsValid() || !scene.isLoaded;
if (opened) scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Additive);
try
{
SceneManager.SetActiveScene(scene);
var root = scene.GetRootGameObjects().Single(g => g.name == "HomeCamp");
var camera = root.GetComponentInChildren<Camera>();
var scale = scene.GetRootGameObjects().Single(g => g.name.StartsWith("ReviewOnly_ScaleReference"));
scale.SetActive(true);
CaptureCamera(camera, 1080, 1920, "homecamp_scale_reference.png");
scale.SetActive(false);
CaptureCamera(camera, 1080, 1920, "homecamp_portrait.png");
CaptureCamera(camera, 1080, 2400, "homecamp_tall.png");
var maps = LightmapSettings.lightmaps.Length;
var ground = root.transform.Find("Ground")?.GetComponentInChildren<MeshRenderer>();
var gi = ground ? (" groundLM=" + ground.lightmapIndex + " scaleOffset=" + ground.lightmapScaleOffset) : "";
return "Rendered scale/portrait/tall with " + maps + " lightmap(s)." + gi;
}
finally
{
if (previous.IsValid() && previous.isLoaded) SceneManager.SetActiveScene(previous);
if (opened) EditorSceneManager.CloseScene(scene, true);
}
}
static GameObject ScaleReference(GameObject ground)
{
var mat = SaveMaterial(Root + "/review_scale.mat", "Universal Render Pipeline/Lit");
mat.SetColor("_BaseColor", new Color(.55f, .55f, .58f)); EditorUtility.SetDirty(mat); AssetDatabase.SaveAssetIfDirty(mat);
var go = GameObject.CreatePrimitive(PrimitiveType.Capsule); go.name = "ReviewOnly_ScaleReference_1.6m";
Object.DestroyImmediate(go.GetComponent<Collider>());
go.transform.localScale = new Vector3(.45f, .8f, .45f);
go.transform.position = new Vector3(2.8f, GroundY(ground, 2.8f, -2.5f) + .8f, -2.5f);
go.GetComponent<Renderer>().sharedMaterial = mat;
SetLayer(go); return go;
}
static void CaptureCamera(Camera camera, int width, int height, string name)
{
Directory.CreateDirectory(Review);
@@ -344,6 +786,7 @@ public static class BuildHomeCamp
}
public static string CaptureParts() => CaptureSet(Parts);
public static string CaptureReuse() => CaptureSet(new[] { "tree", "lamp", "bench", "bush" });
public static string CaptureProps() => CaptureSet(Props.Where(p => AssetDatabase.LoadAssetAtPath<GameObject>(PartPath(p))));
static string CaptureSet(IEnumerable<string> parts)
{
foreach (var part in parts)
@@ -351,7 +794,7 @@ public static class BuildHomeCamp
var scene = EditorSceneManager.NewPreviewScene();
try
{
var path = Parts.Contains(part) ? PartPath(part) : ReusePath(part);
var path = Parts.Contains(part) || Props.Contains(part) ? PartPath(part) : ReusePath(part);
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(path), scene);
SetLayer(go); Light(scene, null);
var b = BoundsOf(go); var extent = Mathf.Max(b.size.x, Mathf.Max(b.size.y, b.size.z));
@@ -403,8 +846,20 @@ public static class BuildHomeCamp
if (bg.sharedMaterial.shader.name != "Universal Render Pipeline/Unlit" || bg.shadowCastingMode != ShadowCastingMode.Off)
throw new InvalidOperationException("Backdrop must be unlit without shadows");
if (EditorBuildSettings.scenes.Any(s => s.path == ScenePath)) throw new InvalidOperationException("Review scene unexpectedly added to build");
var groundRenderer = root.transform.Find("Ground").GetComponentInChildren<Renderer>();
if (groundRenderer.sharedMaterial.shader.name != "XWorld/XTerrain" || !groundRenderer.receiveShadows)
throw new InvalidOperationException("Ground must be tiled XTerrain receiving shadows");
var unlitGameplay = root.GetComponentsInChildren<Renderer>(true)
.Where(r => r.transform != backdrop && r.sharedMaterial && r.sharedMaterial.shader.name.Contains("Unlit")).Select(r => r.name).ToArray();
if (unlitGameplay.Length > 0) throw new InvalidOperationException("Unlit outside backdrop: " + string.Join(",", unlitGameplay));
var volume = root.GetComponentInChildren<Volume>();
if (!volume || !volume.sharedProfile || !volume.sharedProfile.Has<Tonemapping>()) throw new InvalidOperationException("Missing post Volume");
var cards = root.GetComponentsInChildren<Renderer>(true).Count(r => r.sharedMaterial && r.sharedMaterial.shader.name == "Universal Render Pipeline/Simple Lit");
Directory.CreateDirectory(Review);
File.WriteAllText(Path.Combine(Review, "verification.txt"), "PASS " + string.Join("; ", report) + "; assembledTriangles=" + triangleCount + "; ground collision 25/25; unlit quad backdrop; independent scene; cottage/Studio ground retained as review candidates and not placed");
var tech = "TECH PASS " + string.Join("; ", report) + "; assembledTriangles=" + triangleCount + "; ground XTerrain lit+shadow; litFoliageCards=" + cards
+ "; post Volume; fog; ground collision 25/25; unlit quad backdrop only; independent scene; cottage v2 placed as HouseLeft; Studio ground still review-only";
const string art = "ART PENDING six-axis review against home_camp.png; see Tools/output/homecamp_review/compare_portrait.png";
File.WriteAllText(Path.Combine(Review, "verification.txt"), tech + "\n" + art + "\n");
return File.ReadAllText(Path.Combine(Review, "verification.txt"));
}
public static string OpenForReview()
@@ -412,10 +867,18 @@ public static class BuildHomeCamp
var scene = SceneManager.GetSceneByPath(ScenePath);
if (!scene.IsValid() || !scene.isLoaded) scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Additive);
SceneManager.SetActiveScene(scene);
var root = scene.GetRootGameObjects().Single(); Selection.activeGameObject = root;
var root = scene.GetRootGameObjects().Single(g => g.name == "HomeCamp"); Selection.activeGameObject = root;
var camera = root.GetComponentInChildren<Camera>();
if (SceneView.lastActiveSceneView != null) SceneView.lastActiveSceneView.AlignViewToObject(camera.transform);
return "Opened HomeCamp additively; existing scenes preserved.";
}
public static string CloseReview()
{
var scene = SceneManager.GetSceneByPath(ScenePath);
if (!scene.IsValid() || !scene.isLoaded) return "HomeCamp not open.";
if (scene.isDirty) throw new InvalidOperationException("HomeCamp has unsaved edits; save or discard manually.");
EditorSceneManager.CloseScene(scene, true);
return "Closed HomeCamp review scene.";
}
}
#endif
+264
View File
@@ -0,0 +1,264 @@
#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
+1 -1
View File
@@ -6,7 +6,7 @@ using UnityEngine;
using UnityEngine.Rendering;
/// <summary>
/// 把 LmGirl / LmCat 摆进同一观察场景。正面为局部 -Z,相机在 -Z 侧。
/// 历史 LmGirl / LmCat 观察场景,保留旧 -Z 资产及相机方向;项目新资源采用 +Z 正面。
/// unity command run_script --file AgentScripts/BuildModelView.cs --entry BuildModelView.Run
/// </summary>
public static class BuildModelView
+315
View File
@@ -0,0 +1,315 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using AIGame.EditorTools;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>
/// Pipeline script to render high quality 3D portrait icons for formal companions (0..9).
/// Replaces the legacy primitive geometry portraits with actual authored companion models.
/// </summary>
public static class BuildPetPortraits
{
const string Pictures = "Assets/Bundles/UI/Texture/Prototype";
const string Screenshots = "Assets/Screenshots";
const string Animal = "Assets/Bundles/Characters/Animal/";
public static readonly Dictionary<int, string> FormalPets = new Dictionary<int, string>
{
[0] = Animal + "RockBallStudio/RockBallStudio_Companion.prefab",
[1] = Animal + "FireCatStudio/FireCatStudio_Companion.prefab",
[2] = Animal + "BudBudStudio/BudBudStudio_Companion.prefab",
[3] = Animal + "SparkBirdStudio/SparkBirdStudio_Companion.prefab",
[4] = Animal + "BubbleTurtle/BubbleTurtle_Companion.prefab",
[5] = Animal + "RockRhinoStudio/RockRhinoStudio_Companion.prefab",
[6] = Animal + "FlameLion/FlameLion_Companion.prefab",
[7] = Animal + "VineSpriteStudio/VineSpriteStudio_Companion.prefab",
[8] = Animal + "ThunderLeopardStudio/ThunderLeopardStudio_Companion.prefab",
[9] = Animal + "TideWhaleStudio/TideWhaleStudio_Companion.prefab",
};
public static readonly string[] PetNames =
{
"RockBall (岩团团)",
"FireCat (火喵)",
"BudBud (芽芽)",
"SparkBird (闪闪雀)",
"BubbleTurtle (泡泡龟)",
"RockRhino (岩甲犀)",
"FlameLion (烈焰狮)",
"VineSprite (藤蔓灵)",
"ThunderLeopard (雷鸣豹)",
"TideWhale (潮汐鲸)"
};
/// <summary>
/// Executes portrait re-rendering and generates a visual comparison sheet.
/// </summary>
public static string Run()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
throw new InvalidOperationException("Run in Edit Mode.");
// 1. Backup old portraits into memory for comparison
var oldPortraits = new Texture2D[10];
for (int i = 0; i < 10; i++)
{
string oldPath = $"{Pictures}/proto_pet_{i}.png";
byte[] bytes = File.Exists(oldPath) ? File.ReadAllBytes(oldPath) : null;
if (bytes != null)
{
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false);
tex.LoadImage(bytes);
oldPortraits[i] = tex;
}
}
// 2. Render each formal companion
var newPortraits = new Texture2D[10];
for (int i = 0; i < 10; i++)
{
newPortraits[i] = RenderPortrait(i);
}
// 3. Update sprite atlas
UiSpriteAtlasGenerator.GenerateAll();
// 4. Generate comparison sheet
GenerateComparisonSheet(oldPortraits, newPortraits);
for (int i = 0; i < 10; i++)
{
if (oldPortraits[i] != null) UnityEngine.Object.DestroyImmediate(oldPortraits[i]);
if (newPortraits[i] != null) UnityEngine.Object.DestroyImmediate(newPortraits[i]);
}
AssetDatabase.Refresh();
return "Pet portraits generated successfully.";
}
private static Texture2D RenderPortrait(int id)
{
string prefabPath = FormalPets[id];
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (prefab == null)
throw new FileNotFoundException($"Formal companion prefab not found: {prefabPath}");
var scene = EditorSceneManager.NewPreviewScene();
var previousRT = RenderTexture.active;
var rt = new RenderTexture(256, 256, 24, RenderTextureFormat.ARGB32);
Texture2D resultTex = new Texture2D(256, 256, TextureFormat.RGBA32, false);
try
{
var root = new GameObject("PortraitRoot");
SceneManager.MoveGameObjectToScene(root, scene);
var instance = UnityEngine.Object.Instantiate(prefab, root.transform);
instance.name = "Model";
instance.transform.localPosition = Vector3.zero;
instance.transform.localRotation = Quaternion.identity;
// Put everything on layer 30 for isolated rendering
foreach (var trans in root.GetComponentsInChildren<Transform>(true))
{
trans.gameObject.layer = 30;
}
// Calculate renderer bounds
var renderers = instance.GetComponentsInChildren<Renderer>(true);
Bounds bounds = new Bounds(instance.transform.position, Vector3.zero);
bool hasBounds = false;
foreach (var r in renderers)
{
if (r is ParticleSystemRenderer) continue;
if (!hasBounds)
{
bounds = r.bounds;
hasBounds = true;
}
else
{
bounds.Encapsulate(r.bounds);
}
}
if (!hasBounds)
bounds = new Bounds(instance.transform.position + Vector3.up * 0.3f, Vector3.one * 0.6f);
// Subtle rotation: companions face -Z by default, turn slightly to show 3/4 front profile
instance.transform.localRotation = Quaternion.Euler(0, 18f, 0);
// Recompute bounds after rotation
bounds = new Bounds(instance.transform.position, Vector3.zero);
hasBounds = false;
foreach (var r in instance.GetComponentsInChildren<Renderer>(true))
{
if (r is ParticleSystemRenderer) continue;
if (!hasBounds)
{
bounds = r.bounds;
hasBounds = true;
}
else
{
bounds.Encapsulate(r.bounds);
}
}
// Camera setup
var camGO = new GameObject("PortraitCamera");
camGO.transform.SetParent(root.transform, false);
var camera = camGO.AddComponent<Camera>();
camera.scene = scene;
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = Color.clear;
camera.cullingMask = 1 << 30;
camera.orthographic = true;
camera.allowHDR = false;
camera.targetTexture = rt;
// Target roughly upper body/head center of the pet
Vector3 target = bounds.center + Vector3.up * (bounds.size.y * 0.04f);
float maxExtent = Mathf.Max(bounds.size.x, bounds.size.y);
// Orthographic size covers the model with nice framing padding (~12%)
camera.orthographicSize = (maxExtent * 0.5f) * 1.15f;
// Camera placed along -Z looking slightly down (elevation ~10 degrees)
Vector3 camPos = target + new Vector3(0, maxExtent * 0.22f, -3.5f);
camera.transform.position = camPos;
camera.transform.LookAt(target);
// Lights
// 1. Key directional light (warm top-left)
var keyLightGO = new GameObject("KeyLight");
keyLightGO.transform.SetParent(root.transform, false);
var keyLight = keyLightGO.AddComponent<Light>();
keyLight.type = LightType.Directional;
keyLight.color = new Color(1f, 0.96f, 0.92f);
keyLight.intensity = 1.35f;
keyLight.cullingMask = 1 << 30;
keyLightGO.transform.rotation = Quaternion.Euler(28, -25, 0);
// 2. Rim/Back light (cool, highlighting contours)
var rimLightGO = new GameObject("RimLight");
rimLightGO.transform.SetParent(root.transform, false);
var rimLight = rimLightGO.AddComponent<Light>();
rimLight.type = LightType.Directional;
rimLight.color = new Color(0.85f, 0.92f, 1f);
rimLight.intensity = 0.85f;
rimLight.cullingMask = 1 << 30;
rimLightGO.transform.rotation = Quaternion.Euler(-20, 150, 0);
// Render
camera.Render();
RenderTexture.active = rt;
resultTex.ReadPixels(new Rect(0, 0, 256, 256), 0, 0);
resultTex.Apply();
// Save to disk
string destPath = $"{Pictures}/proto_pet_{id}.png";
File.WriteAllBytes(destPath, resultTex.EncodeToPNG());
}
finally
{
RenderTexture.active = previousRT;
EditorSceneManager.ClosePreviewScene(scene);
rt.Release();
UnityEngine.Object.DestroyImmediate(rt);
}
// Configure TextureImporter
string assetPath = $"{Pictures}/proto_pet_{id}.png";
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
var importer = (TextureImporter)AssetImporter.GetAtPath(assetPath);
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single;
importer.mipmapEnabled = false;
importer.alphaIsTransparency = true;
importer.textureCompression = TextureImporterCompression.Uncompressed;
importer.filterMode = FilterMode.Bilinear;
var settings = new TextureImporterSettings();
importer.ReadTextureSettings(settings);
settings.spriteMeshType = SpriteMeshType.FullRect;
importer.SetTextureSettings(settings);
importer.SaveAndReimport();
return resultTex;
}
private static void GenerateComparisonSheet(Texture2D[] oldPortraits, Texture2D[] newPortraits)
{
// 10 columns, 2 rows (Row 0: Old Prototype, Row 1: New 3D Formal)
int cellW = 160;
int cellH = 160;
int padding = 16;
int headerH = 40;
int totalW = cellW * 10 + padding * 11;
int totalH = cellH * 2 + padding * 3 + headerH;
var sheet = new Texture2D(totalW, totalH, TextureFormat.RGBA32, false);
// Fill subtle dark background
Color bg = new Color(0.12f, 0.14f, 0.18f, 1f);
Color[] fill = new Color[totalW * totalH];
for (int i = 0; i < fill.Length; i++) fill[i] = bg;
sheet.SetPixels(fill);
for (int col = 0; col < 10; col++)
{
int x = padding + col * (cellW + padding);
// Row 0: Old (bottom row in texture coordinates)
int yOld = padding;
DrawCell(sheet, oldPortraits[col], x, yOld, cellW, cellH, new Color(0.2f, 0.22f, 0.26f, 1f));
// Row 1: New (top row in texture coordinates)
int yNew = padding * 2 + cellH;
DrawCell(sheet, newPortraits[col], x, yNew, cellW, cellH, new Color(0.18f, 0.26f, 0.35f, 1f));
}
sheet.Apply();
if (!Directory.Exists(Screenshots)) Directory.CreateDirectory(Screenshots);
string comparisonPath = $"{Screenshots}/pet_portraits_upgrade_comparison.png";
File.WriteAllBytes(comparisonPath, sheet.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(sheet);
AssetDatabase.ImportAsset(comparisonPath, ImportAssetOptions.ForceUpdate);
}
private static void DrawCell(Texture2D dest, Texture2D src, int startX, int startY, int w, int h, Color frameBg)
{
// Draw frame background
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
dest.SetPixel(startX + x, startY + y, frameBg);
}
}
if (src == null) return;
// Bilinear blend source into destination
for (int y = 0; y < h; y++)
{
float v = (float)y / (h - 1);
for (int x = 0; x < w; x++)
{
float u = (float)x / (w - 1);
Color c = src.GetPixelBilinear(u, v);
if (c.a > 0.001f)
{
Color current = dest.GetPixel(startX + x, startY + y);
Color blended = Color.Lerp(current, c, c.a);
blended.a = 1f;
dest.SetPixel(startX + x, startY + y, blended);
}
}
}
}
}
#endif
+43 -9
View File
@@ -323,15 +323,49 @@ public static class BuildPrototype
try
{
var root = new GameObject("PortraitRoot"); SceneManager.MoveGameObjectToScene(root, scene);
Pet(id, root.transform, "PortraitPet");
var camera = Group("PortraitCamera", root.transform).gameObject.AddComponent<Camera>(); camera.scene = scene;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.clear; camera.cullingMask = 1 << 30;
bool primitiveBird = id == 3 && !FormalPets.ContainsKey(id);
camera.orthographic = true; camera.orthographicSize = primitiveBird ? 1.22f : id >= 9 ? 1.34f : 1.08f; camera.allowHDR = false;
camera.transform.position = new Vector3(1.6f, 1.55f, -4); camera.transform.LookAt(new Vector3(0, primitiveBird ? .94f : id >= 5 ? .86f : .82f, 0)); camera.targetTexture = rt;
var light = Group("PortraitLight", root.transform).gameObject.AddComponent<Light>(); light.type = LightType.Directional;
light.intensity = 1.15f; light.cullingMask = 1 << 30; light.transform.rotation = Quaternion.Euler(35, -35, 0);
camera.Render(); RenderTexture.active = rt;
bool formal = FormalPets.TryGetValue(id, out var prefabPath) && AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath) != null;
if (formal)
{
var instance = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath), root.transform);
instance.transform.localPosition = Vector3.zero;
instance.transform.localRotation = Quaternion.Euler(0, 18f, 0);
foreach (var trans in root.GetComponentsInChildren<Transform>(true)) trans.gameObject.layer = 30;
var renderers = instance.GetComponentsInChildren<Renderer>(true);
Bounds bounds = new Bounds(instance.transform.position, Vector3.zero);
bool hasBounds = false;
foreach (var r in renderers)
{
if (r is ParticleSystemRenderer) continue;
if (!hasBounds) { bounds = r.bounds; hasBounds = true; } else bounds.Encapsulate(r.bounds);
}
var camera = Group("PortraitCamera", root.transform).gameObject.AddComponent<Camera>(); camera.scene = scene;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.clear; camera.cullingMask = 1 << 30;
camera.orthographic = true; camera.allowHDR = false; camera.targetTexture = rt;
float maxDim = Mathf.Max(bounds.size.x, bounds.size.y);
camera.orthographicSize = (maxDim * 0.5f) * 1.15f;
Vector3 target = bounds.center + Vector3.up * (bounds.size.y * 0.04f);
camera.transform.position = target + new Vector3(0, maxDim * 0.22f, -3.5f);
camera.transform.LookAt(target);
var keyLight = Group("PortraitLight", root.transform).gameObject.AddComponent<Light>(); keyLight.type = LightType.Directional;
keyLight.color = new Color(1f, 0.96f, 0.92f); keyLight.intensity = 1.35f; keyLight.cullingMask = 1 << 30; keyLight.transform.rotation = Quaternion.Euler(28, -25, 0);
var rimLight = Group("PortraitRim", root.transform).gameObject.AddComponent<Light>(); rimLight.type = LightType.Directional;
rimLight.color = new Color(0.85f, 0.92f, 1f); rimLight.intensity = 0.85f; rimLight.cullingMask = 1 << 30; rimLight.transform.rotation = Quaternion.Euler(-20, 150, 0);
camera.Render();
}
else
{
Pet(id, root.transform, "PortraitPet");
var camera = Group("PortraitCamera", root.transform).gameObject.AddComponent<Camera>(); camera.scene = scene;
camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.clear; camera.cullingMask = 1 << 30;
bool primitiveBird = id == 3 && !FormalPets.ContainsKey(id);
camera.orthographic = true; camera.orthographicSize = primitiveBird ? 1.22f : id >= 9 ? 1.34f : 1.08f; camera.allowHDR = false;
camera.transform.position = new Vector3(1.6f, 1.55f, -4); camera.transform.LookAt(new Vector3(0, primitiveBird ? .94f : id >= 5 ? .86f : .82f, 0)); camera.targetTexture = rt;
var light = Group("PortraitLight", root.transform).gameObject.AddComponent<Light>(); light.type = LightType.Directional;
light.intensity = 1.15f; light.cullingMask = 1 << 30; light.transform.rotation = Quaternion.Euler(35, -35, 0);
camera.Render();
} RenderTexture.active = rt;
var texture = new Texture2D(256, 256, TextureFormat.RGBA32, false);
try { texture.ReadPixels(new Rect(0, 0, 256, 256), 0, 0); texture.Apply(); File.WriteAllBytes(Pictures + "/proto_pet_" + id + ".png", texture.EncodeToPNG()); }
finally { UnityEngine.Object.DestroyImmediate(texture); }
-398
View File
@@ -1,398 +0,0 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.SceneManagement;
using YooAsset.Editor;
/// <summary>
/// 把 TownSquare 8 类分件做成 Prefab,拼成可加载场景,并登记 Art 收集。
/// 由 Unity Pipeline: unity command run_script --file AgentScripts/BuildTownSquare.cs --entry BuildTownSquare.Run
/// </summary>
public static class BuildTownSquare
{
const string SceneRoot = "Assets/Bundles/Scenes/Town/TownSquare";
const string ScenePath = SceneRoot + "/TownSquare.unity";
const string ScenePrefabPath = SceneRoot + "/TownSquare.prefab";
const string XPbrShaderName = "Test/XPbr";
const string ArtPackage = "Art";
static readonly string[] Parts =
{
"ground", "shop", "tree", "lamp", "board", "fountain", "bench", "bush"
};
public static string Run()
{
AssetDatabase.Refresh();
Directory.CreateDirectory(SceneRoot.Replace("Assets/", Application.dataPath + "/../Assets/").Replace('/', Path.DirectorySeparatorChar));
var reports = new List<string>();
foreach (var name in Parts)
{
reports.Add(EnsurePartPrefab(name));
}
reports.Add(AssembleScenePrefab());
reports.Add(EnsurePlayableScene());
reports.Add(EnsureArtCollector());
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return string.Join(" | ", reports);
}
static string EnsurePartPrefab(string name)
{
var dir = $"{SceneRoot}/{name}";
var fbxPath = $"{dir}/{name}.fbx";
var matPath = $"{dir}/{name}.mat";
var basePath = $"{dir}/{name}_base.png";
var mixPath = $"{dir}/{name}_mix.png";
var prefabPath = $"{dir}/{name}.prefab";
if (!File.Exists(fbxPath))
return $"{name}: missing fbx";
ConfigureTextures(basePath, mixPath);
ConfigureFbx(fbxPath, File.Exists(matPath), name);
var xpbr = Shader.Find(XPbrShaderName);
var urpLit = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
var baseMap = AssetDatabase.LoadAssetAtPath<Texture2D>(basePath);
var mixMap = AssetDatabase.LoadAssetAtPath<Texture2D>(mixPath);
var useXpbr = xpbr != null && mixMap != null && baseMap != null;
var shader = useXpbr ? xpbr : urpLit;
var mat = AssetDatabase.LoadAssetAtPath<Material>(matPath);
if (mat == null)
{
mat = new Material(shader);
AssetDatabase.CreateAsset(mat, matPath);
}
else
{
mat.shader = shader;
}
if (useXpbr)
{
if (mat.HasProperty("_MainTex")) mat.SetTexture("_MainTex", baseMap);
if (mat.HasProperty("_MixTex")) mat.SetTexture("_MixTex", mixMap);
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", Color.white);
}
else if (baseMap != null && mat.HasProperty("_BaseMap"))
{
mat.SetTexture("_BaseMap", baseMap);
}
EditorUtility.SetDirty(mat);
var modelRoot = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
if (modelRoot == null)
return $"{name}: fbx not loaded";
var instance = (GameObject)PrefabUtility.InstantiatePrefab(modelRoot);
instance.name = name;
foreach (var renderer in instance.GetComponentsInChildren<Renderer>())
renderer.sharedMaterial = mat;
UprightAndSit(instance, name);
if (name == "ground")
EnsureGroundCollider(instance);
PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
Object.DestroyImmediate(instance);
return name == "ground" ? $"{name}.prefab+MeshCollider" : $"{name}.prefab";
}
static void ConfigureTextures(string basePath, string mixPath)
{
ConfigureOneTexture(basePath, srgb: true, alpha: true);
ConfigureOneTexture(mixPath, srgb: false, alpha: false);
}
static void ConfigureOneTexture(string path, bool srgb, bool alpha)
{
if (!File.Exists(path)) return;
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null) return;
importer.textureType = TextureImporterType.Default;
importer.sRGBTexture = srgb;
importer.mipmapEnabled = true;
importer.alphaIsTransparency = alpha;
importer.SaveAndReimport();
}
static void ConfigureFbx(string fbxPath, bool hasMat, string name)
{
var model = AssetImporter.GetAtPath(fbxPath) as ModelImporter;
if (model == null) return;
model.globalScale = 1f;
model.useFileScale = false;
model.bakeAxisConversion = true;
model.materialImportMode = hasMat
? ModelImporterMaterialImportMode.ImportViaMaterialDescription
: ModelImporterMaterialImportMode.None;
model.meshCompression = ModelImporterMeshCompression.Medium;
// MeshCollider 烹饪需要可读网格;其它分件保持不可读。
model.isReadable = name == "ground";
model.addCollider = false;
model.SaveAndReimport();
}
static void EnsureGroundCollider(GameObject instance)
{
var added = 0;
foreach (var filter in instance.GetComponentsInChildren<MeshFilter>())
{
if (filter.sharedMesh == null) continue;
var col = filter.GetComponent<MeshCollider>();
if (col == null)
col = filter.gameObject.AddComponent<MeshCollider>();
col.sharedMesh = filter.sharedMesh;
col.convex = false;
added++;
}
if (added == 0)
throw new System.InvalidOperationException("ground has no mesh for MeshCollider");
}
static void UprightAndSit(GameObject instance, string name)
{
instance.transform.localPosition = Vector3.zero;
instance.transform.localScale = Vector3.one;
instance.transform.localRotation = Quaternion.identity;
var bounds = MeshBounds(instance);
var target = TargetSize(name, bounds);
var current = CurrentSize(name, bounds);
if (target > 0.01f && current > 0.01f)
instance.transform.localScale = Vector3.one * (target / current);
// 游戏 FBX 原点已在底面中点,缩放后仍贴地;position 保持 0。
}
static float TargetSize(string name, Bounds bounds)
{
switch (name)
{
case "ground": return 18f;
case "shop": return 5.5f;
case "tree": return 5.5f;
case "lamp": return 3.6f;
case "board": return 2.4f;
case "fountain": return 3.2f;
case "bench": return 1.8f;
case "bush": return 0.9f;
default: return 1f;
}
}
static float CurrentSize(string name, Bounds bounds)
{
if (name == "ground" || name == "bench")
return Mathf.Max(bounds.size.x, bounds.size.z);
return bounds.size.y;
}
static Bounds MeshBounds(GameObject root)
{
Bounds bounds = default;
var has = false;
foreach (var r in root.GetComponentsInChildren<Renderer>())
{
if (!has)
{
bounds = r.bounds;
has = true;
}
else bounds.Encapsulate(r.bounds);
}
return has ? bounds : new Bounds(root.transform.position, Vector3.one * 0.01f);
}
static string AssembleScenePrefab()
{
var root = new GameObject("TownSquare");
var ground = Place(root.transform, "ground", Vector3.zero, 0f);
Physics.SyncTransforms();
// 总览 L 形;游戏网格正面是局部 -Z,用 YawFrontToward 对准广场中心。
// 水平位置按总览,Y 取地表 MeshCollider 碰撞高度。
var sits = new List<string>();
void Sit(string part, Vector3 xz, float yaw, string instanceName = null)
{
var y = SampleGroundY(ground, xz.x, xz.z);
sits.Add($"{instanceName ?? part}={y:F3}");
Place(root.transform, part, new Vector3(xz.x, y, xz.z), yaw, instanceName);
}
var shopLeft = new Vector3(-6.3f, 0f, 3.4f);
var shopBack = new Vector3(0.6f, 0f, 6.7f);
Sit("shop", shopLeft, YawFrontToward(shopLeft, Vector3.zero), "shop_left");
Sit("shop", shopBack, YawFrontToward(shopBack, Vector3.zero), "shop_back");
Sit("tree", new Vector3(-8.0f, 0f, 5.2f), 15f, "tree_left");
Sit("tree", new Vector3(7.5f, 0f, 6.2f), -15f, "tree_right");
var boardPos = new Vector3(5.0f, 0f, 6.5f);
Sit("board", boardPos, YawFrontToward(boardPos, Vector3.zero));
Sit("fountain", new Vector3(6.1f, 0f, 0.6f), 0f);
Sit("lamp", new Vector3(-6.4f, 0f, -2.8f), 0f, "lamp_front_left");
Sit("lamp", new Vector3(2.7f, 0f, 6.6f), 10f, "lamp_back");
Sit("lamp", new Vector3(8.1f, 0f, 1.3f), -15f, "lamp_right");
var benchLeft = new Vector3(-5.2f, 0f, -5.2f);
var benchRight = new Vector3(5.6f, 0f, -4.2f);
Sit("bench", benchLeft, YawFrontToward(benchLeft, Vector3.zero), "bench_left");
Sit("bench", benchRight, YawFrontToward(benchRight, Vector3.zero), "bench_right");
Sit("bush", new Vector3(-6.2f, 0f, -5.8f), 0f, "bush_bench_l1");
Sit("bush", new Vector3(-4.2f, 0f, -6.0f), 40f, "bush_bench_l2");
Sit("bush", new Vector3(6.6f, 0f, -4.8f), 10f, "bush_bench_r");
Sit("bush", new Vector3(7.4f, 0f, 2.0f), 25f, "bush_fountain_1");
Sit("bush", new Vector3(4.8f, 0f, 1.4f), -10f, "bush_fountain_2");
Sit("bush", new Vector3(7.0f, 0f, 0.2f), 20f, "bush_fountain_3");
Sit("bush", new Vector3(6.6f, 0f, 6.8f), 0f, "bush_board");
PrefabUtility.SaveAsPrefabAsset(root, ScenePrefabPath);
Object.DestroyImmediate(root);
return "TownSquare.prefab sit=[" + string.Join(",", sits) + "]";
}
static GameObject Place(Transform parent, string part, Vector3 pos, float yaw, string instanceName = null)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>($"{SceneRoot}/{part}/{part}.prefab");
if (prefab == null)
throw new System.InvalidOperationException($"missing part prefab {part}");
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab, parent);
go.name = instanceName ?? part;
go.transform.position = pos;
go.transform.rotation = Quaternion.Euler(0f, yaw, 0f) * go.transform.rotation;
return go;
}
static float SampleGroundY(GameObject ground, float x, float z)
{
if (ground == null)
return 0f;
Physics.SyncTransforms();
var ray = new Ray(new Vector3(x, 80f, z), Vector3.down);
var bestDist = float.MaxValue;
var y = 0f;
var hitAny = false;
foreach (var col in ground.GetComponentsInChildren<MeshCollider>())
{
if (col == null || !col.enabled) continue;
if (!col.Raycast(ray, out var hit, 160f)) continue;
if (hit.distance >= bestDist) continue;
bestDist = hit.distance;
y = hit.point.y;
hitAny = true;
}
if (!hitAny)
Debug.LogWarning($"[BuildTownSquare] ground miss at ({x:F2}, {z:F2}), y=0");
return y;
}
static float YawFrontToward(Vector3 from, Vector3 to)
{
// 导出网格正面是局部 -Z(不是 Unity 默认 +Z)。让 +Z 背对目标,则 -Z 朝向目标。
var away = from - to;
away.y = 0f;
if (away.sqrMagnitude < 0.0001f) return 0f;
return Quaternion.LookRotation(away).eulerAngles.y;
}
static string EnsurePlayableScene()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var lightGo = new GameObject("Directional Light");
var light = lightGo.AddComponent<Light>();
light.type = LightType.Directional;
light.color = new Color(1f, 0.96f, 0.88f);
light.intensity = 1.15f;
light.shadows = LightShadows.Soft;
light.shadowStrength = 1f;
lightGo.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
var camGo = new GameObject("Main Camera");
camGo.tag = "MainCamera";
var cam = camGo.AddComponent<Camera>();
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.78f, 0.88f, 0.94f);
cam.fieldOfView = 40f;
cam.nearClipPlane = 0.1f;
cam.farClipPlane = 80f;
camGo.AddComponent<AudioListener>();
camGo.transform.position = new Vector3(0f, 12f, -22f);
camGo.transform.rotation = Quaternion.Euler(28f, 0f, 0f);
var plaza = AssetDatabase.LoadAssetAtPath<GameObject>(ScenePrefabPath);
PrefabUtility.InstantiatePrefab(plaza);
RenderSettings.ambientMode = AmbientMode.Trilight;
RenderSettings.ambientSkyColor = new Color(0.32f, 0.40f, 0.50f);
RenderSettings.ambientEquatorColor = new Color(0.24f, 0.26f, 0.24f);
RenderSettings.ambientGroundColor = new Color(0.16f, 0.15f, 0.13f);
EditorSceneManager.SaveScene(scene, ScenePath);
return ScenePath;
}
static string EnsureArtCollector()
{
var setting = BundleCollectorSettingData.Setting;
setting.ShowPackageView = true;
var package = setting.Packages.Find(p => p.PackageName == ArtPackage);
if (package == null)
package = BundleCollectorSettingData.CreatePackage(ArtPackage);
package.PackageDesc = "角色/场景美术";
package.EnableAddressable = true;
package.SupportExtensionless = true;
package.AutoCollectShaders = true;
package.IgnoreRuleName = nameof(NormalIgnoreRule);
BundleCollectorSettingData.ModifyPackage(package);
var group = package.Groups.Find(g => g.GroupName == "Scenes");
if (group == null)
group = BundleCollectorSettingData.CreateGroup(package, "Scenes");
group.ActiveRuleName = nameof(EnableGroup);
group.AssetTags = "art";
BundleCollectorSettingData.ModifyGroup(package, group);
const string broadPath = "Assets/Bundles/Scenes";
const string collectPath = "Assets/Bundles/Scenes/Town/TownSquare/TownSquare.prefab";
var broad = group.Collectors.Find(c => c.CollectPath == broadPath);
if (broad != null)
BundleCollectorSettingData.RemoveCollector(group, broad);
var collector = group.Collectors.Find(c => c.CollectPath == collectPath);
if (collector == null)
{
collector = new BundleCollector
{
CollectPath = collectPath,
CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath),
CollectorType = ECollectorType.MainAssetCollector,
AddressRuleName = nameof(AddressByFileName),
PackRuleName = nameof(PackSeparately),
FilterRuleName = nameof(CollectPrefab),
AssetTags = "art",
};
BundleCollectorSettingData.CreateCollector(group, collector);
}
else
{
collector.CollectorGUID = AssetDatabase.AssetPathToGUID(collectPath);
collector.AddressRuleName = nameof(AddressByFileName);
collector.PackRuleName = nameof(PackSeparately);
collector.FilterRuleName = nameof(CollectPrefab);
BundleCollectorSettingData.ModifyCollector(group, collector);
}
package.CheckConfigError();
BundleCollectorSettingData.SaveFile();
return $"art collect={collectPath}";
}
}
#endif
@@ -0,0 +1,168 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.Animations;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class BuildXBotController
{
const string Common = "Assets/Bundles/Characters/Human/Common";
const string Model = Common + "/XBot.fbx";
const string Output = Common + "/XBot.controller";
static readonly string[] States = { "Idle", "Walk", "Attack01", "Attack02" };
static readonly string[] Files = { "X Bot@BreathingIdle.fbx", "X Bot@Walking.fbx", "X Bot@atk01.fbx", "X Bot@Atk02.fbx" };
static Avatar AvatarAt(string path)
{
var avatar = AssetDatabase.LoadAllAssetsAtPath(path).OfType<Avatar>().SingleOrDefault();
if (!avatar || !avatar.isValid || !avatar.isHuman) throw new Exception("Invalid Humanoid Avatar: " + path);
return avatar;
}
static Dictionary<string, string> Bones(GameObject model)
{
return model.GetComponentsInChildren<Transform>(true).Where(t => t.name.StartsWith("mixamorig:"))
.ToDictionary(t => t.name, t => t.parent && t.parent.name.StartsWith("mixamorig:") ? t.parent.name : "");
}
static void Transition(AnimatorState from, AnimatorState to, string parameter, AnimatorConditionMode mode, float value, bool exit = false)
{
var t = from.AddTransition(to);
t.hasExitTime = exit; t.exitTime = .95f; t.hasFixedDuration = true; t.duration = .15f;
t.AddCondition(mode, value, parameter);
}
public static string Build()
{
if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Edit Mode required.");
if (File.Exists(Output)) throw new Exception("Controller already exists; inspect it before replacing: " + Output);
foreach (string f in Files) if (!File.Exists(Common + "/AnimX/" + f)) throw new FileNotFoundException(f);
AssetDatabase.ImportAsset(Model, ImportAssetOptions.ForceSynchronousImport);
var modelImporter = (ModelImporter)AssetImporter.GetAtPath(Model);
modelImporter.animationType = ModelImporterAnimationType.Human;
modelImporter.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
modelImporter.SaveAndReimport();
var avatar = AvatarAt(Model);
var bones = Bones(AssetDatabase.LoadAssetAtPath<GameObject>(Model));
if (bones.Count != 65) throw new Exception("Expected XBot's 65 bones, got " + bones.Count);
var clips = new AnimationClip[Files.Length];
for (int i = 0; i < Files.Length; i++)
{
string path = Common + "/AnimX/" + Files[i];
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport);
var source = AssetDatabase.LoadAssetAtPath<GameObject>(path);
var sourceBones = Bones(source);
if (sourceBones.Count != 65 || sourceBones.Any(b => !bones.ContainsKey(b.Key) || bones[b.Key] != b.Value))
throw new Exception("Source skeleton differs from XBot: " + path);
if (source.GetComponentsInChildren<Renderer>(true).Length != 0) throw new Exception("Expected animation without mesh: " + path);
var importer = (ModelImporter)AssetImporter.GetAtPath(path);
importer.animationType = ModelImporterAnimationType.Human;
importer.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;
importer.sourceAvatar = avatar;
importer.importAnimation = true;
importer.importCameras = false; importer.importLights = false;
var settings = importer.clipAnimations;
if (settings.Length == 0) settings = importer.defaultClipAnimations;
if (settings.Length != 1) throw new Exception("Expected one clip: " + path);
settings[0].name = States[i];
settings[0].loopTime = i < 2; settings[0].loopPose = i < 2;
settings[0].lockRootRotation = true; settings[0].lockRootHeightY = true; settings[0].lockRootPositionXZ = true;
settings[0].keepOriginalOrientation = true; settings[0].keepOriginalPositionY = true; settings[0].keepOriginalPositionXZ = true;
importer.clipAnimations = settings; importer.SaveAndReimport();
clips[i] = AssetDatabase.LoadAllAssetsAtPath(path).OfType<AnimationClip>().Single(c => !c.name.StartsWith("__preview__"));
if (!clips[i].humanMotion || clips[i].length <= 0) throw new Exception("Invalid Humanoid clip: " + path);
}
var controller = AnimatorController.CreateAnimatorControllerAtPath(Output);
controller.AddParameter("Motion", AnimatorControllerParameterType.Int);
controller.AddParameter("Attack01", AnimatorControllerParameterType.Trigger);
controller.AddParameter("Attack02", AnimatorControllerParameterType.Trigger);
var sm = controller.layers[0].stateMachine;
sm.entryPosition = new Vector3(0, 0); sm.anyStatePosition = new Vector3(0, 220);
var states = new AnimatorState[4];
for (int i = 0; i < states.Length; i++)
{
states[i] = sm.AddState(States[i], new Vector3(260 + (i % 2) * 280, (i / 2) * 220));
states[i].motion = clips[i]; states[i].iKOnFeet = i < 2; states[i].writeDefaultValues = false;
}
sm.defaultState = states[0];
Transition(states[0], states[1], "Motion", AnimatorConditionMode.Equals, 1);
Transition(states[1], states[0], "Motion", AnimatorConditionMode.NotEqual, 1);
for (int i = 2; i < 4; i++)
{
var attack = sm.AddAnyStateTransition(states[i]);
attack.hasExitTime = false; attack.hasFixedDuration = true; attack.duration = .1f; attack.canTransitionToSelf = false;
attack.AddCondition(AnimatorConditionMode.If, 0, States[i]);
Transition(states[i], states[1], "Motion", AnimatorConditionMode.Equals, 1, true);
Transition(states[i], states[0], "Motion", AnimatorConditionMode.NotEqual, 1, true);
}
EditorUtility.SetDirty(controller); AssetDatabase.SaveAssetIfDirty(controller);
return Verify();
}
static void Advance(Animator animator, float duration)
{
int count = Mathf.CeilToInt(duration * 60);
for (int i = 0; i < count; i++) animator.Update(1f / 60);
}
static void Expect(Animator animator, string state)
{
if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer." + state) || animator.IsInTransition(0))
throw new Exception("Expected settled state " + state);
}
public static string Verify()
{
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(Output);
if (!controller || controller.layers.Length != 1) throw new Exception("Missing controller/layer");
var sm = controller.layers[0].stateMachine;
if (sm.states.Length != 4 || sm.defaultState.name != "Idle") throw new Exception("Wrong states/default");
var scene = EditorSceneManager.NewPreviewScene();
var samples = new List<object>(); var checks = new List<string>();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Model), scene);
var animator = go.GetComponent<Animator>();
animator.avatar = AvatarAt(Model); animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false; animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
animator.Rebind(); animator.Update(0);
foreach (var state in sm.states.Select(s => s.state))
{
var clip = (AnimationClip)state.motion;
Vector3 first = Vector3.zero; float movement = 0;
for (int i = 0; i < 25; i++)
{
animator.Play("Base Layer." + state.name, 0, i / 25f); animator.Update(0);
var hip = animator.GetBoneTransform(HumanBodyBones.Hips);
var hand = animator.GetBoneTransform(HumanBodyBones.LeftHand).position - hip.position;
var foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot).position - hip.position;
if (i == 0) first = hand + foot;
movement = Mathf.Max(movement, Vector3.Distance(first, hand + foot));
if (go.GetComponentsInChildren<Transform>().Any(t => float.IsNaN(t.position.sqrMagnitude) || float.IsInfinity(t.position.sqrMagnitude)))
throw new Exception("Invalid bone pose in " + state.name);
}
if (movement < .00001f) throw new Exception("No sampled movement in " + state.name);
samples.Add(new { state = state.name, source = AssetDatabase.GetAssetPath(clip), clip.length, clip.frameRate, clip.humanMotion, clip.isLooping, samples = 25, sampledMovement = movement });
}
animator.SetInteger("Motion", 0); animator.Play("Base Layer.Idle", 0, 0); animator.Update(0);
animator.SetInteger("Motion", 1); Advance(animator, .4f); Expect(animator, "Walk"); checks.Add("Idle -> Walk");
animator.SetInteger("Motion", 0); Advance(animator, .4f); Expect(animator, "Idle"); checks.Add("Walk -> Idle");
foreach (int motion in new[] { 0, 1 }) foreach (string attack in States.Skip(2))
{
animator.SetInteger("Motion", motion); animator.Play("Base Layer." + (motion == 1 ? "Walk" : "Idle"), 0, 0); animator.Update(0);
animator.SetTrigger(attack); Advance(animator, .2f); Expect(animator, attack);
float length = ((AnimationClip)sm.states.Single(s => s.state.name == attack).state.motion).length;
Advance(animator, length + .4f); Expect(animator, motion == 1 ? "Walk" : "Idle");
checks.Add((motion == 1 ? "Walk" : "Idle") + " -> " + attack + " -> " + (motion == 1 ? "Walk" : "Idle"));
}
var report = new { controller = Output, model = Model, bones = Bones(go).Count, avatarValid = animator.avatar.isValid, avatarHuman = animator.avatar.isHuman,
defaultState = "Idle", parameters = controller.parameters.Select(p => new { p.name, type = p.type.ToString() }), states = samples, transitions = checks,
rootMotion = "Baked rotation/Y/XZ; consumer Animator.applyRootMotion=false", verification = "Isolated Editor Animator; no active scene edits" };
string json = JsonConvert.SerializeObject(report, Formatting.Indented);
string folder = Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName, "Tools/output/common_bot_controller");
Directory.CreateDirectory(folder); File.WriteAllText(Path.Combine(folder, "validation.json"), json);
return json;
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
}
#endif
@@ -0,0 +1,168 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.Animations;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class BuildYBotController
{
const string Common = "Assets/Bundles/Characters/Human/Common";
const string Model = Common + "/YBot.fbx";
const string Output = Common + "/YBot.controller";
static readonly string[] States = { "Idle", "Walk", "Attack01", "Attack02" };
static readonly string[] Files = { "Y Bot@BreathingIdle.fbx", "Y Bot@Walking.fbx", "Y Bot@Atk01.fbx", "Y Bot@atk02.fbx" };
static Avatar AvatarAt(string path)
{
var avatar = AssetDatabase.LoadAllAssetsAtPath(path).OfType<Avatar>().SingleOrDefault();
if (!avatar || !avatar.isValid || !avatar.isHuman) throw new Exception("Invalid Humanoid Avatar: " + path);
return avatar;
}
static Dictionary<string, string> Bones(GameObject model)
{
return model.GetComponentsInChildren<Transform>(true).Where(t => t.name.StartsWith("mixamorig:"))
.ToDictionary(t => t.name, t => t.parent && t.parent.name.StartsWith("mixamorig:") ? t.parent.name : "");
}
static void Transition(AnimatorState from, AnimatorState to, string parameter, AnimatorConditionMode mode, float value, bool exit = false)
{
var t = from.AddTransition(to);
t.hasExitTime = exit; t.exitTime = .95f; t.hasFixedDuration = true; t.duration = .15f;
t.AddCondition(mode, value, parameter);
}
public static string Build()
{
if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Edit Mode required.");
if (File.Exists(Output)) throw new Exception("Controller already exists; inspect it before replacing: " + Output);
foreach (string f in Files) if (!File.Exists(Common + "/AnimY/" + f)) throw new FileNotFoundException(f);
AssetDatabase.ImportAsset(Model, ImportAssetOptions.ForceSynchronousImport);
var modelImporter = (ModelImporter)AssetImporter.GetAtPath(Model);
modelImporter.animationType = ModelImporterAnimationType.Human;
modelImporter.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
modelImporter.SaveAndReimport();
var avatar = AvatarAt(Model);
var bones = Bones(AssetDatabase.LoadAssetAtPath<GameObject>(Model));
if (bones.Count != 65) throw new Exception("Expected YBot's 65 bones, got " + bones.Count);
var clips = new AnimationClip[Files.Length];
for (int i = 0; i < Files.Length; i++)
{
string path = Common + "/AnimY/" + Files[i];
AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport);
var source = AssetDatabase.LoadAssetAtPath<GameObject>(path);
var sourceBones = Bones(source);
if (sourceBones.Count != 65 || sourceBones.Any(b => !bones.ContainsKey(b.Key) || bones[b.Key] != b.Value))
throw new Exception("Source skeleton differs from YBot: " + path);
if (source.GetComponentsInChildren<Renderer>(true).Length != 0) throw new Exception("Expected animation without mesh: " + path);
var importer = (ModelImporter)AssetImporter.GetAtPath(path);
importer.animationType = ModelImporterAnimationType.Human;
importer.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;
importer.sourceAvatar = avatar;
importer.importAnimation = true;
importer.importCameras = false; importer.importLights = false;
var settings = importer.clipAnimations;
if (settings.Length == 0) settings = importer.defaultClipAnimations;
if (settings.Length != 1) throw new Exception("Expected one clip: " + path);
settings[0].name = States[i];
settings[0].loopTime = i < 2; settings[0].loopPose = i < 2;
settings[0].lockRootRotation = true; settings[0].lockRootHeightY = true; settings[0].lockRootPositionXZ = true;
settings[0].keepOriginalOrientation = true; settings[0].keepOriginalPositionY = true; settings[0].keepOriginalPositionXZ = true;
importer.clipAnimations = settings; importer.SaveAndReimport();
clips[i] = AssetDatabase.LoadAllAssetsAtPath(path).OfType<AnimationClip>().Single(c => !c.name.StartsWith("__preview__"));
if (!clips[i].humanMotion || clips[i].length <= 0) throw new Exception("Invalid Humanoid clip: " + path);
}
var controller = AnimatorController.CreateAnimatorControllerAtPath(Output);
controller.AddParameter("Motion", AnimatorControllerParameterType.Int);
controller.AddParameter("Attack01", AnimatorControllerParameterType.Trigger);
controller.AddParameter("Attack02", AnimatorControllerParameterType.Trigger);
var sm = controller.layers[0].stateMachine;
sm.entryPosition = new Vector3(0, 0); sm.anyStatePosition = new Vector3(0, 220);
var states = new AnimatorState[4];
for (int i = 0; i < states.Length; i++)
{
states[i] = sm.AddState(States[i], new Vector3(260 + (i % 2) * 280, (i / 2) * 220));
states[i].motion = clips[i]; states[i].iKOnFeet = i < 2; states[i].writeDefaultValues = false;
}
sm.defaultState = states[0];
Transition(states[0], states[1], "Motion", AnimatorConditionMode.Equals, 1);
Transition(states[1], states[0], "Motion", AnimatorConditionMode.NotEqual, 1);
for (int i = 2; i < 4; i++)
{
var attack = sm.AddAnyStateTransition(states[i]);
attack.hasExitTime = false; attack.hasFixedDuration = true; attack.duration = .1f; attack.canTransitionToSelf = false;
attack.AddCondition(AnimatorConditionMode.If, 0, States[i]);
Transition(states[i], states[1], "Motion", AnimatorConditionMode.Equals, 1, true);
Transition(states[i], states[0], "Motion", AnimatorConditionMode.NotEqual, 1, true);
}
EditorUtility.SetDirty(controller); AssetDatabase.SaveAssetIfDirty(controller);
return Verify();
}
static void Advance(Animator animator, float duration)
{
int count = Mathf.CeilToInt(duration * 60);
for (int i = 0; i < count; i++) animator.Update(1f / 60);
}
static void Expect(Animator animator, string state)
{
if (!animator.GetCurrentAnimatorStateInfo(0).IsName("Base Layer." + state) || animator.IsInTransition(0))
throw new Exception("Expected settled state " + state);
}
public static string Verify()
{
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(Output);
if (!controller || controller.layers.Length != 1) throw new Exception("Missing controller/layer");
var sm = controller.layers[0].stateMachine;
if (sm.states.Length != 4 || sm.defaultState.name != "Idle") throw new Exception("Wrong states/default");
var scene = EditorSceneManager.NewPreviewScene();
var samples = new List<object>(); var checks = new List<string>();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Model), scene);
var animator = go.GetComponent<Animator>();
animator.avatar = AvatarAt(Model); animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false; animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
animator.Rebind(); animator.Update(0);
foreach (var state in sm.states.Select(s => s.state))
{
var clip = (AnimationClip)state.motion;
Vector3 first = Vector3.zero; float movement = 0;
for (int i = 0; i < 25; i++)
{
animator.Play("Base Layer." + state.name, 0, i / 25f); animator.Update(0);
var hip = animator.GetBoneTransform(HumanBodyBones.Hips);
var hand = animator.GetBoneTransform(HumanBodyBones.LeftHand).position - hip.position;
var foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot).position - hip.position;
if (i == 0) first = hand + foot;
movement = Mathf.Max(movement, Vector3.Distance(first, hand + foot));
if (go.GetComponentsInChildren<Transform>().Any(t => float.IsNaN(t.position.sqrMagnitude) || float.IsInfinity(t.position.sqrMagnitude)))
throw new Exception("Invalid bone pose in " + state.name);
}
if (movement < .00001f) throw new Exception("No sampled movement in " + state.name);
samples.Add(new { state = state.name, source = AssetDatabase.GetAssetPath(clip), clip.length, clip.frameRate, clip.humanMotion, clip.isLooping, samples = 25, sampledMovement = movement });
}
animator.SetInteger("Motion", 0); animator.Play("Base Layer.Idle", 0, 0); animator.Update(0);
animator.SetInteger("Motion", 1); Advance(animator, .4f); Expect(animator, "Walk"); checks.Add("Idle -> Walk");
animator.SetInteger("Motion", 0); Advance(animator, .4f); Expect(animator, "Idle"); checks.Add("Walk -> Idle");
foreach (int motion in new[] { 0, 1 }) foreach (string attack in States.Skip(2))
{
animator.SetInteger("Motion", motion); animator.Play("Base Layer." + (motion == 1 ? "Walk" : "Idle"), 0, 0); animator.Update(0);
animator.SetTrigger(attack); Advance(animator, .2f); Expect(animator, attack);
float length = ((AnimationClip)sm.states.Single(s => s.state.name == attack).state.motion).length;
Advance(animator, length + .4f); Expect(animator, motion == 1 ? "Walk" : "Idle");
checks.Add((motion == 1 ? "Walk" : "Idle") + " -> " + attack + " -> " + (motion == 1 ? "Walk" : "Idle"));
}
var report = new { controller = Output, model = Model, bones = Bones(go).Count, avatarValid = animator.avatar.isValid, avatarHuman = animator.avatar.isHuman,
defaultState = "Idle", parameters = controller.parameters.Select(p => new { p.name, type = p.type.ToString() }), states = samples, transitions = checks,
rootMotion = "Baked rotation/Y/XZ; consumer Animator.applyRootMotion=false", verification = "Isolated Editor Animator; no active scene edits" };
string json = JsonConvert.SerializeObject(report, Formatting.Indented);
string folder = Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName, "Tools/output/common_ybot_controller");
Directory.CreateDirectory(folder); File.WriteAllText(Path.Combine(folder, "validation.json"), json);
return json;
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
}
#endif
@@ -0,0 +1,100 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.Animations;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class DiagnoseBotWalkTranslation
{
static float[] V(Vector3 v) => new[] { v.x, v.y, v.z };
public static string Run()
{
const string common = "Assets/Bundles/Characters/Human/Common";
var reports = new List<object>();
foreach (string name in new[] { "XBot", "YBot", "XBot25", "XBot41" })
{
var scene = EditorSceneManager.NewPreviewScene();
try
{
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(common + "/Prefabs/" + name + ".prefab"), scene);
var a = go.GetComponent<Animator>(); a.applyRootMotion = false; a.cullingMode = AnimatorCullingMode.AlwaysAnimate;
a.Rebind(); a.SetInteger("Motion",1); a.Update(0);
var clip = (AnimationClip)((AnimatorController)a.runtimeAnimatorController).layers[0].stateMachine.states.Single(s=>s.state.name=="Walk").state.motion;
var importer = (ModelImporter)AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(clip));
var settings = importer.clipAnimations.Single();
var points = new List<Vector3>(); var roots = new List<Vector3>(); var samples = new List<object>();
for (int i=0;i<=60;i++)
{
float phase = i/60f;
a.Play("Base Layer.Walk",0,Mathf.Min(.99999f,phase)); a.Update(0);
Vector3 hip = a.GetBoneTransform(HumanBodyBones.Hips).position-go.transform.position;
points.Add(hip); roots.Add(go.transform.position);
if (i%15==0) samples.Add(new { phase, hipsFromRoot=V(hip), root=V(go.transform.position) });
}
var min = points.Aggregate(Vector3.positiveInfinity,Vector3.Min); var max = points.Aggregate(Vector3.negativeInfinity,Vector3.Max);
var rootMin=roots.Aggregate(Vector3.positiveInfinity,Vector3.Min);var rootMax=roots.Aggregate(Vector3.negativeInfinity,Vector3.Max);
reports.Add(new { name, clip=AssetDatabase.GetAssetPath(clip), clip.length, applyRootMotion=a.applyRootMotion,
settings.lockRootPositionXZ, settings.keepOriginalPositionXZ, settings.loopTime,
hipRangeWorldMeters=V(max-min), hipNetTravelWorldMeters=V(points.Last()-points.First()), rootRangeWorldMeters=V(rootMax-rootMin), samples });
}
finally { EditorSceneManager.ClosePreviewScene(scene); }
}
string json=JsonConvert.SerializeObject(reports,Formatting.Indented);
string folder=Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/bot_animation_lab");
Directory.CreateDirectory(folder);File.WriteAllText(Path.Combine(folder,"walk_translation_diagnostic.json"),json);
return json;
}
public static string CompareExtraction()
{
const string common="Assets/Bundles/Characters/Human/Common";
const string folder="Assets/Review/BotAnimationLab/TranslationProbe";
if (AssetDatabase.IsValidFolder(folder)) throw new Exception("Probe folder already exists");
AssetDatabase.CreateFolder("Assets/Review/BotAnimationLab","TranslationProbe");
var reports=new List<object>();
try
{
foreach(string name in new[]{"XBot","YBot"})
{
string source=common+"/Anim"+(name=="XBot"?"X":"Y")+"/"+(name=="XBot"?"X":"Y")+" Bot@Walking.fbx";
string path=folder+"/"+name+"_Probe.fbx";
if(!AssetDatabase.CopyAsset(source,path))throw new Exception("Copy failed");
var importer=(ModelImporter)AssetImporter.GetAtPath(path);
var settings=importer.clipAnimations;
settings[0].lockRootPositionXZ=false;
settings[0].keepOriginalPositionXZ=false;
importer.clipAnimations=settings;importer.SaveAndReimport();
var clip=AssetDatabase.LoadAllAssetsAtPath(path).OfType<AnimationClip>().Single(c=>!c.name.StartsWith("__preview__"));
var scene=EditorSceneManager.NewPreviewScene();
AnimatorOverrideController controller=null;
try
{
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(common+"/Prefabs/"+name+".prefab"),scene);
var a=go.GetComponent<Animator>();
controller=new AnimatorOverrideController(a.runtimeAnimatorController);
var original=controller.animationClips.Single(c=>c.name=="Walk");controller[original]=clip;
a.runtimeAnimatorController=controller;a.applyRootMotion=false;a.Rebind();a.SetInteger("Motion",1);a.Update(0);
var points=new List<Vector3>();var roots=new List<Vector3>();
for(int i=0;i<=60;i++)
{
a.Play("Base Layer.Walk",0,Mathf.Min(.99999f,i/60f));a.Update(0);
points.Add(a.GetBoneTransform(HumanBodyBones.Hips).position-go.transform.position);roots.Add(go.transform.position);
}
var range=points.Aggregate(Vector3.negativeInfinity,Vector3.Max)-points.Aggregate(Vector3.positiveInfinity,Vector3.Min);
var rootRange=roots.Aggregate(Vector3.negativeInfinity,Vector3.Max)-roots.Aggregate(Vector3.positiveInfinity,Vector3.Min);
reports.Add(new{name,lockRootPositionXZ=false,keepOriginalPositionXZ=false,hipRangeWorldMeters=V(range),rootRangeWorldMeters=V(rootRange)});
}
finally { EditorSceneManager.ClosePreviewScene(scene);if(controller)UnityEngine.Object.DestroyImmediate(controller); }
}
}
finally { AssetDatabase.DeleteAsset(folder); }
string json=JsonConvert.SerializeObject(reports,Formatting.Indented);
string output=Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/bot_animation_lab/walk_translation_extraction_comparison.json");
File.WriteAllText(output,json);return json;
}
}
#endif
@@ -0,0 +1,64 @@
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>White-albedo XTerrain capture to prove lightmap shade is reaching the ground shader.</summary>
public static class DiagnoseGroundWhiteGi
{
const string Root = "Assets/Bundles/Scenes/Town/HomeCamp";
const string ScenePath = Root + "/HomeCamp.unity";
const string Review = "D:/UD/AI/AIGame/Tools/output/homecamp_review";
public static string Run()
{
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/XTerrain.shader", ImportAssetOptions.ForceSynchronousImport);
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var root = scene.GetRootGameObjects().Single(g => g.name == "HomeCamp");
var ground = root.transform.Find("Ground")?.GetComponentInChildren<MeshRenderer>();
if (!ground) return "FAIL";
var prev = ground.sharedMaterial;
var mat = Object.Instantiate(prev);
var white = Texture2D.whiteTexture;
mat.SetTexture("_LayerTex0", white); mat.SetTexture("_LayerTex1", white);
mat.SetTexture("_LayerTex2", white); mat.SetTexture("_LayerTex3", white);
mat.SetFloat("_LightmapContrast", 1f);
mat.SetFloat("_BumpScale", 0f); mat.SetFloat("_Roughness", 1.5f); mat.SetFloat("_Metallic", 0f);
ground.sharedMaterial = mat;
ShaderUtil.allowAsyncCompilation = false;
var cam = root.GetComponentInChildren<Camera>();
Capture(cam, Review + "/diag_xterrain_white_c1.png");
mat.SetFloat("_LightmapContrast", 2.2f);
Capture(cam, Review + "/diag_xterrain_white_c22.png");
ground.sharedMaterial = prev;
Object.DestroyImmediate(mat);
return "wrote white-gi captures contrastProp=" + prev.GetFloat("_LightmapContrast");
}
static void Capture(Camera camera, string file)
{
var rt = RenderTexture.GetTemporary(720, 1280, 24, RenderTextureFormat.ARGB32);
var old = RenderTexture.active; var oldT = camera.targetTexture; var oldA = camera.aspect;
var tex = new Texture2D(720, 1280, TextureFormat.RGB24, false);
try
{
camera.aspect = 720f / 1280; camera.targetTexture = rt; RenderTexture.active = rt;
camera.Render(); camera.Render();
tex.ReadPixels(new Rect(0, 0, 720, 1280), 0, 0); tex.Apply();
File.WriteAllBytes(file, tex.EncodeToPNG());
}
finally
{
camera.targetTexture = oldT; camera.aspect = oldA; RenderTexture.active = old;
RenderTexture.ReleaseTemporary(rt); Object.DestroyImmediate(tex);
}
}
}
#endif
@@ -0,0 +1,93 @@
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>Temporarily force Ground to visualize bakedGI only, to verify lightmap sampling.</summary>
public static class DiagnoseHomeCampGiViz
{
const string ScenePath = "Assets/Bundles/Scenes/Town/HomeCamp/HomeCamp.unity";
const string Review = "D:/UD/AI/AIGame/Tools/output/homecamp_review";
const string ShaderPath = "Assets/Bundles/Shaders/XShader/XTerrain.shader";
public static string Run()
{
Directory.CreateDirectory(Review);
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var root = scene.GetRootGameObjects().Single(g => g.name == "HomeCamp");
var ground = root.transform.Find("Ground")?.GetComponentInChildren<MeshRenderer>();
if (!ground) return "FAIL: no ground";
var mesh = ground.GetComponent<MeshFilter>()?.sharedMesh;
var uv1 = mesh != null && mesh.uv2 != null && mesh.uv2.Length == mesh.vertexCount;
var uv0 = mesh != null ? mesh.uv.Length : 0;
// Inject a debug return of bakedGI into XTerrain fragment temporarily via material replacement:
// use Unlit with lightmap — simpler: create temp material using URP/Lit which we know samples LM.
var lit = new Material(Shader.Find("Universal Render Pipeline/Lit"));
lit.SetColor("_BaseColor", Color.white);
lit.SetFloat("_Smoothness", 0f);
var prev = ground.sharedMaterial;
ground.sharedMaterial = lit;
var cam = root.GetComponentInChildren<Camera>();
Capture(cam, Review + "/diag_ground_urp_lit.png");
// Also capture original
ground.sharedMaterial = prev;
Capture(cam, Review + "/diag_ground_xterrain.png");
// Sample lightmap at ground renderer scaleOffset a few UV points
var lm = LightmapSettings.lightmaps[0].lightmapColor;
var so = ground.lightmapScaleOffset;
string Samples(string label, float u, float v)
{
// Unity UV: v=0 bottom. Texture2D GetPixel v from bottom if we use GetPixelBilinear with Unity convention...
float uu = u * so.x + so.z;
float vv = v * so.y + so.w;
var rt = RenderTexture.GetTemporary(lm.width, lm.height, 0, RenderTextureFormat.ARGBFloat);
Graphics.Blit(lm, rt);
var prevRt = RenderTexture.active; RenderTexture.active = rt;
var tex = new Texture2D(lm.width, lm.height, TextureFormat.RGBAFloat, false, true);
tex.ReadPixels(new Rect(0, 0, lm.width, lm.height), 0, 0); tex.Apply();
RenderTexture.active = prevRt; RenderTexture.ReleaseTemporary(rt);
// Texture2D: (0,0) bottom-left in GetPixelBilinear? Actually GetPixelBilinear y=0 is bottom.
var c = tex.GetPixelBilinear(uu, vv);
Object.DestroyImmediate(tex);
float l = c.r * .3f + c.g * .59f + c.b * .11f;
return label + "=(" + uu.ToString("F2") + "," + vv.ToString("F2") + ")->" + l.ToString("F3");
}
Object.DestroyImmediate(lit);
return "uv1=" + uv1 + " uv0Count=" + uv0 + " verts=" + (mesh ? mesh.vertexCount : 0)
+ " idx=" + ground.lightmapIndex + " so=" + so
+ " " + Samples("center", .5f, .5f)
+ " " + Samples("near", .5f, .2f)
+ " " + Samples("far", .5f, .8f)
+ " " + Samples("left", .2f, .5f)
+ " " + Samples("right", .8f, .5f);
}
static void Capture(Camera camera, string file)
{
var rt = RenderTexture.GetTemporary(720, 1280, 24, RenderTextureFormat.ARGB32);
var old = RenderTexture.active; var oldT = camera.targetTexture; var oldA = camera.aspect;
var tex = new Texture2D(720, 1280, TextureFormat.RGB24, false);
try
{
camera.aspect = 720f / 1280f; camera.targetTexture = rt; RenderTexture.active = rt;
camera.Render();
tex.ReadPixels(new Rect(0, 0, 720, 1280), 0, 0); tex.Apply();
File.WriteAllBytes(file, tex.EncodeToPNG());
}
finally
{
camera.targetTexture = oldT; camera.aspect = oldA; RenderTexture.active = old;
RenderTexture.ReleaseTemporary(rt); Object.DestroyImmediate(tex);
}
}
}
#endif
@@ -0,0 +1,120 @@
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
using UnityEditor.SceneManagement;
/// <summary>Dump HomeCamp lightmap stats and force a lightmap-only capture for shadow diagnosis.</summary>
public static class DiagnoseHomeCampLightmaps
{
const string ScenePath = "Assets/Bundles/Scenes/Town/HomeCamp/HomeCamp.unity";
const string Review = "D:/UD/AI/AIGame/Tools/output/homecamp_review";
public static string Run()
{
Directory.CreateDirectory(Review);
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var root = scene.GetRootGameObjects().Single(g => g.name == "HomeCamp");
var ground = root.transform.Find("Ground")?.GetComponentInChildren<MeshRenderer>();
var maps = LightmapSettings.lightmaps;
if (maps == null || maps.Length == 0) return "FAIL: no lightmaps loaded";
if (!ground) return "FAIL: no ground";
var lm = maps[0].lightmapColor;
var path = Review + "/lightmap0.png";
ExportReadable(lm, path);
// Sample a few texels for contrast (sunlit vs likely shade).
var readable = MakeReadable(lm);
float minL = 99, maxL = 0, sum = 0;
int n = 0;
for (int y = 0; y < readable.height; y += 8)
for (int x = 0; x < readable.width; x += 8)
{
var c = readable.GetPixel(x, y);
float l = c.r * .3f + c.g * .59f + c.b * .11f;
if (l < minL) minL = l; if (l > maxL) maxL = l; sum += l; n++;
}
Object.DestroyImmediate(readable);
var lights = Object.FindObjectsByType<Light>(FindObjectsSortMode.None)
.Select(l => l.name + " type=" + l.type + " bake=" + l.lightmapBakeType + " inten=" + l.intensity + " en=" + l.enabled)
.ToArray();
var mat = ground.sharedMaterial;
var kw = string.Join(",", mat.shaderKeywords ?? System.Array.Empty<string>());
var sun = Object.FindObjectsByType<Light>(FindObjectsSortMode.None).FirstOrDefault(l => l.type == LightType.Directional && l.name.Contains("Sun"));
// Capture A: as-is
var cam = root.GetComponentInChildren<Camera>();
Capture(cam, Review + "/diag_with_lights.png");
// Capture B: disable all realtime-contributing lights so only lightmaps/probes remain
var prev = Object.FindObjectsByType<Light>(FindObjectsSortMode.None).Select(l => (l, l.enabled)).ToArray();
foreach (var (l, _) in prev) l.enabled = false;
Capture(cam, Review + "/diag_lightmap_only.png");
foreach (var (l, en) in prev) l.enabled = en;
return "maps=" + maps.Length + " groundLM=" + ground.lightmapIndex
+ " scale=" + ground.lightmapScaleOffset
+ " lmSize=" + lm.width + "x" + lm.height
+ " lumaMin=" + minL.ToString("F3") + " max=" + maxL.ToString("F3") + " avg=" + (sum / n).ToString("F3")
+ " exported=" + path
+ " keywords=[" + kw + "]"
+ " lights=[" + string.Join("; ", lights) + "]"
+ " sunBake=" + (sun ? sun.lightmapBakeType.ToString() : "null");
}
static void Capture(Camera camera, string file)
{
var rt = RenderTexture.GetTemporary(720, 1280, 24, RenderTextureFormat.ARGB32);
var old = RenderTexture.active; var oldTarget = camera.targetTexture; var oldAspect = camera.aspect;
var tex = new Texture2D(720, 1280, TextureFormat.RGB24, false);
try
{
camera.aspect = 720f / 1280; camera.targetTexture = rt; RenderTexture.active = rt;
camera.Render();
tex.ReadPixels(new Rect(0, 0, 720, 1280), 0, 0); tex.Apply();
File.WriteAllBytes(file, tex.EncodeToPNG());
}
finally
{
camera.targetTexture = oldTarget; camera.aspect = oldAspect; RenderTexture.active = old;
RenderTexture.ReleaseTemporary(rt); Object.DestroyImmediate(tex);
}
}
static Texture2D MakeReadable(Texture2D src)
{
var rt = RenderTexture.GetTemporary(src.width, src.height, 0, RenderTextureFormat.ARGBFloat);
Graphics.Blit(src, rt);
var prev = RenderTexture.active; RenderTexture.active = rt;
var tex = new Texture2D(src.width, src.height, TextureFormat.RGBAFloat, false);
tex.ReadPixels(new Rect(0, 0, src.width, src.height), 0, 0); tex.Apply();
RenderTexture.active = prev; RenderTexture.ReleaseTemporary(rt);
return tex;
}
static void ExportReadable(Texture2D src, string path)
{
var tex = MakeReadable(src);
// Tone-map HDR lightmap into LDR preview
var outTex = new Texture2D(tex.width, tex.height, TextureFormat.RGB24, false);
for (int y = 0; y < tex.height; y++)
for (int x = 0; x < tex.width; x++)
{
var c = tex.GetPixel(x, y);
// Unity lightmaps are often dLDR / RGBM-ish; simple expose
float e = 1.5f;
outTex.SetPixel(x, y, new Color(Mathf.Clamp01(c.r * e), Mathf.Clamp01(c.g * e), Mathf.Clamp01(c.b * e), 1));
}
outTex.Apply();
File.WriteAllBytes(path, outTex.EncodeToPNG());
Object.DestroyImmediate(tex); Object.DestroyImmediate(outTex);
}
}
#endif
@@ -0,0 +1,146 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditor.SceneManagement;
using UnityEditor.Animations;
using UnityEngine.Animations;
using UnityEngine.Playables;
using System.Collections.Generic;
public static class DiagnoseMixamoPlayback
{
static readonly string[] Paths={"Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx","Assets/Bundles/Characters/Human/Common/Walking.fbx","Assets/Bundles/Characters/Human/Common/CampGuideStudio@Walking.fbx"};
static float[] V(Vector3 v)=>new[]{v.x,v.y,v.z};
static string PathOf(UnityEngine.Object o)=>o?AssetDatabase.GetAssetPath(o):null;
const string DiagnosticRoot="Assets/Review/CharacterAnimationLab/PlaybackDiagnostics";
static string OutputDirectory=>System.IO.Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/campguide_studio/playback_audit");
static void Folder(string path){if(AssetDatabase.IsValidFolder(path))return;var parent=System.IO.Path.GetDirectoryName(path).Replace('\\','/');Folder(parent);AssetDatabase.CreateFolder(parent,System.IO.Path.GetFileName(path));}
public static string PrepareVariants()
{
Folder(DiagnosticRoot);
var avatar=AssetDatabase.LoadAllAssetsAtPath(Paths[0]).OfType<Avatar>().Single();
foreach(var tag in new[]{"CopyBoyAvatar","Generic"})
{
string path=DiagnosticRoot+"/Walking_"+tag+".fbx";
if(!AssetDatabase.LoadMainAssetAtPath(path) && !AssetDatabase.CopyAsset(Paths[1],path))throw new Exception("Copy failed "+path);
var importer=(ModelImporter)AssetImporter.GetAtPath(path);
importer.animationType=tag=="Generic"?ModelImporterAnimationType.Generic:ModelImporterAnimationType.Human;
importer.avatarSetup=tag=="Generic"?ModelImporterAvatarSetup.NoAvatar:ModelImporterAvatarSetup.CopyFromOther;
importer.sourceAvatar=tag=="Generic"?null:avatar;
if(tag=="Generic")importer.animationCompression=ModelImporterAnimationCompression.Off;
importer.SaveAndReimport();
}
return "Prepared isolated generic and CopyBoyAvatar variants";
}
public static string FixWalking()
{
var importer=(ModelImporter)AssetImporter.GetAtPath(Paths[1]);
var before=new{path=Paths[1],animationType=importer.animationType.ToString(),avatarSetup=importer.avatarSetup.ToString(),sourceAvatar=PathOf(importer.sourceAvatar)};
Directory.CreateDirectory(OutputDirectory);
var backup=System.IO.Path.Combine(OutputDirectory,"walking_importer_before.json");
if(!File.Exists(backup))File.WriteAllText(backup,JsonConvert.SerializeObject(before,Formatting.Indented));
importer.animationType=ModelImporterAnimationType.Human;
importer.avatarSetup=ModelImporterAvatarSetup.CopyFromOther;
importer.sourceAvatar=AssetDatabase.LoadAllAssetsAtPath(Paths[0]).OfType<Avatar>().Single();
importer.SaveAndReimport();
var clip=AssetDatabase.LoadAllAssetsAtPath(Paths[1]).OfType<AnimationClip>().First(c=>!c.name.StartsWith("__preview__"));
if(!clip.humanMotion || !importer.sourceAvatar.isValid)throw new Exception("Invalid repaired Humanoid clip/avatar");
var report=new{path=Paths[1],avatarSetup=importer.avatarSetup.ToString(),sourceAvatar=PathOf(importer.sourceAvatar),clip=clip.name,clip.humanMotion,
scenes=Enumerable.Range(0,SceneManager.sceneCount).Select(i=>{var s=SceneManager.GetSceneAt(i);return new{s.path,s.isDirty};}).ToArray()};
string json=JsonConvert.SerializeObject(report,Formatting.Indented);File.WriteAllText(System.IO.Path.Combine(OutputDirectory,"fix.json"),json);return json;
}
public static string VerifyFix()
{
var importer=(ModelImporter)AssetImporter.GetAtPath(Paths[1]);
var avatar=AssetDatabase.LoadAllAssetsAtPath(Paths[0]).OfType<Avatar>().Single();
if(importer.avatarSetup!=ModelImporterAvatarSetup.CopyFromOther || importer.sourceAvatar!=avatar)throw new Exception("Wrong source avatar");
var controller=AssetDatabase.LoadAssetAtPath<AnimatorController>("Assets/Bundles/Characters/Human/Common/Man.controller");
var state=controller.layers[0].stateMachine.states.Single(s=>s.state.name=="walk").state;
if(PathOf(state.motion)!=Paths[1] || !(state.motion as AnimationClip).humanMotion)throw new Exception("Controller walk clip lost");
var animators=UnityEngine.Object.FindObjectsOfType<Animator>(true).Where(a=>a.runtimeAnimatorController==controller).ToArray();
var warnings=animators.Select(a=>new SerializedObject(a).FindProperty("m_WarningMessage")?.stringValue).Where(w=>!string.IsNullOrEmpty(w)).ToArray();
if(warnings.Length>0)throw new Exception(string.Join("\n",warnings));
var json=JsonConvert.SerializeObject(new{sourceAvatar=PathOf(avatar),controller=PathOf(controller),walk=PathOf(state.motion),footIK=state.iKOnFeet,
actorCount=animators.Length,bindingWarnings=warnings.Length,EditorApplication.isPlaying,
dirtyScenes=Enumerable.Range(0,SceneManager.sceneCount).Select(i=>{var s=SceneManager.GetSceneAt(i);return new{s.path,s.isDirty};}).ToArray()},Formatting.Indented);
File.WriteAllText(System.IO.Path.Combine(OutputDirectory,"validation.json"),json);return json;
}
class Sampled
{
public string tag;public GameObject unit;public Animator animator;public PlayableGraph graph;public AnimationClipPlayable playable;
public Transform[] bones;public List<Dictionary<string,float[]>> frames=new List<Dictionary<string,float[]>>();
}
public static string Compare()
{
var scene=EditorSceneManager.NewPreviewScene();var entries=new List<Sampled>();
var source=AssetDatabase.LoadAssetAtPath<GameObject>(Paths[0]);
var material=new Material(Shader.Find("Universal Render Pipeline/Lit"));material.color=new Color(.6f,.65f,.72f);
try
{
string[] tags={"Generic","CurrentHumanoid","CopyBoyAvatar"};
string[] paths={DiagnosticRoot+"/Walking_Generic.fbx",Paths[1],DiagnosticRoot+"/Walking_CopyBoyAvatar.fbx"};
for(int i=0;i<tags.Length;i++)
{
var unit=(GameObject)PrefabUtility.InstantiatePrefab(source,scene);unit.transform.position=new Vector3((i-1)*1.25f,0,0);
var animator=unit.GetComponent<Animator>();animator.runtimeAnimatorController=null;animator.applyRootMotion=false;animator.cullingMode=AnimatorCullingMode.AlwaysAnimate;
if(tags[i]=="Generic")animator.avatar=null;
var clip=AssetDatabase.LoadAllAssetsAtPath(paths[i]).OfType<AnimationClip>().First(c=>!c.name.StartsWith("__preview__"));
var graph=PlayableGraph.Create("MixamoDiagnostic"+tags[i]);graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
var playable=AnimationClipPlayable.Create(graph,clip);playable.SetApplyFootIK(false);playable.SetApplyPlayableIK(false);
var output=AnimationPlayableOutput.Create(graph,"Animation",animator);output.SetSourcePlayable(playable);graph.Play();
foreach(var r in unit.GetComponentsInChildren<Renderer>())r.sharedMaterial=material;
entries.Add(new Sampled{tag=tags[i],unit=unit,animator=animator,graph=graph,playable=playable,bones=unit.GetComponentsInChildren<Transform>().Where(t=>t.name.StartsWith("mixamorig:")).ToArray()});
}
for(int frame=0;frame<32;frame++)foreach(var e in entries)
{
e.playable.SetTime(frame/30.0);e.graph.Evaluate(0);
e.frames.Add(e.bones.ToDictionary(b=>b.name,b=>V(e.unit.transform.InverseTransformPoint(b.position))));
}
var reference=entries[0];var results=new List<object>();
foreach(var e in entries.Skip(1))
{
var errors=new Dictionary<string,float>();
for(int frame=0;frame<32;frame++)foreach(string bone in e.frames[frame].Keys)
{
Vector3 P(float[] p)=>new Vector3(p[0],p[1],p[2]);
var actual=P(e.frames[frame][bone])-P(e.frames[frame]["mixamorig:Hips"]);
var expected=P(reference.frames[frame][bone])-P(reference.frames[frame]["mixamorig:Hips"]);
float error=Vector3.Distance(actual,expected);if(!errors.ContainsKey(bone)||errors[bone]<error)errors[bone]=error;
}
results.Add(new{e.tag,maxJointErrorM=errors.Values.Max(),jointErrors=errors.OrderByDescending(p=>p.Value).ToArray()});
}
foreach(var e in entries){e.playable.SetTime(10/30.0);e.graph.Evaluate(0);var hips=e.bones.First(b=>b.name=="mixamorig:Hips");var p=hips.position;p.x=e.unit.transform.position.x;p.z=0;hips.position=p;}
var light=new GameObject("DiagnosticLight").AddComponent<Light>();SceneManager.MoveGameObjectToScene(light.gameObject,scene);light.type=LightType.Directional;light.intensity=1.4f;light.transform.rotation=Quaternion.Euler(40,140,0);
var camera=new GameObject("DiagnosticCamera").AddComponent<Camera>();SceneManager.MoveGameObjectToScene(camera.gameObject,scene);camera.scene=scene;camera.orthographic=true;camera.orthographicSize=.78f;
camera.transform.position=new Vector3(0,.62f,5);camera.transform.LookAt(new Vector3(0,.62f,0));camera.clearFlags=CameraClearFlags.SolidColor;camera.backgroundColor=new Color(.2f,.23f,.27f);camera.nearClipPlane=.01f;camera.farClipPlane=20;
var rt=new RenderTexture(1440,640,24);var tex=new Texture2D(1440,640,TextureFormat.RGB24,false);var previous=RenderTexture.active;
Directory.CreateDirectory(OutputDirectory);
try{camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;tex.ReadPixels(new Rect(0,0,1440,640),0,0);tex.Apply();File.WriteAllBytes(System.IO.Path.Combine(OutputDirectory,"comparison.png"),tex.EncodeToPNG());}
finally{RenderTexture.active=previous;camera.targetTexture=null;rt.Release();UnityEngine.Object.DestroyImmediate(rt);UnityEngine.Object.DestroyImmediate(tex);}
var json=JsonConvert.SerializeObject(new{order=tags,comparison=results,samples=entries.Select(e=>new{e.tag,e.frames})},Formatting.Indented);File.WriteAllText(System.IO.Path.Combine(OutputDirectory,"comparison.json"),json);
return JsonConvert.SerializeObject(results);
}
finally{foreach(var e in entries)if(e.graph.IsValid())e.graph.Destroy();EditorSceneManager.ClosePreviewScene(scene);UnityEngine.Object.DestroyImmediate(material);}
}
public static string Inspect()
{
var result=new{EditorApplication.isPlaying,scenes=Enumerable.Range(0,SceneManager.sceneCount).Select(i=>{var s=SceneManager.GetSceneAt(i);return new{s.path,s.isDirty};}).ToArray(),
assets=Paths.Select(path=>{
var importer=(ModelImporter)AssetImporter.GetAtPath(path);var hd=importer.humanDescription;var model=AssetDatabase.LoadAssetAtPath<GameObject>(path);
return new{path,importer.animationType,importer.avatarSetup,sourceAvatar=PathOf(importer.sourceAvatar),importer.globalScale,importer.useFileScale,importer.bakeAxisConversion,
human=new{hd.armStretch,hd.legStretch,hd.upperArmTwist,hd.lowerArmTwist,hd.upperLegTwist,hd.lowerLegTwist,hd.feetSpacing,hd.hasTranslationDoF,
mapping=hd.human.Select(b=>new{b.humanName,b.boneName}).ToArray(),skeleton=hd.skeleton.Select(b=>new{b.name,position=V(b.position),rotation=V(b.rotation.eulerAngles),scale=V(b.scale)}).ToArray()},
bones=model.GetComponentsInChildren<Transform>(true).Where(t=>t.name.StartsWith("mixamorig:")).Select(t=>new{t.name,parent=t.parent?t.parent.name:null,world=V(model.transform.InverseTransformPoint(t.position)),local=V(t.localPosition),rotation=V(t.localEulerAngles)}).ToArray(),
clips=importer.clipAnimations.Select(c=>new{c.name,c.firstFrame,c.lastFrame,c.loopTime,c.loopPose,c.lockRootRotation,c.keepOriginalOrientation,c.rotationOffset,c.lockRootPositionXZ,c.keepOriginalPositionXZ,c.lockRootHeightY,c.heightFromFeet}).ToArray(),
animatorClips=AssetDatabase.LoadAllAssetsAtPath(path).OfType<AnimationClip>().Select(c=>new{c.name,c.humanMotion,c.length,settings=AnimationUtility.GetAnimationClipSettings(c)}).ToArray()};
}).ToArray()};
string json=JsonConvert.SerializeObject(result,Formatting.Indented);
string dir=System.IO.Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/campguide_studio/playback_audit");Directory.CreateDirectory(dir);File.WriteAllText(System.IO.Path.Combine(dir,"importers.json"),json);return "Saved "+dir+"/importers.json";
}
}
#endif
@@ -0,0 +1,178 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEditor.Animations;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Animations;
using UnityEngine.Playables;
public static class DiagnoseSharedHumanoidWalk
{
const string Boy="Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx";
const string Girl="Assets/Bundles/Characters/Human/test/girl/LmGirlTpose.fbx";
const string Walk="Assets/Bundles/Characters/Human/Common/Walking.fbx";
static string Output=>Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/lmgirl_tpose_studio/shared_walk_audit");
static float[] V(Vector3 v)=>new[]{v.x,v.y,v.z};
static string P(UnityEngine.Object o)=>o?AssetDatabase.GetAssetPath(o):null;
static readonly HumanBodyBones[] BoneIds={HumanBodyBones.Hips,HumanBodyBones.LeftUpperLeg,HumanBodyBones.RightUpperLeg,HumanBodyBones.LeftLowerLeg,HumanBodyBones.RightLowerLeg,HumanBodyBones.LeftFoot,HumanBodyBones.RightFoot,HumanBodyBones.LeftToes,HumanBodyBones.RightToes};
static object[] Pose(Animator a)=>BoneIds.Select(id=>{var t=a.GetBoneTransform(id);return (object)new{bone=id.ToString(),name=t?t.name:null,position=t?V(a.transform.InverseTransformPoint(t.position)):null,rotation=t?V(t.localEulerAngles):null};}).ToArray();
public static string Inspect()
{
var scene=EditorSceneManager.NewPreviewScene();
try
{
var assets=new List<object>();
foreach(var path in new[]{Boy,Girl,Walk})
{
var importer=(ModelImporter)AssetImporter.GetAtPath(path);var hd=importer.humanDescription;
var avatar=AssetDatabase.LoadAllAssetsAtPath(path).OfType<Avatar>().FirstOrDefault();
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(path),scene);
var animator=go.GetComponent<Animator>();if(animator)animator.runtimeAnimatorController=null;
var ahd=avatar?avatar.humanDescription:new HumanDescription();
assets.Add(new{path,importer.animationType,importer.avatarSetup,sourceAvatar=P(importer.sourceAvatar),avatar=avatar?avatar.name:null,valid=avatar&&avatar.isValid,
importerHuman=hd.human.Select(b=>new{b.humanName,b.boneName}).ToArray(),importerSkeleton=hd.skeleton.Select(b=>new{b.name,p=V(b.position),r=V(b.rotation.eulerAngles),s=V(b.scale)}).ToArray(),
avatarHuman=ahd.human==null?null:ahd.human.Select(b=>new{b.humanName,b.boneName}).ToArray(),avatarSkeleton=ahd.skeleton==null?null:ahd.skeleton.Select(b=>new{b.name,p=V(b.position),r=V(b.rotation.eulerAngles),s=V(b.scale)}).ToArray(),
pose=animator&&animator.isHuman?Pose(animator):null});
UnityEngine.Object.DestroyImmediate(go);
}
Directory.CreateDirectory(Output);var json=JsonConvert.SerializeObject(assets,Formatting.Indented);File.WriteAllText(Path.Combine(Output,"avatars.json"),json);return "Saved "+Output+"/avatars.json";
}
finally{EditorSceneManager.ClosePreviewScene(scene);}
}
public static string Compare()
{
var scene=EditorSceneManager.NewPreviewScene();var results=new List<object>();
try
{
foreach(var path in new[]{Boy,Girl})foreach(bool footIK in new[]{false,true})
{
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(path),scene);
var a=go.GetComponent<Animator>();a.runtimeAnimatorController=null;a.applyRootMotion=false;a.cullingMode=AnimatorCullingMode.AlwaysAnimate;
var clip=AssetDatabase.LoadAllAssetsAtPath(Walk).OfType<AnimationClip>().First(c=>!c.name.StartsWith("__preview__"));
var graph=PlayableGraph.Create("SharedWalkAudit");graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
try
{
var playable=AnimationClipPlayable.Create(graph,clip);playable.SetApplyFootIK(footIK);playable.SetApplyPlayableIK(false);
var output=AnimationPlayableOutput.Create(graph,"Animation",a);output.SetSourcePlayable(playable);graph.Play();
var frames=new List<object>();float minGap=float.MaxValue,maxGap=float.MinValue;int crossed=0;
for(int i=0;i<125;i++)
{
playable.SetTime(i/120.0);graph.Evaluate(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++;
frames.Add(new{t=i/120.0,gap,pose=Pose(a)});
}
results.Add(new{path,footIK,minGap,maxGap,crossed,frames});
}
finally{graph.Destroy();UnityEngine.Object.DestroyImmediate(go);}
}
Directory.CreateDirectory(Output);var json=JsonConvert.SerializeObject(results,Formatting.Indented);File.WriteAllText(Path.Combine(Output,"baseline.json"),json);
return "Saved "+Output+"/baseline.json";
}
finally{EditorSceneManager.ClosePreviewScene(scene);}
}
public static string VerifyController()
{
const string controllerPath="Assets/Bundles/Characters/Human/Common/Man.controller";
var controller=AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
var scene=EditorSceneManager.NewPreviewScene();var results=new List<object>();
var units=new List<GameObject>();var controllers=new List<AnimatorController>();
var material=new Material(Shader.Find("Universal Render Pipeline/Lit"));material.color=new Color(.65f,.7f,.76f);
try
{
int column=0;
foreach(var path in new[]{Boy,Girl})foreach(bool footIK in new[]{false,true})
{
// A transient controller contains only the actual walk state and clip.
var c=new AnimatorController();controllers.Add(c);c.AddLayer("Base Layer");
var state=c.layers[0].stateMachine.AddState("walk");state.motion=controller.layers[0].stateMachine.states.Single(s=>s.state.name=="walk").state.motion;state.iKOnFeet=footIK;c.layers[0].stateMachine.defaultState=state;
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(path),scene);units.Add(go);
go.transform.position=new Vector3((1.5f-column++)*.85f,0,0);
var a=go.GetComponent<Animator>();a.runtimeAnimatorController=c;a.applyRootMotion=false;a.cullingMode=AnimatorCullingMode.AlwaysAnimate;
foreach(var r in go.GetComponentsInChildren<Renderer>())r.sharedMaterial=material;
a.Rebind();a.Update(0);
float minGap=float.MaxValue,maxGap=float.MinValue;int crossed=0,minFrame=0;
for(int i=0;i<125;i++)
{
a.Play("Base Layer.walk",0,(i/120f)/state.motion.averageDuration);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;if(gap<minGap){minGap=gap;minFrame=i;}maxGap=Mathf.Max(maxGap,gap);if(gap<0)crossed++;
}
results.Add(new{path,footIK,minGap,maxGap,crossed,minTime=minFrame/120f});
// Show all variants at the same phase, near the observed crossing.
a.Play("Base Layer.walk",0,(32/120f)/state.motion.averageDuration);a.Update(0);
var hips=a.GetBoneTransform(HumanBodyBones.Hips);var hp=hips.position;hp.x=go.transform.position.x;hp.z=0;hips.position=hp;
}
var light=new GameObject("AuditLight").AddComponent<Light>();SceneManager.MoveGameObjectToScene(light.gameObject,scene);light.type=LightType.Directional;light.intensity=1.5f;light.transform.rotation=Quaternion.Euler(35,135,0);
var camera=new GameObject("AuditCamera").AddComponent<Camera>();SceneManager.MoveGameObjectToScene(camera.gameObject,scene);camera.scene=scene;camera.orthographic=true;camera.orthographicSize=.8f;
camera.transform.position=new Vector3(0,.65f,5);camera.transform.LookAt(new Vector3(0,.65f,0));camera.clearFlags=CameraClearFlags.SolidColor;camera.backgroundColor=new Color(.19f,.22f,.26f);camera.nearClipPlane=.01f;camera.farClipPlane=20;
var rt=new RenderTexture(1600,720,24);var tex=new Texture2D(1600,720,TextureFormat.RGB24,false);var previous=RenderTexture.active;
Directory.CreateDirectory(Output);
try{camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;tex.ReadPixels(new Rect(0,0,1600,720),0,0);tex.Apply();File.WriteAllBytes(Path.Combine(Output,"foot_ik_comparison.png"),tex.EncodeToPNG());}
finally{RenderTexture.active=previous;camera.targetTexture=null;rt.Release();UnityEngine.Object.DestroyImmediate(rt);UnityEngine.Object.DestroyImmediate(tex);}
var json=JsonConvert.SerializeObject(new{order=new[]{"Boy FootIK off","Boy FootIK on","Girl FootIK off","Girl FootIK on"},controllerPath,controllerDirty=EditorUtility.IsDirty(controller),stateDirty=EditorUtility.IsDirty(controller.layers[0].stateMachine.states.Single(s=>s.state.name=="walk").state),results},Formatting.Indented);
File.WriteAllText(Path.Combine(Output,"controller_verification.json"),json);return json;
}
finally
{
foreach(var go in units)UnityEngine.Object.DestroyImmediate(go);
foreach(var c in controllers){foreach(var l in c.layers){foreach(var s in l.stateMachine.states)UnityEngine.Object.DestroyImmediate(s.state);UnityEngine.Object.DestroyImmediate(l.stateMachine);}UnityEngine.Object.DestroyImmediate(c);}
EditorSceneManager.ClosePreviewScene(scene);UnityEngine.Object.DestroyImmediate(material);
}
}
public static string FixFootIK()
{
const string controllerPath="Assets/Bundles/Characters/Human/Common/Man.controller";
var controller=AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
var state=controller.layers[0].stateMachine.states.Single(s=>s.state.name=="walk").state;
if(EditorUtility.IsDirty(controller)||EditorUtility.IsDirty(state))throw new Exception("Controller has unsaved edits; refusing to save unrelated edits.");
var scenesBefore=Enumerable.Range(0,SceneManager.sceneCount).Select(i=>{var s=SceneManager.GetSceneAt(i);return new{s.path,s.isDirty};}).ToArray();
bool previous=state.iKOnFeet;
state.iKOnFeet=true;EditorUtility.SetDirty(state);AssetDatabase.SaveAssetIfDirty(state);
var importer=(ModelImporter)AssetImporter.GetAtPath(Walk);
if(!state.iKOnFeet || P(state.motion)!=Walk || P(importer.sourceAvatar)!=Boy)throw new Exception("Unexpected controller/source setup");
var json=JsonConvert.SerializeObject(new{controllerPath,state=state.name,previousFootIK=previous,footIK=state.iKOnFeet,walk=P(state.motion),sourceAvatar=P(importer.sourceAvatar),
controllerDirty=EditorUtility.IsDirty(controller),stateDirty=EditorUtility.IsDirty(state),
actors=UnityEngine.Object.FindObjectsOfType<Animator>(true).Where(a=>a.runtimeAnimatorController==controller).Select(a=>new{a.name,avatar=P(a.avatar),a.applyRootMotion}).ToArray(),
scenesBefore,scenesAfter=Enumerable.Range(0,SceneManager.sceneCount).Select(i=>{var s=SceneManager.GetSceneAt(i);return new{s.path,s.isDirty};}).ToArray()},Formatting.Indented);
File.WriteAllText(Path.Combine(Output,"fix.json"),json);return json;
}
public static string VerifyApplied()
{
var controller=AssetDatabase.LoadAssetAtPath<AnimatorController>("Assets/Bundles/Characters/Human/Common/Man.controller");
var state=controller.layers[0].stateMachine.states.Single(s=>s.state.name=="walk").state;
if(!state.iKOnFeet)throw new Exception("Foot IK disabled");
var scene=EditorSceneManager.NewPreviewScene();var results=new List<object>();
try
{
foreach(var path in new[]{Boy,Girl})
{
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(path),scene);
try
{
var a=go.GetComponent<Animator>();a.runtimeAnimatorController=controller;a.applyRootMotion=false;a.cullingMode=AnimatorCullingMode.AlwaysAnimate;a.Rebind();a.Update(0);
float minGap=float.MaxValue,maxGap=float.MinValue;int crossed=0;
for(int i=0;i<125;i++)
{
a.Play("Base Layer.walk",0,(i/120f)/state.motion.averageDuration);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++;
}
if(crossed>0)throw new Exception("Crossing after fix: "+path);
results.Add(new{path,avatar=P(a.avatar),samples=125,minGap,maxGap,crossed});
}
finally{UnityEngine.Object.DestroyImmediate(go);}
}
var json=JsonConvert.SerializeObject(new{controller=P(controller),state=state.name,footIK=state.iKOnFeet,clip=P(state.motion),results},Formatting.Indented);
File.WriteAllText(Path.Combine(Output,"validation.json"),json);return json;
}
finally{EditorSceneManager.ClosePreviewScene(scene);}
}
}
#endif
@@ -0,0 +1,63 @@
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>Force-reimport XTerrain, punch ground lightmap contrast, recapture HomeCamp review.</summary>
public static class FixHomeCampShadowPunch
{
const string Root = "Assets/Bundles/Scenes/Town/HomeCamp";
const string ScenePath = Root + "/HomeCamp.unity";
const string Review = "D:/UD/AI/AIGame/Tools/output/homecamp_review";
public static string Run()
{
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/XTerrain.shader", ImportAssetOptions.ForceSynchronousImport);
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/XTerrain_Base.hlsl", ImportAssetOptions.ForceSynchronousImport);
var mat = AssetDatabase.LoadAssetAtPath<Material>(Root + "/ground_terrain.mat");
if (!mat) return "FAIL: no ground mat";
// Stronger than 1.45 — busy albedo still hid plaza shade after the CBUFFER fix.
mat.SetFloat("_LightmapContrast", 1.85f);
EditorUtility.SetDirty(mat);
AssetDatabase.SaveAssetIfDirty(mat);
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var root = scene.GetRootGameObjects().Single(g => g.name == "HomeCamp");
var ground = root.transform.Find("Ground")?.GetComponentInChildren<MeshRenderer>();
var live = ground ? ground.sharedMaterial.GetFloat("_LightmapContrast") : -1f;
// Warm shader variants so Capture does not draw the first frame without LIGHTMAP_ON.
ShaderUtil.allowAsyncCompilation = false;
var cam = root.GetComponentInChildren<Camera>();
Capture(cam, Review + "/homecamp_portrait.png", 1080, 1920);
Capture(cam, Review + "/homecamp_tall.png", 1080, 2400);
return "contrast=" + live.ToString("F2") + " groundLM=" + (ground ? ground.lightmapIndex.ToString() : "?")
+ " maps=" + LightmapSettings.lightmaps.Length;
}
static void Capture(Camera camera, string file, int w, int h)
{
Directory.CreateDirectory(Path.GetDirectoryName(file));
var rt = RenderTexture.GetTemporary(w, h, 24, RenderTextureFormat.ARGB32);
var old = RenderTexture.active; var oldT = camera.targetTexture; var oldA = camera.aspect;
var tex = new Texture2D(w, h, TextureFormat.RGB24, false);
try
{
camera.aspect = (float)w / h; camera.targetTexture = rt; RenderTexture.active = rt;
camera.Render(); camera.Render();
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply();
File.WriteAllBytes(file, tex.EncodeToPNG());
}
finally
{
camera.targetTexture = oldT; camera.aspect = oldA; RenderTexture.active = old;
RenderTexture.ReleaseTemporary(rt); Object.DestroyImmediate(tex);
}
}
}
#endif
-18
View File
@@ -1,18 +0,0 @@
using UnityEditor;
using UnityEngine;
public static class FrameTownSquare
{
public static string Run()
{
SceneView.FocusWindowIfItsOpen<SceneView>();
var v = SceneView.lastActiveSceneView;
if (v == null && SceneView.sceneViews.Count > 0)
v = SceneView.sceneViews[0] as SceneView;
if (v == null)
return "no scene view";
v.LookAt(new Vector3(0f, 1.0f, 0.5f), Quaternion.Euler(38f, 12f, 0f), 22f);
v.Repaint();
return "framed";
}
}
@@ -0,0 +1,123 @@
#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 ImportLmGirlTokenRigFbx
{
const string FolderPath="Assets/Bundles/Characters/Human/test/girl_tokenrig";
const string ModelPath=FolderPath+"/LmGirlTokenRig.fbx";
const string MaterialPath=FolderPath+"/LmGirlTokenRig.mat";
static string Repo=>Directory.GetParent(Application.dataPath).Parent.FullName;
static string Output=>Path.Combine(Repo,"Tools/output/lmgirl_tpose_studio/rig_mixamo41/fbx");
static float[] V(Vector3 v)=>new[]{v.x,v.y,v.z};
static void Folder(string p){if(AssetDatabase.IsValidFolder(p))return;Folder(Path.GetDirectoryName(p).Replace('\\','/'));AssetDatabase.CreateFolder(Path.GetDirectoryName(p).Replace('\\','/'),Path.GetFileName(p));}
static string Hash(string p){using(var sha=SHA256.Create())return BitConverter.ToString(sha.ComputeHash(File.ReadAllBytes(p))).Replace("-","").ToLowerInvariant();}
static void CopyNew(string source,string target)
{
var full=Path.Combine(Directory.GetParent(Application.dataPath).FullName,target);
if(File.Exists(full)){if(Hash(full)!=Hash(source))throw new Exception("Existing destination differs: "+target);}
else File.Copy(source,full);
AssetDatabase.ImportAsset(target,ImportAssetOptions.ForceSynchronousImport);
}
public static string Import()
{
if(EditorApplication.isPlaying)throw new Exception("Edit Mode required");
Folder(FolderPath);
foreach(var suffix in new[]{"base","mix"})
{
string path=FolderPath+"/LmGirlTokenRig_"+suffix+".png";
CopyNew(Path.Combine(Repo,"Tools/output/lmgirl_tpose_studio/xpbr/LmGirlTpose_"+suffix+".png"),path);
var ti=(TextureImporter)AssetImporter.GetAtPath(path);ti.sRGBTexture=suffix=="base";ti.alphaIsTransparency=false;ti.maxTextureSize=1024;ti.SaveAndReimport();
}
var material=AssetDatabase.LoadAssetAtPath<Material>(MaterialPath);
if(!material)
{
var reference=AssetDatabase.LoadAssetAtPath<Material>("Assets/Review/CharacterAnimationLab/LmGirl/Generated/LmGirlTextured.mat");
if(!reference)throw new Exception("Missing approved XPbr reference material");
material=new Material(reference);material.name="LmGirlTokenRig";AssetDatabase.CreateAsset(material,MaterialPath);
}
material.SetTexture("_MainTex",AssetDatabase.LoadAssetAtPath<Texture2D>(FolderPath+"/LmGirlTokenRig_base.png"));
material.SetTexture("_MixTex",AssetDatabase.LoadAssetAtPath<Texture2D>(FolderPath+"/LmGirlTokenRig_mix.png"));
EditorUtility.SetDirty(material);AssetDatabase.SaveAssetIfDirty(material);
CopyNew(Path.Combine(Output,"LmGirlTokenRig.fbx"),ModelPath);
var importer=(ModelImporter)AssetImporter.GetAtPath(ModelPath);
importer.animationType=ModelImporterAnimationType.Human;importer.avatarSetup=ModelImporterAvatarSetup.CreateFromThisModel;
importer.importAnimation=false;importer.globalScale=1;importer.useFileScale=true;
importer.importCameras=false;importer.importLights=false;
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),"LmGirlTokenRig_Body"),material);
importer.SaveAndReimport();
var model=AssetDatabase.LoadAssetAtPath<GameObject>(ModelPath);
var avatar=AssetDatabase.LoadAllAssetsAtPath(ModelPath).OfType<Avatar>().FirstOrDefault();
if(!avatar || !avatar.isValid || !avatar.isHuman)throw new Exception("Invalid Humanoid Avatar");
if(!model.GetComponentsInChildren<SkinnedMeshRenderer>().All(s=>s.sharedMaterial==material))throw new Exception("Material remap failed");
return "Imported "+ModelPath+" with valid Humanoid Avatar and project XPbr material";
}
public static string Verify()
{
var scene=EditorSceneManager.NewPreviewScene();var units=new List<GameObject>();
var controller=AssetDatabase.LoadAssetAtPath<AnimatorController>("Assets/Bundles/Characters/Human/Common/Man.controller");
var walk=controller.layers[0].stateMachine.states.Single(s=>s.state.name=="walk").state;
var records=new List<object>();
try
{
int column=0;
foreach(var path in new[]{"Assets/Bundles/Characters/Human/test/girl/LmGirlTpose.fbx",ModelPath})
{
var go=(GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(path),scene);units.Add(go);
go.transform.position=new Vector3((.5f-column++)*.95f,0,0);
var a=go.GetComponent<Animator>();var skin=go.GetComponentInChildren<SkinnedMeshRenderer>();
if(!a||!a.isHuman||!a.avatar.isValid)throw new Exception("Invalid Animator: "+path);
var baked=new Mesh();skin.BakeMesh(baked);
var vertices=baked.vertices.Select(p=>go.transform.InverseTransformPoint(skin.transform.TransformPoint(p))).ToArray();
var lo=vertices.Aggregate(Vector3.positiveInfinity,Vector3.Min);var hi=vertices.Aggregate(Vector3.negativeInfinity,Vector3.Max);
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(hi.y-lo.y<1 || hi.y-lo.y>1.4f || Mathf.Abs(lo.y)>.001f)throw new Exception("Wrong height/floor: "+path);
if(toes[0].z<=ankles[0].z||toes[1].z<=ankles[1].z)throw new Exception("Not facing +Z: "+path);
var counts=skin.sharedMesh.GetBonesPerVertex();int maxInfluences=0;foreach(var n in counts)maxInfluences=Math.Max(maxInfluences,n);counts.Dispose();
var rest=new{boundsMin=V(lo),boundsMax=V(hi),rootRotation=V(go.transform.localEulerAngles),rootScale=V(go.transform.localScale),toes=toes.Select(V).ToArray(),ankles=ankles.Select(V).ToArray()};
UnityEngine.Object.DestroyImmediate(baked);
a.runtimeAnimatorController=controller;a.applyRootMotion=false;a.cullingMode=AnimatorCullingMode.AlwaysAnimate;a.Rebind();a.Update(0);
float minGap=float.MaxValue,maxGap=float.MinValue;int crossed=0;
for(int i=0;i<125;i++)
{
a.Play("Base Layer.walk",0,(i/120f)/walk.motion.averageDuration);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++;
}
if(crossed>0)throw new Exception("Crossed ankles: "+path);
records.Add(new{path,avatar=a.avatar.name,a.avatar.isHuman,a.avatar.isValid,bones=skin.bones.Length,vertices=skin.sharedMesh.vertexCount,triangles=skin.sharedMesh.triangles.Length/3,
uv=skin.sharedMesh.uv.Length,maxInfluences,material=AssetDatabase.GetAssetPath(skin.sharedMaterial),shader=skin.sharedMaterial.shader.name,rest,
walk=new{clip=AssetDatabase.GetAssetPath(walk.motion),footIK=walk.iKOnFeet,samples=125,minGap,maxGap,crossed}});
a.Play("Base Layer.walk",0,.2666667f/walk.motion.averageDuration);a.Update(0);
var hips=a.GetBoneTransform(HumanBodyBones.Hips);var hp=hips.position;hp.x=go.transform.position.x;hp.z=0;hips.position=hp;
}
Render(scene);
var json=JsonConvert.SerializeObject(new{records,scenes=Enumerable.Range(0,SceneManager.sceneCount).Select(i=>{var s=SceneManager.GetSceneAt(i);return new{s.path,s.isDirty};}).ToArray()},Formatting.Indented);
File.WriteAllText(Path.Combine(Output,"unity_validation.json"),json);return json;
}
finally{EditorSceneManager.ClosePreviewScene(scene);}
}
static void Render(Scene scene)
{
var light=new GameObject("ExportAuditLight").AddComponent<Light>();SceneManager.MoveGameObjectToScene(light.gameObject,scene);light.type=LightType.Directional;light.intensity=1.3f;light.transform.rotation=Quaternion.Euler(35,140,0);
var camera=new GameObject("ExportAuditCamera").AddComponent<Camera>();SceneManager.MoveGameObjectToScene(camera.gameObject,scene);camera.scene=scene;camera.orthographic=true;camera.orthographicSize=.73f;
camera.transform.position=new Vector3(0,.61f,5);camera.transform.LookAt(new Vector3(0,.61f,0));camera.clearFlags=CameraClearFlags.SolidColor;camera.backgroundColor=new Color(.22f,.25f,.29f);camera.nearClipPlane=.01f;camera.farClipPlane=20;
var rt=new RenderTexture(1080,800,24);var tex=new Texture2D(1080,800,TextureFormat.RGB24,false);var previous=RenderTexture.active;
try{camera.targetTexture=rt;camera.Render();RenderTexture.active=rt;tex.ReadPixels(new Rect(0,0,1080,800),0,0);tex.Apply();File.WriteAllBytes(Path.Combine(Output,"unity_walking.png"),tex.EncodeToPNG());}
finally{RenderTexture.active=previous;camera.targetTexture=null;rt.Release();UnityEngine.Object.DestroyImmediate(rt);UnityEngine.Object.DestroyImmediate(tex);}
}
}
#endif
@@ -0,0 +1,34 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
public static class InspectLmGirlMaterials
{
static string PathOf(UnityEngine.Object obj)=>obj?AssetDatabase.GetAssetPath(obj):null;
static object MaterialInfo(Material material)=>material?new{name=material.name,path=PathOf(material),shader=material.shader?material.shader.name:null,
textures=material.GetTexturePropertyNames().Select(p=>new{property=p,texture=PathOf(material.GetTexture(p))}).ToArray()}:null;
static object Renderers(GameObject obj)=>obj.GetComponentsInChildren<Renderer>(true).Select(r=>new{r.name,
mesh=r is SkinnedMeshRenderer skin&&skin.sharedMesh?new{name=skin.sharedMesh.name,vertices=skin.sharedMesh.vertexCount,uv=skin.sharedMesh.HasVertexAttribute(UnityEngine.Rendering.VertexAttribute.TexCoord0)}:null,
materials=r.sharedMaterials.Select(MaterialInfo).ToArray()}).ToArray();
public static string Inspect()
{
const string path="Assets/Bundles/Characters/Human/test/girl/LmGirlTpose.fbx";
var importer=(ModelImporter)AssetImporter.GetAtPath(path);
var model=AssetDatabase.LoadAssetAtPath<GameObject>(path);
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Characters/Human/test/girl/Girl.prefab");
var report=new{importer=new{importer.materialImportMode,importer.materialLocation,importer.materialSearch,
remaps=importer.GetExternalObjectMap().Select(p=>new{p.Key.name,path=PathOf(p.Value)}).ToArray()},
fbx=Renderers(model),prefab=prefab?Renderers(prefab):null,
embeddedMaterials=AssetDatabase.LoadAllAssetsAtPath(path).OfType<Material>().Select(MaterialInfo).ToArray(),
availableReviewMaterial=MaterialInfo(AssetDatabase.LoadAssetAtPath<Material>("Assets/Review/CharacterAnimationLab/LmGirl/Generated/LmGirlTextured.mat")),
scene=UnityEngine.Object.FindObjectsOfType<Animator>(true).Where(a=>a.name.IndexOf("LmGirl",StringComparison.OrdinalIgnoreCase)>=0).Select(a=>new{a.name,renderers=Renderers(a.gameObject)}).ToArray()};
var json=JsonConvert.SerializeObject(report,Formatting.Indented);
var directory=System.IO.Path.Combine(Directory.GetParent(Application.dataPath).Parent.FullName,"Tools/output/lmgirl_tpose_studio/material_audit");
Directory.CreateDirectory(directory);File.WriteAllText(System.IO.Path.Combine(directory,"unity_materials.json"),json);return json;
}
}
#endif
@@ -1,35 +0,0 @@
using System.Text;
using UnityEngine;
using UnityEngine.SceneManagement;
public static class InspectTownSquare
{
public static string Run()
{
var sb = new StringBuilder();
var root = GameObject.Find("TownSquare");
if (root == null)
return "no TownSquare";
foreach (Transform t in root.transform)
{
var b = default(Bounds);
var has = false;
foreach (var r in t.GetComponentsInChildren<Renderer>())
{
if (!has) { b = r.bounds; has = true; }
else b.Encapsulate(r.bounds);
}
var cols = t.GetComponentsInChildren<MeshCollider>().Length;
sb.AppendFormat(
"{0} pos={1} euler={2} scale={3} boundsC={4} size={5} meshCol={6}\n",
t.name,
t.position,
t.eulerAngles,
t.localScale,
has ? b.center : Vector3.zero,
has ? b.size : Vector3.zero,
cols);
}
return sb.ToString();
}
}
@@ -0,0 +1,107 @@
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
/// <summary>
/// Pipeline script to optimize AudioImporter settings across all project sound assets.
/// Converts uncompressed stereo one-shots to mono ADPCM, sets BGM to 2D streaming Vorbis.
/// </summary>
public static class OptimizeAudioSettings
{
const string SoundRoot = "Assets/Bundles/Sound";
public static string Run()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
throw new InvalidOperationException("Run in Edit Mode.");
int count = 0;
var guids = AssetDatabase.FindAssets("t:AudioClip", new[] { SoundRoot });
foreach (var guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var importer = AssetImporter.GetAtPath(path) as AudioImporter;
if (importer == null) continue;
bool isBgm = path.Contains("/BGM/");
// 1. Force to mono for all one-shots (cuts memory in half)
importer.forceToMono = !isBgm;
// 2. Disable 3D spatial calculations for UI, 2D battle feedbacks and BGM
var serialized = new SerializedObject(importer);
var prop3D = serialized.FindProperty("3D");
if (prop3D != null && prop3D.boolValue)
{
prop3D.boolValue = false;
serialized.ApplyModifiedPropertiesWithoutUndo();
}
// 3. Setup platform default settings
var settings = importer.defaultSampleSettings;
if (isBgm)
{
// Streaming Vorbis for long background music tracks
settings.loadType = AudioClipLoadType.Streaming;
settings.compressionFormat = AudioCompressionFormat.Vorbis;
settings.quality = 0.75f;
settings.preloadAudioData = false;
importer.loadInBackground = true;
}
else
{
// Fast ADPCM with zero decompression overhead for short gameplay one-shots
settings.loadType = AudioClipLoadType.DecompressOnLoad;
settings.compressionFormat = AudioCompressionFormat.ADPCM;
settings.sampleRateSetting = AudioSampleRateSetting.PreserveSampleRate;
settings.preloadAudioData = true;
importer.loadInBackground = false;
}
importer.defaultSampleSettings = settings;
importer.SaveAndReimport();
count++;
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return $"Optimized {count} audio assets across UI, Combat SFX, and BGM.";
}
public static string Verify()
{
var guids = AssetDatabase.FindAssets("t:AudioClip", new[] { SoundRoot });
int verified = 0;
foreach (var guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var importer = AssetImporter.GetAtPath(path) as AudioImporter;
if (importer == null) continue;
bool isBgm = path.Contains("/BGM/");
var settings = importer.defaultSampleSettings;
if (isBgm)
{
if (settings.loadType != AudioClipLoadType.Streaming || settings.compressionFormat != AudioCompressionFormat.Vorbis)
throw new Exception($"BGM not streaming Vorbis: {path}");
}
else
{
if (!importer.forceToMono)
throw new Exception($"One-shot SFX not forced to mono: {path}");
if (settings.compressionFormat != AudioCompressionFormat.ADPCM)
throw new Exception($"One-shot SFX not ADPCM: {path}");
}
verified++;
}
return $"PASS all {verified} audio assets conform to mobile runtime specifications.";
}
}
#endif
-82
View File
@@ -1,82 +0,0 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
/// <summary>
/// 把 CaoCao Prefab 摆到当前场景广场中心,Y 取地表碰撞高度。不保存进 TownSquare.prefab。
/// </summary>
public static class PlaceCaoCao
{
const string PrefabPath = "Assets/Bundles/Characters/Human/CaoCao/CaoCao.prefab";
public static string Run()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
if (prefab == null)
return "missing " + PrefabPath;
var existing = GameObject.Find("CaoCao_Preview");
if (existing != null)
Object.DestroyImmediate(existing);
var go = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
go.name = "CaoCao_Preview";
Physics.SyncTransforms();
var y = SampleGroundY(0f, 0f, out var sitInfo);
go.transform.position = new Vector3(0f, y, 0f);
go.transform.rotation = Quaternion.identity;
var bounds = default(Bounds);
var has = false;
foreach (var r in go.GetComponentsInChildren<Renderer>())
{
if (!has) { bounds = r.bounds; has = true; }
else bounds.Encapsulate(r.bounds);
}
return $"pos={go.transform.position} h={(has ? bounds.size.y : 0f):F3} groundY={y:F3} {sitInfo}";
}
static float SampleGroundY(float x, float z, out string info)
{
var plaza = GameObject.Find("TownSquare");
var cols = plaza != null
? plaza.GetComponentsInChildren<MeshCollider>()
: Object.FindObjectsByType<MeshCollider>(FindObjectsSortMode.None);
var y = 0f;
var hitAny = false;
var best = float.MaxValue;
var meshName = "";
var boundsTop = 0f;
foreach (var col in cols)
{
if (col == null || !col.enabled) continue;
meshName = col.sharedMesh != null ? col.sharedMesh.name : "null";
boundsTop = col.bounds.max.y;
var origins = new[]
{
new Vector3(x, 80f, z),
new Vector3(col.bounds.center.x, col.bounds.max.y + 8f, col.bounds.center.z),
new Vector3(x + 0.35f, 80f, z + 0.35f),
};
foreach (var origin in origins)
{
var ray = new Ray(origin, Vector3.down);
if (!col.Raycast(ray, out var hit, 160f)) continue;
if (hit.distance >= best) continue;
best = hit.distance;
y = hit.point.y;
hitAny = true;
}
}
if (!hitAny && boundsTop > 0.01f)
{
y = boundsTop;
info = $"fallbackBoundsTop={y:F3} mesh={meshName} cols={cols.Length}";
return y;
}
info = $"plaza={(plaza != null ? plaza.name : "null")} cols={cols.Length} mesh={meshName} meshHit={hitAny}";
return hitAny ? y : 0f;
}
}
#endif
+13
View File
@@ -0,0 +1,13 @@
/// <summary>Refresh configured Art bundles after scene asset retirement.</summary>
public static class RefreshSceneArt
{
public static string UpdateCollectors()
{
return AOT.Editor.YooAssetTools.BuildAndStageArt.EnsureLoadableScenePrefabs();
}
public static string Build()
{
return AOT.Editor.YooAssetTools.BuildAndStageArt.BuildConfiguredAndStage();
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ using UnityEngine;
using YooAsset.Editor;
/// <summary>
/// 新角色 Prefab:Y-up、底面原点、局部 -Z 正面,不做 +90X。
/// 新角色 Prefab:Y-up、底面原点、局部 +Z 正面,不做 +90X。旧资产保留原绑定空间。
/// unity command run_script --file AgentScripts/SetupCharacter.cs --entry SetupCharacter.Run --args "[\"Human\",\"CaoCao\"]"
/// </summary>
public static class SetupCharacter
@@ -0,0 +1,41 @@
#if UNITY_EDITOR
using System.Linq;
using UnityEditor;
using UnityEngine;
public static class TuneHomeCampShadeLift
{
const string Root = "Assets/Bundles/Scenes/Town/HomeCamp";
public static string Run()
{
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/HomeCampXPbr.shader", ImportAssetOptions.ForceSynchronousImport);
AssetDatabase.ImportAsset("Assets/Bundles/Shaders/XShader/XTerrain.shader", ImportAssetOptions.ForceSynchronousImport);
var report = new System.Collections.Generic.List<string>();
foreach (var path in AssetDatabase.FindAssets("t:Material", new[] { Root }).Select(AssetDatabase.GUIDToAssetPath))
{
var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
if (!mat || mat.shader == null) continue;
if (mat.shader.name == "Test/HomeCampXPbr")
{
// Stronger open: gamma 0.4 ≈ visibly half the crushed shade.
mat.SetFloat("_LightmapShadeGamma", .4f);
mat.SetFloat("_AO", .5f);
EditorUtility.SetDirty(mat);
report.Add(mat.name + " gamma=" + mat.GetFloat("_LightmapShadeGamma"));
}
else if (mat.shader.name == "XWorld/XTerrain")
{
mat.SetFloat("_LightmapContrast", .4f);
mat.SetFloat("_LightmapShadeMul", 1f);
mat.SetColor("_LightmapShadeTint", new Color(.9f, .92f, .96f, 1f));
EditorUtility.SetDirty(mat);
report.Add(mat.name + " contrast=" + mat.GetFloat("_LightmapContrast"));
}
}
AssetDatabase.SaveAssets();
return string.Join("; ", report);
}
}
#endif
+1 -1
View File
@@ -111,7 +111,7 @@ public static class VerifyCollection
PrototypeReply response=null;string error=null;
Flow.StartCoroutine(api.Send(command,(r,e)=>{response=r;error=e;}));await Until(()=>response!=null||error!=null);Check(response?.ok==true,"Capture failed before simulated crash");
var session=Get<Func<SessionData>>("_session")();string key="AIGame.G1.pending."+Uri.EscapeDataString("http://127.0.0.1:18081")+"."+session.Pid;
PlayerPrefs.SetString(key,JsonUtility.ToJson(command));PlayerPrefs.Save();Set("_needsSync",true);Flow.Handle("Home");
UnityEngine.PlayerPrefs.SetString(key,JsonUtility.ToJson(command));UnityEngine.PlayerPrefs.Save();Set("_needsSync",true);Flow.Handle("Home");
await Until(()=>!Flow.IsBusy&&Get<PrototypeReply>("_reply").replayed);
Check(Flow.Game.State.tickets==tickets-1&&Flow.Game.State.captureCount==count+1&&Flow.Game.State.shards.Sum()==shards+20&&!api.HasPending,"Replayed capture redrew or double-debited");
var dialog=UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().CaptureState();Check(dialog.Visible&&dialog.Title=="获得进化碎片","Recovered draw result not surfaced");Capture("recovery");Click("Ok");
+2 -2
View File
@@ -117,9 +117,9 @@ public static class VerifyHomeMobileUi
{
var hud = UiManager.Instance?.Get("UI_MainHUD");
Check(hud != null && hud.GetComponentInChildren<HomeExplorerPresenter>().Ready, "UI preview is still loading");
Check(Object.FindObjectsOfType<HotUpdate.TownSquareSpawner>().Length == 0 && Object.FindObjectsOfType<HotUpdate.CharacterSpawner>().Length == 0,
Check(Object.FindObjectsOfType<HotUpdate.CharacterSpawner>().Length == 0,
"Startup still creates 3D world spawners");
Check(GameObject.Find("TownSquare_Runtime") == null && GameObject.Find("CaoCao_Runtime") == null, "Startup world was loaded");
Check(GameObject.Find("CaoCao_Runtime") == null, "Startup world was loaded");
var raw = hud.GetComponentInChildren<RawImage>();
Check(raw.enabled && raw.texture is RenderTexture && raw.material.shader.name == "AIGame/HomeExplorerCutout", "Live preview material missing");
var presenter = hud.GetComponentInChildren<HomeExplorerPresenter>();