Line patterns blur on pitched maps: offer anisotropic sampling (`textureGrad`) for patterns
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 46/100
- Issue type
- Feature
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- javascript, typescript
- Domain
- computer-graphics, frontend
Research direction
Start with src/shaders/_prelude.fragment.glsl and src/shaders/line_pattern.fragment.glsl to trace how pattern coordinates reach textureLodCustom; compare the anisotropic sampling gate in src/render/draw_raster.ts and texture creation in src/render/image_atlas.ts. Decide how to gate textureGrad and set anisotropy consistently for the shared pattern path. Done means pitched patterns gain sharpness while flat maps and devices without the extension retain current behavior, with relevant rendering coverage.
Written by the indexing model from the issue text.
Description
Motivation
On a pitched map, line-pattern images get noticeably blurry, and the steeper the pitch, the blurrier they get. Looking straight down they're as sharp as the image allows.
We draw one-way arrows on ski runs as a line-pattern (a chevron repeated along the run), because a pattern lies on the ground and is hidden by 3D buildings, unlike symbol icons, which draw on top of them. On the pitched 3D views where that matters most, the chevrons blur.
The cause is deliberate. textureLodCustom in _prelude.fragment.glsl, which line_pattern.fragment.glsl uses, picks the mip level from the larger of the two screen-space derivatives and samples with textureLod. The comment there notes that textureLod doesn't support anisotropic filtering, and that textureGrad would, but is discouraged on ARM GPUs for speed. On a pitched view the larger derivative is the foreshortened one, so the pattern is sampled at the blurrier level. Raster layers, by contrast, sample with texture() and get anisotropic filtering beyond 20° pitch (draw_raster.ts).
Measurements
The repro below: one line-pattern line, black chevrons with a white edge, on a white background, at zoom 18 and bearing 60°, on a 2× screen, with the image at the screen's pixel ratio so that a flat map draws it unscaled. "Crisp" is the share of the pixels the pattern changes that it changes strongly (by more than 300 of 765 in summed RGB), so lower means blurrier.
| Pitch | As shipped | Mipmaps disabled on the pattern texture |
|---|---|---|
| 0° | 73.7% | 73.7% |
| 45° | 64.9% | 74.9% |
| 60° | 66.2% | 75.1% |
| 70° | 58.5% | 76.6% |
- Looking straight down: mipmaps make no difference. With pitch, the pattern blurs, and disabling mipmaps removes the blur.
- A line crossing the view: worse still. At 70° pitch and bearing −18°, it's 44.7% as shipped against 69.7% without mipmaps.
- Anisotropic filtering forced on: setting
TEXTURE_MAX_ANISOTROPY_EXTto 16 on the pattern texture (read back as 16) leaves it at 58.5% at 70°, as expected withtextureLod.
Mipmaps were disabled through internals (imageAtlasTexture.useMipmap), only to locate the cause; it isn't a fix we'd want, since it would risk the aliasing that mipmaps prevent at a distance.
Design Alternatives
- Sample patterns with
textureGradwhere anisotropic filtering is available, as rasters already get anisotropic filtering, perhaps only beyond some pitch, asdraw_raster.tsdoes. - An opt-in style property, such as
line-pattern-filter: "anisotropic", so the cost is paid only by layers that need it. - Pick the LOD from the smaller derivative, or bias it, when pitched. That should be cheaper than
textureGradand sharper on pitched lines, at the risk of some aliasing. - Do nothing. Then patterns stay blurry on pitched and 3D views, and the workarounds are internal patches or bolder artwork.
Design
Option 1, gated like the raster path, beyond 20° pitch and only where EXT_texture_filter_anisotropic is available, would leave flat maps and devices without the extension as they are today. If the cost is a concern, option 2 lets styles opt in.
Implementation
In textureLodCustom, when anisotropic filtering is enabled, sample with textureGrad(image, pos, dFdx(lod_coord), dFdy(lod_coord)), the derivatives of the continuous lod_coord rather than of the discontinuous pos, instead of folding them into a single LOD for textureLod; and set TEXTURE_MAX_ANISOTROPY_EXT on the pattern texture (created with useMipmap: hasPattern in image_atlas.ts). fill-pattern, fill-extrusion-pattern and fill outline patterns sample through the same function, so they would get it too.
Environment and repro
- mapbox-gl-js 3.32.0
- Chromium 152 on macOS, ANGLE Metal, Apple M1 Pro, device pixel ratio 2
<div id="map" style="width: 800px; height: 600px"></div>
<script src="https://api.mapbox.com/mapbox-gl-js/v3.32.0/mapbox-gl.js"></script>
<script>
mapboxgl.accessToken = '<your token>'
const map = new mapboxgl.Map({
container: 'map',
style: { version: 8, sources: { line: { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [[-0.0012, -0.0004], [0.0012, 0.0004]] } } } }, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#fff' } }] },
center: [0, 0], zoom: 18, bearing: 60, pitch: 70,
})
map.on('load', async () => {
// A strip 16 times as long as it is tall with one chevron in its middle, so the chevrons repeat along
// the line, at 2x, drawn 16 px wide: unscaled on a 2x screen when the map is flat.
const chevron = (stroke, width) => `<path d="M252 7 L261 16 L252 25" fill="none" stroke="${stroke}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"/>`
const image = new Image(512, 32)
image.src = 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="512" height="32" viewBox="0 0 512 32">${chevron('#fff', 9)}${chevron('#000', 4.5)}</svg>`)
await image.decode()
map.addImage('chevrons', image, { pixelRatio: 2 })
map.addLayer({ id: 'line', type: 'line', source: 'line', paint: { 'line-pattern': 'chevrons', 'line-width': 16 } })
})
// Compare with pitch: 0, where the chevrons are as sharp as the image.
</script>
- Dominant language
- TypeScript
- Stars
- 12.5k
- Forks
- 2.4k
- PR merge metrics
- No merged PRs in 30d
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from mapbox/mapbox-gl-js
-
TypeError: Cannot read properties of undefined (reading 'get') when a map is removed while a color-theme LUT is decodingPossibly taken @Piyush495 claimed this 18 days ago. Openauto-triaged bug :lady_beetle:
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
mapbox/mapbox-gl-js#13737 · 1 comment ·
-
auto-triaged bug :lady_beetle:
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
mapbox/mapbox-gl-js#13723 · 2 comments ·
-
auto-triaged bug :lady_beetle:
Difficulty 1/5 Under an hour Newbie friendliness 86/100
mapbox/mapbox-gl-js#13717 ·
-
>= v3.21 Symbol layer without feature-state expression crash the mapPossibly taken @liueric-dev claimed this 59 days ago. Openauto-triaged bug :lady_beetle:
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
mapbox/mapbox-gl-js#13714 · 1 reaction ·
-
auto-triaged feature :green_apple:
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
mapbox/mapbox-gl-js#13676 · 2 comments ·
All issues in mapbox/mapbox-gl-js
Similar issues
-
freshness freshness:observed-only
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
thoroc/context-radar#282 ·
-
bug P2 rust
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
area: web
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
gabe-santana/arkdraw#25 ·
-
bug require-triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
expressjs/expressjs.com#2549 · 1 reaction ·
Maintainers usually reply within 1 day
-
bug good first issue hacktoberfest
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
codercops/discord-bot#101 · 1 comment ·
Maintainers usually reply within 1 day