Skip to content

Map Engines

joric edited this page Feb 26, 2025 · 94 revisions

This article is about map engines (Leaflet, etc.). For the game engines see Tools.

Overview

There are a few usable map engines. Most of them use standard geographical CRS (Coordinate Reference System, or Spatial Reference System), by default. Game maps require either transforming all the coordinates or customizing the map CRS.

Leaflet

Leaflet uses an obsolete tile size 256 by default, so set the layer's tileSize option to 512 first. Then set layer's maxNativeZoom to your tileset. Basically, if you're using L.CRS.Simple, then your default coordinate grid is always 512x512, because it's the actual map at zoom level 0. Then you can adjust crs to match the dimensions. Sadly you can't swap x and y.

// w, h, x, y are map size and center
let factor = 1 / tileSize;
let dx = tileSize * (0.5 - x / w);
let dy = tileSize * (0.5 - y / w);
let crs = L.CRS.Simple;
crs.transformation = new L.Transformation(factor, dx, (flip_y ? -1 : 1) * factor, dy);

The flip_y variable depends on the game, CRS.Simple assumes that Y grows down from the left top corner.

  • In the Unreal Engine you get a system where X grows right (from top left), Y grows down, so it matches Simple CRS.
  • In the Creation Engine (Bethesda) X grows right (from the center), Y grows up, so centering and flip_y is needed.

Setting CRS in Leaflet

  let d = getMapSize(mapId); // map size in game units (e.g. map width in centimeters)

  let m = {
    size: d,
    bounds: [[0,0], [d-1,d-1]],
    center: [d/2, d/2],
    flip_y: false,
  }

  let tileSize = 512; // this is essentially map size in tiles on zoom level 0
  let size = tileSize*tileSize; // default map width in pixels is tileSize squared

  // custom map weighted (scaled) dimensions (usually 0..1)
  let [sw,sh] = [m.size, m.size].map(e => e / size);
  let [sx,sy] = m.center.map(e => e / size);

  // calculate factor and offset for the custom map
  let factor = 1 / tileSize / sw;
  let dx = (.5 - sx / sw) * tileSize;
  let dy = (.5 - sy / sh) * tileSize;

  // correct bounds (still need to flip x and y for leaflet)
  let [w,h] = [m.size, m.size];
  let [x,y] = m.center;

  let [[left,top],[right,bottom]] = m.bounds;
  let bounds = [[top, left], [bottom, right]];
  let center = [(m.flip_y ? -1 : 1) * y, x];

  // crs.transformation represents an affine transformation:
  let crs = L.CRS.Simple;
  // a set of coefficients a, b, c, d for transforming a point of a form (x,y) into (a*x + b, c*y + d)
  crs.transformation = new L.Transformation(factor, dx, (m.flip_y ? -1 : 1) *  factor, dy); // Invert the y-axis

  map = L.map('map', {
    crs: crs,
    zoom: 1, // mandatory, need to set zoom and center first (c)
    center: center, // mandatory
    maxBounds: L.latLngBounds(bounds).pad(0.5), // elastic-y bounds, nice to have
  });

  L.tileLayer('https://joric.github.io/stalker2_tileset/tiles/{z}/{x}/{y}.jpg', {
    tileSize: 512,
    maxNativeZoom: 7,
    bounds: bounds, // mandatory to hide 404 errors
  }).addTo(map);

Drawing a lot of markers would need an OpenGL layer (e.g. Leaflet.PixiOverlay) or Leaflet.markercluster.

PixiOverlay API is pretty simple but the performance is bad, my OpenGL setup stuttered at 50k+ markers (canvas renderer becomes unusable at about 5k markers).

MarkerCluster plugin is pretty good. On changes it's faster to reload all markers using the chunkedLoading option.

Maptalks

Setting CRS in Maptalks

  let w = h = 812900;
  let center = [w/2, h/2];
  let [left,top,right,bottom] = [0, 0, w, h];
  let tileSize = 512;
  let maxZoom = 19;

  map = new maptalks.Map('map', {
    center: center,
    maxZoom: maxZoom,
    spatialReference : {
      projection : 'identity',
      fullExtent : { top: top, left: left, bottom: bottom, right: right }, // mandatory to hide 404 errors
      resolutions: Array.from({length: maxZoom + 1},(_,i) => w / tileSize / (1<<i)), // mandatory zoom levels
    },
    // ...
  });

  new maptalks.TileLayer('PDA', {
    maxAvailableZoom: 7,
    urlTemplate: 'https://joric.github.io/stalker2_tileset/tiles/{z}/{x}/{y}.jpg',
    repeatWorld: false,
    tileSize: 512,
  }).addTo(map);

Maptalks introduces OpenGL layers (100k+ markers at once). To add OpenGL image markers simply use PointLayer.

See https://github.com/maptalks/maptalks.js/issues/2486:

const layer = new PointLayer('points', [markers]).addTo(map);
  • Everything is almost the same with rendering markers with VectorLayer
  • except PointLayer only support markers or MultiPoints and several additional symbols
  • so we have another two types of layers: LineStringLayer and PolygonLayer for lines and polygons
  • Currently, every webgl layers has an independent webgl context , so the total number of webgl layers you can create is limited by browser.
    • you can add webgl layers to a container layer called GroupGLLayer to let them share the webgl context and depth buffer
    • in the coming [email protected] version, all the layers will share one global webgl context, so number of layers will not be limited and performance will be significantly improved.
const group = new GroupGLLayer('group', []).addTo(map);
const layer = new PointLayer('points', [markers]).addTo(group);

Other

Mapbox

Proprietary commercial license, wants your credit card for the API key.

Maplibre

JSON-based config. Doesn't seem to support CRS customization (#3890).

References

Clone this wiki locally