本文目标:把风格撤掉,只留 CustomShader 一条通道------一条沿建筑高度向上扫的光带是怎么用 20 行 GLSL 写出来的、它的"时钟"到底是谁给的、为什么在 30 帧的机器上它会慢一倍而在 144 帧的机器上快 2.4 倍、以及一个更诡异的现象:给画面加一道青色的光,红色分量反而变暗了
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
上一篇我们把 Cesium3DTileStyle 和 CustomShader 两条通道接在了一起,结尾留了一句:
下一篇,我们撤掉 style,只留 CustomShader 一条通道:给白模做一条沿高度向上扫的光带。那时候要回答的问题变成------这部动画的时钟是谁给的,为什么它跑起来的速度和你想的不一样。
今天兑现。
先把"撤掉 style"这件事说清楚。前三篇的组合是这样的:第 94 篇只有底座和一个 Style,第 95 篇只有 CustomShader,第 96 篇两个一起用,本篇回到只有 CustomShader------但和 95 篇不同,这一次的着色器要完全接管颜色 :不读 material.diffuse,直接用 uniform 里给的基础色重新画一遍。
结果是这个系列里最"干净"的一个着色器案例:没有要素属性、没有 batch 纹理、没有分级条件,只有高度、帧号和你给的几个数 。正因为干净,那些藏在公式里的假设也暴露得最彻底------尤其是那个写死的 60.0。
demo 是 src/cases/city-shader/CityShaderDemo.vue(220 行),底座和前两篇一样是 src/lib/use-white-model-tileset.ts,高度近似那段也还是共用 src/lib/white-model-glsl.ts。本文数字依旧来自两条路径:把 GLSL 逐行直译成 Node 跑数,以及读 node_modules/cesium/Build/CesiumUnminified/Cesium.js(1.144.0)。
这三篇的着色器放在一起看挺有意思:第 95 篇的灯读 material.diffuse 再加工,第 96 篇的色带也是读进来再加工,只有本篇是从零开始重新算。区别不在能力,而在你承不承认数据自带颜色这件事。
前两篇的潜台词是"数据有颜色,我在它上面做效果";本篇的潜台词是"我只要形状,颜色从头到尾由我定"。后者更适合风格化表达------扫描光带、科技感蓝白、数据可视化这类需求,本来就不需要真实材质。
还有个更实际的好处:不依赖要素属性 。第 96 篇的色带需要瓦片里有 b3_volume_lod22 这一列,换一份没有这个属性的数据就得改代码;本篇只用到高度和帧号,是任何 3D Tiles 都有的信息。所以本篇这段着色器是四篇里最容易搬到别的数据集上去的。
顺便把四篇里"渐变"这一手的发展串一下,能看到作者在试什么:第 95 篇没有渐变(那盏灯只按距离衰减),第 96 篇加了渐变却写成了恒等式(低处压暗失效、量程写死 60),本篇把量程做成了可调、并真的把低处压到 0.28。三篇之间是递进的------同一个作者在同一个效果上,从"能跑"到"能调"。
这类演进在开源仓库里很常见,也是读别人代码最值钱的地方:你不只看最终版本,还能看出哪些参数是踩过坑之后才补上的。本篇的 u_gradMax 之所以做成可调,多半就是因为第 96 篇那个写死的 60 米在新数据上不好使。
本篇也是"城市白模"这条线的收口。从第 94 篇立起那座白城开始,四篇分别解决了数据组织、片元光照、分级上色和扫描动画------它们共用同一个底座、同一条管线,差异全在 customShaderStage 那一环里写了什么。读完这四篇,你应该能对"3D Tiles 上的颜色到底是谁定的"给出一个完整的回答。
把面板上八个控件摆在一起,本篇的可调范围一目了然:
| 控件 | 类型 | 默认 | 范围 / 步长 | 档数 |
|---|---|---|---|---|
| 动态扫描 | 开关 | 开 | --- | 2 |
| 扫描颜色 | 下拉 | 青色 | 5 种 | 5 |
| 扫描周期 | 滑条 | 2.0 s | 0.5 ~ 8 / 0.1 | 76 |
| 扫描带宽 | 滑条 | 14 m | 2 ~ 40 / 1 | 39 |
| 扫描范围 | 滑条 | 90 m | 30 ~ 200 / 5 | 35 |
| 基础颜色 | 下拉 | 白色 | 4 种 | 4 |
| 高度渐变 | 开关 | 开 | --- | 2 |
| 渐变上限 | 滑条 | 60 m | 20 ~ 150 / 5 | 27 |
八个控件里有五个直接改的是高度或时间相关的量,只有三个管颜色。这也解释了为什么本篇的"调参手感"比前两篇更依赖数据:别的 demo 调颜色就行,本篇调的是量程。
一、这次只有一条通道,而且是完全接管
前三篇里 material.diffuse 的来历各不相同,值得摆在一起看:
| 篇 | 用的通道 | material.diffuse 是谁定的 |
着色器怎么处理 |
|---|---|---|---|
| 94 | 只有 Style | style 涂好的色带色 | 无自定义着色器 |
| 95 | 只有 CustomShader | 数据原色(基础色) | base = material.diffuse,在它上面算子 |
| 96 | 两者都用 | style 涂好的色带色 | c = material.diffuse,在色带之上叠效果 |
| 97 | 只有 CustomShader | uniform 里的 u_baseColor |
material.diffuse = color,直接覆盖 |
差别就在最后一行。前两篇都是"取其值、在其上加工",本篇是重新赋值 ------着色器里从头到尾没有读过 material.diffuse,它拿 u_baseColor 当起点,算出 color,然后整个盖上去。
这个细微的差别有个实际后果:数据自带的颜色被完全忽略了 。3DBAG 白模本身是白的,所以视觉上看不出来;换成一份带真实外墙贴图的模型,本篇这个着色器会把贴图颜色全部抹掉,换成一个纯色。想保留原色,至少要把第一行改成 vec3 color = material.diffuse * u_baseColor;。
顺带第三次见到这两行:
ts
// L68-69
tileset.style = undefined
tileset.colorBlendMode = Cesium3DTileColorBlendMode.REPLACE
第 95 篇我们量过:colorBlendMode 只在两条路上会被读成 model_colorBlend------要么 tileset.color 有值(ModelColorPipelineStage 才注册,L130461),要么 tileset.style 有值走 CPU styling。本篇两个都没有(style 还被显式置空),所以这行 REPLACE 依旧是死代码 :HAS_MODEL_COLOR 不会被定义,modelColorStage 不执行,那行赋值谁也不读。
这是本系列第三次见到它了。同一个写法在三篇里出现三次、三次都无效,倒也不是什么严重问题------它更像是作者在三个 demo 之间复制粘贴时留下的痕迹 。真正值得记住的是判断方法:colorBlendMode 不是"设了就生效"的开关,它得有 color 或 style 配合才有意义。
把第 95 篇那张顺序表再搬一次,本篇只走其中一环:
glsl
// L129809-129835
#ifdef HAS_SELECTED_FEATURE_ID
selectedFeatureIdStage(selectedFeature, featureIds); // L129810
#endif
#ifndef CUSTOM_SHADER_REPLACE_MATERIAL
materialStage(material, attributes, selectedFeature); // L129814 → diffuse = 基础色
#endif
#ifdef HAS_CUSTOM_FRAGMENT_SHADER
customShaderStage(material, attributes, ...); // L129818 → 本篇在此把 diffuse 整个换掉
#endif
lightingStage(material, attributes); // L129821 → 乘太阳 + tone map
#ifdef HAS_SELECTED_FEATURE_ID
cpuStylingStage(material, selectedFeature); // L129824 → 本例不执行
#endif
vec4 color = handleAlpha(material.diffuse, material.alpha); // L129835
注意第 2 步和第 3 步的关系:materialStage 从 glTF 材质里算出的 material.diffuse,在本篇被第 3 步整个覆盖 ,一步都没用上。这不是浪费------管线是通用的,Cesium 不知道你会不会用。但如果你在意这点开销,可以把 mode 设成 REPLACE_MATERIAL 跳过第 2 步:那时 material.diffuse 停在 vec3(0.0),而本篇恰好不读它,正好合适。
自定义着色器的装配和拆卸是标准三段:
ts
// L67-86 装配:八个 uniform + 一段片元着色器
function applyCustomization(viewer: Viewer, tileset: Cesium3DTileset): void {
tileset.style = undefined
tileset.colorBlendMode = Cesium3DTileColorBlendMode.REPLACE
shader = new CustomShader({
uniforms: {
u_scanColor: { type: UniformType.VEC3, value: new Cartesian3(0, 0.9, 1) },
u_scanEnabled: { type: UniformType.FLOAT, value: 1 },
u_scanPeriod: { type: UniformType.FLOAT, value: 2 },
u_scanWidth: { type: UniformType.FLOAT, value: 14 },
u_scanRange: { type: UniformType.FLOAT, value: 90 },
u_baseColor: { type: UniformType.VEC3, value: new Cartesian3(1, 1, 1) },
u_useGradient: { type: UniformType.FLOAT, value: 1 },
u_gradMax: { type: UniformType.FLOAT, value: 60 }
},
fragmentShaderText: SHADER_FRAGMENT
})
tileset.customShader = shader
applyUniforms()
viewer.scene.requestRender()
}
// L103-114 挂载与拆卸
const onTilesetReady: OnTilesetReady = (viewer, tileset) => { applyCustomization(viewer, tileset) }
onMounted(async () => { await base.mount(onTilesetReady) })
onBeforeUnmount(() => { base.teardown(); shader = undefined })
onTilesetReady 这个回调是关键:切 LOD 和点"重置"都会销毁旧 tileset、重建新的,新 tileset 上必须重新挂一次 customShader(第 94 篇量过 primitives.remove() 连 GPU 资源一起销毁)。忘了这一条的表现是"切档之后效果消失",而所有滑块看起来还在正常工作。
两个开关(动态扫描、高度渐变)都是用 uniform 传 0 / 1 进去的:
ts
// L92、L98:布尔值转成 0/1 塞进 uniform
shader.setUniform('u_scanEnabled', scanEnabled.value ? 1 : 0)
shader.setUniform('u_useGradient', useGradient.value ? 1 : 0)
GLSL 里没有布尔 uniform 的标准写法,用 FLOAT 加 > 0.5 判断是最常见的替代方案,本篇这么写没问题。
还有一处值得对比:本篇和第 95 篇都是"只有 CustomShader",但两者对 material.diffuse 的态度正好相反。第 95 篇第一句是 vec3 base = material.diffuse;------它必须 读到材质阶段填好的基础色,否则整座城全黑(那一篇专门量过 REPLACE_MATERIAL 会让 diffuse 停在 vec3(0.0));本篇第一句是 vec3 color = u_baseColor;------它完全不关心 材质阶段的值,所以反而可以安全地设成 REPLACE_MATERIAL 省掉 materialStage。
判断该用哪种 mode,就看一件事:你的着色器需不需要数据自带的颜色 。需要就用默认的 MODIFY_MATERIAL,不需要就用 REPLACE_MATERIAL。第 96 篇属于前者(要读色带),本篇属于后者。

二、高度渐变:6.8 倍的动态范围
着色器本体很短,先看前半段------高度渐变:
glsl
// L45-65
void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
vec3 positionWC = fsInput.attributes.positionWC;
float h = dc_approxEllipsoidHeight(positionWC); // 近似海拔
float nh = clamp(h / max(u_gradMax, 1.0), 0.0, 1.0); // 归一化到 [0,1]
vec3 color = u_baseColor;
if (u_useGradient > 0.5) {
color = mix(color * 0.28, color * 1.9, nh); // 低处压到 0.28,高处提到 1.9
}
// ... 扫描部分见下一节
material.diffuse = color;
material.specular = vec3(0.0);
}
dc_approxEllipsoidHeight 是第 96 篇量过的那段自写近似(误差稳定在 0.3268%,60 米高差 0.196 米),这里不再重复。
值得注意的是这一次的渐变幅度和上一篇完全不同:
| 第 96 篇 | 第 97 篇 | |
|---|---|---|
| 低处倍率 | 1.00(mix 的死代码那半边) |
0.28 |
| 高处倍率 | 1.40(g=0.5 时) | 1.90 |
| 动态范围 | 1.40 倍 | 6.786 倍 |
| 量程 | 写死 0~60 米 | 面板可调 20~150 米 |
| 是否真的压暗低处 | 否 | 是 |
也就是说,本篇的渐变是真的在"压暗低处":楼底只有基础色的 28%,楼顶是 190%。这个跨度在视觉上就是"楼根沉在阴影里、楼顶发亮",比上一篇的柔和渐变激进得多。
把默认参数(基础色白、渐变开、不加扫描)代进整个管线算一遍,能看到两极分别落在哪:
| 位置 | 着色器算出的 diffuse | 乘太阳后(×2/π) | 最终 RGB | 触发 tone map |
|---|---|---|---|---|
楼底 nh=0 |
(0.280, 0.280, 0.280) | 0.1783 | (0.4068, 0.4068, 0.4068) | 否 |
楼顶 nh=1 |
(1.900, 1.900, 1.900) | 1.2096 | (0.9587, 0.9587, 0.9587) | 是 |
(计算方式和前两篇一致:czm_pbrNeutralTonemapping 在峰值超过 0.76 后启动压缩并去饱和,最后 czm_linearToSrgb 用 pow(x, 1/2.2) 转回。)
楼顶在默认参数下就已经过曝了 ------1.9 倍这个上限选得相当激进。好处是对比强烈、楼看起来很立体;代价是楼顶那一带的层次被 tone mapper 压平,而且渐变滑块(其实是 u_gradMax)往上调时,被压平的高度区间还会变大。
有个细节值得一提:u_gradMax 在本篇是面板可调的 (20 到 150 米,步长 5),这比第 96 篇写死 60 米进步了。它同时也是 dc_approxEllipsoidHeight 结果的归一化分母------也就是说,调这个滑块不只是"改配色",而是重新定义了"多高算楼顶"。调到 20 米时,几乎所有建筑的顶部都是纯白;调到 150 米时,全城都沉在渐变的暗部。
四套基础色的取值(L25-30):
| 基础色 | RGB | 归一化 |
|---|---|---|
| 白色 | (255, 255, 255) | (1.000, 1.000, 1.000) |
| 银灰 | (199, 207, 217) | (0.780, 0.812, 0.851) |
| 天蓝 | (158, 199, 255) | (0.620, 0.780, 1.000) |
| 暖白 | (255, 217, 158) | (1.000, 0.851, 0.620) |
注意"白色"这一档是纯 (1,1,1) 。第 96 篇我们量过:色带里放纯白会被 isDefaultStyleColor 判为"未着色"从而跳过替换。但那条规则只作用于 style 的颜色 (走 blend() 函数),本篇走的是 u_baseColor uniform,压根不经过 blend(),所以这里的纯白完全有效。同一个"白色",在 style 里是哨兵值,在 uniform 里就是普通颜色------这也是判断"某条规则作用于哪条通道"的一个例子。
在楼顶(nh = 1)时四套基础色走完管线的结果:
| 基础色 | 着色器 diffuse | 乘太阳后峰值 | 最终 RGB |
|---|---|---|---|
| 白色 | (1.900, 1.900, 1.900) | 1.2096 | (0.9587, 0.9587, 0.9587) |
| 银灰 | (1.482, 1.539, 1.615) | 1.0281 | (0.9051, 0.9212, 0.9421) |
| 天蓝 | (1.178, 1.482, 1.900) | 1.2096 | (0.7724, 0.8568, 0.9587) |
| 暖白 | (1.900, 1.615, 1.178) | 1.2096 | (0.9587, 0.8907, 0.7724) |
天蓝和暖白都被"洗淡"了一些------源色里 0.62 : 1 的差距,到画面上只剩 0.77 : 0.96。这就是前两篇反复量过的去饱和,它对所有颜色一视同仁。
再说说渐变为什么用 mix 而不是 smoothstep。两者都能插值,但 mix 是线性、smoothstep 是 S 形。对"高度渐变"这种希望均匀过渡的效果,线性更合适------用 smoothstep 会让中段变化被压缩、两端被拉伸,看起来像"楼根和楼顶各有一道突变"。本篇选 mix 是对的。
最后一个细节:渐变和扫描的作用顺序 。代码里先算渐变(得到 color),再加扫描(color += ...)。这意味着渐变是乘法 (改变明暗基准),扫描是加法 (在调好的明暗上再叠一层)。后果是扫描光的绝对亮度不随高度变化------楼底(0.28 倍)和楼顶(1.9 倍)加的都是同一份 u_scanColor。所以扫描带在暗处更显眼(对比度大),在亮处反而被淹没(总量已经接近饱和)。想让扫描在楼顶也更明显,得把它改成乘法,或者额外加大强度。
渐变的临界点也值得量一下。nh 从 0 到 1,倍率从 0.28 涨到 1.9,但最终画面亮度并不是线性跟涨------中间某处会跨过 tone map 的门槛:
nh |
倍率 | 乘太阳后 | 最终亮度 | 触发 tone map |
|---|---|---|---|---|
| 0.00 | 0.280 | 0.1783 | 0.4068 | 否 |
| 0.25 | 0.685 | 0.4361 | 0.6564 | 否 |
| 0.50 | 1.090 | 0.6939 | 0.8244 | 否 |
| 0.75 | 1.495 | 0.9517 | 0.9303 | 是 |
| 1.00 | 1.900 | 1.2096 | 0.9587 | 是 |
峰值 0.76 的门槛对应 nh ≈ 0.6。也就是说上面四成的高度区间都处在被压缩的状态 :从 nh = 0.6 到 1.0,倍率从约 1.27 涨到 1.9(涨了 50%),而最终亮度只从约 0.87 涨到 0.9587(涨了 10%)。
这就是为什么楼顶那段渐变看起来"没什么变化"------不是渐变没生效,是被压平了。把 u_baseColor 调暗一点(比如用银灰而不是纯白),能让这条曲线退回线性区,渐变的层次就回来了。
配色上还有一条实用经验:五套扫描颜色里,青色和绿色在暗底上最显眼(人眼对绿青波段最敏感),红色和橙色在暗底上偏"闷"、但在亮底上反而更有对比。所以基础色选白色时,扫描色可以用红 / 橙拉出对比;基础色选银灰或天蓝时,扫描色用青 / 绿更容易看清。
另外要记住扫描和基础色的"贡献方式"不同:基础色走乘法,决定整栋楼的基调;扫描色走加法,决定光带的颜色。想要"科技蓝白"就白底配青带,想要"警示感"就银灰底配红带,想要"温暖夜景"就暖白底配橙带。
至于 0.28 和 1.9 这两个数为什么是这个值:它们大概率是试出来的------0.28 让楼根沉下去但不至于全黑,1.9 让楼顶接近饱和。从制图角度看这个跨度偏大:多数专题图的明度跨度控制在 3 到 5 倍就够读了,6.8 倍会把中间层次往两端挤,何况本篇的两端还各自被 tone map 压了一道。
如果你要的是"能读的渐变"而不是"好看的渐变",把这两个数收到 0.45 和 1.4(跨度 3.1 倍)更合适:楼根仍明显暗于楼顶,中间楼层能分出层次,也不会一上来就顶到 tone map。

三、扫描光带:时钟、带宽和可见时间
后半段是本篇的主角。四个参数,十几行:
glsl
if (u_scanEnabled > 0.5) {
float periodFrames = u_scanPeriod * 60.0; // ★ 写死的 60
float cycle = fract(czm_frameNumber / max(periodFrames, 1.0));
float bandPos = cycle * u_scanRange; // 光带中心的高度
float dist = abs(h - bandPos);
float band = 1.0 - smoothstep(0.0, max(u_scanWidth, 0.1), dist);
color += u_scanColor * band; // 加法叠加
}
先说这段在干什么:czm_frameNumber 是 Cesium 每帧自增的一个 uniform(从 0 开始,不停累加)。除以周期得到一个 0~1 的循环进度 cycle,乘以 u_scanRange 就得到光带当前所在的高度 。每个片元算一下自己的高度 h 和光带中心的距离 dist,距离越近越亮。
所以这不是"一道扫过地面的光",而是一条等高线 ------它沿着高度轴从 0 米往上爬到 u_scanRange 米,爬到顶之后 fract 归零,瞬间回到地面重来。
五套扫描颜色(L18-24):青色 (0, 0.9, 1)、绿色 (0.3, 1, 0.53)、橙色 (1, 0.62, 0.18)、紫色 (0.7, 0.48, 1)、红色 (1, 0.36, 0.36)。共同点是都有一到两个通道接近 1------因为扫描是加法,颜色本身越亮,光带越明显。
czm_frameNumber 是 Cesium 的自动 uniform,每渲染一帧自增 1,从场景创建起不停累加。它的好处是"和渲染严格同步":渲染一帧加一,不渲染就不加。坏处也正是这一点------它数的是帧,不是秒。
Cesium 里能拿来驱动动画的时间源大致有三种:
| 时间源 | 单位 | 受帧率影响 | 受 Cesium 时钟倍速影响 |
|---|---|---|---|
czm_frameNumber |
帧 | 是 | 否 |
czm_time(场景时钟) |
秒 | 否 | 是(暂停 / 倍速会跟着变) |
自己用 performance.now() 累计 |
秒 | 否 | 否 |
本篇选了第一种。如果你要的是"随场景时间推进、能被时间轴控制"的动画,应该用第二种;只想"按真实秒数跑",用第三种。第 47 篇那个雷达波用的是墙钟(第二种思路的变体),同样绕开了帧率,代价是"暂停 Cesium 时钟它照转"。三种各有各的坑,没有完美解------只有"你到底想让什么控制它"这一个选择题 。先说清楚这条光带的几何:它不是"一道扫过地面的光",而是一条等高线。
三个参数定义了它:u_scanRange 是扫描的高度上限,u_scanWidth 是带的厚度,u_scanPeriod 是跑完一趟的时间。而"位置"是靠每个片元自己的高度 算出来的------所有高度接近 bandPos 的片元同时发亮,不管它们处在城市的哪个角落。
这个做法有个很妙的副作用:整座城市里所有楼,只要高度相同,就会在同一时刻亮起来。所以你看到的不是"一道光扫过街道",而是"一层高度层整体发光",像一栋看不见的板子在城市里往上浮。
代价是它和水平位置完全无关。你想做"从左往右扫过街区"的效果,这套代码做不到,得改用水平坐标去算 dist------而这在本篇的坐标系里反而要小心:世界坐标是 ECEF 米制,直接拿 positionWC.x 做扫描,得到的是一条和经纬网斜着的线。要沿经纬度扫,得先转 Cartographic。
把"等高线"这个特性再推一步,能看到一个有意思的现象:城市里所有同一高度的楼顶会在同一时刻亮起来,哪怕它们相距几公里。所以扫描带扫过时,画面上出现的不是"一条移动的线",而是"一层浮起来的平台"。
在正俯视(相机垂直向下)时这个效果最不明显------你只能看到屋顶,光带扫过时是"整片屋顶依次发亮"。而在斜视角下(比如底座默认的 77° 俯角),你能同时看到楼顶和立面,光带扫过时立面会出现一条清晰的横向亮带,视觉效果好得多。
这也解释了为什么这个 demo 把相机放在 18000 米这么高:只有在这个高度,你才能一眼看到整座城市的高层"平台"同时亮起。贴到街角去看,效果反而退化成"周围几栋楼突然亮一下"------同一个着色器,换个视点就是两种观感。
把光带在一个周期里的位置排出来,能更直观地看出"它把时间花在哪了"。默认参数(周期 2 秒、范围 90 米)下:
| 时间(秒) | cycle |
光带中心高度(米) | 落在哪 |
|---|---|---|---|
| 0.0 | 0.000 | 0 | 地面 |
| 0.4 | 0.200 | 18 | 住宅楼顶附近 |
| 1.0 | 0.500 | 45 | 空域 |
| 1.6 | 0.800 | 72 | 空域 |
| 2.0 | 1.000 → 0 | 90 → 0 | 到顶,瞬间回到地面 |
整个周期里只有前四分之一(0 到 0.5 秒)光带在建筑上,剩下四分之三都在空域。想验证这一点很简单:把扫描周期调到 8 秒(最慢档),盯着画面数一下光带在建筑上的时间占多少------和这张表对得上,就说明你对这几个参数的理解是对的。
那个写死的 60
u_scanPeriod * 60.0 里的 60.0 是"假设每秒 60 帧"。它把"秒"换算成"帧",然后交给按帧计数的 czm_frameNumber 去除。一旦真实帧率不是 60,周期就错了:
| 标称周期 | 30 fps | 60 fps | 75 fps | 120 fps | 144 fps |
|---|---|---|---|---|---|
| 0.5 秒 | 1.000 秒(+100%) | 0.500 | 0.400 | 0.250 | 0.208 |
| 2 秒(默认) | 4.000 秒(+100%) | 2.000 | 1.600 | 1.000 | 0.833 |
| 8 秒 | 16.000 秒(+100%) | 8.000 | 6.400 | 4.000 | 3.333 |
30 帧的机器上慢一倍,144 帧的机器上快 2.4 倍。 这不是精度问题,是整整一倍的误差------同一个 demo,在卡顿的笔记本上和在高刷屏上跑出来是两种完全不同的节奏。
这类"用帧号当时钟"的写法本系列不是第一次见:第 46 篇那堵动态围墙用的也是 czm_frameNumber,同样随帧率漂移。它唯一的优点是实现简单,一行就拿到了时间;代价是你把动画节奏交给了显卡。
正确的做法是用真正的时钟。Cesium 提供了 czm_frameNumber,但更稳的是自己在 JS 侧累计时间再塞进 uniform:
ts
// 改法:用真实时间驱动,不再假设 60 fps
let elapsed = 0
let lastTime = 0
viewer.scene.preUpdate.addEventListener(() => {
const now = performance.now() / 1000
if (lastTime > 0) elapsed += now - lastTime // 累加真实秒数
lastTime = now
shader?.setUniform('u_time', elapsed) // 着色器里改用 u_time
})
glsl
// 着色器里对应改成:
float cycle = fract(u_time / max(u_scanPeriod, 0.001));
这样不管 30 帧还是 144 帧,周期都严格等于你设的秒数。
把这段动画逻辑直译成 Node,上面这些数字就全出来了:
js
// workspace/_tmp/_probe97a.cjs
function smoothstep(e0, e1, x) {
const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0)));
return t * t * (3 - 2 * t);
}
function band(dist, width) {
return 1 - smoothstep(0, Math.max(width, 0.1), dist);
}
// 周期:写死 60 之后,实际秒数 = periodFrames / 真实帧率
for (const period of [0.5, 2.0, 8.0]) {
for (const fps of [30, 60, 75, 120, 144]) {
const frames = period * 60;
console.log(period, fps, (frames / fps).toFixed(3));
}
}
// 可见时间占比:光带中心落在 0~H 区间内
for (const H of [10, 20, 30, 50]) {
for (const R of [30, 60, 90, 200]) {
console.log(H, R, ((Math.min(H, R) / R) * 100).toFixed(1) + '%');
}
}
回过头看那个 60.0,值得多说一句它是怎么来的。作者意图很清楚:把"秒"换算成"帧",好让 czm_frameNumber 能直接除。问题在于这个换算用的是一次性写死的常量,而不是运行时测出来的真实帧率。
真要在保留帧号的前提下修正,也不是做不到------把真实帧率测出来再传进去:
ts
// 折中方案:每帧测一次真实帧率,再把 periodFrames 算对
let fps = 60
let lastT = 0
viewer.scene.preUpdate.addEventListener(() => {
const now = performance.now()
if (lastT > 0) fps = fps * 0.9 + (1000 / Math.max(now - lastT, 1)) * 0.1 // 指数平滑
lastT = now
shader?.setUniform('u_fps', fps)
})
着色器里改成 float periodFrames = u_scanPeriod * u_fps;,周期就随帧率自适应了。不过既然都要每帧算一次,不如直接用时间累计------前头给的 u_time 方案更直接,也不用担心平滑带来的滞后。
带宽:滑块写的是半高全宽
band = 1 - smoothstep(0, width, dist) 这条曲线值得量一下。smoothstep 是 t²(3-2t) 的平滑插值,所以:
dist(米) |
band |
|---|---|
| 0 | 1.0000(中心,最亮) |
| 3.5 | 0.8438 |
| 7 | 0.5000(半高) |
| 10.5 | 0.1563 |
| 14 | 0.0000(归零) |
smoothstep(t) = 0.5 的解恰好是 t = 0.5,所以半高点在 dist = width / 2 = 7 米处------半高全宽(FWHM)正好等于滑块上的 14 米。
但"有亮度"的范围是 dist ∈ [0, 14],光带中心两侧各 14 米,也就是总共 28 米 。换句话说,滑块上写的"扫描带宽 14 m"是半高全宽,而视觉上被照亮的高度范围是它的两倍。这个偏差在调参时会有感觉:你把带宽调到 40 米,实际会有 80 米高的楼身泛光。
可见时间:光带大部分时间在扫空气
第三个参数 u_scanRange(30 到 200 米,默认 90)决定光带扫多高。而阿姆斯特丹的普通住宅大多在几层到六层之间------光带中心真正落在建筑高度区间内的时间,只占整个周期的一小部分:
| 建筑最高 H | 扫描范围 30 m | 60 m | 90 m(默认) | 200 m |
|---|---|---|---|---|
| 10 m | 33.3% | 16.7% | 11.1% | 5.0% |
| 20 m | 66.7% | 33.3% | 22.2% | 10.0% |
| 30 m | 100% | 50.0% | 33.3% | 15.0% |
| 50 m | 100% | 83.3% | 55.6% | 25.0% |
(口径:光带中心落在 0 ~ H 米区间内的时间占整个周期的百分比。)
把光带自身的 28 米厚度也算进去(只要有任何亮度照在建筑上就算"看得见"),比例会高一些------默认参数、建筑 20 米时是 53.3%------但结论不变:默认参数下,扫描光带有接近一半甚至更多的时间是在建筑上方的空气里跑。
这就解释了很多人第一次打开这个 demo 的困惑:"扫描是有的,但好像一闪就没了"。不是效果坏了,是光带大部分时间在没人看得见的高度上。把 u_scanRange 从 90 调到 30,画面会立刻变得"一直在扫"。
还有一处:fract 的瞬回
cycle = fract(...) 意味着光带爬到 u_scanRange 之后立刻跳回 0 ,没有回落过程。视觉上是"往上扫 → 突然消失 → 从地面重新出现"。如果想要"上去再下来"的呼吸感,得把 fract 换成三角波:
glsl
// 想要往返而非瞬回:把 fract 换成三角波
float cycle = abs(1.0 - fract(u_time / u_scanPeriod) * 2.0);

四、加了青色的光,红色反而变暗了
这一节是本篇最反直觉的一个数字,也是前两篇那套 tone mapping 分析的延续。
扫描是加法 :color += u_scanColor * band。以默认参数为例------基础色白、渐变开、光带中心正好扫到楼顶------此时着色器算出的 diffuse 是:
(1.9, 1.9, 1.9) + 青色(0, 0.9, 1) × 1.0 = (1.9, 2.8, 2.9)
红色分量一个单位都没加,还是 1.9。走完管线之后呢?
| 场景 | 着色器 diffuse | 乘太阳后峰值 | 最终 RGB |
|---|---|---|---|
| 楼顶、无扫描 | (1.900, 1.900, 1.900) | 1.2096 | (0.9587, 0.9587, 0.9587) |
| 楼顶、扫描中心 | (1.900, 2.800, 2.900) | 1.8462 | (0.8260, 0.9654, 0.9794) |
红色分量从 0.9587 掉到了 0.8260,降了 13.8%。
原因在 czm_pbrNeutralTonemapping 的工作方式上(未压缩产物 L41445)。它先取三个通道的最大值 peak,再按 newPeak / peak 的比例整体缩放所有通道,最后朝白色混合:
glsl
float peak = max(color.r, max(color.g, color.b));
if (peak < startCompression) return color; // 0.76 以下原样放行
const float d = 1.0 - startCompression;
float newPeak = 1.0 - d * d / (peak + d - startCompression);
color *= newPeak / peak; // ★ 三个通道乘同一个系数
关键在于那个系数是由最大通道算出来的,却作用在全部三个通道上 。你给绿蓝两通道加了光,peak 从 1.21 涨到 1.85,压缩系数随之变小,红色被一起压暗了。
这就是所谓"通道耦合":在开启了 tone mapping 的管线里,改一个通道的亮度会影响另外两个通道的最终显示值 。想验证的话很简单------把扫描颜色换成纯红 (1, 0, 0),你会发现红色没变暗、而绿蓝两通道掉得更狠。
实用的推论有两条:一是别用"最终画面上的颜色"反推你该调哪个参数,它和你改的那个数不是一一对应的;二是想要真正"加亮",与其靠加法把某个通道顶上去,不如先把整体亮度降到 tone map 门槛以下,让管线处于线性区------那时你加多少就是多少。
把五种扫描颜色都跑一遍(楼顶、光带中心),能看到通道耦合的程度取决于你把光加在了哪几个通道上:
| 扫描颜色 | 着色器 diffuse | 乘太阳后峰值 | 最终 RGB | 红 | 绿 | 蓝 |
|---|---|---|---|---|---|---|
| 无扫描 | (1.90, 1.90, 1.90) | 1.2096 | (0.9587, 0.9587, 0.9587) | 基准 | 基准 | 基准 |
| 青色 | (1.90, 2.80, 2.90) | 1.8462 | (0.8260, 0.9654, 0.9794) | −13.8% | +0.7% | +2.2% |
| 绿色 | (2.20, 2.90, 2.43) | 1.8462 | (0.8754, 0.9794, 0.9112) | −8.7% | +2.2% | −5.0% |
| 橙色 | (2.90, 2.52, 2.08) | 1.8462 | (0.9794, 0.9247, 0.8560) | +2.2% | −3.5% | −10.7% |
| 紫色 | (2.60, 2.38, 2.90) | 1.8462 | (0.9365, 0.9035, 0.9794) | −2.3% | −5.8% | +2.2% |
| 红色 | (2.90, 2.26, 2.26) | 1.8462 | (0.9794, 0.8849, 0.8849) | +2.2% | −7.7% | −7.7% |
五种颜色的 peak 恰好都是 2.90,所以压缩系数完全相同------差异只来自你把光加在了哪几个通道。规律极其干净:
被加光的通道最多只涨 2.2%,没被加光的通道最多掉 13.8%。
也就是说,在已经过曝的区域,你再加多亮的光,它几乎都不会更亮------投入的光没有变成亮度,全变成了"把其它通道的对比压掉"。想让光带真的醒目,正确做法和前两篇一样:先把基础亮度压到 tone map 门槛以下,让管线回到线性区,那时你加多少就是多少。
(作为对照,楼底 nh = 0 不过曝时加青色:红色从 0.4068 到 0.4067 几乎不变,而绿蓝从 0.4068 涨到 0.8561 / 0.8901------不过曝的地方,加法就是真的加法。)
这个效应在第 95 篇和第 96 篇都存在,只是表现不同:第 95 篇是"强度越大颜色越白"(去饱和),第 96 篇是"边缘光吃掉色带层次"(加法冲淡差异),本篇是"加光反而压暗其它通道"。三者同源------都是 color *= newPeak / peak 那一行用一个通道算出的系数缩放了全部通道。
再补一条比"红色变暗"本身更实用的判据:想知道画面在不在 tone map 里,就看"加了光之后,被加的那个通道亮了多少"。
在线性区,加一份 u_scanColor 就应该亮一份;如果你把扫描强度拉满、而画面几乎没变化,说明已经顶到压缩区了------此时任何"加亮"操作都不会让画面更亮,只会改变颜色之间的比例。
这个判据适用于本系列全部四篇:第 95 篇那盏灯强度从 2 拉到 6,画面只从 0.9816 动到 0.9952;第 96 篇亮度滑块推到 2.0,只从 0.940 动到 0.975;本篇加满扫描光带,被加的通道只涨 2.2%。都是同一件事的三种表现。
顺带把 tone mapper 里那个容易被跳过的一行也说一下:
glsl
float x = min(color.r, min(color.g, color.b));
float offset = czm_branchFreeTernary(x < 0.08, x - 6.25 * x * x, 0.04);
color -= offset;
czm_pbrNeutralTonemapping 在压缩之前,会先从三个通道里各减掉一个 offset:最暗通道小于 0.08 时取 x - 6.25x²,否则固定减 0.04。这一步是为了压暗部、让黑色更"实"。
后果是暗部会被额外压掉一点 。本篇楼底 diffuse 是 0.28、乘太阳后 0.1783,大于 0.08,所以固定减 0.04 变成 0.1383,最终亮度 0.4068。如果没有这一步,最终会是 pow(0.1783, 1/2.2) = 0.4565,比实测亮 12%。所以算暗部颜色时别漏了这一笔。
把"加光反而变暗"再往前推一步:既然压缩系数由最大通道决定,那先降低最大通道反而能让画面整体好看一些 。具体到本篇,把 u_baseColor 从纯白 (1,1,1) 换成银灰 (0.78, 0.81, 0.85),楼顶的 diffuse 从 1.9 降到 1.48,压缩系数随之变大,最终亮度从 0.9587 变成 0.9051------暗了一点点,但渐变的层次回来了 ,因为 nh 从 0.6 到 1.0 这段不再被压得那么狠。
这正是第 95、96 两篇反复出现的同一个权衡:亮度换层次。想要对比强烈就顶进压缩区,想要层次分明就退回线性区,两者没有两全。
顺带一提,material.specular = vec3(0.0) 这句在三篇里都出现了,理由也一样:白模是哑光材质,留着 PBR 默认的高光会让楼看起来像塑料。

五、三个量程打架
本篇一共有三个"高度"相关的量,它们互不相干,很容易调乱:
| 量 | 来源 | 默认 | 作用 |
|---|---|---|---|
u_gradMax |
面板,20~150 m | 60 m | 渐变的归一化分母(多高算"顶") |
u_scanRange |
面板,30~200 m | 90 m | 光带扫描的高度上限 |
| 建筑实际高度 | 数据 | --- | 决定光带何时可见、渐变何时饱和 |
三个数的默认值互不匹配:gradMax = 60、scanRange = 90、而大多数建筑在 20 米上下。于是默认画面是这样的:
- 建筑顶部(20 米)在渐变里只到
nh = 20/60 = 0.33,所以全城都处在渐变的暗部 ,倍率只有0.28 + (1.9-0.28)×0.33 = 0.81; - 光带要扫到 90 米,其中只有前 20 米(22%)照在建筑上;
- 结果是:楼整体偏暗、扫描一闪而过。
把三个数调成一致会立刻不一样:gradMax 调到 20、scanRange 调到 30,画面就变成"楼从暗根到亮顶有完整渐变、光带一直在楼身上扫"。
这正是这类"参数面板"最容易误导人的地方:每个滑块单独看都合理,合在一起却不匹配你这份数据 。第 96 篇我们量过色带阈值应该按数据的分位数重算,本篇是同一条原则的另一面------高度类的参数要按你数据的真实高度分布来定,不能沿用 demo 的默认值。
想知道自己的数据该定多少,最快的办法是在着色器里把高度直接输出成颜色做一次"体检":
glsl
// 高度体检:把归一化高度直接当颜色画出来,看分布
material.diffuse = vec3(nh, nh, nh);
material.specular = vec3(0.0);
画面上纯黑就是 0 米、纯白就是 gradMax,一眼就能看出你的建筑落在哪一段。
另一个办法是查数据本身:3DBAG 的属性里通常有最大高度字段(如 b3_h_max),把它的 P50 / P90 算出来再定参数,比目测靠谱得多。实在没有,就在着色器里把高度直接画成灰度做一次"体检",画面上纯黑是 0 米、纯白是 gradMax,一眼就能看出建筑落在哪一段。
顺带把本篇和上一篇的"渐变上限"放一起看:
| 第 96 篇 | 第 97 篇 | |
|---|---|---|
| 参数名 | u_gradMax |
u_gradMax |
| 面板可调 | 否(写死 60) | 是(20~150,步长 5) |
| 归一化式 | (h - gradMin) / (gradMax - gradMin) |
h / gradMax |
| 有无下限 | 有(gradMin = 0) |
无(分母就是上限) |
第 96 篇那个 gradMin 虽然也是 0(等于没有),但公式里留了位置;本篇直接写成 h / gradMax,少一次减法和一次 max,更简洁,代价是"渐变必然从地面开始"成了硬约定------若哪天你想让渐变从 10 米起步,本篇这个公式得改。
最后补一句"怎么验证调对了"。三个量程调完之后,最快的验证方法是盯着一栋楼看满一个周期 :如果光带从楼根平顺地扫到楼顶、中间没有长时间消失,说明 scanRange(或者说扫描上限)对了;如果楼的明暗从根到顶过渡均匀、没有大片死白,说明 gradMax 对了。这两条都满足了,剩下的"扫描颜色 / 基础色"就纯粹是审美问题。
还有一层容易被忽略:这里的"高度"是椭球高 ,不是"离地高度"。dc_approxEllipsoidHeight 算的是相对 WGS84 椭球面的高度,而荷兰大部分地区海拔在 0 米上下(有些地方甚至低于海平面)。所以在这个 demo 里 h 基本等于"离地高度",两者可以混用。
但换到高原城市(比如海拔两千多米的地方),h 会从两千多米起步------此时 nh = h / gradMax 恒等于 1,渐变彻底失效,光带也永远扫不到建筑。正确的做法是用相对地面的高度:要么减去一个基准面高度,要么减掉地形高。本篇不做处理,是因为阿姆斯特丹海拔接近 0,属于"恰好能用",不是"通用正确"。
同理,光带的起点 bandPos = 0 也是椭球面。在海拔 0 米的阿姆斯特丹它正好贴着地面起步;换到高原,光带会从两千多米的空中开始扫,而建筑全在它下方------整条光带从头到尾都扫在建筑上面。
修正方法和渐变一样:把 h 换成相对地面的高度。最小改动是再引入一个 uniform u_baseHeight,在两处同时减掉:
glsl
float hRel = h - u_baseHeight; // 相对基准面的高度
float nh = clamp(hRel / max(u_gradMax, 1.0), 0.0, 1.0);
// ... 扫描部分也改用 hRel
float dist = abs(hRel - bandPos);
u_baseHeight 可以取你数据的平均海拔,也可以用 globe.getHeight 在相机焦点处采一次。这类"绝对坐标转相对坐标"的修正,在跨城市复用时几乎总会遇到。
还有一个与量程相关的小细节:u_scanWidth 的单位是米,但它是沿高度方向的米。"带宽 14 米"指的是 14 米的高度差,不是水平宽度。在斜视角下,这 14 米高度差投影到屏幕上的宽度会随楼的远近变化------远处的楼看起来光带更窄。这是透视的自然结果不是 bug,但如果你期待"屏幕上看起来都一样宽",那就得按到相机的距离做补偿。
六、搬走前要改的四处
1. 把时钟换掉。 这是本篇唯一一条"不改就是 bug"的:u_scanPeriod * 60.0 那个 60 必须去掉,改成真实时间累计(第三节给了改法)。否则你的动画节奏会随机器的帧率漂移,而你在自己机器上永远测不出来。
2. 统一三个量程。 让 u_scanRange 跟着 u_gradMax 走,或者干脆合并成一个"建筑高度上限"参数,再由它派生出渐变分母和扫描上限。两个独立滑块表达的是同一件事,只会互相打架。
3. 分支可以留着,但要知道代价。 着色器里有两处 if (u_xxx > 0.5)。这类分支的条件是 uniform,同一帧里所有片元走同一条路径,GPU 的 warp 不会发散,代价很小(和"逐片元条件不同"的分支完全是两回事)。所以这两处不用改。真正该拆成宏的是那种"永远不会变"的开关------比如你确定永远要开扫描,就别留分支。
4. 想要保留原色就别覆盖。 本篇 material.diffuse = color 是硬覆盖。要保留数据贴图,改成相乘:
glsl
// 想保留数据自带颜色:改成相乘而不是覆盖
vec3 color = material.diffuse * u_baseColor;
// ... 后续渐变与扫描照旧
material.diffuse = color;
最后说回这条线本身。从第 94 篇把荷兰的房子立起来,到 95 篇点灯、96 篇分级上色、本篇做扫描光带,城市白模这条线四篇走完,本质上只做了一件事:在 Cesium 的 Model 片元管线里,找到那个你能插手的 customShaderStage,然后把你想表达的东西写进 material 结构体 。管道的位置从来没变过(L129818),变的只是你往 diffuse 和 emissive 里写什么、以及写完之后谁还会来改它。
搞明白 materialStage → customShaderStage → lightingStage 这三环,这一整条线上每一个"颜色不对"的问题,你都能自己定位到第几步。
5. 性能上没什么可担心的。 这段着色器每片元大约 25 条指令(1 次 atan、1 次 sqrt、1 次 smoothstep、1 次 fract、若干乘加),比第 95 篇的点光源和第 96 篇的色带都便宜。两处 uniform 分支不会产生 warp 发散,代价可忽略。
真正值得注意的是 requestRender()。本篇在 applyUniforms 里每次都调,但底座的 createMapScene 并没有开 requestRenderMode (第 94 篇量过),场景本来就在连续渲染,所以这一句是空转------无害,但也说明作者假设了按需渲染。真要开 requestRenderMode,扫描动画就必须配一个 preUpdate 持续请求渲染,否则画面静止时扫描会停住。这一点和第 68 篇的罗盘、第 69 篇的气泡是同一个坑:凡是用 postRender / 帧号驱动的动画,遇到按需渲染都会冻住 。
把前四条的改动合并起来,本篇这段着色器的"修好版"大致是这样:
glsl
void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
vec3 positionWC = fsInput.attributes.positionWC;
float h = dc_approxEllipsoidHeight(positionWC);
float nh = clamp((h - u_gradMin) / max(u_gradMax - u_gradMin, 1.0), 0.0, 1.0);
vec3 color = material.diffuse * u_baseColor; // 保留数据原色,不再硬覆盖
if (u_useGradient > 0.5) {
color = mix(color * 0.28, color * 1.9, nh);
}
if (u_scanEnabled > 0.5) {
float cycle = abs(1.0 - fract(u_time / max(u_scanPeriod, 0.001)) * 2.0); // 三角波,往返不瞬回
float bandPos = cycle * u_gradMax; // 扫描上限复用渐变上限,不再各设各的
float dist = abs(h - bandPos);
float band = 1.0 - smoothstep(0.0, max(u_scanWidth, 0.1), dist);
color += u_scanColor * band;
}
material.diffuse = color;
material.specular = vec3(0.0);
}
四处改动:基础色改成相乘以保留原色;时钟从帧号换成真实秒数 u_time;fract 换成三角波让光带往返;扫描上限复用 u_gradMax 而不是另设 u_scanRange。改完之后,第三节量出来的"周期随帧率漂移"和"光带大部分时间扫在空气里"这两个问题就都消失了。
6. 关掉开关时的零成本。 两处 if (u_xxx > 0.5) 虽然不产生 warp 发散,但那几行指令始终在编译好的程序里。如果你确定某个开关永远停在一个状态(比如永远开扫描),把它删掉换成常量,着色器能短几行。这点优化对几万栋楼的场景意义不大,但配合第 1 条(换时钟)和第 4 条(保留原色)一起做,整段着色器会干净不少------而越短的着色器,越不容易藏住下一个 mix(..., 1.0) 那样的手滑 。
最后给一条方法论:写这类着色器时,把"数值"和"量程"分开想 。数值是你要表达的东西(本篇是高度),量程是你把它映射到 [0,1] 的方式(本篇是除以 gradMax)。本篇量出来的三个问题------写死 60、量程互不匹配、高原城市失效------本质都出在量程这一步,和着色器写得对不对没关系。
下次写一个"按某个量上色"的效果,先问三句:这个量的实际分布区间是什么?我的归一化分母取多少?区间外的部分该怎么办(clamp 掉还是循环)?把这三问答清楚,本篇踩的坑就都绕过去了。
最后一句收尾:本篇量出来的四个坑里,只有"写死 60"是真 bug,其余三条(量程互不匹配、硬覆盖、uniform 分支)都属于"能用但不够好"。改的时候按这个顺序来------先修时钟,再统一量程,最后才是风格上的取舍。这样花十分钟就能得到一个在任何机器上节奏都正确的扫描光带。
顺便说,这也是本系列一直坚持的做法:凡是能算出来的,就不要靠眼睛估。本篇这四组数字------帧率漂移、带宽的两倍关系、可见时间的两成、加光反而变暗------每一个都能用几十行 Node 脚本量出来,而它们恰恰全都是"盯着画面看不出来"的那一类。
这条线往后还能挖的是另一类问题------光照。本篇和前三篇都是自己算光照、自己叠加,始终没有碰过 Cesium 真正的光源系统:阴影怎么开、太阳方向怎么改、能不能让建筑投影到地面上。那些不在 customShaderStage 的能力范围内,得往 ShadowMap 和 scene.light 那边走。那是一整条新的线,我们换个方向再聊。

下一篇换个方向:回到地面上,看 Cesium 的材质系统 怎么给一条折线换上十种皮肤------那是一套和 CustomShader 完全不同的东西。
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在 src/cases/city-shader/ 目录下,核心就一个 CityShaderDemo.vue,高度近似那段在 src/lib/white-model-glsl.ts。搬去自己项目时,盯住四处:u_scanPeriod * 60.0 那个写死的 60 一定要换成真实时间累计,否则动画速度跟着帧率漂;u_gradMax 和 u_scanRange 是两个独立的高度量程,按你数据的真实高度统一下;两处 uniform 分支不必改,但"永远开着"的开关可以拆成宏;material.diffuse = color 是硬覆盖,想保留数据贴图就改成相乘。
第97篇 光带沿高次第升,快慢却由帧率定;
半宽十四实廿八,加青反使红变轻;
三环管线今走遍,白模一线此收声。