背景1:
美术给spine运动绑定骨骼名比较懒散,众所周知骨骼路径是典型的树目录,我提过几次每次给我们路径的时候需要给全路径但是美术那边不太配合还是习惯性的给结点名,好吧给就给吧那我们只有写工具处理了
背景2:
程序拖进美术的spine动效后打包就spine包都有10多甚至20M一看就是给的贴图太大了未做压缩,因此需要改进spine的导入工具自动处理它的纹理压缩,比如设置为Astc6X6,pc上DXT5
绑骨工具改造:
之前官方的BoneFollowGraphic是没有绑定骨骼这个参数的,也没有目录树,你要绑定就只能
从BoneName这个弹窗里面去选,注意它是一个树结构找的你想死,感觉就是被美术骗着浪费时间。

对官方的BoneFollowerGraphicInspector类进行扩展
新增2个变量,绑定骨骼和目录树

OnEnable的时候绑定属性

显示成中文变量名方便日常开发

有变化就去走深度优先查找


最后完整代码如下
cs
using UnityEditor;
using UnityEngine;
namespace Spine.Unity.Editor {
using Editor = UnityEditor.Editor;
using Event = UnityEngine.Event;
[CustomEditor(typeof(BoneFollowerGraphic)), CanEditMultipleObjects]
public class BoneFollowerGraphicInspector : Editor {
SerializedProperty boneName, CustomBoneName, bonePath, skeletonGraphic, followXYPosition, followZPosition, followBoneRotation,
followLocalScale, followParentWorldScale, followSkeletonFlip, maintainedAxisOrientation;
BoneFollowerGraphic targetBoneFollower;
bool needsReset;
#region Context Menu Item
[MenuItem("CONTEXT/SkeletonGraphic/Add BoneFollower GameObject")]
static void AddBoneFollowerGameObject (MenuCommand cmd) {
var skeletonGraphic = cmd.context as SkeletonGraphic;
var go = EditorInstantiation.NewGameObject("BoneFollower", true, typeof(RectTransform));
var t = go.transform;
t.SetParent(skeletonGraphic.transform);
t.localPosition = Vector3.zero;
var f = go.AddComponent<BoneFollowerGraphic>();
f.skeletonGraphic = skeletonGraphic;
f.SetBone(skeletonGraphic.Skeleton.RootBone.Data.Name);
EditorGUIUtility.PingObject(t);
Undo.RegisterCreatedObjectUndo(go, "Add BoneFollowerGraphic");
}
// Validate
[MenuItem("CONTEXT/SkeletonGraphic/Add BoneFollower GameObject", true)]
static bool ValidateAddBoneFollowerGameObject (MenuCommand cmd) {
var skeletonGraphic = cmd.context as SkeletonGraphic;
return skeletonGraphic.IsValid;
}
#endregion
void OnEnable () {
skeletonGraphic = serializedObject.FindProperty("skeletonGraphic");
boneName = serializedObject.FindProperty("boneName");
CustomBoneName = serializedObject.FindProperty("CustomBoneName");
bonePath = serializedObject.FindProperty("bonePath");
bonePath = serializedObject.FindProperty("bonePath");
followBoneRotation = serializedObject.FindProperty("followBoneRotation");
followXYPosition = serializedObject.FindProperty("followXYPosition");
followZPosition = serializedObject.FindProperty("followZPosition");
followLocalScale = serializedObject.FindProperty("followLocalScale");
followParentWorldScale = serializedObject.FindProperty("followParentWorldScale");
followSkeletonFlip = serializedObject.FindProperty("followSkeletonFlip");
maintainedAxisOrientation = serializedObject.FindProperty("maintainedAxisOrientation");
targetBoneFollower = (BoneFollowerGraphic)target;
if (targetBoneFollower.SkeletonGraphic != null)
targetBoneFollower.SkeletonGraphic.Initialize(false);
if (!targetBoneFollower.valid || needsReset) {
targetBoneFollower.Initialize();
targetBoneFollower.LateUpdate();
needsReset = false;
SceneView.RepaintAll();
}
}
Bone FindBoneDFS(Bone root, string targetName)
{
if (root.Data.Name == targetName)
return root;
var children = root.Children;
for (int i = 0; i < children.Count; i++)
{
var result = FindBoneDFS(children.Items[i], targetName);
if (result != null)
return result;
}
return null;
}
public void OnSceneGUI () {
var tbf = target as BoneFollowerGraphic;
var skeletonGraphicComponent = tbf.SkeletonGraphic;
if (skeletonGraphicComponent == null) return;
var transform = skeletonGraphicComponent.transform;
var skeleton = skeletonGraphicComponent.Skeleton;
var canvas = skeletonGraphicComponent.canvas;
float positionScale = canvas == null ? 1f : skeletonGraphicComponent.canvas.referencePixelsPerUnit;
if (string.IsNullOrEmpty(boneName.stringValue)) {
SpineHandles.DrawBones(transform, skeleton, positionScale);
SpineHandles.DrawBoneNames(transform, skeleton, positionScale);
Handles.Label(tbf.transform.position, "No bone selected", EditorStyles.helpBox);
} else {
var targetBone = tbf.bone;
if (targetBone == null) return;
SpineHandles.DrawBoneWireframe(transform, targetBone, SpineHandles.TransformContraintColor, positionScale);
Handles.Label(targetBone.GetWorldPosition(transform, positionScale), targetBone.Data.Name, SpineHandles.BoneNameStyle);
}
}
private string GetBonePath(Bone bone)
{
if (bone == null) return string.Empty;
// 从当前骨骼向上回溯到 RootBone
var path = bone.Data.Name;
var parent = bone.Parent;
while (parent != null)
{
path = parent.Data.Name + "/" + path;
parent = parent.Parent;
}
return path;
}
override public void OnInspectorGUI () {
if (serializedObject.isEditingMultipleObjects) {
if (needsReset) {
needsReset = false;
foreach (var o in targets) {
var bf = (BoneFollower)o;
bf.Initialize();
bf.LateUpdate();
}
SceneView.RepaintAll();
}
EditorGUI.BeginChangeCheck();
DrawDefaultInspector();
needsReset |= EditorGUI.EndChangeCheck();
return;
}
if (needsReset && Event.current.type == EventType.Layout) {
targetBoneFollower.Initialize();
targetBoneFollower.LateUpdate();
needsReset = false;
SceneView.RepaintAll();
}
serializedObject.Update();
// Find Renderer
if (skeletonGraphic.objectReferenceValue == null) {
SkeletonGraphic parentRenderer = targetBoneFollower.GetComponentInParent<SkeletonGraphic>();
if (parentRenderer != null && parentRenderer.gameObject != targetBoneFollower.gameObject) {
skeletonGraphic.objectReferenceValue = parentRenderer;
Debug.Log("Inspector automatically assigned BoneFollowerGraphic.SkeletonGraphic");
}
}
EditorGUILayout.PropertyField(skeletonGraphic);
var skeletonGraphicComponent = skeletonGraphic.objectReferenceValue as SkeletonGraphic;
if (skeletonGraphicComponent != null) {
if (skeletonGraphicComponent.gameObject == targetBoneFollower.gameObject) {
skeletonGraphic.objectReferenceValue = null;
EditorUtility.DisplayDialog("Invalid assignment.", "BoneFollowerGraphic can only follow a skeleton on a separate GameObject.\n\nCreate a new GameObject for your BoneFollower, or choose a SkeletonGraphic from a different GameObject.", "Ok");
}
}
if (!targetBoneFollower.valid) {
needsReset = true;
}
if (targetBoneFollower.valid) {
EditorGUI.BeginChangeCheck();
EditorGUILayout.PropertyField(CustomBoneName,new GUIContent("绑定骨骼"));
EditorGUILayout.PropertyField(boneName);
EditorGUILayout.PropertyField(bonePath,new GUIContent("目录树"));
needsReset |= EditorGUI.EndChangeCheck();
EditorGUILayout.PropertyField(followBoneRotation);
EditorGUILayout.PropertyField(followXYPosition);
EditorGUILayout.PropertyField(followZPosition);
EditorGUILayout.PropertyField(followLocalScale);
EditorGUILayout.PropertyField(followParentWorldScale);
EditorGUILayout.PropertyField(followSkeletonFlip);
if ((followSkeletonFlip.hasMultipleDifferentValues || followSkeletonFlip.boolValue == false) &&
(followBoneRotation.hasMultipleDifferentValues || followBoneRotation.boolValue == true)) {
using (new SpineInspectorUtility.IndentScope())
EditorGUILayout.PropertyField(maintainedAxisOrientation);
}
if (needsReset && CustomBoneName.stringValue != boneName.stringValue)
{
var tbf = target as BoneFollowerGraphic;
Skeleton tmpSkeleton = tbf.skeletonGraphic.Skeleton;
var bone = FindBoneDFS(tbf.SkeletonGraphic.Skeleton.RootBone, CustomBoneName.stringValue);
tbf.bone = bone;
boneName.stringValue = CustomBoneName.stringValue;
bonePath.stringValue = GetBonePath(bone);
}
//BoneFollowerInspector.RecommendRigidbodyButton(targetBoneFollower);
} else {
var boneFollowerSkeletonGraphic = targetBoneFollower.skeletonGraphic;
if (boneFollowerSkeletonGraphic == null) {
EditorGUILayout.HelpBox("SkeletonGraphic is unassigned. Please assign a SkeletonRenderer (SkeletonAnimation or SkeletonMecanim).", MessageType.Warning);
} else {
boneFollowerSkeletonGraphic.Initialize(false);
if (boneFollowerSkeletonGraphic.skeletonDataAsset == null)
EditorGUILayout.HelpBox("Assigned SkeletonGraphic does not have SkeletonData assigned to it.", MessageType.Warning);
if (!boneFollowerSkeletonGraphic.IsValid)
EditorGUILayout.HelpBox("Assigned SkeletonGraphic is invalid. Check target SkeletonGraphic, its SkeletonData asset or the console for other errors.", MessageType.Warning);
}
}
var current = Event.current;
bool wasUndo = (current.type == EventType.ValidateCommand && current.commandName == "UndoRedoPerformed");
if (wasUndo)
targetBoneFollower.Initialize();
serializedObject.ApplyModifiedProperties();
}
}
}
最终效果展示:

spine纹理压缩格式:
Assets\3rdParty\Spine\Editor\spine-unity\Editor\Utility\AssetUtility.cs中

新增的代码为:
cs
//AssetDatabase.ImportAsset(texturePath);
var android = texImporter.GetPlatformTextureSettings("android");
if(android != null && !android.overridden)
{
android.overridden = true;
android.format = TextureImporterFormat.ASTC_6x6;
texImporter.SetPlatformTextureSettings(android);
}
var ios = texImporter.GetPlatformTextureSettings("ios");
if (ios != null && !ios.overridden)
{
ios.overridden = true;
ios.format = TextureImporterFormat.ASTC_6x6;
texImporter.SetPlatformTextureSettings(ios);
}
var standalone = texImporter.GetPlatformTextureSettings("Standalone");
if (standalone != null && !standalone.overridden)
{
standalone.overridden = true;
standalone.format = TextureImporterFormat.DXT5;
texImporter.SetPlatformTextureSettings(standalone);
}
texImporter.SaveAndReimport();
AssetDatabase.SaveAssets();
普通ui纹理压缩格式自动处理:
TextureAutoImportSession.cs 存放于unity工程 Assets Editor目录下
另外新增了中文纹理资源不得导入到工程的设定,美术经常那么制作。
cs
#if DebugMod && UNITY_EDITOR_WIN
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System;
using System.IO;
/// <summary>
/// 纹理自动导入会话管理 ------ 检测首次打开工程
/// 首次打开时跳过所有纹理处理,避免拉项目后首次导入耗时过长
/// 正常关闭编辑器后标记为已初始化,下次打开才会自动处理新导入的纹理
/// </summary>
[InitializeOnLoad]
public static class TextureAutoImportSession
{
private const string PrefKeyPrefix = "TexAutoImport_Initialized";
private static bool _isFirstProjectOpen;
static TextureAutoImportSession()
{
string key = GetProjectKey();
_isFirstProjectOpen = !EditorPrefs.GetBool(key, false);
if (_isFirstProjectOpen)
{
Debug.Log("[TextureAutoImport] 检测到首次打开此工程,跳过所有纹理自动处理(避免首次导入耗时过长)。正常关闭编辑器后,下次打开将自动处理新导入的纹理。");
EditorApplication.wantsToQuit += OnEditorQuitting;
}
}
private static bool OnEditorQuitting()
{
EditorPrefs.SetBool(GetProjectKey(), true);
return true;
}
private static string GetProjectKey()
{
return PrefKeyPrefix + "_" + Application.dataPath.GetHashCode();
}
/// <summary> 是否为首次打开此工程 </summary>
public static bool IsFirstProjectOpen => _isFirstProjectOpen;
/// <summary> 手动标记工程已初始化(用于设置窗口的按钮) </summary>
public static void MarkProjectInitialized()
{
EditorPrefs.SetBool(GetProjectKey(), true);
_isFirstProjectOpen = false;
}
/// <summary> 重置工程初始化状态(模拟首次打开,用于测试) </summary>
public static void ResetProject()
{
EditorPrefs.DeleteKey(GetProjectKey());
_isFirstProjectOpen = true;
}
}
/// <summary>
/// 纹理自动导入设置 ------ 持久化存储于 EditorPrefs
/// </summary>
public static class TextureAutoImportPrefs
{
private const string EnabledKey = "TexAutoImport_Enabled";
private const string OnlyArtKey = "TexAutoImport_OnlyArt";
private const string DisableRwKey = "TexAutoImport_DisableRW";
private const string CustomIgnoreKey = "TexAutoImport_CustomIgnore";
// ── 基础开关 ──
public static bool Enabled
{
get => EditorPrefs.GetBool(EnabledKey, true);
set => EditorPrefs.SetBool(EnabledKey, value);
}
/// <summary> 仅处理 Assets/Art 目录下的纹理 </summary>
public static bool OnlyProcessArtFolder
{
get => EditorPrefs.GetBool(OnlyArtKey, true);
set => EditorPrefs.SetBool(OnlyArtKey, value);
}
/// <summary> 自动关闭 Read/Write(节省内存) </summary>
public static bool DisableReadable
{
get => EditorPrefs.GetBool(DisableRwKey, true);
set => EditorPrefs.SetBool(DisableRwKey, value);
}
// ── 用户自定义忽略关键字(分号分隔,存储于 EditorPrefs) ──
public static string CustomIgnoreRaw
{
get => EditorPrefs.GetString(CustomIgnoreKey, "");
set => EditorPrefs.SetString(CustomIgnoreKey, value);
}
/// <summary> 获取自定义忽略关键字列表 </summary>
public static List<string> GetCustomIgnoreKeywords()
{
var result = new List<string>();
if (string.IsNullOrEmpty(CustomIgnoreRaw))
return result;
string[] parts = CustomIgnoreRaw.Split(';');
foreach (var p in parts)
{
string trimmed = p.Trim();
if (!string.IsNullOrEmpty(trimmed))
result.Add(trimmed);
}
return result;
}
}
/// <summary>
/// 纹理自动导入处理器
/// 在纹理导入时自动设置压缩格式,处理流程:
/// 1. 功能未启用 → 跳过
/// 2. 批处理模式(batmode) → 跳过
/// 3. 首次打开工程 → 跳过
/// 4. 编译中 → 跳过
/// 5. 非 PNG/JPG/TGA → 跳过
/// 6. 非 Assets 目录 → 跳过
/// 7. 若仅处理 Art 目录且不在 Art 下 → 跳过
/// 8. 路径命中忽略关键字 → 跳过
/// 9. .meta 已存在(重新导入) → 跳过
/// 10. 已勾选平台 Override → 跳过
/// 11. 其余纹理自动设置压缩格式(Android/iOS: ASTC_8x8, Standalone: DXT5)
/// </summary>
public class TextureAutoImportProcessor : AssetPostprocessor
{
// 内置忽略关键字(与现有 TextureASTCViewer / SpineTextureCompress 保持一致)
public static readonly List<string> BuiltinIgnoreKeywords = new List<string>()
{
"Editor/",
"3rdParty/",
"Resources/",
"RTLTMPro/",
"Runtime/",
"Reporter/",
"spine/", // spine 不在此处理,由 SpineTextureCompress 手动工具单独处理
"icon.png" // 游戏图标 忽略
};
void OnPreprocessTexture()
{
// 1. 功能开关
if (!TextureAutoImportPrefs.Enabled)
return;
// 2. 批处理模式不处理
if (Application.isBatchMode)
return;
// 3. 首次打开工程不处理
if (TextureAutoImportSession.IsFirstProjectOpen)
return;
// 4. 编译中不处理(避免域重载引发的批量重新导入)
if (EditorApplication.isCompiling)
return;
// 5. 检查文件扩展名(仅处理 PNG / JPG / TGA)
string ext = Path.GetExtension(assetPath)?.ToLower();
if (ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".tga")
return;
// 6. 必须在 Assets 目录下
if (!assetPath.StartsWith("Assets/"))
return;
// 7. 若要求仅处理 Assets/Art 目录
if (TextureAutoImportPrefs.OnlyProcessArtFolder && !assetPath.StartsWith("Assets/Art"))
return;
// 8. 检查路径是否包含中文(中文资源路径会导致打包/热更问题,直接屏蔽并报错)
if (ContainsChinese(assetPath))
{
AssetDatabase.DeleteAsset(assetPath);
Debug.LogError($"[TextureAutoImport] 检测到中文资源路径,已屏蔽!请使用纯英文路径: {assetPath}");
return;
}
if (IsNonNormalImage(assetPath.ToLower()))
return;
// 12. 应用纹理压缩设置
ProcessNormalImage(assetImporter as TextureImporter);
}
void ProcessNormalImage(TextureImporter importer)
{
if (HasPlatformOverride(importer))
return;
ApplyTextureSettings(importer);
}
void ProcessSpineTexture(TextureImporter importer)
{
if (HasPlatformOverride(importer))
return;
ApplyTextureSettings(importer, TextureImporterFormat.ASTC_6x6);
}
/// <summary> 检查路径是否包含中文字符 </summary>
private bool ContainsChinese(string text)
{
foreach (char c in text)
{
if (c >= 0x4e00 && c <= 0x9fff)
return true;
}
return false;
}
/// <summary> 检查路径是否匹配忽略关键字(大小写不敏感) </summary>
private bool IsNonNormalImage(string path)
{
string lowerPath = path;
// 内置关键字
foreach (var keyword in BuiltinIgnoreKeywords)
{
if (!string.IsNullOrEmpty(keyword) && lowerPath.Contains(keyword.ToLower()))
return true;
}
// 用户自定义关键字
foreach (var keyword in TextureAutoImportPrefs.GetCustomIgnoreKeywords())
{
if (!string.IsNullOrEmpty(keyword) && lowerPath.Contains(keyword.ToLower()))
return true;
}
return false;
}
/// <summary> 检查是否已勾选任意平台的 Override </summary>
private bool HasPlatformOverride(TextureImporter importer)
{
if (importer == null) return true;
// 任意平台已勾选 Override 则认为用户已手动配置,不干预
var android = importer.GetPlatformTextureSettings("android");
var iphone = importer.GetPlatformTextureSettings("ios");
var standalone = importer.GetPlatformTextureSettings("Standalone");
if (android.overridden) return true;
if (iphone.overridden) return true;
if (standalone.overridden) return true;
return false;
}
/// <summary> 应用纹理压缩设置(仅在未 Override 的平台上设置) </summary>
private void ApplyTextureSettings(TextureImporter importer,
TextureImporterFormat formatTex = TextureImporterFormat.ASTC_8x8)
{
if (importer == null)
{
return;
}
bool changed = false;
// 关闭 Read/Write(节省内存)
if (TextureAutoImportPrefs.DisableReadable && importer.isReadable)
{
importer.isReadable = false;
changed = true;
}
// Android: ASTC_8x8
var android = importer.GetPlatformTextureSettings("Android");
if (!android.overridden)
{
android.overridden = true;
android.format = formatTex;
importer.SetPlatformTextureSettings(android);
changed = true;
}
// iOS: ASTC_8x8
var iphone = importer.GetPlatformTextureSettings("ios");
if (!iphone.overridden)
{
iphone.overridden = true;
iphone.format = formatTex;
importer.SetPlatformTextureSettings(iphone);
changed = true;
}
// Standalone: DXT5
var standalone = importer.GetPlatformTextureSettings("Standalone");
if (!standalone.overridden)
{
standalone.overridden = true;
standalone.format = TextureImporterFormat.DXT5;
importer.SetPlatformTextureSettings(standalone);
changed = true;
}
if (changed)
{
Debug.Log($"[TextureAutoImport] 已处理纹理: {assetPath}");
}
}
}
#endif