短视频平台里那种摆钟变图效果,第一次看到的时候我就觉得挺魔性:整个画面像老式挂钟一样来回摆动,每摆到一个节奏点就换一张图,配上卡点BGM,观感相当带感。把这种效果搬进Unity里做的时候,我发现它并没有看上去那么玄乎,核心就是一个二维旋转矩阵,加上一套按时间推进的序列帧采样,再混合一点交叉渐变,就能在GPU里完整跑起来,甚至不需要写任何C#动画逻辑。这篇文章就把这套从拆解到落地、再到真机排坑的完整过程写出来,适合想在Unity里做点视觉花活、但不想被复杂底层劝退的开发者,也适合刚接触Shader、想看一个真正能上手案例的新手。会给出可直接复制的Shader源码、配套控制脚本,以及几个我在实际测试里踩过的细节坑。
1. 先拆解这个效果到底在做什么
做效果之前第一件事不是写代码,而是把眼睛看到的东西翻译成可计算的规则。"摆钟变图"从视觉上拆,就是两个叠在一起的动画:一个是画面像钟摆一样左右摆动,另一个是在摆动过程中周期性替换图片。两者独立又需要节奏感,摆得快一点、换得慢一点,或者反过来,都会得到完全不同的观感。
1.1 摆动的数学表达:一个正弦函数就够了
钟摆的摆动在数学上最自然的模型就是正弦函数。让我用一个参数不断变化的函数来表示摆角:
angle = sin(时间 x 频率) x 最大摆角
这里有几个关键点要理解。第一,正弦函数的值域天然就是 -1 到 1 之间,乘上最大摆角,就能让角度在正负之间来回摆动;第二,时间的连续变化让角度平滑过渡,不会出现跳变;第三,频率控制了摆动的快慢,频率越大,每秒来回的次数越多。
放在真实球面摆钟里,摆动还受重力、阻尼、初始角度这些物理量影响,但这个效果只需要视觉上的"像钟摆",正弦函数已经足够优雅。两个参数就能控制整个摆动节奏:最大摆角 _SwingAngle 和摆动频率 _SwingSpeed,改起来非常直观。
1.2 图片切换:本质是序列帧索引
"换图"这个动作听上去像播放动画,但实现方式其实是序列帧:提前把多张小图水平排成一行,打包成一张大图,然后在Shader里按时间切出对应的小图区域。这里要引入一个概念:图集。图集是一张包含很多小图的大纹理,Shder通过UV坐标的偏移来取不同的小图。
切换的规则也很简单:
当前帧索引 = 向下取整(时间 / 切换周期)
比如切换周期是 1 秒,那么第 0 秒显示第 0 张,第 1 秒显示第 1 张,第 2 秒显示第 2 张,以此类推。为了让最后一张显示完以后能从头开始,再用取模运算对总列数取余,实现循环播放。
1.3 技术路线选型:为什么推荐Shader而不是C#动画
很多人第一反应是用C#写动画:Update里转一下Quad的欧拉角,隔一会儿替换一次材质纹理,再做一个淡入淡出。这样也能实现,但有几个不舒服的地方。
第一,每切一次图就要SetTexture一次,往往会触发材质球的重建,尤其在UI界面上频繁换图,帧耗时很不稳定;第二,摆动和切换是两套独立的逻辑,在C#里要将两边的计时器对齐,否则画面就会显得各动各的;第三,如果后续还想叠加缩放、模糊、边缘辉光这些效果,用C#驱动只会越写越重。
Shader方案把动画逻辑全部放进GPU,一次Draw Call就完成所有东西。时间在Shader里统一读取,摆动和切换天然同步。参数直接暴露在材质面板上,美术同学不用碰代码也能调出不同的节奏感。所以这套方案在我做过同类效果后,成了最顺手的默认选择。
2. Shader核心:让UV按你的规则动起来
Shader里一切视觉变化最终都落在UV坐标上。要让图片像钟摆一样摆起来,本质上不是移动顶点,而是让采样纹理使用的坐标发生旋转。这个思路很多新手会卡住:明明改的是显示区域,为什么画面会动?因为GPU渲染时,每个像素都会根据你给的UV去纹理上取色,UV变了,取到的颜色就变了,画面自然就"动"起来。
2.1 旋转矩阵:绕支点转动UV
二维旋转矩阵是这段代码的数学地基。对一个坐标点 (x, y) 绕原点逆时针旋转角度 θ,新坐标是:
x' = x·cosθ - y·sinθ
y' = x·sinθ + y·cosθ
写成矩阵形式就是一个 2x2 旋转矩阵。但事情没这么简单,我们希望UV是绕某个支点旋转,而不是绕UV原点。这需要一个套路:先平移、再旋转、最后平移回去。
具体来说,假设支点是 pivot,先把原始UV减去 pivot,让支点变成局部坐标原点;然后乘旋转矩阵;最后加上 pivot,把坐标还原到原来的坐标系。我自己第一次写的时候犯过一个错误:只做了旋转,没有先平移到支点,结果画面绕着左下角转,完全不是钟摆的感觉。后来才记住这个三步套路:
- 第一步:Offset,把UV平移到支点为中心
- 第二步:Rotate,乘以旋转矩阵
- 第三步:Offset back,再加回支点坐标
在Unity Shader里,代码是这样一段:
float2 pivot = float2(0.5, 0.0); // 支点在底部中心 float2 centered = i.uv - pivot; float angle = sin(_Time.y * _SwingSpeed * UNITY_TWO_PI) * _SwingAngle; float rad = radians(angle); float sn = sin(rad); float cs = cos(rad); float2x2 rotMat = float2x2(cs, -sn, sn, cs); float2 uvRot = mul(rotMat, centered) + pivot;支点的位置很大程度上决定了效果的观感。pivot 放在底部中心,是"老式挂钟"的质感;放在画面中心,则是画面整体晃动翻转的质感,适合做卡点切换;放在顶部中心,又变成了翻页效果。这个参数我建议直接暴露在材质面板上,让使用者自己试,会发现同一个Shader能玩出好几种玩法。
2.2 序列帧图集:如何从一张大图里切出小图
图集准备有一个约定:所有小图尺寸相同,横向排列成一排。假如有4张512x512的图,拼成一张2048x512的长条图。Shader里要做的,就是根据当前帧索引,把UV的X方向偏移到对应的小图区间。
先理解UV的映射关系。整张图集的X方向范围是 0 到 1,第一张图占的是 0 到 1/4,第二张占 1/4 到 2/4,第三张占 2/4 到 3/4,第四张占 3/4 到 1。如果当前帧索引是 col,那么取样时的UV坐标可以写成:
uvAtlas.x = (uvRot.x + col) / 列数
uvAtlas.y = uvRot.y
这样每个小图的局部坐标都被映射到整张图集的对应区域。循环播放时对列数取模:
float frameIndex = floor(_Time.y / _SwitchTime); float colA = fmod(frameIndex, colCount); float colB = fmod(frameIndex + 1.0, colCount);这里我提前算了当前帧 colA 还有下一帧 colB,目的就是为了给后面的交叉淡化做铺垫。只显示当前帧的话,到了切换边界画面就会"跳"一下;同时取两帧再做混合,过渡就柔顺很多。
2.3 交叉淡化:图片切换不闪不跳
序列帧硬切的抖动,是我在测试过程中第一个注意到的问题。当 _SwitchTime 比较小、图片又比较亮的时候,切换瞬间会明显看到"啪"地换图,特别廉价。解决办法是给两帧之间加一个过渡权重,也就是让当前帧在周期末尾逐渐淡出、下一帧逐渐淡入。
计算过渡权重的关键,是算出当前图片周期内的进度:
float cyclePos = frac(_Time.y / _SwitchTime);cyclePos 从 0 增长到 1。我希望大部分时间显示当前帧,只有快到 1 的时候才开始往下一帧过渡,所以用 smoothstep 做一个带缓动的边界:
float fadeRatio = saturate(_FadeTime / _SwitchTime); float cross = smoothstep(1.0 - fadeRatio, 1.0, cyclePos);当 cyclePos 接近 1 时,cross 从 0 逐渐变到 1,也就是说当前帧权重下降、下一帧权重上升。最后颜色就是当前帧颜色和下一帧颜色按 cross 插值:
float4 finalColor = lerp(colorA, colorB, cross);如果你希望一进入新周期就立刻开始过渡,可以把 smoothstep 的参数反过来,变成 smoothstep(0, fadeRatio, cyclePos),效果从"末尾渐变"变成"开头渐变"。两种模式我都保留在Shader里,用开关控制,不同场景可以选不同节奏。
3. 完整实现:图集、Shader、C#脚本与场景搭建
原理理顺之后,实现就是按部就班的事情了。整个过程分四步:准备图集、写Shader、写控制脚本、搭场景。我会给出每一步的关键细节,尤其是容易忽略的参数设置。
3.1 图集制作与纹理导入参数
图集制作可以直接用Photoshop或者任何能拼图的工具。注意几个约定:所有小图的分辨率必须完全一致,推荐每张512x512;小图之间不要贴得太紧,留2像素左右的透明安全边距,这样可以防止采样时把邻图的像素掺进来;导出格式优先选带透明通道的PNG。
导入Unity后,选中图集纹理,Inspector面板的纹理参数需要注意几点:
- Texture Type:选 Default,用于材质球的2D采样,不需要选 Sprite
- Wrap Mode:必须选 Clamp,不要选 Repeat。Repeat模式下UV越界会平铺重复,旋转出界时会出现奇怪的镜像拼接
- Filter Mode:选 Bilinear,双线性过滤会让交叉淡化更平滑。点采样会看到明显的像素颗粒,做图片切换效果很不推荐
- sRGB:保持勾选,颜色会按Gamma校正处理
这些参数里最容易踩的是Wrap Mode。我最初用的是默认的Repeat,摆角一大,画面边缘就开始出现重复的图像条纹,排查了很久才意识到是纹理包裹模式的问题。
3.2 完整Shader源码
在这个Shader里,我把上面讲到的摆动、切换、淡化、边缘裁剪全部整合起来,并且每个关键参数都做成了材质属性。这样在Inspector面板上就能直接调节动画节奏,不需要反复改代码。
Shader "Custom/PendulumSwitcher" { Properties { _MainTex ("图集纹理(多张小图横向排列)", 2D) = "white" {} _ColCount ("图集列数", Int) = 4 _SwitchTime ("图片切换周期(秒)", Float) = 1.0 _SwingAngle ("最大摆角(角度)", Range(1, 89)) = 30 _SwingSpeed ("摆动频率(Hz)", Float) = 1.0 _FadeTime ("交叉淡化时长(秒)", Range(0, 0.5)) = 0.12 _PivotAtBottom ("以底部为摆动支点", Float) = 1 _FadeAtStart ("在周期开始处淡化", Float) = 0 _CutEdges ("裁剪旋转出界区域", Float) = 1 } SubShader { Tags { "RenderType" = "Transparent" "Queue" = "Transparent" "IgnoreProjector" = "True" } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_TexelSize; int _ColCount; float _SwitchTime; float _SwingAngle; float _SwingSpeed; float _FadeTime; float _PivotAtBottom; float _FadeAtStart; float _CutEdges; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag(v2f i) : SV_Target { float t = _Time.y; float colCount = max(1.0, (float)_ColCount); // 1. 摆钟角度:正弦函数幅值控制摆幅,频率控制速度 float angle = sin(t * _SwingSpeed * UNITY_TWO_PI) * _SwingAngle; float rad = radians(angle); float sn = sin(rad); float cs = cos(rad); // 2. 绕支点旋转UV float2 pivot = _PivotAtBottom > 0.5 ? float2(0.5, 0.0) : float2(0.5, 0.5); float2 centered = i.uv - pivot; float2x2 rotMat = float2x2(cs, -sn, sn, cs); float2 uvRot = mul(rotMat, centered) + pivot; // 3. 裁剪旋转出界区域,避免边缘拉伸 if (_CutEdges > 0.5) { if (uvRot.x < 0.0 || uvRot.x > 1.0 || uvRot.y < 0.0 || uvRot.y > 1.0) discard; } // 4. 序列帧索引:当前帧与下一帧 float frameIndex = floor(t / _SwitchTime); float colA = fmod(frameIndex, colCount); float colB = fmod(frameIndex + 1.0, colCount); // 5. 交叉淡化权重 float cyclePos = frac(t / _SwitchTime); float fadeRatio = saturate(_FadeTime / _SwitchTime); float cross; if (_FadeAtStart > 0.5) cross = smoothstep(0.0, fadeRatio, cyclePos); else cross = smoothstep(1.0 - fadeRatio, 1.0, cyclePos); // 6. 半像素收缩采样,防止图集边缘渗色 float halfTexel = _MainTex_TexelSize.x * 0.5; float2 uvA = lerp( float2(colA / colCount + halfTexel, halfTexel), float2((colA + 1.0) / colCount - halfTexel, 1.0 - halfTexel), uvRot); float2 uvB = lerp( float2(colB / colCount + halfTexel, halfTexel), float2((colB + 1.0) / colCount - halfTexel, 1.0 - halfTexel), uvRot); fixed4 colorA = tex2D(_MainTex, uvA); fixed4 colorB = tex2D(_MainTex, uvB); fixed4 finalColor = lerp(colorA, colorB, cross); return finalColor; } ENDCG } } }这段代码用到了几个Unity内置变量和函数,简单解释一下。_Time.y 是Shader里最常用的时间值,单位是秒,拿来驱动动画非常方便;UNITY_TWO_PI 等于 2π,是用来把频率换成角速度的常数;radians 把角度转弧度,避免sin和cos在角度模式下行为不对。这些都是在UnityCG.cginc里预置好的,直接抄走就行。
3.3 C#控制脚本与参数联动
只用上面的Shader其实已经能跑出完整效果,所有参数都在材质面板上。但如果你希望运行时动态改变摆动速度、切换周期,或者接外部数据源(比如音乐节拍),就需要一个轻量C#脚本。这里给一个足够通用的,参数修改后自动同步到材质球:
using UnityEngine; public class PendulumEffectController : MonoBehaviour { [SerializeField] private Material targetMaterial; [Header("摆动参数")] [Range(1f, 89f)] public float swingAngle = 30f; [Range(0.1f, 5f)] public float swingSpeed = 1f; [Header("图片切换参数")] [Range(0.1f, 5f)] public float switchTime = 1f; [Range(0f, 0.5f)] public float fadeTime = 0.12f; private static readonly int SwingAngleID = Shader.PropertyToID("_SwingAngle"); private static readonly int SwingSpeedID = Shader.PropertyToID("_SwingSpeed"); private static readonly int SwitchTimeID = Shader.PropertyToID("_SwitchTime"); private static readonly int FadeTimeID = Shader.PropertyToID("_FadeTime"); private void Reset() { TryGetMaterial(); } private void Awake() { TryGetMaterial(); } private void OnValidate() { TryGetMaterial(); ApplyParams(); } private void TryGetMaterial() { if (targetMaterial == null) { var rend = GetComponent<Renderer>(); if (rend != null) { targetMaterial = Application.isPlaying ? rend.material : rend.sharedMaterial; } } } private void OnDestroy() { if (targetMaterial != null) { Destroy(targetMaterial); } } private void ApplyParams() { if (targetMaterial == null) return; targetMaterial.SetFloat(SwingAngleID, swingAngle); targetMaterial.SetFloat(SwingSpeedID, swingSpeed); targetMaterial.SetFloat(SwitchTimeID, switchTime); targetMaterial.SetFloat(FadeTimeID, fadeTime); } }脚本里有一个容易忽略的细节:编辑器模式下取值用 rend.sharedMaterial,运行时用 rend.material。因为 rend.material 会实例化材质球,如果只在编辑器里操作就实例化,退出Play模式后材质资产会被脱离,改参数就会失效。这个坑我踩过不止一次,写在这里供参考。
3.4 场景搭建建议
搭建场景分两种常见路线。一种是纯3D平面展示,在场景里创建一个Quad,把材质球拖上去,调节Quad的缩放让宽高比和单张小图一致。比如每张小图是512x512,Quad的Scale就设为(1, 1, 1),这样不会被拉伸。另一种是UI展示,在Canvas下放一个RawImage,把材质球赋给它的Material属性,再把Quad的宽高设为单张图片的显示尺寸。
无论哪种方式,都建议把摄像机背景设成一个干净的纯色,这样能更直观地看到摆钟动画的边界。如果你打算发社交媒体,还可以加一个小分辨率窗口,方便录屏。我实测时习惯用 1080x1080 的Game视图比例,刚好是正方形,摆钟在画面里居中时观感最舒服。
4. 真机踩坑记录与排查手册
任何Shader效果放到不同平台、不同机型上,总会出现一些桌面端发现不了的问题。这一部分挑五个我实际碰过的高频问题,并按现象、原因、解决方案排序,你可以直接对照着排查。
4.1 图集边缘渗色:邻图颜色混进画面
现象:切换图片显示得很不干净,图片边缘出现相邻图片的模糊色块,尤其在双线性过滤模式下特别明显。
原因:双线性过滤采样时,如果UV落点离纹理像素边界很近,GPU会同时把相邻像素的颜色也插值进来。图集里两张小图紧挨着,边界处自然就把邻居的颜色混进来了。
解决方案有两个层次。第一是图集制作时每张小图周围留2像素透明边距,物理上隔开;第二是Shader里做半像素收缩,就是把采样UV的范围往小图内部缩半个纹理像素。上面完整代码里 uvA、uvB 的 lerp 部分就是在做这件事,这也是我建议你不用普通序列帧采样、而是用这个加强版的原因。
4.2 切换一瞬间闪烁或者跳变
现象:图片切换时"啪"地一下,完全没有过渡,节奏快的时候会闪眼。
原因:如果只取当前帧、不做交叉淡化,到了切换周期边界,旧帧突然消失、新帧突然出现,视觉上就是硬切。在明亮的高饱和图片上,硬切特别明显。
解决方案:给切换加交叉淡化,用两帧的权重插值。前面讲过的 smoothstep 就是标准方案。排插几个参数也好用:先关掉 _FadeTime 确认序列帧方向正确,再打开淡化;如果淡化的系数设得过大,切换感会拖沓,一般控制在 0.1 到 0.16 秒之间比较自然。
4.3 摆角过大时画面边缘出现拉伸纹
现象:把摆角调到 60 度以上,画面四周会出现很奇怪的拉伸,某些位置像被拉扯了一样。
原因:旋转后的UV超出 0 到 1 的范围,被纹理的 Clamp 模式拉到边缘像素上,于是旋转区域外变成了边缘色块的延伸。因为图集本身是横排长条,边缘像素又可能来自另一张图,看起来就非常诡异。
解决方案:裁掉出界区域的像素。代码里加了一个 _CutEdges 开关,开启后,uvRot 超出 [0, 1] 范围就让这个片元 discard,不参与渲染。这样旋转出界部分直接透明,不会有拉伸纹。代价是有些机型上 discard 会影响渲染性能优化,但实际测下来影响很小,可以放心开。
4.4 低端安卓机上摆动明显卡顿
现象:同一套Shader在电脑上丝滑,在低端安卓机上能明显感到动作一顿一顿,切换图片时尤其明显。
原因:两个因素叠加。一是图集分辨率太大,例如四张2048x2048的大图合成一张8192x2048的图集,采样带宽压力很大;二是Shader里用了较多 float 运算,低端机的GPU对高精度浮点处理速度较慢。
解决方案:优先压缩图集,单张小图用512甚至256,足以满足手机屏幕的显示清晰度。Shader里可以适当用 half/fixed 精度替代 float,不过做旋转矩阵时我建议保留 float,避免角度计算出错,只在颜色插值处用 half4。另外,开启纹理压缩格式(ASTC或ETC2),能显著减少带宽压力。
4.5 UI和3D平台上的渲染顺序问题
现象:在Canvas里用RawImage显示时,摆钟有时候被其他UI挡住,或者遮挡关系错误;在3D场景里又显示不出透明效果。
原因:Shader的渲染队列被设成了 Transparent,并且关闭了ZWrite,如果不注意场景里其他透明物体的顺序,就会出现意外的遮挡。此外UI材质还需要设置正确的Canvas层级。
解决方案:在Shader标签里显式声明"Queue" = "Transparent",确保透明队列合理,同时Blend模式设为SrcAlpha OneMinusSrcAlpha。UI场景下可以额外在Inspector点击RawImage,设置合适的Canvas Sorting Order,让摆钟物体和普通UI的层级错开。我在项目里遇到这类问题时,通常把摆钟单独放在一个全屏RawImage层,其他UI放另一层,渲染顺序就清晰很多。
5. 基于同一套思路的扩展玩法
摆钟变图这套Shader,本质上是一个"时间驱动UV变形 + 序列帧切换"的模板。把几个参数换一下,就能改造成完全不同的效果,这一点我实际改过之后觉得非常划算。
5.1 音频节拍卡点切换
社交平台上这类摆钟变图几乎都是卡着音乐节拍的。想让切换节奏跟音乐走,不应该把 _SwitchTime 固定成一个常数,而应该让C#脚本从AudioSource读取当前播放时间,按节拍周期计算一个动态的 _SwitchTime。比如BPM是120,一拍就是0.5秒,一拍切一张图,那 _SwitchTime 就是0.5。更进一步,可以让C#每帧把 AudioSource.time 传给Shader,用这个时间替代 _Time.y,这样切换点就完全和音乐对齐,不会漂移。这部分我实现得比较粗糙,但节奏卡上以后,整体观感会立刻上一个大台阶。
5.2 从钟摆到画布整体的旋转、缩放
支点换成画面中心,摆动改成旋转,就变成一个持续旋转的展示框;摆角公式里再乘一个缩放因子,还能做出"摇摆加呼吸"的复合动画。我在测试时给Shader加了一个可选缩放值:
zoom = 1.0 + _Zoom * abs(sin(t * _SwingSpeed * UNITY_TWO_PI))
然后把旋转后的UV围绕中心做一次缩放:
uvScaled = (uvRot - 0.5) / zoom + 0.5
这样钟摆左右摆动时,画面会伴随轻微的放大和缩小,动态感更强。如果你要做更复杂的翻页、立体卡片效果,还能把旋转矩阵换成透视投影矩阵,不过那就是另一个话题了。
5.3 从摆钟到翻页卡片与轮播图
把支点从底部改成顶部,画面就像从上方翻下来;改成左侧或者右侧,就能做水平翻转的卡片。再把 sin 产生的来回摆动去掉,只保留序列帧切换和交叉淡化,它就变成一个自动轮播图。这让我意识到一个事:Shader里很多效果都是共享底层数学模型的,掌握旋转矩阵和UV映射之后,改出花样的成本远比我最初想象的低。
调试的时候还有一个很实用的小习惯分享给各位:我每改一次参数,都会先固定 _SwitchTime 为整数、把 _FadeTime 调成0,确认序列帧的顺序和方向没问题,再打开交叉淡化。不然如果方向反了,你又开着淡化,很容易被混合出来的中间状态误导,白排查半天。这个效果做完以后,我顺手把它改成了同屏多个摆钟的方案,每个材质实例用不同的 _SwingAngle 和 _SwitchTime,摆出来像是好几台挂钟在同步跳舞,发布到社交平台上的反馈确实比静态截图好很多。希望这套源码和思路能帮你省掉一些弯路,也期待看到你在此基础上改出的新玩法。