Unity Shader 动态着色箭头:不贴图纯代码绘制形状与流光效果
1. 为什么想做动态着色箭头从一次看腻的贴图说起先交代一下背景。我平时做 Unity 项目最怕的不是复杂逻辑而是那些看起来小、做起来绕的视觉细节。有一段时间在做数据流向可视化面板需要在场景里表示物体的运动方向、路径走向、力的作用点一开始的方案特别直接——找美术要一张箭头贴图然后放在 Quad 上或者 UI 里用 Image 显示。第一个版本跑起来倒还行静态箭头嘛能看。但项目推进到中期需求变了箭头要动起来要按照数据的变化实时变色要在不同分辨率下保持清晰锐利最好还有那种流光扫过的科技感。贴图方案直接崩了。一张静态 PNG 怎么动靠切换多张序列帧那就得做图片序列占包体、占内存而且颜色没法实时改。换色用 Mask 或者二次叠加思路能走但每加一个需求就多一层丑陋的 hack。后来我把箭头图案全部改成 Shader 实时生成用顶点着色器 片元着色器在运行时把箭头形状 动态色带 流动效果一次性画出来。这个方案跑通之后我意识到这个思路不止适用于箭头任何形状 动态着色 方向指示类型的视觉元素都可以用同一套方法论解决。这也就是这篇博文的由来。如果你正准备在 Unity 里做自定义形状渲染、动态颜色变化、或者单纯想知道 Shader 怎么在不贴图的情况下画出可复用的图形这篇文章就是给你准备的。不涉及复杂的后处理也不依赖第三方插件纯 Unity 内置管线 Unlit Shader后面我会说明为什么不直接用 URP 的 Lit Shader 或 Shader Graph从思路、公式、代码到踩坑一次讲透。先说结论动态着色箭头的核心不是动画也不是贴图而是在片元着色器里把像素点、方向、距离、渐变的数学关系画出来。理解了这一点你就能绕开所有美术资源用纯代码做出任意你想要的动态图形。2. 动态着色箭头的实现路线为什么我选择纯 Shader 方案2.1 三种主流方案对比贴图、LineRenderer、Shader在开始写代码之前我把手头能用的方案都过了一遍列个对比表让还没入坑的读者少走弯路也让已经在 Shader 路口的读者知道自己的选择是不是合理。方案实现难度动态变色能力纹理清晰度顶点数量/性能典型痛点贴图 UI/Quad低差换色需依赖额外Mask或程序化处理受纹理分辨率限制缩放模糊低尺寸一放大就糊动态颜色需求难以满足LineRenderer配合箭头头中一般需单独处理材质和颜色插值受线段宽度和材质影响抗锯齿难控制中等线段分段数量决定箭头头部和尾部分开拼接缝难看Shader 片元着色器绘制中高强颜色完全由代码控制RGB/HSV随意调矢量级无限放大不糊最低一个Quad即可需要理解坐标变换与UV空间调试稍复杂我在最终项目里选了纯 Shader 方案。原因很简单我们需要的不是一张会动的图而是一段能根据数据实时改变颜色和运动状态的代码。一个带动态着色的箭头在数据可视化里通常要和业务逻辑强绑定——数据是正向流动还是反向流动、速率是高是低、当前是报警还是正常状态这些都要能实时反映到视觉上。贴图方案满足不了这种动态性LineRenderer 拼出来的箭头又太工程了不够精细。2.2 Shader 方案下几个想清楚的边界条件采用 Shader 方案之前有几个概念需要先想清楚不然写起来很容易陷入出不来效果的烦躁渲染目标是什么几何体我最终渲染在一个 Quad 上两个三角形组成的平面。这个 Quad 的 Mesh 可以是 Unity 内置的 Plane注意朝向也可以是我自己用代码生成的四边形。在这个 Quad 平面上每个像素都有对应的 UV 坐标而箭头的形状就通过 UV 坐标来判断。坐标空间和方向箭头是有方向的。我假设 Quad 的 UV 空间中从左到右是 U 方向0-1从下到上是 V 方向0-1。让箭头指向正右方最简单因为 u 从 0 到 1 的过程天然可以代表箭杆长度的延伸方向。颜色如何动动态着色的动态主要体现在三个维度颜色值随时间变化比如 HSV 色轮旋转、颜色亮度随 UV 渐变形成流动感比如从尾到头越来越亮、以及流光沿箭头方向移动用时间偏移来控制。这三个维度可以组合也可以独立使用。等这些边界条件理清了Shader 代码就有了明确的输出目标给定任意一个像素的 UV 坐标先判断它是否属于箭头形状如果属于就根据它所在的相对位置和时间变量计算颜色如果不属于就输出透明色。3. 纯代码绘制箭头形状从平面几何到片元着色器3.1 最简箭头矩形箭杆 三角形箭头的组合判断在片元着色器里画箭头最直观的方式是用几何判断组合出形状。一个向右指的箭头可以拆成两部分箭杆一个矩形条例如 v 方向上位于0.4到0.6之间u 方向上从 0 到 0.65。箭头头部一个三角形底边在 u 0.65 处顶点在 u 1.0 处v 方向上底边宽度从 0.3 到 0.7到顶点收拢到 0.5。在 HLSL/CG 中UV 坐标的范围是 [0,1]采样纹理时的归一化坐标我们可以直接用 if 判断或 step 函数来实现形状判定// 伪代码示意 fixed4 frag (v2f i) : SV_Target { float u i.uv.x; float v i.uv.y; // 箭杆 float shaft step(0.0, u) * step(u, 0.65) * step(0.4, v) * step(v, 0.6); // 箭头三角形底边宽0.4顶点收拢到0.5 float headU 1.0 - (u - 0.65) / 0.35; // 在u0.65处为1在u1.0处为0 float halfWidth 0.2 * headU; // 底边半宽为0.2到顶点变为0 float head step(0.65, u) * step(u, 1.0) * step(0.5 - halfWidth, v) * step(v, 0.5 halfWidth); float alpha shaft head; if (alpha 0.01) discard; // 不在箭头范围内直接丢弃 // 临时颜色后面会替换为动态着色逻辑 return float4(1, 0, 0, 1); }这个基本版本跑起来后你会得到一个红色的、向右的实心箭头。它的形状是硬边的没有任何抗锯齿边缘会有明显的像素阶梯感。如果在 UI 上缩小到 100px 以下还勉强能忍一旦放大边缘锯齿就会非常碍眼。所以下一步我们要解决的问题就是平滑边缘抗锯齿方法是用smoothstep替代step让边缘变成一个从小到大的渐变过渡带而不是 0/1 直接跳变。3.2 引入 smoothstep 消除锯齿把是不是箭头变成属于箭头的程度smoothstep(edge0, edge1, x)这个函数做的事情是当 x 在 edge0 到 edge1 之间时返回一个 0 到 1 之间的平滑插值小于 edge0 返回 0大于 edge1 返回 1。利用它我们可以把硬边缘变成约 1~2 个像素宽的柔和过渡带。假设我们判断v 是否在 0.4 到 0.6 之间原来用step(0.4, v) * step(v, 0.6)现在可以改成float vInShaft smoothstep(0.38, 0.40, v) * (1.0 - smoothstep(0.60, 0.62, v));这里我特意把过渡带设在 0.38-0.40 和 0.60-0.62 之间也就是射界外放 0.02 的软化范围。这个 0.02 不是拍脑袋定的而是和屏幕分辨率、Quad 尺寸有关——Quad 在屏幕上占据的像素越多这个软化范围的绝对像素宽度就越大锯齿就越不明显。反过来如果 Quad 很小过渡带太宽会让箭头变虚需要把软化范围缩小。经验值是先取 0.01 到 0.03再根据实际效果微调。同理三角形头部的边缘也可以用 smoothstep 处理。完整的形状判定代码我放在后面第 5 节给出因为它还涉及流动效果和动态颜色的整合。这里你要抓住的核心思想是着色器阶段没有实体的概念一切边缘都是数值上的阶跃或不连续smoothstep 就是在不连续的地方人为制造一个连续的斜坡用来欺骗人眼让边缘看起来是柔和的。3.3 为什么用 UV 数学不用 SDF有向距离场也能画出好箭头有图形学背景的读者可能会问箭头这种带锐利角点的形状用 SDFSigned Distance Field不是更标准吗比如sdTriangle、sdSegment组合起来距离场天然支持抗锯齿和描边。确实SDF 的形状判断更优雅尤其是做圆角、描边、阴影时优势巨大。但我在这个场景里选择直接用 UV 几何分区原因有两个省预算SDF 需要计算点到线段、点到三角形的最短距离涉及开方或向量投影运算量相对更大。箭头这种简单形状用 UV 区间判断已经足够每个像素只做几次比较性能开销非常低。与后续的动态色带、流光 UV 计算更贴合我们的流动效果本质上是沿箭杆方向做 UV 偏移而 UV 偏移在 SDF 距离空间里表达很绕在原始 UV 空间里则是一行u _Time.y * speed的事。当然如果你的箭头形态复杂比如多个分叉、弯折、渐变描边SDF 会是更好的选择。这里先把简单方案吃透遇到复杂需求时再升级不会太痛苦。4. 动态着色的核心逻辑时间变量、渐变和色带流动形状画出来了接下来就是让颜色动起来。这里的动有几个层面我分开说每一层都有对应的代码技巧和调试思路。4.1 颜色随时间变化HSV 色轮旋转最简单的动态着色就是让某个基色随时间周期变化。比较实用的做法是 HSV 转 RGB因为 HSV 的 H色相是个环状量直接对 H 做时间偏移就能平滑地遍历彩虹色而不会像 RGB 那样偶尔出现难看的中间色。在 Unity Shader 中可以用内置的_Time.y获取自场景加载以来的时间秒然后乘上自定义的旋转速度float hue _BaseHue _Time.y * _HueSpeed; // 色相在0~1之间连续旋转 float3 color hsv2rgb(float3(frac(hue), _Saturation, _Value));注意hsv2rgb在 Unity 的 CG/HLSL 里不是内置函数URP 中部分 Shader 库有但为了保证兼容我一般自己写一个几十行的实现。网上有很多标准的 hsv2rgb 实现稍微一搜就有也可以自己推色相映射到色环的六个区间再插值得到 RGB。我用的版本是经典的两段式插值很稳定。4.2 沿箭头方向的明暗渐变用 UV 坐标当位置信息箭头除了整体颜色会变还可以在形状内部呈现尾部暗、头部亮或头部暗、尾部亮的渐变。这个效果特别适合表达能量流动或数据正在传出。做法非常直接用当前像素的 u 坐标0~1作为亮度因子。float gradient smoothstep(0.0, 1.0, u); // 从尾到头逐渐变亮 float3 shaded color * gradient;如果想让渐变更柔和或更集中在某一端可以引入 pow 来调整曲线形状float gradient pow(u, _GradientPower);_GradientPower等于 1 是线性渐变大于 1 会让亮区更集中在头部小于 1 则让亮区更早出现。这是个很实用的旋钮我一般会暴露成材质参数方便美术在 Inspector 里直接调。4.3 流动流光时间偏移驱动 UV 段接下来是重头戏——让一个亮带或一个色块沿着箭头方向流动。思路是这样的我们希望在某个 u 区间上颜色比亮部的底色更亮或呈现另一种颜色并且这个 u 区间随时间向左或向右移动。比如定义一个流光中心位置float flowCenter frac(_Time.y * _FlowSpeed); // 0~1循环 float flowRegion smoothstep(_FlowWidth, _FlowWidth * 0.5, abs(u - flowCenter));这个flowRegion在流光中心附近接近 1远离中心则光滑衰减到 0。用它叠加到最终颜色上float3 finalColor colored flowRegion * _FlowColor * _FlowIntensity;如果你希望流光看起来像一条有方向的拖尾而不是一个对称亮斑可以用不同的左右衰减速度float distToCenter u - flowCenter; float flowRegion smoothstep(_FlowWidth, 0.0, distToCenter) * smoothstep(-_FlowWidth * 0.5, 0.0, -distToCenter);这样光带的后沿衰减更慢前沿更锐利视觉上更像一个方向明确的冲击波。4.4 动态着色的三种模式组合一个可选开关为了让这个 Shader 有更强的通用性我加了一个_ColorMode开关0固定颜色模式纯色 渐变适合被其他脚本控制颜色1HSV 旋转模式H 随时间变化适合做彩虹效果2数据驱动模式颜色由外部脚本通过 MaterialPropertyBlock 实时传入适合做报警变色等场景代码里用简单的if分支即可GPU 的 branch 在简单模式下性能影响可忽略。需要说明的是如果用的是 Shader Graph这三种模式通过 Sub Graph 和 Branch 节点也能实现但节点比代码更繁琐。我选择手写 Shader 的一个原因就是模式组合多的时候代码的维护成本远比节点图低。5. 完整可运行代码Unlit Shader 直接抄作业下面给出我实际在用的完整 Shader。基于 Unity 内置渲染管线Built-in RP的 Unlit Shader完全不需要灯光、阴影适合 UI、粒子、特效等不需要受光的场景。如果你想把它转成 URP 或 HDRP 版本本质改动只有#include和内置变量的差异后面我也会提到。Shader Unlit/DynamicArrow { Properties { _BaseColor (Base Color, Color) (1,1,1,1) _FlowColor (Flow Color, Color) (1,0.5,0,1) _FlowIntensity (Flow Intensity, Range(0, 3)) 1.2 _FlowSpeed (Flow Speed, Range(0, 5)) 1.5 _FlowWidth (Flow Width, Range(0.05, 0.5)) 0.15 _GradientPower (Gradient Power, Range(0.1, 3)) 1.0 // 模式切换0固定渐变, 1HSV旋转, 2外部驱动 [KeywordEnum(Fixed, HsvRotate, External)] _ColorMode (Color Mode, Float) 0 _HueSpeed (Hue Speed, Range(0, 3)) 0.5 _Saturation (Saturation, Range(0, 1)) 0.8 _Value (Value, Range(0, 1)) 1.0 // 通过 MaterialPropertyBlock 动态赋值的颜色在External模式下用 _ExternalColor (External Color, Color) (0,1,0,1) } SubShader { Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _COLORMODE_FIXED _COLORMODE_HSVROTATE _COLORMODE_EXTERNAL #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; float4 _BaseColor; float4 _FlowColor; float _FlowIntensity; float _FlowSpeed; float _FlowWidth; float _GradientPower; float _HueSpeed; float _Saturation; float _Value; float4 _ExternalColor; // HSV - RGB标准实现 float3 hsv2rgb(float3 c) { float3 rgb saturate(abs(fmod(c.x * 6.0 float3(0,4,2), 6.0)) - 1.5); return c.z * lerp(float3(1,1,1), clamp(rgb, 0, 1), c.y); } v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float u i.uv.x; float v i.uv.y; // ---------- 形状判定 ---------- // 箭杆矩形 float shaftV smoothstep(0.38, 0.40, v) * (1.0 - smoothstep(0.60, 0.62, v)); float shaftU smoothstep(-0.02, 0.0, u) * (1.0 - smoothstep(0.63, 0.65, u)); float shaft shaftV * shaftU; // 箭头头部三角形 float headU saturate((u - 0.65) / 0.35); // 0~1从箭杆末端到尖 float headHalfWidth lerp(0.20, 0.0, headU); // 底边半宽0.2尖部变0 float headEdge smoothstep(0.015, 0.0, abs(v - 0.5) - headHalfWidth); float head step(0.65, u) * headEdge; float shape saturate(shaft head); if (shape 0.01) discard; // 边缘软化后极低alpha直接丢弃 // ---------- 颜色计算 ---------- // 根据箭杆上的相对位置做渐变头部也沿用u float gradientPos saturate((u - 0.05) / 0.95); float gradient pow(gradientPos, _GradientPower); float3 baseColor; #if _COLORMODE_FIXED baseColor _BaseColor.rgb; #elif _COLORMODE_HSVROTATE float hue frac(_Time.y * _HueSpeed _BaseColor.r); // 用BaseColor.r作为初始相位偏移 baseColor hsv2rgb(float3(hue, _Saturation, _Value)); #else baseColor _ExternalColor.rgb; #endif float3 color baseColor * gradient; // ---------- 流光 ---------- float flowCenter frac(_Time.y * _FlowSpeed); float flowDist u - flowCenter; // 非对称衰减正向更锐利负向拖尾更柔和 float flow smoothstep(_FlowWidth, 0.0, flowDist) * smoothstep(-_FlowWidth * 0.4, 0.0, -flowDist); color flow * _FlowColor.rgb * _FlowIntensity * shape; // ---------- 输出 ---------- return float4(color, shape); } ENDCG } } }这段代码里有些细节值得展开讲smoothstep(-0.02, 0.0, u)这是为了处理 UV 边缘的溢出软化。因为 Quad 的 UV 严格在 0~1但 smoothstep 需要一小段过渡带我把过渡带的一部分放到了形状外侧负数区这样箭杆的左边缘不会因为软化而少掉一截。headU 用 saturate 限制在 0~1因为 u 超过 0.65 之后才进入头部区域(u - 0.65) / 0.35正好在 u1 时为 1但为了安全加了 saturate。flowDist 没有做绝对值的对称衰减这里用了两个 smoothstep 的乘积实现了前锐后拖的视觉。如果你需要对称的扇形流光把两个 smoothstep 的参数改成对称的即可。if (shape 0.01) discard这一步非常关键。不丢弃的话边缘软化区被 Blend 叠加时会和背景正确混合但性能会差一些丢弃掉低 alpha 像素可以减少片元写入。在大量箭头同屏时这一行能实打实地省不少 fillrate。6. 在场景中部署动态箭头脚本控制、朝向与布局Shader 写完后后续的用例部署决定这个效果能否真正落地。我总结了几件日常使用中一定会遇到的问题和我的解决方案。6.1 用代码生成四边形网格Quad而不是用内置 MeshUnity 内置的 Quad 网格是 X-Y 平面朝 Z 的用起来其实没问题。但我在数据可视化项目里经常需要不同的大小、不同的轴对齐方式用代码生成一个自定义的四边形 Mesh 更清爽还能顺便设置好 UV 方向和网格尺寸。生成的核心函数如下using UnityEngine; public static class QuadMeshFactory { // width/height 是局部坐标下的尺寸uvYUpfalse 时V轴向下UI习惯true时向上世界空间习惯 public static Mesh Create(float width, float height, bool uvYUp true) { var mesh new Mesh(); var vertices new Vector3[4]; float hw width * 0.5f; float hh height * 0.5f; // 顺时针左下、右下、左上、右上 vertices[0] new Vector3(-hw, -hh, 0); vertices[1] new Vector3(hw, -hh, 0); vertices[2] new Vector3(-hw, hh, 0); vertices[3] new Vector3(hw, hh, 0); var uvs new Vector2[4]; if (uvYUp) { uvs[0] new Vector2(0, 0); uvs[1] new Vector2(1, 0); uvs[2] new Vector2(0, 1); uvs[3] new Vector2(1, 1); } else { uvs[0] new Vector2(0, 1); uvs[1] new Vector2(1, 1); uvs[2] new Vector2(0, 0); uvs[3] new Vector2(1, 0); } var triangles new int[] { 0, 2, 1, 2, 3, 1 }; mesh.vertices vertices; mesh.uv uvs; mesh.triangles triangles; mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; } }注意 UV 的方向决定了箭头在场景中的朝向默认 u 从左到右所以箭头指向局部坐标的 X 方向。如果你想做朝上的箭头只需要把 Quad 绕着 Z 轴旋转 -90 度或直接把顶点和 UV 交换让 v 方向的渐变成为主方向。两种做法我都在项目里用过旋转 Quad 更省事但如果你要在一个材质下批量渲染同一个 Mesh 的不同方向还是只在代码里改顶点/UV 更可控。6.2 用 MaterialPropertyBlock 给每个箭头独立颜色多个箭头如果共用同一个材质直接改material.color会导致所有箭头颜色全部改变。区分个体颜色的标准做法是MaterialPropertyBlockMPB。它的原理是在同一个材质、同一个批次的前提下为不同对象覆盖某些着色器属性值并且不产生新的材质实例减少 Draw Call。使用示例using UnityEngine; [RequireComponent(typeof(MeshRenderer))] public class DynamicArrowController : MonoBehaviour { private static readonly int ExternalColor Shader.PropertyToID(_ExternalColor); private static readonly int FlowSpeed Shader.PropertyToID(_FlowSpeed); private MaterialPropertyBlock _block; private Renderer _renderer; private void Awake() { _renderer GetComponentRenderer(); _block new MaterialPropertyBlock(); } public void SetColor(Color c) { _block.SetColor(ExternalColor, c); _renderer.SetPropertyBlock(_block); } public void SetFlowSpeed(float speed) { _block.SetFloat(FlowSpeed, speed); _renderer.SetPropertyBlock(_block); } }使用前记得在 Shader 的属性关键字里启用_COLORMODE_EXTERNAL或者直接用Shader.EnableKeyword在运行时切换。我一般会在脚本里做一个SetColorMode方法把模式切换和参数赋值封装到一起避免外部调用者直接依赖关键字名。6.3 在 UI 上使用从世界空间到 Canvas这个 Shader 在 UI 场景也一样适用。UI 上的 Image 本质是继承自 MaskableGraphic 的可自定义网格组件你可以写一个继承自MaskableGraphic的自定义组件用OnPopulateMesh生成 Quad 并将canvasRenderer.SetMaterial指定为动态箭头材质。不过我更常用的做法是简单粗暴在 Canvas 下放一个有Image组件的空物体让它的 SourceImage 保持空或者给一张全白 1x1 纹理然后在自定义脚本里覆写它的材质和网格。因为 Image 组件默认会重新生成网格覆写起来比较麻烦所以如果你在 UI 上需要高频使用值得专门写一个 UI 版绘制组件。核心逻辑和 Mesh 版一致只是把顶点从世界坐标变成了 UI 矩形坐标。6.4 箭头的朝向拖动和旋转的实用技巧在很多交互场景中箭头要跟随鼠标位置或朝向某个目标。我喜欢把箭头做成一个朝右的默认形状然后通过transform.rotation Quaternion.LookRotation(direction)或transform.up direction来让它指向目标。由于箭头在局部空间朝 X朝向右方所以用Quaternion.FromToRotation(Vector3.right, direction)一行代码即可完成指向。如果使用 LineRenderer 绘制路径上的连续箭头比如沿曲线流动的方向指示则会在曲线上每隔一段距离实例化一个箭头 Mesh每个箭头的朝向依据切线方向来旋转。这部分逻辑不复杂但要注意批量生成时的性能——建议使用 GPU Instancing 或 Graphics.DrawMeshInstanced 来做大量箭头的高效渲染。7. 优化、常见坑与调试技巧从模糊边缘到看不见的箭头7.1 常规优化让一大堆箭头同屏不卡动态箭头在数据可视化里经常成百上千个一起出现。这时候最容易踩的坑是盲目给每个箭头一套独立的材质实例。我踩过一次一个场景放 300 个箭头每个都 clone 一个 material结果 Draw Call 飙到 300帧率直接掉到 20 帧左右。后来把所有箭头的基色、流光色、速度等参数全部用 MaterialPropertyBlock 控制共同持有同一个材质Draw Call 瞬间降到个位数因为 Mesh 都是一样的Unity 会自动合批。另一个优化是在 Shader 里做低开销剔除如果箭头的屏幕尺寸特别小比如占用不到 16 像素就提高 u/v 的软化值甚至直接跳过高成本的流光计算。不过这个属于特种优化一般项目用不上不展开。7.2 边缘模糊到看不见这是 UV 渐变公式的锅有段时间我发现部分小尺寸箭头渲染出来像一团雾完全没有锐利的边缘。后来定位到问题是smoothstep的过渡带相对 UV 空间来说太大。比如 Quad 在屏幕上只有 64 像素宽那么 UV 的 0.02 就相当于 1.28 像素的过渡带理论上还好。但箭头头部三角形的半宽是随 u 变化的到了尖部附近三角形本身的宽度已经小于过渡带了于是尖部形状崩溃变得圆钝。解决方法是在像素着色器里根据屏幕空间衍生物ddx/ddy动态调整过渡带宽度。具体做法是利用fwidth()函数获取当前像素在屏幕空间的变化率把它乘上一个系数作为 smoothstep 的过渡带宽度float aa fwidth(v) * _AAScale; float shaftV smoothstep(0.4 - aa, 0.4, v) * (1.0 - smoothstep(0.6, 0.6 aa, v));fwidth在 Unity CG 中需要启用_SUNDER相关扩展不过在大多数平台上已经默认支持。这个方案应对不同屏幕分辨率、不同缩放比例的 Quad 都很稳定强烈推荐在需要跨屏的设备上使用。7.3 动态颜色和 UI 遮挡的关系TextMeshPro 被 UI 挡到的经验这个话题有些读者可能觉得跑题但我在做 UI 版箭头时真踩过把箭头放在 Canvas 底层上面覆盖一排TextMeshProUGUI结果箭头渲染在了文字上面文字看起来像被半透明色块遮挡。排查了半天发现是渲染队列和 ZWrite 的问题。我的 Shader 设置了ZWrite Off但Queue是Transparent它的渲染顺序是越靠后越晚渲染。在同一个 Canvas 内如果 Image 的material没有设置正确的renderQueue默认的材质可能是Geometry或者AlphaTest它们比 Transparent 早渲染于是箭头就被认为画在文字前面。解决方案在生成材质时显式把renderQueue调到一个比较大的值比如(int)UnityEngine.Rendering.RenderQueue.Transparent 100或者放进 Overlay 层。如果你用的是 SpriteRenderer 世界空间箭头也要注意 Sorting Layer 和 Order in Layer 的设置。这类看不见的问题往往不是 Shader 本身的问题而是渲染管线的队列和层级问题。7.4 Shader Graph 路线什么时候可以用什么时候还是写代码快如果你看到这里觉得代码方案太硬核想用 Shader Graph 实现也可以。基本结构是UV 节点 - 各种运算节点Smoothstep、One Minus、Multiply- 组合成形状 - 再用时间节点做流光。但我要泼一点冷水动态着色箭头这个场景Shader Graph 做原型确实快但做生产版本很痛苦因为形状判断逻辑一旦复杂节点树的连线会非常难维护而且不同 Unity 版本之间节点兼容性也可能出问题。我现在的建议是只是想快速看效果用 Shader Graph 搭个原型10 分钟。要深入业务逻辑、要动态控制颜色速度、要跨平台兼容直接手写 Shader 维护起来更轻松。7.5 在 URP/HDRP 下使用这段 Shader 的改动清单我在一个 URP 项目里也用过这个 Shader改动点其实不多列出来给你参考#include UnityCG.cginc改为#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlslUnityObjectToClipPos改为TransformObjectToHClip如果有雾/光照相关需求可能需要加入 URP 的Lighting.hlsl但因为我们用 Unlit所以不需要。注意 URP 中默认会开启 SRP Batcher如果你的材质被大量合批需要确保 Shader 的所有材质属性都声明在CBUFFER中不能是Properties里直接裸露的变量。在 URP 的手写 Shader 里要把每个参数都包在CBUFFER_START(UnityPerMaterial)和CBUFFER_END中否则 SRP Batcher 无法生效。8. 不止于箭头这套动态着色思路能复用的场景最后再说点稍微发散的内容。动态着色箭头的本质是把形状通过 UV 区域判断和着色通过时间/位置变量做渐变分离开来。这个结构一旦建立你能扩展出的效果非常可观多边形箭头、圆点标记、动态圆环形状判定从矩形 三角形换成极坐标下的角度范围 半径范围就能画出圆形扫描、环形进度条、罗盘指针。流动路径 / 流程图连线在一段线条上可以用多个 Quad 拼接或一个宽扁 Quad用同样的流光公式让色带沿路径移动可以做出数据流传输的视觉反馈。放射状脉冲 / 雷达扫描把 UV 映射从直角坐标换成极坐标再叠加时间偏移就能从箭头流光衍生成雷达扫描或声波扩散。技能范围指示器经常在 MOBA 游戏里看到的扇形、圆形技能指示器也是同一套 Shader 逻辑能做出来的。只要把范围形状的数学公式写对动态颜色和淡入淡出都好办。这些扩展方向的共同点都是不需要美术画贴图、不需要序列帧、不需要额外资源包只靠一段 Shader 代码在运行时动态生成。对压缩包体、提高清晰度、以及动态数据可视化需求来说价值非常大。我在实际项目中踩过几次坑之后最深的体会是Shader 调试不能靠猜一定要把中间量可视化出来。我的做法是给 Shader 加几个调试通道用#define控制frag函数的输出分别输出形状值、渐变值、流光值这样一眼就能看出哪一步的计算出问题了。你也可以在 Material 的MainTex上用纹理直接可视化某些控制量——不过这属于 Upscale 操作正常调试先用调试通道就够了。最后分享一个使用上的小技巧动态着色的视觉效果很大程度上依赖于过度带参数的微调。不要试图在一个分辨率下调到一个完美的值然后永远不变。我的项目测试清单里有四档手机竖屏、手机横屏、PC 全屏、UI 缩放到 50%逐档跑一遍看边缘和流动效果稍有变化用fwidth动态软化能解决大部分问题。如果懒得做动态软化就保证在最小目标分辨率下边缘锐利较大屏幕上稍微柔和点也无伤大雅。这些经验是我从贴图箭头糊一脸的阶段一步步走过来攒下的写在这希望能让刚入门的读者少走几段弯路。如果你在移植到自己的项目时遇到 Shader 效果和预期不一致的情况欢迎按调试通道的思路拆一遍多半能找到问题所在。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →