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轴上,再加上物体中心的世界坐标,就得到了一个“始终面向摄像机”的顶点位置。这个思路听起来复杂,实际代码只有几行。这也是为什么广告牌效果最好不要用脚本每帧LookAt:Shader直接把它变成了一次纯数学计算,而且是在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里有一行看起来不起眼但绝对不能省略的Tag:DisableBatching = 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并不神秘,它做的无非就是一件事:把“物体原本怎么摆”忘掉,重新按摄像机位置摆一遍。