用着色器动态绘制箭头:Shader Graph与HLSL双方案实战
2026/9/8 0:21:48 网站建设 项目流程

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) < halfThicknessalong > -bodyLengthalong < headStart。由于箭头指向为正方向,箭杆沿负方向延伸。
  • 箭头区域along >= headStartalong < 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。做法是:

  1. 添加一个Vector2属性Direction,默认值为(1,0),表示箭头指向右侧。
  2. Normalize节点归一化该方向,确保长度不影响结果。
  3. 添加一个Vector2属性UV,使用UV节点,这是每个像素的坐标。
  4. 计算沿方向分量:Dot Product(UV, DirectionNormalized)
  5. 计算垂直方向分量:Dot Product(UV, PerpVector),其中PerpVector = (-Direction.y, Direction.x),可以用Vector2节点手动输入。

这样我们就得到了两路数值:ProjectedAlongProjectedCross。后面的所有形状判定,都基于这两个值。

3.3 箭头各部分的判定函数:从画线区域到头部三角形

得到投影坐标后,开始判断像素是否属于箭头图案。

先添加三个Float属性,方便在Inspector里调整形状:

  • HalfThickness:箭杆半宽,默认0.05
  • BodyLength:箭杆长度,默认0.4
  • HeadLength:箭头头部长度,默认0.25
  • HeadHalfWidth:箭头头部半宽,默认0.15

箭杆的判定分三步:

  • 先用Step节点或者Less节点判断abs(ProjectedCross) < HalfThickness,这一步得到0或1的mask。注意Shader Graph里有Absolute节点在Math -> Basic下。
  • 再判断ProjectedAlong > -BodyLengthProjectedAlong < 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 >= 0ProjectedAlong <= HeadLength,就能得到头部的mask。

如果觉得Saturate节点隐藏得深,可以通过Smoothstep配合Clamp值实现相同的效果。把mask计算出来后,用Add把箭杆mask和头部mask合在一起,整个箭头区域就算出来了。

3.4 颜色、透明度与深度写入的串联

整个箭头区域mask最终会是一个0到1之间的值。现在是把它变成最终颜色的时候了。

添加两个Color属性:BaseColorHeadColor。用Lerp节点把两种颜色混合起来——用哪个值作为Lerp的T值呢?可以用头部mask。这样整个箭头主体是BaseColor,只有头部三角区域是HeadColor。这就是双色箭头的做法。

如果不想要双色,直接让BaseColorHeadColor相同即可。

将合成后的RGB数据连到Master Stack的Base Color端口,将mask连到Alpha端口。由于材质是Transparent模式,透明区域会正常消失。

还有一个细节必须处理:透明物体的深度写入默认是关闭的,这会带来两个物体穿插时排序异常的问题。如果箭头只是画在地面上,一般问题不大;但如果箭头和建筑模型交叉,建议把Master Stack里的Depth Write开启,并设置Depth TestLEqual。开启后,箭头会正确地遮挡在没有被其他物体遮挡的区域之后。

以上,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上,halffloat的行为不对等,导致箭头在特定角度下边缘出现锯齿。这是因为我上面代码里用了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.xdir.z。需要注意的是,如果箭头所在Quad被旋转过,方向还需要坐上Quad自身的旋转矩阵,我这里直接简化了——实际项目中让Script的朝向与Quad的局部坐标对齐,并用自定义的forward向量做正交化,能少踩很多坑。

针对不同项目需求,这套方案还能扩展成颜色由中心到边缘渐变的版本、根据速度改变箭头密度的版本、以及用贴花投影方式贴在任意模型表面的版本。每次扩展,我都能明显感受到“动态着色”这种用数学去表达图案的方式——它让你不再受限于美术资源和纹理尺寸,而是直接用GPU算力去生成视觉,这在我看来是Unity渲染方面最值得掌握的一种能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询