深度感知

遮挡不是画出来的效果,而是一次逐片元的比较:真实表面有多远,你这个像素有多远 —— 而给出答案的那张深度缓冲,比彩色画面粗一个数量级。

四个虚拟球,一根真实柱子

球体绕着柱子和矮墙转。右边那块面板就是比较时真正读的深度缓冲 —— 把它的分辨率拖低,遮挡边缘会变成一级级台阶,因为数据本来就长这样。

这个演示需要 WebGL,你的浏览器没有提供。下面的正文本身是完整的,不看演示也能读。

正在加载演示…

这个模块真正给你的东西

深度感知每帧、每个视图给你一张图,图上每个值是沿该方向到最近真实表面的距离。仅此而已。人们和这个功能联系在一起的所有东西 —— 遮挡、把物体放到东西上、和房间做物理碰撞 —— 都是你在这一张图之上自己搭出来的。

这张图很小。当前的 Android 硬件上大约 160×90,而彩色画面有两百万像素以上。它还有噪声,而且有洞:透明表面、深色表面、超出传感器量程的一切,返回的要么是垃圾值,要么什么都没有。把它当成房间的可靠深度图,是让产品在一间装着玻璃门的厨房里看起来彻底坏掉的最快路径。

它真正擅长的,恰恰是让 AR 不再像贴在屏幕上的贴纸那件事。一个正确地消失在真实椅子后面的虚拟物体,会被读成「在房间里」;同一个物体画在所有东西之上,就会被读成「在显示器上」。这个区别,只差每个片元一次比较。

请求它,然后读一帧

请求里带的是偏好,不是要求,会话完全可能给你一个你没要的东西。把实际拿到的读回来是强制动作 —— CPU 和 GPU 是两套不同的 API,而不同数据格式的解包方式也不一样。

const session = await navigator.xr.requestSession('immersive-ar', {
  requiredFeatures: ['depth-sensing'],
  depthSensing: {
    // 两个都是有序偏好。你可能拿到的是另一个。
    usagePreference: ['gpu-optimized', 'cpu-optimized'],
    dataFormatPreference: ['luminance-alpha', 'float32'],
  },
});

// 读实际授予的结果。按「自己请求的那个」分支,
// 是多数深度感知代码里的第一个 bug。
const usage = session.depthUsage;        // 'cpu-optimized' | 'gpu-optimized'
const format = session.depthDataFormat;  // 'luminance-alpha' | 'float32' | ...

function onFrame(time, frame) {
  const pose = frame.getViewerPose(localSpace);
  if (!pose) return;

  for (const view of pose.views) {
    if (usage === 'cpu-optimized') {
      const depth = frame.getDepthInformation(view);
      if (!depth) continue;   // 这一帧没有深度是正常的,不是错误。

      // 参数是归一化视图坐标,不是像素。返回值单位是米。
      const centre = depth.getDepthInMeters(0.5, 0.5);

      // 直接读原始缓冲时:值的单位是缓冲自己的单位,必须换算。
      // const raw = new Uint16Array(depth.data)[y * depth.width + x];
      // const metres = raw * depth.rawValueToMeters;
    } else {
      const depth = glBinding.getDepthInformation(view);
      if (!depth) continue;

      gl.bindTexture(gl.TEXTURE_2D, depth.texture);
      // 这个变换把归一化视图坐标映射到深度缓冲坐标。
      // 深度缓冲和视口**并不对齐**。省掉它,是遮挡整体偏移或旋转
      // 最常见的单一原因。
      setUniformMatrix('uDepthFromView', depth.normDepthBufferFromNormView.matrix);
      setUniform('uRawValueToMeters', depth.rawValueToMeters);
    }
  }
}

只要运行时这一帧对该视图没有深度,getDepthInformation 就返回 null —— 启动阶段、突然移动时、传感器过曝时都会发生。沿用上一帧的结果,或者这一帧不做遮挡;不要因此拆掉任何东西。

必须弄对的几个名字

模块不大,但下面每一个都咬过人。六个里有四个讲的是同一件事:别相信你请求的那个值。

usagePreference
cpu-optimized 给你一个能逐值读取的 ArrayBuffer;gpu-optimized 给你一张只能在着色器里采样的 WebGL 纹理。按你真正要用的方式请求 —— 每帧把 GPU 纹理回读到 CPU 会让整条管线停顿。
dataFormatPreference
luminance-alpha 把一个 16 位值拆进两个通道;float32 直接给米。打包格式的支持面宽得多,所以只按 float32 写的代码会在大多数设备上失败。
rawValueToMeters
从缓冲单位到米的比例。它是**帧**的属性,不是常量。把某台设备上碰巧对的值写死,项目就会莫名其妙地多出一个「按机型不同的缩放系数」。
normDepthBufferFromNormView
从归一化视图坐标到深度缓冲坐标的变换。深度缓冲有自己的宽高比和朝向。省掉它,遮挡就会被拉伸、偏移,或者整个转 90 度 —— 而且在竖屏下往往看着「差不多是对的」,所以它能一路活过评审。
getDepthInMeters(x, y)
CPU 路径上的便捷取值方法。x 与 y 是 0..1 的归一化视图坐标,不是像素下标,而且越界时它抛异常,不做钳制。
XRWebGLBinding.getDepthInformation
GPU 路径的入口。它挂在 binding 上,不在 frame 上 —— 在两种使用模式之间移植代码时最容易漏掉这一点。

边缘为什么是台阶状的,以及该怎么办

一张 160 像素宽的深度缓冲铺满整个视口,大约每八到十个屏幕像素才有一个深度样本。拿它做硬比较,得到的正是你能预料的东西:块状、带锯齿的轮廓,而且会随用户移动而抖动。这不是你着色器的 bug。

常规解法是把**比较**放软,而不是把**数据**变锐。不做二值判断,而是让虚拟片元在几厘米的深度差里渐隐。边缘于是变成一小段梯度,读起来像柔和的阴影而不是锯齿几何,代价是一次 smoothstep。上面的演示就是这么做的 —— 在低分辨率下切换硬/软两种模式,差别一眼可见。

解法的另一半,是知道什么时候干脆别信这张图。深度在轮廓边缘最不可靠,因为一个样本会同时跨在近处和远处两个表面上。有些实现会给出置信度信号;没有的时候,常见做法是比较相邻样本,在它们分歧过大的地方跳过遮挡 —— 宁可让虚拟物体多压出去一点,也别让它的轮廓沸腾。

上面的演示在做什么

地板、柱子和矮墙是真实世界。它们每帧被单独渲染进一张很小的离屏缓冲,缓冲里只存到相机的距离 —— 和真实深度传感器提供的是同一种信息,只不过这里由演示自己知道的几何生成。右侧面板直接显示这张缓冲,越近越亮。

球体是虚拟内容。它们的材质在片元自己的屏幕位置上采样那张缓冲,把采样值换算成米,然后比较。在后面就丢弃。这和你面对真实 XRWebGLDepthInformation 纹理会写的着色器是同一个,只少了那个对齐变换 —— 演示不需要它,因为这张缓冲本来就和视口对齐,而它恰恰是真机上把人绊倒的那一步。

深度缓冲按每像素一字节、八米量程存储,也就是量化到约三厘米 —— 这是故意的,因为真实传感器同样是量化的。选上硬遮挡,把分辨率拖到最低,轮廓就变成一段楼梯;切到软遮挡,同样的数据立刻变得可用。

功能可用性

深度感知是这里几个 AR 功能里可移植性最差的一个,而且你拿到的使用模式未必是你请求的那个。

平台depth-sensingCPU 模式GPU 模式
Android Chrome(ARCore)支持支持支持
Meta Quest 3 / Pro 浏览器支持部分支持
visionOS Safari不支持不支持不支持
iOS Safari(iPhone)不支持不支持不支持
桌面浏览器不支持不支持不支持

数据核对于 2026-09。运行时请读 session.depthUsage 与 session.depthDataFormat —— 功能被授予了,交付的仍可能是你列在第二位的那个模式。

最费时间的那些坑

第一条制造的困惑比其余加起来还多,因为它产出的遮挡虽然是错的,看上去却像是故意的。

跳过 normDepthBufferFromNormView
直接拿视口 UV 去采样深度纹理。结果是偏移或拉伸,而在深度缓冲宽高比碰巧和视口一致的设备上它看着几乎是对的 —— 直到有人把手机横过来。
把 rawValueToMeters 写死
它随设备和格式变化。写死的比例会让遮挡在除了写代码那台之外的每部手机上,稳定地偏近或偏远。
默认是 float32
多数设备交付的是 luminance-alpha。把缓冲按 Float32Array 读的代码不会抛异常,只会给出毫无意义的距离 —— 它是静默失败的。
把 null 当成致命错误
启动阶段和快速移动时会整帧没有深度。这一帧退回到不做遮挡是正确的,结束会话不是。
在全分辨率下做二值遮挡
拿一张 160 宽的缓冲做硬判定,走样严重且随运动抖动。改成在几厘米内渐隐 —— 一行代码,也是上线产品的做法。
在轮廓边缘相信深度
一个同时跨在近处和远处表面上的样本,返回的是两者之间的某个值,而这个值哪个表面都不属于。遮挡瑕疵就集中在那里,在那里退让比相信数据更划算。

延伸阅读

规范很短,而且两条使用路径是分开描述的 —— 读你打算用的那条,别两条都草草扫一遍。