上篇结尾我留了这么一句话:
"下一篇我们接着往回收------这次不是遮罩,而是把两块影像拼在同一屏上左右对比。地图卷帘控件,我们下一篇见。"
上一篇 #64 我们做了一件"没有 Cesium 参与"的事:一层黑布、五个光点、一个 2.2 秒的定时器。它不碰 WebGL,也不问地球加载到哪了。
今天这个控件正好反过来------它几乎是纯 Cesium 的 :卷帘的分割不是靠裁剪 DOM,而是靠逐片元丢弃。屏幕上那条 5 px 的线只是个"把手",真正干活的是着色器。
大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。
写这篇前我又干了一件傻事:点了"关闭卷帘",满心以为会回到正常的 Bing 底图,结果屏幕一整片变成了高德。那一刻我才意识到,"关闭"这个词在这个控件里,关掉的其实是另一侧。
本文目标 :把卷帘的三件套(splitDirection / splitPosition / 逐片元 discard)说清楚,再顺着那条能拖的分割线,看看它的坐标是怎么换算的、滑块偏了多少像素,以及"关闭卷帘"到底关掉了什么。💪
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:为什么要让两块底图同屏
🎯 背景现状:影像对比不是炫技,是刚需------时态变化、多源校核、规划前后,全靠"左边一张、右边一张"摆在一起看。而如果做成两个窗口,用户就得靠记忆对齐视角,误差全在脑子里。
典型场景里,它要对付的是这些:
- 🕐 时态对比:灾前 / 灾后、去年 / 今年,同一块地的变迁
- 🛰️ 多源校核:卫星影像 vs 街道图,看道路与建筑对不对得上
- 🏗️ 规划前后:现状影像 vs 规划总平,一屏说服甲方
- 🗺️ 矢量与影像叠加:一边看地名路网,一边看真实地貌
- 🔍 变化检测人工复核:算法标出的图斑,靠卷帘快速抽查
🤦 传统痛点:做法不少,各有各的别扭。
| 方案 | 问题 |
|---|---|
| 开两个窗口对比 | 视角、缩放各自独立,靠人脑对齐,误差说不清 |
| 用透明度叠两层 | 两层彼此"洗掉"细节,颜色互相污染,看不出变化 |
| 自己写 DOM 裁剪 | 只能裁 DOM 层,裁不了 WebGL 画布里的地球 |
| 反复切换图层显隐 | 切换之间有时间差,变化靠"闪"来感知,最容易看漏 |
💡 本文方案:Cesium 原生给的卷帘三件套:
1️⃣ 两侧各挂一个方向 :imageryLayer.splitDirection = SplitDirection.LEFT / RIGHT
2️⃣ 一个分割位置 :scene.splitPosition(0~1 的相对值)
3️⃣ 着色器逐片元丢弃 :不属于自己那一侧的像素直接 alpha = 0
✨ 一句话总结:真正干活的是着色器里的一次 discard,屏幕上那条线和那个圆把手,只是给鼠标一个能抓的地方。
二、核心技术原理:三件套与那条 5 px 的线
2.1 三件套:两个方向 + 一个位置
先把枚举值钉死------它和直觉是反的 (我把源码翻出来确认过,Scene/SplitDirection.js):
| 常量 | 数值 | 含义 |
|---|---|---|
SplitDirection.LEFT |
−1.0 | 只显示在分割线左侧 |
SplitDirection.NONE |
0.0 | 永远显示(默认) |
SplitDirection.RIGHT |
1.0 | 只显示在分割线右侧 |
注意 LEFT 是负数。这条后面会用到。
demo 里的挂法也很直白:
ts
// 源码级剧透:WidgetMapSplitDemo.vue
loadBingImagery(viewer, sceneCallbacks, (layer) => {
layer.splitDirection = SplitDirection.RIGHT // 右侧 = Bing 影像
})
// ...
compareLayer = viewer.imageryLayers.addImageryProvider(provider)
compareLayer.splitDirection = SplitDirection.LEFT // 左侧 = 高德街道 / 影像
viewer.scene.splitPosition = viewer.scene.splitPosition || 0.5
2.2 分割靠的是逐片元丢弃,不是裁剪
这是本篇最该记住的一点。我把 GlobeFS.glsl 里那几行挖了出来(L245-253):
glsl
#ifdef APPLY_SPLIT
float splitPosition = czm_splitPosition;
// Split to the left
if (split < 0.0 && gl_FragCoord.x > splitPosition) {
alpha = 0.0;
}
// Split to the right
else if (split > 0.0 && gl_FragCoord.x < splitPosition) {
alpha = 0.0;
}
#endif
读法:
split < 0.0(也就是LEFT = -1)→ 当像素的 x 大于 分割位置就把 alpha 打成 0 → 只在左边活着split > 0.0(RIGHT = +1)→ 当 x 小于 分割位置就打 0 → 只在右边活着
而 czm_splitPosition 是相对值换算出来的绝对像素:Renderer/UniformState.js 里是 splitPosition × drawingBufferWidth。所以 scene.splitPosition 是一个 0~1 的比例,和屏幕分辨率无关。
🔑 顺带一个默认值上的坑:FrameState.js 里 this.splitPosition = 0.0------默认是 0,不是 0.5 。也就是说,如果你只给图层设了 LEFT/RIGHT 却忘了设 splitPosition,右侧图层会占满、左侧一个像素都看不见。(#12 那篇已经提醒过一次,这里再说一遍,因为它实在太容易踩。)

2.3 拖动链路:坐标是"相对把手"的
拖动手感全在这几行:
ts
function moveHandler(movement: { endPosition: { x: number } }): void {
const relativeOffset = movement.endPosition.x
const splitPosition =
(splitterWrap.value.offsetLeft + relativeOffset) / splitterWrap.value.parentElement!.offsetWidth
splitterWrap.value.style.left = `${100 * splitPosition}%`
viewer.scene.splitPosition = splitPosition
}
关键在于 movement.endPosition.x 到底是什么坐标。我去翻了 Core/ScreenSpaceEventHandler.js 的 getPosition(L11-20):
js
function getPosition(screenSpaceEventHandler, event, result) {
const element = screenSpaceEventHandler._element;
if (element === screenSpaceEventHandler._canvas) {
result.x = event.clientX; // 画布:直接用客户端坐标
} else {
const rect = element.getBoundingClientRect();
result.x = event.clientX - rect.left; // 其它元素:减去元素左边,得到「相对元素」的坐标
}
}
- 🔑 把手(
.cesium-slider)不是 canvas ,所以拿到的是相对把手自身的 x------鼠标在它左边就是负数,右边可以远大于 5 - 🔑 于是
offsetLeft + relativeOffset正好把"相对把手"换算回"相对父容器",再除以父容器宽度就是比例------这行是对的 - ⚠️ 但它没有夹取(clamp) :鼠标拖出容器,
splitPosition会变成负数或大于 1,把手也会跟着left: -30%之类跑出屏幕
另外,demo 里把把手元素强转成了 HTMLCanvasElement:
ts
handler = new ScreenSpaceEventHandler(splitterWrap.value as unknown as HTMLCanvasElement)
从上面的源码看,ScreenSpaceEventHandler 接受任意 element,这个 as unknown as HTMLCanvasElement 纯属为了满足类型,运行时没有任何作用。
2.4 滑块几何:圆心其实偏了 2.5 px
把手和线的尺寸都在 CSS 里:
css
.cesium-slider { position: absolute; top: 0; bottom: 0; left: 50%;
z-index: 20; width: 5px; background-color: #d3d3d3; }
.splitter { position: absolute; left: -21px; top: calc(50% - 22px);
width: 42px; height: 42px; border-radius: 50%; ... }
把数字代进去算一下:
- 水平 :线宽 5 px,把手宽 42 px。要让把手居中压在线上 ,偏移量应是
−(42 − 5) / 2 = −18.5 px,代码写的是 −21 px → 把手中心落在线的左边缘 ,比线的中心偏左 2.5 px - 垂直 :把手高 42 px,居中应是
50% − 21px,代码写的是50% − 22px→ 偏上 1 px
这两个偏移肉眼几乎看不出来,但一旦你要让"线的位置"和"鼠标抓的位置"严格对齐(比如做吸附),这 2.5 px 就会跳出来咬人。

2.5 「关闭卷帘」关掉的是哪一侧
这是本篇最反直觉的一处。看 onEnableChange:
ts
if (value) {
splitterWrap.value.style.display = 'block'
viewer.scene.splitPosition = 0.5
} else {
splitterWrap.value.style.display = 'none'
viewer.scene.splitPosition = 1 // ⚠️ 注意是 1
}
把 splitPosition = 1 代回 2.2 那两行着色器:
split < 0(左侧图层 )→ 只在x ≤ 分割位置时存活;分割位置在最右边 → 整屏都是它split > 0(右侧图层 )→ 只在x ≥ 分割位置时存活;分割位置在最右边 → 整屏都不满足,全被丢掉
所以"关闭卷帘"不是回到 Bing 底图,而是:
| 左侧对比底图 | 点"关闭卷帘"之后 |
|---|---|
| 高德街道图 / 影像图 | 整屏变成高德,Bing 影像彻底消失 |
| 选「无」(没有左侧图层) | 所有影像都被丢弃,只剩地球底色 |
🔑 真想要"关闭后回到底图",应该是 splitPosition = 0(右侧图层占满),或者干脆把图层的 splitDirection 改回 SplitDirection.NONE。

2.6 那个 || 0.5 让 0 永远设不进去
ts
viewer.scene.splitPosition = viewer.scene.splitPosition || 0.5
splitPosition 为 0 时是 falsy ,所以这行会把 0 换成 0.5。后果:你没法通过这行把分割位置设成 0 (虽然手动拖到最左还是可以的)。判断该写成 ?? 或者直接判 undefined。
2.7 顺带一句:图层顺序
addImageryProvider 是往上叠 的,所以后加的高德在 Bing 之上。本例里两层分处左右、互不重叠,顺序无所谓;但如果你哪天把两层都设成 NONE,盖在上面的就是后加的那一层。
2.8 卷帘不只属于影像
我顺着 czm_splitPosition 这个 uniform 搜了一遍着色器目录,发现它出现在不止一处:
| 着色器 | 管的是 |
|---|---|
GlobeFS.glsl(L245) |
地球表面------影像与地形 |
BillboardCollectionFS.glsl(L129-133) |
billboard------#62 那些文字标签 |
Shaders/Model/ModelSplitterStageFS.glsl |
模型------3D Tiles 也会走这一步 |
而 Cesium3DTileset 自己就带着 splitDirection 属性(Cesium3DTileset.js L927,默认 SplitDirection.NONE)。
- 🔑 也就是说:同一个
scene.splitPosition可以同时切开影像、文字标签和 3D Tiles------不是只有底图能卷 - 🔑 判断条件也一模一样:
split < 0管左边、split > 0管右边,都拿gl_FragCoord.x比 - ⚠️ 但每个对象要自己设
splitDirection:影像设了、模型没设,模型就不会跟着卷------它默认是NONE(永远显示)
三、代码实战:关键实现拆解
⚠️ 温馨提示 :demo 用的是 Bing 影像 + 高德瓦片(UrlTemplateImageryProvider)。换成你自己的服务时,URL 模板、subdomains、maximumLevel 都要跟着改,但"两侧各挂一个方向 + 一个分割位置 + 一条可拖的线"这条主线一字不差。
3.1 给两侧各挂一个方向
ts
const provider = new UrlTemplateImageryProvider({
url, // 高德 style=7 街道 / style=6 影像
subdomains: ['1', '2', '3', '4'],
maximumLevel: 18
})
compareLayer = viewer.imageryLayers.addImageryProvider(provider)
compareLayer.splitDirection = SplitDirection.LEFT
- 🔑
splitDirection是逐图层属性:想换边,改图层的这个字段即可,不用重建 - ⚠️
maximumLevel: 18是硬上限:拉到比这更近就没有瓦片了,那一侧会糊成一片 - ⚠️ 第三方瓦片服务有坐标系差异:高德是 GCJ-02,与 WGS84 底图叠加会有百米级偏移。做严格校核时这点必须先处理
3.2 把鼠标接上去
ts
handler = new ScreenSpaceEventHandler(splitterWrap.value as unknown as HTMLCanvasElement)
handler.setInputAction(() => { moveActive = true }, ScreenSpaceEventType.LEFT_DOWN)
handler.setInputAction(() => { moveActive = true }, ScreenSpaceEventType.PINCH_START)
handler.setInputAction(moveHandler, ScreenSpaceEventType.MOUSE_MOVE)
handler.setInputAction(moveHandler, ScreenSpaceEventType.PINCH_MOVE)
handler.setInputAction(() => { moveActive = false }, ScreenSpaceEventType.LEFT_UP)
handler.setInputAction(() => { moveActive = false }, ScreenSpaceEventType.PINCH_END)
- 🔑
moveActive这道闸:只有按下之后才响应移动,避免鼠标随便路过就把分割线推走 - 🔑 同时接了 PINCH:触屏双指也能拖
- ⚠️
onBeforeUnmount里要handler.destroy():忘了的话,事件残留在已销毁的元素上
3.3 开关:别忘了它设的是 1
ts
function onEnableChange(value: boolean): void {
if (value) {
splitterWrap.value.style.display = 'block'
viewer.scene.splitPosition = 0.5
} else {
splitterWrap.value.style.display = 'none'
viewer.scene.splitPosition = 1 // 左层占满、右层全丢
}
syncSplitter()
viewer.scene.requestRender()
}
- 🔑
requestRender()不能省:demo 用的是按需渲染,不主动请求画面不会重画 - ⚠️ 想要"关闭 = 回到右侧底图",把这里的
1改成0
3.4 记得把线同步回来
ts
function syncSplitter(): void {
const position = viewer.scene.splitPosition
splitterWrap.value.style.left = `${position * 100}%`
}
- 🔑 DOM 只是引擎状态的镜像 :
splitPosition才是真身,线的位置由它推出来 - 🔑 所以任何改了
splitPosition的地方(初始化、开关、拖动)都要调一次syncSplitter() - ⚠️ 反过来不成立:只改线的
style.left而没改splitPosition,地球不会跟着变------看着动了,其实没动
3.5 补上缺失的 clamp 与开关语义
两个改动都很小,但能消掉 2.5 与 2.7 里的两个坑:
ts
// 1) 拖动时夹取,避免把手跑出容器
const raw = (splitterWrap.value.offsetLeft + relativeOffset) / parentWidth
const splitPosition = Math.min(1, Math.max(0, raw))
splitterWrap.value.style.left = `${100 * splitPosition}%`
viewer.scene.splitPosition = splitPosition
// 2) 让「关闭」回到右侧底图,而不是把右层全丢掉
} else {
splitterWrap.value.style.display = 'none'
viewer.scene.splitPosition = 0 // 原本写的是 1
}
- 🔑
Math.min(1, Math.max(0, raw)):把 splitPosition 锁在 0, 1,把手就不会飘出容器 - 🔑 关闭时给 0:右侧图层占满、左侧图层被丢光------这才是"回到 Bing 底图"
- ⚠️ 顺带把
splitPosition || 0.5改成?? 0.5,否则 0 永远设不进去
四、功能亮点与性能优势

4.1 面板
- 启用卷帘 :开关(同时改
splitPosition) - 左侧对比底图:高德街道图 / 高德影像图 / 无
- 提示:按住中央圆形滑块左右拖动
4.2 实测数字
全部来自源码字面值与 Cesium 源码,不是估算:
| 项 | 实测值 |
|---|---|
SplitDirection.LEFT |
−1.0 |
SplitDirection.NONE |
0.0(默认) |
SplitDirection.RIGHT |
+1.0 |
scene.splitPosition 默认 |
0.0 (FrameState.js L371) |
| 启用时设为 | 0.5 |
| 关闭时设为 | 1(左层占满、右层全丢) |
| 分割线宽度 | 5 px (z-index: 20) |
| 把手尺寸 | 42 × 42 px(圆形) |
| 把手水平偏移 | −21 px (居中应为 −18.5 px,偏左 2.5 px) |
| 把手垂直偏移 | 50% − 22px(居中应为 −21px,偏上 1 px) |
| 相机 | 118.8°E / 32.05°N / 260 km |
4.3 核心优势
- 🚀 零几何开销:只是片元着色器里两次比较,不建缓冲、不增绘制
- 🧩 与图层系统解耦:任意图层都能挂方向,加几层都行
- 📐 与分辨率无关 :
splitPosition是比例,缩放窗口不用重算 - 👆 交互全在 DOM:拖动的把手是普通元素,样式随便改
五、小贴士:避坑指南
🚫 关于「关闭卷帘」
- ⚠️ 重要提示 :demo 的关闭是把
splitPosition设成 1 ,结果是左侧图层占满、右侧图层全部丢掉------不是"回到 Bing 底图" - 左侧选「无」时再点关闭,所有影像都会被丢弃,只剩地球底色
- 想要"关闭后显示右侧底图":改成
splitPosition = 0,或把图层的splitDirection设回NONE
🎯 关于那个默认值 0
scene.splitPosition默认 0,不是 0.5- 只设
LEFT/RIGHT却忘了设位置 → 左侧一个像素都看不见 splitPosition || 0.5会把 0 吃掉,想精确控制请用??或显式判空
📏 关于拖动没有夹取
splitPosition是算出来就直接用,没有 clamp- 鼠标拖出容器,值会跑到 0, 1 之外,把手
left也会跟着变成负百分比而跑出屏幕 - 补一行即可:
Math.min(1, Math.max(0, splitPosition))
📐 关于把手与线的对齐
- 42 px 的把手压在 5 px 的线上,居中偏移应是 −18.5 px ,代码是 −21 px(偏左 2.5 px)
- 垂直方向
50% − 22px也比居中值偏上 1 px - 平时看不出来,一旦要做"吸附到某比例"就会暴露
🧭 关于换底图
UrlTemplateImageryProvider的 URL 模板、subdomains、maximumLevel都要跟着换- ⚠️ 高德是 GCJ-02 坐标系,与 WGS84 底图叠加有百米级偏移;做严格校核必须先做坐标转换
- 第三方瓦片随时可能变更接口,生产环境要有降级方案
🧹 关于清理
ScreenSpaceEventHandler要在onBeforeUnmount里destroy()- 切换对比底图时先
imageryLayers.remove(compareLayer),否则会越叠越多 - 改了
splitPosition就调syncSplitter(),改了线而没改splitPosition是看着动了其实没动
坦诚局限:这个 demo 只做了"两块底图左右分屏",没有做分割线吸附、键盘操作、触屏单指拖动、卷帘方向切换(横向/纵向),也没有处理坐标系偏移。这些都不改变"三个值 + 一次 discard"的主线,但每一条都是上生产前要补的。
六、写在最后
Cesium 是头优雅的大象。
我们曾经以为,卷帘是"把两张图各裁一半再拼起来";
今天才发现,它一片几何也没裁------只是让每个片元自己回答一句"我该在这一侧吗",不该就不画。
#64 那层遮罩是"完全不碰引擎 "的极端,#65 这条卷帘正好是另一个极端:几乎没有一行代码是自己的,全靠引擎给的三个值。两个极端摆在一起,倒把"控件"这件事的边界画清楚了------有的控件是补丁,有的控件只是把引擎的能力搬到一个能抓的地方。
而最该记住的还是那句:"关闭卷帘"关掉的是右侧那一层。一个布尔值写错方向,用户看到的就是另一张地图。🎉
这个案例还不完美:开关语义反直觉、拖动没夹取、把手偏了 2.5 px、坐标系没校正。但它把"卷帘到底是怎么分屏的、那条线是怎么接上去的"这件事讲清楚了,这就够了。
下一篇我们接着在控件这块走------这次不是左右分屏,而是把"这一屏有多少米"标在角落里。距离比例尺控件,我们下一篇见。
如果你也做过影像对比的功能,欢迎留言说说你是怎么解决"关掉之后该显示哪一侧"的------是老老实实改 splitDirection,还是干脆重建图层?
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在 src/cases/widget-map-split/ 目录下,核心就一个 172 行的 WidgetMapSplitDemo.vue,把高德的 URL 模板换成你自己的瓦片服务就能跑。
Cesium酱の百宝箱 · 第 65 篇
一条五像素的线不问缘由,只让每个片元自己站队
2026 年的秋夜,与你共探"两块底图如何在同一屏上各守半边"