尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

URP中Billboard特效实现:原理、避坑与性能优化指南

URP中Billboard特效实现:原理、避坑与性能优化指南 1. 项目概述为什么Billboard特效是URP项目的“刚需”在Unity的通用渲染管线URP里做项目尤其是涉及到开放世界、RPG或者策略类游戏Billboard广告牌特效几乎是一个绕不开的话题。你可能在游戏里见过那些永远面朝摄像机的树木、远处的草丛或者UI界面上始终正对你的状态图标这些背后大概率就是Billboard技术。这个标题“5分钟搞定”听起来有点营销味但说实话对于一个熟悉Shader基础的老手在URP里实现一个基础Billboard5分钟真不算夸张。关键在于你得知道“坑”在哪以及如何绕过去。很多教程只讲“怎么做”但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分钟教程”避而不谈的。深度测试ZTestBillboard面片在旋转后其包围盒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 { RenderTypeOpaque RenderPipelineUniversalPipeline IgnoreProjectorTrue QueueGeometry } LOD 100 // 深度写入通常开启确保正确的遮挡关系 ZWrite On ZTest LEqual Cull Off // Billboard通常需要关闭背面剔除因为两面都可能被看到 Pass { Name ForwardLit Tags { LightModeUniversalForward } 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中的RenderPipelineUniversalPipeline是告诉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...}设置得比背景物体稍晚数字更大例如背景是Geometry2000Billboard可以设为Geometry12001。但这并非根本解决方法。最稳健的方法在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物体数量增加时这两个值线性增长说明批处理失效了。这时就需要考虑上述优化策略。对于中近距离的、数量不多的关键物体如角色头上的状态图标即使不批处理开销也是完全可以接受的。优化要有针对性不要过度设计。
返回列表