大家好,今天来拆解一个 CesiumJS 实战项目 ------ 地图图层主题系统。通过两套着色器方案叠加,实现实时颜色滤镜、深度遮罩和参数化调参,最终用 lil-gui 搭建出一个可交互的调色面板。
完整 HTML 源码在文末,单文件即可运行。
环境搭建
项目依赖三个库,全部走 CDN 无需本地构建:
- Vue 3:驱动 UI 响应式,这里用 Options API 管理 lil-gui 面板状态
- CesiumJS:三维地球引擎,负责底图渲染和着色器管线
- lil-gui:轻量调参面板,绑定到 Cesium 图层属性和自定义着色器的 uniform
html
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cesium@1.133.0/Build/Cesium/Cesium.js"></script>
<script type="module">
import GUI from "https://cdn.jsdelivr.net/npm/lil-gui@0.19.1/dist/lil-gui.esm.js";
</script>
底图与相机
使用高德地图瓦片作为底图,通过 UrlTemplateImageryProvider 加载。相机初始定位在中国中部上空约 14,000 公里处,俯视视角。
javascript
let imageryLayer = viewer.imageryLayers.addImageryProvider(
new Cesium.UrlTemplateImageryProvider({
url: "https://webrd02.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}",
maximumLevel: 18,
}),
);

注意两个关键设置:关闭 FXAA 避免干扰深度纹理采样,启用 depthTestAgainstTerrain 确保地形遮挡正确。
javascript
viewer.scene.postProcessStages.fxaa.enabled = false;
viewer.scene.globe.depthTestAgainstTerrain = true;
核心一:PostProcessStage 色彩滤镜
这是整个系统的核心。Cesium 的 PostProcessStage 可以注入自定义片段着色器,在全屏四边形上执行后处理。
着色器接收两个纹理:colorTexture(当前帧的颜色缓冲)和 depthTexture(深度缓冲)。关键技巧在于用深度值区分天空和地面:
glsl
uniform sampler2D colorTexture;
uniform sampler2D depthTexture;
uniform vec3 filterColor;
uniform bool enabled;
void main() {
vec4 color = texture(colorTexture, v_textureCoordinates);
float depth = texture(depthTexture, v_textureCoordinates).r;
if (enabled && depth < 0.999) {
vec3 inverted = vec3(1.0) - color.rgb;
vec3 filtered = inverted * filterColor;
out_FragColor = vec4(filtered, color.a);
} else {
out_FragColor = color;
}
}
深度阈值 0.999 是关键:Cesium 中天空盒渲染到远平面,深度值接近 1.0。小于 0.999 意味着像素属于地球表面或地形 ------ 只对这些区域做反色 + 滤镜。天空保持原样,避免出现怪异色块。
算法分两步:
- 反色 :
vec3(1.0) - color.rgb,把浅色底图变成深色基底 - 滤镜乘法 :
inverted * filterColor,每个通道独立缩放,实现任意色调
通过 JS 侧更新 filterColor uniform,就能在运行时切换主题色:
javascript
colorFilterStage.uniforms.filterColor = new Cesium.Cartesian3(r, g, b);
colorFilterStage.uniforms.enabled = true;
核心二:Globe 表面着色器注入
PostProcessStage 作用于全屏,无法精确控制地球表面内部的光照计算。为此项目同时修改了 Cesium 内部的 Globe 片段着色器。
Cesium 的 _surfaceShaderSet.baseFragmentShaderSource.sources 是一个字符串数组,包含 Globe 表面的 GLSL 源码。代码在 czm_saturation 调用之后注入自定义逻辑:
javascript
const baseFragShader =
viewer.scene.globe._surfaceShaderSet.baseFragmentShaderSource.sources;
for (let i = 0; i < baseFragShader.length; i++) {
const strS = "color = czm_saturation(color, textureSaturation);\n#endif\n";
let strT = strS + `
color.r = 1.0 - color.r;
color.g = 1.0 - color.g;
color.b = 1.0 - color.b;
color.r = color.r * ${filterR} / 255.0;
color.g = color.g * ${filterG} / 255.0;
color.b = color.b * ${filterB} / 255.0;
`;
baseFragShader[i] = baseFragShader[i].replace(strS, strT);
}
⚠️ 这是 Cesium 内部 API,未在文档中公开。版本升级可能导致
_surfaceShaderSet结构变化,建议锁定 Cesium 版本号。
为什么两套着色器都要?
- PostProcessStage 覆盖全屏(含地形、3D Tiles、矢量),但无法区分地球表面内部的光照阶段
- Globe 着色器注入仅在 Globe 表面生效,但受 Cesium 批次管理(多个 shader variant 对应不同光照条件)
- 两者叠加才能保证:无论 Day/Night 切换、地形开关、视角变化,滤镜效果一致
核心三:主题切换系统
switchTheme() 将两套着色器的参数打包成一个主题对象,一次调用完成全局切换:
javascript
switchTheme(theme) {
if (theme.isOriginal) {
colorFilterStage.uniforms.enabled = false;
baseLayer.brightness = 1;
baseLayer.contrast = 1;
baseLayer.gamma = 1;
baseLayer.hue = 0;
baseLayer.saturation = 1;
} else {
colorFilterStage.uniforms.enabled = true;
colorFilterStage.uniforms.filterColor = new Cesium.Cartesian3(
theme.filterRGB[0], theme.filterRGB[1], theme.filterRGB[2]
);
baseLayer.brightness = theme.brightness ?? 0.6;
baseLayer.contrast = theme.contrast ?? 1.8;
baseLayer.gamma = theme.gamma ?? 0.3;
baseLayer.hue = 0;
baseLayer.saturation = 0;
}
viewer.scene.requestRender();
}
五个图层属性各有含义:
| 属性 | 作用 | 典型值 |
|---|---|---|
| brightness | 亮度,值越大越亮 | 0.6 |
| contrast | 对比度,增强明暗差异 | 1.8 |
| gamma | 伽马校正,压暗或提亮中间调 | 0.3 |
| hue | 色相偏移 | 0 |
| saturation | 饱和度,0 = 灰度 | 0 |
饱和度设为 0 是关键 ------ 去掉底图原有色彩后,着色器的 filterColor 乘法才能精确控制最终色调,不会被原图颜色干扰。


lil-gui 调参面板
lil-gui 使用 ESM 引入,通过链式 API 绑定控件:
javascript
let params = {
colorArray: [255, 0, 0],
brightness: 1.0,
contrast: 1.0,
saturation: 1.0,
hue: 0.0,
gamma: 1.0,
reset: () => this.switchTheme({ isOriginal: true }),
};
myLilGui
.addColor(params, "colorArray").name("颜色")
.onChange((value) => this.switchTheme({ filterRGB: value }));
myLilGui
.add(params, "brightness", 0.1, 10.0, 0.1).name("亮度")
.onChange(() => { this.imageryLayer.brightness = params.brightness; });
颜色控件走 switchTheme() 触发完整主题更新(着色器 + 图层属性),而亮度/对比度等滑块直接修改 imageryLayer 属性。这样设计的原因是:颜色变化需要着色器重新计算,而亮度等属性是 Cesium 内置的图层参数,直接赋值即可实时生效。
重置按钮传入 { isOriginal: true } 一次性还原所有参数,比逐个恢复更简洁。

避坑笔记
- FXAA 与深度纹理 :开启 FXAA 会影响深度纹理采样精度,导致
depth < 0.999判断失效,必须在初始化时关闭 - ESM 与 global 混用 :Vue 走
<script>全局加载,lil-gui 走import模块加载,Cesium 是 UMD。三者共存的关键是 Cesium 用<script>加载且window.CESIUM_BASE_URL必须在import之前设置 - saturation = 0 是故意的 :不是 bug。去饱和后让着色器的
filterColor乘法成为唯一色彩来源,保证主题色一致 - Globe 着色器是数组 :
baseFragmentShaderSource.sources是数组而非单个字符串 ------ Cesium 为不同光照条件生成了多个 shader variant,必须全部替换
完整代码
见原文:https://mp.weixin.qq.com/s/Tkwbxhf8eeL7ZB4YyqnitA
总结
本文拆解的地图主题系统,核心思路是两套着色器叠加 + 参数化面板:
- PostProcessStage 用深度纹理做天空/地面分割,只在非天空区域做反色和滤镜乘法
- Globe 着色器注入在表面渲染阶段做同样的色彩变换,确保所有 shader variant 覆盖
- lil-gui 将颜色、亮度、对比度、伽马等参数暴露为交互控件,实时调参
switchTheme()把离散的参数组合打包为主题对象,一键切换
这个架构可以扩展为预设多套主题、接入后端配色方案,甚至做成可视化主题编辑器。把单文件 HTML 部署到任何静态服务器就能运行,无需构建工具。