1. 先说说为什么会想到用Shader做动态箭头
做Unity开发的人应该都有过这种经历:策划提了个需求,要在场景地面或者模型表面加一个"流向指示",比如指引玩家前进方向、表现传送带运动、标出风力路径。第一反应往往是拉一个箭头模型进去,旋转一下,完事。但真做起来就会发现,这种方式在动态效果上很受限,箭头是死的,想让它"动"起来就得靠代码改贴图偏移或者轮换贴图,不仅麻烦,而且效果生硬。
我这次要实现的,是用Shader直接生成箭头图案,并且让箭头沿着指定方向循环流动。整个过程不依赖任何美术资源,不需要模型、贴图,纯代码完成,改起来也方便——改个颜色、调个速度、换个方向,全都是参数的事。
这个需求的典型应用场景包括:
- 地图寻路路径的方向指示
- 地面技能范围提示(箭头指向技能释放方向)
- 传送带、管道、气流等流动效果表现
- 角色脚下的目标引导圈
先说结论:用Shader做动态箭头,核心就两件事——用UV坐标算出一个箭头形状;再用时间变量让这个形状沿着指定方向移动。理解了这个思路,后面所有代码都是围绕这两个点展开的。
2. 方案选型:为什么不用贴图、不用模型,偏要写Shader
在动手写代码之前,我其实纠结过一阵子。箭头图案的实现方案大致有四种:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 美术制作箭头模型 | 效果好,可控性高 | 需要美术资源,迭代慢,方向调整要重新做 |
| 贴图 + UV滚动 | 实现简单 | 贴图需要美术出,分辨率固定,拉伸变形后模糊 |
| 程序化网格生成箭头 | 可动态修改顶点 | 每帧修改Mesh性能差,计算复杂 |
| Shader程序化生成箭头 | 无资源依赖,实时计算,参数全开放 | 需要一定的Shader基础 |
我最终选了Shader方案。原因很实际:场景里要做动态箭头的地方可能不止一处,传送带我可能要放十几个,每个方向、颜色、速度都不一样。如果用贴图,一个方向就要一套贴图参数,维护成本直接爆炸。用Shader的话,一个Material属性面板改改参数就行,复用性拉满。
而且动态着色这个需求,本质就是"让图案动起来"。Shader能直接在GPU上做UV运算,不占用CPU,性能开销几乎可以忽略。这一点在移动端项目里尤其重要——后面我会详细说移动端要注意的地方。
提示:如果你的项目对箭头形状要求特别精细(比如带渐变描边的艺术风格箭头),程序化生成的方案可能不够灵活,还是走贴图方案更保险。但如果是功能性的方向指示,Shader方案绝对是最优解。
3. 核心原理拆解:UV坐标系里的箭头数学
先解决第一个问题:一段Shader代码,怎么在平面上画出箭头形状?
3.1 从UV坐标到箭头区域
Shader的fragment阶段会收到每个像素点的UV坐标(范围0到1)。我可以把UV空间当成一块画布,用数学公式判断每个点应该在箭头内部还是外部。
最常见的做法是用分段函数。箭头可以拆成两段:三角形头部 + 矩形尾部。但这样写出来要判断两步,逻辑稍微绕一点。还有一种思路是用距离函数,判断像素点是否在箭头的"骨架"附近。
我用的方案是这样的:把UV坐标映射到以箭头中心为原点的坐标系里,然后判断这个点是否落在箭头轮廓范围内。判断的逻辑是:
- 箭头的头部是一个三角形区域,以(0.5, 1)为顶点,向两侧张开
- 箭头的尾部是一个竖直的矩形,从底部延伸到中间
写成代码就是:
float arrowMask(float2 uv) { // 将UV平移到以(0.5, 0.5)为中心 float2 centered = uv - float2(0.5, 0.5); // 尾部矩形:x方向宽度0.2,y方向占下半部分 float tail = step(abs(centered.x), 0.1) * step(centered.y, 0.0) * step(-0.4, centered.y); // 头部三角形:顶角到两侧的宽度随高度线性变化 float headHalfWidth = 0.35 * (1.0 - abs(centered.y)); // y越接近0.5,宽度越小 float head = step(-0.1, centered.y) * step(centered.y, 0.5) * step(abs(centered.x), headHalfWidth); return saturate(tail + head); }这个算法其实还有优化空间——比如直接用smoothstep做边缘抗锯齿,后面我会讲到。先把基本逻辑跑通。
3.2 让箭头动起来:时间驱动的UV偏移
图案画出来了,接下来让它动。Shader内建变量_Time.y会随时间持续增加,把它加到UV的某个分量上,再对结果取小数(frac),就能实现无限循环的滚动效果。
箭头的流动方向是向上的,所以我对UV的v分量做偏移:
float2 flowUV = float2(uv.x, frac(uv.y + _Time.y * _FlowSpeed));frac保证了UV值永远在0到1区间内,偏移超过1就回到开头重新计算,视觉上就是箭头不断从底部冒出来、流向顶部。这就是"动态着色"的第二个核心点。
这里有一个容易踩的坑:如果直接对整体UV做偏移再算箭头形状,图案的边界在循环接缝处会有一瞬间的"跳变"。我一开始就是这么写的,实际运行起来发现箭头经过边界时会突然消失再出现,非常明显。
解决方案是:对箭头图案本身做平铺。把UV除以一个周期值,再取小数:
float2 tileUV = uv * _TileCount; float2 flowUV = float2(tileUV.x, frac(tileUV.y + _Time.y * _FlowSpeed));这样每个格子内部都是一个独立的箭头,整体看起来就是一行行箭头在持续向上流动,接缝处的跳变被隐藏在相邻箭头的连续性里了。
4. 完整Shader代码与逐行讲解
先放完整代码,再逐段拆解。这段Shader是用Unity的ShaderLab写的,表面是Unlit类型,适合不需要光照的UI和地表标记场景。
Shader "Custom/DynamicArrow" { Properties { _ArrowColor ("箭头颜色", Color) = (1, 1, 1, 1) _BackgroundColor ("背景颜色", Color) = (0, 0, 0, 0) _ArrowScale ("箭头大小", Float) = 0.5 _FlowSpeed ("流动速度", Float) = 0.5 _TileCount ("平铺数量", Float) = 4 _ArrowDirection ("方向旋转", Float) = 0 [Toggle]_UseFade ("边缘淡出", Float) = 0 _FadeWidth ("淡出宽度", Range(0.001, 0.1)) = 0.02 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" float4 _ArrowColor; float4 _BackgroundColor; float _ArrowScale; float _FlowSpeed; float _TileCount; float _ArrowDirection; float _UseFade; float _FadeWidth; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } // 抗锯齿step封装 float aaStep(float edge, float x) { return smoothstep(edge - _FadeWidth, edge + _FadeWidth, x); } fixed4 frag (v2f i) : SV_Target { // 平铺UV float2 tiledUV = i.uv * _TileCount; // 时间偏移实现流动 float2 flowUV = float2(tiledUV.x, frac(tiledUV.y + _Time.y * _FlowSpeed)); // 将UV移到目标区域中心 float2 centered = flowUV - float2(0.5, 0.5); // 按缩放系数调整 centered /= _ArrowScale; // 方向旋转(支持任意角度) float rad = radians(_ArrowDirection); float2x2 rot = float2x2(cos(rad), -sin(rad), sin(rad), cos(rad)); centered = mul(rot, centered); // 箭头轮廓计算 float tailWidth = 0.15; float tailHeight = 0.35; float headWidth = 0.4; float headHeight = 0.4; // 尾部矩形 float tail = aaStep(-tailHeight, centered.y) * aaStep(centered.y, 0.0) * aaStep(-tailWidth, centered.x) * aaStep(centered.x, tailWidth); // 头部三角形:线性收窄 float headT = 1.0 - centered.y / headHeight; // 0在顶点,1在底 headT = saturate(headT); float headHalfW = headWidth * headT; float head = aaStep(0.0, centered.y) * aaStep(headWidth, headHalfW + centered.x) * aaStep(headWidth, headHalfW - centered.x) * aaStep(centered.y, headHeight); // 合并mask float mask = saturate(tail + head); // 混合颜色 float4 col = lerp(_BackgroundColor, _ArrowColor, mask); col.a *= mask; return col; } ENDCG } } }4.1 属性面板与渲染队列设置
Properties里我把所有可调参数都暴露了出来。颜色、速度、平铺数量、方向旋转,全都可以直接在材质面板拖。为什么加了一个_UseFade开关?因为纯step画出来的箭头边缘会有锯齿,尤其是移动端,锯齿会非常刺眼。我用smoothstep做了抗锯齿,但smoothstep本身也有代价——过渡带越宽,边缘越糊。所以把过渡宽度做成可调,让使用者按需配置。
SubShader里的几个设置分别说明:
Queue=Transparent:让这个Shader的渲染顺序排在透明队列Blend SrcAlpha OneMinusSrcAlpha:标准Alpha混合,配合透明度使用ZWrite Off:关闭深度写入,防止箭头挡在透明物体前产生遮挡错乱Cull Off:双面渲染,有些场景下模型法线方向可能朝内,双面保证任何角度都可见
4.2 抗锯齿的细节处理
aaStep函数是对step的升级版:
float aaStep(float edge, float x) { return smoothstep(edge - _FadeWidth, edge + _FadeWidth, x); }step是硬边,在边缘处会产生1像素的突变,视觉效果就是锯齿。smoothstep则是在边界附近做一段线性过渡,让弧形边缘看起来平滑。_FadeWidth控制过渡带的宽度,我建议在0.01到0.03之间取值,太宽会看到明显的"发光"边缘。
4.3 方向旋转为什么用矩阵
我一开始没打算做旋转功能,后来发现实际使用中不是所有方向指示都朝上——有的要朝左,有的要朝右,还有45度斜着的。让美术去旋转贴图不现实,直接在Shader里做一个2x2旋转矩阵处理UV是最优雅的。
float rad = radians(_ArrowDirection); float2x2 rot = float2x2(cos(rad), -sin(rad), sin(rad), cos(rad)); centered = mul(rot, centered);注意:这里旋转的是"坐标",不是"图案"。等价于你把箭头想象成在坐标纸上画好的,现在转动坐标纸的方向。所以旋转的是centered坐标值在实际UV空间中的位置,效果等价于把箭头转了对应角度。
5. 这版Shader是怎么从"箭头"升级到"实用工具"的
上面这版代码如果只是画一个静态箭头,其实没有太大价值。它的真正意义在于"参数化"——所有视觉表现都通过面板调整,不需要改代码。
5.1 平铺数量与流动速度的配合
_TileCount控制一个平面上纵向排列多少个箭头,_FlowSpeed控制流动速度。这里有一个视觉上的小技巧:流动速度要考虑箭头密度。
如果你在1个单位长度上平铺了4个箭头,每个箭头只有0.25个单位的空间,_FlowSpeed设成1,箭头从底部到顶部只需要1秒,视觉上会显得非常快。如果设成0.1,整行箭头要10秒才走完,又显得拖沓。
我实测下来的经验值是:
- 平铺数量2~3,速度0.3~0.5
- 平铺数量4~6,速度0.6~1.0
- 平铺数量8以上,速度1.2以上
这个经验值是针对观察距离在1到5米范围内的情况。距离远了,速度要调快一些,不然流动感会被削弱。
5.2 颜色混合与透明度策略
Shader里用lerp对背景色和箭头色做了混合。有几个场景你需要特殊处理:
第一种:箭头要叠在别的地表Texture上显示,比如草地、路面。这时候_BackgroundColor应该设成半透明的黑色或者直接设Alpha为0。
第二种:箭头本身要发光、要醒目,比如技能预警。这时候背景色设成深色、箭头色设成高亮暖色,对比感最强。
第三种:做路径引导线,箭头需要与背景融为一体,只通过流动感来引导方向。这时候箭头色和背景色用同色系,只是透明度不同。
这三种场景的Alpha混合方式其实不一样,但Shader里我只用了一种——标准Alpha混合。如果遇到特殊的混合需求,比如加法混合Blend One One,可以在SubShader里另开一个Pass,这里先不展开。
5.3 旋转参数和本地坐标的交互问题
_ArrowDirection旋转变换是在UV坐标里做的。如果你把Shader应用到一个旋转过的物体上,需要注意:Shader里的旋转是"相对物体本地坐标"的,物体本身旋转后,箭头方向也会跟着物体转。
这个特性在某些场景下反而有用。举个例子:你要在一个环形跑道模型上做方向箭头,Shader里设旋转角度0,跑道模型旋转到任意方向,箭头都会指向跑道前进方向。因为箭头方向跟随物体旋转了。
但如果你的箭头是绝对方向指示(比如永远指向地图北边),那就必须处理物体旋转和Shader旋转之间的关系,或者干脆用World Space的UV(需要额外传世界坐标到fragment shader)。
6. 实际应用场景与扩展思路
6.1 场景一:地面引导路径
我最早做这个Shader就是为了给寻路系统做可视化引导。玩家点击目的地,地面上出现一条箭头流动的路径,箭头朝向目的地。实现方式非常简单:把路径的Mesh直接使用这个Shader,方向设置为0(默认朝上),然后沿路径放置几段平面,调整平铺数量让箭头密度看起来合适。
实测下来,这条路径的GPU开销几乎可以忽略不计,因为Shader只在fragment阶段做了一些简单的数学运算,没有纹理采样,没有循环。在移动端中端机型上跑,帧率没有任何波动。
6.2 场景二:技能范围指示
MMO或者MOBA里,技能预警经常需要做一些方向性的提示。比如某个Boss要施放一个扇形AOE,扇形内部有箭头指示方向。这种需求Shader也能做,只是要把箭头轮廓的判断改成扇形区域判断,再加上方向旋转参数。
我的做法是:把扇形判断抽象出来,在Shader里增加一个角度范围参数和一个方向参数。先在UV里算出当前像素相对中心点的角度,然后判断角度是否在扇形范围内,在范围内则显示箭头,不在范围内则透明。这部分代码要稍微复杂一点,需要用到atan2,但是效果提升非常大。
6.3 场景三:传送带和管道流
工业仿真和数字孪生项目里经常有传送带、管道流体这类需要表现"物质在流动"的场景。直接用我的动态箭头Shader,把_ArrowScale调小、_TileCount调大,就能形成一条细密的流动点线效果,比单纯的UV滚动贴图更有方向感。
6.4 与URP的兼容性
我用的是Built-in管线版本的Shader代码。如果你的项目用的是URP(Universal Render Pipeline),需要做一点改动:
- 把
#include "UnityCG.cginc"替换成#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" - 顶点Shader里用
TransformObjectToHClip替代UnityObjectToClipPos - Properties里不需要改动
这里提一下,URP的Shader变体会比Built-in多一些,如果你的目标平台有变体数量限制(比如移动端),注意控制变体数量。
7. 我在调试过程中踩过的坑
这部分单独拎出来写,因为这些坑不看实测几乎不可能预料到。
7.1 frac函数带来的箭头断裂
前面提到过,用frac做UV循环时,箭头会在边界处断裂。我最初的做法是把UV整体偏移后再计算箭头,结果每个箭头的首尾在平铺边界处是错开的,视觉上看起来很乱。
我的解决方式是把偏移折叠进UV的v分量计算中:
float2 flowUV = float2(tiledUV.x, frac(tiledUV.y + _Time.y * _FlowSpeed));这样每个tile内部的坐标系是独立的,箭头的完整性在每个tile内得到保证。但要注意,如果你的箭头形状是横跨多个tile的(比如长箭头),这个方案就不适用了,需要换成基于距离的判断逻辑。
7.2 旋转矩阵导致的UV拉伸
用旋转矩阵处理UV时有个隐藏问题:旋转后的坐标范围会超过原始0到1的范围。如果你设置的_ArrowDirection是45度,箭头旋转后,图案的一部分可能跑出原始UV的[0,1]区间,导致显示异常。
我遇到的情况是:箭头旋转后边缘出现"裁剪"效果,箭头的一部分消失。原因就是旋转后的UV超出了0到1范围,Shader默认把范围外的采样值截断(clamp到边界)。
解决方法是:在旋转前对UV做一个缩放,确保旋转后图案仍然在可见范围内。缩放系数需要根据旋转角度动态计算,最保险的做法是:
float absCos = abs(cos(rad)); float absSin = abs(sin(rad)); float scale = max(absCos, absSin); centered /= scale;这样做的好处是:无论旋转多少度,图形最长边始终保持在可视范围内。
7.3 透明队列里的排序问题
动态箭头Shader设置了Queue=Transparent,如果场景里其他透明物体比较多,可能会出现排序问题。最典型的表现是:引导路径的一端被其他半透明粒子效果遮挡,显示顺序不对。
这个问题我建议优先考虑用Queue=Geometry+1或者Queue=Overlay+20来调整排序层级,而不是在Shader内部做复杂处理。透明物体本身的排序规则就是根据深度来定,写死的Shader控制优先级不如调队列位置来得直接。
7.4 移动端性能的实测数据
我在一台骁龙835处理器的老手机上测试过这个Shader,分辨率1080p,屏幕上同时渲染了10个使用该Shader的平面,每个平面约1000个三角形。帧率从满帧60fps掉到了58fps,几乎是净损耗2fps,完全可以接受。
但有一个性能陷阱要注意:不要在高分辨率屏幕上把这个Shader应用于全屏Quad。因为fragment阶段的计算量是跟像素数成正比的,如果全屏渲染,1080p就是200万像素级别的计算量。虽然每个像素计算不多,但总量大,对GPU的负载会上升。我建议箭头Shader尽量应用在小面积物体上,除非你有特殊的全屏需求。
8. 这版Shader还能怎么扩展
8.1 多段颜色渐变
目前的箭头是单色填充,想让它更好看的话,可以加一个渐变参数。在fragment里用centered.y坐标对颜色进行lerp,就能实现箭头从上到下的渐变色。
8.2 闪动效果
技能预警场景里,闪烁比持续箭头更醒目。加一个sin(_Time.y * _FlashSpeed)的系数乘到颜色上,就能让箭头周期性闪烁。
8.3 程序化动画控制
如果你需要让箭头跟随某个事件的触发而流动(比如开始施法时才流动,结束就停止),可以在C#里通过MaterialPropertyBlock控制_FlowSpeed的数值:
var mpb = new MaterialPropertyBlock(); mpb.SetFloat("_FlowSpeed", 0f); // 停止流动 renderer.SetPropertyBlock(mpb);用MaterialPropertyBlock的好处是不会为每个物体创建新的材质实例,不会造成材质批次增加。
8.4 在UI上的应用
如果想在UGUI上使用这个Shader,需要把Shader改成UI专用的版本。核心改动是把UnityObjectToClipPos替换成UI的坐标转换函数,并加上Stencil相关的参数声明。具体代码可以参考Unity内置的UI-Default.shader,把里面的片元着色替换成箭头计算逻辑即可。
我自己测试过UGUI版本,效果和场景版一致,而且UI上可以做得更花哨——加描边、加投影、加流光效果。做技能冷却图标的环形箭头指示就是很好用的场景。
9. 一些个人觉得值得分享的优化经验
最后聊聊我在优化这个Shader过程中的思考。
第一个是Shader变体数量控制。我给_UseFade加了一个Toggle特性,Unity会自动为这个开关生成Shader变体。如果你的项目在最终打包时发现变体数量过多导致加载变慢,可以考虑去掉Toggle,把所有逻辑都塞进主Pass里。我是倾向于用[Toggle]的,因为可以自由切换是否启用抗锯齿,性能可选性好。
第二个是精度选择。这个Shader的运算都是float精度,我知道有人为了优化会改成half。实测下来,因为做的是纯数学运算,精度下降到half后在部分移动端GPU上会出现微小色阶断裂,而性能提升几乎不可感知。所以我的建议是:保持float,不要在精度上扣。
第三个是避免在Fragment里做条件分支。我这个Shader一个if都没有,所有逻辑都用函数和step实现。为什么?因为GPU是SIMT架构,同一个warp里的线程如果走不同的分支就会出现线程发散,性能急剧下降。而step、saturate、lerp这些函数本质上是纯数学操作,GPU可以快速并行执行。
第四个是方向参数与UV坐标系的对齐。很多新手会把_ArrowDirection当世界方向来用,结果发现箭头旋转方向跟预期不符。原因在于UV坐标系的原点在左下角(OpenGL系)或左上角(纹理坐标通常如此),与物体坐标系并不天然一致。用的时候最好先在材质面板上试一下0度、90度、180度分别对应什么视觉方向,再设定你的业务参数。
这段Shader代码和调试经验目前已经稳定用在我几个项目里,效果都很理想。各位在自己项目里实现动态箭头时,如果遇到什么奇怪的显示问题,可以回看上面提到的几个坑——尤其是frac接缝和旋转裁剪这两个,属于高频踩雷区。自己跑一遍,比看我写一百个字都管用。