diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c308bd8bb..4b63050fd0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### 🐞 Bug fixes - Fix missing `constrainOverride` setter in `TransformHelper.apply` ([#6642](https://github.com/maplibre/maplibre-gl-js/pull/6642)) (by [@larsmaxfield](https://github.com/larsmaxfield)) +- Fix blank map after WebGL context restore ([#6242](https://github.com/maplibre/maplibre-gl-js/issues/6242)) (by [@ToHold](https://github.com/ToHold)) + - _...Add new stuff here..._ ## 5.11.0 diff --git a/src/render/glyph_manager.ts b/src/render/glyph_manager.ts index d63bae65919..263de4aab7f 100644 --- a/src/render/glyph_manager.ts +++ b/src/render/glyph_manager.ts @@ -179,7 +179,7 @@ export class GlyphManager { unicodeBlockLookup['CJK Symbols and Punctuation'](id) || // 、。〃〄々〆〇〈〉《》「... unicodeBlockLookup['Halfwidth and Fullwidth Forms'](id) // !?"#$%&... ); - + } /** @@ -283,4 +283,17 @@ export class GlyphManager { } return match; } + + destroy() { + for (const stack in this.entries) { + const entry = this.entries[stack]; + if (entry.tinySDF) { + entry.tinySDF = null; + } + entry.glyphs = {}; + entry.requests = {}; + entry.ranges = {}; + } + this.entries = {}; + } } diff --git a/src/render/image_manager.ts b/src/render/image_manager.ts index b0cb071c303..87173bfc8b9 100644 --- a/src/render/image_manager.ts +++ b/src/render/image_manager.ts @@ -69,6 +69,21 @@ export class ImageManager extends Evented { this.dirty = true; } + destroy() { + // Destroy atlas texture if it exists + if (this.atlasTexture) { + this.atlasTexture.destroy(); + this.atlasTexture = null; + } + // Remove all images and patterns + for (const id of Object.keys(this.images)) { + this.removeImage(id); + } + + this.patterns = {}; + this.atlasImage = new RGBAImage({width: 1, height: 1}); + this.dirty = true; + } isLoaded() { return this.loaded; } @@ -90,7 +105,6 @@ export class ImageManager extends Evented { getImage(id: string): StyleImage { const image = this.images[id]; - // Extract sprite image data on demand if (image && !image.data && image.spriteData) { const spriteData = image.spriteData; @@ -334,4 +348,16 @@ export class ImageManager extends Evented { } } } + + cloneImages() { + const clonedImages: Record = {}; + for (const id in this.images) { + const image = this.images[id]; + clonedImages[id] = { + ...image, + data: image.data ? image.data.clone() : null + }; + } + return clonedImages; + } } diff --git a/src/render/painter.ts b/src/render/painter.ts index 92e68e16bc0..d06085babf2 100644 --- a/src/render/painter.ts +++ b/src/render/painter.ts @@ -793,9 +793,45 @@ export class Painter { } destroy() { + if (this._tileTextures) { + for (const size in this._tileTextures) { + const textures = this._tileTextures[size]; + if (textures) { + for (const texture of textures) { + texture.destroy(); + } + } + } + this._tileTextures = {}; + } + + if (this.tileExtentBuffer) this.tileExtentBuffer.destroy(); + if (this.debugBuffer) this.debugBuffer.destroy(); + if (this.rasterBoundsBuffer) this.rasterBoundsBuffer.destroy(); + if (this.rasterBoundsBufferPosOnly) this.rasterBoundsBufferPosOnly.destroy(); + if (this.viewportBuffer) this.viewportBuffer.destroy(); + if (this.tileBorderIndexBuffer) this.tileBorderIndexBuffer.destroy(); + if (this.quadTriangleIndexBuffer) this.quadTriangleIndexBuffer.destroy(); + if (this.tileExtentMesh) this.tileExtentMesh.vertexBuffer?.destroy(); + if (this.tileExtentMesh) this.tileExtentMesh.indexBuffer?.destroy(); + if (this.debugOverlayTexture) { this.debugOverlayTexture.destroy(); } + + if (this.cache) { + for (const key in this.cache) { + const program = this.cache[key]; + if (program && program.program) { + this.context.gl.deleteProgram(program.program); + } + } + this.cache = {}; + } + + if (this.context) { + this.context.setDefault(); + } } /* diff --git a/src/source/canvas_source.ts b/src/source/canvas_source.ts index a1897e2b9bb..a0e718f650f 100644 --- a/src/source/canvas_source.ts +++ b/src/source/canvas_source.ts @@ -200,6 +200,8 @@ export class CanvasSource extends ImageSource { serialize(): CanvasSourceSpecification { return { type: 'canvas', + animate: this.animate, + canvas: this.options.canvas, coordinates: this.coordinates }; } diff --git a/src/style/style.ts b/src/style/style.ts index 13bd9b08e58..8a778ce35bb 100644 --- a/src/style/style.ts +++ b/src/style/style.ts @@ -261,15 +261,7 @@ export class Style extends Evented { this.lineAtlas = new LineAtlas(256, 512); this.crossTileSymbolIndex = new CrossTileSymbolIndex(); - this._spritesImagesIds = {}; - this._layers = {}; - - this._order = []; - this.tileManagers = {}; - this.zoomHistory = new ZoomHistory(); - this._loaded = false; - this._availableImages = []; - this._globalState = {}; + this._setInitialValues(); this._resetUpdates(); @@ -300,6 +292,36 @@ export class Style extends Evented { }); } + private _setInitialValues() { + this._spritesImagesIds = {}; + this._layers = {}; + this._order = []; + this.tileManagers = {}; + this.zoomHistory = new ZoomHistory(); + this._availableImages = []; + this._globalState = {}; + this._serializedLayers = {}; + this.stylesheet = null; + this.light = null; + this.sky = null; + if (this.projection) { + this.projection.destroy(); + delete this.projection; + } + this._loaded = false; + this._changed = false; + this._updatedLayers = {}; + this._updatedSources = {}; + this._changedImages = {}; + this._glyphsDidChange = false; + this._updatedPaintProps = {}; + this._layerOrderChanged = false; + this.crossTileSymbolIndex = new (this.crossTileSymbolIndex?.constructor || Object)(); + this.pauseablePlacement = undefined; + this.placement = undefined; + this.z = 0; + } + _rtlPluginLoaded = () => { for (const id in this.tileManagers) { const sourceType = this.tileManagers[id].getSource().type; @@ -1997,4 +2019,72 @@ export class Style extends Evented { } } } + + /** + * Destroys all internal resources of the style (sources, images, layers, etc.) + */ + destroy() { + // cancel any pending requests + if (this._frameRequest) { + this._frameRequest.abort(); + this._frameRequest = null; + } + if (this._loadStyleRequest) { + this._loadStyleRequest.abort(); + this._loadStyleRequest = null; + } + if (this._spriteRequest) { + this._spriteRequest.abort(); + this._spriteRequest = null; + } + + // remove sourcecaches + for (const id in this.tileManagers) { + const tileManager = this.tileManagers[id]; + tileManager.setEventedParent(null); + + if (tileManager._tiles) { + for (const tileId in tileManager._tiles) { + const tile = tileManager._tiles[tileId]; + tile.unloadVectorData(); + } + tileManager._tiles = {}; + } + tileManager._cache.reset(); + tileManager.onRemove(this.map); + } + this.tileManagers = {}; + + // Destroy imageManager and clear images + if (this.imageManager) { + this.imageManager.setEventedParent(null); + this.imageManager.destroy(); + this._availableImages = []; + this._spritesImagesIds = {}; + } + + // Destroy glyphManager + if (this.glyphManager) { + this.glyphManager.destroy(); + } + + // Remove layers + for (const layerId in this._layers) { + const layer = this._layers[layerId]; + layer.setEventedParent(null); + if (layer.onRemove) layer.onRemove(this.map); + } + + // reset internal state + this._setInitialValues(); + + // Remove event listeners + this.setEventedParent(null); + this.dispatcher.unregisterMessageHandler(MessageType.getGlyphs); + this.dispatcher.unregisterMessageHandler(MessageType.getImages); + this.dispatcher.unregisterMessageHandler(MessageType.getDashes); + this.dispatcher.remove(true); + this._listeners = {}; + this._oneTimeListeners = {}; + } } diff --git a/src/ui/map.ts b/src/ui/map.ts index 4a90b69715e..c5110d4d417 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -208,7 +208,7 @@ export type MapOptions = { */ trackResize?: boolean; /** - * The initial geographical centerpoint of the map. If `center` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `[0, 0]` + * The initial geographical centerpoint of the map. If `center` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `[0, 0]` * !!! note * MapLibre GL JS uses longitude, latitude coordinate order (as opposed to latitude, longitude) to match GeoJSON. * @defaultValue [0, 0] @@ -387,6 +387,11 @@ type DelegatedListener = { type Delegate = (e: E) => void; +type LostContextStyle = { + style: StyleSpecification | null; + images: {[_: string]: StyleImage} | null; +}; + const defaultMinZoom = -2; const defaultMaxZoom = 22; @@ -545,6 +550,15 @@ export class Map extends Camera { */ _imageQueueHandle: number; + /** + * @internal + * Used to store the previous style and images when a context loss occurs, so they can be restored. + */ + _lostContextStyle: LostContextStyle = { + style: null, + images: null + }; + /** * The map's {@link ScrollZoomHandler}, which implements zooming in and out with a scroll wheel or trackpad. * Find more details and examples using `scrollZoom` in the {@link ScrollZoomHandler} section. @@ -1279,7 +1293,7 @@ export class Map extends Camera { /** Sets or clears the callback overriding how the map constrains the viewport's lnglat and zoom to respect the longitude and latitude bounds. * * @param constrain - A {@link TransformConstrainFunction} callback defining how the viewport should respect the bounds. - * + * * `null` clears the callback and reverts the constrain to the map transform's default constrain function. * @example * ```ts @@ -2035,6 +2049,21 @@ export class Map extends Camera { } } + /** + * @internal + * Returns the map's style and cloned images to restore context. + * @returns An object containing the style and images. + */ + _getStyleAndImages(): LostContextStyle { + if (this.style) { + return { + style: this.style.serialize(), + images: this.style.imageManager.cloneImages() + }; + } + return {style: null, images: {}}; + } + /** * Returns a Boolean indicating whether the map's style is fully loaded. * @@ -3257,10 +3286,36 @@ export class Map extends Camera { this._frameRequest.abort(); this._frameRequest = null; } + this.painter.destroy(); + + // check if style contains custom layers to warn user that they can't be restored automatically + for (const layer of Object.values(this.style._layers)) { + if (layer.type === 'custom') { + console.warn(`Custom layer with id '${layer.id}' cannot be restored after WebGL context loss. You will need to re-add it manually after context restoration.`); + } + + if (layer._listeners) { + for (const [event] of Object.entries(layer._listeners)) { + console.warn(`Custom layer with id '${layer.id}' had event listeners for event '${event}' which cannot be restored after WebGL context loss. You will need to re-add them manually after context restoration.`); + } + } + } + + this._lostContextStyle = this._getStyleAndImages(); + this.style.destroy(); + this.style = null; this.fire(new Event('webglcontextlost', {originalEvent: event})); }; _contextRestored = (event: any) => { + if (this._lostContextStyle.style) { + this.setStyle(this._lostContextStyle.style, {diff: false}); + } + + if (this._lostContextStyle.images) { + this.style.imageManager.images = this._lostContextStyle.images; + } + this._setupPainter(); this.resize(); this._update(); diff --git a/src/util/actor.ts b/src/util/actor.ts index 99babeefe84..346ba8d5939 100644 --- a/src/util/actor.ts +++ b/src/util/actor.ts @@ -87,6 +87,10 @@ export class Actor implements IActor { this.messageHandlers[type] = handler; } + unregisterMessageHandler(type: T) { + delete this.messageHandlers[type]; + } + /** * Sends a message from a main-thread map to a Worker or from a Worker back to * a main-thread map instance. diff --git a/src/util/dispatcher.ts b/src/util/dispatcher.ts index be3d3558b7d..9e44252f957 100644 --- a/src/util/dispatcher.ts +++ b/src/util/dispatcher.ts @@ -61,6 +61,12 @@ export class Dispatcher { actor.registerMessageHandler(type, handler); } } + + public unregisterMessageHandler(type: T) { + for (const actor of this.actors) { + actor.unregisterMessageHandler(type); + } + } } let globalDispatcher: Dispatcher; diff --git a/test/build/min.test.ts b/test/build/min.test.ts index 2eb60b651a2..1c2e07d0636 100644 --- a/test/build/min.test.ts +++ b/test/build/min.test.ts @@ -38,7 +38,7 @@ describe('test min build', () => { const decreaseQuota = 4096; // feel free to update this value after you've checked that it has changed on purpose :-) - const expectedBytes = 958463; + const expectedBytes = 963443; expect(actualBytes).toBeLessThan(expectedBytes + increaseQuota); expect(actualBytes).toBeGreaterThan(expectedBytes - decreaseQuota); diff --git a/test/integration/browser/browser.test.ts b/test/integration/browser/browser.test.ts index ccbd44e1e10..3fb8635a93a 100644 --- a/test/integration/browser/browser.test.ts +++ b/test/integration/browser/browser.test.ts @@ -479,4 +479,74 @@ describe('Browser tests', () => { expect(center.lng).toBeCloseTo(11.39770); expect(center.lat).toBeCloseTo(47.29960); }); + + test('Map canvas is not blank after context lost and restored', {retry: 3, timeout: 20000}, async () => { + const pixel = await page.evaluate(async () => { + function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + const canvas = map.getCanvas(); + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + const ext = gl && gl.getExtension('WEBGL_lose_context'); + // Context loss and restore + const restored: Promise = new Promise(resolve => { + const onRestored = () => { + canvas.removeEventListener('webglcontextrestored', onRestored); + resolve(); + }; + canvas.addEventListener('webglcontextrestored', onRestored); + }); + ext.loseContext(); + await sleep(50); + ext.restoreContext(); + await restored; + await new Promise(res => map.once('render', res)); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.finish(); + + // Read central pixel from the WebGL framebuffer + const dpr = window.devicePixelRatio || 1; + const width = canvas.width / dpr; + const height = canvas.height / dpr; + const x = Math.floor(width / 2); + const y = Math.floor(height / 2); + const readY = height - y - 1; + const rgba = new Uint8Array(4); + gl.readPixels(x, readY, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, rgba); + + return Array.from(rgba); + }); + + expect(pixel[0]).toBeGreaterThan(0); + expect(pixel[1]).toBeGreaterThan(0); + expect(pixel[2]).toBeGreaterThan(0); + expect(pixel[3]).toBeGreaterThan(0); + }); + + test('Map does not log invalid WebGL warnings on context loss/restore', async () => { + const warnings: string[] = []; + page.on('console', msg => { + if (msg.type() === 'warn' || msg.type() === 'error') { + warnings.push(msg.text()); + } + }); + + // Simulate context loss + await page.evaluate(() => { + const canvas = map.getCanvas(); + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + const ext = gl && gl.getExtension('WEBGL_lose_context'); + if (ext) { + ext.loseContext(); + setTimeout(() => ext.restoreContext(), 50); + } + }); + + // Wait a bit to allow logs to arrive + await sleep(500); + + const webglWarnings = warnings.filter(w => w.toLowerCase().includes('webgl')); + expect(webglWarnings).to.not.contain('WebGL: INVALID_OPERATION: deleteVertexArray: object does not belong to this context'); + expect(webglWarnings).to.not.contain('WebGL: INVALID_OPERATION: bindBuffer: object does not belong to this context'); + expect(webglWarnings).to.not.contain('[.WebGL-0x3e1400107800] GL_INVALID_OPERATION: glDrawElements: Must have element array buffer bound.'); + }); }); \ No newline at end of file