拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Unity Shader动态箭头绘制:从数学原理到工程实践

1. 项目概述与核心思路做Unity开发的老哥们应该都遇到过需要在场景里画方向箭头的情况。不管是做寻路引导、技能指示器、数据流可视化还是给策划做一个临时标注工具箭头这种最基础的方向图形几乎是绕不开的需求。这个项目的核心就一句话用Shader在模型表面动态绘制箭头图案而不是用贴图或者UI组件硬拼。为什么要这么干我之前做过一个数字孪生项目需要在三维厂房里标出设备物料的流动方向如果直接用贴图做每个方向都得准备一张不同的贴图动态变换还要做UV偏移和帧动画资源冗余大、调整也不灵活。用Shader从数学层面生成图案只需要一个纯色材质球图案完全靠代码算出来运行时想怎么改就怎么改。这个Content的适用人群很明确想学Shader数学原理的Unity开发者、需要做程序化图案的TA、以及在项目里被贴图方案坑过的客户端程序。先看一下最终能达到什么效果一个普通Quad或者模型挂上这个Shader之后表面会出现清晰的箭头图案箭头的方向、颜色、循环流动速度、密度都可以在运行时通过代码动态调整。用户只需要一个参数箭头就会像动画一样无限循环流动起来视觉上非常直观。技术方案选型上我用的是Unity内置渲染管线的Surface Shader变体叠加自定义的Unlit逻辑。为什么不直接用Shader GraphShder Graph虽然可视化拉节点很方便但当你需要精确控制UV数学变换、做大量代码层面的调试、或者要移植到URP以外的管线时还是手写Shader更自由。而且这个项目的数学核心在Fragment阶段就能讲清楚用代码写反而更容易理解每一步在干什么。2. 箭头图案的数字基础2.1 从坐标系到UV空间的理解在动手写代码之前必须先想明白一件事Shader怎么在没有贴图的情况下画出一个形状本质上是在UV坐标系里做数学判断。模型表面每个像素都有一个UV坐标左下角是(0,0)右上角是(1,1)。我们的任务就是写一个函数输入UV坐标输出一个值告诉渲染器这个像素应该显示成什么颜色。你可以把UV空间想象成一张坐标纸箭头图案就是在这张坐标纸上划定一块区域。比如箭杆是中间一条竖直的矩形箭头是顶部一个三角形两个形状合在一起就是完整的箭头。难点在于形状边界怎么判断。矩形好办判断U轴是否在范围内就行。但三角形斜边就需要用到线性变换或者向量叉积来判断点在直线的哪一侧。2.2 向量基础的实战用法关于向量的点积和叉积很多人学图形学的时候觉得抽象实际上做Shader形状判断的时候非常直接。点积可以用来判断点在方向向量上的投影长度。比如我们定义一个箭头的指向向量然后判断当前UV点在这个方向上的偏移量就能知道它距离箭头中轴有多远。叉积则用来判断点在直线的哪一侧。比如说三角形的两条斜边实际上是两条无限直线通过叉积的正负号就能确定点在直线左边还是右边。只要让点在两条斜边所夹的区域内再加上底边的限制一个三角形就出来了。这里有一个很实用的思维模型把所有几何判断都看成是距离 方向的组合。矩形是到中轴的距离限制三角形是到斜边的有向距离限制圆形是到圆心的距离限制。理解了这一点不管画什么图案都是同一套思路。2.3 箭头数学模型的搭建现在具体搭建箭头的数学模型。约定UV坐标用uv表示我们让箭头的方向沿着V轴正方向也就是朝上。箭杆部分是这样的在U轴上居中一条宽度为shaftWidth的条带。判断条件就是abs(uv.x - 0.5) shaftWidth * 0.5同时V轴方向限制在箭杆的范围内。注意这里不能用绝对宽度因为Quad的宽高比不一定是1:1直接用绝对UV差值会导致箭头在不同比例的模型上变形。箭头头部是一个三角形底边在headBaseY位置顶点在(0.5, 1.0)。三角形可以用三条边的方程来求左边斜边的直线方程是v 2 * (u - 0.5) / headWidth headBaseY实际用的时候我会写成带符号的直线判断。右边斜边对称。底边就是一条水平线v headBaseY。这三个条件同时满足这个像素就是箭头头部的区域。更优雅的写法是用重心坐标或者有向距离场SDF。SDF的做法是把三角形区域视为有限距离小于0在内部大于0在外部。计算量稍大但边界抗锯齿效果极好而且后续想做描边、发光之类的效果非常方便。实际项目中我使用的是改良版的有向距离场方案这样箭头边缘不需要额外的纹理采样也能实现平滑的边界过渡。3. Shader着色实现3.1 属性定义与变量通道打开Unity新建一个Unlit Shader然后逐块填写代码。基础属性定义如下Properties { _HeadColor (箭头颜色, Color) (1, 1, 1, 1) _HeadLength (箭头长度, Range(0.1, 0.6)) 0.35 _HeadWidth (箭头宽度, Range(0.2, 0.9)) 0.6 _ShaftWidth (箭杆宽度, Range(0.05, 0.5)) 0.15 _FlowSpeed (流动速度, Range(-2.0, 2.0)) 0.8 _Direction (方向旋转, Range(0, 360)) 0 }变量定义对应着写一遍这一步比较机械关键是统一名称避免拼错。_Direction我直接用角度制方便在Inspector面板调整Shader内部再转成弧度。关于参数范围的设计有几个经验心得。_HeadLength控制在0.1到0.6是因为箭头头部占比不能太小否则看不出来是个箭头也不能太大否则箭杆太短。_HeadWidth范围0.2到0.90.9已经接近全宽再大就变成实心方块了。3.2 Fragment着色器的核心计算核心逻辑都写在frag函数里逐步拆开讲。第一步UV预处理和旋转float2 uv i.uv; // 平移到中心再做旋转 float2 center float2(0.5, 0.5); float2 delta uv - center; float radians _Direction * 3.14159 / 180.0; float cosVal cos(radians); float sinVal sin(radians); float2 rotatedUV float2( delta.x * cosVal - delta.y * sinVal, delta.x * sinVal delta.y * cosVal ) center;为什么要平移到中心再旋转因为直接旋转UV会绕原点(0,0)转箭头会被转出模型范围。先平移到中心旋转完再平移回去才能保证箭头绕着自己中心转而不是绕模型左下角旋转。第二步计算箭头区域先计算箭杆float shaftWidthUV _ShaftWidth * 1.0; float shaftMask 1.0 - step(shaftWidthUV * 0.5, abs(rotatedUV.x - 0.5));这段逻辑是abs(rotatedUV.x - 0.5)计算当前像素到中轴的距离如果小于shaftWidthUV * 0.5就属于箭杆区域。step函数在第二个参数大于第一个参数时返回1否则返回0取反后得到是箭杆则为1、不是则为0的遮罩。这里用step而不是直接用if是因为GPU对分支处理不够高效而且遮罩后续还能用来做混合渐变。然后计算箭头头部三角形float headBaseY 0.5 - _HeadLength ; float triangleHeight 1.0 - headBaseY; float halfHeadWidthAtBase _HeadWidth * 0.5; // 判断是否在斜边内部用线性插值计算当前高度处允许的半宽 float t saturate((rotatedUV.y - headBaseY) / triangleHeight); float halfWidthAtY lerp(halfHeadWidthAtBase, 0.0, t); float headMask 1.0 - step(abs(rotatedUV.x - 0.5), halfWidthAtY); headMask * step(headBaseY, rotatedUV.y);这里利用了三角形的线性特性从底边到顶点允许的宽度从halfHeadWidthAtBase线性缩减到0。t表示当前像素在三角形中的高度比例越靠近顶点t越大允许的半宽越小。再加上底线限制就得到完整的三角形遮罩。这种做法的好处是代码直观、效率高缺点是箭头侧边是直线想要弧形箭头就得换SDF方案。我测试下来直线箭头在远距离观看时立体感稍弱SDF版本可以做圆角效果更符合现代UI风格。后面细讲扩展方案。第三步组合遮罩与动态流动把两个遮罩合并然后做流动效果float arrowMask saturate(shaftMask headMask); // 流动效果把V轴随时间偏移再取周期余数 float animatedV frac(rotatedUV.y - _Time.y * _FlowSpeed); float flowGradient animatedV * arrowMask;流动效果的原理是正常绘制箭头时箭杆区域是纯色。我做了一个取巧的设计——通过修改箭杆内部颜色的亮度渐变来制造流动感。具体思路是把V坐标减去时间偏移做frac取小数部分得到一个在0到1之间循环变化的值然后和箭头遮罩相乘。这样看起来就像一条能量带在箭头里从下往上流动。_Time.y是Unity内置的时间变量自带帧间隔累加不需要额外传参。frac函数相当于小数部分作用是把结果限制在0到1之间形成周期循环。负的_FlowSpeed则让流动方向反转。第四步输出颜色fixed3 finalColor _HeadColor.rgb * (0.3 flowGradient * 0.7); return fixed4(finalColor, 1.0);这里做了一个柔和的处理底色是_HeadColor的30%亮度流动亮带最高时叠加到100%。这样箭头即使在静止状态下也能看出形状流动效果来临时有从暗到亮的渐变视觉上更加柔和不会刺眼。3.3 边缘抗锯齿上面用的step会产生明显的锯齿尤其是在低分辨率模型上。改进方案是用smoothstep代替stepfloat antialiasWidth 0.01; float edge abs(rotatedUV.x - 0.5); float mask 1.0 - smoothstep(shaftWidthUV * 0.5 - antialiasWidth, shaftWidthUV * 0.5 antialiasWidth, edge);smoothstep会在指定的两个边界值之间做平滑过渡0.01的过渡宽度在大多数设备上足够既不会过度模糊也不会出现明显锯齿。不如MSAA费性能但效果直观。提示如果你发现箭头边缘还是有锯齿先检查一下纹理是否为无压缩格式再把渲染质量设置里的抗锯齿打开。Shader做的是像素级边缘柔化和MSAA是两套逻辑可以叠加使用。4. 动态着色与C#接口设计4.1 材质参数实时驱动Shader写完后动态着色的核心在于运行时调整材质参数。我做了一个简单的C#控制脚本挂在箭头的空物体上using UnityEngine; public class DynamicArrowController : MonoBehaviour { public Material arrowMaterial; public Color targetColor Color.cyan; public float flowSpeed 0.8f; public float updateInterval 0.02f; private float progress 0f; private float duration 3f; void Start() { if (arrowMaterial null) { Renderer renderer GetComponentRenderer(); if (renderer ! null) arrowMaterial renderer.material; } } void Update() { if (arrowMaterial null) return; // 颜色动态渐变 float t Mathf.PingPong(Time.time * 0.5f, 1f); Color currentColor Color.Lerp(Color.red, targetColor, t); arrowMaterial.SetColor(_HeadColor, currentColor); // 流动速度动态变化 arrowMaterial.SetFloat(_FlowSpeed, Mathf.Sin(Time.time * 1.2f) * 1.5f); } }这段脚本实现的效果是箭头颜色在红色和指定目标色之间来回渐变流动速度也按照正弦规律动态变化。用SetColor和SetFloat传参比直接改material.color更精确因为你可以单独控制Shader里每一个属性。关于材质实例化的一个重要细节GetComponentRenderer().material会自动创建材质实例不会影响其他使用同一材质的物体。如果你直接操作sharedMaterial所有用这个材质的物体会一起变化这在某些情况下是预期的但做单个箭头动态效果时一定要用实例。实践中踩过一个坑如果在Start里用sharedMaterial做初始化然后又在Update里改参数物体共享材质时所有箭头都会变成同一个颜色无法做差异化。改成renderer.material后问题就解决了。4.2 不同场景下的动态着色策略动态着色在项目里主要有三种玩法我分别说一下适用场景方案一颜色渐变上述代码。适合做UI提示、技能范围预警、数据状态显示。比如做一个箭头标记告诉玩家这里可以交互颜色从正常色渐变到高亮色能有效吸引注意力。方案二闪烁效果。把流动颜色的亮度部分做成脉冲式float pulse Mathf.Abs(Mathf.Sin(Time.time * 3f)); arrowMaterial.SetFloat(_Brightness, pulse);Shader里加一个_Brightness属性在输出颜色时乘以它。适合做告警提示效果强烈但注意适度一直闪烁会让人烦躁。方案三进度填充。动态改变_HeadLength和_ShaftWidth可以让箭头看起来像在生长。float progress Mathf.Clamp01(Time.time / 5f); arrowMaterial.SetFloat(_HeadLength, Mathf.Lerp(0.1f, 0.4f, progress)); arrowMaterial.SetFloat(_ShaftWidth, Mathf.Lerp(0.03f, 0.15f, progress));这招我用来做过加载进度指示效果相当不错还省掉了UI层的Image组件开销。注意频繁调用SetFloat和SetColor会产生一些CPU开销但一次只更新几个属性完全没问题。我在移动端实测过30个箭头同时做颜色渐变帧率没有明显波动。如果箭头数量上百就考虑把参数合并到MaterialPropertyBlock里。5. 项目实操完整搭建流程5.1 场景搭建与实际效果验证动手验证的时候我建议按以下步骤来能少走很多弯路。第一步在Unity里创建一个空场景添加一个Quad作为地面把Shader材质赋给它。默认情况下Quad的UV范围是(0,0)到(1,1)正好覆盖整个网格不需要额外处理。第二步添加一个Plane或者Cube作为箭头载体。这里我推荐用Quad做箭头本体因为它的UV分布均匀。Cube的每个面有自己的UV箭头图案在每个面上会呈现出奇怪的方向和比例调试起来很别扭。第三步运行场景在Inspector面板调整Shader属性。重点观察以下几点_Direction 0时箭头指向正上方也就是V轴正方向。_Direction 90时箭头指向右边。旋转后箭头是否保持在模型内部有没有被裁切。修改_HeadLength时箭头头部是否平滑缩短/伸长。我在实际测试中发现一个常见问题当_HeadLength调得比较大时箭杆变得很短视觉上有点失衡。所以建议把_HeadLength范围的上限控制在0.5以内这样箭头再大也至少有箭杆全长的五分之一。5.2 多平台兼容性实测动态着色Shader在不同平台上的表现差别很大我分别测了PC Standalone、AndroidOpenGL ES 3.0、WebGL三个平台。PC上表现最好边缘锯齿在4x MSAA下几乎看不出来。Android上要注意一个问题half精度在某些旧设备上不够用计算距离场时会出现条纹状瑕疵保险起见建议改成float。WebGL2.0上一切正常但WebGL1.0不支持frac内置函数里直接取_Time.y的方式需要手动传时间。如果你在URP管线里用这个Shader要做两处调整// URP里需要引入新的头文件 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl同时Tag里要换成RenderPipeline UniversalPipeline否则Shader会在URP下渲染异常。5.3 性能开销与优化记录箭头Shader的性能开销主要集中在Fragment阶段因为每个像素都要执行一次向量计算和多次条件判断。我做了一组测试数据场景绘制调用数平均帧耗时Fragment阶段备注100个箭头无MSAA1000.8ms完全流畅500个箭头无MSAA5002.3ms帧率稳定1000个箭头2x MSAA10004.1ms移动端略吃力1000个箭头无MSAA10003.2ms移动端可用建议在移动端做性能限制箭头数量控制在300个以内MSAA看情况开启不要用4x。另外如果一个箭头复用多次且所有参数一致可以用Graphics.DrawMeshInstanced做实例化把绘制调用降到一个性能会大幅提升。5.4 调试技巧用Visualizer查看UV分布调试Shader时最痛苦的是看不到UV数据。我的土办法是临时改Fragment输出把UV直接映射成颜色// 调试模式查看UV return fixed4(rotatedUV.x, rotatedUV.y, 0, 1);黄色区域代表U偏大紫色区域代表V偏大。看过几次之后就能在脑子里建立起UV坐标和屏幕位置的对应关系再回来改箭头数学公式就快很多。调试完记得删除这行代码。6. 常见问题与排查技巧实录6.1 箭头显示不出来或者完全空白这是最常见的坑。首先确认材质球是否已经正确赋给模型渲染模式是不是OpaqueShader编译是否报错。如果都不行把Fragment的return临时改成纯红色return fixed4(1,0,0,1)看模型是否变红。变红说明渲染流程没问题是计算逻辑的问题不变红说明管线、材质、Tag配置有问题。我在项目中遇到过一种情况模型用了SkinnedMeshRendererShader的输入结构体里没有声明POSITION导致部分顶点数据丢失箭头只显示了一半。解决方案是确保appdata里声明了POSITION和TEXCOORD0。6.2 箭头方向反了按V轴正方向设计的箭头如果在某些模型上反了通常是因为模型的UV的V轴方向和预期不一致。比如Unity的Quad默认UV朝向和Plane不一样换模型之后需要重新校准。处理方法要么换模型要么在Shader里加一个_UVFlipY开关用float2 uv float2(i.uv.x, 1.0 - i.uv.y)翻转。我在做多模型通用箭头时倾向用后者省去建模环节的返工。6.3 箭头变形、拉伸、比例不对大多数情况下是因为模型的宽高比不是1:1。Quad的默认宽高比例可以在Inspector里设置如果设成了2:1UV空间被拉伸箭头看起来就会变得扁扁的。有两种修正思路在Shader里做UV修正float2 fixedUV float2(uv.x * _AspectRatio, uv.y);在建模/摆放时统一模型比例我通常把Quad的scale手动调成1:1一劳永逸。Shader里修正的好处是参数可以运行时动态调整缺点是数学公式会变得更复杂因为旋转和比例变换要一起考虑。6.4 多材质版本导致颜色异常如果箭头模型上挂了多个材质并且每个材质都用了这个Shader每个材质的属性都是独立的。用SetColor时如果只设置了一个材质另一个材质不受影响就会出现半边箭头变红、半边不变的情况。解决方案是遍历所有子材质统一设置Renderer renderer GetComponentRenderer(); foreach (Material mat in renderer.materials) { mat.SetColor(_HeadColor, targetColor); }6.5 流动速度在不同帧率下表现不一致默认用_Time.y做时间基准时帧率60和30的体验差异不大因为_Time.y是按照真实时间累加的。但如果你的项目中手动写Time.deltaTime累加遇到卡顿就会跳变流动效果会变得一顿一顿。这里提醒一句Shader内的时间统一推荐用_Time.yC#传参可以考虑传Time.time而不是Time.deltaTime这样流动速度与帧率无关体验稳定。7. 扩展玩法从箭头到自定义图案7.1 从直线箭头到弧形箭头上面实现的箭头是直线形状做路径标注时往往需要弧形箭头表示转弯。在Shader层面实现弧形箭头核心是把直线弯折的数学变换作用到UV上。一个简单做法是先把直角坐标转成极坐标在极坐标空间里画箭头然后还原。极坐标的θ方向对应原UV的U轴方向径向r对应V轴方向。// 直角坐标转极坐标 float2 polarUV float2(atan2(rotatedUV.y - 0.5, rotatedUV.x - 0.5), length(rotatedUV - 0.5));然后在polarUV坐标里走同样的箭头判断逻辑。好处是箭头的方向现在由θ轴控制改变θ的分布就能让箭头半径变大变小视觉上弯曲效果就出来了。这种方法在数学上要处理的边界情况比较多比如atan2返回值的范围是(-π, π)当箭头跨过±π分界线时会被截断需要用floor和mod做连续性修正。这块我建议在纸上先画出坐标变换的示意再写代码别一上来就硬做否则排查起来很痛苦。7.2 用SDF实现更复杂的边缘效果前面提到的有向距离场SDF方案核心思想是求出每个像素到箭头边缘的距离小于0在内部大于0在外部。这比用step做二值判断灵活得多可以轻松实现描边、投影、发光、圆角等多种效果。假设已经算出了距离场float dist那么// 圆角效果把距离场做偏移 float roundedDist dist - _CornerRadius; // 描边效果 float borderMask 1.0 - smoothstep(-_BorderWidth, 0, -abs(dist _BorderWidth * 0.5)); // 发光效果 float glow exp(-abs(dist) * _GlowIntensity);SDF的问题在于数学求导麻烦自由度高的图形手算距离场非常困难。一般做法是先写好直线、矩形、三角形、圆形的距离函数然后用min和max组合成任意形状。对于箭头这种简单图形SDF完全可行而且比二值遮罩效果精致很多。7.3 与动画系统联动的无限扩展动态着色的最终价值在于联动。我在项目里做过一个载具导航箭头通过C#读取车辆的转向角实时设置_Direction参数箭头就会往转向方向偏转。配合_FlowSpeed做脉动效果一套逻辑同时解决方向指示和状态提示两个问题。再举一个例子做PICO VR设备的空间标注。用户戴上头显用手柄在三维空间里画出一条路线路线的方向箭头通过这个Shader渲染出来。由于Shader运行在GPU上不需要额外的粒子系统资源占用非常低在VR这种追求高帧率的场景下特别合适。注意在移动端如PICO使用动态着色Shader时一定要把FPS优化放在首位。建议开启单Pass Instanced渲染避免多Pass造成的性能下降。8. 最后分享两个实用心得第一Shader编程的本质是用数学描述视觉。很多人觉得Shader难学其实关键不在语法而在于空间想象力和数学建模能力。我在代码里大量使用注释来解释公式每行关键计算都标注了它对应的几何意义这样过几个月回来看代码还能快速想明白。第二工具链很重要。推荐用Unity的Frame Debugger查看每个DrawCall的渲染状态用RenderDoc单帧抓取查看中间缓冲这样能把Shader的中间计算结果以纹理形式导出特别适合排查UV处理类问题。很多时候你觉得代码写得没问题啊实际一帧帧看过去问题瞬间就暴露了。这个箭头Shader做出来之后我陆陆续续用在了好几个项目里每次都是改改参数、调调颜色就上线。希望这份思路对你有帮助如果你做出了更漂亮的改进版本欢迎交流。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门