spine绑定骨骼运动工具&spine纹理压缩自动设置工具

背景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
相关推荐
惊鸿醉5 小时前
Unity 实战:用讯飞 WebAPI 做一个“说普通话、播方言“的语音程序
unity·c#·游戏引擎·语音识别
电脑玩家柒柒5 小时前
FreeToken:在游戏 PC 上跑 290B+ 前沿 MoE——带宽自适应执行的本地推理新范式
游戏
略略略咯咯5 小时前
C#Unity
开发语言·unity·c#
小贺儿开发11 小时前
Unity 局域网遥控幻灯片展示工具 1.0
unity·网络通信·工具·ppt·控制·网页·互动
朋友圈自动点赞工具12 小时前
NAS游戏库自动下载新方案,Questarr部署教程
服务器·数据库·游戏·科技资讯
小尘要自信13 小时前
2026手机远控电脑横测:CMD、游戏按键、144帧...ToDesk/向日葵/RustDesk跨设备处理任务
游戏·智能手机·电脑
yangmu320313 小时前
《天国拯救2》MOD整合包安装教程与深度解析
游戏
政安晨16 小时前
政安晨【人工智能随笔】— 从像素到星际:游戏如何塑造了现代AI的二十年演进史 (读DeepMind的EVE宇宙AI研究有感)
人工智能·游戏·ai·智能体·deepmind·人工智能与游戏·智能体与游戏
发量惊人的中年网工16 小时前
游戏开服就被DDoS攻击怎么办?开服7分钟服务器被打穿的复盘与防护清单
运维·服务器·网络·安全·游戏