-
Notifications
You must be signed in to change notification settings - Fork 2.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Attenuate label size scaling with distance #4547
Conversation
326e42c
to
e3b2df2
Compare
d203ef7
to
86af564
Compare
@nickidlugash @anandthakker helped me draft up some math that could help us avoid uneven spacing on highly curved labels while still doing all the glyph position calculations in the shader: https://gist.github.com/ChrisLoer/fb8c22845b6c3216afff0f19dc53f6d6 The math depends on adding an(other) approximation that will have the biggest effect on labels that are close to horizontal. I attached some screenshots to a comment on the gist to show the expected difference -- doesn't look like a big deal to me, but could use your input. |
d67741d
to
08fdd72
Compare
@mourner @kkaefer I'm going to increasingly elaborate lengths to do complicated glyph positioning in the vertex shader. Tangram looks like it does glyph positioning in the CPU at render time and seems to get acceptable performance. Doing positioning that way also allows them to send smaller vertex arrays to the GPU, because they don't have to send copies of glyphs for each possible line segment they could be on. Do either of you have any advice on how to think about the performance trade-offs of the two architectures here? I don't have strong enough intuitions to guess at the differences very well, and since the architectural approaches are so different, it's not easy to just find out through experiment... |
@ChrisLoer Awesome to see the improvements and to do's on this PR!
It's hard to say without being able to interact with it and experiencing the rate of change, but the |
Yes, the same thought occurred to me! It's definitely possible considering all the different approximations that are involved, although right now if that's actually what's happening it's just dumb luck. I don't have a theoretical explanation -- but with enough time staring at this, it should be possible to construct one. 😅 |
BTW, if you want to play with the behavior of |
@ChrisLoer Another unrelated issue I noticed: the angle of the rendered glyphs for vertical labels seems off, the further away they are from vertical center: They look like they are on tilted planes. Is this related to how the incident angle does not take into account (This is an issue too in the current released implementation – it's just not as obvious with the current foreshortening behavior). |
I think that's our brain applying perspective to the labels based on what's around them, and interpreting the input as "tilted plane": If we zoom in on the lower left corner, look at Wilshire Blvd, then use photoshop to rotate the street back to vertical, we can see the glyphs are lined up parallel to the road and "flat". I think the effect is kind of cool because it makes the map feel a little more three-dimensional. Other mapping products™️ seem to do the same thing. |
I got an initial implementation of the fancy trigonometry for label expansion... kind of working. It looks like it's doing a better job of keeping spacing even on curved labels, but there are two big problems:
Overall, it's feeling like too complicated a solution, but I'll keep poking at it a bit longer to see if I can come up with anything compelling. Here's a shot showing the algorithm actually doing its thing. This is the kind of label that does poorly with the simple algorithm: the center of the label is at about 45 degrees, while the left side is near vertical and the right side is near horizontal. As a result, the simple algorithm (on the left) squishes the left side and stretches out the right side. The fancy algorithm appears to do a reasonable job of spacing across the length of the label. |
We have never tried this out, but as a data point: We moved label placement on rotation from pre-calculcated to on-demand (CPU) and haven't run into any performance issues. |
Are you talking about how we re-do collision tiles on rotation? Although we do it on demand with the CPU, we also do it asynchronously and coalesce multiple frames into one calculation. That works for collision detection which is just on/off, but for glyph placement to be smooth we would need to synchronously calculate positions on every frame. |
08fdd72
to
76e9b50
Compare
Per discussion with @nickidlugash, we think the navigation use case can benefit from the |
4fd8643
to
35127d3
Compare
Yeah, but we do it asynchronously because the placement is the slow part, not the uploading part. If we can calculate the placement fast enough, a synchronous upload might be possible as well, but, as all things GL, we'll have to try them out to know how they perform. |
Here's an example of how broken a curved label can get when it's in the far distance (with Maybe a hardwired |
I implemented a I imagine that if we were to make a navigation style using just the functionality from this PR, we would use the |
@ChrisLoer Is it possible to explain the distance whose multiple is taken in "max-camera-distance" via a diagram? (Whiteboard photo etc should be good enough. Thanks for fixing the issues with symbol/label placement in pitched state.). |
ef96da6
to
bed53c9
Compare
I moved to only re-doing collision detection every 300 ms while the map is panning. I think it helps with the feeling that labels are flickering all over the place, but overall label placement still doesn't feel super smooth or predictable. There's a subtle problem with the approach I'm using. On a flat map, we calculate collisions for a tile between a range of Now consider a city label and a a highway shield placed near each other. The city label's minimum placement scale is The result of all this is that as you pan a titled map tile into the distance, you could see:
In terms of avoiding collisions and getting good label density, this is fine. But it breaks the desideratum that once a label appears it should stick around as scale increases -- and when it happens across a whole bunch of small labels and highway shields, it contributes to that "flickery" feel. Note that we already have the same problem when we cross integer zoom levels, it's just much less noticeable. |
Some problems to tack onto the list:
|
While debugging that issue, I noticed another design issue that we might want to think about. Because the collision detection is based off the "least scaled" item in a tile, label density can change a lot between a tile that has an item with low scaling vs a tile without any items that have low scaling. Look at the tiles that have Highway 2 (Santa Monica Blvd) in them, vs. the more distant tiles that don't have any highway shields. Because my test style gives these highway shields low scaling (i.e. bigger size relative to the map when they're in the distance), the tiles that include the highway shields get pretty empty (following the conservative algorithm). However, the tiles above without any shields get denser label placement even though they're further away. This could just be something style designers need to be aware of, or we could change the code to share the same conservative collision scaling factor across all visible tiles (this would trade lower overall label density in exchange for more uniformity of label density). |
293cd08
to
f45f304
Compare
…hat won't render well. Hardwire to value of 1.5 for viewport-pitch-aligned line labels. Depending on the size of the viewport, this means something like "start hiding line labels at the top of the screen as you approach a pitch of 50 degrees".
… text scaling behavior.
…ne collision features if the map isn't pitched.
1575bf3
to
4309634
Compare
src/source/image_source.js
Outdated
gl.bindTexture(gl.TEXTURE_2D, this.tile.texture); | ||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image); | ||
for (const w in this.tiles) { | ||
const tile = this.tiles[w]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is creating multiple textures in the wrapped coordinates. Is it possible to create just one texture and reuse it in all the wrapped tiles ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I took a stab at sharing the texture between tiles -- it passes tests, but I could definitely use a review to see if the approach makes sense. All the image/texture binding stuff just looks like a magic incantation to me.
src/source/image_source.js
Outdated
} | ||
|
||
prepare() { | ||
if (!this.tile || !this.image) return; | ||
if (this.tiles.length === 0 || !this.image) return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this.tiles
is an object, not an array, so I think you need Object.keys(this.tiles).length === 0
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops, good catch.
src/source/query_features.js
Outdated
resultFeatures.push(tileFeatures[f]); | ||
for (const tileFeature of tileFeatures) { | ||
if (!tileFeature.id || !wrappedIDFeatures[tileFeature.id]) { | ||
wrappedIDFeatures[tileFeature.id] = true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I'm reading this right, it looks like in the case where features do not have an id
and there are two tiles w/ same wrapped id, the wrappedIDFeatures
object will look like { 'undefined': true }
after the first of those two tiles' results are collected. Then, if the results from the second tile have features that aren't duplicates, they'd be skipped.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the features don't have IDs, they'll get duplicated (because of the !tileFeature.id
check). If the feature has an ID, it should get included once. Say the new feature has ID "1" and wrappedIDFeatures
looks like { 'undefined': true }
. wrappedIDFeatures[1]
will still be undefined
. Am I missing something?
Do you have any opinion on whether it might make sense to just change the behavior here to "duplicate on wrapped tiles"? The current behavior is publicly documented (https://www.mapbox.com/mapbox-gl-js/api/#map#queryrenderedfeatures) so I'm thinking the answer is "no", but on the other hand users of the API can already see the same feature returned multiple times across tile boundaries.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jfirebaugh Pointed me at #2321: we definitely intend to avoid duplicates. I pushed changes to get rid of the !tileFeature.id
case by using the grid key from feature_index
as a unique identifier for de-duplication.
src/source/video_source.js
Outdated
@@ -107,7 +107,7 @@ class VideoSource extends ImageSource { | |||
// setCoordinates inherited from ImageSource | |||
|
|||
prepare() { | |||
if (!this.tile || this.video.readyState < 2) return; // not enough data for current position | |||
if (this.tiles.length === 0 || this.video.readyState < 2) return; // not enough data for current position |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See comment in ImageSource
Benchmarks look reasonable to me, although I'm not sure what the expected variability is:
|
@@ -107,7 +107,7 @@ class VideoSource extends ImageSource { | |||
// setCoordinates inherited from ImageSource | |||
|
|||
prepare() { | |||
if (!this.tile || this.video.readyState < 2) return; // not enough data for current position | |||
if (Object.keys(this.tiles).length === 0 || this.video.readyState < 2) return; // not enough data for current position |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The CanvasSource
class also inherits from ImageSource
and will need this change in its prepare
method as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I updated CanvasSource
, but I didn't see an equivalent of debug/image.html
for testing it -- do we have any working examples?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I pushed a simple debug/canvas.html
. Looks like it's rendering fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome thanks @ChrisLoer!
- Necessary because pitched/wrapped tiles now have different CollisionTiles - Introduces potential for more flicker while crossing dateline at zoom level 0 - To preserve the ImageSource/VideoSource wrapping behavior, we can now bind the same image/video to multiple (wrapped) tiles - query_features#mergeRenderedFeatureLayers adds a fair amount of extra work to preserve existing query behavior with wrapped tiles: i.e. don't return a feature twice if it's returned from both tiles. - Flip the tile order of results in box-cutting-antimeridian-z0. This was previously based on the order provided by SourceCache#getIDs, which was only strictly defined for zoom level. It's now defined by query_features#sortTilesIn
This eliminates precision-related disagreement between collision box colors and whether symbols actually show.
…inzoom, etc. This resolves an iOS rendering issue where right at the edge of a collision some vertices could be hidden while others showed. It's possible the underlying issue could still be exposed when the un-rounded perspective_zoom_adjust value is almost exactly at a multiple of .1, but if so it will be much less common.
6131a7b
to
6f06f78
Compare
6d441c4
to
4b0235a
Compare
The motivation for these changes is that we want to improve our rendering support for the "navigation" use case, in which:
This PR improves support for this case by:
Giving style designers the ability to control how much labels scale down with distance from the camera. This addresses two of the remaining items in Complete set of *-rotation-alignment, *-pitch-alignment, and *-pitch-scale properties #4120 (text-pitch-scale
andicon-pitch-scale
).Changes we make to placement logic to support collision detection over a wider range of zooms may make it easier to address #4376.
Control how icons and text scale based on distance from camera usingHalve the effect of perspective on label scaling for both icons and text.text-pitch-scale
andicon-pitch-scale
Make collision boxes conservative over the entire range ofNo longer necessary with single hardwired valuetext-pitch-scale
values in usepitch-alignment: viewport
from showing in the distant field at high pitches (the approximations used to draw the labels don't hold up well)Renametext-pitch-scale
based on Rename circle-pitch-scale to circle-pitch-scaling #4165Decide on collision debug colors (since these show up in Studio, we want them to look decent... but also ideally be legible by colorblind folk)Keep original colors.