1. 从零开始:为什么我们需要CustomShader?
如果你用过Cesium加载过3D Tiles数据,比如城市建筑、倾斜摄影模型,第一感觉可能是“哇,真酷!”,但看久了,是不是觉得有点……平淡?默认的渲染效果虽然准确,但总感觉少了点“灵魂”,比如让建筑在夜晚发出呼吸般的脉动光效,或者根据高度生成流动的光带。这些酷炫的动态效果,靠Cesium默认的材质系统很难实现,或者实现起来非常别扭。
这就是CustomShader出场的时候了。简单来说,它就像给你的3D Tiles模型穿上了一件可以自定义图案和特效的“智能外衣”。这件外衣的编织方法(着色器代码)完全由你掌控。Cesium在1.87版本正式推出了这个功能,彻底改变了之前想改点渲染效果就得去啃源码、打补丁的窘境。现在,你只需要像写配置一样,传入几段GLSL代码,就能直接干预GPU的渲染管线,实现各种天马行空的效果。
我刚开始接触时也犯怵,网上能找到的例子要么太简单,要么关键部分一笔带过,看得云里雾里。后来硬着头皮把Cesium源码里相关的部分翻了一遍,又踩了好几个坑,才总算摸清了门道。这篇文章,我就把自己实战中的经验、踩过的坑,以及如何实现一个漂亮的动态高度光带效果,掰开揉碎了讲给你听。即使你WebGL基础不那么扎实,跟着步骤走,也能在自己的模型上跑起来。
2. 庖丁解牛:深入CustomShader的每一个参数
光说不练假把式,我们先来看一个最基础的CustomShader结构长什么样。理解了骨架,再填血肉就简单了。
const customShader = new Cesium.CustomShader({ lightingModel: Cesium.LightingModel.UNLIT, // 光照模型,先关掉,我们自己控制颜色 uniforms: { u_time: { type: Cesium.UniformType.FLOAT, value: 0.0 }, u_highlightColor: { type: Cesium.UniformType.VEC3, value: new Cesium.Cartesian3(1.0, 0.0, 0.0) } }, varyings: { v_customData: Cesium.VaryingType.VEC3 }, vertexShaderText: ` void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) { // 在这里操作顶点 v_customData = vsInput.attributes.color_0; // 把顶点颜色传给片元着色器 } `, fragmentShaderText: ` void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { // 在这里操作最终像素颜色 vec3 color = v_customData * sin(u_time); // 让颜色随时间闪烁 material.diffuse = vec3(color); material.alpha = 1.0; } ` });2.1 Uniforms:你的Shader控制面板
你可以把uniforms理解成从JavaScript世界通往GPU着色器世界的“传声筒”和控制旋钮。它们是在着色器程序外部定义,但在内部全局可读(只读)的变量。为什么需要它?因为你的效果可能需要动态变化,比如光带移动的速度、颜色、扫描的起始时间等。
类型(UniformType)是关键:你必须严格匹配。比如你在JS里定义了一个FLOAT,在GLSL里就得用float来接收;定义了一个SAMPLER_2D(2D纹理),在GLSL里就得用sampler2D。类型不匹配,效果出不来,还很难调试。常见的类型有FLOAT,VEC2,VEC3,VEC4,MAT3,MAT4,SAMPLER_2D等。
动态修改Uniform:创建时给的value是初始值。如何在运行时改变它呢?这就需要用到tileset.customShader.setUniform方法。通常我们在tileset.readyPromise.then回调里做这个事,因为那时模型数据加载完毕,我们可以获取到模型的一些属性(比如最大最小高度)来设置uniform。
tileset.readyPromise.then((loadedTileset) => { // 假设我们获取到了模型的高度范围 const maxHeight = loadedTileset.properties.Height.maximum; const minHeight = loadedTileset.properties.Height.minimum; // 动态设置uniform值 loadedTileset.customShader.setUniform("maxHeight", maxHeight); loadedTileset.customShader.setUniform("minHeight", minHeight); // 也可以动态更新,比如让一个颜色循环变化 let time = 0; function update() { time += 0.01; loadedTileset.customShader.setUniform("u_time", time); requestAnimationFrame(update); } update(); });2.2 Varyings:顶点与片元之间的信使
varyings定义的变量,负责从**顶点着色器(vertexShader)向片元着色器(fragmentShader)**传递数据。这是图形学的一个核心概念:顶点着色器处理每个顶点,输出位置等信息;片元着色器处理每个像素(片元),决定最终颜色。但片元着色器需要知道它所在的三角形内部各顶点的信息(比如颜色、纹理坐标)是如何插值的,varying变量就承担了这个插值传递的任务。
在CustomShader里,你需要在varyings对象中声明变量名和类型(如VEC3,FLOAT),然后在vertexShaderText中给它赋值,在fragmentShaderText中读取它。比如,你想根据顶点的高度来染色,就可以在顶点着色器里计算一个高度系数,通过varying传给片元着色器。
2.3 VertexShaderText:顶点的舞台
这是最需要理解的部分。Cesium没有让你写完整的顶点着色器,而是让你写一个vertexMain函数。为什么?因为Cesium内部已经搭建好了复杂的渲染管线,它帮你处理了坐标系转换、光照计算(如果开启)、实例化渲染等一大堆繁琐的事情。vertexMain就像是这个管线中预留给你的一小块“自定义插件”入口。
这个函数有两个参数:
VertexInput vsInput:输入结构体,包含了当前顶点的各种属性(attributes)。这是数据的源泉。inout czm_modelVertexOutput vsOutput:输出结构体,inout表示可读可写。你要修改的就是它里面的东西,最终会影响顶点的位置、大小等。
vsInput.attributes里有什么宝藏?这是最常用的部分,它包含了模型自带的或Cesium计算的各种顶点属性。常用的有:
positionMC:模型坐标(Model Coordinates)下的顶点位置。这是你最常操作的坐标,是相对于模型本地原点的。注意,它不是最终的gl_Position!normalMC: 模型坐标下的法线向量。color_0,color_1: 模型可能附带的顶点颜色(如果有的话)。texCoord_0,texCoord_1: 纹理坐标。featureId_0: 用于拾取和样式化的要素ID。
vsOutput里你主要改什么?
positionMC: 修改这个值,就等于在模型空间里移动顶点。比如你可以让顶点沿着法线方向膨胀一点,做出膨胀效果。再次强调:修改positionMC后,Cesium会用它去计算最终的裁剪坐标gl_Position,所以你的修改是有效的,但概念上隔了一层。pointSize: 如果渲染的是点云,可以控制点的大小。
2.4 FragmentShaderText:像素的魔法师
片元着色器决定了屏幕上每一个像素最终的颜色。同样,Cesium让你写一个fragmentMain函数。
它的参数是:
FragmentInput fsInput:输入,其attributes和顶点着色器阶段经过插值后的varying变量在这里可以访问。inout czm_modelMaterial material:这是核心中的核心!你要修改的材质属性全在这里。
czm_modelMaterial材质属性详解:你可以把它想象成一个“颜料桶”,你往不同的属性里填颜色,Cesium会按照一定的光照模型把它们混合起来。对于实现自定义颜色效果,我们最关心这几个:
material.diffuse:漫反射颜色。这是物体本身的基色。在无光照(UNLIT)模型下,直接设置它就能看到颜色。material.alpha:透明度。1.0为完全不透明,0.0为完全透明。material.emissive:自发光颜色。这个属性非常有用!它表示物体自己发出的光,不受场景光照影响。做霓虹灯、发光边框、扫描线效果,主要就是修改它。即使场景是黑的,它也能亮起来。material.specular: 镜面反射颜色。material.roughness: 粗糙度。material.normal: 法线贴图。
对于大多数动态光效,我们的策略是:将lightingModel设为Cesium.LightingModel.UNLIT(关闭Cesium光照),然后主要通过material.diffuse或material.emissive来输出我们计算好的颜色。这样最简单直接,完全由我们掌控。
3. 实战:为3D建筑实现动态高度扫描光带
理论说了这么多,我们来搞个真家伙。我们的目标是:让一个城市3D Tileset中的建筑,从底部到顶部出现一道缓缓上升的、平滑的发光带,就像高科技扫描一样。
3.1 场景与模型准备
首先,我们需要一个3D Tiles数据。这里用Cesium Ion上的纽约建筑数据(AssetId: 75343)作为示例。你需要一个Cesium Ion的访问令牌。
// 初始化Viewer const viewer = new Cesium.Viewer('cesiumContainer', { terrainProvider: Cesium.createWorldTerrain() }); viewer.scene.globe.depthTestAgainstTerrain = true; // 开启地形深度检测,让建筑插在地上 // 定位到纽约曼哈顿 viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(-74.018813, 40.691143, 1000), orientation: { heading: Cesium.Math.toRadians(21.278), pitch: Cesium.Math.toRadians(-21.343), roll: 0.0 } }); // 启用实验性功能(CustomShader需要) Cesium.ExperimentalFeatures.enableModelExperimental = true;3.2 构建CustomShader:核心逻辑拆解
接下来是重头戏,我们一步步构建这个光带的Shader。
第一步:定义Uniforms我们需要两个uniform来控制光带:
u_scanHeight: 当前扫描线的高度(0.0到1.0之间变化)。u_glowColor: 光带的颜色。u_glowWidth: 光带的宽度(控制衰减平滑度)。
const customShader = new Cesium.CustomShader({ lightingModel: Cesium.LightingModel.UNLIT, // 关光照,我们自己发光 uniforms: { u_scanHeight: { type: Cesium.UniformType.FLOAT, value: 0.0 // 初始在底部 }, u_glowColor: { type: Cesium.UniformType.VEC3, value: new Cesium.Cartesian3(0.0, 0.96, 1.0) // 青色 }, u_glowWidth: { type: Cesium.UniformType.FLOAT, value: 0.05 // 宽度系数 } }, // 我们不需要在顶点和片元间传递复杂数据,这里可以不用varyings // varyings: {},第二步:编写Fragment Shader(关键算法)光带效果的魔法几乎全在片元着色器里。思路是:
- 获取当前片元在模型中的高度(归一化到0-1)。
- 计算当前片元高度与扫描线高度的距离。
- 使用
smoothstep函数生成一个平滑的过渡区域,模拟光带的边缘羽化效果。 - 将光带颜色与模型原本的颜色混合。
fragmentShaderText: ` // 这是一个经典的平滑阶跃函数,用于生成柔和边缘 float smoothEdge(float center, float width, float x) { float halfWidth = width * 0.5; return smoothstep(center - halfWidth, center + halfWidth, x); } void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { // 1. 获取当前片元的模型空间高度,并归一化。 // 这里假设模型的包围盒高度范围我们已经通过其他方式归一化了。 // 更健壮的做法是从uniform传入模型的minHeight和maxHeight,这里为简化,假设z轴是高度。 // 实际上,3D Tiles的坐标系可能不同,需要根据实际情况调整。这里用positionMC.z做一个演示。 // 注意:这是不精确的,仅用于原理演示。实际项目需要计算真实高度范围。 float height = fsInput.attributes.positionMC.z; // 假设我们知道这个模型的大致高度范围,或者从uniform传入。这里硬编码一个范围用于演示。 const float modelMinZ = -10.0; const float modelMaxZ = 300.0; float normalizedHeight = (height - modelMinZ) / (modelMaxZ - modelMinZ); normalizedHeight = clamp(normalizedHeight, 0.0, 1.0); // 钳制到0-1 // 2. 计算与扫描线的距离 float dist = abs(normalizedHeight - u_scanHeight); // 3. 核心:利用smoothstep生成光带强度 // 当dist很小时(靠近扫描线),强度接近1.0;随着dist增大,强度平滑衰减到0.0 float glowIntensity = 1.0 - smoothstep(0.0, u_glowWidth, dist); // 可以给光带加一点脉冲效果,让宽度随时间微微变化 // float pulse = sin(czm_frameNumber * 0.05) * 0.02 + 1.0; // glowIntensity = 1.0 - smoothstep(0.0, u_glowWidth * pulse, dist); // 4. 混合颜色 // 获取模型原本的漫反射颜色(如果有的话)。这里假设模型有颜色属性color_0。 vec3 originalColor = material.diffuse; // 如果模型没有颜色,可以给一个默认色,比如灰色 // if (length(originalColor) < 0.001) originalColor = vec3(0.7); // 将光带颜色(自发光)与原始颜色混合 vec3 glowColor = u_glowColor.rgb; material.emissive = glowColor * glowIntensity; // 自发光部分 material.diffuse = originalColor; // 保持原始漫反射颜色 // 也可以选择让光带区域覆盖原始颜色 // material.diffuse = mix(originalColor, glowColor, glowIntensity * 0.7); // material.emissive = glowColor * glowIntensity * 0.3; } ` });关于czm_frameNumber:这是一个Cesium内置的uniform(不需要我们声明),代表渲染的帧数。用它乘以一个很小的系数(如0.05),可以创造出与时间相关的动画效果,比如上面注释掉的脉冲宽度。
第三步:将CustomShader应用到3D Tileset
const tileset = new Cesium.Cesium3DTileset({ url: Cesium.IonResource.fromAssetId(75343), customShader: customShader // 应用我们的自定义着色器 }); viewer.scene.primitives.add(tileset);3.3 让光带动起来:JavaScript驱动动画
现在光带已经有了,但它是静止的。我们需要在JavaScript里更新u_scanHeight这个uniform,让扫描线从下往上移动。
tileset.readyPromise.then((loadedTileset) => { console.log('Tileset loaded'); let scanHeight = 0.0; let direction = 1; // 1向上,-1向下 function updateScanLine() { // 更新扫描线高度 scanHeight += 0.005 * direction; // 调整这个值可以改变扫描速度 if (scanHeight >= 1.0) { scanHeight = 1.0; direction = -1; // 到达顶部后向下扫描 } else if (scanHeight <= 0.0) { scanHeight = 0.0; direction = 1; // 到达底部后向上扫描 } // 更新uniform loadedTileset.customShader.setUniform("u_scanHeight", scanHeight); // 下一帧继续 requestAnimationFrame(updateScanLine); } updateScanLine(); });把上面的代码整合到一起,运行后,你应该能看到纽约的建筑群被一道青色的光带从下至上缓缓扫描,光带的边缘是平滑渐变的,非常酷炫。你可以通过修改u_glowColor来改变光带颜色,修改u_glowWidth来改变光带的粗细和柔和度。
4. 避坑指南与性能优化
实现效果很开心,但想让它在实际项目中稳定高效运行,还需要注意以下几点。
4.1 常见问题与调试技巧
问题一:为什么我的Shader没效果,模型变黑了或变白了?这是最常见的问题。首先检查:
- 控制台错误:打开浏览器开发者工具的控制台,看是否有WebGL编译错误。Cesium会把GLSL编译错误打印出来,仔细阅读错误信息,通常是语法错误、类型不匹配、未声明的变量等。
- Uniform设置:确认
setUniform调用成功,且值在合理范围内。可以在fragmentShader里先用一个简单的颜色测试,比如material.diffuse = vec3(1.0, 0.0, 0.0);,如果模型变红,说明Shader基本通路是好的,问题出在你的逻辑或uniform传递上。 - 光照模型:如果你设置了
material.diffuse但模型还是黑,检查lightingModel。如果是PBR(物理渲染),你需要正确设置粗糙度、金属度等全套参数,模型才会正确反射光照。对于快速测试颜色,建议先用UNLIT。 - 坐标空间:混淆
positionMC、positionWC(世界坐标)、positionEC(眼坐标)是另一个大坑。在vertexMain里,vsInput.attributes里通常只有positionMC是直接可用的。如果你想用世界坐标做计算,需要在Shader里用Cesium内置函数转换,或者通过uniform传入相机、矩阵等信息,这比较复杂。大部分效果在模型空间(MC)下计算就足够了。
问题二:如何获取模型的真实高度范围?上面的例子我硬编码了modelMinZ和modelMaxZ,这很不准确。更专业的做法是:
- 在
tileset.readyPromise回调中,查询tileset.properties。很多3D Tiles数据会自带属性,如Height。 - 如果属性里没有,可以遍历tileset的包围盒(
tileset.boundingSphere),但这是球形范围,不是精确高度。更精确的需要遍历所有tile的包围盒计算,计算量较大,通常可以在预处理阶段完成,然后将最大最小值作为uniform传入。
tileset.readyPromise.then((loadedTileset) => { // 方法1:使用模型自带的属性(如果有) if (loadedTileset.properties && loadedTileset.properties.Height) { const maxH = loadedTileset.properties.Height.maximum; const minH = loadedTileset.properties.Height.minimum; loadedTileset.customShader.setUniform("u_modelMaxHeight", maxH); loadedTileset.customShader.setUniform("u_modelMinHeight", minH); } else { // 方法2:使用包围球粗略估算(z轴方向) const sphere = loadedTileset.boundingSphere; const center = sphere.center; // 世界坐标中心 const radius = sphere.radius; // 这是一个非常粗略的估计!实际模型可能不是竖直的。 const approxMinZ = center.z - radius; const approxMaxZ = center.z + radius; loadedTileset.customShader.setUniform("u_modelMaxHeight", approxMaxZ); loadedTileset.customShader.setUniform("u_modelMinHeight", approxMinZ); } });然后在Shader中:
float normalizedHeight = (positionMC.z - u_modelMinHeight) / (u_modelMaxHeight - u_modelMinHeight);4.2 性能考量与最佳实践
CustomShader很强大,但滥用也会带来性能问题。
- 复杂度:片元着色器里的计算要尽可能简单。避免在Shader里做复杂的循环、分支判断(
if语句)和纹理查找(除非必要)。smoothstep、mix、sin等内置函数在GPU上很快,可以多用。 - Uniform更新频率:每帧都更新uniform(比如
u_time)是没问题的。但如果你的uniform值不常变化,就不要放在动画循环里更新。 - 多效果组合:想实现复杂效果(如扫描光带+颜色映射+边缘高亮),尽量在一个Shader内完成。避免给同一个模型叠加多个CustomShader(虽然技术上可能可行,但管理复杂且性能差)。
- 测试不同硬件:在低端显卡或移动设备上测试你的效果。如果帧率下降明显,考虑简化Shader逻辑或降低效果精度(比如减少
sin计算的频率)。
5. 举一反三:更多动态光效灵感
掌握了高度扫描光带,你已经打开了自定义渲染的大门。这里再给你几个思路,你可以尝试实现:
1. 根据属性值着色:很多3D Tiles模型带有属性,比如建筑年代、类型、高度。你可以在readyPromise里读取这些属性,将其最大值、最小值作为uniform传入,然后在Shader里根据每个片元对应的属性值(这通常需要通过featureId和样式系统关联,稍微复杂一些)映射到颜色梯度上,实现按属性分色渲染。
2. 伪体积光/雾效:利用模型到相机的距离,计算一个雾化系数。距离越远,模型颜色越淡,并与背景雾颜色混合。这可以在Shader中通过计算positionWC(世界坐标)到相机位置的距离来实现。
3. 交互式高亮:结合Cesium的拾取(Pick)功能。当用户点击或鼠标悬停某个建筑时,获取到它的featureId,将这个ID通过uniform传入Shader。在Shader中,判断当前片元所属的featureId是否与高亮ID匹配,如果匹配,就增加它的自发光(emissive)强度,实现高亮效果。
4. 数据驱动动画:将实时数据(如温度、人口密度、车流量)与uniform绑定。数据变化时,更新uniform值,Shader根据新值实时改变颜色或发光强度,实现动态数据可视化。
实现这些效果的关键,依然在于深入理解uniforms、varyings、vertexMain和fragmentMain之间的数据流,以及熟练运用GLSL进行数学计算和颜色混合。多动手写,多调试,从简单的效果开始,逐步增加复杂度。遇到问题,多查Cesium的官方文档和源码中的CustomShader相关部分,里面的注释往往能给你启发。