URP中Billboard特效实现:原理、避坑与性能优化指南
1. 项目概述:为什么Billboard特效是URP项目的“刚需”?
在Unity的通用渲染管线(URP)里做项目,尤其是涉及到开放世界、RPG或者策略类游戏,Billboard广告牌特效几乎是一个绕不开的话题。你可能在游戏里见过那些永远面朝摄像机的树木、远处的草丛,或者UI界面上始终正对你的状态图标,这些背后大概率就是Billboard技术。这个标题“5分钟搞定”听起来有点营销味,但说实话,对于一个熟悉Shader基础的老手,在URP里实现一个基础Billboard,5分钟真不算夸张。关键在于,你得知道“坑”在哪,以及如何绕过去。很多教程只讲“怎么做”,但URP和内置管线、甚至和旧版URP之间的一些细微差别,足以让新手卡上好几个小时。
我自己在最近的一个卡通风格大世界项目里,就大量使用了Billboard来处理远景植被。最初直接套用内置管线的老方法,结果不是深度测试出错导致物体“穿帮”,就是阴影接收异常,让美术同事头疼不已。所以,这篇文章不只是复现一个功能,更是把我趟过的坑、验证过的解决方案,以及URP下的一些最佳实践,系统地梳理给你。无论你是想优化性能(用简单的面片代替复杂模型),还是实现特定的艺术效果(如始终朝向玩家的提示符),掌握URP下的Billboard都是提升效率和质量的关键一步。
2. Billboard核心原理与URP适配要点
2.1 Billboard的本质:顶点变换的艺术
Billboard的核心思想非常简单:让一个通常由两个三角形构成的面片(Quad),在渲染时,其法线方向始终与摄像机的观察方向(View Direction)平行。换句话说,无论摄像机怎么转,这个面片都“正对着”你。
在Shader中,这主要通过修改物体空间(Object Space)到裁剪空间(Clip Space)的变换矩阵来实现。关键步骤发生在顶点着色器(Vertex Shader)里。我们不是直接使用Unity提供的标准unity_ObjectToWorld和UNITY_MATRIX_VP矩阵,而是需要构建一个特殊的旋转矩阵。
经典的实现思路是:
- 获取面向摄像机的三个轴向:在视图空间(View Space)或世界空间(World Space)中,计算面片的“上方向”(通常是世界空间的向上向量,如(0,1,0)或模型本身的向上向量)和“右方向”(通过叉乘得到)。
- 重建模型矩阵:用计算出的新轴向,替换掉原始模型矩阵中的旋转部分,而保留其缩放和位移。这样,模型本身的位移和大小不变,但旋转被强制对齐到摄像机。
- 应用变换:使用这个重建后的矩阵对顶点进行变换。
在URP中,由于渲染架构和Shader库函数的变化,我们不能直接照搬内置管线的代码。URP提供了更模块化的函数,比如TransformObjectToHClip,但我们需要在它之前“做手脚”。
2.2 URP Shader Graph vs 手写HLSL:两条路径的选择
实现Billboard,你现在有两条主要路径:使用Shader Graph可视化连线,或者手写HLSL代码。标题说“5分钟搞定”,用Shader Graph确实可能更快,但理解手写代码能让你拥有绝对的掌控力,便于调试和应对复杂需求。
Shader Graph路径:优点是直观,无需直接面对矩阵运算。你可以通过Transform节点将顶点位置转换到世界空间,然后利用View Direction节点和叉乘(Cross Product)节点来计算新的轴向,再组合回裁剪空间位置。对于简单的、无光照的广告牌(比如UI粒子、提示标记),这种方法非常高效。但它的局限性在于,当需要处理复杂的旋转约束(如只绕Y轴旋转的“Y轴锁定”Billboard)或者需要与URP的复杂光照、阴影管线深度集成时,节点连线会变得异常复杂和难以维护。
手写HLSL路径:这是我更推荐给希望深入图形编程的开发者的方式。你可以在URP的Unlit或Lit Shader模板基础上修改。手写代码的优势是性能透明、逻辑清晰,并且可以方便地集成到URP的渲染流程中,例如正确处理_ProjectionParams、_ScreenParams等URP特有的内置变量,以及处理深度纹理和阴影。
注意:URP 12(对应Unity 2021.2 LTS)之后,Shader的包含文件和常量声明方式有较大变化。例如,
UnityCG.cginc不再被推荐使用,取而代之的是Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl等。如果你在网上找到的代码编译报错,大概率是头文件引用或函数接口不兼容导致的。
2.3 深度与阴影:URP下的特殊挑战
这是Billboard在URP中最容易出问题的地方,也是很多“5分钟教程”避而不谈的。
- 深度测试(ZTest):Billboard面片在旋转后,其包围盒(Bounding Box)与实际渲染的几何体并不完全匹配。如果深度测试设置不当(例如使用默认的
LEqual),可能导致面片在应该被遮挡时反而渲染在了前面,产生“穿帮”。通常,对于Billboard,我们需要更谨慎地考虑深度测试策略,有时甚至需要配合自定义的深度偏移(Depth Offset)来修正。 - 阴影接收(Receive Shadows):这是URP Billboard的一个大坑。一个面片要能正确地接收其他物体投射的阴影,其世界空间位置和深度信息必须绝对准确。如果你的Billboard Shader中,顶点变换计算有细微错误,或者没有将正确的世界空间位置输出给片元着色器用于阴影计算,那么阴影要么完全不显示,要么会出现奇怪的错位。关键在于,你必须确保用于阴影计算的世界空间顶点位置,与用于最终渲染的位置,是基于同一套变换逻辑计算出来的。
- 阴影投射(Cast Shadows):让Billboard面片投射阴影通常不是好主意,因为一个单面面片投射的阴影视觉上很怪异。通常我们会关闭其阴影投射功能。如果在URP中需要,则必须额外编写一个
ShadowCasterPass,并确保其顶点变换逻辑与主Pass完全一致,否则会导致阴影与物体分离。
3. 实战:手写一个URP兼容的Billboard Unlit Shader
我们来动手写一个最实用、问题最少的版本。这个Shader将实现:
- 基础的世界空间Billboard。
- 正确的深度测试。
- 可选的纹理采样与顶点颜色支持。
- 为后续接收阴影打下正确的基础(输出正确的世界空间位置)。
3.1 创建Shader与属性定义
首先,在Unity中创建一个新的Unlit Shader文件,命名为URP_BillboardUnlit.shader。清空内容,我们从最基础的结构开始。
Shader "Custom/URP_BillboardUnlit" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _Cutoff ("Alpha Cutoff", Range(0,1)) = 0.5 [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 1 [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0 [Toggle(_ALPHATEST_ON)] _UseAlphaClip ("Use Alpha Clip", Float) = 0 }这里定义了基本的纹理、颜色和混合模式。_Cutoff和_UseAlphaClip用于实现透明裁剪,这对于做树叶、栅栏等效果很有用。混合模式参数化,方便我们后期调整半透明效果。
3.2 编写SubShader与HLSLINCLUDE
接下来是核心的SubShader部分。我们使用HLSLINCLUDE块来存放共用的代码,这样Vertex和Fragment着色器都能访问。
SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "IgnoreProjector"="True" "Queue"="Geometry" } LOD 100 // 深度写入通常开启,确保正确的遮挡关系 ZWrite On ZTest LEqual Cull Off // Billboard通常需要关闭背面剔除,因为两面都可能被看到 Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } Blend [_SrcBlend] [_DstBlend] HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma shader_feature_local _ALPHATEST_ON // URP核心库 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" // 定义属性和纹理 TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; half4 _Color; half _Cutoff; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; // 顶点颜色,可用于粒子等效果 }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; // 关键:输出世界坐标,用于后续阴影/深度计算 float4 color : COLOR; };这里有几个关键点:
Tags中的"RenderPipeline"="UniversalPipeline"是告诉Unity这是URP着色器。Cull Off:因为广告牌旋转后,背面也可能朝向摄像机,所以通常关闭背面剔除。struct Varyings中我们定义了一个positionWS : TEXCOORD1。这是解决URP下阴影和深度相关问题的核心。我们将计算好的世界空间位置从顶点着色器传递到片元着色器,供URP的阴影投射和深度计算使用。
3.3 实现顶点着色器(核心中的核心)
下面是顶点着色器vert函数的实现,包含了Billboard计算。
Varyings vert(Attributes IN) { Varyings OUT; // 1. 获取摄像机在世界空间的位置和方向 float3 worldCameraPos = _WorldSpaceCameraPos; float3 worldPos = mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz; // 模型原点在世界空间的位置 float3 viewDir = normalize(worldCameraPos - worldPos); // 2. 计算Billboard的旋转轴向(世界空间) // 假设广告牌的“向上”方向是世界空间的Y轴 float3 up = float3(0, 1, 0); // 也可以使用模型自身的向上向量,更具灵活性: // float3 up = normalize(mul((float3x3)unity_ObjectToWorld, float3(0, 1, 0))); float3 right = normalize(cross(viewDir, up)); // 重新正交化up向量,确保与right和viewDir垂直 up = normalize(cross(right, viewDir)); // 3. 将模型空间顶点偏移量,应用Billboard旋转 // IN.positionOS.xyz是相对于模型原点的偏移量(包含缩放)。 // 我们将其从模型空间转换到新的、面向摄像机的Billboard空间。 float3 localOffset = IN.positionOS.xyz; float3 billboardWorldPos = worldPos + right * localOffset.x * length(unity_ObjectToWorld._m00_m10_m20) // 考虑X轴缩放 + up * localOffset.y * length(unity_ObjectToWorld._m01_m11_m21) // 考虑Y轴缩放 + viewDir * localOffset.z * length(unity_ObjectToWorld._m02_m12_m22); // 考虑Z轴缩放(通常为0) // 4. 变换到齐次裁剪空间 OUT.positionHCS = TransformWorldToHClip(billboardWorldPos); // 5. 传递其他数据 OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex); OUT.positionWS = billboardWorldPos; // 存储世界坐标! OUT.color = IN.color; return OUT; }逐行解析与避坑指南:
- 第10行:我们计算的是模型原点(pivot)的世界坐标。所有顶点的Billboard旋转都围绕这个点进行。确保你的3D模型轴心(Pivot)在期望的旋转中心上。
- 第14-20行(计算轴向):这是核心算法。
up = float3(0,1,0)是最常见的“球形Billboard”(Spherical Billboard),即完全面向摄像机。如果你想实现“圆柱形Billboard”(Cylindrical Billboard),即只绕世界Y轴旋转,那么up向量应固定为(0,1,0),并且viewDir的Y分量在计算right向量前应被置零。这是实现不同Billboard类型的关键。 - 第23-28行(应用偏移与缩放):这是最容易出错的地方。
IN.positionOS.xyz是模型空间下的顶点位置,它已经包含了你在建模软件中或Unity Transform组件上设置的缩放。unity_ObjectToWorld矩阵的每一列的前三个元素,分别代表了模型X、Y、Z轴在世界空间中的方向向量,其长度(magnitude)就是该轴的缩放值。我们用length()函数提取这个缩放值,并分别应用到对应轴的偏移上。这样,即使你在Unity里把Billboard物体的Scale设置为(2,2,1),面片也会正确地进行非均匀缩放,而不会变形。 - 第31行:使用URP提供的
TransformWorldToHClip函数,将世界坐标转换到裁剪空间。这比内置管线的mul(UNITY_MATRIX_VP, float4(worldPos, 1.0))更规范。 - 第35行:
OUT.positionWS = billboardWorldPos;这一行至关重要!它把经过Billboard计算后的、准确的世界坐标传递下去。URP的阴影投射和屏幕空间阴影(SSAO等)需要这个数据。如果这里传递的是未经Billboard变换的原始世界坐标,阴影就会错位。
3.4 实现片元着色器与透明度处理
片元着色器相对标准,但需要注意透明度测试(Alpha Clip)的实现,这是植被Billboard的常用功能。
half4 frag(Varyings IN) : SV_Target { half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv); col *= _Color; col *= IN.color; // 叠加顶点颜色 #ifdef _ALPHATEST_ON clip(col.a - _Cutoff); #endif return col; } ENDHLSL } } }#ifdef _ALPHATEST_ON和clip指令配合,可以实现像素级的透明裁剪。对于树叶、铁丝网等具有复杂透明形状的广告牌,这比透明混合(Alpha Blend)性能更好,且没有渲染顺序问题。
4. 常见问题修复与深度优化
理论很美好,但实际应用时总会遇到各种妖魔鬼怪。下面是我在项目中遇到并解决过的典型问题。
4.1 问题一:阴影错位或完全不显示
症状:Billboard物体本身渲染正常,但其他物体投射到它上面的阴影位置不对,或者根本没有阴影。
根因分析:在URP中,阴影接收依赖于片元着色器能够获取到该片元准确的世界空间位置。这个位置信息通常来自顶点着色器输出的positionWS。如果这个positionWS不是Billboard变换后的位置,而是原始模型的位置,那么阴影映射(Shadow Mapping)技术计算深度时使用的世界坐标就是错的,导致阴影贴图上的深度值与Billboard实际深度不匹配。
解决方案:正如我们在顶点着色器中所做,必须确保Varyings结构中的positionWS是经过Billboard计算后的billboardWorldPos。此外,还需要检查材质的“Receive Shadows”选项是否开启。对于更复杂的情况,可能需要确保Shader中包含了#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl",并在片元着色器中调用GetMainLight等相关函数来获取阴影信息。
实操心得:调试阴影问题,可以临时将
positionWS作为颜色输出到屏幕(return float4(IN.positionWS, 1.0);),观察其值是否随着摄像机旋转而平滑变化。如果某个通道的值固定不变,说明你的Billboard计算可能没生效。
4.2 问题二:深度冲突与“Z-Fighting”
症状:Billboard面片与后方或前方的其他几何体重叠时,出现闪烁的像素,即“Z-Fighting”。
根因分析:两个片元在深度缓冲区中的值过于接近,由于深度缓冲精度有限,无法稳定判断谁在前谁在后。Billboard面片很薄,且其变换方式可能导致其深度值与附近曲面物体的深度值几乎相同。
解决方案:
- 轻微深度偏移(Depth Bias):在Shader的Pass中,添加
Offset 0, -1指令。这会在深度计算时施加一个微小的偏移,让Billboard稍微“靠后”一点渲染。数值需要根据场景调整,过大会导致明显的不合理遮挡。 - 调整渲染队列(Queue):尝试将Billboard物体的渲染队列(
Tags{"Queue"="..."})设置得比背景物体稍晚(数字更大),例如背景是Geometry(2000),Billboard可以设为Geometry+1(2001)。但这并非根本解决方法。 - 最稳健的方法:在Shader中手动计算深度偏移。可以在顶点着色器中,沿着顶点法线(或摄像机视线方向)对
billboardWorldPos做一个微小的偏移,然后再进行投影变换。这种方法最精确,但实现稍复杂。
// 在顶点着色器中,计算完billboardWorldPos后,可添加: float depthBias = 0.01; // 一个很小的值,根据场景单位调整 float3 viewVector = normalize(worldCameraPos - billboardWorldPos); billboardWorldPos += viewVector * depthBias * IN.positionOS.z; // 利用顶点Z坐标做渐变偏移4.3 问题三:在VR或分屏模式下失效
症状:在单屏下正常,但在VR双屏或Split-Screen游戏模式下,Billboard朝向错误,可能只朝向其中一个眼睛/屏幕的摄像机。
根因分析:我们之前的代码中,viewDir的计算依赖于_WorldSpaceCameraPos,这个变量在单摄像机场景下是准确的。但在多摄像机(如VR的左眼/右眼)渲染同一物体时,Unity可能会以某种方式批量渲染,导致_WorldSpaceCameraPos并非当前正在渲染的摄像机位置。
解决方案:使用更可靠的GetWorldSpaceViewDir函数(如果可用),或者直接从TransformWorldToViewDir相关的函数中推导。更通用的方法是,在顶点着色器中,使用当前渲染的视图矩阵的逆矩阵来推导摄像机位置。因为UNITY_MATRIX_I_V矩阵的第四列的前三个分量,就是视图空间原点在世界空间中的位置,也就是摄像机世界坐标。
修改顶点着色器开头部分:
// 替换 float3 worldCameraPos = _WorldSpaceCameraPos; float4x4 ivMatrix = GetWorldToViewMatrix(); // 获取世界到视图矩阵 // 注意:UNITY_MATRIX_I_V 是视图到世界矩阵,其第四列是摄像机世界位置 // 但在URP中,更推荐使用以下方式获取当前渲染摄像机的世界位置: // 需要包含 `ShaderVariablesFunctions.hlsl` #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/ShaderVariablesFunctions.hlsl" float3 worldCameraPos = GetCameraPositionWS(); // URP提供的可靠方法 float3 worldPos = mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz; float3 viewDir = normalize(worldCameraPos - worldPos); // ... 后续计算不变使用GetCameraPositionWS()是URP中获取当前渲染摄像机世界位置的首选方法,它能正确处理多摄像机渲染场景。
4.4 问题四:性能开销与批处理破坏
症状:大量使用Billboard后,Draw Call数量飙升,性能下降。
根因分析:Billboard计算需要在Shader中进行矩阵运算,这本身会增加一些GPU开销,但通常不是主要问题。更大的问题在于,动态计算世界坐标的Billboard Shader,往往会破坏Unity的静态/动态批处理(Batching)。因为批处理要求多个物体共享相同的顶点数据和变换状态,而Billboard的顶点变换是每帧依赖摄像机的,导致它们无法被批量处理。
解决方案与权衡:
- 使用GPU Instancing:这是解决大量相同Billboard物体性能问题的终极武器。你需要为Shader添加
#pragma multi_compile_instancing指令,并处理实例化相关的缓冲区。在Billboard Shader中实现Instancing相对复杂,因为每个实例的Billboard旋转计算是独立的。你需要将模型原点的世界位置计算改为基于实例数据。网上有成熟的方案,但实现门槛较高。 - 对于静态背景物体:如果某些Billboard物体(如远山、永远固定的树木)完全不会移动,可以考虑将它们“烘焙”成始终面向某个固定视角的静态网格,从而享受静态批处理的优化。这失去了动态面向摄像机的特性,但换来了性能。
- LOD策略:在非常远的距离,直接用更简单的面片、甚至一个点来代替复杂的Billboard模型。Unity的LOD Group组件可以管理这个。
- 手动合批:在美术制作阶段,就将多个相邻的、材质相同的广告牌面片合并成一个大的网格。这样一次Draw Call就能绘制多个广告牌,但失去了单个控制的灵活性。
性能实测建议:在Unity Profiler的Rendering面板中,密切关注Batches和SetPass Calls的数量。如果Billboard物体数量增加时,这两个值线性增长,说明批处理失效了。这时就需要考虑上述优化策略。对于中近距离的、数量不多的关键物体(如角色头上的状态图标),即使不批处理,开销也是完全可以接受的。优化要有针对性,不要过度设计。