Unity Shader 动态着色箭头:不贴图纯代码绘制形状与流光效果
2026/9/5 17:31:33 网站建设 项目流程

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; // 在u=0.65处为1,在u=1.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(有向距离场)也能画出好箭头?

有图形学背景的读者可能会问:箭头这种带锐利角点的形状,用 SDF(Signed Distance Field)不是更标准吗?比如sdTrianglesdSegment组合起来,距离场天然支持抗锯齿和描边。确实,SDF 的形状判断更优雅,尤其是做圆角、描边、阴影时优势巨大。

但我在这个场景里选择直接用 UV + 几何分区,原因有两个:

  1. 省预算:SDF 需要计算点到线段、点到三角形的最短距离,涉及开方或向量投影,运算量相对更大。箭头这种简单形状用 UV 区间判断已经足够,每个像素只做几次比较,性能开销非常低。
  2. 与后续的动态色带、流光 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:固定颜色模式(纯色 + 渐变,适合被其他脚本控制颜色)
  • 1:HSV 旋转模式(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=固定渐变, 1=HSV旋转, 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 { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } 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正好在 u=1 时为 1,但为了安全加了 saturate。
  • flowDist 没有做绝对值的对称衰减:这里用了两个 smoothstep 的乘积,实现了"前锐后拖"的视觉。如果你需要对称的扇形流光,把两个 smoothstep 的参数改成对称的即可。
  • if (shape < 0.01) discard:这一步非常关键。不丢弃的话,边缘软化区被 Blend 叠加时,会和背景正确混合,但性能会差一些;丢弃掉低 alpha 像素,可以减少片元写入。在大量箭头同屏时,这一行能实打实地省不少 fillrate。

6. 在场景中部署动态箭头:脚本控制、朝向与布局

Shader 写完后,后续的用例部署决定这个效果能否真正落地。我总结了几件日常使用中一定会遇到的问题和我的解决方案。

6.1 用代码生成四边形网格(Quad),而不是用内置 Mesh

Unity 内置的 Quad 网格是 X-Y 平面朝 +Z 的,用起来其实没问题。但我在数据可视化项目里经常需要不同的大小、不同的轴对齐方式,用代码生成一个自定义的四边形 Mesh 更清爽,还能顺便设置好 UV 方向和网格尺寸。

生成的核心函数如下:

using UnityEngine; public static class QuadMeshFactory { // width/height 是局部坐标下的尺寸,uvYUp=false 时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会导致所有箭头颜色全部改变。区分个体颜色的标准做法是MaterialPropertyBlock(MPB)。它的原理是:在同一个材质、同一个批次的前提下,为不同对象覆盖某些着色器属性值,并且不产生新的材质实例,减少 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 = GetComponent<Renderer>(); _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 OffQueueTransparent,它的渲染顺序是"越靠后越晚渲染"。在同一个 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.hlsl"
  • UnityObjectToClipPos改为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 效果和预期不一致的情况,欢迎按调试通道的思路拆一遍,多半能找到问题所在。

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

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

立即咨询