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


一句话说清问题
Mapbox GL 在同一块 WebGL 画布里渲染楼层、墙体、货架和 3D 模型,由深度缓冲决定哪个像素胜出。而 `mapboxgl.Marker` 是叠在画布上方的 HTML 元素。把相机倾斜到一栋三层商店上方,一楼的每个标记都会穿透上面的楼板;垂直俯视时,所有楼层的标记同时出现,根本无从区分。
最容易想到的出路是 Marker 自带的 `occludedOpacity`。但它在这里帮不上忙:它只会让 3D 地形后面的标记变淡,也仅限地形。
常见做法(以及为什么不够)
- 每次相机移动都调用 `queryRenderedFeatures`。 它只告诉你哪些要素覆盖了某个屏幕像素,却不告诉你它们是否比标记更靠近相机;而且每个标记、每一帧都调用,代价很高。
- 把 HTML 标记换成 symbol 图层。 symbol 会参与深度测试,但你会失去 DOM 内容(React 图标、悬停状态、拖拽和变换手柄);而且如下文所述,深度测试本身对贴地物体并不可靠。
- 把标记画成拉伸圆柱。 它们进入了深度缓冲,但看起来像被压扁的易拉罐,也很难点中。
我们真正想要的是:DOM 标记保持原样,只要相机和它之间隔着实体几何,就把它隐藏。
解法:一条射线,多个棱柱
编辑器拉伸出的一切都是棱柱:一个底面轮廓在两个高度之间扫掠而成。于是「这个标记是否被挡住?」就变成「相机→标记线段在棱柱高度区间内的那一段,是否穿过它的底面轮廓?」。一个测试即可覆盖两种关键情形:标记在楼板下方(线段穿过上层楼板的轮廓),以及标记在墙后(线段穿过一个覆盖相机自身高度区间的细长轮廓)。平面的楼层平面图栅格只是底面与顶面重合的退化棱柱。
全部计算在墨卡托空间进行,恒定高度就是恒定的 `z`,因此按高度区间裁剪只需两次除法。轮廓始终来自各类图形自己的生成器,旋转、墙厚和图像裁剪都与实际绘制一致:
- 区域和圆形 —— 从底部高程到「高程 + 高度」的一个棱柱(网格模式的圆:每格一个)。
- 墙体 —— 带厚度的墙体多边形;没有厚度的折线没有体积,不遮挡任何东西。
- 楼梯和电梯 —— 每级台阶和每个平台各一个棱柱,并应用旋转;玻璃面板跳过。
- 货架 —— 每个货格一个棱柱(排 × 列 × 层),这样前排货架能挡住后排货架上的标签;扁平货格跳过。
- 楼层平面图 —— 图像裁剪后的四边形(而不是原始包围盒),位于平面图的高程处。
- 1只读一次相机
`map.getFreeCameraOptions().position` 给出相机的墨卡托 x/y/z。
- 2构造线段
从相机到标记在其自身高度上的位置。
- 3按高度区间裁剪
对每个棱柱,把线段裁剪到 `[zBottom, zTop]`。落在眼睛后方或标记之后的交点被丢弃 —— 所以俯视时,标记下方的几何永远不会挡住它。
- 4测试底面轮廓
先做包围盒快速排除,再判断:裁剪后的端点是否落在轮廓内,或者线段是否穿过某条边?
- 5首次命中即停止
一个遮挡体就够了;循环中断,标记 id 加入被遮挡集合。
// 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"` 只生效一瞬间,随后就被悄悄覆盖。
/* Outside any Tailwind @layer: the attribute only ever exists at runtime. */
.shape-marker[data-occluded='true'] {
opacity: 0 !important;
pointer-events: none !important;
}

踩坑换来的规则
- 半透明几何不能遮挡。 把上层楼板调到 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 —— 这样在任何缩放级别下隐藏都是确定的。
// 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 标记的遮挡计算停止,恢复为标记始终画在最上层的平面效果 —— 想一次看清所有楼层的所有设备时很方便。参见查看器中的图形与样式。


它在多层建筑里最有价值:带自动扶梯和电梯的多层商店(参见电梯与自动扶梯)、购物中心或多层办公楼 —— 俯视时应该只看到你正在查看的楼层,而不是所有楼层的图钉挤在一起。用图层与楼层中介绍的楼层选择器单独显示某一层。
常见陷阱
让数据落到该在的平台上。
Spatial Map 把电子表格、传感器数据流和 CAD 图纸融合成一张实时的 3D 场地视图 —— 无需安装,七天免费试用。
继续阅读
- 深度剖析室内导航软件:在你的平面图上生成逐步路线指引GPS 到了大门口就失灵了。本文介绍 Spatial Map 如何从你本来就在维护的平面图生成步行路线、在查看器里给访客逐步指引,并如实说明楼层、电梯和无障碍今天是怎么处理的。
- 深度剖析用 AI 把平面图变成数字孪生:即将推出什么,今天又该怎么做能把平面图变成房间和墙的 AI,是数字孪生项目里呼声最高的捷径。我们的 AI Map 正在开发中,目前还不能使用。本文介绍它将要做什么,以及今天就能得到同样结果的工作流程。
- 深度剖析AMR / AGV 导航与设施地图:Spatial Map 能为机器人团队提供什么(以及不能提供什么)机器人车队不需要重新画一张图。它需要的是你已经画好的墙、门、货架和通道 —— 以 GeoJSON 导出 —— 一个能在浏览器里完成的路线检查,以及一张显示机器人位置的地图。调度和控制仍归车队管理系统。