Skip to content

fix(core, layers): hide icons, text and billboard points behind the globe - #10750

Open
chrisgervang wants to merge 10 commits into
masterfrom
claude/globe-billboard-occlusion-GveWO
Open

chrisgervang wants to merge 10 commits into
masterfrom
claude/globe-billboard-occlusion-GveWO

Conversation

@chrisgervang

@chrisgervang chrisgervang commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

For #9592, #9777 and #9554. Alternative to #9975.

Background

GlobeView enables cullMode: 'back', but back-face culling only correlates with the far side for geometry that follows the sphere. Camera-facing quads wind the same way on both hemispheres, so culling removes them either everywhere or nowhere. IconLayer and TextLayer quads wind clockwise after the y flip in their shaders and are culled everywhere, so nothing renders on a standalone GlobeView; billboard ScatterplotLayer is never culled and shows through the globe. #9975 tested dot(normal, viewDir) at the anchor, which is only correct at sea level and cut off raised geometry near the horizon, and it left visible sprites clipped by the globe surface in interleaved mode.

Before (master) After
Icons, 20 near side (blue) and 28 far side (red)
Same, before with the cullMode: 'none' workaround
Icons over an opaque SolidPolygonLayer earth
Along the horizon, sea level (blue) vs 600 km up (orange)
Billboard ScatterplotLayer
TextLayer with backgrounds (blank before, like icons)
TextLayer with a fixed-height getContentBox (blank before)
Non-billboard ScatterplotLayer and IconLayer on the surface

Standalone GlobeView at zoom 0.9 over lng 0, lat 10, default parameters. In the surface row, note the bottom row of icons on the limb: cut by the globe before, whole after. In the altitude row, the raised icons reach further down than the sea-level ones because a raised anchor is visible past the horizon of the ground below it, and they draw over the sea-level icons where the two overlap.

Change List

  • IconLayer and TextBackgroundLayer: emit the quad's top row first so the strip is front-facing after the y flip; MultiIconLayer inherits the geometry. The background's clip-rect path assigns the same corners in the flipped order so a content box keeps the strip front-facing
  • project shader module (GLSL and WGSL): add project_globe_is_occluded(commonPosition), a ray-sphere test between the camera and the anchor that is exact at any altitude, and project_globe_billboard_clipspace(clipPosition, commonPosition), which shifts a billboard toward the camera by the amount the globe surface rises within a 64 px footprint around its anchor. The shift depends on the anchor direction only, so sprites anchored at the same place keep their depth order whatever their altitude and a label's glyphs stay at the depth of their background. It is zero where the surface faces the camera and grows with the grazing angle, about 1.8 km at zoom 12 with pitch 60°, so 3D geometry in front of an anchor by less than that no longer hides the billboard; project.md documents the trade-off. Both are identity in flat projections
  • IconLayer, MultiIconLayer, TextBackgroundLayer, ScatterplotLayer (GLSL and WGSL): in billboard mode, hide objects whose anchor is behind the globe and take the globe-safe depth before DECKGL_FILTER_GL_POSITION, so collision-priority depth still wins in the collision pass. Non-billboard marks lie on the surface and are left to culling
  • Render tests: new globe-occlusion.spec.ts with the seven cases above, plus globe-occlusion-billboard-depth, which records the depth shift against an extruded wall at zoom 12 with pitch 60°, on WebGL and WebGPU; existing icon, text and scatterplot goldens unchanged
  • Unit tests: maplibre/globe-icons-text.spec.ts checks that IconLayer and TextLayer labels, backgrounds and picking survive on MapLibre 6 under mercator and globe, overlaid and interleaved (adapted from fix(layers): keep IconLayer and TextLayer visible on globe projections #10822)
  • Documentation: project.md for the two helpers, docs/whats-new.md; globe-view.md Occlusion remarks note that back-face culling is the default

Ran the new spec and the icon, text and scatterplot render specs headless on WebGL, tsc for core and layers, and lint. WebGPU runs on CI only.

TODO

  • Verify far-side occlusion against a MapLibre globe basemap in interleaved mode; the new MapLibre spec covers near-side visibility only

Note

Medium Risk
Changes core projection shaders and multiple layer vertex paths, including depth-buffer behavior near the horizon; visual regressions are possible in GlobeView and interleaved basemap setups despite new tests.

Overview
GlobeView billboard rendering for IconLayer, TextLayer, and billboard ScatterplotLayer is fixed by adding globe-specific helpers to the project shader module (GLSL and WGSL): project_globe_is_occluded hides anchors behind the sphere via a camera–position ray test (including altitude), and project_globe_billboard_clipspace nudges depth toward the camera so limb sprites are not clipped by the curved globe surface.

Billboard vertex shaders call those helpers and discard far-side anchors; quad winding is reordered in icon and text-background geometry so strips stay front-facing under GlobeView's default back-face culling. Docs cover the new APIs, default cullMode, and what's new; render and MapLibre tests exercise occlusion, horizon clipping, depth-shift trade-offs, and mercator/globe parity.

Reviewed by Cursor Bugbot for commit 1f82ced. Bugbot is set up for automated code reviews on this repo. Configure here.

@coveralls

coveralls commented Sep 25, 2026 •

Copy link
Copy Markdown

Coverage Status

coverage: 83.75% (+0.05%) from 83.703% — claude/globe-billboard-occlusion-GveWO into master

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3973c50. Configure here.

Comment thread modules/layers/src/scatterplot-layer/scatterplot-layer-vertex.glsl.ts Outdated
@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High impact] The PR appears safe to merge; no new actionable finding or outstanding previous finding remains.

Summary

This PR makes icons, text, and billboard points render on GlobeView while hiding anchors behind the globe. It also corrects quad winding, documents the fixed billboard depth-shift trade-off, and adds WebGL, WebGPU, and MapLibre coverage.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Billboard anchor] --> B{Behind globe?}
  B -->|Yes| C[Clip billboard]
  B -->|No| D[Shift depth for globe curvature]
  D --> E[Apply pixel offset and render]
Loading

Reviews (11) · Last reviewed commit: "Merge branch 'master' into claude/globe-..." · Reviewed by Greptile

Comment thread modules/core/src/shaderlib/project/project.glsl.ts Outdated
Comment thread modules/layers/src/scatterplot-layer/scatterplot-layer-vertex.glsl.ts Outdated
Comment thread test/render/test-cases/globe-occlusion.spec.ts Outdated
Comment thread modules/core/src/shaderlib/project/project.glsl.ts
@charlieforward9 charlieforward9 linked an issue Sep 28, 2026 that may be closed by this pull request
3 of 7 tasks
Comment thread docs/api-reference/core/globe-view.md Outdated
In the MapView, it is often sufficient to provide a solid background color where there is no geometry. In the GlobeView, the user can "see through" to the other side of the earth. There are two ways to fix this:
In the MapView, it is often sufficient to provide a solid background color where there is no geometry. In the GlobeView, the user can "see through" to the other side of the earth.

Camera-facing geometry is handled by the layers themselves: `IconLayer`, `TextLayer` and billboard `ScatterplotLayer` hide objects whose anchor is behind the globe, and keep the curve of the globe from clipping an object whose anchor is visible. Positions with altitude stay visible past the horizon of the ground below them. Objects that are not billboards lie on the surface and are culled with it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This explains itself in whats-new and feels redundant.

If you prefer to keep it, it feels better to put beneath the suggested code that still requires occlusive workarounds.

My thought process is copyable examples/workarounds precede written explanations of behavior.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Claude writes too much documentation so I removed it

}

// Screen-space radius, in pixels, that a billboard is assumed to cover around its anchor
const GLOBE_BILLBOARD_FOOTPRINT_PIXELS: f32 = 64.0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for more clarity - 64 was chosen as an assumed extent for most use cases? Whats the cost of making it larger?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, 64 was picked to match the default icon size, which is a common size to use.

The larger the value, the more the sprite is lifted away from the surface - I had claude generate a visual comparison for us in the other thread

// DECKGL_FILTER_SIZE(offset, geometry);
let clipOffset = project_pixel_size_to_clipspace(offset.xy);
pos = vec4<f32>(pos.x + clipOffset.x, pos.y + clipOffset.y, pos.z, pos.w);
// Hide icons whose anchor is behind the globe; culling handles non-billboard icons

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe a feature to stack on this is incremental occlusion like you mentioned in my original issue.

Would that be handled in here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fading the icons as they approach the edge would be nice. I'd look into it in a follow up once the globe changes that are up are all landed and proven stable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot you alive? Draft a PR for this to remind me to get back to this sometime!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed follow-up PR:

Title: feat(layers): fade billboard sprites near the globe horizon

Body: Add optional horizon fade for billboard IconLayer, TextLayer, and ScatterplotLayer on GlobeView, so sprites transition out smoothly as they approach globe occlusion instead of disappearing at a hard cutoff. Build it on top of the new globe occlusion helpers, keep the current default behavior unchanged, and cover it with render tests plus docs.

@greptile-apps

This comment has been minimized.

Copy link
Copy Markdown
Collaborator Author

The billboard paragraph in globe-view.md was removed in befac28 at reviewer request as redundant: the behavior is described in docs/whats-new.md and on the helpers in project.md, and the Occlusion remarks keep their pre-existing scope. No further change planned there.


Generated by Claude Code

@chrisgervang
chrisgervang force-pushed the claude/globe-billboard-occlusion-GveWO branch from befac28 to 323a809 Compare October 3, 2026 22:12
claude added 4 commits October 3, 2026 22:31
The vertex shaders flip y, so the strips wound clockwise and GlobeView's
default cullMode: 'back' culled every icon and text background. Emit the
top row first so the quads are front-facing in any view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyzWaF5TmQPmGjh3Zr3XL9
Add project_globe_is_occluded, a ray-sphere test between the camera and
the vertex that is exact at any altitude, and
project_globe_billboard_clipspace, which gives a billboard the depth of
the nearest globe surface point so the curve of the globe never clips a
sprite whose anchor is visible. IconLayer, TextLayer (glyphs and
backgrounds) and ScatterplotLayer use them: objects behind the globe are
hidden, objects at the horizon are drawn whole over an opaque surface, and
raised objects stay visible past the ground horizon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyzWaF5TmQPmGjh3Zr3XL9
…nchors only

project_globe_billboard_clipspace moved every billboard to the distance of the
globe point below the camera, which put all sprites at roughly the same depth
and in front of any 3D geometry. It now shifts a sprite toward the camera only
by the amount the surface rises within a 64 px footprint around its anchor, so
raised sprites and other geometry keep their order and the shift vanishes where
the surface is flat on screen.

The occlusion test now runs only in billboard mode, on the anchor. Non-billboard
marks lie on the surface and are culled with it; testing their offset vertices
tore marks straddling the horizon across the viewport.

TextBackgroundLayer's clip-rect path assigns the same corners in the flipped
order of the default path so the reversed strip stays front-facing with a
content box.

Render cases: globe-occlusion-text-content-box and globe-occlusion-marks-surface
added, WebGPU enabled for the suite, altitude and text goldens regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyzWaF5TmQPmGjh3Zr3XL9
… remarks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyzWaF5TmQPmGjh3Zr3XL9
Checks that labels, text backgrounds and picking survive back-face culling
under MapLibre's mercator and globe projections, overlaid and interleaved,
on MapLibre 5 and 6. Fails without the front-facing quad order from this PR.

Adapted from #10822 by @wldalx.
Halves its runtime. MapboxOverlay on MapLibre 5 shares the same layer code.
Comment thread test/modules/maplibre/globe-icons-text.spec.ts
…spec

The spec created a map and an overlay per case, 16 WebGL contexts in a
few seconds. Chrome drops the oldest live context once 16 exist, and in
the headless suite the oldest is the shared test device, so every GPU
test after this file failed with a null link error. One map and one
overlay per interleaved mode now serve all cases, with a fresh label id
per case so the reused Deck initializes each label instead of updating
it, and the render wait keys on the label's isLoaded and its pixel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyzWaF5TmQPmGjh3Zr3XL9
Both overlays now run on a single map, and the non-interleaved overlay
releases its WebGL context when it is removed. The headless suite runs
within one or two contexts of the browser's limit of 16, past which the
browser evicts the oldest context, the shared test device.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyzWaF5TmQPmGjh3Zr3XL9
@chrisgervang chrisgervang added this to the v9.4 patch releases milestone Oct 10, 2026

@ibgreen ibgreen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving the general globe rendering improvements, with the depth-ordering concern recorded inline for follow-up. Correcting quad winding and testing camera-to-anchor intersection with the sphere are basemap-independent fixes and are useful for other globe integrations, including a future Tangram integration.

Review assisted by Codex. Local headless tests could not start because of sandbox server restrictions and incompatible installed dependency exports; the current GitHub test workflow passed. The numerical depth reproduction in the inline comment is not a rendered regression test.

if (shift <= 0.0) {
return clipPosition;
}
vec4 shiftedClip = project_common_position_to_clipspace(vec4(commonPosition - direction * shift, 1.0));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The depth lift does not generally preserve ordering against other 3D geometry, despite the guarantee in the helper comment and project.md. Only billboards receive this adjustment; terrain, meshes and extrusions retain their original depths. Numerically evaluating this shader formula for a standalone GlobeView centered at [0, 0], zoom 12, pitch 60°, height 512 and default altitude 1.5 gives a shift of about 0.07249 common units, equivalent to approximately 1.8 km toward the camera, at the center anchor. Consequently, foreground geometry closer than the original anchor can lose its occlusion of the billboard, including at high zoom. This is a numerical reproduction rather than a rendered test. Please add a rendered regression with a billboard behind foreground 3D geometry and either revise the depth strategy or explicitly document and expose the depth-lift policy as a controllable trade-off. Keeping text glyphs and backgrounds at the same depth is sensible, but does not preserve ordering against the rest of the scene. Apply any corresponding changes to the WGSL helper too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed your number. Evaluating the helper's formula against GlobeViewport I get 1767 m at the center anchor for a standalone GlobeView at lng/lat 0, zoom 12, pitch 60 (0.0710 common units), and 0 m at pitch 0, 614 m at pitch 30, 110 m at zoom 16 with pitch 60.

The guarantee in the comment and project.md was wrong and is corrected in GLSL, WGSL and the docs. What holds is narrower: the shift depends on the anchor direction alone, so sprites anchored at the same place keep their order whatever their altitude and a label's glyphs stay at the depth of their background. Against the rest of the scene it is zero only where the surface faces the camera; where the surface is foreshortened it grows with the grazing angle, and 3D geometry in front of the anchor by less than the shift no longer hides the billboard. The docs now give the magnitude, the oversized-sprite limit, and that the 64 px radius is a shader constant.

New render case globe-occlusion-billboard-depth records the behavior: an opaque wall 4 km tall standing 1 km in front of three columns of icons 1 km, 3 km and 10 km behind it, at zoom 12 with pitch 60. The flanking columns show all three icons; behind the wall only the icon 1 km back draws through, while the ones 3 km and 10 km back stay hidden. It runs on WebGL and WebGPU.

I did look at revising the depth strategy rather than documenting it. Subtracting the tangent plane at the anchor's surface point leaves a shift that is less than or equal to zero everywhere, so the clipping this helper fixes is the grazing-angle effect and not curvature as such; there is no curvature-only term to keep. The remaining alternative is per-vertex depth, which would put TextLayer glyphs behind their own background, since the background and the glyphs are separate layers with different extents. So the radius stays a constant here, and I'll offer the configurable depth-lift policy as a follow-up.

In 424c5ea.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pictures of the new case, since the trade-off is easier to see than to read. Same scene both times: zoom 12, pitch 60°, icons 1 km / 3 km / 10 km behind an opaque wall, three columns (one hidden by the wall, two beside it). The control flattens the wall to 1 m so you can tell which icons are being depth-tested away rather than dropped.

Control: wall flattened globe-occlusion-billboard-depth golden
Control render: all nine icons visible with the wall flattened Golden render: behind the wall only the orange icon 1 km back draws through

All nine draw in the control. With the wall standing, the orange icon 1 km behind it draws through anyway, because the wall is nearer to the anchor than the ~1.8 km lift is long; the blue icons 3 km and 10 km behind it stay hidden, so the lift is bounded rather than unconditional. That is exactly the cost you measured, now pinned by a golden.

Generated by Claude Code

chrisgervang and others added 2 commits October 11, 2026 10:06
The helper comments and `project.md` claimed the shift kept depth order
against the rest of the scene. It only keeps order among sprites anchored
at the same place: the shift is the depth the globe surface spans across
the 64 px footprint, which grows with the grazing angle and reaches about
1.8 km at zoom 12 with pitch 60, so nearer 3D geometry stops hiding a
billboard. State that in GLSL, WGSL and `project.md`, and add
`globe-occlusion-billboard-depth`, a wall in front of icons 1 km, 3 km
and 10 km behind it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Icon Layer on Globe Projection

6 participants