MapLibre GL 实战:一套引擎搞定 2D 平面与 3D 球面

适合已经会 Vue / 原生 JS,想在 Web 里做「可交互地图」,并且需要 平面底图 + 球面浏览 的同学。文中 API 基于 MapLibre GL ^5.x


写在前面:这里的「3D」是什么

做地图产品时,很多人一听到「3D」会想到:

  • DEM 地形抬升(setTerrain
  • 建筑物拔高(fill-extrusion
  • 天空 / 大气雾效(sky / fog)

这些 MapLibre 都能做,但不是本文讨论的路径

本文说的 2D / 3D 是:

模式 投影 观感
2D mercator 传统平面瓦片世界
3D globe 地球仪球面,可俯仰观察

同一张 Map 实例,只切投影,不销毁重建;覆盖物仍是普通 GeoJSON 的 fill / line / circle / symbol,贴在平面或球面上。

这种方案的优点很直接:

  1. 一套图层栈覆盖两种视图,不用维护两套引擎。
  2. 切换成本低setProjection + 少量布局属性同步。
  3. 几何语义统一:数据永远是 WGS84,命中检测也能共用。

一、最小可用的 Map 初始化

建议把 maplibre-gl 动态 import,避免首屏把 WebGL 库塞进主包:

ts 复制代码
import type { Map as MapLibreMap } from 'maplibre-gl'

let map: MapLibreMap | null = null

async function createMap(container: HTMLElement) {
  const maplibregl = await import('maplibre-gl')
  await import('maplibre-gl/dist/maplibre-gl.css')

  map = new maplibregl.Map({
    container,
    style: {
      version: 8,
      sources: {
        osm: {
          type: 'raster',
          tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
          tileSize: 256,
          attribution: '© OpenStreetMap',
        },
      },
      layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
      // symbol 文字需要 glyphs;演示可先用官方 demo 字体
      glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
    },
    center: [116.4, 39.9],
    zoom: 4,
    minZoom: 3,
    maxZoom: 18,
    attributionControl: false,
    fadeDuration: 0,   // 业务交互多时可关掉淡入,减少「闪一层」的感觉
    dragRotate: false, // 右键留给自己的上下文菜单时很有用
  })

  await new Promise<void>((resolve, reject) => {
    map!.once('load', () => resolve())
    map!.once('error', (e) => reject(e.error ?? new Error('MapLibre load failed')))
  })

  // 默认进球面;若只要平面,改成 mercator 即可
  map.setProjection({ type: 'globe' })
  map.setMaxPitch(85)

  return map
}

几个实践细节:

  • loadsetProjection / addSource,否则容易踩「style 还没好」的时序问题。
  • 用单例或 pendingInit Promise 防抖,避免 Vue onMounted + 热更新导致重复 new Map
  • dragRotate: false 很常见:球面浏览主要靠 pitch / pan,右键旋转和业务菜单容易冲突。

二、2D ↔ 3D:同一实例切投影

核心就几行:

ts 复制代码
type ViewMode = '2d' | '3d'

function setViewMode(map: MapLibreMap, mode: ViewMode) {
  if (mode === '3d') {
    map.setProjection({ type: 'globe' })
  } else {
    map.setProjection({ type: 'mercator' })
    // 回到平面时把俯仰、朝向掰正,避免留下「歪着看世界」的残态
    map.easeTo({ pitch: 0, bearing: 0, duration: 0 })
  }
}

配套建议:

  1. UI 状态单独存 (如 viewMode: '2d' | '3d'),地图只做执行层。
  2. 切投影后刷新依赖投影的表现 (标注 pitch 对齐、可见性 declutter、路径线宽手感等),但不必重装整份 GeoJSON------几何没变。
  3. 容器加 CSS class(--2d / --3d)方便做光标、遮罩等样式差异。

架构可以简化成:

text 复制代码
Toolbar 切换 viewMode
    ↓
Map 编排层 watch
    ↓
setProjection(globe | mercator)
    + easeTo 复位(仅回 2D)
    + 同步 symbol 的 pitch-alignment
    + 按需刷新标注/交互半径

三、底图用 Raster,业务层用 GeoJSON

很多业务地图并不需要完整矢量底图样式,更常见的是:

text 复制代码
Style Spec v8
├── raster 底图(OSM / 卫星 / 地形影像瓦片)
└── 业务 overlay(GeoJSON → fill / line / circle / symbol)

叠加层初始化大致如下:

ts 复制代码
function initOverlaySources(map: MapLibreMap) {
  map.addSource('polygons', {
    type: 'geojson',
    data: { type: 'FeatureCollection', features: [] },
    promoteId: 'id', // 配合 feature-state 做高亮
  })

  map.addLayer({
    id: 'polygons-fill',
    type: 'fill',
    source: 'polygons',
    paint: {
      'fill-color': [
        'case',
        ['boolean', ['feature-state', 'hover'], false],
        '#3b82f6',
        '#60a5fa',
      ],
      'fill-opacity': 0.35,
    },
  })

  map.addLayer({
    id: 'polygons-outline',
    type: 'line',
    source: 'polygons',
    paint: {
      'line-color': '#1d4ed8',
      'line-width': 1.5,
    },
  })
}

// 后续只更新数据
function setPolygons(map: MapLibreMap, fc: GeoJSON.FeatureCollection) {
  const src = map.getSource('polygons') as maplibregl.GeoJSONSource
  src.setData(fc)
}

高亮 优先用 setFeatureState,避免每次 hover 重写整份 setData

ts 复制代码
map.setFeatureState({ source: 'polygons', id: featureId }, { hover: true })

运行时换底图:删旧 raster layer/source → 在业务层 之前 addSource / addLayertriggerRepaint(),保证业务图元始终叠在底图上面。


四、Globe 上最容易踩的坑:球外像素

平面模式下,画布内任意点 unproject 通常都合理;球面模式下,画布四角经常是「太空」,不是地表。

如果直接信 mousemove 事件里的 lngLat,或对球外点做查询,会出现:

  • 悬停落在地平线外却「命中」某个要素
  • 点击空白宇宙却触发选中

稳妥写法是:先判定像素是否在地球表面上,再反投影:

ts 复制代码
import { Point } from 'maplibre-gl'

function isPixelOnMapSurface(map: MapLibreMap, x: number, y: number): boolean {
  if (map.getProjection()?.type !== 'globe') return true
  try {
    // 内部 transform API:判断该像素是否落在球体可见面
    return map.transform.isPointOnMapSurface(new Point(x, y))
  } catch {
    return false
  }
}

function pickLonLat(map: MapLibreMap, x: number, y: number): { lon: number; lat: number } | null {
  if (!isPixelOnMapSurface(map, x, y)) return null
  const { lng, lat } = map.unproject([x, y])
  if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
  return { lon: lng, lat }
}

交互流建议:

text 复制代码
pointer → 是否在球面上?
        → unproject 得经纬度
        → 用 WGS84 几何做命中(Turf / 自研空间索引)
        → setFeatureState / 高亮 GeoJSON 反馈

对于大面积多边形 ,纯靠 queryRenderedFeatures 往往不够稳(堆叠顺序、半透明 fill、2D 世界拷贝等)。在经纬度里做 hit-test,2D / 3D 还能共用同一套几何判断。

点要素(航点、POI)可以反着来:先 project 到屏幕,再算像素距离;2D / 3D 拾取半径也可以不同(例如平面 28px、球面 22px),手感会更自然。


五、标注在球面与平面上要「两种对齐」

symbol 层在 globe 下若仍按 viewport 对齐,俯仰时文字会「立在屏幕上」;很多场景更希望文字贴着球面倾斜

投影切换时同步布局属性即可:

ts 复制代码
function syncLabelPitchAlignment(map: MapLibreMap, layerId: string) {
  const globe = map.getProjection()?.type === 'globe'
  const align = globe ? 'map' : 'viewport'

  map.setLayoutProperty(layerId, 'text-pitch-alignment', align)
  map.setLayoutProperty(layerId, 'text-rotation-alignment', align)
  map.setLayoutProperty(layerId, 'icon-pitch-alignment', align)
  map.setLayoutProperty(layerId, 'icon-rotation-alignment', align)
}

额外经验:

  • 背景色尽量用 预生成的 addImage + icon-text-fit ,比用经纬度 fill 多边形当「文字底板」更稳------后者缩放时要跟着屏幕尺寸重算,容易和文字不同步。
  • 低缩放级别务必做 屏幕空间 declutter(投影到像素后做矩形碰撞),否则全球一缩,标注糊成一片。
  • Globe 总览时瓦片 / 要素密度体感会变,可按纬度修正等效 zoom,再决定「本视口最多画多少要素」。

六、相机与布局:常用动作清单

需求 API
平滑飞到目标 flyTo({ center, zoom, duration })
瞬时复位视角 jumpTo({ center, zoom }) / easeTo({ pitch, bearing, duration: 0 })
框住某个 bbox cameraForBounds → 再 flyTo,或直接 fitBounds
工具条缩放 zoomTo(map.getZoom() ± 1, { duration: 200 })
侧栏挤占可视区 setPadding({ left/right/... }),让地球「视觉中心」落在未遮挡区域
布局变化后 resize()v-show、分栏拖拽后必做)
绘制工具进行中 临时 dragPan.disable() / scrollZoom.disable(),避免和画多边形抢事件

configure 阶段常见组合:

ts 复制代码
map.setMaxPitch(85)
map.setMinZoom(3)
map.setMaxZoom(18)
map.dragRotate.disable()

注意:若产品状态只持久化 center + zoom,切回 3D 时 pitch 不会自动恢复------这通常是预期行为;需要「记住俯仰角」要另存字段。


七、性能:别让建索引抢走首帧

MapLibre 自己已经用 Worker 拉瓦片,但业务侧如果还要:

  • 解析大批量覆盖面
  • 建空间网格
  • 预烘焙 Feature / 锚点

请放到 应用自己的 Web Worker ,主线程优先把地图 load 出来。

推荐顺序:

  1. 动态加载 MapLibre,创建 Map,跑通底图。
  2. Worker 里烘焙 GeoJSON / 索引。
  3. setData 增量喂给 overlay。
  4. 视口变化时用空间索引取「当前该画的子集」,控制 setData 频率(可挂在 moveend / idle,移动中可跳过重计算)。

Globe 低缩放时尤其要做 LOD / 数量上限,否则一球要素把 GPU 和主线程一起打满。


八、一张图串起来

text 复制代码
┌─────────────┐
│  UI:2D/3D  │
└──────┬──────┘
       ▼
┌──────────────────────────────┐
│ MapLibre Map(单例)          │
│  style: raster 底图           │
│  projection: mercator|globe  │
│  overlays: GeoJSON 图层栈     │
└──────────────┬───────────────┘
               ▼
┌──────────────────────────────┐
│ 交互:像素 → 球面校验 → 经纬度 │
│      → WGS84 命中 → 视觉反馈  │
└──────────────────────────────┘
               ▲
┌──────────────┴───────────────┐
│ Worker:重数据预处理 / 索引    │
└──────────────────────────────┘

设计原则可以压成五句:

  1. 单引擎双投影,不要为了 3D 再挂一个引擎。
  2. 3D = globe,先把球面交互做对,再考虑 terrain / extrusion。
  3. 数据永远是 WGS84,渲染跟投影走,命中逻辑尽量共用。
  4. 球外像素一律视为无效 ,再 unproject
  5. 重计算进 Worker,轻 setData 进 MapLibre

九、和「真 · 三维 GIS」怎么选

目标 更合适的方向
地球仪感、全球浏览、2D↔3D 切换 globe 投影(本文)
山地起伏、等高、剖面 terrain + DEM
城市白模、楼块高度 fill-extrusion / 矢量建筑数据
复杂三维场景、模型编辑 Three.js / Cesium 等(成本完全另一档)

很多产品其实只需要「能把世界看成球,并在上面点选面要素」------这时 MapLibre Globe 往往够用,也更贴近 2D Web 地图的工程习惯。


小结

MapLibre 5.x 把 Mercator / Globe 收敛到同一套 Style 与图层模型里,让「2D + 3D」不必拆成两套技术栈。落地时真正拉开差距的,往往不是 new Map 那几行,而是:

  • 投影切换后的 姿态复位与标注对齐
  • Globe 下的 球外拾取防护
  • 大批量 overlay 的 Worker + 视口裁剪
  • setPadding / resize / 交互锁这些「工程边角」

如果你正从 Mapbox / Leaflet 迁到 MapLibre,优先把 投影切换 + 统一经纬度命中 跑通;地形抬升和建筑拔高,可以放进下一迭代。


参考


相关推荐
山河木马2 天前
GPU自动处理专题4-填充覆盖区域(光栅化)
javascript·webgl·计算机图形学
智商偏低2 天前
WebGL 百万线段高性能 Demo(批量绘制,无循环绘制)
webgl
weixin_436804074 天前
毒蘑菇性能测试增强版 - 毒蘑菇性能测试 - 在线 WebGL GPU 帧率压力测试工具
webgl
山河木马5 天前
GPU自动处理专题3-NDC怎么映射成屏幕像素坐标(视口变换)
javascript·webgl·图形学
山河木马5 天前
GPU自动处理专题2-NDC是什么(透视除法)
javascript·webgl·计算机图形学
山河木马6 天前
GPU自动处理专题1-裁剪到底在裁什么(裁剪)
前端·webgl·计算机图形学
山河木马7 天前
渲染管线-计算得到gl_FragColor(片元着色器)之后续GPU流程
前端·webgl·计算机图形学
TMAH7 天前
OpenLayers 实现网格色斑图:WebGL 叠加层方案
webgl
熊猫钓鱼>_>7 天前
2026 前端的三场底层革命:当 Vue、React Native 和 Three.js 同时去掉中间层
前端·javascript·vue.js·react native·架构·webgl·three.js