文章目录
-
- [0. 效果预览](#0. 效果预览)
- [1. 原理简述](#1. 原理简述)
- [2. 功能点](#2. 功能点)
- [3. 完整 Shader(可直接用)](#3. 完整 Shader(可直接用))
- [4. 使用方法](#4. 使用方法)
- [5. 参数说明](#5. 参数说明)
- [6. 变体与扩展](#6. 变体与扩展)
-
- [6.1 带 Billboard 的顶点着色器(Shader 内置面向摄像机)](#6.1 带 Billboard 的顶点着色器(Shader 内置面向摄像机))
- [6.2 外部控制帧索引(C# 驱动)](# 驱动))
- [6.3 Additive 混合(火焰/光效)](#6.3 Additive 混合(火焰/光效))
- [7. 常见问题](#7. 常见问题)
- [8. 性能建议](#8. 性能建议)
0. 效果预览
序列帧动画是特效制作的基本功:把一组连续帧画面排列在一张贴图上,Shader 按时间依次采样每一帧,实现火焰、爆炸、烟雾、魔法阵等循环动画。不需要骨骼、不需要粒子系统,一张图 + 一个 Shader 就能跑。

1. 原理简述
序列帧动画的本质:把 UV 坐标限制在贴图的某一格内,随时间推移切换到下一格。
一张 4×4 的序列帧贴图有 16 帧。每一帧占据 1/4 宽度、1/4 高度的 UV 区域。Shader 要做的事:
hlsl
// 1. 算出当前是第几帧
int frameIndex = floor(_Time.y * _Speed) % totalFrames;
// 2. 算出这一帧在第几行第几列
int col = frameIndex % _Columns;
int row = frameIndex / _Columns;
// 3. 把 UV 缩放到单帧大小,再偏移到对应格子
float2 uv = (uv + float2(col, row)) / float2(_Columns, _Rows);
注意:贴图的 UV 原点在左下角,但序列帧通常从左上角开始排列,所以行号需要翻转。
2. 功能点
- 自动播放 :基于
_Time驱动,无需 C# 脚本即可循环播放 - 行列可配:支持任意 N×M 的序列帧布局(4×4、8×8、2×4 等)
- 速度可调 :
_Speed控制每秒播放帧数(FPS) - 帧间插值:可选开启两帧之间的线性混合,消除跳帧感
- 透明支持:Alpha Blend 渲染,适合叠加在场景上的特效
- Billboard 可选:配合顶点着色器实现始终面向摄像机
- GPU Instancing:支持多实例渲染
3. 完整 Shader(可直接用)
hlsl
Shader "Custom/SpriteSheet_URP"
{
Properties
{
// 序列帧贴图(一张包含所有帧的图集)
_MainTex ("Sprite Sheet", 2D) = "white" {}
// 主颜色叠乘(可用于调色或控制整体透明度)
_BaseColor ("Base Color", Color) = (1,1,1,1)
// 列数(水平方向有几帧)
_Columns ("Columns", Int) = 4
// 行数(垂直方向有几帧)
_Rows ("Rows", Int) = 4
// 播放速度(每秒帧数)
_Speed ("Speed (FPS)", Range(1, 60)) = 10
// 总帧数(如果最后一行没排满,填实际帧数)
_TotalFrames ("Total Frames", Int) = 16
// 帧间插值开关(1 = 开启混合,0 = 硬切)
[Toggle] _Interpolate ("Frame Interpolation", Float) = 0
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalRenderPipeline"
"Queue" = "Transparent"
"RenderType" = "Transparent"
}
Pass
{
Name "SpriteSheetPass"
Tags { "LightMode" = "UniversalForward" }
Cull Off
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
// GPU Instancing 支持
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// =========================================================
// 贴图声明
// =========================================================
TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);
// =========================================================
// 材质属性(与 Properties 一一对应)
// =========================================================
float4 _MainTex_ST;
float4 _BaseColor;
int _Columns;
int _Rows;
float _Speed;
int _TotalFrames;
float _Interpolate;
struct Attributes
{
float4 positionOS : POSITION; // 模型空间顶点
float2 uv : TEXCOORD0; // UV 坐标
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionHCS : SV_POSITION; // 裁剪空间位置
float2 uv : TEXCOORD0; // 传递 UV
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
// =========================================================
// 计算某一帧的 UV:将原始 UV 缩放并偏移到对应格子
// =========================================================
float2 GetFrameUV(float2 uv, int frameIndex)
{
// 列号 = 帧索引 % 列数
int col = frameIndex % _Columns;
// 行号 = 帧索引 / 列数(从上往下数)
int row = frameIndex / _Columns;
// UV 原点在左下角,但序列帧从左上角排列
// 翻转行号:实际行 = 总行数 - 1 - row
int flippedRow = _Rows - 1 - row;
// 每帧占据的 UV 尺寸
float2 frameSize = float2(1.0 / _Columns, 1.0 / _Rows);
// 缩放 UV 到单帧大小,再偏移到目标格子
return uv * frameSize + float2(col, flippedRow) * frameSize;
}
// =========================================================
// 顶点着色器
// =========================================================
Varyings vert(Attributes v)
{
Varyings o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
o.positionHCS = TransformObjectToHClip(v.positionOS.xyz);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
// =========================================================
// 片元着色器:核心序列帧采样
// =========================================================
half4 frag(Varyings i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
// 1) 计算当前时间对应的帧进度(浮点数)
// _Time.y = 自场景加载以来的秒数
float frameProgress = _Time.y * _Speed;
// 2) 当前帧索引(取模实现循环)
int currentFrame = ((int)floor(frameProgress)) % _TotalFrames;
// 3) 采样当前帧
float2 currentUV = GetFrameUV(i.uv, currentFrame);
half4 currentColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, currentUV);
// 4) 帧间插值(可选)
half4 finalColor = currentColor;
if (_Interpolate > 0.5)
{
// 下一帧索引
int nextFrame = (currentFrame + 1) % _TotalFrames;
float2 nextUV = GetFrameUV(i.uv, nextFrame);
half4 nextColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, nextUV);
// 用小数部分作为混合权重
float blend = frac(frameProgress);
finalColor = lerp(currentColor, nextColor, blend);
}
// 5) 叠乘基础颜色
finalColor *= (half4)_BaseColor;
return finalColor;
}
ENDHLSL
}
}
}
4. 使用方法
-
在
Assets/Shaders/下新建文件SpriteSheet_URP.shader,粘贴上方完整代码。 -
新建材质(Create → Material),Shader 选择
Custom/SpriteSheet_URP。 -
准备序列帧贴图:
- 一张包含所有帧的图集,帧按从左到右、从上到下排列
- 常见来源:特效软件导出(Houdini、EmberGen)、手绘帧动画、Unity 粒子录制
- 贴图导入设置:
Texture Type = Default,Alpha Source = Input Texture Alpha,关闭Generate Mip Maps(避免帧边缘渗色)

-
将贴图拖到材质的
Sprite Sheet槽位。 -
设置参数:
Columns/Rows:与贴图的实际行列数一致(如 4×4 就填 4 和 4)Total Frames:实际帧数(如果最后一行没排满,填真实帧数而不是行×列)Speed:每秒播放帧数,10~15 适合火焰,20~30 适合爆炸
-
创建一个 Quad(3D Object → Quad),赋上材质,在 Game 视图中即可看到动画循环播放。
-
如果需要特效面向摄像机,给 Quad 挂一个简单的 Billboard 脚本:
csharp
using UnityEngine;
public class Billboard : MonoBehaviour
{
void LateUpdate()
{
// 让物体始终面向摄像机
transform.forward = Camera.main.transform.forward;
}
}
5. 参数说明
| 参数 | 类型 | 范围/默认值 | 说明 |
|---|---|---|---|
_MainTex |
2D | white | 序列帧贴图(所有帧排列在一张图上) |
_BaseColor |
Color | (1,1,1,1) | 颜色叠乘,可用于调色或控制整体透明度 |
_Columns |
Int | 4 | 贴图水平方向的帧数 |
_Rows |
Int | 4 | 贴图垂直方向的帧数 |
_Speed |
Range(1,60) | 10 | 播放速度,每秒帧数(FPS) |
_TotalFrames |
Int | 16 | 实际总帧数(最后一行没排满时填真实值) |
_Interpolate |
Toggle | 0 | 帧间插值:开启后两帧之间线性混合,动画更平滑 |

6. 变体与扩展
6.1 带 Billboard 的顶点着色器(Shader 内置面向摄像机)
不需要 C# 脚本,直接在顶点着色器中实现 Billboard:
hlsl
Varyings vert(Attributes v)
{
Varyings o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
// Billboard:用摄像机的右方向和上方向替换模型的 X/Y 轴
float3 centerWS = TransformObjectToWorld(float3(0, 0, 0));
float3 camRight = UNITY_MATRIX_V[0].xyz; // 摄像机右方向
float3 camUp = UNITY_MATRIX_V[1].xyz; // 摄像机上方向
// 用模型空间的 xy 作为偏移量,沿摄像机平面展开
float3 positionWS = centerWS
+ camRight * v.positionOS.x * unity_ObjectToWorld._m00 // 保留缩放
+ camUp * v.positionOS.y * unity_ObjectToWorld._m11;
o.positionHCS = TransformWorldToHClip(positionWS);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
6.2 外部控制帧索引(C# 驱动)
有时需要精确控制播放进度(如技能释放到某个阶段播放特定帧段):
csharp
// 在 Properties 中加一个手动帧索引
// _ManualFrame ("Manual Frame", Range(0, 64)) = -1
// 负值 = 自动播放,非负值 = 锁定到指定帧
// C# 端
material.SetFloat("_ManualFrame", 5); // 锁定到第 5 帧
material.SetFloat("_ManualFrame", -1); // 恢复自动播放
hlsl
// frag 中替换帧索引计算
int currentFrame;
if (_ManualFrame >= 0)
currentFrame = clamp((int)_ManualFrame, 0, _TotalFrames - 1);
else
currentFrame = ((int)floor(_Time.y * _Speed)) % _TotalFrames;
6.3 Additive 混合(火焰/光效)
火焰、闪电等发光特效用 Additive 混合比 Alpha Blend 更自然:
hlsl
// 把 Pass 中的 Blend 模式改为:
Blend SrcAlpha One // Additive:源色 × Alpha 加到目标上
效果:亮的部分叠加发光,暗的部分(黑色)自然消失,不需要精确的 Alpha 通道。
7. 常见问题
Q: 动画播放顺序反了(从右到左或从下到上)?
A: 检查两个地方:① 贴图的帧排列方向是否是"左到右、上到下"------这是本 Shader 的默认约定;② 如果贴图是"左到右、下到上"排列,把 GetFrameUV 中的 flippedRow 改为 int flippedRow = row;(去掉翻转)。
Q: 帧与帧之间有明显的接缝/渗色?
A: 两个原因:① Mip Maps 导致相邻帧的像素渗透------在贴图导入设置中关闭 Generate Mip Maps ;② 贴图的 Wrap Mode 设为 Clamp 而不是 Repeat,避免边缘采样到对面的帧。
Q: 开启帧间插值后画面变模糊?
A: 正常现象------两帧线性混合本质上就是叠加,运动剧烈的帧之间会产生重影。如果不能接受,关闭 _Interpolate,用更高的帧率(更多帧)来弥补平滑度。
Q: 贴图最后一行没排满(比如 4×4 但只有 14 帧),播放到空白帧?
A: 把 Total Frames 设为实际帧数(14),Shader 会在第 14 帧后循环回第 0 帧,不会播放到空白格子。
Q: 多个使用同一材质的 Quad 动画完全同步?
A: 因为它们共享同一个 _Time 值。解决方案:用 MaterialPropertyBlock 给每个实例设置不同的时间偏移,或者在 Shader 中加一个 _TimeOffset 属性,C# 端随机赋值。
8. 性能建议
- 贴图尺寸控制:4×4 的序列帧贴图,每帧 256×256 → 整张图 1024×1024,这是移动端的舒适区。8×8 每帧 128×128 也是 1024×1024,帧数更多但单帧精度降低,按需取舍。
- 关闭 Mip Maps:序列帧贴图不需要 Mip Maps(UI/特效通常在固定距离观看),关掉可以节省 33% 显存。
- Additive 优于 Alpha Blend:如果视觉上允许,Additive 混合不需要排序,GPU 友好。
- 帧间插值的代价:开启插值 = 每个像素采样两次贴图。如果特效数量多(几十个同屏),关闭插值可以减半采样开销。
- 合批注意 :多个使用相同材质的 Quad 可以被 SRP Batcher / GPU Instancing 合批。如果用了
MaterialPropertyBlock设置不同参数,GPU Instancing 仍然有效,但 SRP Batcher 会被打断。