
做项目的时候经常遇到一个很朴素的需求天上飘的云、远处点睛的小树、角色头顶的ID标记、脚下踩的技能圈它本质上就是一张四边形贴图可它必须永远正对着摄像机。一旦你用C#在每帧里给每个物体做一次LookAt几百个对象之后CPU就开始吃紧DrawCall也很难看。后来我把这套逻辑塞进了Shader的顶点着色器在GPU上直接重排顶点位置效果就是“花一张贴图的代价让全世界都追着相机转”。这篇文章从广告牌的原理讲起把我从头到尾踩过的坑、试过的方法、验证过的代码一次性整理完希望能帮同样被这个问题卡住的人省点时间。广告牌Billboard在Unity引擎里是一个很老但非常实用的Shader技巧常被用在粒子、云层、低精度植被、技能指示器、UI头顶标牌等场景。它的核心目标只有一个让四边形网格无论相机怎么旋转渲染出来的画面始终是“正脸”朝着摄像机。下面我会从数学原理、两种实现模式、完整Shader代码再到实际项目填坑逐一拆开讲。1. 广告牌效果到底在解决什么问题1.1 一个每天都在用的技术点先明确“广告牌”解决的是哪一类问题。引擎渲染一个三维物体时往往要区分“几何细节”和“观感细节”。一棵树的精细建模可能动辄几千面但你在游戏里看到远处的树真正需要的只是一张带透明通道的树的照片。为了实现这种“照片级替代品”我们得把这张照片所在的四边形贴图始终对准摄像机否则转个角度就会看到纸片侧面穿帮感非常强。广告牌Shader就是这个“始终对准”的专用方案。它不同于简单的Transform.LookAt不是去旋转物体的Transform而是在顶点着色器中直接计算顶点应该在世界空间落在哪个位置。这样做的第一个好处是CPU上完全无开销不管场景里挂了500个还是5000个广告牌CPU侧只是提交Mesh和材质旋转计算全部交给GPU。第二个好处是它和批处理、实例化更容易配合因为Transform不再参与旋转Unity在做合批时少了一个潜在的破坏因素。实际开发里广告牌通常有两类用法一类是“垂直广告牌”另一类是“全向广告牌”不少刚接触的人容易搞混所以下一节我单独讲。1.2 两种广告牌模式垂直广告牌Vertical Billboard的特点是四边形只绕世界Y轴旋转顶部永远保持水平。这种模式非常适合树木、草丛、人物名字标签和路标。因为树哪怕离得再远你也不会希望它歪着长人物头顶的名字如果跟着相机横滚旋转看起来就非常难受。垂直广告牌在计算上只需要在水平面上找到朝向摄像机的方向再建立一个正交基实现成本极低。全向广告牌Camera-Facing Billboard则完全不同它让四边形的正方向始终严格指向摄像机连上下倾斜都会跟着相机改变。适合云朵、光晕、魔法粒子、爆炸特效这类“无所谓上下”的物体。云如果永远用一个水平面去朝向相机仰头看的时候就会变成一条线全向广告牌能保证你在任何角度看到的都是一张完整云图。下面这张表可以直接对照着选型模式旋转轴顶部方向代表场景实现复杂度垂直广告牌仅绕Y轴旋转始终世界Y轴树木、草丛、头顶ID低全向广告牌自由旋转跟随摄像机姿态云层、粒子、光晕中混合模式绕Y轴局部俯仰受限制的Y轴技能范围圈、地面标记中需要说明的是无论哪种模式广告牌解决的都是“单张平面面对相机”的问题。如果是一个有若干交叉平面的树干模型每个面各自做广告牌是另一套更复杂的“十字树”方案后面扩展章节我会顺带提一句。2. 原理拆解从顶点变换到正交基2.1 为什么C#里LookAt不靠谱很多人刚接触时第一反应是直接写脚本transform.LookAt(camera.transform);这个做法在小规模场景里没有毛病一两个血条、三四个标记都能跑。但一旦数量多起来问题就暴露了每帧每个物体都要做一次矩阵运算和一次Transform同步Unity引擎为了维护场景树、处理合批和剔除还得跟着做一堆额外工作如果再用上多相机渲染每个相机都要重新算一遍。实测下来单纯几百个广告牌用脚本LookAtCPU占用可能比一个中等粒度的特效系统还高。更关键的是脚本LookAt只能旋转整个Transform如果你的广告牌模型内部还有动画、顶点偏移或者LOD顶点动画脚本很难介入。而把旋转逻辑放在Shader里等于手里多了一把“顶点级”的改锥想怎么拧就怎么拧。2.2 广告牌的核心数学广告牌Shader的所有秘密都藏在“正交基”三个字里。任何一组互相垂直、长度都为1的向量我们叫它一组正交基。举个例子世界坐标系的X轴、Y轴、Z轴就是一组正交基三维空间里任何一个点都可以表示为“X分量乘X轴 Y分量乘Y轴 Z分量乘Z轴”。广告牌的思路是放弃模型本身的旋转矩阵直接用摄像机的三个方向作为新的正交基然后把四边形顶点原来的X、Y、Z偏移量分别映射到摄像机坐标系的right、up、forward上。用生活类比最好理解你举着一张报纸站在摄影棚里无论你身体怎么转只要保证报纸正面一直冲着镜头镜头里看到的永远是那张报纸的完整封面。广告牌Shader干的事情就是帮你的手每秒转几十次而且它转的不是整个身体Transform只是报纸上的每个点顶点。具体到数学计算假设四边形的模型中心在世界空间的坐标是center摄像机在世界空间的坐标是camPos那么从模型中心指向摄像机的方向就是viewDir normalize(camPos - center)这个viewDir就是四边形“正面”最终应该朝向的方向。接着我们需要构造一组以viewDir为核心的正交基。不同广告牌模式构造方法会有差异这也是3.2和3.3里代码的关键差异点。2.3 全向广告牌的稳定正交基构建全向广告牌最稳定的做法是找一个参考上向量再通过两次叉积得到一组正交基。流程分三步第一步取参考上向量upRef通常就是(0,1,0)但有一个特殊情况当相机几乎从正上方往下看时viewDir和upRef会变得近乎平行叉积结果趋近于零向量导致后面的计算全军覆没。因此代码里要加一个保护判断float3 upRef abs(viewDir.y) 0.999 ? float3(0, 0, 1) : float3(0, 1, 0);第二步用叉积算出右向量float3 right normalize(cross(upRef, viewDir));在Unity的左手坐标系里cross(upRef, viewDir)的结果正好指向右边。第三步用viewDir和right再叉积一次重新计算出和它们都正交的up向量float3 up cross(viewDir, right);经过这三步right、up、viewDir就形成了一组稳定的正交基。接下来四边形顶点的模型空间偏移量直接按分量映射到这三个向量上即可float3 offset v.vertex.xyz; float3 worldPos center right * offset.x up * offset.y viewDir * offset.z;注意这里我用viewDir作为前向轴因为四边形在模型空间里的Z轴通常是面的朝向。对一张完全扁平的四边形贴图来说Z偏移本来就是0映射到哪个方向影响不大但对于云朵这类有厚度的网格前向轴映射到viewDir可以保证物体的“体积感”方向始终朝向相机。垂直广告牌和这个唯一的区别就是up固定为世界Y轴(0,1,0)right只绕Y轴旋转再重新算出前向轴保证顶面永远不倾斜。3. 手写Shader从零开始的一套可用代码3.1 属性与渲染状态先给出一套基础可跑的Shader代码后面再逐段解释。它包含一个主纹理和一个色调参数适合绝大多数带透明通道的广告牌贴图。Shader Custom/Billboard { Properties { _MainTex (主纹理, 2D) white {} _TintColor (色调, Color) (1, 1, 1, 1) } SubShader { Tags { Queue Transparent RenderType Transparent IgnoreProjector True DisableBatching True } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; float4 _TintColor; ... ENDCG } } }标签里的几项一个都不能乱动。其中最重要的是DisableBatching True。Unity引擎默认会做动态合批和静态合批一旦合批多个广告牌的模型矩阵会被合并unity_ObjectToWorld不再代表单个物体的坐标系这时候你从模型矩阵中取中心点取到的就是错的位置广告牌会满天乱飞。所以必须强制关闭批处理。Queue、RenderType设置为Transparent是常规透明渲染的标配Blend SrcAlpha OneMinusSrcAlpha启用Alpha混合让带透明通道的云图、植被图能正常融合到场景ZWrite Off避免半透明物体互相写深度造成遮挡错误Cull Off则保证即使相机转到四边形背面面片依然可见避免看到“纸片背面”的尴尬。3.2 顶点着色器实现顶点着色器是整个广告牌的核心。我用两种方式来写你先看第一种这是全向广告牌的完整实现v2f vert (appdata v) { v2f o; // 模型中心的世界坐标 float3 center mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz; // 从模型中心指向摄像机的方向 float3 viewDir normalize(_WorldSpaceCameraPos - center); // 稳定的正交基构造 float3 upRef abs(viewDir.y) 0.999 ? float3(0, 0, 1) : float3(0, 1, 0); float3 right normalize(cross(upRef, viewDir)); float3 up normalize(cross(viewDir, right)); // 将模型空间顶点偏移映射到广告牌基向量 float3 offset v.vertex.xyz; float3 worldPos center right * offset.x up * offset.y viewDir * offset.z; // 世界坐标转裁剪坐标 o.vertex mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }逐行解释一下。mul(unity_ObjectToWorld, float4(0, 0, 0, 1))这条路子是从模型矩阵中取出平移分量它代表模型原点在世界空间的位置。对绝大多数模型来说原点就是建模时的中心点因此这个center就是我们旋转广告牌的锚点。_WorldSpaceCameraPos是引擎内置的摄像机世界坐标两者相减再归一化就得到了广告牌需要面对的方向。后面三步就是2.3小节里讲的正交基构造。worldPos就是把原本属于模型空间里X轴方向、Y轴方向、Z轴方向的顶点偏移重新分配到新的right、up、viewDir方向上去。这个方法有个好处它只看_WorldSpaceCameraPos和模型中心的位置不关心摄像机姿态里的上下左右是怎么转的所以即使摄像机在翻滚Roll广告牌依然严格正对摄像机。但对于垂直广告牌来说这个“跟随翻滚”反而会变成坏处应该改用固定世界Y轴作为up的写法float3 right normalize(cross(float3(0, 1, 0), viewDir)); float3 up float3(0, 1, 0); float3 forward normalize(cross(right, up));两者可以共用一个Shader加一个开关变量_Mode或者属性去切换实际项目里我建议直接写两个Pass对应的宏或者用Shader Feature控制分支。3.3 保留Scale的小技巧上面代码里有一个很容易被坑的点offset v.vertex.xyz直接使用了模型空间顶点坐标那Transform里的Scale去哪了如果你把广告牌挂在一个缩放为0.5的物体下它的渲染尺寸依然是模型原始尺寸大小完全不变。原因是我们在顶点着色器里绕过了模型矩阵的旋转缩放部分只取了它的平移。想保留缩放最直接的办法是从unity_ObjectToWorld矩阵中把三个轴的长度提取出来再乘以顶点偏移量float3 scale float3( length(unity_ObjectToWorld._m00_m10_m20), length(unity_ObjectToWorld._m01_m11_m21), length(unity_ObjectToWorld._m02_m12_m22) ); float3 offset v.vertex.xyz * scale;这里用length分别取矩阵第一列、第二列、第三列的长度如果模型只做了等比缩放三个长度相同如果做了非等比缩放三个长度各不相同也能正确保留。需要注意如果模型本身有旋转这种提取方式会把旋转后的轴向长度也算进去但广告牌本身就不关心模型旋转只要缩放轴和广告牌基向量不冲突结果一般正常。还有一种更稳妥的做法是提前在模型导出时把广告牌做成1x1的Quad然后在材质面板上暴露一个_ScaleU、_ScaleV参数用UV坐标的TRANSFORM_TEX去控制贴图缩放。这种方式完全绕开Transform缩放和脚本解耦更彻底也是粒子系统的常规思路。我自己的经验是凡是广告牌要参与合批、实例化、或者被运行时动态调整尺寸推荐用UV参数凡是广告牌少、且美术习惯直接调Transform缩放用提取Scale的方案。4. 实际项目里的填坑记录4.1 批处理坑最无情的一个坑就是第3.1节说到的DisableBatching。很多新手写完Shader发现广告牌一多就开始乱飞位置完全不对第一反应往往是代码哪里写错了其实问题出在Unity的合批流程。Unity的动态合批会把多个Mesh合并成一个合并后的模型矩阵不再是单个物体的矩阵而是“合并后的大Mesh”对应到一个特殊坐标系。你按单个物体去取unity_ObjectToWorld平移自然取不到正确位置。关闭合批的代价是DrawCall上升。如果场景里的广告牌数量很大可以改用GPU Instancing方式但要注意实例化时每个物体的矩阵存储在缓冲中顶点着色器里取的是unity_ObjectToWorld的实例化数据不能直接在SurfaceShader或普通Pass里用老办法取。最方便的做法是走DrawMeshInstanced或Graphics.RenderPrimitives让脚本把每个广告牌的模型中心写进MaterialPropertyBlockShader从属性里读中心点完全不依赖unity_ObjectToWorld。这样既不需要DisableBatching又能保持实例化合批。4.2 法线与光照的坑广告牌把顶点位置改了但法线还是模型原法线。如果你在广告牌Shader里用了Lambert、HalfLambert这类漫反射光照会出现一种非常诡异的现象贴图方向是正对相机的光照却明一块暗一块旋转相机时还能看到法线方向纹丝不动。原因是广告牌旋转之后物体的法线并没有跟着旋转到“面向相机”的方向。要让广告牌在光照下表现正常通常有几个办法。最简单的办法是直接用Unlit不光照Shader广告牌素材本身大部分是带透明贴图的光照信息已经烘焙在纹理里常用在云、UI标记、UI精灵上。如果需要受到动态光照可以在顶点着色器里把法线强制设为viewDir方向因为广告牌总是面向相机法线就应该是从中心指向相机的方向o.normal viewDir;但DMDirectional Meterial光照模型下还要考虑法线贴图广告牌的法线贴图在旋转后已经失去意义除非你在片元里重新用viewDir去扰动否则不建议广告牌配合复杂PBR流程。我在实际项目里见到的绝大多数方案广告牌都用Unlit或者最简单的Lambert处理。4.3 平台差异与矩阵顺序矩阵在Unity CG/HLSL里的存储和乘法顺序是另一个经典坑。Unity使用列主序矩阵但Shader里的mul函数在处理float4x4和float4时顺序不同结果也不同。比如o.vertex mul(UNITY_MATRIX_VP, float4(worldPos, 1.0));这里UNITY_MATRIX_VP是4x4矩阵float4是列向量数学上等价于矩阵乘以列向量。如果你写成mul(float4(worldPos, 1.0), UNITY_MATRIX_VP)在多数平台上会被当成行向量乘以矩阵结果刚好是转置之后的另一种变换轻则位置错乱重则整个模型消失。所以统一用mul(矩阵, 向量)写避免折腾。另外OpenGL平台和DirectX平台的裁剪空间Z范围不一致UNITY_MATRIX_VP已经做了平台适配但如果你为了调试自己去构造投影矩阵容易在部分安卓设备上出现近裁剪面被切掉的怪问题。我的建议是广告牌只做到世界空间剩下的投影变换全部交给引擎内置的UNITY_MATRIX_VP不要自己去碰投影矩阵。4.4 透明排序与深度广告牌经常是半透明的云和粒子都是典型代表。如果只按第3.1节的设置把所有广告牌都放在Transparent队列它们之间的排序是按距离从远到近画的距离相同或接近时会出现层次感错乱。表现就是两张云朵贴图边缘互相穿插一会儿A盖B一会儿B盖A。解决透明穿插没有完美方案只能尽量从源头上控制。第一把广告牌的包围球大小尽量做小排序距离更准确第二在材质上设置合适的Queue数值比如云层可以放到Queue2500附近UI标记放在更靠后的位置让业务上明确的层级先画第三如果某个广告牌需要被其他半透明物体遮挡考虑使用ZWrite On配合深度偏移但这会带来新的排序问题一般不推荐。另外广告牌如果开启Cull Off从背面渲染时深度关系和正面不同和半透明混合叠加起来可能会出现边缘重复加深的黑色描边。遇到这种问题优先检查纹理本身是否带预乘AlphaPremultiplied Alpha如果带混合模式需要改成Blend One OneMinusSrcAlpha而不是最常见的SrcAlpha OneMinusSrcAlpha。4.5 Shader Graph 与 URP 的替代方案如果你用的是URP或HDRP手写老式CG Shader也能跑但URP的SRP Batcher对自定义Shader有一定限制要求材质属性必须放在CBUFFER里而且很多内置变量名发生了变化。这种情况我更推荐用Shader Graph来做广告牌。Shader Graph里有一个现成的“Billboard”节点挂在顶点阶段输入顶点位置和物体中心输出重定向后的顶点位置内置了全部正交基逻辑。它唯一的缺点是相对固定没法像手写Shader一样自由控制保留Scale、垂直/全向切换这些细节。如果非要在URP里手写至少要做到把Properties里的参数放进CBUFFER_START(UnityPerMaterial)区块矩阵变量统一用URP提供的宏头文件从UniversalRP ShaderLibrary/Core.hlsl引入。最稳妥的其实是直接把第3.2节的核心数学塞进URP Unlit Shader模板里然后把位置输出改为TransformWorldToHClip(worldPos)。5. 常见问题速查与扩展玩法5.1 常见问题速查表症状可能原因处理方法广告牌位置乱飞、整体偏移动态/静态合批破坏了模型矩阵加DisableBatchingTrue标签广告牌大小不受缩放控制顶点偏移未乘模型缩放提取unity_ObjectToWorld三轴长度相机垂直看向广告牌时面片消失或扭曲参考上向量与viewDir平行发生退化加abs(viewDir.y)0.999分支广告牌旋转后贴图左右翻转叉积方向反了把cross(upRef, viewDir)换成cross(viewDir, upRef)对比广告牌自身旋转不跟随相机使用了模型旋转矩阵而不是重建基向量确保最终用center基向量*offset半透明广告牌边缘重叠出现黑边纹理带预乘Alpha但混合模式不对改用Blend One OneMinusSrcAlphaURP下Shader不显示或报错自定义Shader缺少CBUFFER或旧宏迁移到URP Shader Library并补充CBUFFER排查这些问题我见过最快的办法就是写一个调试版Shader把广告牌的颜色直接设为顶点坐标里的viewDir用颜色像素值可视化摄像机的相对方向一眼就能看出正交基构造是否正确比盯着代码空想要高效得多。5.2 更进一步群体渲染、交叉树木、拉伸粒子如果广告牌数量巨大比如一整片桨叶草、上千个烟花粒子除了GPU Instancing还可以把所有广告牌顶点预生成到一个巨大的Mesh里每个广告牌占用4个顶点顶点数据里用颜色通道或UV2存储中心点位置和单个广告牌的缩放旋转。这样一个DrawCall就能画完所有广告牌代价是每帧要更新顶点数据适合数量适中、变化不频繁的静态场景。交叉树木是垂直广告牌的常见升级版本。一棵树用两到三张交叉着的四边形面片模拟树冠每个面片各自做垂直广告牌这样从任何角度看起来都有一定立体感。实现上不能让一个Mesh里的多个面片共享同一个中心点因为那样它们会整体转向歪在空中。正确做法是把每个面片做成独立的子Mesh或单独的GameObject各自挂这套Shader。粒子的拉伸则是广告牌的一个变种。默认广告牌把顶点偏移严格映射到正交基粒子在拖尾或拉伸时需要对某一轴向做动态缩放。做法很简单在顶点着色器里根据粒子的速度向量对某一个基向量做额外拉伸比如速度方向的投影大于某个阈值时把right * offset.x的系数乘以一个_StretchFactor。这是很多弹道特效和拖尾光效的做法不需要额外粒子系统节点只要在Shader里多传一个速度向量就行。最后说点实操体会我真正把这套Shader用在正式项目里前前后后改过三个版本。第一版图省事用脚本LookAt结果被主程直接打回原因是场景里有大量交互标记CPU开销完全不可控。第二版用全向正交基摄像机俯仰不大时一切正常直到有一次测试角色跑到塔顶从正上方往下看广告牌突然开始疯狂抖动才发现是参考上向量退化的问题。第三版就是上面给的这套代码垂直/全向模式可以切换、提取三轴缩放、强制关闭批处理并把Shader内的矩阵顺序全部统一成mul(矩阵, 向量)。后来踩过几次坑之后我的习惯是先确认业务上到底需要垂直还是全向再决定写哪一种正交基构造。能用垂直广告牌解决的绝不写全向必须全向的一定带上abs(viewDir.y) 0.999这个保护分支。这样既省性能也免去了一堆隐藏的数学退化问题。这个内容后续还可以继续做扩展比如把广告牌和GPU Instancing结合做一整片随风摇晃的草场那是我目前正在折腾的方向等出结果了再来补充。