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

add support for animated images #7999

Merged
merged 5 commits into from
Mar 12, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/documentation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ toc:
- CameraOptions
- PaddingOptions
- RequestParameters
- StyleImageInterface
- CustomLayerInterface
- name: Geography & Geometry
description: |
Expand Down
85 changes: 85 additions & 0 deletions docs/pages/example/add-image-animated.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<div id='map'></div>

<script>

var map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v9'
});

var size = 200;

var pulsingDot = {
width: size,
height: size,
data: new Uint8Array(size * size * 4),

onAdd: function() {
var canvas = document.createElement('canvas');
canvas.width = this.width;
canvas.height = this.height;
this.context = canvas.getContext('2d');
},

render: function() {
var duration = 1000;
var t = (performance.now() % duration) / duration;

var radius = size / 2 * 0.3;
var outerRadius = size / 2 * 0.7 * t + radius;
var context = this.context;

// draw outer circle
context.clearRect(0, 0, this.width, this.height);
context.beginPath();
context.arc(this.width / 2, this.height / 2, outerRadius, 0, Math.PI * 2);
context.fillStyle = 'rgba(255, 200, 200,' + (1 - t) + ')';
context.fill();

// draw inner circle
context.beginPath();
context.arc(this.width / 2, this.height / 2, radius, 0, Math.PI * 2);
context.fillStyle = 'rgba(255, 100, 100, 1)';
context.strokeStyle = 'white';
context.lineWidth = 2 + 4 * (1 - t);
context.fill();
context.stroke();

// update this image's data with data from the canvas
this.data = context.getImageData(0, 0, this.width, this.height).data;

// keep the map repainting
map.triggerRepaint();

// return `true` to let the map know that the image was updated
return true;
}
};

map.on('load', function () {

map.addImage('pulsing-dot', pulsingDot, { pixelRatio: 2 });

map.addLayer({
"id": "points",
"type": "symbol",
"source": {
"type": "geojson",
"data": {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [0, 0]
}
}]
}
},
"layout": {
"icon-image": "pulsing-dot"
}
});
});

</script>
11 changes: 11 additions & 0 deletions docs/pages/example/add-image-animated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*---
title: Add an animated icon to the map
description: Add an animated icon to the map that was generated at runtime with a Canvas.
tags:
- styles
- layers
pathname: /mapbox-gl-js/example/add-image-animated/
---*/
import Example from '../../components/example';
import html from './add-image-animated.html';
export default Example(html);
69 changes: 46 additions & 23 deletions src/render/image_atlas.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { register } from '../util/web_worker_transfer';
import potpack from 'potpack';

import type {StyleImage} from '../style/style_image';
import type ImageManager from './image_manager';
import type Texture from './texture';

const padding = 1;

Expand All @@ -19,10 +21,12 @@ type Rect = {
export class ImagePosition {
paddedRect: Rect;
pixelRatio: number;
version: number;

constructor(paddedRect: Rect, {pixelRatio}: StyleImage) {
constructor(paddedRect: Rect, {pixelRatio, version}: StyleImage) {
this.paddedRect = paddedRect;
this.pixelRatio = pixelRatio;
this.version = version;
}

get tl(): [number, number] {
Expand Down Expand Up @@ -55,35 +59,17 @@ export default class ImageAtlas {
image: RGBAImage;
iconPositions: {[string]: ImagePosition};
patternPositions: {[string]: ImagePosition};
haveRenderCallbacks: Array<string>;
uploaded: ?boolean;

constructor(icons: {[string]: StyleImage}, patterns: {[string]: StyleImage}) {
const iconPositions = {}, patternPositions = {};
this.haveRenderCallbacks = [];

const bins = [];
for (const id in icons) {
const src = icons[id];
const bin = {
x: 0,
y: 0,
w: src.data.width + 2 * padding,
h: src.data.height + 2 * padding,
};
bins.push(bin);
iconPositions[id] = new ImagePosition(bin, src);
}

for (const id in patterns) {
const src = patterns[id];
const bin = {
x: 0,
y: 0,
w: src.data.width + 2 * padding,
h: src.data.height + 2 * padding,
};
bins.push(bin);
patternPositions[id] = new ImagePosition(bin, src);
}
this.addImages(icons, iconPositions, bins);
this.addImages(patterns, patternPositions, bins);

const {w, h} = potpack(bins);
const image = new RGBAImage({width: w || 1, height: h || 1});
Expand Down Expand Up @@ -114,6 +100,43 @@ export default class ImageAtlas {
this.iconPositions = iconPositions;
this.patternPositions = patternPositions;
}

addImages(images: {[string]: StyleImage}, positions: {[string]: ImagePosition}, bins: Array<Rect>) {
for (const id in images) {
const src = images[id];
const bin = {
x: 0,
y: 0,
w: src.data.width + 2 * padding,
h: src.data.height + 2 * padding,
};
bins.push(bin);
positions[id] = new ImagePosition(bin, src);

if (src.hasRenderCallback) {
this.haveRenderCallbacks.push(id);
}
}
}

patchUpdatedImages(imageManager: ImageManager, texture: Texture) {
imageManager.dispatchRenderCallbacks(this.haveRenderCallbacks);
for (const name in imageManager.updatedImages) {
this.patchUpdatedImage(this.iconPositions[name], imageManager.getImage(name), texture);
this.patchUpdatedImage(this.patternPositions[name], imageManager.getImage(name), texture);
}
}

patchUpdatedImage(position: ?ImagePosition, image: ?StyleImage, texture: Texture) {
if (!position || !image) return;

if (position.version === image.version) return;

position.version = image.version;
const [x, y] = position.tl;
texture.update(image.data, undefined, {x, y});
}

}

register('ImagePosition', ImagePosition);
Expand Down
72 changes: 61 additions & 11 deletions src/render/image_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { RGBAImage } from '../util/image';
import { ImagePosition } from './image_atlas';
import Texture from './texture';
import assert from 'assert';
import {renderStyleImage} from '../style/style_image';

import type {StyleImage} from '../style/style_image';
import type Context from '../gl/context';
Expand All @@ -25,17 +26,20 @@ type Pattern = {
const padding = 1;

/*
ImageManager does two things:
ImageManager does three things:
ansis marked this conversation as resolved.
Show resolved Hide resolved

1. Tracks requests for icon images from tile workers and sends responses when the requests are fulfilled.
2. Builds a texture atlas for pattern images.
ansis marked this conversation as resolved.
Show resolved Hide resolved
3. Rerenders renderable images once per frame

These are disparate responsibilities and should eventually be handled by different classes. When we implement
data-driven support for `*-pattern`, we'll likely use per-bucket pattern atlases, and that would be a good time
to refactor this.
*/
class ImageManager extends Evented {
images: {[string]: StyleImage};
updatedImages: {[string]: boolean};
callbackDispatchedThisFrame: {[string]: boolean};
loaded: boolean;
requestors: Array<{ids: Array<string>, callback: Callback<{[string]: StyleImage}>}>;

Expand All @@ -47,6 +51,8 @@ class ImageManager extends Evented {
constructor() {
super();
this.images = {};
this.updatedImages = {};
this.callbackDispatchedThisFrame = {};
this.loaded = false;
this.requestors = [];

Expand Down Expand Up @@ -83,10 +89,25 @@ class ImageManager extends Evented {
this.images[id] = image;
}

updateImage(id: string, image: StyleImage) {
const oldImage = this.images[id];
assert(oldImage);
assert(oldImage.data.width === image.data.width);
assert(oldImage.data.height === image.data.height);
image.version = oldImage.version + 1;
this.images[id] = image;
this.updatedImages[id] = true;
}

removeImage(id: string) {
assert(this.images[id]);
const image = this.images[id];
delete this.images[id];
delete this.patterns[id];

if (image.userImage && image.userImage.onRemove) {
image.userImage.onRemove();
}
}

listImages(): Array<string> {
Expand Down Expand Up @@ -126,7 +147,9 @@ class ImageManager extends Evented {
response[id] = {
data: image.data.clone(),
pixelRatio: image.pixelRatio,
sdf: image.sdf
sdf: image.sdf,
version: image.version,
hasRenderCallback: Boolean(image.userImage && image.userImage.render)
};
}
}
Expand All @@ -143,23 +166,29 @@ class ImageManager extends Evented {

getPattern(id: string): ?ImagePosition {
const pattern = this.patterns[id];
if (pattern) {
return pattern.position;
}

const image = this.getImage(id);
if (!image) {
return null;
}

const w = image.data.width + padding * 2;
const h = image.data.height + padding * 2;
const bin = {w, h, x: 0, y: 0};
const position = new ImagePosition(bin, image);
this.patterns[id] = {bin, position};
if (pattern && pattern.position.version === image.version) {
return pattern.position;
}

if (!pattern) {
const w = image.data.width + padding * 2;
const h = image.data.height + padding * 2;
const bin = {w, h, x: 0, y: 0};
const position = new ImagePosition(bin, image);
this.patterns[id] = {bin, position};
} else {
pattern.position.version = image.version;
}

this._updatePatternAtlas();

return position;
return this.patterns[id].position;
}

bind(context: Context) {
Expand Down Expand Up @@ -204,6 +233,27 @@ class ImageManager extends Evented {

this.dirty = true;
}

beginFrame() {
this.callbackDispatchedThisFrame = {};
}

dispatchRenderCallbacks(ids: Array<string>) {
for (const id of ids) {

// the callback for the image was already dispatched for a different frame
if (this.callbackDispatchedThisFrame[id]) continue;
this.callbackDispatchedThisFrame[id] = true;

const image = this.images[id];
assert(image);

const updated = renderStyleImage(image);
if (updated) {
this.updateImage(id, image);
}
}
}
}

export default ImageManager;
2 changes: 2 additions & 0 deletions src/render/painter.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ class Painter {

this.symbolFadeChange = style.placement.symbolFadeChange(browser.now());

this.imageManager.beginFrame();

const layerIds = this.style._order;
const sourceCaches = this.style.sourceCaches;

Expand Down
9 changes: 5 additions & 4 deletions src/render/texture.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ class Texture {
this.update(image, options);
}

update(image: TextureImage, options: ?{premultiply?: boolean, useMipmap?: boolean}) {
update(image: TextureImage, options: ?{premultiply?: boolean, useMipmap?: boolean}, position?: { x: number, y: number }) {
const {width, height} = image;
const resize = !this.size || this.size[0] !== width || this.size[1] !== height;
const resize = (!this.size || this.size[0] !== width || this.size[1] !== height) && !position;
const {context} = this;
const {gl} = context;

Expand All @@ -72,10 +72,11 @@ class Texture {
}

} else {
const {x, y} = position || { x: 0, y: 0};
if (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || image instanceof HTMLVideoElement || image instanceof ImageData) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, gl.RGBA, gl.UNSIGNED_BYTE, image);
} else {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, image.data);
gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, image.data);
Copy link
Member

Choose a reason for hiding this comment

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

How does this behave when we have a lot of icons in the atlas? Is there a threshold where it's more efficient to update the entire texture rather than patching it? I remember reading somewhere (can't find the reference now) that a glTexSubImage2D causes a copy of the underlying texture object (to avoid race conditions with render calls that are in flight), which then gets patched. This means that multiple glTexSubImage2D calls would repeatedly copy and patch rather than have one swap operation.

We already coalesce all icon updates before issuing GL calls, but then trigger upload calls for every single icon rather than coalescing them. Not saying that we need to change this now, but it could be a potential performance bottleneck.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is good to know!

I'm going to merge this as is but we should keep an eye on the performance this. Profiling this with a low number of images seemed to work ok. It's likely that for larger numbers of images your approach could work better. I initially implemented this by copying the images into the image and then calling texSubImage2D once per texture. The cost of the manual copies was measurable. It's very possible that by switching to the current approach I just made the cost more hidden.

Hopefully if this API turns out to be useful we can optimize based on users' use cases.

One of my motivations for patching images individually is that it lets you supply dom elements as the input. We aren't using this right now but I'm hoping to extend image support to include HTMLVideoElement. In a prototype, updating a image by going the canvas drawImage --> canvas getImageData --> texture update was much slower and this approach avoids that. Implementing both approaches shouldn't add more complexity.

}
}

Expand Down
Loading