✅ 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 | 自动生成可直接解析的 |
| 插件 | 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 简单实现伤害数字的显示 - 鹿快](https://img.lukuai.com/blogimg/20251109/eabe12173e714bb097aa56234d61cd04.png)

每个 表示单个字符,由 Shader 控制动画。
SingleNum
🧠 核心 Shader(支持 Instancing + UV 裁剪 + 动画)
支持以下效果:
| 动画 | 参数 | 描述 |
|---|---|---|
| Punch Scale | , |
伤害刚出现时的弹性缩放 |
| Move Up | , |
飘字向上或带偏移移动 |
| Fade Out | , |
延迟渐隐消失 |
| GPU Instancing | |
多数字共用材质,不增加批次 |
无需对每个数字单独生成材质,全部复用同一个 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
















暂无评论内容