Unity Shader 简单实现伤害数字的显示

✅ Unity Shader 实现伤害数字展示(支持图集 + Instancing 动画)

Unity 版本:2022.3.62f2c1
核心目标:提升性能


📌 背景 & 技术需求

传统用
TextMeshPro

UI
做飘字有几个性能痛点:

方法 缺点
TextMeshPro 每个数字生成一次 mesh、GC频繁、批次高

本文提供一种更“性能友好且高度可定制”的做法:

✔ 使用数字散图(美术可自定义形状)
✔ TexturePacker 自动生成图集与UV信息,无需手动切割
✔ 使用 Shader + GPU Instancing 驱动飘字动画
✔ 多位数字自动排版,支持大小写字母(如 K、M、Q)


🧰 资源准备

1. 美术数字资源

数字使用散图,高度统一,宽度根据字符自然宽度即可支持数字、字母、符号,例如:
0–9
,
.
,
K
,
M
,
Q

2. TexturePacker 打图集

配置要求:

配置项 说明
Trim Mode None(无修剪) 保留原图尺寸,UV对齐更简单
Data Format Unity 自动生成可直接解析的
.tpsheet
插件 TexturePacker Importer 自动导入图集与切片信息

Importer 地址(必须导入):
https://assetstore.unity.com/packages/tools/sprite-management/texturepacker-importer-16641

导入后即可自动生成 Sprite 切片与 Pivot。


🏗️ 伤害数字预制体结构

推荐结构如下:


DamageNum (Prefab)
 ├─ SingleNum (MeshRenderer + DamageNumber Shader)
 ├─ SingleNum
 ├─ SingleNum
 ├─ ...

示例示意图:
图片[1] - Unity Shader 简单实现伤害数字的显示 - 鹿快

每个
SingleNum
表示单个字符,由 Shader 控制动画。


🧠 核心 Shader(支持 Instancing + UV 裁剪 + 动画)

支持以下效果:

动画 参数 描述
Punch Scale
_PunchIntensity
,
_PunchDuration
伤害刚出现时的弹性缩放
Move Up
_InstanceStartPosition
,
_InstanceTargetPosition
飘字向上或带偏移移动
Fade Out
_FadeDelay
,
_FadeDuration
延迟渐隐消失
GPU Instancing
MaterialPropertyBlock
多数字共用材质,不增加批次

无需对每个数字单独生成材质,全部复用同一个 Shader


Shader "Custom/NumAnimatedSprite"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Offset ("UV Offset (x,y,scaleX,scaleY)", Vector) = (0, 0, 1, 1)
        
        _PunchIntensity ("Punch Intensity", Range(0, 1)) = 0.5
        _PunchDuration ("Punch Duration", Float) = 0.5
        _MoveDuration ("Move Duration", Float) = 1.0
        _FadeDuration ("Fade Duration", Float) = 0.8
        _FadeDelay ("Fade Delay", Float) = 0.4
    }

    SubShader
    {
        Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" }
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha
        Cull Off
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog
            
            #pragma multi_compile_instancing
            #pragma instancing_options assumeuniformscaling

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                // 传入实例ID
                UNITY_VERTEX_INPUT_INSTANCE_ID 
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
                float4 color : COLOR;
                // fragment 可访问 instance 数据
                UNITY_VERTEX_INPUT_INSTANCE_ID 
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            
            float _PunchIntensity;
            float _PunchDuration;
            float _MoveDuration;
            float _FadeDuration;
            float _FadeDelay;

            UNITY_INSTANCING_BUFFER_START(Props)
                UNITY_DEFINE_INSTANCED_PROP(float4, _Offset)
                // 实例创建时间(通用参数)
                UNITY_DEFINE_INSTANCED_PROP(float, _InstanceCreationTime)
                // Move 效果参数
                UNITY_DEFINE_INSTANCED_PROP(float3, _InstanceStartPosition)
                UNITY_DEFINE_INSTANCED_PROP(float3, _InstanceTargetPosition)
                // Color 效果参数
                UNITY_DEFINE_INSTANCED_PROP(float4, _InstanceColor)
            UNITY_INSTANCING_BUFFER_END(Props)

            // ========== 精确的InOutBack缓动函数 ==========
            float InOutBack(float t)
            {
                const float c1 = 1.70158;
                const float c2 = c1 * 1.525;
                
                float t2 = t * 2.0;
                if (t < 0.5) {
                    return (pow(t2, 2.0) * ((c2 + 1.0) * t2 - c2)) * 0.5;
                }
                t2 = t2 - 2.0;
                return (pow(t2, 2.0) * ((c2 + 1.0) * t2 + c2) + 2.0) * 0.5;
            }
            
            // ========== Scale实现 ==========
            float3 DoPunchScale(float progress)
            {
                // 1. 应用InOutBack缓动
                float easeProgress = InOutBack(progress);
                
                // 2. 创建冲击缩放效果
                float punch_scale;
                if (progress < 0.5) {
                    // 前半段:放大到1.5(1.0 + 0.5)
                    punch_scale = lerp(1.0, 1.0 + _PunchIntensity, easeProgress * 2.0);
                } else {
                    // 后半段:缩回到1.0
                    punch_scale = lerp(1.0 + _PunchIntensity, 1.0, (easeProgress - 0.5) * 2.0);
                }
                
                return float3(punch_scale, punch_scale, punch_scale);
            }

            // ========== Move效果 ==========
            float3 DoMoveAnimation(float progress)
            {
                // 应用InOutBack缓动
                float easeProgress = InOutBack(progress);
                
                // 计算移动方向(从起始位置到目标位置)
                float3 startPos = UNITY_ACCESS_INSTANCED_PROP(Props, _InstanceStartPosition);
                float3 targetPos = UNITY_ACCESS_INSTANCED_PROP(Props, _InstanceTargetPosition);
                float3 moveDirection = targetPos - startPos;
                
                // 应用缓动移动(对应DOMove(newPosition, 1f))
                float3 currentOffset = moveDirection * easeProgress;
                
                return currentOffset;
            }

            // ========== Fade效果 ==========
            float DoFadeAnimation(float totalElapsed)
            {
                // 计算淡出动画的进度(考虑延迟)
                float fadeStartTime = _FadeDelay; // 0.4秒延迟
                float fadeElapsed = totalElapsed - fadeStartTime;
                float fadeProgress = saturate(fadeElapsed / _FadeDuration); // 0.8秒淡出
                
                // 淡出:从1.0淡出到0.0
                return 1.0 - fadeProgress;
            }

            v2f vert (appdata v)
            {
                v2f o;
                // 初始化 instance id
                UNITY_SETUP_INSTANCE_ID(v);
                // 传递到 fragment
                UNITY_TRANSFER_INSTANCE_ID(v, o);

                // 获取创建时间
                const float creationTime = UNITY_ACCESS_INSTANCED_PROP(Props, _InstanceCreationTime);
                float4 originColor = UNITY_ACCESS_INSTANCED_PROP(Props, _InstanceColor);
                
                // 计算总经过时间
                float totalElapsed = _Time.y - creationTime;
                
                // 计算动画进度(0.5秒内完成)
                float elapsed = _Time.y - creationTime;
                float progress = saturate(elapsed / _PunchDuration);
                float moveProgress = saturate(_Time.y - creationTime) / _MoveDuration;
                float alpha = DoFadeAnimation(totalElapsed);
                
                // 1. Scale效果
                float3 scale = DoPunchScale(progress);
                float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
                worldPos.xyz *= scale;
                
                // 2. Move移动效果
                float3 moveOffset = DoMoveAnimation(moveProgress);
                float3 finalWorld = worldPos + moveOffset;
                o.vertex = mul(UNITY_MATRIX_VP, float4(finalWorld, 1));
                
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.color = float4(originColor.rgb, alpha);

                UNITY_TRANSFER_FOG(o, o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                UNITY_SETUP_INSTANCE_ID(i);
                float4 offset = UNITY_ACCESS_INSTANCED_PROP(Props, _Offset);

                const float2 uv = i.uv * offset.zw + offset.xy;
                fixed4 col = tex2D(_MainTex, uv);
                // 颜色混合和透明度叠加
                col.rgb *= i.color.rgb;
                col.a *= i.color.a;
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}


🧩 关键脚本说明

🎯 SingleNum.cs(单字符逻辑)

作用:

设置 UV 区域(来自 TexturePacker)设置动画起点、终点、颜色使用 MaterialPropertyBlock 传入 Shader


    public class SingleNum : MonoBehaviour
    {
        private MaterialPropertyBlock _propertyBlock;
        private MeshRenderer _meshRenderer;
        private static readonly int Offset = Shader.PropertyToID("_Offset");
        private static readonly int InstanceCreationTime = Shader.PropertyToID("_InstanceCreationTime");
        private static readonly int InstanceStartPosition = Shader.PropertyToID("_InstanceStartPosition");
        private static readonly int InstanceTargetPosition = Shader.PropertyToID("_InstanceTargetPosition");
        private static readonly int InstanceColor = Shader.PropertyToID("_InstanceColor");

        private void Awake()
        {
            _propertyBlock = new MaterialPropertyBlock();
            _meshRenderer = GetComponent<MeshRenderer>();
        }

        public void SetNum((float startX, float width, float scaleX) str, float offsetX, Color color)
        {
            gameObject.SetActive(true);

            var tf = transform;
            tf.localScale = new Vector3(str.scaleX, 1f, 1f);
            var originPos = tf.position;
            _propertyBlock.SetFloat(InstanceCreationTime, Time.time);
            _propertyBlock.SetVector(InstanceStartPosition, originPos);
            _propertyBlock.SetColor(InstanceColor, color);
            var targetPos = originPos + Vector3.up + Vector3.right * offsetX;
            _propertyBlock.SetVector(InstanceTargetPosition, targetPos);
            _propertyBlock.SetVector(Offset, new Vector4(str.startX, 0, str.width, 1f));
            _meshRenderer.SetPropertyBlock(_propertyBlock);
        }
    }

关键代码解释:


_propertyBlock.SetVector(Offset, new Vector4(str.startX, 0, str.width, 1f));

其中:

参数 原因
startX 图集 UV 起点X
width UV宽度
scaleX 字符在横向上的缩放系数(防止宽字符撑开)

🧮 DamageNum.cs(拼接多字符)

主要作用:

根据字符宽度拼接出完整数字支持随机X偏移,避免堆叠太整齐造成视觉重复多字符根据宽度自动居中排版


public class DamageNum : MonoBehaviour
{
    [SerializeField]
    private SingleNum[] singleNums;
    public void SetNum(string num, Color color)
    {
        transform.localScale = Vector3.one * .6f;

        foreach (var t in singleNums)
            t.gameObject.SetActive(false);

        var totalScaleX = 0f;
        var randomX = Random.Range(-0.5f, 0.5f);
        for (int i = 0; i < num.Length; i++)
        {
            var str = NumInfoLoader.Instance.GetInfo(num[i]);
            var scaleX = str.scaleX;

            if (i > 0) totalScaleX += scaleX / 2;
            singleNums[i].transform.localPosition = new Vector3(totalScaleX, 0, 0);
            singleNums[i].SetNum(str, randomX, color);
            totalScaleX += scaleX / 2;
        }
    }
}

🧪 Test.cs(测试)

按空格键生成一个伤害数字飘字示例。


    public class Test : MonoBehaviour
    {
        [SerializeField]
        private GameObject damageNumPrefab;
        
        private void Update()
        {
            if (Input.GetKeyDown(KeyCode.Space))
            {
                var go = Instantiate(damageNumPrefab);
                go.transform.position = Vector3.zero;
                go.transform.localScale = Vector3.one * 0.5f;
                var com = go.GetComponent<DamageNum>();
                com.SetNum("12.5KQ", Color.red);
            }
        }
    }

🧪 NumInfoLoader.cs(加载.tpsheet文件)


    public class NumInfoLoader : MonoBehaviour
    {
        public static NumInfoLoader Instance { get; private set; }
        
        [Serializable]
        class CharInfo
        {
            public int star;
            public int w;
            public int h;
        }
        
        private float _textureWidth;
        private readonly Dictionary<char, CharInfo> _numMap = new();
        
        private void Awake()
        {
            if (Instance != null)
            {
                Destroy(gameObject);
                return;
            }
            Instance = this;
            DontDestroyOnLoad(gameObject);

            NumInfoLoad();
        }

        private void NumInfoLoad()
        {
            var sb = new StringBuilder();
            var file = "/NumTpSheet/num1.tpsheet";
#if UNITY_EDITOR || UNITY_STANDALONE_WIN
#elif UNITY_ANDROID
            // sb.Append("jar:file://");
#endif
            sb.Append(Application.streamingAssetsPath);
#if UNITY_EDITOR || UNITY_STANDALONE_WIN
#elif UNITY_ANDROID
            // sb.Append("!/assets");
#endif
            sb.Append(file);
            Debug.Log($"filePath: {sb}");

            StartCoroutine(LoadTpSheetFile(sb.ToString()));
        }
        
        private IEnumerator LoadTpSheetFile(string filePath)
        {
            using (UnityWebRequest www = UnityWebRequest.Get(filePath))
            {
                yield return www.SendWebRequest();
                if (www.result == UnityWebRequest.Result.Success)
                {
                    // 加载成功,www.downloadHandler.data 是字节数组
                    byte[] fileBytes = www.downloadHandler.data;
                    string fileContent = Encoding.UTF8.GetString(fileBytes);
                    Debug.Log($"fileContent: {fileContent}");
                    var lines = fileContent.Split('
');
                    foreach (var t in lines)
                    {
                        if (t.StartsWith(":size"))
                        {
                            var wh = t.Split('=')[1].Split('x');
                            _textureWidth = int.Parse(wh[0]);
                        }
                        else if (!t.StartsWith(":") && !t.StartsWith("#") && !string.IsNullOrWhiteSpace(t) && !string.IsNullOrEmpty(t))
                        {
                            var tArr = t.Split(';');
                            var c = tArr[0].Split('_');
                            var star = int.Parse(tArr[1]);
                            var w = int.Parse(tArr[3]);
                            var h = int.Parse(tArr[4]);
                            var charInfo = new CharInfo { star = star, w = w, h = h };
                            _numMap.Add(c[1][0], charInfo);
                        }
                    }
                }
                else
                {
                    Debug.LogError("加载文件失败: " + www.error);
                }
            }
        }
        
        public (float startX, float width, float scaleX) GetInfo(char c)
        {
            var info = _numMap[c];
            // 先计算自己的宽度占比
            var widthRatio = info.w / _textureWidth;
            // 再计算自己的起始位置
            var startX = info.star / _textureWidth;
            var scaleX = info.w / (float)info.h;
            return (startX, widthRatio, scaleX);
        }
    }
© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
小半的头像 - 鹿快
评论 抢沙发

请登录后发表评论

    暂无评论内容