搜“Unity镜子效果”,十个结果里八个让你写Shader。我不是说Shader方案不好——那确实是性能最优解,但对于大部分独立开发和中小团队来说,为了大厅里一面装饰镜,或者换装界面一个“照镜子”的交互,专门去写一个自定义镜子Shader,还要应对URP兼容、多Pass、反射采样这些后续问题,投入产出比实在低得离谱。
这篇博文记录的是我实际项目里用的RenderTexture方案:不写一行镜子Shader,纯C#脚本搞定,运行时把一台“反射摄像机”拍到的东西实时贴到镜子表面。这套思路在PC端和移动端我都跑过,工程稳定,效果满足绝大多数交互场景。文章会拆解反射矩阵的计算、RenderTexture的创建与材质挂接、镜子边缘裁剪穿帮的根治办法,以及URP迁移时需要注意的坑。不管你是被Shader劝退的新手,还是想找一个快速方案的老兵,这篇都值得看完。
1. 为什么绕开Shader:RenderTexture方案的选型逻辑
1.1 这个方案的本质与适用边界
RenderTexture方案的核心逻辑其实非常朴素:你在镜子前面再摆一台摄像机,把它渲染到的画面当作“镜子里的世界”,最后贴到镜子模型上。听起来像作弊,但物理上完全说得通——平面镜成像的本质就是观察点关于镜面的镜像,一台位于镜像位置的摄像机,拍到的画面就是人站在镜子前能看到的东西。
这个方案最大的优势是完全绕开了图形学底层。你不需要理解BRDF、不需要处理反射向量、不需要考虑URP和Build-in管线的Shader差异,甚至美术同学都能独立维护:镜子就是一个普通的模型,贴图是一张RenderTexture,脚本挂上去就能跑。
代价也很明显:整个场景要多渲染一遍。反射相机看到的范围和主相机越接近,多出来的开销就越大。所以这个方案天然适合这些场景:换装镜、展示台、走廊装饰镜、特定房间里的交互镜,这些镜子数量少、画面有明确视觉中心,多渲染一遍的成本可以接受。如果你的项目里有一整面镜子墙,每个角色都要实时倒影,那还是老老实实回去写Shader或者用反射探针(Reflection Probe)做近似。
1.2 RenderTexture方案 vs 手写Shader的取舍表
我给自己项目选型的时候列过一个对比表,这里直接分享出来:
| 对比维度 | RenderTexture方案 | 手写镜子Shader方案 |
|---|---|---|
| 开发周期 | 半天搞定,纯C# | 至少一周起步,要懂渲染管线 |
| 运行开销 | 高一倍(场景渲两次) | 低,单Pass额外绘制 |
| 效果真实度 | 物理级正确,反射无失真 | 取决于实现深度,平面反射也可做到 |
| 动态物体支持 | 天然支持,角色走过去就有倒影 | 如果只反射静态环境,需要额外处理动态物体 |
| 多镜子扩展 | 每面镜子一张RT,数量大了会爆炸 | 可以共享Pass,扩展性更好 |
| 维护成本 | 低,换换CullingMask就行 | 中,管线升级时Shader要跟着改 |
| 画质调整空间 | 分辨率、图层控制,简单粗暴 | 可以做模糊、扭曲、菲涅尔等高级效果 |
我最终选了RenderTexture,就是看中“半天搞定”和“天然支持动态角色”。做换装镜的项目,角色是动态的,身体、头发、衣服每帧都在变,用反射探针根本没法把角色本身反射进去,Shader方案工作量又不可控,RT方案恰好卡在需求和成本之间的甜点上。
2. 反射矩阵的计算与反射相机同步
2.1 反射矩阵是怎么一步步算出来的
要让反射相机拍出“主相机镜像位置”的画面,最简单的办法是直接修改相机的View矩阵。View矩阵的本质是把世界坐标变换到相机空间,那么主相机的View矩阵再左乘一个“反射矩阵”,就是在世界空间中先把坐标镜像翻转,再送进主相机的坐标系里。这一步到位,不需要手动移动反射相机的Transform。
反射矩阵的数学基础是平面反射公式。对于一个平面,用四维向量表示法线n = (nx, ny, nz)和距离d,空间里任意一点P关于这个平面的对称点P'为:
P' = P - 2 * (n · P + d) * n把这一步整理成矩阵形式,就是Unity官方Water.cs里那段经典的CalculateReflectionMatrix:
static Matrix4x4 CalculateReflectionMatrix(Matrix4x4 reflectionMat, Vector4 plane) { reflectionMat.m00 = (1F - 2F * plane[0] * plane[0]); reflectionMat.m01 = (-2F * plane[0] * plane[1]); reflectionMat.m02 = (-2F * plane[0] * plane[2]); reflectionMat.m03 = (-2F * plane[3] * plane[0]); reflectionMat.m10 = (-2F * plane[1] * plane[0]); reflectionMat.m11 = (1F - 2F * plane[1] * plane[1]); reflectionMat.m12 = (-2F * plane[1] * plane[2]); reflectionMat.m13 = (-2F * plane[3] * plane[1]); reflectionMat.m20 = (-2F * plane[2] * plane[0]); reflectionMat.m21 = (-2F * plane[2] * plane[1]); reflectionMat.m22 = (1F - 2F * plane[2] * plane[2]); reflectionMat.m23 = (-2F * plane[3] * plane[2]); reflectionMat.m30 = 0F; reflectionMat.m31 = 0F; reflectionMat.m32 = 0F; reflectionMat.m33 = 1F; return reflectionMat; }平面向量plane怎么来?假设你的镜子是一个Quad或者空物体,在Inspector里指定法线方向planeNormal,镜面位置是transform.position,那么平面方程的d就是-Vector3.Dot(normal, position)。组合起来就是四维向量:
Vector3 normal = transform.TransformDirection(planeNormal).normalized; Vector4 reflectionPlane = new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, transform.position));这里有个特别容易踩坑的地方:不同模型的默认法线方向不一样。Unity自带的Quad,顶点法线默认指向自己的局部Z轴负方向,也就是-transform.forward;而内置的Plane(默认放在XZ平面),法线是transform.up。所以我在脚本里统一用一个planeNormal字段暴露出来,默认值给Vector3.back,对应Quad。如果你的镜子模型是Plane或者Cube的某个面,把这个向量改成transform.up对应的方向测试一下即可。
2.2 反射相机的渲染主循环
有了反射矩阵,剩下的工作就是拼装反射相机。我直接放一段精简过、去掉官方冗余逻辑的核心代码,项目里可以照着改:
public class MirrorReflection : MonoBehaviour { public LayerMask reflectLayers = -1; public int textureResolution = 512; Camera reflectionCamera; RenderTexture rt; int mirrorLayer = 8; // 指定镜子所在Layer void Awake() { // 创建反射相机 var go = new GameObject("ReflectionCamera"); go.hideFlags = HideFlags.HideAndDontSave; reflectionCamera = go.AddComponent<Camera>(); reflectionCamera.enabled = false; rt = new RenderTexture(textureResolution, textureResolution, 16); rt.name = "MirrorRT_" + GetInstanceID(); } void OnWillRenderObject() { Camera cam = Camera.current; if (cam == null || cam != Camera.main) return; Vector3 normal = transform.TransformDirection(planeNormal).normalized; float sideSign = Mathf.Sign(Vector3.Dot(normal, cam.transform.position - transform.position)); if (sideSign < 0f) return; // 主相机在镜子背面,啥也不用干 // 反射矩阵 Vector4 reflectionPlane = new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, transform.position)); Matrix4x4 reflectionMatrix = CalculateReflectionMatrix(Matrix4x4.identity, reflectionPlane); // 同步主相机参数,然后把view矩阵替换为镜像view矩阵 reflectionCamera.CopyFrom(cam); reflectionCamera.worldToCameraMatrix = cam.worldToCameraMatrix * reflectionMatrix; reflectionCamera.cullingMask = cam.cullingMask & ~(1 << mirrorLayer); reflectionCamera.targetTexture = rt; // 关键:镜像view矩阵会让三角形绕序反转,必须翻转culling GL.invertCulling = true; reflectionCamera.Render(); GL.invertCulling = false; } }脚本挂在镜子模型上,利用OnWillRenderObject在镜子即将被渲染时刷新RT。这样做的好处是——镜子不在屏幕里时,反射相机也不会白干活,Unity的视锥剔除会自动跳过OnWillRenderObject的触发。如果放在Update里,哪怕镜子在另一个房间,你也在白白渲染RT,性能差距很直观。
代码里GL.invertCulling = true;这行很多人会漏掉,漏了之后镜子里物体的正反面会颠倒,看起来像“纸片人”一样缺面。原因是反射矩阵的行列式为负,会把三角形的绕序(Winding Order)反转,渲染管线根据绕序做背面剔除,结果就把本应朝向镜子的面全剔掉了。这个坑我在第一次实现的时候踩过,排查了半天才意识到是Culling的问题。
3. 把反射画面贴到镜面上:RT创建、材质调节与UV翻转
3.1 RenderTexture的创建细节
RenderTexture的创建代码上面已经出现了,但有几个参数值得单独说。深度缓冲区填16就够,反射画面不需要深度精度。分辨率建议做成可配置项,不要直接用主相机分辨率,否则在高分屏上会很痛:
rt = new RenderTexture(textureResolution, textureResolution, 16);这个textureResolution在PC上512能看清大部分细节,移动端256就够用。分辨率过高,RT带宽和填充率都会迅速成为瓶颈,尤其是手机GPU上,一张1024的RT加整个场景重绘,直接能把帧率拉低10帧以上。
还有质量等级的问题。Unity切换画质等级(QualitySettings)时,纹理压缩格式和抗锯齿设置可能会变,RT的格式最好跟着重建。简单做法是缓存QualitySettings.GetQualityLevel(),发现变化就rt.Release()后再创建一个新的:
int cachedQualityLevel = -1; void UpdateRTIfNeeded() { int currentQuality = QualitySettings.GetQualityLevel(); if (cachedQualityLevel != currentQuality) { cachedQualityLevel = currentQuality; rt.Release(); rt.width = textureResolution; rt.height = textureResolution; rt.Create(); } }3.2 材质组装:Standard的Emission路线与Unlit路线
RT渲染好之后,怎么把它显示到镜面上?这里有两种路人最常用的做法,我分别说下优劣。
第一种是Standard Shader配Emission。把镜子材质设成Standard,Metallic拉到1.0,Smoothness调到0.85左右模拟光滑玻璃,然后在Emission区域把颜色调成接近白色,再把RT指定给Emission Map:
Material mat = GetComponent<Renderer>().sharedMaterial; mat.SetTexture("_EmissionMap", rt); mat.EnableKeyword("_EMISSION");这个做法的好处是URP和Build-in下Standard都自带,不用额外找Shader,光照效果也自然——镜面反射光加上Emission的亮度,看上去会有一点“发光镜子”的高级感。缺点是Standard的Emission Map默认受光照影响程度有限,如果场景很暗,镜面会显得偏淡。
第二种是Unlit/Texture路线。材质用Unlit Shader的Unlit/Texture,直接把RT指定给主纹理:
mat.mainTexture = rt;画面明亮干净,镜像内容不会被光照干扰,颜色还原准确。但Unlit材质在URP下需要替换成URP的Unlit Shader,否则编辑器里会显示成紫红色,这对新手是个隐藏坑。
我个人觉得,如果是换装、拍照这类看重色的镜子,Unlit更合适;如果是场景装饰镜,Standard加Emission更协调。两种都试一遍,哪个顺眼用哪个。
3.3 UV翻转:那些年踩过的渲染坐标经典坑
把RT贴到材质上之后,你十有八九会看到镜像画面上下颠倒。这不是Bug,是Unity里RenderTexture的UV坐标约定和屏幕空间不一致导致的:屏幕空间的UV原点在左下角,而RT的采样原点在左上角。同一个画面被当作纹理贴出来,就相当于垂直翻转了一次。
解决办法很简单,在材质上直接把纹理的TilingY设成-1、OffsetY设成1,或者反过来,调一两次就能试对:
Material mat = GetComponent<Renderer>().sharedMaterial; mat.SetTextureScale("_MainTex", new Vector2(1, -1)); mat.SetTextureOffset("_MainTex", new Vector2(0, 1));用Standard的Emission轨道时,属性名要改成_EmissionMap:
mat.SetTextureScale("_EmissionMap", new Vector2(1, -1)); mat.SetTextureOffset("_EmissionMap", new Vector2(0, 1));具体的正负号取决于你的镜子模型朝哪个方向,以及RT翻转的方向。如果一个方向不对,把x改成-1、y改成1组合着试。这个小技巧放到渲染层处理其实就是UNITY_UV_STARTS_AT_TOP那套逻辑,但我们既然不写Shader,用材质参数修正是最快的解法。
4. 斜裁剪矩阵:镜子边缘穿帮的根治
4.1 穿帮现象:镜面底部凭空出现的“地板横截面”
反射相机同步好、RT跑通后,你会在特定角度发现一个很恼人的问题:当主相机靠近镜子、俯角比较大时,镜子画面底部或者边缘会凭空冒出一块不自然的平面,就像地面被切开一样。这块多出来的东西,其实是地板被反射相机“错误地渲染”出来了——准确说,是地面的一个截面挡住了本该被反射的上层空间。
根因在于反射相机的近裁剪面。主相机的View矩阵被反射矩阵镜像之后,它的近裁剪面相当于被翻到了镜面背后的某个位置。当主相机贴近镜子时,反射相机的近裁剪面可能会和地板平面相交,那个在裁剪面以下的地板截面就被渲染出来了。
4.2 用Oblique Near Plane把裁剪面掰到镜子平面
Unity官方Water.cs里专门处理了这个问题,思路是修改反射相机的投影矩阵,把近裁剪面强行推到镜面所在的平面上,这样地板等位于裁剪面下方的物体就被正确剔除了。
核心代码是这两段:
static Vector4 CameraSpacePlane(Camera cam, Vector3 pos, Vector3 normal, float sideSign) { Vector3 offsetPos = pos + normal * 0.07f; Matrix4x4 m = cam.worldToCameraMatrix; Vector3 cpos = m.MultiplyPoint(offsetPos); Vector3 cnormal = m.MultiplyVector(normal).normalized * sideSign; return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal)); }static void CalculateObliqueMatrix(ref Matrix4x4 projection, Vector4 clipPlane) { Vector4 q = projection.inverse * new Vector4( Mathf.Sign(clipPlane.x), Mathf.Sign(clipPlane.y), 1.0f, 1.0f ); Vector4 c = clipPlane * (2.0F / (Vector4.Dot(clipPlane, q))); projection[2] = c.x - projection[3]; projection[6] = c.y - projection[7]; projection[10] = c.z - projection[11]; projection[14] = c.w - projection[15]; }然后在反射相机同步主相机投影矩阵之后,立刻调用一次:
Vector3 normal = transform.TransformDirection(planeNormal).normalized; float sideSign = Mathf.Sign(Vector3.Dot(normal, cam.transform.position - transform.position)); if (sideSign < 0f) return; Vector4 clipPlane = CameraSpacePlane(reflectionCamera, transform.position, normal, sideSign); Matrix4x4 projection = cam.projectionMatrix; CalculateObliqueMatrix(ref projection, clipPlane); reflectionCamera.projectionMatrix = projection;注意顺序:先CopyFrom(cam)再设置投影矩阵,因为CopyFrom会把主相机的投影矩阵也复制过来,你必须在之后覆盖它。CameraSpacePlane里的0.07f是ClipPlaneOffset,作用是把裁剪面稍微往镜子前方偏移一点,防止镜子自身模型和裁剪面产生Z-fighting闪面。
4.3 自动判断反射方向:避免镜子背面也渲染
斜裁剪矩阵里的sideSign同时被用来判断主相机在镜子的哪一侧。这个判断很有用:当玩家角色绕到镜子背面时,正常来说他应该看不到反射画面。如果没有sideSign < 0f这个早退逻辑,反射相机会一直工作,哪怕主相机在镜子背面,也会把RT渲染出来贴在镜子上,反射画面和实际场景会错位,看着很假。
代码里sideSign的计算是Mathf.Sign(Vector3.Dot(normal, cam.transform.position - transform.position)),点积为正说明主相机在镜面法线方向那一侧,也就是正面;为负说明在背面,直接跳过渲染。这个判断要在你设置好planeNormal之后才生效,因为法线方向决定了“正面”在哪边。
5. 从能用到好用:分辨率策略、分层渲染与URP适配
5.1 RT分辨率:一半已经是上限
RT分辨率是RT方案里性价比最高的旋钮。我实测下来,反射相机的分辨率设为主相机分辨率的1/2,肉眼几乎分辨不出差别;设成1/4,镜子稍微远一点也看不出破绽。原因很简单——RT最终被贴在镜面模型上,经过透视缩小之后,原始分辨率会被大幅稀释,你根本不需要和屏幕一样大的RT。
实操建议:PC端把RT固定到512或1024,移动端固定到256,复杂场景先用256跑通再往上调。与其堆分辨率,不如保证抗锯齿。RT自带抗锯齿(MSAA)在移动端开销很大,我更推荐关掉RT的MSAA,把分辨率拉高一点,效果反而清爽。
5.2 分层渲染:只用CullingMask控制可见内容
这是RT方案性价比最高的一条优化手段。反射相机继承主相机的CullingMask之后,你可以手动排除一些不需要出现在镜子里的东西:
- 镜子自身所在的Layer,避免反射相机把镜子自己渲染进去,产生无限递归闪烁
- 只在主相机渲染的UI层、特效层、天空盒相关Layer,在反射相机里排除
- 场景里距离镜子很远的物件,用一个自定义Layer归纳,反射相机不渲染
reflectionCamera.cullingMask = cam.cullingMask & ~(1 << mirrorLayer);分层操作虽然朴素,但是效果立竿见影。有一次客户场景里有个动画特效层忘在CullingMask里,反射RT每秒渲染一整层粒子特效,帧率直接掉了15,排查半天才发现是Layer没过滤干净。
5.3 URP下的兼容问题与绕行方案
这篇文章标题说“无需镜子Shader”,但URP迁移这件事还是要提一嘴,因为现在新建的Unity项目默认都是URP了。
在Build-in管线下,OnWillRenderObject+GL.invertCulling+reflectionCamera.Render()这套组合是能跑的。切到URP之后,OnWillRenderObject依然会被调用,但Camera.current的判断逻辑在URP里不太稳定,而且URP有些RenderFeature会接管相机渲染,直接导致RT输出异常或者反射画面全黑。
URP下我更推荐的做法是:把RT的刷新逻辑从OnWillRenderObject挪到LateUpdate或RenderPipelineManager.beginCameraRendering事件里。
void OnEnable() { RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering; } void OnDisable() { RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering; } void OnBeginCameraRendering(ScriptableRenderContext context, Camera cam) { if (cam != Camera.main) return; // 这里执行上面第2章的同步逻辑 UpdateReflectionCamera(cam); }注意URP下GL.invertCulling这个全局开关虽然还在,但在SRP里它可能影响后续其他绘制命令,用完立刻复位是必须的。另外一个最省心的做法是:URP项目里直接给镜子用URP自带Unlit Shader,配合简单材质参数,RT贴上去就能看,不走Standard那套,反而避免了很多SRP Batcher兼容问题。
5.4 当镜子不止一面:假镜子与静态RT的取舍
项目里出现多面镜子时,RT方案的短板会迅速暴露。三面镜子就是三张RT,三倍的场景重绘,普通手机根本扛不住。
这时候要分情况处理。如果镜子是玩家必经之路上的交互点,比如换装间,把RT分辨率压低,同时用CullingMask限制反射内容,可以撑住两三面。如果镜子只是场景装饰,我的建议是干脆做“假镜子”——把RT渲染结果烘焙成一张静态图片,运行时只贴图不渲染。烘焙方法也简单,编辑器脚本跑一次反射相机渲染,把RT通过ReadPixels存成PNG,资源里当普通贴图用。
Texture2D tex = new Texture2D(rt.width, rt.height, TextureFormat.RGB24, false); RenderTexture.active = rt; tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); RenderTexture.active = null; File.WriteAllBytes("Assets/MirrorStatic.png", tex.EncodeToPNG());这个技巧尤其适合移动端的大型场景——你得到了一面完全真实的镜子,但运行时开销约等于零。
还有一个常见补充:如果你需要镜面更“像玻璃”,可以在RT上面再叠一层半透明的反射扰动贴图,用UV动画模拟镜面水汽或擦痕,算是RT方案之外的一点小点缀,但那就是材质美术的活儿了,不在本文范围。
最后再分享两个实操心得
一个是RT的释放时机。项目里如果频繁切换场景,记得在OnDisable或OnDestroy里把RT和反射相机对象释放掉,否则很容易造成显存泄漏。别只放在OnDestroy,场景切换时OnDisable一定会先触发,双保险更稳。
另一个是布光对镜子效果的影响比想象中大。反射相机是直接复用主相机场景的灯光环境的,RT里渲染出来的光照和角色身上的光照天然一致。有人做完镜子觉得“看起来假”,排查半天不是代码问题,而是镜子正前方缺少一个用于反射画面的光源。只要场景光打好了,RT镜子的视觉欺骗性非常强,玩家基本不会意识到这是个实时贴图。这套方案我前后用在了换装、展厅、交互导览三个项目里,稳定上线,推荐你也试试。