Skip to content
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

Decode tile features to dart:ui and render in tile space #12

Merged
merged 6 commits into from Feb 10, 2022

Conversation

blaugold
Copy link
Contributor

@blaugold blaugold commented Feb 7, 2022

With this change, tile feature geometry is decoded from the raw vector tile geometry to Path and Offset. This means that Paths and Offsets don't have to be constructed during each render pass. Also, no itermediate lists need to be created during decoding.

The decoded geometry is in tile space. By rendering in tile space the projection to screen space is performed by the render engine, which can take advantage of GPU acceleration.

Before this change:

RenderPicture(zoom: 0.0, preprocessTile: false)(RunTime): 98281.90476190476 us.
RenderPicture(zoom: 0.0, preprocessTile: true)(RunTime): 35279.333333333336 us.
RenderPicture(zoom: 12.0, preprocessTile: false)(RunTime): 75429.37037037036 us.
RenderPicture(zoom: 12.0, preprocessTile: true)(RunTime): 37234.166666666664 us.
RenderPicture(zoom: 24.0, preprocessTile: false)(RunTime): 84570.0 us.
RenderPicture(zoom: 24.0, preprocessTile: true)(RunTime): 43621.52173913043 us.

After this change:

RenderPicture(zoom: 0.0, preprocessTile: false)(RunTime): 54389.18918918919 us.
RenderPicture(zoom: 0.0, preprocessTile: true)(RunTime): 8515.063829787234 us.
RenderPicture(zoom: 12.0, preprocessTile: false)(RunTime): 46599.09302325582 us.
RenderPicture(zoom: 12.0, preprocessTile: true)(RunTime): 11793.017647058823 us.
RenderPicture(zoom: 24.0, preprocessTile: false)(RunTime): 57811.0 us.
RenderPicture(zoom: 24.0, preprocessTile: true)(RunTime): 18780.168224299065 us.

@greensopinion
Copy link
Owner

This is really cool. Thanks for the PR!

Great to see the performance metrics.

Have you looked into whether this will work with isolates? I had avoided any dart:ui references in the model intentionally, thinking that it wouldn't work due to lack of dart:ui bindings.

What are your plans with this PR? There are a few things that could use some attention. e.g. CI build is failing and there is at least one spot that looks unfinished (feature.paths.first)
I'll leave some comments in the code where I have questions.

Copy link
Owner

@greensopinion greensopinion left a comment

Choose a reason for hiding this comment

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

Nice work!

final double pixelsPerTileUnit;

@override
LabelSpace get labelSpace => _context.labelSpace;
Copy link
Owner

Choose a reason for hiding this comment

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

It's unfortunate that we need all of this boilerplate so that we can pass around pixelsPerTileUnilt. Would it make sense to split out a separate class, e.g. PixelSpaceMapper that encapsulates the new logic, and make it a member of Context?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

}

bool isPathWithinTileClip(Path path) {
return tileClip.overlaps(rectFromTileToPixels(path.getBounds()));
Copy link
Owner

Choose a reason for hiding this comment

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

This will get called quite a lot while rendering. How about having a pixelTileClip member that is converted from tile to pixels once, then we can check overlaps directly without converting.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

logger,
);

final effectivePaint = style.linePaint!.paint(evaluationContext);
Copy link
Owner

Choose a reason for hiding this comment

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

we could use ?. here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

final lines = feature.lines;
logger.log(() => 'rendering linestring symbol');
// What if the feature has multiple paths?
final path = feature.paths.first;
Copy link
Owner

Choose a reason for hiding this comment

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

What's the plan here? Will we ever have multiple paths?

Thinking on how LabelSpace works, this might have the same effect if the first line ends up placing the label (since labels can only appear once in LabelSpace) but if the first path ends up with a collision with some other label, then another path might cause the label to be rendered.

I'm not sure if this ever worked before.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My bad. I can just do this in the same the previous version did.

Do you remember why you implemented _findMiddleMetric they way you did? Why not just iterate throuh all PathMetrics and use the first one that can be used. I could not find anything on the ordering of the result of Path.computeMetrics.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But yes I found instances of symbol linestrings with multiple lines.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

return;
}

final textAbbr = TextAbbreviator().abbreviate(text);
Copy link
Owner

Choose a reason for hiding this comment

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

I'd prefer to spell things out in full to avoid creating a barrier to others reading the code. We should go wth text or textAbbreviation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

lib/src/features/symbol_line_renderer.dart Show resolved Hide resolved

logger.log(() => 'rendering symbol points');

final textApprox =
Copy link
Owner

Choose a reason for hiding this comment

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

same re: abbreviations

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

lib/src/model/geometry_decoding.dart Show resolved Hide resolved
lib/src/model/tile_model.dart Show resolved Hide resolved
@blaugold
Copy link
Contributor Author

blaugold commented Feb 7, 2022

Have you looked into whether this will work with isolates? I had avoided any dart:ui references in the model intentionally, thinking that it wouldn't work due to lack of dart:ui bindings.

With this approach decoding the feature geometry does not work in normal isolates that are spawned by the UI isolate. If decoding in a different isolate is still necessary flutter_isolate could be a path. The raw vector tiles and feature properties could still be decoded in a normal isolate.

What are your plans with this PR? There are a few things that could use some attention. e.g. CI build is failing and there is at least one spot that looks unfinished (feature.paths.first)

I think CI is failing because it uses a new version of Flutter which has a few new analyzer rules. Might be best to fix that first and rebase.

@greensopinion
Copy link
Owner

greensopinion commented Feb 7, 2022 via email

@blaugold
Copy link
Contributor Author

blaugold commented Feb 7, 2022

I have addressed most of your comments but still have to add some tests.

Copy link
Owner

@greensopinion greensopinion left a comment

Choose a reason for hiding this comment

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

This is really great. Thanks for adding tests.

I've given it a try with my latest flutter-vector-map-tiles experiment and so far the results look good subjectively, and in the profiler.

The implication of this change is that TileFactory and tileset preprocessing must occur on the UI thread. This will potentially affect latency of tile loading, and it means that we will need to do some expensive work on the UI thread.

Tile loading latency occurs anyways, so a small addition in delay there probably won't matter. It could contribute to flashing when one tile is unloaded and others are not yet loaded. Previously I mitigated this issue by rendering the previous tiles until the new ones are loaded, but ran into a memory issue that caused crashes due to excessive RSS growth. We can continue to explore that path. Another option to mitigate this is improving how the map background is rendered.

As for performing expensive work on the UI thread, a couple of options come to mind that might help to mitigate. We could be careful about when to schedule that work, for example if it's done one at a time in between frames it may not be problematic. Alternatively we could look at running with ui bindings on an isolate, but I'd prefer to avoid that if possible.

Have you had any further ideas about potential impact of this change?

One minor issue below, see comment inline.


final layer = layers.first;

context.tileSpaceMapper = TileSpaceMapper(
Copy link
Owner

Choose a reason for hiding this comment

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

is there a reason we can't pass the TileSpaceMapper in the constructor?

Copy link
Contributor Author

@blaugold blaugold Feb 9, 2022

Choose a reason for hiding this comment

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

I wanted to avoid making tileSpaceMapper nullable because when it is actually used it is never null. We have to set it in the ThemeLayer because before that, we don't know what extent the layer tile has we are rendering. A tileset could have tiles with different extents.

@blaugold
Copy link
Contributor Author

blaugold commented Feb 9, 2022

The implication of this change is that TileFactory and tileset preprocessing must occur on the UI thread. This will potentially affect latency of tile loading, and it means that we will need to do some expensive work on the UI thread.

In a next step, we could refactor TileFeature a bit to remove the decoded dart:ui geometry so that is only has the raw geometry list from the raw tile. Then we can still do all the protobuf and feature property decoding in another isolate, as well as the preprocessing. The UI isolate than only has to decode the geometry. One idea would be to use an Expando to attach the decoded geometry to the TileFeature in the UI isolate.

As for performing expensive work on the UI thread, a couple of options come to mind that might help to mitigate. We could be careful about when to schedule that work, for example if it's done one at a time in between frames it may not be problematic. Alternatively we could look at running with ui bindings on an isolate, but I'd prefer to avoid that if possible.

Carefully scheduling when to decode the geometry and when to paint new tiles would be my first choice as well.

@greensopinion
Copy link
Owner

we could refactor TileFeature a bit to remove the decoded dart:ui geometry so that is only has the raw geometry list from the raw tile. Then we can still do all the protobuf and feature property decoding in another isolate, as well as the preprocessing. The UI isolate than only has to decode the geometry.

That's an excellent idea. If decoding were done on-demand (i.e. lazy), it could reduce the number of features that need to be decoded depending on the theme and whether it renders all of the features in the tile (e.g. with minzoom/maxzoom)

Do you know if it's possible to pass a null reference to a dart:ui object to an isolate? If so, we could decode geometry lazily without needing anything else special in the model. Alternatively, we could do the same with dynamic.

So we could end up with something like this:

class TileFeature {
  final TileFeatureType type;
  final Map<String, dynamic> properties;
  List<int>? _geometry;
  List<dynamic>? _paths;
  List<dynamic>? _points;

  TileFeature({
    required this.type,
    required this.properties,
    List<int> geometry
  })  : _geometry = geometry;

  get List<dynamic> paths {
    if (_paths == null) {
       _paths = decodePaths(_geometry!);
       _geometry = null;
    }
    return _paths!;
  }

   ...
}

What do you think?

I'm reluctant to merge this PR as-is since just yet since it locks us into a specific threading model that would be hard to undo. To move this forward I think we either need to improve it so that we can use isolates, or some concrete metrics related to its use in a map so that we can understand the impact of this change and a plan to address any shortcomings.

What do you think about taking the next step on this PR?

@blaugold
Copy link
Contributor Author

blaugold commented Feb 9, 2022

Based on this issue (flutter/flutter#10647 (comment)) I think that importing and using types from dart:ui works, but you just cannot use them.

That's an excellent idea. If decoding were done on-demand (i.e. lazy), it could reduce the number of features that need to be decoded depending on the theme and whether it renders all of the features in the tile (e.g. with minzoom/maxzoom)

I didn't even think about that, but that's a nice side effect :)

I'll go ahead and refactor TileFeature like you suggested.

Copy link
Owner

@greensopinion greensopinion left a comment

Choose a reason for hiding this comment

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

This change is fantastic. Great that it was so easy to make it workable with isolates.

To make it work correctly with flutter maps this commit is needed, otherwise we end up attempting to pass ui objects to an isolate.

See a minor question below.

}
List<Offset> get points {
if (type != TileFeatureType.point) {
throw Exception('Feature does not have points');
Copy link
Owner

Choose a reason for hiding this comment

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

why Exception here and StateError below?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch. They should both be StateError.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@greensopinion greensopinion merged commit d572cca into greensopinion:main Feb 10, 2022
@greensopinion
Copy link
Owner

nice work, thanks for the contribution!

@blaugold
Copy link
Contributor Author

Did you mean to squash when merging? Right now all the fixup commits are in the main branch :)

@greensopinion
Copy link
Owner

greensopinion commented Feb 10, 2022 via email

@blaugold blaugold deleted the optimize-decoding branch July 28, 2022 08:54
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.

None yet

2 participants