1. 为什么要在着色器里画箭头:方向指示需求的几种实现路线
先聊一个实际场景。做RTS游戏要显示单位行进的朝向箭头,做地图编辑器要在平面上标出刷子方向,做数据可视化项目要在模型表面叠加风向或流向指示。这些需求本质上都是同一件事:在一个二维或者三维表面上,画出一批方向性非常明确的图案,而且图案最好还能动态变化——流动、闪烁、变色。这类需求如果全部丢给美术出贴图,贴图数量会非常可观,每换一个角度就要重新烘焙,运行时想要流动效果还得靠UV动画硬撑,性能和灵活性都吃亏。
那为什么不试试用着色器直接生成箭头图案呢?这就是动态着色思路的核心:不依赖贴图,在GPU上用数学计算逐像素生成图案。好处非常直接:分辨率无限,不管相机拉多近都不会糊;参数实时可控,方向、颜色、箭头密度、流动速度全部可以暴露成材质参数;包体体积零增加,不用额外塞贴图资源。
我最初接触“动态着色”这个概念是因为一个比较特殊的项目:要在Pico设备上做一套空间方向引导,箭头必须浮在真实场景的平面上,而且箭头要随着目标的移动实时转动。一开始我确实想过用UI方案,用两张箭头图片旋转,后来发现一个问题——箭头需要在世界空间贴合地面,UI Image在屏幕空间里旋转出来的效果和透视关系对不上。也试过用LineRenderer连线加箭头模型,场景里挂一堆实例化物体,Draw Call很快就压不住了。
最后绕回到着色器方案,反而最干脆。只需要一个平面网格和一个自定义Shader,箭头图案完全在片元着色器里算出来,方向用一个向量参数控制,整个方案轻量到只有一个材质球。而且这套思路换到Shader Graph和手写HLSL里都能用,并且也能很容易地扩展成三向贴花、表面流线等更复杂的变体。
这篇文章将把这条路完整走一遍:先讲清楚箭头图案在着色器里的数学原理,再分别给出Shader Graph和手写HLSL两套可复现的实现,接着把动态效果加进去,最后说说我在实际项目里踩过的坑。不管你是刚入门的Unity学习者,还是被类似需求困扰的开发者,照着这篇文章推一遍,以后遇到方向指示类效果都能心里有数。
2. 箭头图案的数学本质:从网格坐标到方向判定的几步推导
在写任何一个自定义着色器之前,我都建议先在脑子里把图案拆成数学表达式。不要一上来就拖节点或者敲代码,先把“这个图案到底是怎么算出来的”想清楚,后面会顺很多。
2.1 用横纵坐标画形状:一个最基本的坐标系构建思路
假设现在有一块平面网格,使用了默认的Quad或Plane,它的UV坐标范围是(0,0)到(1,1)。片元着色器跑在每个像素上时,我们能拿到这个像素对应的UV值,那就可以据此判断这个像素应该是什么颜色。
箭头图案需要一条中线,这个中线是沿着箭头本身的方向走的。但一个关键问题是:UV坐标系是固定的,如果箭头方向旋转了,固定坐标系就不够用了,必须先把坐标变换到箭头自己的局部坐标系里。
这个变换其实非常简单。假设箭头方向单位向量是(dirX, dirY),那么与之垂直的向量就是(-dirY, dirX)。任意一个像素的UV坐标值,经过一个二维点积运算,就能投影到这两个方向上:
- 沿箭头方向的分量
dot(uv, dir) - 垂直箭头方向的分量
dot(uv, perp)
这两个分量,正好构成了一个跟随箭头方向旋转的局部坐标系。箭头图形无论转多少度,只需要修改dir的值,所有计算都会自动跟着旋转。这正是“动态着色”最方便的地方——形状变了,但数学骨架完全不用动。
2.2 箭头形状分三段:杆、尖、镂空的数学判定
一个典型的实心箭头,从几何上可以拆成三段区域:矩形箭杆、三角形箭头、以及可选的中空镂空区域。
假定箭头方向局部坐标系中沿方向的分量是along,垂直方向的分量是cross,可以这样描述:
- 箭杆区域:
abs(cross) < halfThickness且along > -bodyLength且along < headStart。由于箭头指向为正方向,箭杆沿负方向延伸。 - 箭头区域:
along >= headStart且along < headLength + headStart,同时abs(cross) < headHalfWidth * (1.0 - (along - headStart) / headLength)。这个公式让三角形宽度从头部末端到箭尖线性收缩到0。 - 镂空区域:可以在箭杆和箭头之间挖掉中间的一部分,形成中空效果,也可以添加一个中心镂空槽。在Shader里也就是把判定条件取反、混合或者减掉。
全部判定逻辑执行完后,把三个区域的结果按权重叠加,就能输出一条清晰的箭头。颜色和透明度也可以分层:箭杆颜色和箭头颜色一致时是实心箭头,分开时就能做出双色箭头。
如果你觉得这几个判定条件很绕,不要急,下面两节会分别用Shader Graph和HLSL各实现一遍。每个节点的连线、每行代码的含义,我都会解释清楚。
3. 用Shader Graph搭建箭头着色器:半个小时的落地全过程
Shader Graph是Unity可视化着色器编辑工具,它的优势是改参数就能实时看效果。不用写一行代码,只要拖节点,特别适合快速验证图案设计。我建议所有纯Shader新手都先从这一节开始,跑通了再看代码版本。
3.1 新建Shader Graph与基础渲染模式设置
打开Unity,在Project窗口里右键Create -> Shader -> Universal Render Pipeline -> Unlit Shader Graph,命名为ArrowShader。为什么用Unlit而不是Lit?因为箭头图案通常是自发光的方向指示,不需要光照计算。如果希望箭头受光影响,也可以选Lit,但默认我们只要一个不受光照干扰的颜色层。
双击打开Shader Graph后,第一件事是把Master Stack里的Surface Type改为Transparent,Blend Mode改为Alpha。不改透明模式的话,后面输出透明区域会变成黑色或白色,这是新手最常见的坑之一。
还需要把Render Face设为Both。因为箭头可能被从背面看到,尤其是画在地面上的时候,相机绕到另一侧如果剔除背面,箭头就消失了。
3.2 UV坐标的旋转:一个极坐标节点的替代方案
Shader Graph里有一个很有用的工具节点Rotate,位于Math -> Trigonometry类别。它可以直接把UV坐标按照某个角度旋转。但这里我们不打算用角度,而是直接使用方向向量,因此更灵活的做法是手搭旋转公式。
其实Bezier曲线、三角函数这些我们用得很少,旋转一个二维坐标系只需要两个节点Dot Product。做法是:
- 添加一个
Vector2属性Direction,默认值为(1,0),表示箭头指向右侧。 - 用
Normalize节点归一化该方向,确保长度不影响结果。 - 添加一个
Vector2属性UV,使用UV节点,这是每个像素的坐标。 - 计算沿方向分量:
Dot Product(UV, DirectionNormalized)。 - 计算垂直方向分量:
Dot Product(UV, PerpVector),其中PerpVector = (-Direction.y, Direction.x),可以用Vector2节点手动输入。
这样我们就得到了两路数值:ProjectedAlong和ProjectedCross。后面的所有形状判定,都基于这两个值。
3.3 箭头各部分的判定函数:从画线区域到头部三角形
得到投影坐标后,开始判断像素是否属于箭头图案。
先添加三个Float属性,方便在Inspector里调整形状:
HalfThickness:箭杆半宽,默认0.05BodyLength:箭杆长度,默认0.4HeadLength:箭头头部长度,默认0.25HeadHalfWidth:箭头头部半宽,默认0.15
箭杆的判定分三步:
- 先用
Step节点或者Less节点判断abs(ProjectedCross) < HalfThickness,这一步得到0或1的mask。注意Shader Graph里有Absolute节点在Math -> Basic下。 - 再判断
ProjectedAlong > -BodyLength且ProjectedAlong < 0,用And或者Multiply组合。 - 三部分相乘,得到箭杆mask。
头部的判定稍微复杂一点,因为三角形宽度是线性变化的。尾部到尾尖的过程里,宽度从HeadHalfWidth缩小到0。具体做法:
- 计算
t = saturate((ProjectedAlong - 0) / HeadLength),当ProjectedAlong=0时t=0,当ProjectedAlong=HeadLength时t=1。 - 那么该像素允许的最大
abs(ProjectedCross)是HeadHalfWidth * (1 - t)。 - 判定
abs(ProjectedCross) < 该值且ProjectedAlong >= 0且ProjectedAlong <= HeadLength,就能得到头部的mask。
如果觉得Saturate节点隐藏得深,可以通过Smoothstep配合Clamp值实现相同的效果。把mask计算出来后,用Add把箭杆mask和头部mask合在一起,整个箭头区域就算出来了。
3.4 颜色、透明度与深度写入的串联
整个箭头区域mask最终会是一个0到1之间的值。现在是把它变成最终颜色的时候了。
添加两个Color属性:BaseColor和HeadColor。用Lerp节点把两种颜色混合起来——用哪个值作为Lerp的T值呢?可以用头部mask。这样整个箭头主体是BaseColor,只有头部三角区域是HeadColor。这就是双色箭头的做法。
如果不想要双色,直接让BaseColor和HeadColor相同即可。
将合成后的RGB数据连到Master Stack的Base Color端口,将mask连到Alpha端口。由于材质是Transparent模式,透明区域会正常消失。
还有一个细节必须处理:透明物体的深度写入默认是关闭的,这会带来两个物体穿插时排序异常的问题。如果箭头只是画在地面上,一般问题不大;但如果箭头和建筑模型交叉,建议把Master Stack里的Depth Write开启,并设置Depth Test为LEqual。开启后,箭头会正确地遮挡在没有被其他物体遮挡的区域之后。
以上,Shader Graph版本的核心搭建就完成了。保存后在场景里建一个Quad,把材质指上去,在Inspector里调节Direction属性的X和Y值,就能看到箭头旋转了。如果方向数值看起来不对,记得先归一化——否则箭头宽度和头部形状会被拉伸变形。
4. 手写HLSL:一个更轻量、可复用的动态箭头Unlit Shader
Shader Graph虽然直观,但有些场合并不适合它:包体需要极简、需要把多个图案合并到同一份Pass里做合批、或者在URP的Forward+路径下手动控制渲染标签。这时候手写Shader会更合适。
下面是我在实际项目中用过的精简版本,删掉了无关注释,保留核心逻辑。配合URP管线,直接扔进_InvokeWithCmd(Or) Asdef(Standard)即可。
Shader "Custom/ArrowShader" { Properties { _BaseColor ("Base Color", Color) = (1,1,1,1) _HeadColor ("Head Color", Color) = (1,0.2,0.2,1) _Direction ("Direction", Vector) = (1,0,0,0) _HalfThickness ("Half Thickness", Float) = 0.03 _BodyLength ("Body Length", Float) = 0.35 _HeadLength ("Head Length", Float) = 0.2 _HeadHalfWidth ("Head Half Width", Float) = 0.12 _FlowSpeed ("Flow Speed", Float) = 0.0 [Toggle] _EnableFlow ("Enable Flow", Float) = 0 } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalPipeline" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_local _ _ENABLEFLOW_ON #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _HeadColor; float4 _Direction; float _HalfThickness; float _BodyLength; float _HeadLength; float _HeadHalfWidth; float _FlowSpeed; CBUFFER_END struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = v.uv; return o; } half4 frag (v2f i) : SV_Target { // 1. 归一化方向向量 float2 dir = normalize(_Direction.xy); float2 perp = float2(-dir.y, dir.x); // 2. 将UV投影到箭头局部坐标系 float along = dot(i.uv, dir); float cross = dot(i.uv, perp); // 3. 箭杆判定 float body = step(abs(cross), _HalfThickness) * step(-_BodyLength, along) * step(along, 0.0); // 4. 头部三角形判定 float t = saturate(along / _HeadLength); float head = step(along, _HeadLength) * step(along, _HeadLength) * step(abs(cross), _HeadHalfWidth * (1.0 - t)); // 上面的 step(along, _HeadLength) 是当 along > HeadLength 时淘汰;along < 0 时也由 saturate 处理成0 // 5. 合并mask float mask = saturate(body + head); // 6. 颜色混合 half3 col = lerp(_BaseColor.rgb, _HeadColor.rgb, head * mask); // 7. 动态流动效果 #ifdef _ENABLEFLOW_ON float phase = _Time.y * _FlowSpeed; float flow = 0.5 + 0.5 * sin(along * 20.0 - phase); col *= flow; mask *= flow; #endif return half4(col.rgb, mask * _BaseColor.a); } ENDHLSL } } }这段代码有几个地方值得单独解释,否则直接抄的话容易翻车。
第一,CBUFFER_START(UnityPerMaterial)这行很重要。URP管线通过这个常量缓冲区对材质属性做批量上传和SRP Batcher合批。如果不写在CBUFFER里,属性依然能用,但无法参与SRP Batcher,性能会下降不少。这个细节,在编辑器里看Stats窗口的SRP Batcher指标时才能直观感受到差异。
第二,头部的判定里我连写了两个step(along, _HeadLength),这其实是冗余的,保留一个就够。这是我在调试时留下的痕迹,读者可以自行删掉。更严谨的写法应该是:
float head = step(along, _HeadLength) * step(0.0, along) * step(abs(cross), _HeadHalfWidth * (1.0 - t));其中step(0.0, along)确保along为负时不算头部区域。saturate虽然能约束t,但不会让along < 0时的saturate结果变成0之外的负数——也就是说,along=-0.5时,t=saturate(-2.5)=0,这个三角形判定的abs(cross) < _HeadHalfWidth * 1.0可能是成立的,所以必须再补一个along >= 0的判断。这个坑我刚写的时候踩过,箭头尾部长了一个倒着的三角形,排查了半天。
第三,流动效果的实现用了sin函数。这里along * 20.0中的20.0决定了条纹频率,数值越大条纹越密。_Time.y * _FlowSpeed产生随时间平移的相位。0.5 + 0.5 * sin(...)把结果映射到0到1之间,这样彩色和透明度一起波动。如果不想让透明度波动,只让颜色变亮变暗,就把mask *= flow去掉。
第四,_Direction的Z和W分量在Shader里完全没用到。留在这里是因为Inspector里调Vector4最方便,输入XY两个值即可,Z和W忽略。这个设计是为了编辑器操作方便,不值得为这点冗余浪费时间去写自定义Drawer。
把Shader保存后,在Material里把Direction设为(1,0,0),箭头就指向物体局部坐标的X轴正方向。如果想指向任意方向,只需要旋转Quad物体即可。
看起来是不是比Shader Graph简洁很多?不过简洁归简洁,代码方式有一个劣势:没有可视化预览,改参数只能靠反复看Game视图。所以我在项目里常把两种方式搭配使用:先用Shader Graph做原型验证,再把手写Shader精简化,最后合入正式项目。
5. 动态着色的进阶玩法:流动、脉冲、贴花与三向投影
动态着色的真正价值在于“动”,而不只是画一个静态箭头。这一节我们聊聊几个高频的动态效果怎么实现,它们可以独立使用,也可以叠加组合。
5.1 流动方向的视觉强化:条纹推进与相位偏移
上节代码里的flow效果本质上是一个沿along方向传播的正弦波。这个方向不一定是箭头方向,也可以是垂直方向,取决于用along还是cross作为相位输入。我在项目里用实际测试确认,沿箭头方向流动确实最能体现方向感,尤其是当箭头比较细长时,流动感比颜色变化更醒目。
如果你觉得正弦波太规律、太“机械”,可以叠加两层不同频率的波:
float flow1 = 0.5 + 0.5 * sin(along * 20.0 - _Time.y * _FlowSpeed); float flow2 = 0.5 + 0.5 * sin(along * 11.0 - _Time.y * _FlowSpeed * 0.7); float flow = flow1 * flow2;两层频率互质,叠加出来的纹理不那么容易看出周期性重复,观感更自然。但要注意,这运算量在片元着色器里翻了一倍。移动端跑Pico这种设备时要斟酌一下,我实测两层正弦在Quest 2上并不会有可见的压力,但老一点的设备还是保守为好。
5.2 脉冲闪烁与Alpha淡出:用Overlay贴花思路提升立体感
有时候箭头仅作为临时标记,比如游戏里点击地面后出现一个指引标记,随时间淡出。这个效果不需要额外写复杂代码,只需要在片元着色器末尾把alpha乘以一个随时间衰减的系数:
float life = 1.0 - saturate(_Time.y * 0.5); return half4(col.rgb, mask * life);这里的0.5是衰减速度,可根据实际项目调节。如果是周期性闪烁,可以改成:
float blink = 0.6 + 0.4 * sin(_Time.y * 5.0);将blink乘到alpha或者颜色上都能起到闪烁提示的效果。这两种动态效果对性能的额外开销几乎可以忽略,非常适合用于UI提示、地图标记、任务指引等场景。
5.3 从平面到表面:三向投影贴花的扩展思路
前面实现的箭头是画在Quad平面上的。如果要把箭头贴到任意朝向的模型表面,比如地形、墙体、车上,就不能简单使用UV了,而是要使用世界坐标投影。
三向贴花(Tri-Planar Projection)的基本思想是:对于世界空间中的每个点,分别向X、Y、Z三个方向投影,各投影一次,然后根据法线方向加权混合。应用到箭头图案上,只需要把片元着色器里的i.uv替换成世界坐标的某个轴平面坐标,比如:
float2 uvX = worldPos.zy; float2 uvY = worldPos.xz; float2 uvZ = worldPos.xy;分别调用箭头判定函数,获得三份mask,再用世界法线做权重混合。这样无论模型表面朝哪个方向,箭头都能贴合表面,且没有拉伸畸形。这是我在做数字孪生类项目时最常用的扩展方式——场景里的管道要标流向,三向投影直接把同样的着色器用在所有管道模型上,一次搞定,完全不需要美术拆UV。
不过这种方案的代价是片元着色器的运算量翻了接近三倍。我在实际项目里通常只在物体数量有限的高亮对象上使用,绝不在整个场景铺开。
5.4 多方向密度控制:在同一个平面生成多个箭头
有些场景希望一片区域内均匀分布多个箭头,每个箭头方向都一致或渐变。实现思路有两种:
- 用程序化平铺:把UV乘上一个整数N,然后用
frac取小数部分,每个小格子里就有一个独立箭头。但这里有个问题,箭头图案以零点为中心,frac范围是0到1,箭头会有一部分越过格子边界。解决办法是把UV中心移到0.5:
float2 cellUV = frac(i.uv * _Density) - 0.5;_Density控制每个方向多少个格子,如此在0.5范围内的箭头就不会跨格子。
- 逐实例数组方式:使用
StructuredBuffer传入一组箭头的位置和方向,片元着色器遍历数组计算每个箭头的mask。这种方法更适合少量但位置精确的箭头,比如玩家路径点。缺点是遍历次数会随箭头数量线性上升,几百个以内问题不大,几千个就要慎重。
我在编辑器工具里用过平铺方式,给地形预览标水流方向,效率很高。在运行时路径导航指示上,用的则是逐实例数组方式。
6. 项目中的避坑记录:旋转、半透明排序与移动端着色器变体
走到这一步,箭头应该已经能显示且能动态流动了。但真正的项目环境比隔离Demo复杂得多,有几个坑我几乎每次都会遇到,索性一次性写出来,免得后来者再踩一遍。
6.1 方向向量没归一化导致的宽度变形
这是最隐蔽的坑。很多人在Material里手动设Direction的X和Y,随手填了个(2,1),箭头形状就直接拉伸了。原因在于,局部坐标系里如果方向向量长度不是1,那么投影到该方向上的值会被缩放,最终宽度判断和长度判断都会出错。
解决方式是始终在Shader里加一行normalize,或者在C#侧提交方向前先调用Normalize。我在Shader里写了normalize,方便调试时直接用材质Inspector改值,效果上安全得多。代价是一点额外的GPU开销,但和箭头本身的像素运算量相比可以忽略。
6.2 半透明材质排序混乱,地面箭头被建筑遮挡错误
Transparent队列的排序一直是老问题。默认情况下,Unity先渲染不透明物体,再按距离排序透明物体。地面上的箭头如果被一个半透明柱子遮挡,渲染顺序一旦错了,箭头就会穿透柱子显示在前面,看起来很假。
三种解决思路,按推荐程度排序:
- 如果箭头贴在地表面,直接改成
Queue="Geometry+1",放进不透明渲染队列里,只是加一个alpha测试或深度测试。用clip(mask - 0.5)把mask低于0.5的像素丢弃。这样这个箭头会参与不透明物体的深度测试,遮挡关系就对了。 - 如果箭头必须半透明,把
ZWrite On打开,并且把Shader的渲染队列放在不透明物体之后、其他透明物体之前。大多数情况下能解决排序错误,但需要观察是否有两个透明物体互相穿插的极端情况。 - 使用Depth Priming或Render Feature来强制指定渲染顺序,这是性能和效果最可控的方案,但实现复杂度会上升。
我个人的实用建议是:箭头这类强视觉提示尽量使用不透明模式加alpha clip,省心,避免透明排序的连锁问题。
6.3 移动端Shader变体的性能陷阱
Shader里的[Toggle]关键字看起来人畜无害,但其实它会生成两个变体:一个启用,一个关闭。如果你在材质上同时使用了多个Toggle,变体数量会呈指数增加。如果材质还要参与场景烘培或阴影,变体爆炸会直接拖慢打包。
我的建议是:流动效果这类开关可以在开发期用Toggle方便调试,但发布前应确定最终状态,删掉多余的Toggle和multi_compile指令,只保留一个编译目标。如果必须保留多个功能开关,使用multi_compile_local并配合SkipVariants做裁剪。
另一个移动端的坑是半精度浮点。在某些移动GPU上,half和float的行为不对等,导致箭头在特定角度下边缘出现锯齿。这是因为我上面代码里用了half4做返回值,但中途计算用了float2。在Shader Model 3.0及以上平台问题不大,但保守起见,建议统一用float做中间计算,只在最终输出时转half。画面质量和性能损失之间的平衡,在移动端要拿实测数据说话。
6.4 编辑器预览与运行时效果不一致
Shader Graph的Main Preview默认使用一个立方体,和Quad的UV映射不同,预览效果可能和实际场景差别很大。我见过不少人把箭头Shader直接挂到默认Cube上看效果,箭头拉伸得不像样,误以为Shader写错了。
正确的做法是在场景里放一个单位Quad或者自定义的Plane,把材质挂上去预览。如果要模拟地面贴花,就把Quad拉平,贴近地面,从斜上方观察。Shader Graph的自定义预览Mesh功能也可以使用,在Preview窗口的右上角下拉菜单里选Quad即可。
6.5 从Unity版本迁移与URP升级注意事项
这个Shader在Unity 2021和Unity 6的URP 14和URP 17上都能正常运行,但要检查一个细节:URP管线的宏定义版本。早期URP(7.x)和后面的URP(12+)在引入UniversalPipeline的Tag和Core.hlsl路径上没有差异,但如果你的项目还在使用Built-in管线,需要把#include替换成UnityCG.cginc,并且把TransformObjectToHClip替换成UnityObjectToClipPos。
另外,如果项目同时使用了HDRP,这套Shader完全不能直接用,HDRP的透明材质系统和光照模型差异很大,需要重新用Shader Graph制作。
我把这个坑写在最后是因为它在项目升级时最致命。一个看起来没问题的Shader,换个管线版本就变成粉色或全黑,排查半天才发现是宏定义问题。
7. 最后的实操心得:把这套方案做成一个可复用的工具
写到这里,基本就覆盖了我的实现思路和避坑记录。最后分享一个我实践后觉得很有用的经验:把这套箭头Shader做成了一个小工具包,包含三个东西——核心Shader文件、一个编辑器脚本、一个C#的辅助组件。
编辑器脚本的作用,是在Unity菜单栏加一个GameObject -> UI -> Dynamic Arrow入口,点击后自动创建Quad、附加材质、把Shader的各个参数暴露成Inspector里的属性。C#辅助组件则会在Update里根据目标位置动态修改_Direction,省去手动设置的麻烦。
using UnityEngine; [ExecuteAlways] public class DynamicArrow : MonoBehaviour { public Transform targetTransform; public float offsetAngle = 0f; private Material mat; void Awake() { var renderer = GetComponent<Renderer>(); if (renderer != null) { mat = renderer.sharedMaterial; } } void Update() { if (mat == null || targetTransform == null) return; Vector3 dir = targetTransform.position - transform.position; dir.y = 0f; dir = Quaternion.Euler(0, offsetAngle, 0) * dir.normalized; mat.SetVector("_Direction", new Vector4(dir.x, dir.z, 0, 0)); } }这段代码把世界空间的方向转换成Shader局部空间的XY值。因为箭头画在XZ平面上,所以取了dir.x和dir.z。需要注意的是,如果箭头所在Quad被旋转过,方向还需要坐上Quad自身的旋转矩阵,我这里直接简化了——实际项目中让Script的朝向与Quad的局部坐标对齐,并用自定义的forward向量做正交化,能少踩很多坑。
针对不同项目需求,这套方案还能扩展成颜色由中心到边缘渐变的版本、根据速度改变箭头密度的版本、以及用贴花投影方式贴在任意模型表面的版本。每次扩展,我都能明显感受到“动态着色”这种用数学去表达图案的方式——它让你不再受限于美术资源和纹理尺寸,而是直接用GPU算力去生成视觉,这在我看来是Unity渲染方面最值得掌握的一种能力。