ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity 动态着色实战:用 Shader 实现流动箭头图案

Unity 动态着色实战:用 Shader 实现流动箭头图案 先别急着打开Unity这个需求比想象中通用得多。你搜索“【动态着色】Unity 实现箭头图案”大概率不是真想画一个静态箭头贴图而是要在一张平面、一块地面或者一段路径上做出“有方向感的、会流动的箭头提示”。这种事我在项目里遇到过好几次地面技能预警要标方向、河道水流要给人“往那边流”的视觉暗示、工厂数字孪生场景里要标传送带物料走向。用序列帧做要准备一堆图用粒子做控制方向又不够准最后兜兜转转还是回到Shader。这篇文章就把我实现这个效果的完整思路写出来。我会从需求拆解、原理推导、可复制的Shader代码、参数调优到常见坑位全部过一遍。适合刚接触Shader但又不想抄一坨看不懂代码的Unity开发者也适合被策划临时加需求、想快速出效果的客户端同学。你不一定要懂很深的光照模型只要知道UV、坐标变换和一点点函数就可跟着做。1. 这个东西到底解决什么问题先看应用场景1.1 效果定义流动箭头不是一个花哨需求我把“动态着色 箭头图案”拆成三个实际维度看。第一是“箭头”也就是物体表面要出现清晰的、能看出指向的形状第二是“动态”箭头不是静止贴图而是会沿某个方向持续移动或者颜色会有节奏地变化第三是“着色”它更多依赖片元着色器里的实时运算而不是靠美术提前画好一帧帧动画。这个组合在实战里的出现频率远比你想象的高。比如MOBA或者动作游戏里地面技能的扇形预警往往带一个由中心向外扩散的箭头纹理开放世界里的任务引导也会用一层动态箭头光线来指引玩家往目标点走在偏工业展示类的项目里管道流体方向、传送带运动方向、甚至人流动线模拟都可以用同一套方案来做。我最早做这个效果是被项目里一个“地表技能指示器”逼出来的。策划希望玩家的某个位移技能释放时地面上能出现三条并排的箭头快速扫过并且方向和技能位移方向严格一致。当时我第一反应是找美术做序列帧结果美术反问了一句“技能方向有8个施法距离还有3档你要我出多少套图”那一刻我就知道必须换一种真正程序化的方案。1.2 三个自然方案怎么选序列帧、粒子、Shader接到类似需求时最简单的直觉往往是找美术资源但这背后得算账。序列帧动画是老牌方案把箭头流动过程预渲染成一摞图然后按时间播放。优点是画面效果完全可以由美术控制缺点是方向一旦多了内存和制作时间会指数级增加。假设一个技能需要8方向每方向24帧每帧512 x 512的贴图这一套下来光贴图内存就相当可观而且UWA一查又要解释半天。粒子方案听起来灵活用ParticleSystem发射一排箭头模型或者Quad贴图。但这个方案的问题在于粒子的排布很容易看起来“散”很难做到严格等距、固定数量的箭头队列。尤其你想让箭头像传送带一样均匀持续地移动用粒子去维护这个节奏并不省心调曲线的时间不比写Shader少。Shader方案是最后胜出的那个。它不依赖额外贴图资源用代码实时计算颜色和形状方向只要改一个角度参数密度和速度也都是参数。换了角度需求材质复制一份改个数值就行项目打包体积几乎不增加。所以我个人的判断标准很简单只要箭头是规则排列、有明确方向、且数量会随需求变化优先考虑Shader。1.3 “动态着色”这个名字怎么理解有些文章里会把它叫“程序化纹理动画”咱们这里就叫动态着色因为效果最终由片元着色器逐像素计算出来。你在材质面板上调速度、调角度Shader内部把UV坐标做旋转和滚动再根据滚动后的位置算出每个像素应该输出箭头还是背景色。这里有一个核心心得像这种表面图案别一上来就想着写复杂的光照。动态着色通常跟PBR光照没关系它更像是在物体表面叠了一层“会动的纹理”你只需要在片元着色器里把颜色算出来必要时和原有纹理混合即可。Unity里最简单的是用一个Unlit Shader改出来的Pass这样既不会受场景光照干扰又能保持箭头颜色鲜艳。2. 核心原理一个箭头是怎么从坐标里长出来的2.1 从UV说起为什么流动要先做坐标偏移想理解动态着色先得理解UV是什么。UV本质上就是一张二维坐标纸横向是U纵向是V取值范围通常在0到1之间。模型上每个顶点都带一组UV代表这个点对应到纹理的哪个位置。片元着色器拿到的是经过插值以后的UV所以你在片元Shader里看到的uv其实是一个连续变化的坐标。箭头流动的本质就是对UV坐标做滚动每个时刻让采样坐标或者计算坐标往前走一小段只要走得足够快人眼就觉得画面在流动。实现时可以直接把U值加上 _Time.y 乘一个速度再把结果传给后面的形状判断。不过更严谨的做法是先建立“方向轴”这样箭头不止能横着走还能朝任意角度流动。我是这么处理方向的先把UV中心从0.5移到原点这样后面旋转时绕的是面片中心而不是左下角。然后用向量点乘把二维UV拆分成长度方向和宽度方向。一个向量决定箭头朝哪走另一个垂直向量决定箭头离中心线多远。看起来有点数学实际上就是换了一套坐标系让后续一切都变得简单。2.2 分段是这一切的骨架frac取小数floor取整数如果你只把整张UV滚起来看到的效果是一张连续纹理在平移并不会自动变成一排整齐的箭头。要让箭头一个一个出现得先把方向轴的坐标“切成段”。UnityShader里有个非常好用的组合floor函数向下取整frac函数取小数部分。假设某个位置的方向坐标是3.7floor(3.7)得到3这是第3段frac(3.7)得到0.7代表在这个段内的相对位置。箭头的形状只需要在0到1这个“段内空间”里画就天然变成了重复排列。FlowSpeed会改变方向坐标随时间增长的速度时间一前进原本处于0.7的像素会变成0.8、0.9然后从1跳回0进入下一段。正是这个从1跳回0的循环让每个箭头看起来都在往前走。这一点理解以后动态效果就不再神秘。2.3 箭头本体是“杆头”的组合接下来要解决的问题很直接怎么让一个像素知道自己属于箭头的哪个位置我的做法是先定义箭头的轮廓函数。箭头可以粗略看成由一段细长的杆和一个逐渐展开的头部组成。段内坐标从小到大的过程中前半部分半宽较小用来画杆后半部分半宽从一个较宽的值逐渐收缩到一个点形成箭头尖。用代码写就是在给定段内位置 segPhase 后计算当前的半宽 halfW。杆长的比重用 _BodyRatio 控制比如0.65表示前65%是杆后35%是头部。头部为了让尖端在段末尾可以让半宽随 segPhase 增大而线性缩小到接近0。离中心线的横向距离小于 halfW 的像素就输出箭头色否则输出透明。这个方案实现简单而且只需改几个参数就能从“剑形”切换到“大头箭头”形态。这有一个容易踩的坑直接用 step 或 if 判断会产生生硬的锯齿边缘尤其在移动端上看非常明显。我建议用 smoothstep 做一个宽度过渡带。抗锯齿的过渡带宽度不一定要固定可以跟随半宽稍微变化代码里用aa变量控制。这一步虽然只是细节但实际画面差异极大。2.4 颜色怎么动态起来段索引不只是用来切形状如果所有箭头都是一个颜色效果会显得死板。所谓动态着色除了让形状移动还可以让颜色跟着“段索引”变化。这里有个小技巧前面用 floor 拿到的 segIndex 其实是个很好的颜色来源。你可以用它做奇偶交替让连贯的箭头变成红、蓝、红、蓝的队列也可以用 segIndex 乘一个系数再取小数画出彩虹一样循环渐变的颜色。由于每个箭头都带着自己的索引移动颜色不会在移动过程中漂移整体看起来是“一排彩色箭头在跑”秩序感很强。不过要注意颜色如果变化太快会产生闪烁感。人眼对规则流动的敏感度很高但对高频颜色闪烁会很反感。我通常把相邻箭头的色相控制在一定范围比如一暖一冷交替而不是每段都换一个完全不同的颜色。节奏稳定比花哨更重要。3. 实战一个可直接复制的Unity Shader3.1 先搭好Shader框架我直接给一份在Unity Built-in管线下能用的Shader。你把代码存成FlowArrowPattern.shader放到工程里创建材质并赋上去然后放到任意物体上就能看到效果。Shader Custom/FlowArrowPattern { Properties { _FlowSpeed (Flow Speed, float) 1.0 _FlowAngle (Flow Angle, Range(0, 360)) 0.0 _SegmentCount (Segment Count, Range(2, 32)) 8.0 _BodyRatio (Body Length Ratio, Range(0.3, 0.95)) 0.65 _BodyWidth (Body Width, Range(0.005, 0.1)) 0.015 _HeadWidth (Head Width, Range(0.02, 0.5)) 0.12 _ColorA (Color A, Color) (1, 0.3, 0.3, 1) _ColorB (Color B, Color) (0.3, 0.6, 1, 1) } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; float _FlowSpeed; float _FlowAngle; float _SegmentCount; float _BodyRatio; float _BodyWidth; float _HeadWidth; fixed4 _ColorA; fixed4 _ColorB; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { // 把采样坐标移到以0.5为中心的区域 float2 uv i.uv - 0.5; // 根据角度构建流动方向轴和垂直方向轴 float rad radians(_FlowAngle); float2 axis float2(cos(rad), sin(rad)); float2 perp float2(-axis.y, axis.x); float along dot(uv, axis); float across dot(uv, perp); // 方向轴坐标随时间滚动并切成段 float moving along * _SegmentCount _Time.y * _FlowSpeed; float segIndex floor(moving); float segPhase frac(moving); // 根据段内位置计算箭头半宽 float halfW _BodyWidth; if (segPhase _BodyRatio) { float u saturate((segPhase - _BodyRatio) / max(1e-5, 1.0 - _BodyRatio)); halfW lerp(_HeadWidth, 0.002, u); } // 用平滑过渡抗锯齿 float aa 0.004; float arrowMask 1.0 - smoothstep(halfW - aa, halfW aa, abs(across)); // 根据段索引交替颜色 fixed4 finalColor lerp(_ColorA, _ColorB, step(0.5, frac(segIndex * 0.5))); finalColor.a * arrowMask; return finalColor; } ENDCG } } }这段代码没有开阴影、没有灯光计算就是最纯粹的图案叠加。你是用它给Cube当特效用或是放在Plane上当脚底指示都没问题。注意我设置了Cull Off和ZWrite Off是为了让它在透明物体上使用时不至于出现背面剔除和遮挡排序的意外情况。3.2 关键代码逐段拆解上面这份代码里uv那两行是很多新手容易忽略的。Unity模型自带UV通常左下角是(0,0)你要是直接拿它做旋转旋转中心会跑到左下角去等会你设角度45度时会发现箭头在物体上乱窜。所以第一步把uv整体减0.5让原点挪到面片中心。然后是方向轴构建。axis向量决定了箭头的前进方向perp向量是垂直于它的横向。along坐标用于判断间隔和流动across坐标用于判断像素离中心线的距离有多远从而绘制箭头的宽度。这四个量是后面所有计算的坐标系基础。moving那段完成了分段滚动。_SegmentCount就是你想在平面上看到多少个箭头组值越大箭头越密。_Time.y乘_FlowSpeed让坐标随时间均匀增长。Unity的_Time是内置时间变量不用自己声明也可用。颜色尾段用了frac(segIndex * 0.5)配合step产生0和1交替。step函数类似一个阈值开关第一个参数是边界第二个参数超过边界就返回1。我这里不是直接用segIndex取模而是用小数比较是为了避免不同平台对负数的取模行为不一致。Shader在移动端的精度和行为差异比你想的敏感多了。3.3 在场景里快速搭一个演示保存Shader代码后我建议你先别急着放到正式模型上。新建一个Plane把材质赋给它因为Plane的UV正好是0到1预览效果最直观。材质创建后Inspector里你会看到Flow Speed、Flow Angle、Segment Count等参数。试着先调这两个Flow Angle改成90会让箭头从水平流动变成垂直流动Flow Speed改成2流速会明显加快。Modify Body Ratio箭头的杆和头比例会变Body Width和Head Width会改变箭头的粗细和大小。我发现最直观验证效果的方式是把Plane放在默认相机前然后打开Game视图稍微转动平台让透明图案从不同角度被看见。你如果发现箭头看起来是反的别慌把Flow Angle旋转180度即可或者去看贴图的Tiling和Offset是否被改过。3.4 如果项目用URP/Shader Graph怎么办这段代码基于内置管线但URP项目里还是能用只是得注意Shader语义的兼容。新版URP里Unity已经推荐用HLSL不建议继续使用CGPROGRAM不过Unity为了兼容性一般还能编译简单的CG Shader。如果你的项目升级后报错最简单的做法不是硬解这段代码而是照着思路在Shader Graph里搭一个。Shader Graph里实现这个效果的核心节点是Tilling And Offset加Time再加上一个自定义的UV旋转。用Vector2节点做方向轴用Dot Product节点把旋转后的UV拆成长度方向和垂直方向然后剩下的就是数学节点拼图。箭头半宽部分用Add、Multiply、Smoothstep能搭出来。坦白说Shader Graph搭这种程序化图案比写代码更折腾因为很多中间变量没有命名空间但优点是美术能直接拖参数。如果你的项目坚持用URP的Lit渲染又不想写无光照Pass还可以在Fragment Stage里把算出的颜色接到Emission上再配合Surface Transparency做半透明。这样图案会受场景后处理影响在某些需要统一色调的项目里反而更自然。4. 调参、避坑和优化记录4.1 我试出来的常用参数区间这里给一份比较稳妥的初始参数组合适合大多数视觉场景。想表现“技能方向预警”时Segment Count可以大一点8到10之间箭头小而密配合高饱和度颜色看起来更紧张。想表现传送带或水流的场景Segment Count推荐4到6让单个箭头更完整、更明显配合0.4左右的Flow Speed会显得从容。_FlowSpeed不是越大越好。速度拉到3以上后人眼会看到明显的闪烁和跳变因为每一帧里箭头位移距离变大视觉上丢失了连续性。移动端尤其明显我一般控制在0.5到1.5之间。_BodyRatio我通常不改0.65是比较自然的杆头比例。_BodyWidth和_HeadWidth需要根据物体尺寸调整不要看着Inspector里的默认值就以为所有物体都适用一个超大屏上和一个小胶囊上的观感会差很远。我自己的调参顺序是先把Flow Angle调到正确方向再调Segment Count决定数量接着调BodyWidth和HeadWidth看清形状最后才调Speed和颜色。如果一开始就动Speed视觉效果非常容易骗人会让你误以为需要改密度或宽度。4.2 最容易翻车的三个细节第一个坑是透明排序。Shader里用了透明混合而透明物体在Unity里是单独排序的。如果这个箭头特效和另一个粒子特效重叠后写的往往会盖住前写的看起来像闪烁。解决方法一般不是改Shader而是改Tags里的Queue值或者把特效挪到不同层。别在这上面死磕透明排序本身就是个系统工程。第二个坑是模型UV可能不是0到1整块。如果你的模型是美术从DCC软件导出的复杂MeshUV有可能是多块UV岛甚至超出0到1范围。这样箭头会在某些面片中间断掉。判断方法很简单你在Material上临时挂一张黑白网格贴图看网格在表面是不是裂的。如果裂了那就别用这个Shader直接套需要做整UV重排或者只用在专用Plane上。第三个坑是负数的frac结果。不同GPU对frac负数定义并不总是相同在一些旧Android机型上负数取小数可能得到负数导致箭头形状反着。所以我前面代码里把uv减0.5后得到的负值直接参与运算一定要观察测试机效果。保险做法是把滚动前的坐标整体加一个足够大的偏置让along始终为正比如along along 10000然后再参与floor和frac。4.3 关于性能你只需要担心这几点这段Shader逻辑本身很轻没有纹理采样没有循环没有分支的复杂运算绝大多数GPU都能轻松扛住。性能风险主要来自两个地方半透明物体填充开销以及你在Shader里额外叠加的纹理数量和特效逻辑。半透明的性能消耗不能只看Shader计算量还要看屏幕上被覆盖的像素数量。一张全屏半透明Plane可能比一个小面积的不透明高画质特效更耗性能。如果你的箭头只出现在模型局部建议把Mesh设法拆小或者直接用贴花方式实现避免整块透明面一直覆盖在场景里。代码里我刻意避免使用for循环。有些教程会把形状设计成好几段的重复求和看着巧妙但对移动端不友好。一个箭头形状如果需要大量迭代老老实实拆成几个if或者用smoothstep组合都比循环干净。我见过有人把一段纹理生成写到10次循环里在PC上顺畅在Pico设备上一掉就是十几帧后来改成纯函数组合才救回来。5. 常见问题排查速查表现象可能原因解决方案箭头完全看不见Shader没有正确赋到材质或渲染队列被挡确认材质Shader路径为Custom/FlowArrowPattern检查物体是否被其他不透明物体遮挡箭头只显示半个像被裁切Plane的UV不完整或模型存在多UV岛换标准Plane测试若模型UV多岛则需重排或改用专用面片箭头方向反了_FlowAngle差180度或UV的V方向定义相反把_FlowAngle加180或调整代码里axis向量的构造方式流速看起来一顿一顿速度参数过高人眼跟不上将_FlowSpeed降到1.0左右检查帧率是否过低导致采样跳变边缘出现明显锯齿忘了用smoothstep或过渡带宽度aa太小调大aa到0.01左右确保arrowMask基于平滑函数输出颜色在移动端显示异常half精度不足或者代码里用了平台相关的取模在关键数值上尽量用float避免对负数直接frac跟场景其他透明物体互相遮挡透明排序问题调整Queue或拆分渲染层级必要时改用不透明背景底URP项目里报错Shader是Built-in CGPROGRAM换成URP兼容HLSL或改在Shader Graph里重构参数名建议初始值适用方向Flow Speed1.0传送带、技能指示Flow Angle0正右方向其他方向按需设置Segment Count8技能高密度提示Body Ratio0.65杆与头比例控制Body Width0.015杆越细越好Head Width0.12头部越大视觉越强Color A红警示类需求Color B蓝对比或信息分层排查方向问题有个笨办法把Plane的贴图坐标打开在编辑器里显示网格和UV然后对着预览调角度边调边记住0度、90度、180度的视觉对应关系。做过一次方向映射后面再也不会倒着加了。如果是AR或者Pico这类以头显视角为主的项目还要额外注意地面特效的方向感是否和玩家头显朝向匹配因为屏幕坐标系和世界坐标系的Y轴处理每家厂商还不完全一致。我后来把这段Shader复制给另一个项目做传送带优化他们嫌默认纯色箭头太“开发版”让我加一层底纹。我就把原来的输出颜色跟一张MainTex采样相加底色保留地面贴图箭头颜色通过乘法叠上去。这样既保留了流程动向提示又能融入场景视觉不用再单独建模或者铺一堆粒子。从实际测试来看这套方案从Built-in管线到URP的迁移也很顺只要你把颜色输出的思路保留住就算Shader Graph节点被美术改得面目全非最终画面逻辑也还是“旋转坐标、滚动坐标、分段画形状、按段上色”四步。所有箭头类动态着色本质上都是这四步的组合体。最后再分享一个小的个人体会写这类程序化Shader时别老想着一次就调到完美状态。先把轴线、分段、形状、颜色四层分别用临时变量固定住一层一层打开调试。你看到的效果越早满足初级需求后面遇到美术提意见时就越有调整底气。这个思路比收藏一堆现成Shader当黑盒用要划算得多。
返回列表