尧图精选

Unity Shader实战:程序化生成动态流动箭头,无资源实现方向指示

🕒 发布时间:2026/9/5 17:52:55 📁 来源:尧图网络
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.2y方向占下半部分 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 { QueueTransparent RenderTypeTransparent } 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里的几个设置分别说明QueueTransparent让这个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代码。如果你的项目用的是URPUniversal Render Pipeline需要做一点改动把#include UnityCG.cginc替换成#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl顶点Shader里用TransformObjectToHClip替代UnityObjectToClipPosProperties里不需要改动这里提一下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设置了QueueTransparent如果场景里其他透明物体比较多可能会出现排序问题。最典型的表现是引导路径的一端被其他半透明粒子效果遮挡显示顺序不对。这个问题我建议优先考虑用QueueGeometry1或者QueueOverlay20来调整排序层级而不是在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接缝和旋转裁剪这两个属于高频踩雷区。自己跑一遍比看我写一百个字都管用。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →