
Cesium 行政区反向遮罩的屏幕空间实现
解析如何把行政区 GeoJSON 投影到屏幕 canvas,用 evenodd 填充规则实现地图压暗与行政区透明洞口。
Kevin
这篇文章解决什么问题
行政区遮罩常见于指挥调度、态势感知和专题地图页面:业务关注区保持清晰,区域外统一压暗,再用高亮边界告诉用户当前视野的主角在哪里。
这个效果看起来像是在地图上挖了一个洞,但它不一定适合用真实地理裁剪来做。这里的目标只是视觉表达:不裁剪瓦片、不改造底图、不参与空间分析,也不要求区域外数据真的被过滤。
因此这个 Demo 最终采用屏幕空间反向遮罩:在 Cesium 画布上方叠加一个透明 canvas,每帧把 GeoJSON 面投影到屏幕坐标,再用 evenodd 规则绘制“全屏遮罩 + 行政区洞口”。地图本身照常渲染,遮罩只负责改变用户看到的视觉层次。
核心难点
第一个难点是遮罩应该跟着相机变化。行政区边界存在于经纬度空间,但屏幕遮罩画在 DOM canvas 上。相机缩放、旋转、飞行之后,GeoJSON 点必须重新投影到当前屏幕坐标,否则洞口会和真实行政区错位。
第二个难点是面数据的结构。GeoJSON 里既可能是 Polygon,也可能是 MultiPolygon;一个面还可能有内洞。遮罩需要只消费面要素,并跳过线、点等非面几何,避免把业务数据里的其他几何误当成遮罩。
第三个难点是边界观感。单条 cyan 线很容易显得薄,图形化大屏里更常见的是外层发光加内层亮线。这个 Demo 使用两层 Cesium polyline:外层负责氛围发光,内层负责轮廓锐度。
实现思路
整体链路可以分成四步。
第一步,解析行政区 GeoJSON。实现支持 FeatureCollection、Feature 和 Geometry 输入,但只收集 Polygon / MultiPolygon,并把每个 ring 规范成可投影的经纬度点列。
第二步,创建屏幕遮罩 canvas。canvas 挂在 viewer.container 上方,pointer-events 设为 none,让它不影响 Cesium 原有交互;同时监听 scene.postRender,在 Cesium 每次渲染后重画遮罩。
第三步,把经纬度投影到屏幕坐标。每个点先转成 Cartesian3,再通过 Cesium 的场景转换 API 得到当前窗口坐标,最终写入 canvas path。
第四步,用 evenodd 填充。先画一个覆盖全屏的矩形,再画行政区 ring。canvas 使用奇偶填充规则后,外部会被遮罩色填充,行政区内部则成为透明洞口。
技术流程图
正在渲染流程图...
关键代码解析
GeoJSON 解析阶段只收集面几何。这样同一份行政区数据里即使带有辅助线、标注点或其他几何,也不会影响遮罩生成。
function collectPolygonCoordinates(input, target, stats) {
if (input.type === 'Polygon') {
target.push(input.coordinates)
return
}
if (input.type === 'MultiPolygon') {
const polygons = Array.isArray(input.coordinates) ? input.coordinates : []
target.push(...polygons)
return
}
stats.skippedGeometryCount += 1
}
屏幕 canvas 的关键不在样式,而在它和 Cesium 渲染循环的绑定。遮罩每帧根据当前相机重新计算,因此它不会在飞行或缩放后停留在旧位置。
const canvas = document.createElement('canvas')
Object.assign(canvas.style, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
pointerEvents: 'none'
})
container.appendChild(canvas)
this.screenContext = canvas.getContext('2d')
this.screenRenderCallback = () => {
this.drawScreenMask()
}
this.viewer.scene.postRender.addEventListener(this.screenRenderCallback)
投影阶段需要兼容 Cesium 版本差异。当前实现优先使用 worldToWindowCoordinates,如果项目里仍有旧版本 Cesium,也保留后备转换路径。
function worldToWindowCoordinates(Cesium, scene, cartesian) {
const sceneTransforms = Cesium.SceneTransforms
if (typeof sceneTransforms?.worldToWindowCoordinates === 'function') {
return sceneTransforms.worldToWindowCoordinates(scene, cartesian)
}
if (typeof scene.cartesianToCanvasCoordinates === 'function') {
return scene.cartesianToCanvasCoordinates(cartesian)
}
return null
}
真正形成“外部压暗、内部透明”的是 evenodd。先把整屏矩形写入 path,再写入行政区边界;填充时 canvas 会把重叠次数为奇数的区域涂色,行政区内部则被反向镂空。
context.clearRect(0, 0, size.width, size.height)
context.beginPath()
context.rect(0, 0, size.width, size.height)
for (const polygon of this.parsedPolygons) {
this.drawProjectedRing(polygon.outer.points)
for (const hole of polygon.holes) {
this.drawProjectedRing(hole.points)
}
}
context.fillStyle = colorToCssRgba(this.options.maskColor)
context.fill('evenodd')
边界没有画在 canvas 里,而是继续交给 Cesium entity。这样线宽、发光和贴地显示可以利用 Cesium 的 polyline 能力,也能跟地图深度关系保持一致。
polyline: {
positions: this.createBoundaryPositions(points),
width,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower,
color
}),
clampToGround: true
}
效果与边界
这个方案最适合图形化大屏和业务专题图:区域内保持原始地图清晰度,区域外用统一色调压暗,边界通过 cyan 发光线加强识别。遮罩颜色、透明度、边界宽度和发光强度都可以作为视觉参数暴露给业务侧。
它的边界也很明确。屏幕遮罩不是地理裁剪,不会阻止用户点击区域外对象,也不会减少底图或瓦片加载。它只改变最终画面,因此实现成本低,和底图、地形、影像源之间的耦合也低。
另外,屏幕空间方案依赖当前相机投影。俯视地图场景下效果最稳定;如果相机大幅倾斜、行政区跨过地平线,屏幕投影得到的是当前可见画面里的形状,而不是严格的二维制图投影。
小结
这类行政区遮罩的核心不是把 Cesium 地图真的裁开,而是把空间数据转换成屏幕上的反向镂空路径。
GeoJSON 负责提供行政区边界,Cesium 负责把经纬度点投影到当前窗口坐标,canvas 负责用 evenodd 填充规则生成透明洞口,polyline 负责提供可视化边界强调。
当需求只是“突出一个行政区、压暗其他区域”时,屏幕空间遮罩比真实地表挖洞更直接,也更容易做出图形化大屏里常见的 HUD 视觉效果。