mapbox / mapbox-gl-js

Interactive, thoroughly customizable maps in the browser, powered by vector tiles and WebGL
https://docs.mapbox.com/mapbox-gl-js/
Other
11.01k stars 2.2k forks source link

Synchronous redraw API #7893

Open Pessimistress opened 5 years ago

Pessimistress commented 5 years ago

Motivation

In react-map-gl, when the viewport updates, we call Map.jumpTo() inside componentDidUpdate(), which schedules a Mapbox rerender in the next animation frame. This causes the canvas rerender to always occur one step behind the React updates.

You can see this issue in https://github.com/uber/react-map-gl/pull/720 and https://github.com/uber/deck.gl/issues/2458.

Design

Provide a synchronous API e.g. redraw() that immediately flushes the dirty state.

Implementation

Here's how we are currently forcing rerender:

// map render will throw error if style is not loaded
if (map.style) {
  // cancel the scheduled update
  if (map._frame) {
    map._frame.cancel();
    map._frame = null;
  }
  map._render();
}

Private methods and properties are manipulated here, which is not a reliable solution.

peterqliu commented 5 years ago

does triggerRepaint() address this need?

https://docs.mapbox.com/mapbox-gl-js/api/#map#triggerrepaint

Pessimistress commented 5 years ago

triggerRepaint is asynchronous: https://github.com/mapbox/mapbox-gl-js/blob/master/src/ui/map.js#L1766

mourner commented 5 years ago

@Pessimistress would exposing the map._render method publicly fit the use case? Edit: we could then also move canceling the frame logic to it (if there's no ongoing animation).

Pessimistress commented 5 years ago

We need to both call map._render and clear the scheduled rerender (map._frame) - see code snippet in the issue summary.

mourner commented 5 years ago

@Pessimistress yeah, I updated the comment — should we add the logic of clearing the frame if there's no ongoing animation to (potentially renamed) _render (instead of making another wrapper)?

Pessimistress commented 5 years ago

Yeah that makes sense. Though I figure the naming of render and triggerRepaint might be confusing to a lot of users.

mourner commented 5 years ago

@Pessimistress yeah, maybe something like forceRepaint?

ansis commented 5 years ago

@Pessimistress would this be used for instantly jumping to a single new location or also animating a movement to a new location?

Also, what would be deciding when frames are rendered? Would there be an animation loop outside of mapbox-gl that uses requestAnimationFrame?

Pessimistress commented 5 years ago

@ansis timing will be managed by React's component life cycle, on componentDidUpdate

ansis commented 5 years ago

I'm not that familiar with React so please correct me if I'm getting something wrong. React's component life cycle appears to not to use requestAnimationFrame. Using animation frames lets the map rendering be synced with browser repainting so that the map is rendered only once for each browser repaint which avoids doing extra work and dropping frames.

Would it be an option to have all the components that need to be synced with the map live in a separate react tree and have updates to those components be set after the map renders a frame?

I think just adding a synchronous render method wouldn't fix the underlying problem because you could still end up with multiple map renders for each browser repaint.

Pessimistress commented 5 years ago

The root issue is that both React and Mapbox's render cycles are asynchronous. If we update component states on a render event, React would not immediately repaint either.

On a high level, when we are developing a React application, React is the source of truth for all app states, and the React clock should be driving all rerenders caused by app state change. In the context of mapbox, when the canvas is resized and/or the camera position is changed, the React root tells all child components when to update, not the other way around.

In your proposal, imagine if more than one sub-component require to be informed of an update before everyone else. They can kick off an infinite update loop that never ends.

With that said - It is only a description of why this API would solve our problem. I can understand your concern that such an API may get abused by the user and affect performance.

ansis commented 5 years ago

Thanks for the explanation!

I'm wondering if a Portal could be used to solve the marker syncing problem. For each react marker you could create a mapboxgl.Marker and render within that. This way the map would be responsible for positioning the elements while React would be responsible for rendering them.

render() {
  return ReactDOM.createPortal(this.props.children, this.mapboxmarker.getElement());
}

This example uses mapboxgl markers but something similar could be implemented for react so that it works without mapbox maps.

The positioning of containers would happen synchronously with respect to the map. The rendering of the markers within the containers would happen synchronously with respect to react.

This would allow the webgl rendering to be synced with browser repaints instead of react renders and could avoid unnecessary rendering.


Side note: I looked into the spec and it looks like animation frames should always be run just before a repaint. This should mean that triggering a repaint would update the map before the dom element changes are visible. But I'm guessing browsers don't follow that exactly and maybe break that sometimes :( https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model

Pessimistress commented 5 years ago

I'm wondering if a Portal could be used to solve the marker syncing problem.

Yes, portals might fix the rendering, though other issues would surface (e.g. React's event handling do not mix well with native event listeners).

I've been working on the React wrapper for over two years now. Many of mapbox-gl's APIs are incompatible with the pure reactive pattern that we are after, as illustrated in this old issue. Being able to manage map states and control render cycles outside of the Map component is essential to a lot of our applications. Another example would be rendering two maps side by side and synchronize their cameras, which you cannot do with mapbox's native API.

We have also built a lot more components than just marker, especially DeckGL. Admittedly some of the implementation decisions are legacy and could be revisited, but some are with good reason. I suspect either way there needs to be a lot of patching and hacking.

ansis commented 5 years ago

@Pessimistress thanks for all the feedback and explanations! They were very helpful for me. We'll add a synchronous render method.

ansis commented 5 years ago

@Pessimistress I'm finally getting around to implementing this.

An idea that popped up internally was to expose this functionality by letting users implement their own AnimationFrameProvider that we would request frames from. The pr has some examples, including one where you can synchronously dispatch frames: https://github.com/mapbox/mapbox-gl-js/pull/8131

The main advantages of this approach seemed to be that:

It is more complicated that a map.synchronousRender() though. Does the AnimationFrameProvider approach look like it would work for you? Would you still prefer a map.synchronousRender()?

Thanks!

Pessimistress commented 5 years ago

I prefer map.synchronousRender(). External animation frame provider is a neat feature and I get why it's desirable, but it doesn't affect our particular use case.

I observed something weird with the above hack. Although map._render() is executed, the canvas does not update immediately. i.e. when render event is fired, the visuals do not yet reflect the new camera position. This seems more pronounced when tiles that are not cached are brought into view. Is this behavior expected? Will I be able to work around it with the proposed synchronousRender API?

wesg52 commented 4 years ago

I believe we also require a synchronous solution to implement this feature https://stackoverflow.com/questions/60803985/how-to-synchronously-update-mapbox-gl-style

The proposed implementation above did not work.

charleyw commented 4 years ago

Hi there, I met a problem with jumpTo and _render() trick. We have some style like this:

"icon-image": { "stops": [[4, "Capital"], [9, ""]] },
"text-font": [
                  "step",
                    ["zoom"],
                    ["literal", ["noto sans"]],
                    9,
                    ["literal", ["noto sans bold"]]
                ],
"text-field": "{name}"

Which make the map show both icon and text before zoom 9 for a specific poi. And start from zoom9 hide icon and show bold text.

When current zoom is 8, we use jumpTo and _render to zoom 9, all these works., icon will disappear and text become bold.

But when current zoom is 9, and use jumpTo and _render change to zoom 8, the map will not update, icon not appear and font weight still keep bold. And it need another trigger (like move or even call jumpTo with same properties again), map will change, icon will disappear and font weight become normal.

RyanMcManaman commented 3 years ago

@Pessimistress I'm finally getting around to implementing this.

Did this ever get implemented? Couldn't find any documentation for it, looks like others are finding this webpage in search of it