最近刷视频时被那种“摆钟切图”特效给种草了一个钟摆在画面里左右摆动摆到不同角度时画面上的图片跟着切换像是钟摆扫过哪张图就显示哪张图。第一次看到时我还以为是视频剪辑卡点后来在Unity里想复刻这个效果发现本质就是“一个用时间驱动的图片索引切换”完全可以用Shader实现而且不复杂。我把自己实际做这个效果的思路、完整Shader代码、C#脚本、遇到的坑全部整理了一下。这篇文章会从需求拆解讲到最终落地涉及摆钟摆动的数学建模、多张图片的图集采样、脚本与Shader的配合、Unity场景参数调优。不管你是才学Shader的新手还是想在项目里做点带交互的动效这套方案都可以直接拿去改。1. 效果拆解与整体设计思路1.1 这个“摆钟切图”效果到底在做什么先把这个效果拆开看它其实由两个不同层面的东西组成一个是摆钟自身的摆动也就是旋转变换另一个是表面显示内容根据摆动角度进行切换。先说画面层。抖音里常见的摆钟切图有两种表现第一种是整个画面就是一面大钟钟盘上分成几个扇形区域每个区域内放一张图钟摆扫过某个区域时那个区域会高亮或放大第二种是摆钟本身是一个方块或海报左右摆动时正面显示的图片会根据当前摆角切换到对应的一张。这次我要讲的是第二种也是最容易用Shader统一实现的方案。这种效果应用到具体产品和Demo里一般就是动态相册、卡片轮播、新手引导的翻页交互或者一些模拟物理的小游戏。比如做一个“摇摆礼品盒”摆到左侧显示A商品摆到右侧显示B商品本质上都是同一套逻辑将连续变化的摆角离散化为若干档位每一档对应一张图片。真正上手前先把一个关键问题想清楚这个摆钟的摆动是自动循环还是由用户拖拽控制的这两种模式在Shader里差别不大区别主要在角度的来源但会影响你的代码结构。后面第3部分我会分别说明。1.2 为什么用Shader而不是纯脚本拼UI可能有人会问这个效果用Unity的UGUI多放几个Image写个脚本每帧改图片的透明度不就行了理论上确实可以但实际做下来你会发现Shader方案有很多优势。先看性能。如果图片数量多比如要做10张图的切换纯UI意味着同时要管理10个UI节点、10个RawImage控件还要做交叉淡入淡出。稍微操作不好draw call和重建网格的开销就上来了。而Shader方案只需要一个Quad或者一个Image组件把10张图打包进一张纹理图集每次采样一个Tile一个draw call就搞定。再看同步性。摆钟摆动和图片切换必须在同一时刻对齐。如果摆角由C#脚本更新图片切换也由C#脚本控制很容易出现一帧的错位看起来就是“图片先换了一下钟摆才到位”。Shader方案中角度和采样点在同一个Pass里计算天然不存在不同步的问题。当然Shader也不是没有代价。调试没有UGUI那么直观如果写错了UV画面直接就是花的、黑的不像UI控件那样容易排查。所以我后面也会把调试思路单独写一节帮你快速定位问题。1.3 整体方案架构与技术选型我在做这个Demo时选用的技术方案如下功能模块技术选型说明渲染对象Quad或RawImage用一个平面网格承载整个特效方便旋转和观察着色器自定义ShaderLab CG/HLSL内置渲染管线和URP下都能跑不依赖URP专属特性图片承载单张纹理图集多张图片横向排布在同一个Texture中通过UV偏移采样动画驱动C#脚本设置材质参数自动模式用正弦函数生成摆角手动模式读取鼠标输入过渡方式相邻切片边缘做插值硬切换会产生跳变插值后观感更顺滑这套架构有一个非常明显的好处Shader只管“根据某个角度显示哪张图”至于角度怎么来你完全可以在外面随意控制。你可以用正弦自动摆可以用鼠标拖可以用加速度计数据甚至可以用外部序列帧数据材质参数传进来就行。2. 核心细节解析与实操要点2.1 摆钟运动的数学建模摆钟的物理模型严格来说是单摆运动真实情况下要考虑重力加速度、摆长、空气阻力但在视觉特效里完全不需要那么复杂。抖音那种“左右往复摆动”的效果本质上就是一个正弦函数θ(t) A * sin(ωt φ)其中A是最大摆角决定左右的极限位置ω是角频率决定摆得快慢φ是初始相位。用Unity的时间变量_Time.y作为tShader里就能直接产生持续摆动的角度。实际调参时我一般会先定振幅A再定速度ω。比如一个普通海报的摆动振幅设置在30到50度之间比较舒服超过60度会让人感觉摆得太夸张。周期方面如果希望钟摆从左边摆到右边再回到左边大约是1.5秒那ω 2π / T ≈ 4.19。如果你希望过渡更缓慢可以把ω控制在1.5-2.5区间。有一个重要的细节是正弦函数在两端速度慢中间速度快。这意味着如果你把图片按角度均匀切分在摆到中间区域时图片切换会非常快而摆到两端时图片停留时间会变得很长。这一点在设计图片内容时要提前考虑。如果你希望图片切换更均匀也可以把“角度归一化”换成“时间归一化”直接对(sin(t)1)/2做映射但对展示型摆钟来说按角度切分反而更符合直觉。2.2 多图片切换的UV切片机制要让一个材质显示多张图片最简单可靠的办法就是用纹理图集Texture Atlas。假设你有5张图它们被打包进了一张横向的纹理里那这张图集的宽度就是单张图片宽度的5倍。在Shader里我们只需要在不同时刻采样图集的不同区域。图集采样的核心公式其实只有一句uv.x (原uv.x 当前图片序号) / 总列数。假设图集的列数为_Columns当前要显示第idx张图那么采样UV就变成(uv.x idx) / _Columns。uv.y不变。这里的计算顺序很关键。先要根据摆角算出一个normalized的0到1之间的值再用它乘上_Columns得到带小数的位置floor得到整数列号。举个例子最大摆角A45度图集列数N5当前角θ18度那normalized(1845)/(90)0.7乘5等于3.5floor后是第3列。也就是说摆角处于中间偏右的位置时会显示第3张图。如果把3.5的小数部分0.5当作两张图之间的混合权重用lerp去混合第3列和第4列就能得到平滑的交叉渐变。这个渐变会让钟摆看起来像“翻页翻到一半”观感比硬切好很多。需要注意的是图集内每列之间如果没有任何间隔Bilinear过滤会导致采样时把相邻图片的边缘颜色也采进来表现出来就是图片边缘发糊、发灰甚至能看到旁边图片的内容。这个问题第4部分我会专门讲怎么处理。2.3 关键参数设计振幅、速度与过渡在做这个Demo时参数设计比写Shader本身更影响最终效果。我习惯先把参数列成一张表按“先定图片数再定角度分区最后定速度”的顺序去调。参数名作用推荐取值范围备注_Columns图集列数3-8列数决定总共能切换多少张图片_Amplitude最大摆角30-50度太大会失真太小切换不明显_Speed摆动角频率ω1.5-4.0配合Sin函数的周期使用_UseManual自动/手动模式切换0或1手动模式适合拖拽交互_ManualAngle手动角度-Amplitude 到 Amplitude由C#脚本写入_FadeRange过渡带宽度0.1-0.4如果不需要渐变就设0或取消该逻辑如果你是用手动拖拽模式我建议把角度写在C#脚本里而不是让Shader里的Sin函数一直覆盖。因为一旦用户拖拽和自动摆动同时生效画面会变得不可控。后面第3章我会展示完整的脚本把这两种模式用开关切干净。3. 实操过程与核心环节实现3.1 素材准备纹理图集制作与导入设置做图集前先定图片张数和分辨率。以5张正方形图片为例每张512x512横向排列最终图集就是2560x512。你可以自己拼也可以用TexturePacker这类工具。制作完图集后Unity导入设置有几个地方必须手动调整否则后面Shader采样会出问题Wrap Mode必须设为Clamp。如果保持默认的Repeat当UV超出图集边界时纹理会不断重复画面会出现莫名其妙的循环图案。Filter Mode建议用Bilinear。如果用Point图片边缘的锯齿会非常明显Bilinear搭配每列间的padding效果最自然。压缩格式按目标平台设置。PC平台用RGBA32或ASTC移动端建议用ASTC 6x6或ETC2。图集本身比较大压缩格式设置不好会占大量内存。还有一个容易被忽略的点如果每张图都有Alpha通道注意把Alpha Source选成Input Texture Alpha不要让Unity自己根据RGB去计算透明值否则半透明边缘会被压成灰色块。如果你实在不想手工拼图集也可以用C#脚本在运行时把多张Texture2D合并成一张横向图集。这个方案适合图片是程序化生成或异步下载的情况。只要保证合并后的texel坐标和Shader里的_Columns计算一致即可。3.2 Shader完整代码与逐段讲解下面是这个Demo的核心Shader。我尽量把注释写完整你可以直接复制到Unity工程里跑一遍。Shader Custom/PendulumPicture { Properties { _AtlasTex (图片图集 (横向排列), 2D) white {} _Columns (图集列数, Int) 5 _Amplitude (最大摆角(度), Range(1, 90)) 45 _Speed (摆动速度(弧度/秒), Range(0.1, 10)) 2.5 _UseManual (手动模式开关, Float) 0 _ManualAngle (手动角度(度), Range(-180, 180)) 0 } SubShader { Tags { QueueTransparent RenderTypeTransparent } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _AtlasTex; float4 _AtlasTex_ST; int _Columns; float _Amplitude; float _Speed; float _UseManual; float _ManualAngle; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float angle : TEXCOORD1; }; v2f vert (appdata v) { v2f o; // 当前摆角手动模式优先 float currentAngle _UseManual 0.5 ? _ManualAngle : _Amplitude * sin(_Time.y * _Speed); // 绕Y轴旋转Quad本身让它像钟摆一样左右摆动 float rad radians(currentAngle); float c, s; sincos(rad, s, c); // 旋转局部坐标的 x 和 zy 轴保持不变 float3 rotated float3( v.vertex.x * c v.vertex.z * s, v.vertex.y, -v.vertex.x * s v.vertex.z * c ); o.vertex UnityObjectToClipPos(rotated); o.uv TRANSFORM_TEX(v.uv, _AtlasTex); o.angle currentAngle; return o; } fixed4 frag (v2f i) : SV_Target { // 将摆角归一化到 [0,1] float totalRange max(_Amplitude * 2.0, 0.0001); float normalized saturate((i.angle _Amplitude) / totalRange); // 得到带小数的切片位置 float slicePos normalized * _Columns; float idx floor(slicePos); float blend frac(slicePos); // 限制最大列号防止越界 idx min(idx, _Columns - 1.0); // 下一列用于混合 float idxNext min(idx 1.0, _Columns - 1.0); // 计算两个切片对应的UV float2 uv0 float2((i.uv.x idx) / _Columns, i.uv.y); float2 uv1 float2((i.uv.x idxNext) / _Columns, i.uv.y); fixed4 col0 tex2D(_AtlasTex, uv0); fixed4 col1 tex2D(_AtlasTex, uv1); // 在两列之间做交叉淡化 return lerp(col0, col1, blend); } ENDCG } } }这段Shader有几个我要专门解释的点。顶点旋转顺时针还是逆时针取决于你的Quad轴方向和摄像机方向。如果你的Quad是默认朝Z轴正方向把相机放在Z轴负方向这个旋转公式会让Quad在XZ平面内摆动看起来像是绕自身Y轴甩动。如果你的场景里Quad朝向不对旋转看起来就会是“缩成一条线”这时需要调整坐标变换里的轴组合。UV采样很多人初次写图集采样时会写错一个地方就是忘记了i.uv.x本身也是0到1之间的值。如果直接写成idx / _Columns采样的区域会完全错误画面只会显示图片左上角的一小块。正确做法是把原uv.x作为列内偏移加上列偏移后一起除以总列数才能让图片完整地铺满整个Quad。为什么用lerp(col0, col1, blend)这样在图片切换时会产生一个交叉渐变。比如当blend0时全显示第idx列blend1时全显示第idx1列中间则是两张图各显示一半。这种过渡方式比硬切更贴近“摆钟扫过”的感觉。如果你偏喜好干脆利落的效果可以把blend直接设成0把idx四舍五入成整数。3.3 C#控制脚本自动摆动与鼠标拖拽Shader负责渲染接下来需要一个C#脚本把角度传进去。下面这个PendulumController同时支持自动摆动和手动拖拽用Unity的材质参数直接控制。using UnityEngine; public class PendulumController : MonoBehaviour { public Material targetMat; public bool manualMode false; public float amplitude 45f; public float speed 2.5f; public float manualAngle 0f; private float m_DragStartAngle 0f; private void Update() { if (targetMat null) return; // 把模式同步给Shader targetMat.SetFloat(_UseManual, manualMode ? 1f : 0f); if (manualMode) { HandleDragInput(); targetMat.SetFloat(_ManualAngle, manualAngle); } else { // 自动摆动与Shader内公式保持一致 float angle amplitude * Mathf.Sin(Time.time * speed); targetMat.SetFloat(_ManualAngle, angle); } } private void HandleDragInput() { if (Input.GetMouseButtonDown(0)) { m_DragStartAngle manualAngle; } if (Input.GetMouseButton(0)) { float dx Input.GetAxis(Mouse X) * 3f; manualAngle Mathf.Clamp(manualAngle dx, -amplitude, amplitude); } } }注意在自动模式下我使用的公式是amplitude * Mathf.Sin(Time.time * speed)。这里Time.time对应Shader里的_Time.y都是程序运行时间因此两者的角度在帧间是对齐的。只要C#和Shader里都用同一个角度计算公式就不会出现“图片切换时摆钟还没摆到对应位置”的问题。手动模式我采用的是最简单直观的“鼠标横向位移换角度”做法。你也可以换成EasyTouch、新Input System的触摸输入本质都是把增量累加到manualAngle上。拖拽模式配合Shader里的过渡混合感觉就像真实在拨动一本小册子非常推荐体验一下。3.4 Unity场景搭建与材质调试按下面步骤搭一个最小Demo在场景中创建一个Quad适当调整Scale比如(2, 3, 0.1)。新建材质把Shader选成Custom/PendulumPicture。把图集纹理拖到_AtlasTex设置_Columns 5。给Quad挂上PendulumController脚本把材质拖到targetMat。相机放到Quad的正前方比如(0, 0, -6)用正交或透视都行。到了运行时你看到Quad会左右摆动表面上图片会随着摆角切换。如果一切正常你可以直接在材质面板改_Speed和_Amplitude。注意Shader里用了_ManualAngle来接收C#传入的角度在Inspector里它对应的就是Manual Angle这个属性如果手动模式开启你拖动Inspector里的滑块也能看到Quad实时旋转和切换图片这其实是个很不错的Shader调试手段。调试时我建议先在frag里临时把输出变成float4(normalized, 0, 0, 1)也就是用红色通道可视化角度归一化值。如果画面亮度线性变化说明角度计算正常如果画面都是一会儿全黑一会儿全白说明角度范围或归一化逻辑有问题。4. 常见问题与排查技巧实录4.1 常见症状速查表我在做这个Demo和后续扩展时遇到过下面几个高概率问题列成速查表方便你直接对照。症状可能原因解决方案画面显示黑色或全白图集没赋到材质_Columns设置与图集实际列数不一致检查材质Inspector重新拖入图集确认列数图片边缘串色、发灰图集没有PaddingWrapMode不是Clamp给每列加透明Padding设Filter为BilinearWrapMode设Clamp动画模式下图片不切换C#脚本没设置_UseManual或_ManualAngle一直被覆盖检查脚本是否传入材质确认manualModefalseQuad摆动时缩成一条线顶点旋转矩阵里的轴组合与Quad朝向不匹配调整旋转公式中x/z坐标的组合或旋转Quad的父节点图片切换有明显跳变没有过渡blend没有参与计算或frac被误用检查frag中是否用了lerp(col0, col1, blend)Alpha边缘出现黑边图片本身的Alpha通道信息丢失图集导入设置里勾选“Alpha Is Transparency”并把Alpha Source设为Input Texture Alpha移动端性能压力大图集分辨率过大压缩格式不合适将图集切成适合移动端的尺寸使用ASTC压缩如果你遇到的是“画面能摆但图片位置不对”多半是uv.x的处理里少加或多加了一个i.uv.x。这个病特别容易犯我检查这类问题时喜欢把frag输出改成float4(uv0, 0, 1)直接看到UV颜色一下子就能判断当前采样区域是否在正常图片范围内。4.2 避坑经验从实际项目中总结的细节第一个坑是图集Padding。我最初用5张图直接拼成一张图集间距为0跑出来的效果在图片边缘总是有细细的灰线远看就像图片被描了个边。后来我重新制作了图集每张图片左右各留4个透明像素然后把Shader采样坐标微调把采样位置从单纯(uv.x idx) / Columns略缩到(uv.x idx 0.5) / Columns - 0.5 / Columns。这样做之后交叉淡化时邻图内容的干扰几乎消失了。第二个坑是动态合批的问题。如果场景里同材质物体很多Unity会尝试合并Draw Call但材质实例化有时会打断合批。我的做法是给这段Shader的材质设置一个固定的Render Queue不做动态排序如果你只需要一个摆钟其实不用操心这些。第三个坑是URP兼容性。一开始我只在内置渲染管线里测试后来切到URP时发现Shader没报错但画面变白。原因很简单URP下如果材质没特殊处理某些旧式Shader会被当成Fallback处理。虽然我这段Shader没有使用除UnityCG之外的任何URP敏感特性但如果你在URP下遇到问题建议检查项目的“SRP Batcher”是否开启以及材质是否实例化。必要的时候可以把Tags改成RenderPipelineUniversalPipeline并加上对应的Blend设置。第四个建议是关于动画曲线的。如果你们设计师希望“摆中间的时候快速切图摆到两边时减速停留”正弦函数默认就是两头慢中间快与需求正好吻合。但是如果你想做出“匀速扫过所有图片”的感觉就不能用角度归一化而应该用时间归一化。这个区别很小但做产品时经常被纠结。还有一点是关于半透明的。我为了把图片做成圆角海报把图集里的每张图都带着Alpha圆角。Shader里我已经写了Blend SrcAlpha OneMinusSrcAlpha和ZWrite Off如果你在URP或后处理流程里发现圆角边缘出现奇怪光晕多半是透明排序问题把Queue改成Transparent基本能解决。最后再说一个实操中的小心得这个摆钟切图效果我真正调下来后最大的体会是Shader代码本身不是难点图集和角度的映射关系才是决定效果好坏的关键。如果你发现切换时“图片到了但感觉不自然”先别急着改代码去看看你的图片张数、最大摆角和速度这三个参数之间的配合。比如你放了8张图但最大摆角只有30度那每张图分到的角度范围就非常窄摆锤稍微一动就跳过好几张观感一定差。反过来如果你只有3张图却把摆角调到60度那中间就会有一大段空白角度显示同一张图也显得呆。一个很实用的小技巧是你可以把_Columns和_Amplitude这两个参数做成公开属性在Unity运行时用滑动条实时调整。把图片张数放上去再一边看效果一边收拢摆角很快就能找到一个“每张图停留时间刚刚好”的值。我把这个流程叫做“先粗调数量再细调摆角最后定速度”比起一头扎进Shader里去调代码省太多时间。这个效果后续也很容易扩展。你可以把图集替换成序列帧做成“钟摆翻书”的动画也可以把lerp过渡换成更复杂的缩放或位移效果让图片切换时有明显空间位移甚至还可以在顶点Shader里加一个随角度变化的偏移让摆锤本身有一种“甩出去”的惯性感。思路都是一样的用连续时间或连续角度驱动离散索引让同一个材质在不同时刻呈现出不同的内容。
阅读完成 · 觉得有帮助?