深度剖析mapbox-gl3Docclusion

Mapbox GL 标记遮挡:让 HTML 标记藏到 3D 楼板、墙体和货架后面

Mapbox GL 的 HTML 标记画在 WebGL 画布之上,楼板、墙体和货架的拉伸体都挡不住它们。本文介绍我们如何用「相机射线 vs 棱柱」解析测试实现标记遮挡。

阅读约 9 分钟发布于 更新于 作者 Spatial Map team
斜视角下的 Retail Mart Store 3D 场景:货架、墙体和自动扶梯,RFID 读写器脉冲、库存徽标和标记都位于各自楼层的高度

一句话说清问题

Mapbox GL 在同一块 WebGL 画布里渲染楼层、墙体、货架和 3D 模型,由深度缓冲决定哪个像素胜出。而 `mapboxgl.Marker` 是叠在画布上方的 HTML 元素。把相机倾斜到一栋三层商店上方,一楼的每个标记都会穿透上面的楼板;垂直俯视时,所有楼层的标记同时出现,根本无从区分。

最容易想到的出路是 Marker 自带的 `occludedOpacity`。但它在这里帮不上忙:它只会让 3D 地形后面的标记变淡,也仅限地形。

常见做法(以及为什么不够)

  • 每次相机移动都调用 `queryRenderedFeatures`。 它只告诉你哪些要素覆盖了某个屏幕像素,却不告诉你它们是否比标记更靠近相机;而且每个标记、每一帧都调用,代价很高。
  • 把 HTML 标记换成 symbol 图层。 symbol 会参与深度测试,但你会失去 DOM 内容(React 图标、悬停状态、拖拽和变换手柄);而且如下文所述,深度测试本身对贴地物体并不可靠。
  • 把标记画成拉伸圆柱。 它们进入了深度缓冲,但看起来像被压扁的易拉罐,也很难点中。

我们真正想要的是:DOM 标记保持原样,只要相机和它之间隔着实体几何,就把它隐藏。

解法:一条射线,多个棱柱

编辑器拉伸出的一切都是棱柱:一个底面轮廓在两个高度之间扫掠而成。于是「这个标记是否被挡住?」就变成「相机→标记线段在棱柱高度区间内的那一段,是否穿过它的底面轮廓?」。一个测试即可覆盖两种关键情形:标记在楼板下方(线段穿过上层楼板的轮廓),以及标记在墙后(线段穿过一个覆盖相机自身高度区间的细长轮廓)。平面的楼层平面图栅格只是底面与顶面重合的退化棱柱。

全部计算在墨卡托空间进行,恒定高度就是恒定的 `z`,因此按高度区间裁剪只需两次除法。轮廓始终来自各类图形自己的生成器,旋转、墙厚和图像裁剪都与实际绘制一致:

  • 区域和圆形 —— 从底部高程到「高程 + 高度」的一个棱柱(网格模式的圆:每格一个)。
  • 墙体 —— 带厚度的墙体多边形;没有厚度的折线没有体积,不遮挡任何东西。
  • 楼梯和电梯 —— 每级台阶和每个平台各一个棱柱,并应用旋转;玻璃面板跳过。
  • 货架 —— 每个货格一个棱柱(排 × 列 × 层),这样前排货架能挡住后排货架上的标签;扁平货格跳过。
  • 楼层平面图 —— 图像裁剪后的四边形(而不是原始包围盒),位于平面图的高程处。
  1. 1
    只读一次相机

    `map.getFreeCameraOptions().position` 给出相机的墨卡托 x/y/z。

  2. 2
    构造线段

    从相机到标记在其自身高度上的位置。

  3. 3
    按高度区间裁剪

    对每个棱柱,把线段裁剪到 `[zBottom, zTop]`。落在眼睛后方或标记之后的交点被丢弃 —— 所以俯视时,标记下方的几何永远不会挡住它。

  4. 4
    测试底面轮廓

    先做包围盒快速排除,再判断:裁剪后的端点是否落在轮廓内,或者线段是否穿过某条边?

  5. 5
    首次命中即停止

    一个遮挡体就够了;循环中断,标记 id 加入被遮挡集合。

ts·高度区间裁剪(简化版)
// Simplified from src/lib/marker-occlusion.ts — the real loop also handles a
// camera level with the marker, an AABB reject and the "embedded in a solid" skip.
const camera = map.getFreeCameraOptions().position!        // read once
const p = mapboxgl.MercatorCoordinate.fromLngLat(marker.position, altitudeM)
const dx = p.x - camera.x, dy = p.y - camera.y, dz = p.z - camera.z

for (const prism of prisms) {
  if (prism.topM <= altitudeM + 0.5) continue              // standing on it, not behind it
  // Clip the camera→marker segment to the prism's altitude slab: two divisions.
  const ta = (prism.zBottom - camera.z) / dz
  const tb = (prism.zTop - camera.z) / dz
  let tLo = Math.min(ta, tb), tHi = Math.max(ta, tb)
  if (tHi <= 0 || tLo >= 1) continue                       // behind the eye, or past the marker
  tLo = Math.max(tLo, 0); tHi = Math.min(tHi, 1)
  const ax = camera.x + tLo * dx, ay = camera.y + tLo * dy
  const bx = camera.x + tHi * dx, by = camera.y + tHi * dy
  if (segmentHitsRing(prism.ring, ax, ay, bx, by)) {        // endpoint inside, or an edge crossed
    occluded.add(marker.id)
    break
  }
}

这一计算挂在 Mapbox 的 `move` 事件上 —— 遮挡取决于相机而不是数据,而 `move` 每帧最多触发一次。对每个标记和每个棱柱,只是一次包围盒检查加几次除法,不需要和 GPU 往返。

隐藏靠一个 data 属性加一条 CSS 规则,而且必须是 `!important`:每当相机移动,`mapboxgl.Marker` 都会在自己的雾/地形淡出过程中改写元素的内联 `opacity` 和 `pointer-events`,所以简单的 `el.style.opacity = "0"` 只生效一瞬间,随后就被悄悄覆盖。

css·index.css
/* Outside any Tailwind @layer: the attribute only ever exists at runtime. */
.shape-marker[data-occluded='true'] {
  opacity: 0 !important;
  pointer-events: none !important;
}
斜视角下三层的 Retail Mart Store —— 标记、货架徽标和 RFID 读写器脉冲都位于各自楼层的高度,穿插在墙体、货架和自动扶梯之间
Spatial Map 预览中的 Retail Mart Store 3D 场景:红、橙、绿色的部门区域和货架,中间是自动扶梯,紫色的 RFID 读写器脉冲和绿色库存徽标位于货架上方
Spatial Map 预览中的 Retail Mart Store 3D 场景:红、橙、绿色的部门区域和货架,中间是自动扶梯,紫色的 RFID 读写器脉冲和绿色库存徽标位于货架上方

踩坑换来的规则

  • 半透明几何不能遮挡。 把上层楼板调到 30% 透明度去看下一层,是真实的工作方式。只有可见且不透明度至少 50% 的图形才算遮挡体;被隐藏的图层和楼层也不再遮挡。
  • 站在表面上不等于在它后面。 顶面距离标记自身高度 0.5 m 以内的东西不会遮挡它;标记也永远不会被它所属的楼层平面图遮挡。
  • 嵌在实体里的标记忽略该实体。 否则相机环绕时它会闪烁。检查时对棱柱底部刻意使用 `>=`:位于 0 m、处在从 0 m 拉伸起的房间里的标签,是*在*房间里,而不是在房间后面。
  • 门洞保持实心。 墙体棱柱构建时不含门洞,因此门框里的标记会被隐藏而不是闪烁。宁可多遮,这是安全的方向。
  • 不要重新推导。 用图层绘制时所用的同一个生成器来构建轮廓。手写的轮廓,正是遮挡体悄悄偏离屏幕内容的根源。

我们刻意不让它们遮挡的东西

  • GLB 模型和图像平面 —— 我们只知道它们的包围盒,把包围盒当棱柱会让大型模型周围几米内的标记都被隐藏。
  • 电梯和自动扶梯的玻璃 —— 玻璃是透明的,永远不遮挡。
  • 不透明度低于 50%、被隐藏或位于隐藏楼层的任何东西 —— 优先保证透视工作流。

symbol 图层也需要同样处理

地图上并非所有东西都是 DOM 标记。文字标签、货格和门的标签,以及来自数据链接的实时数据点,都是原生 `symbol` 图层。你可能以为 Mapbox 的 `text-occlusion-opacity` 能搞定它们,但有两件事挡了路。

缺省的遮挡不透明度并不等于默认值。 样式规范把 `text-occlusion-opacity` 的默认值写成 `0`(「被遮挡时隐藏」),但紧接着一行说明:*缺省*值意味着只针对地形完全遮挡。如果不设置,图层会无视你的楼板和墙体。凡是需要被几何遮挡的 symbol 图层,都必须显式写出这个属性。

深度精度对贴地物体来说太粗。 位于 0 m 的标签正好处在 15 cm 厚的区域楼板或 10 cm 厚的平面图栅格下方一点点。俯视时,深度测试把这些标签判为「在 3D 内容后面」,以零不透明度绘制,倾斜后又让其中一些重新出现。因此文字标签图层改为设置 `text-occlusion-opacity: 1`(透视),交由解析求解器决定,由它切换 `feature-state.occluded` → `text-opacity: 0`。货架和门的标签也使用同一求解器。

实时数据点又带来一个 Mapbox 事实:`circle` 图层既不能抬高(没有 `circle-z-offset`),也不能被遮挡。所以每个点、圆盘和圆环都画成带 `●` 字形的 `symbol`。这些图层保留 `text-occlusion-opacity: 0`,并把解析结果并入 `text-opacity` —— 以要素*属性*的形式写入,因为 `setData` 会清空 feature‑state —— 这样在任何缩放级别下隐藏都是确定的。

ts·能被楼层遮挡的数据链接圆点
// Data-link dot: a symbol with a '●' glyph, because a circle layer
// can neither be lifted (no circle-z-offset) nor occluded in Mapbox GL 3.25.
paint: {
  'symbol-z-offset': ['coalesce', ['get', 'elevation'], 0],
  // Name it explicitly — an ABSENT occlusion opacity only occludes against terrain.
  'text-occlusion-opacity': 0,
  // The analytic verdict, stamped into feature properties (setData wipes feature-state).
  'text-opacity': ['case', ['boolean', ['get', 'occluded'], false], 0, 1],
}

在 Spatial Map 里哪里能看到

在查看器中,Quality 按钮会打开 Graphics quality 对话框,其中 Hide items under floors(隐藏楼层下方的元素)默认开启;编辑器侧边面板里同一个开关叫 Hide markers under floors。该设置保存在你的浏览器中。关闭后,DOM 标记的遮挡计算停止,恢复为标记始终画在最上层的平面效果 —— 想一次看清所有楼层的所有设备时很方便。参见查看器中的图形与样式。

查看器的 Graphics quality 对话框 —— 3D shadows、Ambient occlusion 和 Hide items under floors(已开启)
模糊 3D 地图上方的 Graphics quality 对话框:3D shadows 关闭,Ambient occlusion 关闭,Hide items under floors 开启,以及 Done 按钮
模糊 3D 地图上方的 Graphics quality 对话框:3D shadows 关闭,Ambient occlusion 关闭,Hide items under floors 开启,以及 Done 按钮

它在多层建筑里最有价值:带自动扶梯和电梯的多层商店(参见电梯与自动扶梯)、购物中心或多层办公楼 —— 俯视时应该只看到你正在查看的楼层,而不是所有楼层的图钉挤在一起。用图层与楼层中介绍的楼层选择器单独显示某一层。

常见陷阱

让数据落到该在的平台上。

Spatial Map 把电子表格、传感器数据流和 CAD 图纸融合成一张实时的 3D 场地视图 —— 无需安装,七天免费试用。

继续阅读