☰
Unity Shader实现摆钟切图特效:正弦驱动与UV图集采样解析
2026/10/18 4:35:51 网站建设 项目流程

最近刷视频时被那种“摆钟切图”特效给种草了:一个钟摆在画面里左右摆动,摆到不同角度时,画面上的图片跟着切换,像是钟摆扫过哪张图就显示哪张图。第一次看到时我还以为是视频剪辑卡点,后来在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作为t,Shader里就能直接产生持续摆动的角度。

实际调参时我一般会先定振幅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得到整数列号。举个例子,最大摆角A=45度,图集列数N=5,当前角θ=18度,那normalized=(18+45)/(90)=0.7,乘5等于3.5,floor后是第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 { "Queue"="Transparent" "RenderType"="Transparent" } 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 和 z,y 轴保持不变 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):这样在图片切换时会产生一个交叉渐变。比如当blend=0时全显示第idx列,blend=1时全显示第idx+1列,中间则是两张图各显示一半。这种过渡方式比硬切更贴近“摆钟扫过”的感觉。如果你偏喜好干脆利落的效果,可以把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:

  1. 在场景中创建一个Quad,适当调整Scale,比如(2, 3, 0.1)。
  2. 新建材质,把Shader选成Custom/PendulumPicture。
  3. 把图集纹理拖到_AtlasTex,设置_Columns = 5。
  4. 给Quad挂上PendulumController脚本,把材质拖到targetMat。
  5. 相机放到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,重新拖入图集,确认列数
图片边缘串色、发灰图集没有Padding;WrapMode不是Clamp给每列加透明Padding,设Filter为Bilinear,WrapMode设Clamp
动画模式下图片不切换C#脚本没设置_UseManual,或_ManualAngle一直被覆盖检查脚本是否传入材质,确认manualMode=false
Quad摆动时缩成一条线顶点旋转矩阵里的轴组合与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改成"RenderPipeline"="UniversalPipeline"并加上对应的Blend设置。

第四个建议是关于动画曲线的。如果你们设计师希望“摆中间的时候快速切图,摆到两边时减速停留”,正弦函数默认就是两头慢中间快,与需求正好吻合。但是如果你想做出“匀速扫过所有图片”的感觉,就不能用角度归一化,而应该用时间归一化。这个区别很小,但做产品时经常被纠结。

还有一点是关于半透明的。我为了把图片做成圆角海报,把图集里的每张图都带着Alpha圆角。Shader里我已经写了Blend SrcAlpha OneMinusSrcAlpha和ZWrite Off,如果你在URP或后处理流程里发现圆角边缘出现奇怪光晕,多半是透明排序问题,把Queue改成Transparent基本能解决。

最后再说一个实操中的小心得

这个摆钟切图效果,我真正调下来后最大的体会是:Shader代码本身不是难点,图集和角度的映射关系才是决定效果好坏的关键。如果你发现切换时“图片到了但感觉不自然”,先别急着改代码,去看看你的图片张数、最大摆角和速度这三个参数之间的配合。比如你放了8张图,但最大摆角只有30度,那每张图分到的角度范围就非常窄,摆锤稍微一动就跳过好几张,观感一定差。反过来,如果你只有3张图,却把摆角调到60度,那中间就会有一大段空白角度显示同一张图,也显得呆。

一个很实用的小技巧是:你可以把_Columns和_Amplitude这两个参数做成公开属性,在Unity运行时用滑动条实时调整。把图片张数放上去,再一边看效果一边收拢摆角,很快就能找到一个“每张图停留时间刚刚好”的值。我把这个流程叫做“先粗调数量,再细调摆角,最后定速度”,比起一头扎进Shader里去调代码省太多时间。

这个效果后续也很容易扩展。你可以把图集替换成序列帧,做成“钟摆翻书”的动画;也可以把lerp过渡换成更复杂的缩放或位移效果,让图片切换时有明显空间位移;甚至还可以在顶点Shader里加一个随角度变化的偏移,让摆锤本身有一种“甩出去”的惯性感。思路都是一样的:用连续时间或连续角度驱动离散索引,让同一个材质在不同时刻呈现出不同的内容。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询