Unity呼吸效果

让空物体下的多个图片模型做周期性"呼吸"(正弦缩放,可选上下浮动 + 透明度明暗)。

 

image

 

 

 

代码挂载到空物体下面就能用,模型是空物体的子物体

代码如下:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// 呼吸效果:让空物体下的多个图片模型做周期性"呼吸"(正弦缩放,可选上下浮动 + 透明度明暗)。
/// 两种模式:
///   Parent  = 整体呼吸,缩放空物体自身,所有图片作为整体一起胀缩(布局跟着呼吸)
///   Children = 独立呼吸,每个直接子物体围绕自身缩放,布局不动,可用相位差做出依次起伏的波浪感
/// 透明度支持 3D Renderer(材质属性块,不破坏共享材质)、SpriteRenderer、UGUI Graphic(Image/Text)。
/// 不依赖 DOTween,纯 Update 实现,支持随时暂停/继续/停止复位。
/// 用法:挂到空物体上;Play() 开始;Pause()/Resume()/TogglePause() 控制;ResetToBase() 恢复初始状态。
/// 注意:只收集"直接子物体";若运行时增删子物体或修改相位差,调用 RefreshCache() 重新缓存。
/// 测试按键:B = 开始/停止切换,空格 = 暂停/继续,R = 复位(接入项目后可删除 Update 里的按键段)。
/// </summary>
public class BreathEffect : MonoBehaviour
{
    public enum BreathMode { Parent, Children }

    [Header("呼吸模式")]
    [Tooltip("Parent = 缩放空物体自身(整体呼吸);Children = 每个图片模型独立呼吸(布局不动)")]
    public BreathMode mode = BreathMode.Children;

    [Tooltip("独立呼吸时相邻子物体的相位差(秒),0 = 全部同步呼吸;整体呼吸无效")]
    public float phaseStagger = 0.15f;

    [Header("缩放呼吸")]
    [Tooltip("一次完整呼吸时长(秒),越小越快")]
    public float duration = 2.4f;

    [Tooltip("缩放幅度,0.08 = 在初始大小基础上 ±8%(以各自缓存到的初始缩放为基准)")]
    public float scaleAmplitude = 0.08f;

    [Header("上下浮动(可选,沿世界 Y 轴)")]
    public bool enableFloat = true;

    [Tooltip("浮动幅度(米)。整体呼吸时浮动空物体自身,独立呼吸时各图片各自浮动")]
    public float floatAmplitude = 0.04f;

    [Header("透明度呼吸(可选,变暗再亮回)")]
    public bool enableFade = false;

    [Tooltip("最暗时透明度 = 初始透明度 × 该值")]
    [Range(0f, 1f)] public float minAlphaScale = 0.5f;

    [Header("其他")]
    [Tooltip("Start 时自动开始呼吸")]
    public bool playOnStart = true;

    // 每个直接子物体的呼吸数据(初始状态缓存,改参数不需要重新拖场景)
    private class BreathItem
    {
        public Transform Tr;
        public Vector3 BaseScale;      // 初始本地缩放
        public Vector3 BaseLocalPos;   // 初始本地位置
        public float Phase;            // 相位偏移(秒)
        public SpriteRenderer Sprite;  // 2D 图片(直接改 color 最干净)
        public Renderer Renderer;      // 3D 图片模型(用材质属性块,不实例化材质)
        public int ColorProp;          // 材质颜色属性 ID,-1 = 材质不支持改色
        public Graphic Graphic;        // UGUI Image/Text
        public Color BaseColor;        // 初始颜色(含透明度)
    }

    private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); // URP/Lit
    private static readonly int ColorId = Shader.PropertyToID("_Color");         // Built-in

    private readonly List<BreathItem> _items = new List<BreathItem>();
    private Vector3 _baseSelfScale;
    private Vector3 _baseSelfPos;
    private MaterialPropertyBlock _mpb;
    private float _elapsed;
    private bool _isPlaying;

    private void Awake()
    {
        _mpb = new MaterialPropertyBlock();
        _baseSelfScale = transform.localScale;
        _baseSelfPos = transform.localPosition;
        CacheChildren();
    }

    private void OnEnable()
    {
        if (playOnStart) Play();
    }
    private void Update()
    {

        if (!_isPlaying) return;

        _elapsed += Time.deltaTime;   // 暂停时不累计,恢复后从当前进度接着呼吸

        if (mode == BreathMode.Parent)
        {
            float s = Mathf.Sin(BreathT(0f));
            transform.localScale = _baseSelfScale * (1f + scaleAmplitude * s);
            if (enableFloat)
            {
                transform.localPosition = _baseSelfPos + Vector3.up * (floatAmplitude * s);
            }
            if (enableFade)
            {
                for (int i = 0; i < _items.Count; i++)
                {
                    ApplyAlpha(_items[i], 0.5f + 0.5f * s);   // 整体明暗同步
                }
            }
        }
        else
        {
            for (int i = 0; i < _items.Count; i++)
            {
                var item = _items[i];
                float s = Mathf.Sin(BreathT(item.Phase));
                item.Tr.localScale = item.BaseScale * (1f + scaleAmplitude * s);
                if (enableFloat)
                {
                    item.Tr.localPosition = item.BaseLocalPos + Vector3.up * (floatAmplitude * s);
                }
                if (enableFade)
                {
                    ApplyAlpha(item, 0.5f + 0.5f * s);
                }
            }
        }
    }

    /// <summary>开始/重新开始呼吸(时间归零,从初始状态进入)</summary>
    public void Play()
    {
        _elapsed = 0f;
        _isPlaying = true;
    }

    /// <summary>暂停(冻结在当前呼吸进度)</summary>
    public void Pause()
    {
        _isPlaying = false;
    }

    /// <summary>从暂停处继续(不会从头开始)</summary>
    public void Resume()
    {
        _isPlaying = true;
    }

    /// <summary>暂停/继续一键切换</summary>
    public void TogglePause()
    {
        _isPlaying = !_isPlaying;
    }

    /// <summary>停止并恢复到缓存时的初始缩放/位置/颜色</summary>
    public void StopAndReset()
    {
        _isPlaying = false;
        transform.localScale = _baseSelfScale;
        transform.localPosition = _baseSelfPos;
        for (int i = 0; i < _items.Count; i++)
        {
            var item = _items[i];
            item.Tr.localScale = item.BaseScale;
            item.Tr.localPosition = item.BaseLocalPos;
            if (item.Sprite != null) item.Sprite.color = item.BaseColor;
            else if (item.Graphic != null) item.Graphic.color = item.BaseColor;
            else if (item.Renderer != null && item.ColorProp >= 0) item.Renderer.SetPropertyBlock(null);   // 清除属性块覆盖,还原共享材质本色
        }
    }

    /// <summary>重新收集直接子物体并缓存初始状态(运行时增删子物体/改相位差后调用)</summary>
    public void RefreshCache()
    {
        StopAndReset();
        _baseSelfScale = transform.localScale;
        _baseSelfPos = transform.localPosition;
        CacheChildren();
    }

    // 按索引缓存所有直接子物体的初始状态与颜色通道
    private void CacheChildren()
    {
        _items.Clear();
        for (int i = 0; i < transform.childCount; i++)
        {
            var child = transform.GetChild(i);
            var item = new BreathItem
            {
                Tr = child,
                BaseScale = child.localScale,
                BaseLocalPos = child.localPosition,
                Phase = i * phaseStagger,   // 依次错峰,产生波浪式呼吸
                Sprite = child.GetComponent<SpriteRenderer>(),
                Graphic = child.GetComponent<Graphic>()
            };

            if (item.Sprite != null)
            {
                item.BaseColor = item.Sprite.color;
            }
            else if (item.Graphic != null)
            {
                item.BaseColor = item.Graphic.color;
            }
            else
            {
                item.Renderer = child.GetComponent<Renderer>();
                if (item.Renderer != null)
                {
                    var mat = item.Renderer.sharedMaterial;
                    if (mat.HasProperty(BaseColorId)) item.ColorProp = BaseColorId;
                    else if (mat.HasProperty(ColorId)) item.ColorProp = ColorId;
                    else item.ColorProp = -1;
                    if (item.ColorProp >= 0) item.BaseColor = mat.GetColor(item.ColorProp);
                }
            }
            _items.Add(item);
        }
    }

    // 带相位偏移的呼吸正弦,-1 ~ 1(吸气=变大变亮,呼气=变小变暗)
    private float BreathT(float phase)
    {
        return (Mathf.PI * 2f / duration) * (_elapsed + phase);
    }

    // t01:0 = 最暗,1 = 正常;只压暗到初始透明度的一定比例,不会比初始更亮
    private void ApplyAlpha(BreathItem item, float t01)
    {
        Color c = item.BaseColor;
        c.a = Mathf.Lerp(c.a * minAlphaScale, c.a, t01);

        if (item.Sprite != null)
        {
            item.Sprite.color = c;
        }
        else if (item.Graphic != null)
        {
            item.Graphic.color = c;
        }
        else if (item.Renderer != null && item.ColorProp >= 0)
        {
            // 材质属性块:改色不实例化材质,多个图片共用同一材质也不会被逐个拷贝
            item.Renderer.GetPropertyBlock(_mpb);
            _mpb.SetColor(item.ColorProp, c);
            item.Renderer.SetPropertyBlock(_mpb);
        }
    }

}

 

 
posted @ 2026-09-16 11:23  剑起苍穹  阅读(14)  评论(0)    收藏  举报
/*鼠标点击特效*/