Harald 
我用的spine-unity runtime是4.2版本,spine是4.2.43版本。
spine中的角色

游戏中的初始角色分别在场景内和在UI上。
场景中

装备界面中

当替换一个头部装备时:
场景中

装备界面中

再换上武器、盾牌和披风(背部挂件)后:
场景中

装备界面中

最后换上全身护甲时还会导致全部都变一次:
场景中

装备界面中

下面这个是我场景内的Player组件


下面这个是我的UI Player组件

下面是我的换装系统和同步UI换装的代码
using System.Collections.Generic;
using UnityEngine;
using Spine;
using Spine.Unity;
using Spine.Unity.AttachmentTools;
using System.Collections;
using Sirenix.OdinInspector; // Odin 按钮等
/// <summary>
/// ✅ Spine-Unity 4.2 / Straight Alpha 流程(无 PMA / 无 Repack)
/// - Sprite 直接替换 Slot(支持 Region/Mesh),继承模板位姿/网格
/// - Eye、Weapon 槽支持 GrayPaint 着色材质(阈值/反向/倍增)
/// - 兼容 SkeletonAnimation / SkeletonMecanim
/// </summary>
public enum HelmetType { FullCover, Open }
public enum EquipSlot {
    // 装备
    Weapon_R,
    Weapon_L,
    Shield_L,
// 身体
Torso,
Pelvis,
Cloak,
// 右手
Arm_R,
Forearm_R,
Sleeve_R,
Hand_R,
// 左手
Arm_L,
Forearm_L,
Hand_L,
Finger_L,
// 腿部
Leg_L,
Leg_R,
Shin_L,
Shin_R,
// 头部
Helmet,
Hair,
Eye,
Eyebrow,
Mouth,
}
[RequireComponent(typeof(SkeletonRenderer))]
public class SpineEquipSystem : MonoBehaviour {
    [Header("基础设置")]
    [Tooltip("默认皮肤名(一般为 'default'),用于提供模板附件的位姿/网格")]
    public string defaultSkinName = "default";
    [Tooltip("更详细日志(排错用)")]
    public bool logVerbose = false;
[Header("(可选)默认附件材质(Straight Alpha Shader)")]
[Tooltip("不指定则按贴图自动创建基于 Straight Alpha 的材质。建议使用 'Spine/Skeleton (Straight Alpha)' 或兜底 'Sprites/Default'。")]
public Material defaultAttachmentMaterial;
[Header("(可选)材质模板(Straight Alpha Shader)")]
public Material materialTemplate; // 若提供,将基于此材质实例化
[Header("按槽位材质覆盖(可选)")]
[Tooltip("Eye 槽使用的材质模板(例如 Hero Editor/Gray Paint),会在运行时基于此模板实例化并设置 _MainTex。")]
public Material eyeMaterialTemplate;
[Tooltip("Weapon 槽使用的材质模板(例如你的 EquipmentPaint.mat,Shader=Gray Paint)。")]
public Material weaponMaterialTemplate;
// Eye 参数
[Header("Eye 着色参数(仅 Eye 槽生效)")]
[Range(0, 1)] public float eyeSaturationBound = 0.25f;
[Range(1, 2)] public float eyeColorMultiplier = 2f;
[Tooltip("勾上只给高饱和度区域上色(虹膜上色,眼白不变)")]
public bool eyeInverse = true;
// Weapon 参数
[Header("Weapon 着色参数(仅 Weapon 槽生效)")]
[Range(0, 1)] public float weaponSaturationBound = 0.25f;
[Range(1, 2)] public float weaponColorMultiplier = 1f;
[Tooltip("一般设为 false(对高饱和区域不上反向阈值)")]
public bool weaponInverse = false;
// 组件引用
SkeletonAnimation _skeletonAnimation; SkeletonMecanim _skeletonMecanim; SkeletonRenderer _renderer;
// 缓存
readonly Dictionary<Texture, Material> _materialCache = new Dictionary<Texture, Material>();                 // Straight Alpha 共享
readonly Dictionary<string, Material> _slotTexMatCache = new Dictionary<string, Material>();                 // 槽位专用材质缓存
// 槽位名映射
readonly Dictionary<EquipSlot, string> _slotNameMap = new Dictionary<EquipSlot, string> {
    // 装备
    { EquipSlot.Weapon_R, "Weapon_R" },
    { EquipSlot.Weapon_L, "Weapon_L" },
    { EquipSlot.Shield_L, "Shield_L" },
    // 身体
    { EquipSlot.Torso, "Torso" },
    { EquipSlot.Pelvis, "Pelvis" },
    { EquipSlot.Cloak, "Cloak" },
    // 右手
    { EquipSlot.Arm_R, "Arm_R" },
    { EquipSlot.Forearm_R, "Forearm_R" },
    { EquipSlot.Sleeve_R, "Sleeve_R" },
    { EquipSlot.Hand_R, "Hand_R" },
    // 左手
    { EquipSlot.Arm_L, "Arm_L" },
    { EquipSlot.Forearm_L, "Forearm_L" },
    { EquipSlot.Hand_L, "Hand_L" },
    { EquipSlot.Finger_L, "Finger_L" },
    // 腿部
    { EquipSlot.Leg_L, "Leg_L" },
    { EquipSlot.Leg_R, "Leg_R" },
    { EquipSlot.Shin_L, "Shin_L" },
    { EquipSlot.Shin_R, "Shin_R" },
    // 头部
    { EquipSlot.Helmet, "Helmet" },
    { EquipSlot.Hair, "Hair" },
    { EquipSlot.Eye, "Eye" },
    { EquipSlot.Eyebrow, "Eyebrow" },
    { EquipSlot.Mouth, "Mouth" },
};
[Header("Hair Clipping (可选)")]
[SerializeField] string hairClipStartSlot = "HelmetClip";     // 开启裁剪的槽
[SerializeField] string hairClipAttachmentName = "HairClip";  // 裁剪附件名(ClippingAttachment)
[Header("全局美术配置(可选)")]
public HairArtConfig hairDB;
public EyeArtConfig eyeDB;
public ExpressionArtConfig expressionDB;
// —— 表情快照(支持嵌套/多次覆盖)——
private struct ExpressionSnapshot {
    public Attachment eyebrow;
    public Attachment mouth;
    public Attachment eye;
}
private readonly Stack<ExpressionSnapshot> _exprStack = new Stack<ExpressionSnapshot>();
private Coroutine _exprAutoRestoreCo;
// 槽附件备份(裁剪用)
readonly Dictionary<int, Attachment> _slotBackup = new Dictionary<int, Attachment>();
// ===== Odin 按钮 =====
private bool IsPlayingOdin => UnityEngine.Application.isPlaying;
[Button("打印 Eye 材质信息"), EnableIf(nameof(IsPlayingOdin)), GUIColor(0.6f, 0.8f, 1f)]
public void Btn_LogEyeMaterial() => DebugLogSlotMaterial(EquipSlot.Eye);
[Button("清空运行时材质缓存"), EnableIf(nameof(IsPlayingOdin)), GUIColor(1f, 0.8f, 0.4f)]
public void Btn_ClearMatCache() => ClearRuntimeMaterialCache();
// ===== 生命周期 =====
void Awake() {
    _skeletonAnimation = GetComponent<SkeletonAnimation>();
    _skeletonMecanim = GetComponent<SkeletonMecanim>();
    _renderer = GetComponent<SkeletonRenderer>();
}
Skeleton GetSkeleton() {
    if (_skeletonAnimation != null) return _skeletonAnimation.Skeleton;
    if (_skeletonMecanim != null) return _skeletonMecanim.Skeleton;
    return null;
}
Spine.AnimationState GetAnimState() {
    if (_skeletonAnimation != null) return _skeletonAnimation.AnimationState;
    return null;
}
// ===== Straight Alpha 通用材质 =====
Material GetOrCreateStraightAlphaMaterial(Texture tex) {
    if (tex == null) return null;
    if (_materialCache.TryGetValue(tex, out var mat)) return mat;
    Material created;
    if (materialTemplate != null) {
        created = new Material(materialTemplate);
    } else {
        Shader shader = Shader.Find("Spine/Skeleton (Straight Alpha)");
        if (shader == null) shader = Shader.Find("Spine/Skeleton Straight Alpha");
        if (shader == null) shader = Shader.Find("Sprites/Default");
        created = new Material(shader);
    }
    created.mainTexture = tex;
    _materialCache[tex] = created;
    return created;
}
Material GetDefaultMaterial(Texture texFallback = null) {
    if (defaultAttachmentMaterial != null) return defaultAttachmentMaterial;
    if (texFallback != null) return GetOrCreateStraightAlphaMaterial(texFallback);
    return null;
}
// ===== 模板附件查找 =====
Attachment FindBestTemplateAttachment(int slotIndex, string preferAttachmentName = null) {
    var skeleton = GetSkeleton(); if (skeleton == null) return null;
    var sData = skeleton.Data;
    var defaultSkin = sData.FindSkin(defaultSkinName) ?? sData.DefaultSkin;
    if (!string.IsNullOrEmpty(preferAttachmentName) && defaultSkin != null) {
        var at = defaultSkin.GetAttachment(slotIndex, preferAttachmentName);
        if (at != null) { if (logVerbose) Debug.Log($"模板:指定名 {preferAttachmentName}"); return at; }
    }
    var slotName = skeleton.Slots.Items[slotIndex].Data.Name;
    if (defaultSkin != null) {
        var at2 = defaultSkin.GetAttachment(slotIndex, slotName);
        if (at2 != null) { if (logVerbose) Debug.Log($"模板:默认皮肤同名槽 {slotName}"); return at2; }
    }
    if (defaultSkin != null) {
        var entries = new List<Skin.SkinEntry>();
        defaultSkin.GetAttachments(slotIndex, entries);
        foreach (var e in entries) if (e.Attachment != null) { if (logVerbose) Debug.Log($"模板:默认皮肤任意 {e.Name}"); return e.Attachment; }
    }
    var current = skeleton.Slots.Items[slotIndex].Attachment;
    if (current != null) { if (logVerbose) Debug.Log("模板:当前槽附件兜底"); return current; }
    Debug.LogWarning($"找不到模板附件:slotIndex={slotIndex}");
    return null;
}
// ===== 兼容:无 slot 的 remap(不建议外部再用) =====
Attachment RemapFromTemplateAttachment(Attachment template, Sprite sprite) {
    if (template == null || sprite == null) return null;
    var tex = sprite.texture;
    var mat = GetDefaultMaterial(tex) ?? GetOrCreateStraightAlphaMaterial(tex);
    if (template is RegionAttachment templateRegion) {
        return templateRegion.GetRemappedClone(sprite, mat, false, true);
    }
    if (template is MeshAttachment templateMesh) {
        return templateMesh.GetRemappedClone(sprite, mat, false, true);
    }
    Debug.LogWarning($"不支持的模板类型:{template.GetType().Name}");
    return null;
}
// ===== 带 slot 的 remap:Eye/Weapon 走专用材质 =====
Material CreatePaintMaterialForSlot(EquipSlot slot, Texture tex) {
    if (!tex) return null;
    Material created = null;
    if (slot == EquipSlot.Eye && eyeMaterialTemplate) {
        created = new Material(eyeMaterialTemplate);
        created.mainTexture = tex;
        created.SetFloat("_SaturationBound", eyeSaturationBound);
        created.SetFloat("_ColorMultiplier", eyeColorMultiplier);
        created.SetFloat("_Inverse", eyeInverse ? 1f : 0f);
        created.name = $"Eye[{tex.name}] GrayPaint";
    } else if ((slot == EquipSlot.Weapon_R || slot == EquipSlot.Weapon_L) && weaponMaterialTemplate) {
        created = new Material(weaponMaterialTemplate);
        created.mainTexture = tex;
        created.SetFloat("_SaturationBound", weaponSaturationBound);
        created.SetFloat("_ColorMultiplier", weaponColorMultiplier);
        created.SetFloat("_Inverse", weaponInverse ? 1f : 0f);
        created.name = $"Weapon[{slot}|{tex.name}] GrayPaint";
    }
    return created;
}
Material GetMaterialForSlotTexture(EquipSlot slot, Texture tex) {
    if (tex == null) return null;
    // 参数也入 key,防止运行时改了阈值/反向后仍复用旧实例
    string key;
    if (slot == EquipSlot.Eye) {
        key = $"{(int)slot}|{tex.GetInstanceID()}|{(eyeInverse ? 1 : 0)}|{eyeSaturationBound:F3}|{eyeColorMultiplier:F3}";
    } else if (slot == EquipSlot.Weapon_R || slot == EquipSlot.Weapon_L) {
        key = $"{(int)slot}|{tex.GetInstanceID()}|{(weaponInverse ? 1 : 0)}|{weaponSaturationBound:F3}|{weaponColorMultiplier:F3}";
    } else {
        key = $"{(int)slot}|{tex.GetInstanceID()}";
    }
    if (_slotTexMatCache.TryGetValue(key, out var m)) return m;
    // Eye/Weapon 优先走 GrayPaint
    var created = CreatePaintMaterialForSlot(slot, tex);
    if (!created) {
        created = GetDefaultMaterial(tex) ?? GetOrCreateStraightAlphaMaterial(tex);
        if (created != null && created.mainTexture == null)
            created.mainTexture = tex;
    }
    _slotTexMatCache[key] = created;
    return created;
}
Attachment RemapFromTemplateAttachment(EquipSlot slot, Attachment template, Sprite sprite) {
    if (template == null || sprite == null) return null;
    var skeleton = GetSkeleton(); if (skeleton == null) return null;
    var tex = sprite.texture;
    var mat = GetMaterialForSlotTexture(slot, tex);
    if (mat == null) return null;
    if (template is RegionAttachment ra) return ra.GetRemappedClone(sprite, mat, false, true);
    if (template is MeshAttachment ma) return ma.GetRemappedClone(sprite, mat, false, true);
    Debug.LogWarning($"不支持的模板类型:{template.GetType().Name}");
    return null;
}
// ===== Slot/Index 工具 =====
int FindSlotIndexByEnum(EquipSlot slot) {
    var skeleton = GetSkeleton(); if (skeleton == null) return -1;
    if (!_slotNameMap.TryGetValue(slot, out var slotName)) { Debug.LogError($"未映射 EquipSlot: {slot}"); return -1; }
    var slotData = skeleton.Data.FindSlot(slotName);
    int idx = slotData != null ? slotData.Index : -1;
    if (idx < 0) Debug.LogError($"未找到 slot: {slotName}");
    return idx;
}
Slot FindRuntimeSlot(EquipSlot slot) {
    var sk = GetSkeleton(); if (sk == null) return null;
    if (!_slotNameMap.TryGetValue(slot, out var name)) return null;
    return sk.FindSlot(name);
}
// ===== 刷新 =====
void ApplyAndRefresh() {
    var skeleton = GetSkeleton(); if (skeleton == null) return;
    var state = GetAnimState();
    if (state != null) state.Apply(skeleton);
    if (_renderer != null) _renderer.LateUpdate();
}
// ===== 对外 API =====
public bool ReplaceSlotWithSprite(EquipSlot slot, Sprite sprite, string preferTemplateAttachmentName = null) {
    var skeleton = GetSkeleton(); if (skeleton == null || sprite == null) return false;
    int slotIndex = FindSlotIndexByEnum(slot); if (slotIndex < 0) return false;
    // 若该槽有 CustomSlotMaterials 覆盖,先移除,让 remap 后的专用材质生效
    RemoveCustomMaterialForSlot(slot);
    var template = FindBestTemplateAttachment(slotIndex, preferTemplateAttachmentName);
    if (template == null) { Debug.LogError($"缺少模板附件:{slot} / prefer={preferTemplateAttachmentName}"); return false; }
    var remapped = RemapFromTemplateAttachment(slot, template, sprite);
    if (remapped == null) return false;
    skeleton.Slots.Items[slotIndex].Attachment = remapped;
    ApplyAndRefresh();
    return true;
}
public bool ApplyAttachmentFromSkin(EquipSlot slot, string skinName, string attachmentName) {
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    int slotIndex = FindSlotIndexByEnum(slot); if (slotIndex < 0) return false;
    // 从皮肤抽取前也先移除自定义覆盖
    RemoveCustomMaterialForSlot(slot);
    var skin = skeleton.Data.FindSkin(skinName); if (skin == null) { Debug.LogError($"未找到 skin:{skinName}"); return false; }
    var attach = skin.GetAttachment(slotIndex, attachmentName); if (attach == null) { Debug.LogError($"skin[{skinName}] 无附件:{attachmentName}"); return false; }
    skeleton.Slots.Items[slotIndex].Attachment = attach;
    ApplyAndRefresh();
    return true;
}
public bool ApplyMixedSkins(IEnumerable<string> skins) {
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    var data = skeleton.Data; var mix = new Skin("runtime-mix");
    foreach (var name in skins) {
        if (string.IsNullOrEmpty(name)) continue;
        var s = data.FindSkin(name);
        if (s != null) mix.AddSkin(s); else if (logVerbose) Debug.LogWarning($"跳过:未找到 skin [{name}]");
    }
    skeleton.SetSkin(mix);
    skeleton.SetToSetupPose();
    ApplyAndRefresh();
    return true;
}
// 用当前槽位模板把 Sprite 转成 Attachment,再设置到该槽(不立即刷新)
private bool SetSlotFromSprite(EquipSlot slot, Sprite sprite) {
    if (!sprite) return false;
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    // 覆盖存在时先移除
    RemoveCustomMaterialForSlot(slot);
    int slotIndex = FindSlotIndexByEnum(slot);
    if (slotIndex < 0) return false;
    var template = FindBestTemplateAttachment(slotIndex, preferAttachmentName: null);
    if (template == null) { if (logVerbose) Debug.LogWarning($"[Expression] 缺少模板:slot={_slotNameMap[slot]}"); return false; }
    var at = RemapFromTemplateAttachment(slot, template, sprite);
    if (at == null) { if (logVerbose) Debug.LogWarning($"[Expression] Remap 失败:slot={_slotNameMap[slot]} sprite={sprite.name}"); return false; }
    skeleton.Slots.Items[slotIndex].Attachment = at;
    return true;
}
public bool ApplyArmorSet(ArmorArtConfig config) {
    if (config == null) return false;
    var map = new Dictionary<EquipSlot, Sprite>();
    void AddIf(Sprite s, EquipSlot slot) { if (s != null) map[slot] = s; }
    // 身体
    AddIf(config.Torso, EquipSlot.Torso);
    AddIf(config.Pelvis, EquipSlot.Pelvis);
    // 右手
    AddIf(config.Arm_R, EquipSlot.Arm_R);
    AddIf(config.Forearm_R, EquipSlot.Forearm_R);
    AddIf(config.Sleeve_R, EquipSlot.Sleeve_R);
    AddIf(config.Hand_R, EquipSlot.Hand_R);
    // 左手
    AddIf(config.Arm_L, EquipSlot.Arm_L);
    AddIf(config.Forearm_L, EquipSlot.Forearm_L);
    AddIf(config.Hand_L, EquipSlot.Hand_L);
    AddIf(config.Finger_L, EquipSlot.Finger_L);
    // 腿部(按你现有映射逻辑)
    AddIf(config.Leg_L, EquipSlot.Shin_L);
    AddIf(config.Leg_R, EquipSlot.Shin_R);
    AddIf(config.Shin_L, EquipSlot.Shin_L);
    AddIf(config.Shin_R, EquipSlot.Shin_R);
    return SetMultipleSlotsSprites(map);
}
/// <summary>批量替换多个槽位的 Sprite(一次刷新)。</summary>
public bool SetMultipleSlotsSprites(Dictionary<EquipSlot, Sprite> map) {
    var skeleton = GetSkeleton();
    if (skeleton == null || map == null || map.Count == 0) return false;
    var plan = new Dictionary<int, Attachment>();
    bool any = false;
    foreach (var kv in map) {
        var slot = kv.Key;
        var sprite = kv.Value;
        if (sprite == null) continue;
        // 先移除 Eye/Weapon 的覆盖,避免覆盖抢材质
        RemoveCustomMaterialForSlot(slot);
        int slotIndex = FindSlotIndexByEnum(slot);
        if (slotIndex < 0) { if (logVerbose) Debug.LogWarning($"[BatchEquip] 未找到槽位:{slot}"); continue; }
        var template = FindBestTemplateAttachment(slotIndex, preferAttachmentName: null);
        if (template == null) { if (logVerbose) Debug.LogWarning($"[BatchEquip] 模板缺失:slot={_slotNameMap[slot]}"); continue; }
        var newAttach = RemapFromTemplateAttachment(slot, template, sprite);
        if (newAttach == null) { if (logVerbose) Debug.LogWarning($"[BatchEquip] Remap 失败:slot={_slotNameMap[slot]} sprite={sprite.name}"); continue; }
        plan[slotIndex] = newAttach;
        any = true;
    }
    if (!any) return false;
    foreach (var p in plan) skeleton.Slots.Items[p.Key].Attachment = p.Value;
    ApplyAndRefresh();
    return true;
}
public void SetHelmetType(HelmetType type) {
    if (type == HelmetType.FullCover) EnableHairClip(true);
    else EnableHairClip(false);
}
// 头发裁剪
public bool EnableHairClip(bool enabled) {
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    var sData = skeleton.Data;
    var defaultSkin = sData.FindSkin(defaultSkinName) ?? sData.DefaultSkin;
    var slotData = sData.FindSlot(hairClipStartSlot);
    if (slotData == null) { Debug.LogWarning($"[HairClip] 未找到槽 {hairClipStartSlot}"); return false; }
    int idx = slotData.Index;
    var slot = skeleton.Slots.Items[idx];
    var current = slot.Attachment;
    bool IsClip(Attachment at) => at is ClippingAttachment || (at != null && at.Name == hairClipAttachmentName);
    if (enabled) {
        var clip = defaultSkin?.GetAttachment(idx, hairClipAttachmentName);
        if (clip == null) { Debug.LogWarning($"[HairClip] 未找到附件 {hairClipAttachmentName}"); return false; }
        if (!_slotBackup.ContainsKey(idx) && !IsClip(current)) _slotBackup[idx] = current;
        slot.Attachment = clip;
    } else {
        if (_slotBackup.TryGetValue(idx, out var prev) && prev != null && !IsClip(prev)) slot.Attachment = prev;
        else slot.Attachment = null;
        if (_slotBackup.ContainsKey(idx)) _slotBackup.Remove(idx);
    }
    ApplyAndRefresh();
    return true;
}
// ===== 名称归一 =====
public static string NormalizeNameStatic(string s) {
    if (string.IsNullOrEmpty(s)) return string.Empty;
    return s.Trim().ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
}
// ===== 外部配置应用 =====
public bool ApplyWeaponConfig(WeaponConfig cfg) => cfg && cfg.ApplyTo(this);
public bool ApplyHelmetConfig(HelmetConfig cfg) => cfg && cfg.ApplyTo(this);
public bool ApplyCloakConfig(CloakConfig cfg) => cfg && cfg.ApplyTo(this);
// ===== 快捷接口 =====
public bool SetHairByName(string name) {
    if (!hairDB) return false;
    if (!hairDB.TryGet(name, out var sp) || !sp) return false;
    return ReplaceSlotWithSprite(EquipSlot.Hair, sp);
}
public bool SetEyeByName(string name) {
    if (!eyeDB) return false;
    if (!eyeDB.TryGet(name, out var sp) || !sp) return false;
    return ReplaceSlotWithSprite(EquipSlot.Eye, sp);
}
/// <summary>对任意槽进行乘色。Eye/Weapon 若还在用原皮肤 atlas,会自动套 GrayPaint 槽位覆盖,仅对目标像素(如虹膜)上色。</summary>
public bool SetSlotColor(EquipSlot slot, Color color) {
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    if (!_slotNameMap.TryGetValue(slot, out var slotName)) return false;
    var sl = skeleton.FindSlot(slotName);
    if (sl == null) return false;
    // Eye/Weapon:若仍使用原始 atlas 材质,则先套一次槽位覆盖(GrayPaint + atlas 主纹理)
    if ((slot == EquipSlot.Eye && eyeMaterialTemplate) ||
        ((slot == EquipSlot.Weapon_R || slot == EquipSlot.Weapon_L) && weaponMaterialTemplate)) {
        EnsurePaintOnOriginal(slot);
    }
    sl.SetColor(color);
    ApplyAndRefresh();
    return true;
}
public bool ResetSlotColor(EquipSlot slot) => SetSlotColor(slot, Color.white);
// ====== 槽位覆盖:在不 remap 的情况下给 Eye/Weapon 套 GrayPaint ======
private Material GetAttachmentMaterial(Attachment at) {
    if (at == null) return null;
    if (at is RegionAttachment ra) {
        var reg = ra.Region as AtlasRegion; var page = reg != null ? reg.page : null;
        return page != null ? page.rendererObject as Material : null;
    }
    if (at is MeshAttachment ma) {
        var reg = ma.Region as AtlasRegion; var page = reg != null ? reg.page : null;
        return page != null ? page.rendererObject as Material : null;
    }
    return null;
}
private bool EnsurePaintOnOriginal(EquipSlot slot) {
    if (_renderer == null) return false;
    var sk = GetSkeleton(); if (sk == null) return false;
    var rtSlot = FindRuntimeSlot(slot); if (rtSlot == null) return false;
    var at = rtSlot.Attachment; if (at == null) return false;
    // 当前页材质(Spine/Skeleton)拿 atlas 纹理
    var pageMat = GetAttachmentMaterial(at); if (!pageMat || !pageMat.mainTexture) return false;
    // 已有覆盖且 Shader 匹配则不用重复设
    if (_renderer.CustomSlotMaterials != null &&
        _renderer.CustomSlotMaterials.TryGetValue(rtSlot, out var cur) && cur) {
        // 若已是对应模板的 Shader,可以直接返回
        var wantedShader = (slot == EquipSlot.Eye ? eyeMaterialTemplate?.shader : weaponMaterialTemplate?.shader);
        if (wantedShader && cur.shader == wantedShader) return true;
    }
    // 用 atlas 纹理创建一张该槽的 GrayPaint
    var overrideMat = CreatePaintMaterialForSlot(slot, pageMat.mainTexture);
    if (!overrideMat) return false;
    _renderer.CustomSlotMaterials[rtSlot] = overrideMat;
    _renderer.LateUpdate();
    return true;
}
private void RemoveCustomMaterialForSlot(EquipSlot slot) {
    if (_renderer == null) return;
    var rtSlot = FindRuntimeSlot(slot);
    if (rtSlot != null && _renderer.CustomSlotMaterials.ContainsKey(rtSlot)) {
        _renderer.CustomSlotMaterials.Remove(rtSlot);
    }
}
// ===== 调试输出 =====
[ContextMenu("Debug/Log Eye Material")]
public void DebugLogEyeMaterial() => DebugLogSlotMaterial(EquipSlot.Eye);
static void LogMatParams(string tag, EquipSlot slot, Material m)
{
    if (!m) { Debug.Log($"[MatDebug:{slot}] ({tag}) material = null"); return; }
    var texName = m.mainTexture ? m.mainTexture.name : "null";
    float inv = m.HasProperty("_Inverse") ? m.GetFloat("_Inverse") : -1f;
    float sb = m.HasProperty("_SaturationBound") ? m.GetFloat("_SaturationBound") : -1f;
    float mul = m.HasProperty("_ColorMultiplier") ? m.GetFloat("_ColorMultiplier") : -1f;
    Debug.Log($"[MatDebug:{slot}] ({tag}) shader='{m.shader.name}', tex='{texName}', _Inv={inv}, _Sat={sb}, _Mul={mul}");
}
public void DebugLogSlotMaterial(EquipSlot slot)
{
    var sk = GetSkeleton();
    if (sk == null) { Debug.LogWarning("[MatDebug] skeleton null"); return; }
    if (!_slotNameMap.TryGetValue(slot, out var slotName))
    {
        Debug.LogWarning("[MatDebug] no slot"); return;
    }
    var slotObj = sk.FindSlot(slotName);
    if (slotObj == null) { Debug.LogWarning("[MatDebug] slot not found"); return; }
    var at = slotObj.Attachment;
    if (at == null) { Debug.LogWarning("[MatDebug] attachment = null"); return; }
    // 1) 先看是否有 CustomSlotMaterials 覆盖
    if (_renderer != null &&
        _renderer.CustomSlotMaterials != null &&
        _renderer.CustomSlotMaterials.TryGetValue(slotObj, out var customMat) &&
        customMat)
    {
        LogMatParams("Custom", slot, customMat);
        return;
    }
    // 2) 否则用附件所属 AtlasPage 的材质
    var pageMat = GetAttachmentMaterial(at);
    if (!pageMat)
    {
        Debug.LogWarning($"[MatDebug:{slot}] material not found from AtlasRegion.page.rendererObject");
        return;
    }
    LogMatParams("Page", slot, pageMat);
}
[ContextMenu("Debug/Clear Runtime Material Cache")]
public void ClearRuntimeMaterialCache() {
    _slotTexMatCache.Clear();
    _materialCache.Clear();
    Debug.Log("[SpineEquipSystem] Cleared runtime material caches.");
}
// ===== 表情(含 Eye)=====
public bool ApplyExpression(string name, bool rememberPrevious = true) {
    if (!expressionDB) return false;
    if (!expressionDB.TryGet(name, out var browSprite, out var mouthSprite, out var eyeSprite)) return false;
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    if (rememberPrevious) {
        _exprStack.Push(new ExpressionSnapshot {
            eyebrow = GetCurrentAttachment(EquipSlot.Eyebrow),
            mouth   = GetCurrentAttachment(EquipSlot.Mouth),
            eye     = GetCurrentAttachment(EquipSlot.Eye),
        });
    }
    bool changed = false;
    if (browSprite)  changed |= SetSlotFromSprite(EquipSlot.Eyebrow, browSprite);
    if (mouthSprite) changed |= SetSlotFromSprite(EquipSlot.Mouth,   mouthSprite);
    if (eyeSprite)   changed |= SetSlotFromSprite(EquipSlot.Eye,     eyeSprite);
    if (changed) ApplyAndRefresh();
    return changed;
}
public bool RestoreExpression() {
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    if (_exprStack.Count > 0) {
        var snap = _exprStack.Pop();
        bool changed = false;
        changed |= SetSlotAttachment(EquipSlot.Eyebrow, snap.eyebrow);
        changed |= SetSlotAttachment(EquipSlot.Mouth, snap.mouth);
        changed |= SetSlotAttachment(EquipSlot.Eye, snap.eye);
        if (changed) ApplyAndRefresh();
        return changed;
    } else {
        return RestoreExpressionFromDefaultSkin();
    }
}
public void ApplyExpressionForSeconds(string name, float seconds) {
    if (_exprAutoRestoreCo != null) { StopCoroutine(_exprAutoRestoreCo); _exprAutoRestoreCo = null; }
    if (ApplyExpression(name, rememberPrevious: true))
        _exprAutoRestoreCo = StartCoroutine(_CoRestoreExpressionAfter(seconds));
}
public void CancelPendingExpressionRestore() {
    if (_exprAutoRestoreCo != null) { StopCoroutine(_exprAutoRestoreCo); _exprAutoRestoreCo = null; }
}
private IEnumerator _CoRestoreExpressionAfter(float seconds) {
    yield return new WaitForSeconds(seconds);
    RestoreExpression();
    _exprAutoRestoreCo = null;
}
private Attachment GetCurrentAttachment(EquipSlot slot) {
    var skeleton = GetSkeleton(); if (skeleton == null) return null;
    int idx = FindSlotIndexByEnum(slot); if (idx < 0) return null;
    return skeleton.Slots.Items[idx].Attachment;
}
private bool SetSlotAttachment(EquipSlot slot, Attachment attachment) {
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    int idx = FindSlotIndexByEnum(slot); if (idx < 0) return false;
    skeleton.Slots.Items[idx].Attachment = attachment;
    return true;
}
private bool RestoreExpressionFromDefaultSkin() {
    var skeleton = GetSkeleton(); if (skeleton == null) return false;
    var sData = skeleton.Data;
    var defaultSkin = sData.FindSkin(defaultSkinName) ?? sData.DefaultSkin;
    bool changed = false;
    changed |= TryRestoreFromSkin(defaultSkin, EquipSlot.Eyebrow);
    changed |= TryRestoreFromSkin(defaultSkin, EquipSlot.Mouth);
    changed |= TryRestoreFromSkin(defaultSkin, EquipSlot.Eye);
    if (changed) ApplyAndRefresh();
    return changed;
}
private bool TryRestoreFromSkin(Skin skin, EquipSlot slot) {
    if (skin == null) return false;
    int idx = FindSlotIndexByEnum(slot); if (idx < 0) return false;
    var slotName = _slotNameMap[slot];
    var at = skin.GetAttachment(idx, slotName);
    if (at == null) {
        var entries = new List<Skin.SkinEntry>();
        skin.GetAttachments(idx, entries);
        foreach (var e in entries) { if (e.Attachment != null) { at = e.Attachment; break; } }
    }
    if (at == null) return false;
    return SetSlotAttachment(slot, at);
}
}
using System.Collections.Generic;
using UnityEngine;
using Spine;
using Spine.Unity;
// 避免与 UnityEngine.AnimationState 混淆
using AnimationState = Spine.AnimationState;
/// <summary>
/// 方案A:把“世界骨架当前实际渲染所用的**贴图**”全部映射到 UI:
/// - 收集来源:
///   (1) 世界骨架每个槽当前 Attachment 的 page 材质 → mainTexture
///   (2) MeshRenderer.sharedMaterials(兜底)
///   (3) 世界端 CustomSlotMaterials(若你给 Eye/Weapon 套了槽级材质)
/// - 为每张贴图创建 UI 材质(同参数,但 Shader 切到 Spine/SkeletonGraphic)
/// - 把 Texture → UIMaterial 写入 uiGraphic.CustomMaterialOverride
/// 这样即便某个部位是运行时 Remap 出来的独立 Sprite 贴图,也能正确显示。
/// </summary>
[DisallowMultipleComponent]
public class SpineUIMirror : MonoBehaviour
{
    [Header("源(世界)")]
    public SkeletonRenderer worldRenderer;     // 场景主角上的 SkeletonRenderer(与 SkeletonAnimation 在同物体)
    public SkeletonAnimation worldAnim;        // (可选)用于镜像当前 Track 动画
[Header("目标(UI)")]
public SkeletonGraphic uiGraphic;          // UI 里的 SkeletonGraphic
[Header("联动(可选)")]
public EquipmentManager equipmentManager;  // 拖你的 EquipmentManager 进来,自动在 OnEquipmentChanged 时同步
public bool mirrorAnimations = true;       // 是否复制 Track0/1 动画名与时间
// 缓存:世界材质 => UI 材质
private readonly Dictionary<Material, Material> _matWorld2UI = new();
void Reset() {
    uiGraphic = GetComponent<SkeletonGraphic>();
}
void OnEnable() {
    if (equipmentManager != null)
        equipmentManager.OnEquipmentChanged += ScheduleSync; // 用“下一帧”同步更稳
}
void OnDisable() {
    if (equipmentManager != null)
        equipmentManager.OnEquipmentChanged -= ScheduleSync;
}
/// <summary>更稳的做法:排队到下一帧尾再同步,避免你换装代码和渲染器还没刷新完。</summary>
public void ScheduleSync() => StartCoroutine(_CoSyncEndOfFrame());
System.Collections.IEnumerator _CoSyncEndOfFrame() { yield return new WaitForEndOfFrame(); SyncNow(); }
Skeleton GetWorldSkeleton() {
    if (worldAnim != null) return worldAnim.Skeleton;
    if (worldRenderer != null) return worldRenderer.Skeleton;
    return null;
}
AnimationState GetWorldState() {
    if (worldAnim != null) return worldAnim.AnimationState;
    return null;
}
/// <summary>主入口:复制 Skin/Attachment/颜色;建立 Texture→UI材质;可选复制 Track 动画;最后刷新 UI。</summary>
public void SyncNow() {
    if (!uiGraphic || uiGraphic.Skeleton == null) return;
    var srcSk = GetWorldSkeleton();
    if (srcSk == null) return;
    // 1) 复制皮肤 + 槽附件与颜色
    var dstSk = uiGraphic.Skeleton;
    dstSk.SetSkin(srcSk.Skin);
    dstSk.SetSlotsToSetupPose();
    // 为稳妥起见,逐槽复制“当前附件与颜色”
    var srcSlots = srcSk.Slots.Items;
    for (int i = 0; i < srcSlots.Length; i++) {
        var sSrc = srcSlots[i];
        var sDst = dstSk.FindSlot(sSrc.Data.Name);
        if (sDst == null) continue;
        sDst.Attachment = sSrc.Attachment;
        sDst.R = sSrc.R; sDst.G = sSrc.G; sDst.B = sSrc.B; sDst.A = sSrc.A;
    }
    // 2) 重建 UI 侧 CustomMaterialOverride(关键!)
    BuildTextureOverrideFromWorld(srcSk);
    // 3)(可选)镜像 Track0/1 动画
    if (mirrorAnimations) CopyTracks(GetWorldState(), uiGraphic.AnimationState);
    // 4) 应用 & 刷新
    uiGraphic.AnimationState?.Apply(dstSk);
    uiGraphic.LateUpdate();
}
// —— 收集“世界端正在实际使用”的每一张贴图,并建立 Texture→UI材质 的映射
void BuildTextureOverrideFromWorld(Skeleton srcSk) {
    var dict = uiGraphic.CustomMaterialOverride;
    if (dict == null) return;
    dict.Clear();
    var seen = new HashSet<Texture>();
    // (a) 从世界骨架的“当前附件”收集
    var draw = srcSk.DrawOrder.Items;
    for (int i = 0; i < draw.Length; i++) {
        var slot = draw[i];
        var at = slot.Attachment;
        var pageMat = GetAttachmentPageMaterial(at);
        TryAdd(pageMat);
    }
    // (b) 兜底:世界 MeshRenderer.sharedMaterials 也扫一遍
    var mr = worldRenderer ? worldRenderer.GetComponent<MeshRenderer>() : null;
    var shared = mr ? mr.sharedMaterials : null;
    if (shared != null) foreach (var m in shared) TryAdd(m);
    // (c) 若世界端启用了“槽位材质覆盖”(如 Eye/Weapon 灰度着色),也把这些材质的贴图映射过去
    var slotMats = worldRenderer ? worldRenderer.CustomSlotMaterials : null;
    if (slotMats != null && slotMats.Count > 0) {
        foreach (var kv in slotMats) TryAdd(kv.Value);
    }
    void TryAdd(Material worldMat) {
        if (!worldMat) return;
        var tex = worldMat.mainTexture;
        if (!tex) return;
        if (!seen.Add(tex)) return; // 去重
        var uiMat = GetOrCreateUIMatFor(worldMat);
        if (uiMat != null) dict[tex] = uiMat;      // 注意:key=Texture
    }
}
// —— 从 Attachment 拿到它所在 atlas page 的材质(Spine-Unity 4.2 通用写法)
static Material GetAttachmentPageMaterial(Attachment at) {
    if (at == null) return null;
    if (at is RegionAttachment ra) {
        var reg = ra.Region as AtlasRegion; var page = reg != null ? reg.page : null;
        return page != null ? page.rendererObject as Material : null;
    }
    if (at is MeshAttachment ma) {
        var reg = ma.Region as AtlasRegion; var page = reg != null ? reg.page : null;
        return page != null ? page.rendererObject as Material : null;
    }
    return null;
}
// —— 把“世界材质”复制成“UI材质”:Shader 切到 Spine/SkeletonGraphic,并且沿用 mainTexture/参数
Material GetOrCreateUIMatFor(Material worldMat) {
    if (!worldMat) return null;
    if (_matWorld2UI.TryGetValue(worldMat, out var cached)) return cached;
    // 选择 UI 侧 Shader:常规用 Spine/SkeletonGraphic;如果你项目里有 Tint Black,需要换成带 Tint Black 的 UI Shader
    Shader uiShader = Shader.Find("Spine/SkeletonGraphic");
    var m = new Material(worldMat);
    if (uiShader) m.shader = uiShader;
    // 主纹理兜底
    if (!m.mainTexture && worldMat.mainTexture) m.mainTexture = worldMat.mainTexture;
    _matWorld2UI[worldMat] = m;
    return m;
}
void CopyTracks(AnimationState src, AnimationState dst) {
    if (src == null || dst == null) return;
    for (int track = 0; track <= 1; track++) {
        var e = src.GetCurrent(track);
        if (e == null || e.Animation == null) { dst.SetEmptyAnimation(track, 0f); continue; }
        var ne = dst.SetAnimation(track, e.Animation.Name, e.Loop);
        if (ne != null) { ne.TrackTime = e.TrackTime; ne.TimeScale = e.TimeScale; ne.MixDuration = e.MixDuration; }
    }
}
// ====== 调试工具(强烈建议先跑一遍看日志)======
[ContextMenu("Debug/打印当前贴图映射(世界→UI)")]
void DebugDumpTextureOverride() {
    var srcSk = GetWorldSkeleton();
    if (srcSk == null || uiGraphic == null) { Debug.LogWarning("[SpineUIMirror] 无法调试:缺少引用"); return; }
    var dict = uiGraphic.CustomMaterialOverride;
    if (dict == null) { Debug.LogWarning("[SpineUIMirror] uiGraphic.CustomMaterialOverride == null"); return; }
    var lines = new System.Text.StringBuilder();
    lines.AppendLine("=== 世界→UI 贴图映射检查 ===");
    var draw = srcSk.DrawOrder.Items;
    var seen = new HashSet<Texture>();
    for (int i = 0; i < draw.Length; i++) {
        var slot = draw[i];
        var at = slot.Attachment;
        var pageMat = GetAttachmentPageMaterial(at);
        var tex = pageMat ? pageMat.mainTexture : null;
        string texName = tex ? tex.name : "null";
        string slotName = slot.Data.Name;
        bool mapped = tex && dict.ContainsKey(tex);
        lines.AppendLine($"{i:00}. Slot={slotName}, At={at?.Name ?? "null"}, Tex={texName}, Mapped={mapped}");
        if (tex && seen.Add(tex) && mapped) {
            var uimat = dict[tex];
            lines.AppendLine($"     → UI Mat: {uimat?.shader?.name} | mainTex={uimat?.mainTexture?.name}");
        }
    }
    // 额外列出世界端 CustomSlotMaterials
    var slotMats = worldRenderer ? worldRenderer.CustomSlotMaterials : null;
    if (slotMats != null && slotMats.Count > 0) {
        lines.AppendLine("—— 世界端 CustomSlotMaterials ——");
        foreach (var kv in slotMats) {
            var sname = kv.Key?.Data?.Name ?? "null-slot";
            var tex = kv.Value ? kv.Value.mainTexture : null;
            lines.AppendLine($"   Slot={sname}, Tex={tex?.name ?? "null"}, UI-Mapped={(tex && dict.ContainsKey(tex))}");
        }
    }
    Debug.Log(lines.ToString());
}
}