1. 广告牌效果在解决什么为什么旋转脚本不是正解1.1 一个每次都让特效变得很假的场景做粒子特效或者场景里的贴片物体时最讨厌的一件事就是只要摄像机一转原本正对着镜头的画面就歪了。举个例子我做过一个技能释放特效地面上要出现一个光圈贴片。光圈是一个四边形默认朝向世界Y轴也就是平铺在地上。这没问题。可是当特效变成人物头顶的“感叹号”或者怪物脚底的目标圈时如果只是简单给它一个固定旋转摄像机一绕贴片就像一张被风吹歪的纸片看着特别假。这种需求有一个专门的名字就是广告牌效果英文叫Billboard。用Unity Shader来做这件事的核心结论很直接让一个四边形贴片或者任意二维网格在渲染时永远正对着摄像机。标题里写的“画面永远朝向摄像机”本质上就是要在每一帧的渲染阶段重新计算这个网格的朝向而不是用脚本在CPU端去转它。我第一次接到这种需求时第一反应是挂一个Transform.LookAt(Camera.main.transform)。单看一两个物体好像没问题但实际用下来会发现几个很麻烦的点脚本逻辑要每帧执行数量一多CPU开销不小如果物体在动画层级里父节点一旋转子物体的朝向计算就会变得混乱更重要的是这根本不是“广告牌”只是把一个物体硬转过来了。广告牌要的是在渲染时完全忽略物体自身的旋转甚至忽略父级旋转只用“摄像机位置”和“物体中心”这两个信息来重建朝向。1.2 广告牌的本质每帧重新定义局部坐标轴想理解广告牌Shader不能只把它当成一个旋转技巧。它的核心其实是放弃网格原来的旋转矩阵在顶点着色器里重新定义一组坐标轴让这组坐标轴满足两个条件第一个是坐标轴的Z轴指向摄像机第二个是XY轴落在摄像机看到的平面上。这样一来原本位于XY平面上的四边形就会被重新铺到朝向摄像机的平面上。打个比方一个人站在广场中间你绕着他走。如果他是广告牌他不需要每帧“转身”而是他的“正面”这个概念永远定义成“指向你的位置的方向”。在Shader里这个“你的位置”就是_WorldSpaceCameraPos“他的位置”就是物体的中心点。两者做差、归一化就得到了一个单位方向向量。这个方向向量就是广告牌新的Z轴。然后用向量叉积算出新的X轴和Y轴。叉积保证了这组轴两两垂直。最后把顶点原来的局部XY坐标乘到新的X轴和Y轴上再加上物体中心的世界坐标就得到了一个“始终面向摄像机”的顶点位置。这个思路听起来复杂实际代码只有几行。这也是为什么广告牌效果最好不要用脚本每帧LookAtShader直接把它变成了一次纯数学计算而且是在GPU上完成的数量再多也不慌。2. 第一版可用的Shader屏幕对齐广告牌的完整实现2.1 最简Shader代码下面这版是我在实际项目里经常用的基础版本。它做的是“屏幕对齐广告牌”贴片始终正对摄像机并且贴片的上方向始终和屏幕的上方向一致。这个版本最适合技能图标、飘字、血条底图这类和屏幕方向强相关的效果。Shader Custom/BillboardScreen { Properties { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) } SubShader { Tags { Queue Transparent RenderType Transparent 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; fixed4 _Color; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; // 把模型中心从模型空间变换到观察空间 // 观察空间里摄像机就是原点X轴朝右Y轴朝上Z轴朝里 float4 center mul(UNITY_MATRIX_MV, float4(0, 0, 0, 1)); // 提取物体的世界空间缩放让广告牌的尺寸随物体缩放变化 float3 scale float3( length(unity_ObjectToWorld._11_21_31), length(unity_ObjectToWorld._12_22_32), length(unity_ObjectToWorld._13_23_33) ); // 把局部XY顶点偏移直接加到观察空间的中心上 // 因为观察空间的X轴和Y轴天然就是“屏幕的右和上” float4 viewPos center float4(v.vertex.x * scale.x, v.vertex.y * scale.y, 0, 0); o.pos mul(UNITY_MATRIX_P, viewPos); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag(v2f i) : SV_Target { fixed4 c tex2D(_MainTex, i.uv) * _Color; return c; } ENDCG } } }如果你只需要最简单的效果甚至可以把提取缩放的三行去掉。直接在观察空间里对顶点的XY做偏移就已经完成了一个广告牌。保留提取缩放是因为很多人会在场景里拉一个Quad然后习惯性地缩大缩小。如果不提取缩放Shader只认原始的模型顶点坐标场景里怎么缩放都不起作用第一次用的人很容易以为Shader写错了。2.2 核心三行代码拆解这个Shader最关键的地方在顶点着色器里。第一行是mul(UNITY_MATRIX_MV, float4(0,0,0,1))它把物体的模型中心点变换到了观察空间。观察空间可以简单理解成“以摄像机为原点、以摄像机右方为X轴、以摄像机上方向为Y轴”的一个坐标系。第二行是构造观察空间的偏移量。假设你创建的是一个Quad那么它的四个顶点坐标大概是(±0.5, ±0.5, 0)。在原来的模型空间里这些坐标描画的是一张平面。现在把它们放到观察空间里等于告诉Shader你不用管这个物体原本怎么旋转你只要把这张平面铺在摄像机面前方向完全跟随摄像机的视野方向。第三行是mul(UNITY_MATRIX_P, viewPos)。观察空间的位置还需要乘以投影矩阵才能变成屏幕上渲染的裁剪空间位置。因为投影矩阵会把“离摄像机更远的点”压缩得更小所以远处和近处的广告牌会呈现正常的近大远小效果。这其实就是游戏里3D世界中的2D贴片应该有的样子。这个版本的另一个好处是它天然处理了摄像机的旋转。摄像机歪着脑袋看广告牌也会跟着歪保证贴片始终覆盖同样的屏幕区域。这在做飘字、受击数字、瞄准标记的时候非常重要因为你要的是“看起来永远是正对着屏幕的”而不是“在世界空间里站着但歪向一侧”。2.3 放到场景里测试的步骤新建一个场景创建一个Quad把Shader赋给一个新材质材质上随便放一张带Alpha的贴图。把Quad放在摄像机前方大概三到五米的位置然后拖动Scene视图里的摄像机或者运行后旋转主摄像机。你会发现Quad无论从哪个角度看都完整正对着镜头。我在测试时还会做两个额外验证第一复制十几个Quad随机摆在不同位置确认它们各自独立朝摄像机而不是一起转动第二给Quad挂一个空父物体让父物体不停旋转观察子物体。你会发现父物体怎么转Quad依然正对摄像机因为Shader只取了物体中心点根本不关心物体原本的旋转矩阵。这也就是广告牌Shader和脚本LookAt最大的差别。脚本是“先得到当前旋转再改成新旋转”本质还是受父级影响Shader是“干脆不用原来的旋转直接在世界空间或观察空间把顶点重算一遍”。理解了这一点后面的各种变体就都顺了。3. “永远朝向摄像机”也分方向世界竖直广告牌和柱状广告牌3.1 世界竖直广告牌保留世界Up屏幕对齐广告牌确实能满足“始终朝向摄像机”但有一个隐藏问题它的上方向完全跟随屏幕。如果摄像机左右翻滚广告牌也会跟着旋转。做UI类特效这很合理但放到3D场景里就会很奇怪。比如一棵树的贴片摄像机稍微倾斜树也跟着倾斜显然不对劲。这时候需要的是世界竖直广告牌。它同样始终正对摄像机但上方向永远保持世界坐标的Y轴。像一个真人站在你面前你绕着他走他会跟着转脸但他不会因为你的头歪了而跟着歪。实现方式是在世界空间里重新构造坐标轴float3 center mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz; // 从物体中心指向摄像机作为广告牌新的Z轴 float3 look normalize(_WorldSpaceCameraPos - center); // 上方向固定为世界Y轴避免look恰好竖直导致叉积失效 float3 up abs(look.y) 0.999 ? float3(1, 0, 0) : float3(0, 1, 0); float3 right normalize(cross(up, look)); float3 finalUp cross(look, right); float3 worldPos center v.vertex.x * right * scale.x v.vertex.y * finalUp * scale.y v.vertex.z * look; // 如果原网格有厚度可以加进来 o.pos UnityWorldToClipPos(worldPos);这里的核心是look normalize(_WorldSpaceCameraPos - center)它表示从物体中心指向摄像机。世界竖直广告牌把look作为新平面的法线方向再用世界Y轴和它叉积得到右轴最后再叉积一次得到最终的上轴。这组轴既保证了平面正对摄像机又保证了上方向尽可能接近世界竖直。3.2 柱状广告牌只绕Y轴转再往下还有一种很常见的形态柱状广告牌。它不要求贴片完全朝上或朝下倾斜只让它绕着世界Y轴水平旋转。比如场景里的NPC头顶标记、地上的任务光圈、远处的公告板这些物体通常不会把脸朝向天空或地面而是只需要在水平方向上转向玩家。柱状广告牌和世界竖直广告牌的区别只有一处计算look之后先把Y轴分量去掉再重新归一化。代码是float3 look normalize(_WorldSpaceCameraPos - center); look.y 0; look normalize(look);这样得到的广告牌永远只做水平旋转不管摄像机在头顶还是在脚下贴片都不会倾斜。你要是做草地装饰、树影、路边的牌子这种模式比屏幕对齐模式自然得多。缺点是当摄像机正好在物体正上方时look去掉Y分量后会变成零向量归一化会出错。所以用柱状广告牌时最好加一个分支判断如果look.y接近1就用一个默认的水平朝向比如float3(0,0,1)。3.3 不同场景怎么选很多人第一次接触广告牌会觉得“反正都是面向摄像机随便写一个就行”。实际用下来选择不同朝向会直接影响效果是否真实。我总结的判断方式很简单画面内容如果是“纸片”类的UI特效选屏幕对齐画面内容如果是“长在地上的东西”选世界竖直画面内容如果是“一个本身就该垂直站着的标记或者牌子”选柱状。一句话类比屏幕对齐像你手里拿着一张报纸你总是把报纸正对着自己世界竖直像远处的一棵树你会绕着它转但树永远垂直向上柱状像一个吧台边站立的人你围着他走他上半身始终面向吧台外围但他不会弯腰或抬头来强行看你的脸。4. 实测最容易翻车的细节Batching、Scale和透明体排序4.1 为什么必须关闭动态批处理广告牌Shader里有一行看起来不起眼但绝对不能省略的TagDisableBatching True。刚接触Shader的人很容易忽略它然后碰到一个非常诡异的现象广告牌物体在Scene视图里正常一运行游戏就消失或者全部挤在一块。原因是Unity的动态批处理会修改渲染用的模型矩阵。当一个物体被动态合批后Unity会把多个小网格重新组织到同一个批处理网格里物体原本的Model矩阵可能被改变甚至变成单位矩阵。Shad要求用UNITY_MATRIX_MV或unity_ObjectToWorld取物体中心一旦矩阵不是原始物体的矩阵算出来的中心点就是错的广告牌自然会被扔到奇怪的位置。这个Tag写在SubShader的Tags里就是让Unity不要对这个材质对应的物体做动态批处理。代价是少了一些合批优化但对大多数广告牌场景来说完全值得。如果你还要配合静态批处理最好也检查一下Mesh是否被标成了Static。稳妥的做法是让广告牌物体保持动态不参与静态合批。4.2 物体缩放对广告牌尺寸的影响我见过很多人在第二个坑里卡住广告牌确实始终朝向摄像机了但是场景里把Quad放大到十倍画面一点变化都没有。原因很简单因为根据广告牌原理顶点偏移用的是模型原始局部坐标并没有理会物体变换里的缩放值。所以我把缩放提取写进了Shader。利用unity_ObjectToWorld这个矩阵的列向量长度可以拿到物体在世界空间三个轴上的缩放。严格说这提取的是包含父级变换的“综合缩放”大多数情况下够用。如果你的物体有非均匀缩放比如X轴拉得很大、Y轴不变广告牌也会正确变成扁的或宽的。这里还牵出一个细节广告牌的中心点取的是float4(0,0,0,1)也就是物体的Pivot点。Unity自带的Quad和Plane中心都在网格中心所以没问题。但如果是自己做的模型Pivot在脚下或者左下角那么广告牌会始终以那个Pivot为中心不会自动居中。这种情况要么改模型要么在Shader里给顶点偏移加上一个中心修正量。4.3 透明排序、Cull和ZWrite的坑广告牌大部分用于透明贴片所以Shader里通常会开Blend并且为了半透明效果正确把ZWrite关掉。这个组合本身没问题但它会带来透明物体的排序问题。Unity的透明物体排序主要是按对象中心到摄像机的距离排两个大广告牌交叠时排序经常不是视觉上想要的。如果广告牌只是普通不透明物体比如树的剪影可以把Queue改成AlphaTest或Geometry然后打开ZWrite。如果是半透明的粒子贴片就要接受排序问题的存在尽量避免多个半透明广告牌互相穿插。还有一个容易被忽略的设置是Cull Off。从原理上讲广告牌始终正对摄像机正反面不会出错用Cull Back就够了。但实际项目中很多广告牌网格是程序生成的面的朝向可能不规范或者粒子系统里的贴片翻转方式多种多样。保险起见我常开Cull Off让背面也能渲染。对于一张简单的单面贴片这个开销几乎可以忽略但能避免很多“某一边看过去就消失”的诡异状况。5. 进阶玩法固定屏幕尺寸、URP移植和批量渲染5.1 做血条/名字牌固定屏幕尺寸广告牌还有个常见衍生需求名字牌和血条不管离摄像机多远在屏幕上的大小都固定。普通广告牌在3D世界里是正常的近大远小但UI类标记需要一直保持可读性。屏幕对齐广告牌的基础上做固定屏幕尺寸很简单。观察空间中摄像机到物体的深度距离就是center.z的绝对值。透视投影的效果是物体离得越远屏幕上的大小越小。要让屏幕大小不随距离变化就要让广告牌的顶点偏移量跟着距离变大抵消透视压缩float4 center mul(UNITY_MATRIX_MV, float4(0, 0, 0, 1)); float dist -center.z; // 距离越远偏移越大抵消近大远小 float fixedScale dist * _ScreenScale; float4 viewPos center float4(v.vertex.x * fixedScale, v.vertex.y * fixedScale, 0, 0); o.pos mul(UNITY_MATRIX_P, viewPos);这里的_ScreenScale是一个Material属性代表“每距离单位对应的世界偏移量”实际数值需要你自己调。注意这个做法针对透视摄像机如果场景用正交摄像机距离本身不影响投影大小直接用一个固定缩放值就行不需要乘dist。5.2 移植到URP的注意事项上面代码用的是内置渲染管线和UnityCG.cginc。如果你项目已经切到URP核心思路不用变。URP里依然有UNITY_MATRIX_MV、UNITY_MATRIX_P这些矩阵变量但头文件要换成URP的Core.hlsl或者ShaderLibrary里的对应文件。如果你喜欢写世界空间版本只要把UnityWorldToClipPos替换成URP的TransformWorldToHClip然后确保在HLSL代码里引入了对应的函数库。语法上差别很小真正要留意的是SRP Batcher。URP启用SRP Batcher后Shader必须使用CBuffer包裹和材质相关的属性不然Unity会一路报错或者效果看起来正常但一直无法合批。写的时候优先把_Color、_MainTex_ST这类属性放进CBuffer里。5.3 性能与合批的取舍广告牌Shader本身很轻顶点数量也不多GPU压力基本可以忽略。问题主要出在合批上。前面提到为了拿到正确的物体中心点我们关掉了动态批处理。这意味着如果场景里有两百个广告牌就可能产生两百个Draw Call。这个取舍要根据实际项目定。我个人的做法是数量不大比如几十个血条、十几个特效贴片完全不用纠结数量特别大比如野外场景里成百上千的草和树就不要自己硬写广告牌Shader了优先考虑Unity的粒子系统或者GPU Instancing。特别是粒子系统里本来就内置了Billboard渲染模式设置一下即可不需要自己实现。如果在自写Shader里想保留合批需要换一种思路不依赖物体变换矩阵而是把广告牌的中心点、尺寸、旋转轴等信息通过顶点色或者实例化属性传入。这个方案复杂度会高不少但确实能实现大批量渲染。新手阶段我更建议先用DisableBatching保效果等到真的因为Draw Call卡了再考虑优化。最后分享一个我自己的经验做广告牌效果时先想清楚“这个贴片是贴地、贴墙、还是贴屏幕”。贴地选柱状贴墙选屏幕对齐贴屏幕也选屏幕对齐。方向选对了后面的代码基本都是同一个模板来回改。踩过几次坑之后你会发现广告牌Shader并不神秘它做的无非就是一件事把“物体原本怎么摆”忘掉重新按摄像机位置摆一遍。