Slow vector tiles off screen canvas rendering with Openlayers

234 views Asked by At

There's an example on Openlayers website:

https://openlayers.org/en/latest/examples/offscreen-canvas.html

It works fine but when I try to add an additional tile layer (XYZ or WMS) to the map, the rendering process gets drastically slower.

Did anyone solve the problem?

Just tried to add an OSM layer to the map along with the layer from the example which resulted in significantly slowing render.

1

There are 1 answers

6
Mike On BEST ANSWER

From the way the code is using frameState it might be expecting the worker to do all the rendering and adding another layer directly would conflict with that assumption. If you must add directly to the map specifying transition: 0 for the OSM might help.

You could also try adding the OSM to the worker - it will not need stylefunction but I think everything else is applicable

const layers = [];
const layer = new TileLayer({
  source: new OSM({transition: 0})
});
layer.getRenderer().useContainer = function (target, transform) {
  this.containerReused = this.getLayer() !== layers[0];
  this.canvas = canvas;
  this.context = context;
  this.container = {
    firstElementChild: canvas,
    style: {
      opacity: layer.getOpacity(),
    },
  };
  rendererTransform = transform;
};
layers.push(layer);

Some additional work would be needed as this would need a custom Tile class - Image cannot be used in a worker so it would need to be loaded in the same way as sprites.

UPDATE

Ideally OSM could take a tileLoadFunction which loaded the image as a worker compatible imageBitmap in the same way as sprite images:

    tileLoadFunction: function (tile, src) {
      const worker = self;
      worker.postMessage({
        action: 'loadImage',
        src: src,
      });
      worker.addEventListener('message', (event) => {
        tile.setImage(event.data.image);
      });
    }

Unfortunately the code in ImageTile will still throw an error because it initially tries to create an empty Image without checking if that is supported, so unless that is fixed a custom class will be needed - for example you could copy all the code from https://github.com/openlayers/openlayers/blob/main/src/ol/ImageTile.js change the class name to ImageBitmapTile and edit any occurrences of new Image() to new Observable() (imported from ol/Observable.js - a simple generic object which accepts listeners)

OSM does not accept a custom tile class so you would need use a TileImage source and specify the OSM url and maxZoom:

new TileLayer({
  source: new TileImage({
    url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
    tilegrid: createXYZ({maxZoom: 19}),
    tileClass: ImageBitMapTile,
    tileLoadFunction: function (tile, src) {
      const worker = self;
      worker.postMessage({
        action: 'loadImage',
        src: src,
      });
      worker.addEventListener('message', (event) => {
        tile.setImage(event.data.image);
      });
    }
  }),
}),

I finally got it working with an OSM source simply by defining a dummy self.Image at the start of the worker. The tileLoadFunction must also check that the image returned is the one requested otherwise it picks up images for other tiles, and renderDeclutter should only be called for vector layers

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/ol.css" type="text/css">
    <style>
      .map {
        width: 100%;
        height: 400px;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ol.js"></script>
  </head>
  <body>
    <div id="map" class="map"></div>
     <script>

// inline script used to create worker

const workerScript = () => {

importScripts('https://cdn.jsdelivr.net/npm/[email protected]/dist/ol.js')

function stringify(obj, replacer, spaces, cycleReplacer) {
  return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces)
}
function serializer(replacer, cycleReplacer) {
  var stack = [], keys = []
  if (cycleReplacer == null) cycleReplacer = function(key, value) {
    if (stack[0] === value) return "[Circular ~]"
    return "[Circular ~." + keys.slice(0, stack.indexOf(value)).join(".") + "]"
  }
  return function(key, value) {
    if (stack.length > 0) {
      var thisPos = stack.indexOf(this)
      ~thisPos ? stack.splice(thisPos + 1) : stack.push(this)
      ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key)
      if (~stack.indexOf(value)) value = cycleReplacer.call(this, key, value)
    }
    else stack.push(value)
    return replacer == null ? value : replacer.call(this, key, value)
  }
}

self.Image = EventTarget;

/** @type {any} */
const worker = self;

let frameState, pixelRatio, rendererTransform;
const canvas = new OffscreenCanvas(1, 1);
// OffscreenCanvas does not have a style, so we mock it
canvas.style = {};
const context = canvas.getContext('2d');

const layers = [];
const layer = new ol.layer.Tile({
  source: new ol.source.OSM({
    transition: 0,
    tileLoadFunction: function (tile, src) {
      worker.postMessage({
        action: 'loadImage',
        src: src,
      });
      worker.addEventListener('message', (event) => {
        if (event.data.src == src) {
          tile.setImage(event.data.image);
        }
      });
    }
  }),
});
layer.getRenderer().useContainer = function (target, transform) {
  this.containerReused = this.getLayer() !== layers[0];
  this.canvas = canvas;
  this.context = context;
  this.container = {
    firstElementChild: canvas,
    style: {
      opacity: layer.getOpacity(),
    },
  };
  rendererTransform = transform;
};
layers.push(layer);

const maxTotalLoading = 8;
const maxNewLoads = 2;

const tileQueue = new ol.TileQueue(
  (tile, tileSourceKey, tileCenter, tileResolution) =>
  ol.TileQueue.getTilePriority (
      frameState,
      tile,
      tileSourceKey,
      tileCenter,
      tileResolution
    ),
  () => worker.postMessage({action: 'requestRender'})
);

worker.addEventListener('message', (event) => {

  if (event.data.action !== 'render') {
    return;
  }
  frameState = event.data.frameState;
  if (!pixelRatio) {
    pixelRatio = frameState.pixelRatio;
  }
  frameState.tileQueue = tileQueue;
  frameState.viewState.projection = ol.proj.get('EPSG:3857');
  layers.forEach((layer) => {
    if (ol.layer.Layer.inView(layer.getLayerState(), frameState.viewState)) {
      const renderer = layer.getRenderer();
      renderer.renderFrame(frameState, canvas);
    }
  });
  layers.forEach(
    (layer) => layer.getRenderer().context // && layer.renderDeclutter(frameState)
  );
  if (tileQueue.getTilesLoading() < maxTotalLoading) {
    tileQueue.reprioritize();
    tileQueue.loadMoreTiles(maxTotalLoading, maxNewLoads);
  }
  const imageData = canvas.transferToImageBitmap();
  worker.postMessage(
    {
      action: 'rendered',
      imageData: imageData,
      transform: rendererTransform,
      frameState: JSON.parse(stringify(frameState)),
    },
    [imageData]
  );
});

};

// start of main thread

function stringify(obj, replacer, spaces, cycleReplacer) {
  return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces)
}
function serializer(replacer, cycleReplacer) {
  var stack = [], keys = []
  if (cycleReplacer == null) cycleReplacer = function(key, value) {
    if (stack[0] === value) return "[Circular ~]"
    return "[Circular ~." + keys.slice(0, stack.indexOf(value)).join(".") + "]"
  }
  return function(key, value) {
    if (stack.length > 0) {
      var thisPos = stack.indexOf(this)
      ~thisPos ? stack.splice(thisPos + 1) : stack.push(this)
      ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key)
      if (~stack.indexOf(value)) value = cycleReplacer.call(this, key, value)
    }
    else stack.push(value)
    return replacer == null ? value : replacer.call(this, key, value)
  }
}
class WebWorker {
  constructor(worker) {
    const code = worker.toString();
    const blob = new Blob(["(" + code + ")()"]);
    return new Worker(URL.createObjectURL(blob));
  }
}

// create worker from inline script
const worker = new WebWorker(workerScript);

let container,
  transformContainer,
  canvas,
  rendering,
  workerFrameState,
  mainThreadFrameState;

// Transform the container to account for the difference between the (newer)
// main thread frameState and the (older) worker frameState
function updateContainerTransform() {
  if (workerFrameState) {
    const viewState = mainThreadFrameState.viewState;
    const renderedViewState = workerFrameState.viewState;
    const center = viewState.center;
    const resolution = viewState.resolution;
    const rotation = viewState.rotation;
    const renderedCenter = renderedViewState.center;
    const renderedResolution = renderedViewState.resolution;
    const renderedRotation = renderedViewState.rotation;
    const transform = ol.transform.create();
    // Skip the extra transform for rotated views, because it will not work
    // correctly in that case
    if (!rotation) {
      ol.transform.compose(
        transform,
        (renderedCenter[0] - center[0]) / resolution,
        (center[1] - renderedCenter[1]) / resolution,
        renderedResolution / resolution,
        renderedResolution / resolution,
        rotation - renderedRotation,
        0,
        0
      );
    }
    transformContainer.style.transform = ol.transform.toString(transform);
  }
}

const map = new ol.Map({
  layers: [
    new ol.layer.Layer({
      render: function (frameState) {
        if (!container) {
          container = document.createElement('div');
          container.style.position = 'absolute';
          container.style.width = '100%';
          container.style.height = '100%';
          transformContainer = document.createElement('div');
          transformContainer.style.position = 'absolute';
          transformContainer.style.width = '100%';
          transformContainer.style.height = '100%';
          container.appendChild(transformContainer);
          canvas = document.createElement('canvas');
          canvas.style.position = 'absolute';
          canvas.style.left = '0';
          canvas.style.transformOrigin = 'top left';
          transformContainer.appendChild(canvas);
        }
        mainThreadFrameState = frameState;
        updateContainerTransform();
        if (!rendering) {
          rendering = true;
          worker.postMessage({
            action: 'render',
            frameState: JSON.parse(stringify(frameState)),
          });
        } else {
          frameState.animate = true;
        }
        return container;
      },
      source: new ol.source.Source({
        attributions: [
          '<a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>',
        ],
      }),
    }),
  ],
  target: 'map',
  view: new ol.View({
    resolutions: ol.tilegrid.createXYZ({tileSize: 512}).getResolutions(),
    center: [0, 0],
    zoom: 2,
  }),
});

// Worker messaging and actions
worker.addEventListener('message', (message) => {
  if (message.data.action === 'loadImage') {
    // Image loader for ol-mapbox-style
    const image = new Image();
    image.crossOrigin = 'anonymous';
    image.addEventListener('load', function () {
      createImageBitmap(image, 0, 0, image.width, image.height).then(
        (imageBitmap) => {
          worker.postMessage(
            {
              action: 'imageLoaded',
              image: imageBitmap,
              src: message.data.src,
            },
            [imageBitmap]
          );
        }
      );
    });
    image.src = message.data.src;
  } else if (message.data.action === 'requestRender') {
    // Worker requested a new render frame
    map.render();
  } else if (canvas && message.data.action === 'rendered') {
    // Worker provides a new render frame
    requestAnimationFrame(function () {
      const imageData = message.data.imageData;
      canvas.width = imageData.width;
      canvas.height = imageData.height;
      canvas.getContext('2d').drawImage(imageData, 0, 0);
      canvas.style.transform = message.data.transform;
      workerFrameState = message.data.frameState;
      updateContainerTransform();
    });
    rendering = false;
  }
});

    </script>
  </body>
</html>