遮挡渲染让虚拟物体真正融入环境
什么是遮挡
没有遮挡的 AR 应用里,虚拟物体永远"盖在"所有真实物体上面——你放的虚拟恐龙会穿过桌子腿、穿出墙壁、盖在人的头上。这会让 AR 体验看起来非常"假"。
遮挡(Occlusion)的目标是:让虚拟物体出现在真实物体的正确位置关系上——桌子上的虚拟杯子被手挡住、虚拟龙的尾巴被墙遮住、人站在虚拟电视前面时电视就应该被人挡住。
遮挡渲染的基本原理
Depth API 每一帧都提供一张深度图(每个像素 = 该点到相机的距离)。虚拟物体渲染时也会生成自己的深度值(每个像素 = 虚拟物体到相机的距离)。两者对比:
| 深度关系 | 渲染结果 |
|---|---|
| 虚拟物体距离 ≤ 真实物体距离 | 虚拟物体在前面 → 正常渲染 |
| 虚拟物体距离 > 真实物体距离 | 虚拟物体在后面 → 不渲染该像素(被真实物体遮挡) |
这本质上是传统 3D 渲染中的 Z-Buffer(深度缓冲)测试——不同之处在于 AR 场景的背景是摄像头画面,不是 3D 场景,所以背景的深度值需要从 Depth API 读进来写入 Z-Buffer。
实现流程概览
每一帧的渲染流程:
1. 更新 Session → 获取最新 RGB 画面 + 深度图
2. 把 RGB 画面全屏渲染到屏幕
3. 把深度图的每个像素写入深度缓冲 Z-Buffer
4. 像普通 3D 渲染一样渲染虚拟物体
→ GPU 自动用 Z-Buffer 做遮挡测试
5. 最终画面 = RGB 背景 + 带遮挡的虚拟物体
第 3 步是关键:把 Depth API 的数据传给 GPU 深度缓冲。
OpenGL ES 中的实现思路
在 Android 原生开发中,使用 OpenGL ES:
// 1. Depth API 已经把深度数据渲染到了一个 FBO(Frame Buffer Object)
// 这个 FBO 的 color attachment 是深度纹理,depth attachment 是一个可写的深度缓冲
// 2. 在渲染虚拟物体之前,把深度缓冲切换成 Depth API 提供的深度缓冲
// 让后续的渲染调用用真实世界的深度做遮挡测试
glBindFramebuffer(GL_FRAMEBUFFER, depthApiFboId)
glDrawBuffers(GL_NONE) // 不写颜色,只写深度
// 3. 渲染虚拟物体
glBindFramebuffer(GL_FRAMEBUFFER, defaultFboId) // 切回屏幕 FBO
glDrawBuffers(GL_COLOR_ATTACHMENT0)
// 禁用 glDepthMask,防止虚拟物体覆盖真实深度
glDepthMask(GL_FALSE)
// 启用深度测试,用真实深度做遮挡判断
glEnable(GL_DEPTH_TEST)
// 渲染虚拟物体...
// GPU 自动对比虚拟物体的深度值和 Z-Buffer 中的真实世界深度值
// 在后方的像素会被丢弃
glDepthMask(GL_TRUE)
Unity AR Foundation 中的遮挡
Unity 把遮挡的麻烦封装好了——用一个组件就能启用:
方式一:XR Occlusion Manager(自动)
AR Foundation 4.0+ 内置了遮挡支持:
- 确保 AR Camera 上有 AROcclusionManager 组件
- 把 Occlusion Prefab 设为 Human Stencil 或 Environment
- Unity 会自动处理好所有深度缓冲的绑定和测试
// 运行时控制遮挡模式
var occlusionManager = GetComponent<AROcclusionManager>();
// 环境遮挡(用 Depth API 的真实世界深度)
occlusionManager.environmentOcclusionMode = EnvironmentOcclusionMode.Automatic;
// 人体遮挡(检测到的人脸/身体)
occlusionManager.humanOcclusionMode = HumanOcclusionMode.Automatic;
方式二:手动用深度纹理做自定义遮挡着色器
如果你需要精细控制(比如只让某些虚拟物体受遮挡),可以写自定义着色器:
// 片段着色器片段
Texture2D _OcclusionDepthTexture;
float4x4 _OcclusionDepthTransform; // 深度纹理坐标变换矩阵
float PseudoRandom(float2 uv) {
return frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453);
}
fixed4 frag(v2f i) : SV_Target
{
// 把屏幕 UV 变换到深度纹理坐标
float4 depthUV = mul(_OcclusionDepthTransform, float4(i.screenUV, 0, 1));
float realDepth = _OcclusionDepthTexture.Sample(sampler, depthUV.xy).r;
float virtualDepth = i.position.z;
// 如果虚拟物体在真实物体后面,丢弃
if (virtualDepth > realDepth)
discard;
return col;
}
遮挡的视觉质量问题
遮挡渲染并不是完美的,实际开发中会遇到以下问题:
边缘锯齿(Aliasing)
深度图分辨率较低时,遮挡边缘会有阶梯状锯齿。解决方案:
- 边缘膨胀(Dilation):用简单的图像处理把深度图中的物体边缘向外扩展几个像素,让遮挡更宽松
- 屏幕空间边缘检测 + 模糊:在边缘位置用 α 混合过渡而不是硬切
深度图质量差
光滑表面(白色墙壁、玻璃)的深度值噪声大,会导致:
- 虚拟物体在墙面上看起来抖动
- 遮挡关系不稳定(一会儿被挡一会儿不被挡)
解决方案:
- 对深度值做时间平滑(Temporal Smoothing):融合多帧深度数据,减少闪烁
- 过滤掉低置信度的深度值(ARCore 提供了 confidence mask)
ToF vs 被动深度的遮挡质量
| 深度来源 | 遮挡质量 | 原因 |
|---|---|---|
| ToF 硬件 | 好 | 深度值噪声小、分辨率稳定 |
| 被动深度 | 中~差 | 纹理丰富时还行,光滑表面会失败 |
| 双摄 | 中等 | 精度和 ToF 相当但需要双摄硬件 |
只用平面检测做"伪遮挡"
如果设备不支持 Depth API,可以用平面检测做一种近似遮挡:
- 把检测到的 Plane 渲染成深度缓冲(作为遮挡参考)
- 平面边缘之外的区域(还没检测到的)不做遮挡
效果不如 Depth API 好,但比完全没有遮挡强。适合低端设备的降级方案。