Análisis en profundidadmapbox-gl3Docclusion

Oclusión de marcadores en Mapbox GL: ocultar marcadores HTML tras plantas, muros y estanterías 3D

Los marcadores HTML de Mapbox GL se pintan sobre el canvas WebGL y las extrusiones no los tapan. Así los ocultamos con un rayo analítico contra prismas.

9 min de lecturaPublicado Actualizado Por Spatial Map team
Retail Mart Store en 3D con cámara inclinada: estanterías, muros y escaleras mecánicas con pulsos de lectores RFID, insignias de stock y marcadores a la altura de su planta

El problema en una frase

Mapbox GL dibuja plantas, muros, estanterías y modelos 3D dentro de un único canvas WebGL, donde el depth buffer decide qué píxel gana. Un `mapboxgl.Marker` es un elemento HTML superpuesto a ese canvas. Inclina la cámara sobre una tienda de tres plantas y cada marcador de la planta baja atraviesa las losas de arriba; mira en vertical y ves los marcadores de todas las plantas a la vez, sin forma de distinguirlos.

La salida tentadora es el propio `occludedOpacity` del Marker. Aquí no sirve: atenúa marcadores detrás del terreno 3D, y solo del terreno.

Lo que se suele probar (y por qué se queda corto)

  • `queryRenderedFeatures` en cada movimiento de cámara. Dice qué features cubren un píxel de pantalla, no si están más cerca de la cámara que el marcador; y llamarlo por marcador y por frame sale caro.
  • Cambiar los marcadores HTML por una capa symbol. Los symbols pasan el test de profundidad, pero pierdes el contenido DOM (iconos React, estados hover, tiradores de arrastre y transformación) y, como verás abajo, el propio test de profundidad no es fiable para lo que está sobre un suelo.
  • Dibujar los marcadores como círculos extruidos. Entran en el depth buffer, pero parecen latas aplastadas y cuesta hacer clic en ellos.

Lo que realmente queríamos: dejar el marcador DOM tal cual y ocultarlo cuando haya geometría sólida entre él y la cámara.

La solución: un rayo, muchos prismas

Todo lo que extruye el editor es un prisma: un anillo de huella barrido entre dos altitudes. Así, «¿está este marcador detrás de algo?» pasa a ser «¿el segmento cámara→marcador, recortado a la franja de altitud del prisma, cruza su huella?». Un único test cubre los dos casos importantes: un marcador bajo una losa (el segmento cruza la huella de una planta superior) y un marcador tras un muro (el segmento cruza una huella fina que abarca la franja de altitud de la propia cámara). Un plano raster plano es simplemente el prisma degenerado cuya base coincide con su techo.

Todo el cálculo se hace en espacio Mercator, donde una altitud constante es una `z` constante, y el recorte a la franja se reduce a dos divisiones. Las huellas salen siempre del generador de cada tipo, así que rotación, grosor de muro y recortes de imagen coinciden con lo que se pinta:

  • Áreas y círculos: un prisma de elevación a elevación + altura (círculos en modo cuadrícula: uno por celda).
  • Muros: el polígono del muro con su grosor; una polilínea plana no tiene volumen y no oculta nada.
  • Escaleras y ascensores: un prisma por peldaño y rellano, con la rotación aplicada; los cristales se omiten.
  • Estanterías: un prisma por celda (fila × columna × nivel), de modo que una estantería delantera oculta las etiquetas de la de detrás; las celdas planas se omiten.
  • Planos: el cuadrilátero recortado de la imagen (no su bounding box bruto) a la elevación del plano.
  1. 1
    Leer la cámara una vez

    `map.getFreeCameraOptions().position` da la cámara en x/y/z Mercator.

  2. 2
    Construir el segmento

    Desde la cámara hasta la posición del marcador a su propia altitud.

  3. 3
    Recortar a la franja

    Para cada prisma, recorta el segmento a `[zBottom, zTop]`. Los cruces detrás del ojo o más allá del marcador se descartan: por eso la geometría bajo un marcador nunca lo oculta cuando miras hacia abajo.

  4. 4
    Probar la huella

    Primero descarte por bounding box; después: ¿algún extremo recortado está dentro del anillo, o el segmento cruza una arista?

  5. 5
    Parar en el primer impacto

    Con un oclusor basta: el bucle se corta y el id del marcador entra en el conjunto ocluido.

ts·El recorte a la franja (simplificado)
// 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
  }
}

El cálculo corre en el evento `move` de Mapbox: la oclusión depende de la cámara, no de los datos, y `move` se dispara como mucho una vez por frame. Por marcador y prisma es una comprobación de bounding box y un par de divisiones, sin ida y vuelta a la GPU.

Ocultar es un atributo data y una regla CSS. Tiene que ser `!important`: `mapboxgl.Marker` reescribe el `opacity` y el `pointer-events` en línea del elemento durante su propio fundido de niebla/terreno cada vez que se mueve la cámara, así que un simple `el.style.opacity = "0"` funciona un instante y luego se revierte sin avisar.

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, tres plantas con cámara inclinada: marcadores, insignias de estantería y pulsos de lectores RFID a la altitud de su planta entre muros, estanterías y escaleras mecánicas
Vista previa de Spatial Map de Retail Mart Store en 3D: áreas de departamento rojas, naranjas y verdes con estanterías, escaleras mecánicas en el centro, pulsos morados de lectores RFID e insignias verdes de stock sobre las estanterías
Vista previa de Spatial Map de Retail Mart Store en 3D: áreas de departamento rojas, naranjas y verdes con estanterías, escaleras mecánicas en el centro, pulsos morados de lectores RFID e insignias verdes de stock sobre las estanterías

Reglas que aprendimos por las malas

  • La geometría translúcida no debe ocluir. Bajar una planta superior al 30 % para mirar la de abajo es un uso real. Una forma solo cuenta como oclusor si es visible y tiene al menos un 50 % de opacidad; las capas y plantas ocultas tampoco ocluyen.
  • Estar sobre una superficie no es estar detrás. Nada cuyo techo quede a menos de 0,5 m de la altitud del marcador lo oculta, y un marcador nunca queda oculto por el plano al que pertenece.
  • Un marcador dentro de un sólido ignora ese sólido. Si no, parpadea al orbitar la cámara. La comprobación usa `>=` sobre la base del prisma a propósito: una etiqueta a 0 m dentro de una sala extruida desde 0 m está *en* la sala, no detrás.
  • Las puertas siguen siendo sólidas. Los prismas de muro se construyen sin sus huecos de puerta, así que un marcador enmarcado en una puerta se oculta en lugar de parpadear. Ocluir de más es la dirección segura.
  • No re‑derivar nada. Construye las huellas con el mismo generador con el que pinta la capa. Una huella hecha a mano es la forma en que un oclusor se desvía en silencio de lo que hay en pantalla.

Lo que deliberadamente no ocluimos

  • Modelos GLB y planos de imagen: solo conocemos un bounding box, y tratarlo como prisma ocultaría marcadores a metros de un activo grande.
  • Cristal de ascensores y escaleras mecánicas: es transparente, así que nunca oculta nada.
  • Todo lo que esté por debajo del 50 % de opacidad, oculto o en una planta oculta: gana el flujo de trabajo translúcido.

Las capas symbol necesitan el mismo trato

No todo en el mapa es un marcador DOM. Las etiquetas de texto, las de celdas de estantería y puertas, y los puntos de datos en vivo de los data links son capas `symbol` nativas. Cabría esperar que `text-occlusion-opacity` de Mapbox se encargara. Dos cosas lo impidieron.

Una opacidad de oclusión ausente no es el valor por defecto. La especificación de estilos indica `0` («ocultar si está ocluido») como valor por defecto de `text-occlusion-opacity`, pero la línea siguiente aclara que un valor *ausente* significa oclusión completa solo contra el terreno. Si no la defines, la capa ignora tus losas y muros. Toda capa symbol que deba esconderse tras la geometría tiene que nombrar la propiedad explícitamente.

La precisión de profundidad es demasiado gruesa para lo que está sobre un suelo. Una etiqueta a 0 m queda justo por debajo de una losa de área de 15 cm o de un raster de planta de 10 cm. En vista cenital el test de profundidad consideraba esas etiquetas «detrás de contenido 3D» y las pintaba con opacidad cero, y luego dejaba reaparecer algunas al inclinar. Por eso la capa de etiquetas fija `text-occlusion-opacity: 1` (rayos X) y deja decidir al solucionador analítico, que cambia `feature-state.occluded` → `text-opacity: 0`. Las etiquetas de estanterías y puertas usan el mismo solucionador.

Los puntos de datos en vivo añaden otro hecho de Mapbox: una capa `circle` no se puede elevar (no existe `circle-z-offset`) ni ocluir. Por eso cada punto, disco y anillo se dibuja como `symbol` con un glifo `●`. Esas capas mantienen `text-occlusion-opacity: 0` e integran el veredicto analítico en `text-opacity` —como *propiedad* de la feature, porque `setData` borra el feature‑state—, de modo que la ocultación es determinista a cualquier zoom.

ts·Un punto de data link que puede esconderse tras una planta
// 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],
}

Dónde lo ves en Spatial Map

En el visor, el botón Quality abre Graphics quality, donde Hide items under floors (ocultar elementos bajo las plantas) está activado por defecto; el panel lateral del editor llama al mismo interruptor Hide markers under floors. El ajuste se guarda en tu navegador. Al desactivarlo se apaga el cálculo para los marcadores DOM y vuelve el comportamiento plano en el que los marcadores se pintan encima de todo, útil cuando quieres ver todos los activos de todas las plantas a la vez. Consulta Gráficos y estilo en el visor.

Diálogo Graphics quality del visor: 3D shadows, Ambient occlusion y Hide items under floors (activado)
Diálogo Graphics quality sobre un mapa 3D difuminado: 3D shadows desactivado, Ambient occlusion desactivado, Hide items under floors activado y botón Done
Diálogo Graphics quality sobre un mapa 3D difuminado: 3D shadows desactivado, Ambient occlusion desactivado, Hide items under floors activado y botón Done

Donde más importa es en edificios apilados: una tienda de varias plantas con escaleras mecánicas y ascensores (ver ascensores y escaleras mecánicas), un centro comercial o un edificio de oficinas de varias plantas, donde la vista cenital debe mostrar la planta que estás mirando y no los pines de todas a la vez. Usa el selector de plantas descrito en capas y plantas para aislar un nivel.

Errores frecuentes

Prueba la plataforma que tus datos merecen.

Spatial Map convierte hojas de cálculo, sensores y planos CAD en una vista 3D en vivo de tus instalaciones — sin instalar nada, prueba gratuita de siete días.

Sigue leyendo