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());
}
}