Vertiefungmapbox-gl3Docclusion

Marker‑Occlusion in Mapbox GL: HTML‑Marker hinter 3D‑Geschossen, Wänden und Regalen verbergen

HTML‑Marker in Mapbox GL liegen über dem WebGL‑Canvas, Extrusionen verdecken sie nicht. So verbergen wir sie mit einem analytischen Strahl‑Prisma‑Test.

9 Min. LesezeitVeröffentlicht Aktualisiert Von Spatial Map team
Retail Mart Store in 3D aus schräger Perspektive: Regale, Wände und Rolltreppen mit RFID‑Leser‑Pulsen, Bestands‑Badges und Markern auf der Höhe ihres Stockwerks

Das Problem in einem Satz

Mapbox GL rendert Geschosse, Wände, Regale und 3D‑Modelle in einem einzigen WebGL‑Canvas, in dem der Depth Buffer entscheidet, welches Pixel gewinnt. Ein `mapboxgl.Marker` ist ein HTML‑Element, das über diesem Canvas liegt. Neigen Sie die Kamera über einem dreigeschossigen Markt, und jeder Erdgeschoss‑Marker schlägt durch die Decken darüber; schauen Sie senkrecht nach unten, sehen Sie die Marker aller Etagen gleichzeitig — ohne sie unterscheiden zu können.

Der naheliegende Ausweg ist die Marker‑Option `occludedOpacity`. Sie hilft hier nicht: Sie blendet Marker hinter 3D‑Terrain aus, und nur hinter Terrain.

Was man üblicherweise versucht (und warum es nicht reicht)

  • `queryRenderedFeatures` bei jeder Kamerabewegung. Es sagt, welche Features ein Bildschirmpixel bedecken, nicht ob sie näher an der Kamera liegen als der Marker — und pro Marker und Frame ist es teuer.
  • HTML‑Marker durch einen Symbol‑Layer ersetzen. Symbole werden tiefengetestet, aber Sie verlieren DOM‑Inhalte (React‑Icons, Hover, Zieh‑ und Transformationsgriffe) — und wie unten gezeigt ist der Tiefentest selbst für Dinge auf einem Boden unzuverlässig.
  • Marker als extrudierte Kreise zeichnen. Sie landen im Depth Buffer, sehen aber aus wie zerdrückte Dosen und sind schwer anzuklicken.

Was wir eigentlich wollten: den DOM‑Marker unverändert lassen und ihn ausblenden, sobald feste Geometrie zwischen ihm und der Kamera liegt.

Die Lösung: ein Strahl, viele Prismen

Alles, was der Editor extrudiert, ist ein Prisma: ein Grundriss‑Ring, zwischen zwei Höhen gezogen. Aus „Liegt dieser Marker hinter etwas?“ wird also „Schneidet das Kamera→Marker‑Segment, beschnitten auf das Höhenband des Prismas, dessen Grundriss?“. Ein einziger Test deckt beide relevanten Fälle ab: Marker unter einer Decke (das Segment kreuzt den Grundriss eines höheren Geschosses) und Marker hinter einer Wand (das Segment kreuzt einen schmalen Grundriss im Höhenband der Kamera). Ein flaches Grundriss‑Raster ist nur das entartete Prisma, dessen Unterseite gleich der Oberseite ist.

Gerechnet wird im Mercator‑Raum, in dem eine konstante Höhe ein konstantes `z` ist; der Schnitt mit dem Höhenband kostet zwei Divisionen. Grundrisse kommen immer aus dem Generator der jeweiligen Form, sodass Drehung, Wandstärke und Bildzuschnitt exakt dem Gezeichneten entsprechen:

  • Flächen und Kreise — ein Prisma von Höhe bis Höhe + Höhenmaß (Kreise im Rastermodus: eines pro Zelle).
  • Wände — das Wandpolygon mit seiner Stärke; eine flache Polylinie hat kein Volumen und verdeckt nichts.
  • Treppen und Aufzüge — ein Prisma pro Stufe und Podest, mit Drehung; Glasflächen werden übersprungen.
  • Regale — ein Prisma pro Zelle (Reihe × Spalte × Ebene), damit ein vorderes Regal die Labels des Regals dahinter verdeckt; flache Zellen werden übersprungen.
  • Grundrisse — das zugeschnittene Bildviereck (nicht die rohe Bounding Box) auf der Höhe des Plans.
  1. 1
    Kamera einmal lesen

    `map.getFreeCameraOptions().position` liefert die Kamera in Mercator‑x/y/z.

  2. 2
    Segment bilden

    Von der Kamera zur Position des Markers auf seiner eigenen Höhe.

  3. 3
    Auf das Band schneiden

    Für jedes Prisma das Segment auf `[zBottom, zTop]` beschneiden. Schnittpunkte hinter dem Auge oder jenseits des Markers fallen weg — deshalb verdeckt Geometrie unter einem Marker ihn nie, wenn man nach unten schaut.

  4. 4
    Grundriss testen

    Zuerst Bounding‑Box‑Ausschluss, dann: Liegt ein beschnittener Endpunkt im Ring, oder kreuzt das Segment eine Kante?

  5. 5
    Beim ersten Treffer stoppen

    Ein Verdecker genügt; die Schleife bricht ab und die Marker‑ID kommt in die verdeckte Menge.

ts·Der Schnitt mit dem Höhenband (vereinfacht)
// 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
  }
}

Der Durchlauf hängt am `move`‑Event von Mapbox — Verdeckung hängt von der Kamera ab, nicht von den Daten, und `move` feuert höchstens einmal pro Frame. Pro Marker und Prisma ist es ein Bounding‑Box‑Check und ein paar Divisionen, ohne GPU‑Roundtrip.

Das Ausblenden ist ein Data‑Attribut plus eine CSS‑Regel. Sie muss `!important` sein: `mapboxgl.Marker` überschreibt bei jeder Kamerabewegung im Rahmen seiner eigenen Nebel‑/Terrain‑Blende die Inline‑Werte `opacity` und `pointer-events`. Ein einfaches `el.style.opacity = "0"` wirkt kurz und wird dann stillschweigend zurückgesetzt.

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, drei Etagen aus schräger Perspektive — Marker, Regal‑Badges und RFID‑Leser‑Pulse auf der Höhe ihres Geschosses zwischen Wänden, Regalen und Rolltreppen
Spatial‑Map‑Vorschau von Retail Mart Store in 3D: rote, orange und grüne Abteilungsflächen mit Regalen, Rolltreppen in der Mitte, violette RFID‑Leser‑Pulse und grüne Bestands‑Badges über den Regalen
Spatial‑Map‑Vorschau von Retail Mart Store in 3D: rote, orange und grüne Abteilungsflächen mit Regalen, Rolltreppen in der Mitte, violette RFID‑Leser‑Pulse und grüne Bestands‑Badges über den Regalen

Regeln, die wir auf die harte Tour gelernt haben

  • Durchscheinende Geometrie darf nicht verdecken. Ein oberes Geschoss auf 30 % zu stellen, um darunter zu schauen, ist ein echter Arbeitsablauf. Eine Form zählt nur als Verdecker, wenn sie sichtbar und mindestens 50 % deckend ist; ausgeblendete Layer und Etagen verdecken ebenfalls nicht mehr.
  • Auf einer Fläche stehen heißt nicht dahinter liegen. Nichts, dessen Oberkante weniger als 0,5 m über der Höhe des Markers liegt, verdeckt ihn, und ein Marker wird nie vom Grundriss verdeckt, zu dem er gehört.
  • Ein Marker in einem Körper ignoriert diesen Körper. Sonst flackert er beim Umkreisen. Der Test nutzt bewusst `>=` auf der Unterkante des Prismas: Ein Label auf 0 m in einem ab 0 m extrudierten Raum ist *im* Raum, nicht dahinter.
  • Türöffnungen bleiben massiv. Wandprismen werden ohne Türöffnungen gebaut, ein Marker im Türrahmen wird also ausgeblendet statt zu flackern. Zu viel Verdeckung ist die sichere Richtung.
  • Nichts nachbauen. Grundrisse mit demselben Generator erzeugen, mit dem der Layer zeichnet. Ein handgebauter Grundriss ist genau der Weg, auf dem ein Verdecker still vom Bildschirm abweicht.

Was wir bewusst nicht verdecken lassen

  • GLB‑Modelle und Bildebenen — wir kennen nur eine Bounding Box; sie als Prisma zu behandeln würde Marker meterweit um ein großes Objekt verstecken.
  • Aufzugs‑ und Rolltreppenglas — Glas ist durchsichtig und verdeckt nie etwas.
  • Alles unter 50 % Deckkraft, ausgeblendet oder auf einer ausgeblendeten Etage — der Durchblick‑Workflow hat Vorrang.

Symbol‑Layer brauchen dieselbe Behandlung

Nicht alles auf der Karte ist ein DOM‑Marker. Textlabels, Regalzellen‑ und Türlabels sowie Live‑Datenpunkte aus Data Links sind native `symbol`‑Layer. Man würde erwarten, dass `text-occlusion-opacity` von Mapbox das erledigt. Zwei Dinge standen im Weg.

Eine fehlende Occlusion‑Opacity ist nicht der Standardwert. Die Style‑Spezifikation nennt `0` („bei Verdeckung ausblenden“) als Default für `text-occlusion-opacity`, aber die nächste Zeile sagt: Ein *fehlender* Wert bedeutet volle Verdeckung nur gegen Terrain. Bleibt die Eigenschaft ungesetzt, ignoriert der Layer Ihre Decken und Wände. Jeder Symbol‑Layer, der sich hinter Geometrie verstecken soll, muss die Eigenschaft ausdrücklich setzen.

Die Tiefenpräzision ist für Dinge auf dem Boden zu grob. Ein Label auf 0 m liegt knapp unter einer 15‑cm‑Flächenplatte oder einem 10‑cm‑Grundriss‑Raster. In der Draufsicht hielt der Tiefentest diese Labels für „hinter 3D‑Inhalt“ und zeichnete sie mit Deckkraft null — beim Neigen tauchten einige wieder auf. Der Textlabel‑Layer setzt deshalb `text-occlusion-opacity: 1` (Röntgenblick) und überlässt die Entscheidung dem analytischen Solver, der `feature-state.occluded` → `text-opacity: 0` schaltet. Regal‑ und Türlabels nutzen denselben Solver.

Live‑Datenpunkte bringen eine weitere Mapbox‑Tatsache mit: Ein `circle`‑Layer lässt sich weder anheben (kein `circle-z-offset`) noch verdecken. Jeder Punkt, jede Scheibe und jeder Ring wird deshalb als `symbol` mit einem `●`‑Glyph gezeichnet. Diese Layer behalten `text-occlusion-opacity: 0` und verrechnen das analytische Ergebnis in `text-opacity` — als Feature‑*Property*, weil `setData` den Feature‑State löscht —, sodass das Ausblenden bei jedem Zoom deterministisch ist.

ts·Ein Data‑Link‑Punkt, der sich unter einer Etage verstecken kann
// 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],
}

Wo Sie es in Spatial Map sehen

Im Viewer öffnet die Schaltfläche Quality den Dialog Graphics quality, in dem Hide items under floors (Elemente unter Geschossen ausblenden) standardmäßig aktiv ist; im Seitenpanel des Editors heißt derselbe Schalter Hide markers under floors. Die Einstellung wird in Ihrem Browser gespeichert. Ausgeschaltet entfällt der Durchlauf für die DOM‑Marker, und das flache Verhalten kehrt zurück, bei dem Marker über allem liegen — praktisch, wenn Sie alle Assets aller Etagen gleichzeitig sehen wollen. Siehe Grafik und Stil im Viewer.

Der Dialog Graphics quality im Viewer — 3D shadows, Ambient occlusion und Hide items under floors (aktiv)
Dialog Graphics quality über einer unscharfen 3D‑Karte: 3D shadows aus, Ambient occlusion aus, Hide items under floors an, Schaltfläche Done
Dialog Graphics quality über einer unscharfen 3D‑Karte: 3D shadows aus, Ambient occlusion aus, Hide items under floors an, Schaltfläche Done

Am wichtigsten ist das bei gestapelten Gebäuden: einem mehrgeschossigen Markt mit Rolltreppen und Aufzügen (siehe Aufzüge und Rolltreppen), einem Einkaufszentrum oder einem mehrgeschossigen Bürogebäude, wo die Draufsicht die betrachtete Etage zeigen soll und nicht die Pins aller Etagen auf einmal. Mit der Etagenauswahl aus Layer und Etagen isolieren Sie eine Ebene.

Stolperfallen

Testen Sie die Plattform, auf die Ihre Daten gehören.

Spatial Map verwandelt Tabellen, Sensor‑Streams und CAD‑Zeichnungen in eine lebende 3D‑Ansicht Ihrer Standorte — nichts zu installieren, sieben Tage kostenfrei.

Weiterlesen