本文目标:把 Cesium3DTileStyle 和 CustomShader 同时接上同一份白模------分级色带是怎么按建筑体量把颜色涂上去的、它和自定义着色器在管线里究竟谁先谁后、为什么"单色银"色带里最亮的那档纯白会悄悄失效、高度渐变那行为什么只增不减、以及默认参数下画面其实已经过曝了
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
上一篇我们给白城点了一盏灯,结尾留了一句:
下一篇,我们把 style 和 CustomShader 同时请上台:给每栋楼按体量分级上色,再叠一层边缘光。要回答的问题就变成------两次上色谁先谁后,以及为什么最亮的那档色带反而涂不上去。
今天兑现。
先说清楚"两条通道"这件事。Cesium 给 3D Tiles 上色有两条完全独立的路:
Cesium3DTileStyle:在 CPU 侧按要素属性 求值。你写一句b3_volume_lod22 >= 5000之类的条件,它给每个要素算出一个颜色,塞进一张 batch 纹理,着色器再去查。它擅长"按数据分门别类",但不擅长"按像素算效果"。CustomShader:在 GPU 侧逐片元算。它擅长"按几何、按位置、按视角算效果",但读不到要素属性(除非你显式声明 metadata)。
上一篇那盏灯只用了后者。
Cesium3DTileStyle 本身还有两种实现路径,值得先分清楚,因为它决定了本文这套分析适用于哪一类:
| CPU styling | GPU styling | |
|---|---|---|
| 触发条件 | 要素来自传统 batch 表(b3dm 的 _BATCHID)或显式声明的 featureId |
3D Tiles 1.1 + glTF 元数据(EXT_structural_metadata) |
| 颜色在哪算 | JS 侧,逐要素求值后写进 batch 纹理 | GLSL 里,着色器读元数据按表达式算 |
| 着色器入口 | cpuStylingStage(L112116,本篇走的这条) |
metadataStage + 编译进材质阶段的表达式 |
| 改一次 style 的代价 | 重建 style 对象、重新求值所有要素 | 重新编译着色器 |
3DBAG 这份数据带的是传统 batch 表(属性名 b3_volume_lod22 就是表里的一列),所以走 CPU 那条路。如果你换成带 EXT_structural_metadata 的新版数据,本文"style 出现两次"的结论就不完全适用了------GPU styling 会把表达式编译进材质阶段,只出现一次。
这三篇的推进顺序也值得回头看一眼:第 94 篇解决"数据怎么组织、那层白从哪来",第 95 篇解决"怎么在片元上算出一盏灯",本篇解决"怎么让颜色层和效果层叠加而不打架"。三篇共用同一个底座(use-white-model-tileset.ts),所以相机、LOD、性能面板那几处的行为完全一致------第 94 篇量出来的"性能面板 FPS 恒等于 3"(300 毫秒定时器的锅)在本篇依然成立,不再重复。
如果你是直接从本篇读起的,只需要知道一个前提:本文讨论的一切,都发生在"瓦片已加载完毕、相机已飞到阿姆斯特丹上空 18000 米"之后。底座怎么把那棵树拉起来、那层白到底是谁涂的,请看第 94 篇;片元管线里五个 stage 的先后顺序,请看第 95 篇。本篇只讲这两条线的交汇处。
体量(volume)作为分级变量本身也值得说一句。三维城市数据里能拿来分级的属性不少------高度、层数、占地面积、体量、建造年份------其中体量是"三维感"最强的一个:它同时包含底面积和高度,一栋占地大但矮的仓库和一栋细高的塔楼可能有相同体量,而这正是"大小"这个直观感受的数学表达。
色带的选择也有讲究。五套里"地形暖色""冷色蓝""森林绿""单色银"是顺序色带 (从暗到亮单方向变化,适合表达"量"的大小);"Turbo 热力"是彩虹色带,后者在制图学里一直有争议:彩虹色带在感知上不均匀,人眼对其中某些区段的分辨力明显低于另一些,还容易读出数据中并不存在的"分层"(Turbo 里黄绿那一带看起来像是断开了,实际是连续的)。做严肃的专题图建议用顺序色带;彩虹色带好看,但别拿它读精确数值。
另一个共同点是:四套顺序色带都是亮色在低值、暗色在高值 (Turbo 反着来)。所以画面上"越大越深",符合直觉。本篇两个一起用:style 负责"每栋楼按体量该是什么颜色",着色器负责"在这个颜色上再叠一层边缘光和高度渐变"。听起来是分工明确的两层,但接上之后你会发现------它们不是简单的上下叠加,而是在管线里交错出现。
demo 是 src/cases/city-style-shader/CityStyleShaderDemo.vue(329 行),底座依旧是 src/lib/use-white-model-tileset.ts,另外引了一个共用的 GLSL 片段库 src/lib/white-model-glsl.ts。本文所有数字依旧来自两条路径:把着色器逐行直译成 Node 跑数,以及读 node_modules/cesium/Build/CesiumUnminified/Cesium.js(1.144.0)确认源码行为。
一、style 不是"涂一次",它在管线里出现两次
这是本篇最该建立的一个认知。很多人以为 tileset.style 就是"在某个环节把颜色换掉",但它在片元着色器里被用了两次 ,而且一次在你的 fragmentMain 之前、一次在之后。
先把第 95 篇那张顺序表搬回来(未压缩产物 L129751 起的 main()):
glsl
// L129809-129835
#ifdef HAS_SELECTED_FEATURE_ID
selectedFeatureIdStage(selectedFeature, featureIds); // L129810 查要素颜色
#endif
#ifndef CUSTOM_SHADER_REPLACE_MATERIAL
materialStage(material, attributes, selectedFeature); // L129814 第 1 次用 style
#endif
#ifdef HAS_CUSTOM_FRAGMENT_SHADER
customShaderStage(material, attributes, ...); // L129818 你的 fragmentMain
#endif
lightingStage(material, attributes); // L129821 太阳 + tone map
#ifdef HAS_SELECTED_FEATURE_ID
cpuStylingStage(material, selectedFeature); // L129824 第 2 次用 style
#endif
两处用到 style 的地方,代码是这样的。
第一次在 materialStage(L125770) ,它决定 material.diffuse 的初值:
glsl
// L125770 附近
bool isDefaultStyleColor(vec3 color)
{
return all(greaterThan(color, vec3(1.0 - czm_epsilon3)));
}
vec3 blend(vec3 sourceColor, vec3 styleColor, float styleColorBlend)
{
vec3 blendColor = mix(sourceColor, styleColor, styleColorBlend);
vec3 color = isDefaultStyleColor(styleColor.rgb) ? sourceColor : blendColor;
return color;
}
// ... materialStage 内
#ifdef USE_CPU_STYLING
baseColorWithAlpha.rgb = blend(baseColorWithAlpha.rgb, feature.color.rgb, model_colorBlend);
#endif
material.baseColor = baseColorWithAlpha;
material.diffuse = baseColorWithAlpha.rgb;
第二次在 cpuStylingStage(L112116) ,排在 customShaderStage 之后:
glsl
void cpuStylingStage(inout czm_modelMaterial material, SelectedFeature feature)
{
vec4 featureColor = feature.color;
if (featureColor.a == 0.0) { discard; }
// ...
featureColor = czm_gammaCorrect(featureColor);
float highlight = ceil(model_colorBlend);
material.diffuse *= mix(featureColor.rgb, vec3(1.0), highlight);
material.alpha *= featureColor.a;
}
关键在于那个 highlight = ceil(model_colorBlend)。model_colorBlend 是 colorBlendMode 和 colorBlendAmount 算出来的一个数,实测(第 94 篇量过,这里再补一遍机制):
| 模式 | 枚举值 | getColorBlend(mode, amount) |
ceil(blend) |
第二次的作用 |
|---|---|---|---|---|
HIGHLIGHT |
0 | 恒为 0 |
0 |
diffuse *= featureColor |
REPLACE |
1 | 恒为 1 |
1 |
diffuse *= vec3(1.0) ------ 乘 1,等于没做 |
MIX |
2 | 0→0.0001,其余 = amount |
1 |
diffuse *= vec3(1.0) ------ 同样等于没做 |
注意 MIX 那一行:就算把 colorBlendAmount 滑到 0,getColorBlend 也会返回 Math.EPSILON4 = 0.0001,ceil(0.0001) 依然是 1 。所以在 REPLACE 和 MIX 两种模式下,cpuStylingStage 那一句乘法都是乘 1,什么都不改变。
于是本篇(colorBlendMode = REPLACE)的真实执行顺序是:
materialStage用 style 的颜色把diffuse换掉(这是真正的"上色");customShaderStage拿到的是已经涂好色带的material.diffuse,在它上面叠边缘光和渐变;cpuStylingStage乘 1,被完全跳过;lightingStage拿最终的diffuse去和太阳做 PBR,并做 tone mapping。
这个结论有个非常实用的推论:你的 fragmentMain 里第一行 vec3 c = material.diffuse; 取到的不是数据原色,而是色带涂好的颜色。所以着色器里的"整体亮度""边缘光"都是作用在色带之上的------你想改的是原色,就得先知道你拿到的是哪一层。
selectedFeatureIdStage 排在 materialStage 之前(L129810),负责查出"当前片元属于哪个要素、这个要素被 style 涂成了什么颜色",放进 selectedFeature 结构体供后面两步使用。查询有两条路,取决于数据把要素 ID 存在哪:
js
// L115643 / L115678 / L115680
shaderBuilder.addDefine("HAS_SELECTED_FEATURE_ID", ...);
// 具体是下面两者之一:
return "HAS_SELECTED_FEATURE_ID_TEXTURE"; // 存在纹理里(batch 表)
return "HAS_SELECTED_FEATURE_ID_ATTRIBUTE"; // 存在顶点属性里
3DBAG 走的是纹理那条(batch 表本质上是一张按要素 ID 索引的纹理)。这也解释了 style 的粒度上限:同一个要素的所有片元查到的都是同一个颜色,所以色带的最小单位是一栋楼,不是一面墙、更不是一个像素。想做"同一栋楼的不同高度不同色",style 做不到,那正是着色器该干的活。
想确认 style 到底有没有接上,有个比读代码更快的办法------把混合模式临时换掉:
ts
// 自查:REPLACE 与 HIGHLIGHT 下画面应该明显不同
ts.colorBlendMode = Cesium3DTileColorBlendMode.HIGHLIGHT
如果切换之后画面毫无变化,说明 style 压根没生效(多半是属性名不对,或者数据里没有 featureId);如果明显变了,说明链路是通的,你看到的就是色带的颜色。这个"换个模式看反应"的技巧,对任何一种 styling 都适用。
Cesium3DTileStyle 能改的不只是颜色,还有 show(要素显隐)、pointSize(点云点径)、meta(自定义字段)等,但只有 color 会走本文讲的这条"两次写入"路径 ------show 在 CPU 侧直接决定要素是否绘制,pointSize 走的是点云那条支路。所以"style 出现两次"这个结论不能无脑推广到其它字段。
另外要把它和 tileset.color 区分开。后者是整棵树一个颜色 (modelColorStage,L110374,第 94 篇讲过),前者是逐要素一个颜色 。两者都叫"上色",粒度完全不同,调度条件也不一样------modelColorStage 需要 defined(model.color) 才注册(L130461),cpuStylingStage 看的是 style。本例只用了 style,没碰 color。
把本篇(REPLACE 模式)的完整时序摊开,material.diffuse 每一步是这样变化的。以"地形暖色"最低档 rgb(230,224,210) 为例:
| 步骤 | 位置 | 对 diffuse 做什么 | 之后的值 |
|---|---|---|---|
| 0 | defaultModelMaterial() L129708 |
初始化 | vec3(0.0) |
| 1 | selectedFeatureIdStage L129810 |
只查要素颜色,不改 diffuse | vec3(0.0) |
| 2 | materialStage L129814 |
blend(原色, 要素色, 1.0) → 换成色带色 |
(0.902, 0.878, 0.824) |
| 3 | customShaderStage L129818 |
渐变 ×1.0~1.4 → 亮度 ×1.15 → 加边缘光 | 约 1.037 ~ 2.152 |
| 4 | lightingStage L129821 |
乘太阳(×2/π)→ tone map → 转 sRGB | 见第四节 |
| 5 | cpuStylingStage L129824 |
diffuse *= vec3(1.0),乘 1 |
不变 |
第 5 步那一行"不变",就是本篇最重要的结论:你以为后涂的会盖住先涂的,实际上它在 REPLACE 下什么都没做 。真正决定颜色的是第 2 步和第 3 步------前者在你的 fragmentMain 之前把色带涂好,后者让你在色带之上做文章。
换个模式,层叠关系整个反过来。把 colorBlendMode 改成 HIGHLIGHT,第 2 步的 blend 系数变成 0(不替换、保留原色),第 5 步变成 diffuse *= featureColor(把要素色乘上去)------于是你的边缘光会被色带的颜色再压一遍。同一个 demo、同一段着色器,只改一个枚举值,两条通道的先后关系就变了 。这个开关不在面板上,只在源码里,而且改了之后没有任何报错,只有画面悄悄变化。也正因为第 3 步是乘 1,本篇不存在"style 覆盖掉着色器结果"的问题。但如果你把模式改成 HIGHLIGHT,第 3 步会变成"再乘一次要素颜色",你的边缘光就会被 style 的颜色再压一遍------换一个 colorBlendMode,两条通道的先后关系就变了。这是同一个 demo 里最容易被忽略的隐藏开关。

二、按体量分级:conditions 怎么写,以及那档涂不上去的白
Cesium3DTileStyle 的写法是一串 conditions,从上往下匹配,命中的第一条生效:
ts
// L131-146
function applyStyle(): void {
const ts = base.tileset.value
if (!ts) return
const alpha = Math.max(0, Math.min(1, alphaValue.value / 100))
const stops = PALETTES[paletteIndex.value].stops
const conditions: string[][] = stops.map((s) => [
`\${b3_volume_lod22} >= ${s.min}`,
toRgba(s.color, alpha)
])
conditions.push(['true', toRgba(stops[stops.length - 1].color, alpha)])
ts.style = new Cesium3DTileStyle({
color: { conditions }
})
ts.colorBlendMode = Cesium3DTileColorBlendMode.REPLACE
base.viewer.value?.scene.requestRender()
}
stops 的五套色带都是 7 档,阈值从大到小(L24-85):
| 档位(体量 m³) | 地形暖色 | 冷色蓝 | Turbo 热力 | 森林绿 | 单色银 |
|---|---|---|---|---|---|
| ≥ 5000 | rgb(84,39,9) | rgb(13,37,96) | rgb(76,0,112) | rgb(10,52,20) | rgb(255,255,255) |
| ≥ 2000 | rgb(140,72,12) | rgb(24,77,146) | rgb(60,60,205) | rgb(22,82,34) | rgb(226,230,236) |
| ≥ 800 | rgb(204,124,33) | rgb(48,120,190) | rgb(0,160,190) | rgb(44,116,52) | rgb(198,204,212) |
| ≥ 300 | rgb(240,175,82) | rgb(90,165,214) | rgb(0,200,120) | rgb(78,148,78) | rgb(170,177,187) |
| ≥ 100 | rgb(250,214,150) | rgb(150,210,232) | rgb(150,215,20) | rgb(130,185,120) | rgb(142,150,161) |
| ≥ 30 | rgb(244,235,200) | rgb(205,235,245) | rgb(245,190,0) | rgb(185,215,170) | rgb(116,124,136) |
| ≥ 0 | rgb(230,224,210) | rgb(232,244,250) | rgb(165,40,30) | rgb(220,235,210) | rgb(92,100,112) |
顺序从大到小是对的------如果反着写,>= 0 会先命中,整座城全变一个色。末尾那句 ['true', 兜底色] 其实是冗余的:最后一档已经是 >= 0,任何数都命中。留着无害,但别指望它兜底。
把 style 造出来直接在 Node 里求值,能确认分档确实按预期工作(以"地形暖色"为例):
b3_volume_lod22 |
命中颜色(归一化) |
|---|---|
| 0 / 29 | (0.9020, 0.8784, 0.8235) |
| 30 / 99 | (0.9569, 0.9216, 0.7843) |
| 100 / 299 | (0.9804, 0.8392, 0.5882) |
| 300 / 799 | (0.9412, 0.6863, 0.3216) |
| 800 / 1999 | (0.8000, 0.4863, 0.1294) |
| 2000 / 4999 | (0.5490, 0.2824, 0.0471) |
| 5000 / 20000 | (0.3294, 0.1529, 0.0353) |
那档涂不上去的白
现在看"单色银"色带的第一档:rgb(255, 255, 255),纯白。回到第一节那段源码:
glsl
bool isDefaultStyleColor(vec3 color)
{
return all(greaterThan(color, vec3(1.0 - czm_epsilon3)));
}
czm_epsilon3 实测等于 0.001 ,所以判定阈值是"三个分量都 > 0.999 "。归一化之后,(255,255,255) 是 1.0000,全部大于 0.999 → 判定为"这是默认白色" → blend() 直接返回原色,不做替换。
也就是说:体量 ≥ 5000 立方米的那批最大的建筑,在"单色银"色带下不会被涂成白色,而是保留数据自己的颜色。因为这个 demo 的数据本来就是白模,视觉上你几乎看不出差别------但这只是巧合。换成任何一份非白色的原始模型,"单色银"这一档就会露馅:最大的那批楼会是数据原色,其余是银灰。
实测确认(阈值 0.999 对应 8 位通道的整数 255):
| 色带档位 | 归一化值 | isDefaultStyleColor |
结果 |
|---|---|---|---|
| 单色银 ≥5000 | (1.0000, 1.0000, 1.0000) | 真 | 保持原色,不替换 |
| 单色银 ≥2000 | (0.8863, 0.9020, 0.9255) | 假 | 正常替换 |
| 单色银 ≥0 | (0.3608, 0.3922, 0.4392) | 假 | 正常替换 |
这个"白色 = 未着色"的约定不是 bug,是 Cesium 有意留的哨兵值------色带里没法表达"这一档不着色",于是拿纯白当那个信号。你要是自己在色带里放白色(比如想做雪山顶、想做高亮白),那一档一定不生效 。绕开的办法是把白色降到 254:rgb(254,254,254) 归一化是 0.996,小于 0.999,就能正常替换,肉眼也分不出。
顺带一提,applyStyle 里还有一句 alpha >= 1 时走 style = undefined 的分支(第 94 篇讲过),那是作者在另一处对同一条约定的响应------他知道白色是特殊的。
阈值本身也有个值得看的地方:0 / 30 / 100 / 300 / 800 / 2000 / 5000,相邻比值依次是 3.33 / 3.00 / 2.67 / 2.50 / 2.50------一条近似等比的序列。这不是随手填的:建筑体量是三维量,人对它的感知接近"边长"(三次方根)而不是体积。5000 立方米对应的立方体边长约 17 米,30 立方米约 3.1 米,中间正好覆盖普通住宅的体量跨度。按等比切,每一档覆盖的边长跨度才均匀。
toRgba 的实现也值得一提,它同时处理了两件事:
ts
// L148-152
function toRgba(color: string, alpha: number): string {
const m = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
if (!m) return `rgba(255, 255, 255, ${alpha})`
return `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${alpha})`
}
一是把 rgb(...) 正则拆成三个通道再拼回 rgba(...)(用 Color.fromCssColorString 会更稳,还能接受 rgba / #hex / 命名色);二是匹配失败时回退成白色 ------而白色恰恰是会被 isDefaultStyleColor 判为"未着色"的那个哨兵值。也就是说,色带里任何一个格式不对的颜色字符串,都会静默失效,而且失效的方式和最亮那档一模一样。
conditions 是纯字符串拼接的(\${b3_volume_lod22} >= ${s.min}),属性名写错不会有任何编译期报错------表达式求值失败时返回 undefined,比较结果为假,最后落到 ['true', ...] 兜底。这类"静默降级"在本系列反复出现:不报错,只是悄悄给你一个错误的结果。
分级方法本身是专题制图里的一门学问,常见的有四种:
| 方法 | 做法 | 适用 |
|---|---|---|
| 等间距 | 最大最小值之间均分 | 分布均匀 |
| 分位数 | 每档装同样多个要素 | 分布偏态(最常见) |
| 自然断点 | 按数据簇自动找断点 | 数据有明显聚集 |
| 标准差 | 以均值为中心按 σ 分档 | 要看偏离程度 |
本例的阈值(0 / 30 / 100 / 300 / 800 / 2000 / 5000)是作者按经验给的近似等比序列,不是从数据分布算出来的 。而建筑体量恰恰是典型的右偏分布------绝大多数是普通住宅,少数是大型综合体。经验阈值很容易让下面几档挤成一团、上面几档空着,第四节量出来的"最下两档分不出来"就是证据之一。
更稳的做法是先统计一遍分布:拿到 b3_volume_lod22 的最小值、分位数(P10 / P25 / P50 / P75 / P90)和最大值,再用分位数定档。这样每档的要素数量才均衡,画面上的颜色分布也才均匀。
五套色带各自适合什么,也值得有个说法:
| 色带 | 类型 | 适合 |
|---|---|---|
| 地形暖色 | 顺序(暖) | 通用首选,暗部不发脏,适合长时间看 |
| 冷色蓝 | 顺序(冷) | 与夜景、水系这类冷色底图搭配 |
| Turbo 热力 | 彩虹 | 对比度最高、最容易出图,但感知不均匀 |
| 森林绿 | 顺序(绿) | 与植被、地形配色统一的场景 |
| 单色银 | 顺序(灰) | 想弱化颜色、只突出几何轮廓时 |
"单色银"除了最亮那档白色会失效,还有个细节:它从 rgb(92,100,112) 到 rgb(255,255,255) 是偏冷的灰,最低档明显带蓝。所以在这套色带下,小体量的楼会显得偏蓝灰、大体量的楼偏白。想要中性灰得自己调,把三个通道拉齐。
另外,conditions 里的值不只能写常量颜色。Cesium 的样式表达式支持算术和内置函数,连续插值也做得到:
js
// 表达式能力示例(说明边界,非本例代码)
color: "colorMix(color('#8c480c'), color('#fad696'), clamp((${b3_volume_lod22} - 100) / 2000, 0, 1))"
本例之所以选七档分级而不是连续映射,是因为分级在图上更好读、更容易配图例。但如果你要的是平滑过渡,不必硬套 conditions------代价是这类表达式在 CPU styling 下要逐要素求值,要素一多开销就上来了。

三、着色器那 12 行:边缘光与"只增不减"的高度渐变
着色器本体很短,但每一行都有话说:
glsl
// L115-129
void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
vec3 N = normalize(fsInput.attributes.normalEC);
vec3 V = normalize(-fsInput.attributes.positionEC);
float rim = pow(max(1.0 - max(dot(N, V), 0.0), 0.0), 2.5);
vec3 c = material.diffuse; // ← 拿到的是色带涂好的颜色
float h = dc_approxEllipsoidHeight(fsInput.attributes.positionWC);
float nh = clamp((h - u_gradMin) / max(u_gradMax - u_gradMin, 1.0), 0.0, 1.0);
c = mix(c * (1.0 - 0.3 * u_gradStrength), c * (1.0 + 0.8 * u_gradStrength * nh), 1.0);
c = c * u_brightness;
c += rim * u_rimColor * u_rimStrength;
material.diffuse = c;
material.specular = vec3(0.0);
}
边缘光:只在很掠射的角度才亮
rim = pow(1 - dot(N,V), 2.5) 是最经典的 Fresnel 近似:法线越接近垂直于视线(也就是面越"侧对着你"),值越大。
dot(N, V) |
视线与法线夹角 | rim |
|---|---|---|
| 1.0 | 0°(正对) | 0.0000 |
| 0.9 | 25.8° | 0.0032 |
| 0.7 | 45.6° | 0.0493 |
| 0.5 | 60.0° | 0.1768 |
| 0.3 | 72.5° | 0.4100 |
| 0.1 | 84.3° | 0.7684 |
| 0.0 | 90°(完全侧对) | 1.0000 |
半亮(rim = 0.5)出现在 76°------也就是说,边缘光只在极掠射的一圈上才明显,正对你的墙面几乎为零。指数 2.5 控制的就是这个收束程度:指数越大,亮边越窄越锐。
效果上这是"给楼描一圈光边",对于从高空俯视的城市很合适------俯视时屋顶正对相机(rim≈0)、侧面掠射(rim 大),于是楼的四壁会亮起来、屋顶相对暗,立体感立刻出来。
高度渐变那行是只增不减的
这一行看着像"低处压暗、高处提亮",但它是恒等式:
glsl
c = mix(c * (1.0 - 0.3 * u_gradStrength), c * (1.0 + 0.8 * u_gradStrength * nh), 1.0);
mix(a, b, 1.0) 永远等于 b。所以第一个参数 c * (1.0 - 0.3 * u_gradStrength)(低处压暗那一半)一次都没被用过,整行退化成:
c = c * (1.0 + 0.8 * u_gradStrength * nh)
实测两条路径对比(低处 nh=0、高处 nh=1 的亮度倍率):
| 强度 g | 实际:低处 | 实际:高处 | 若改成 mix(..., nh):低处 |
高处 |
|---|---|---|---|---|
| 0 | 1.0000 | 1.0000 | 1.0000 | 1.0000 |
| 0.25 | 1.0000 | 1.2000 | 0.9250 | 1.2000 |
| 0.5 | 1.0000 | 1.4000 | 0.8500 | 1.4000 |
| 0.75 | 1.0000 | 1.6000 | 0.7750 | 1.6000 |
| 1.0 | 1.0000 | 1.8000 | 0.7000 | 1.8000 |
低处那一列恒为 1.0000 :不管滑块怎么拉,地面附近的建筑亮度纹丝不动,只有高处被提亮。所以这个"高度渐变"实际是一个单向的提亮斜坡 ,不是渐变配色。想要"低处更暗、高处更亮"的对比,把第三个参数从 1.0 改成 nh 就行:
glsl
c = mix(c * (1.0 - 0.3 * u_gradStrength), c * (1.0 + 0.8 * u_gradStrength * nh), nh);
顺便说,这个滑块的名字叫"高度渐变",但它改的只是亮度倍率 (1.0 ~ 1.8),跟颜色渐变毫无关系。想做真正的高度配色,得让 mix 的两端是两个不同的颜色。
边缘光加在 diffuse 上,是要付代价的
注意最后一行写的是 material.diffuse = c,不是 material.emissive。这和第 95 篇那盏灯正好相反------那篇把光照结果塞进 emissive 绕开了整个光照系统,本篇的边缘光留在 diffuse 里,于是它会被 lightingStage 再乘一遍太阳。
后果是:边缘光的亮度跟着太阳方向走。太阳在东边时,朝东的墙面那一圈光边比朝西的明显;转到背光面,边缘光会被压得很暗。你调出来的"描边效果"在不同朝向的楼上强度并不一致,而这一点从面板上完全看不出来------面板只告诉你 rimStrength 是 0.7,不会告诉你它在背光面还剩多少。
想要"不受太阳影响的光边",把它挪到 emissive 就行:
glsl
// 改法:边缘光走自发光通道,不参与光照
material.emissive += rim * u_rimColor * u_rimStrength;
material.diffuse = c; // 色带 + 亮度 + 渐变仍留在 diffuse
material.specular = vec3(0.0);
代价和第 95 篇说过的一样:走 emissive 就没有阴影、没有环境光遮蔽。对于"描一圈光边"这种纯装饰效果,这点代价完全可以接受------而且第四节会量出它还有一个额外好处。
顺带说两个容易被跳过的细节。
第一个:rim 不受亮度滑块影响。 看这两行的先后:
glsl
c = c * u_brightness; // 亮度只乘到这一步为止
c += rim * u_rimColor * u_rimStrength; // 边缘光是之后才加的
u_brightness 先乘、rim 后加,所以拖"整体亮度"对边缘光一点作用都没有 。想让光边跟着整体一起变亮,得把 u_brightness 也乘到 rim 上,或者把两行换个顺序------但换顺序又会改变 rim 与底色的比例关系。这是"运算顺序即语义"的又一个例子:同样的两行代码换个位置,含义就变了。
第二个:指数 2.5 的来历。 这类菲涅尔近似常见写法有 pow(1 - ndotv, 5)(Schlick 近似的原始指数)、pow(1 - ndotv, 3)、pow(1 - ndotv, 2.5),指数越大亮边越窄。本例的 2.5 是偏软的一档:在 dot(N,V) = 0.5(60°)处,指数 5 给出 0.0313,指数 2.5 给出 0.1768,后者是前者的 5.6 倍。所以本例的光边不是一条细线,而是一圈相当宽的辉光,正好适合从高空俯视的楼群。想要更锐利的描边,把 2.5 提到 4 或 5。
还有一行容易被跳过:material.specular = vec3(0.0)。它把 PBR 的 f0(正入射反射率)清零,等于关掉了全部镜面反射 。对白模这种哑光材质这是对的------真实的外墙不是镜面,留着默认高光会让楼看起来像塑料。代价是:后面想给玻璃幕墙加反射,得把这一行去掉,并配合 material.roughness 一起调。第 95 篇那盏灯也写了同样一行,两处的理由完全一样。
至于 fsInput.attributes.positionWC,它不是默认就有的。CustomShader 构造时会调 findUsedVariables(L243846)静态扫描你写的 GLSL,发现用到了 positionWC 才自动开启 COMPUTE_POSITION_WC_CUSTOM_SHADER 并生成对应的 varying。也就是说写了就有、不写就不生成,不会白白多算一个世界坐标。这段逻辑和第 95 篇讲的是同一处,也是同一个提醒:别在注释里顺手写不用的变量名,它可能被扫进去。
把这一节的三个效果按作用顺序排一下,能看清它们各自的"作用域":色带 (style,每栋楼一个值)→ 高度渐变 (着色器,每个片元按海拔插值)→ 整体亮度 (着色器,全局乘)→ 边缘光(着色器,按视角加)。四个效果分属三个不同的粒度,混在一起调的时候很容易搞不清自己在改哪一层。记住这个顺序,调参时就知道该动哪一个------想要"某栋楼更红"改色带,想要"楼顶更亮"改渐变,想要"整体提亮"改亮度,想要"轮廓更清楚"改边缘光。
高度是怎么来的:一段自写的近似
fsInput.attributes.positionWC 是片元的世界坐标,转成"海拔"用的是仓库里共用的一段 GLSL(src/lib/white-model-glsl.ts):
glsl
float dc_approxEllipsoidHeight(vec3 p) {
float a = 6378137.0;
float e2 = 0.00669437999014;
float pxy = length(p.xy);
float b = a * sqrt(1.0 - e2);
float theta = atan(p.z * a, pxy * b);
float ep2 = e2 / (1.0 - e2);
float xh = pxy - a * cos(theta);
float yh = p.z - b * sin(theta);
float zh = pxy * ep2 / a * (b * sin(theta) - p.z);
return length(vec3(xh, yh, zh)) - zh;
}
为什么不直接用 Cartographic.fromCartesian 那套?因为那是 CPU 侧的迭代算法,GPU 上写不划算。这段是一次性的近似公式(本质是"大地纬度反算"的一步迭代闭式解)。拿它和 Cesium 的精确值对一遍(阿姆斯特丹上空):
| 真高 (m) | 近似值 (m) | 误差 (m) | 相对误差 |
|---|---|---|---|
| 10 | 10.0327 | +0.0327 | 0.3268% |
| 30 | 30.0980 | +0.0980 | 0.3268% |
| 60 | 60.1961 | +0.1961 | 0.3268% |
| 100 | 100.3268 | +0.3268 | 0.3268% |
| 300 | 300.9805 | +0.9805 | 0.3268% |
| 1000 | 1003.2687 | +3.2687 | 0.3269% |
误差稳定在 0.3268% 左右,是个纯粹的比例偏差。对本篇"0 到 60 米"的量程来说,偏差最多 0.2 米,完全够用。纬度上也有依赖(真高 60 m 时:赤道 0.0000 m、40° 0.1993 m、85° 0.0352 m),但同样是厘米到分米级。
最后提醒一句:u_gradMin = 0、u_gradMax = 60 是写死在构造里的 (L162-163),面板上没有对应的滑块。所以"高度渐变"的量程永远是 0 到 60 米------对阿姆斯特丹这种以低层住宅为主的城市刚好,拿到有高层的地方,超过 60 米的部分全被 clamp 到 1,渐变就失效了。

四、默认参数下,画面其实已经过曝了
上一篇我们说过:写进 material.diffuse 的值,还会被 lightingStage 拿去和太阳做一次 PBR。本篇这个 demo 恰恰写的是 material.diffuse(不是 emissive),所以色带 + 亮度 + 边缘光加起来之后,还要再乘一遍太阳。
把默认参数("地形暖色"最低档 rgb(230,224,210)、亮度 1.15、渐变 0.5、边缘光 0.7 白光)代进去,走完整个管线:
| 场景 | 着色器算完的 diffuse | 乘太阳后(×2/π) | 最终 RGB | 触发 tone map |
|---|---|---|---|---|
| 平地、无边缘光 | (1.0373, 1.0102, 0.9471) | 0.6603 | (0.8049, 0.7947, 0.7701) | 否 |
| 平地、满边缘光 | (1.7373, 1.7102, 1.6471) | 1.1060 | (0.9506, 0.9437, 0.9276) | 是 |
| 楼顶、无边缘光 | (1.4522, 1.4143, 1.3259) | 0.9245 | (0.9248, 0.9133, 0.8858) | 是 |
| 楼顶、满边缘光 | (2.1522, 2.1143, 2.0259) | 1.3701 | (0.9670, 0.9595, 0.9416) | 是 |
(计算方式沿用第 95 篇那套:czm_pbrNeutralTonemapping 在峰值 > 0.76 后启动压缩并去饱和,最后 czm_linearToSrgb 用 pow(x, 1/2.2) 转回。)
结论有点反直觉:默认参数里"整体亮度 1.15"就已经把楼顶推过 1.0 了 ,只是太阳那一乘(×2/π ≈ 0.6366)把它拉回 0.92,仍然超过 0.76 的门槛。所以你一进这个 demo,楼顶和所有掠射边缘就已经在被 tone mapping 压缩------色带设计出来的那些层次,有一部分在显示前就被压平了。
两个"亮度类"滑块的实际有效区间也因此很窄:
| 亮度滑块 | 乘太阳后 | 最终首通道 |
|---|---|---|
| 0.5 | 0.5673 | 0.7476 |
| 0.8 | 0.7741 | 0.8689 |
| 1.15(默认) | 1.0152 | 0.9404 |
| 1.5 | 1.2564 | 0.9615 |
| 2.0 | 1.6009 | 0.9745 |
| 边缘光强度 | 乘太阳后 | 最终首通道 |
|---|---|---|
| 0 | 0.7924 | 0.8787 |
| 0.7(默认) | 1.2380 | 0.9604 |
| 1.2 | 1.5563 | 0.9733 |
| 2.0 | 2.0656 | 0.9824 |
从默认档往上推,最终亮度只从 0.94 动到 0.97------滑块后半程基本是无用功。真正能拉开差距的是 0.5 到 1.0 这一段。所以调这个 demo 的正确姿势是:先把亮度降到 1.0 以下让画面退出 tone map,再去调色带和边缘光的相对关系;而不是一上来就把亮度推到 2。
过曝吃掉的,是色带本身的层次
上一节只说了"画面太亮",这一节说一个更实际的后果:色带辛辛苦苦分出来的七个档,最后还剩几档分得出来。
把"地形暖色"七档挨个走完整个管线,量相邻两档在最终画面上的颜色距离(RGB 欧氏距离,0 表示完全同一个颜色):
| 档位 | 源色 | 乘太阳后峰值 | 最终 RGB | 与上一档色差 |
|---|---|---|---|---|
| ≥5000 | (84,39,9) | 0.3376 | (0.587, 0.394, 0.113) | --- |
| ≥2000 | (140,72,12) | 0.5627 | (0.749, 0.538, 0.146) | 0.2193 |
| ≥800 | (204,124,33) | 0.8200 | (0.892, 0.701, 0.339) | 0.2905 |
| ≥300 | (240,175,82) | 0.9647 | (0.933, 0.803, 0.556) | 0.2429 |
| ≥100 | (250,214,150) | 1.0049 | (0.939, 0.873, 0.738) | 0.1956 |
| ≥30 | (244,235,200) | 0.9807 | (0.935, 0.919, 0.852) | 0.1225 |
| ≥0 | (230,224,210) | 0.9245 | (0.925, 0.913, 0.886) | 0.0360 |
最下面两档的色差只有 0.1225 和 0.0360 ------30~100 立方米和 0~30 立方米这两档,在屏幕上是同一个颜色。七档色带实际只呈现出五档。
再看亮度滑块对这件事的影响:
| 亮度 | 相邻档平均色差 | 最大单段 | 最小单段 |
|---|---|---|---|
| 0.5 | 0.1333 | 0.2104 | 0.0285 |
| 0.8 | 0.1655 | 0.2434 | 0.0345 |
| 1.15(默认) | 0.1845 | 0.2905 | 0.0360 |
| 1.5 | 0.1804 | 0.2713 | 0.0391 |
| 2.0 | 0.1655 | 0.2490 | 0.0387 |
有意思的是:默认的 1.15 恰好落在层次最好的位置,往两头调都会变差。这大概是作者试出来的而不是算出来的,但既然它是最优点,就别轻易动它。
真正会毁掉层次的是另一个旋钮。边缘光是加法,给每一栋楼都加上同样一份白光,于是色带原本的差异被整体冲淡:
| 边缘光强度(掠射处) | 相邻档平均色差 | 相对 rim = 0 |
|---|---|---|
| 0 | 0.1845 | 100% |
| 0.7(默认) | 0.0685 | 37.1% |
| 1.2 | 0.0457 | 24.8% |
| 2.0 | 0.0325 | 17.6% |
默认情况下色带层次只剩三分之一;把边缘光推到 2.0,只剩不到两成。
所以这个 demo 里有两个旋钮是互相打架的:"边缘光强度"想要的,正是"体量分级"想表达的 。你要看清体量分布,就把边缘光压到 0.2 以下;你要那种漂亮的描边质感,就得接受七档色带看起来像三四档。想两头都要,唯一的正解是把边缘光挪到 emissive------它是加法,加在自发光通道里不会稀释 diffuse 上已经分好的档位。
把两篇摆在一起,diffuse 与 emissive 这两条路的差别就很完整了:
| 第 95 篇(点光源) | 第 96 篇(色带 + 边缘光) | |
|---|---|---|
| 写进哪个字段 | emissive |
diffuse |
| 是否参与 PBR 光照 | 否,直接加到结果上 | 是,会被太阳再乘一遍(×2/π ≈ 0.6366) |
| 是否受朝向影响 | 不受 | 受,背光面明显变暗 |
| 是否走 tone map | 走 | 走 |
| 有无阴影 / 环境光遮蔽 | 没有 | 有(diffuse 参与完整 PBR) |
| 最终亮度是否可控 | 完全可控 | 被太阳和 tone map 两头夹 |
所以选哪条路不是风格问题,是需求问题:要"我说了算"就走 emissive,要"融入场景"就走 diffuse 。本例想让色带跟着太阳一起有明暗变化,所以选了 diffuse,代价就是本文量出来的这些过曝。
如果既想保留 diffuse 的融入感、又想躲开 tone map 的压缩,还有一个选项是打开 scene.highDynamicRange:此时 lightingStage 里的 #ifndef HDR 不成立,着色器内不做 tone mapping,改由后处理阶段的 ToneMapping(默认 NEUTRAL)在全屏合成后统一处理。本文这些表就都得重算,但色带的层次会保留得更多。
判断画面有没有进 tone map,有个不用看代码的现场办法:把"整体亮度"从当前值一路往下拖,看画面会不会在某个点突然"活过来"------颜色变鲜、层次变多。那个拐点就是退出 tone map 的位置。反过来,往上拖如果画面几乎不动,说明已经压到头了。
更硬一点的办法是找一个已知颜色当参照:色带里最亮的那一档("地形暖色"的 ≥100 档是 rgb(250,214,150),一个很浅的暖黄),如果它在画面上看起来接近纯白,那 tone map 一定已经在工作。这个方法对任何"颜色不对劲"的排查都适用------先确认是不是被 tone map 压了,再去调别的东西 。第 95 篇那盏灯遇到的其实是同一件事:强度拉满之后颜色褪成白,根因就在这个 tone mapper 的最后那行 mix。
把这两节合起来看,本篇真正想说的是:"好看"和"可读"在这个 demo 里是两个不同的目标,而默认参数偏向了前者。亮度 1.15 加边缘光 0.7 让画面通透有光泽,代价是七档色带只剩三档半的分辨力、两个滑块的下半段基本失效。如果你的目的是展示体量分布,把边缘光降到 0.2、亮度降到 0.9,画面会"素"一些,但每一档都分得清------专题图的第一要务是被读懂,不是被赞叹。
还有一点必须说明:本文所有数字都是在"地形暖色"这一套色带下量出来的。换一套色带,过曝的临界点和层次损失的比例都会变------Turbo 那套整体偏亮,会更早触到 tone map;单色银最暗的几档则可能永远进不去。所以别把上面这些表当成通用常数,它们是"这一组参数下的实测",换色带就得重算。好在思路不变:先确认有没有进 tone map,再量相邻档的色差。

五、切一次 LOD,配色就散了
这一节讲三个"换个档位就露馅"的问题,都跟那份数据的属性名有关。
属性名里带着 LOD 后缀
分级用的是 b3_volume_lod22------注意那个 lod22 后缀。而底座的 LOD 下拉框有三档(LOD_OPTIONS,L21-25):
ts
export const LOD_OPTIONS = [
{ label: 'LOD 2.2(精细)', suffix: 'lod22' },
{ label: 'LOD 1.3(简化)', suffix: 'lod13' },
{ label: 'LOD 1.2(最简)', suffix: 'lod12' }
]
切档走的是 removeModel() + addModel()(第 94 篇量过,整棵树连 GPU 资源一起销毁重建),新 tileset 建好后再 applyStyle()。如果 lod13 / lod12 的数据里没有同名属性,那串 conditions 就会全部落空 ------b3_volume_lod22 >= N 对 undefined 求值为假,最后只剩 ['true', 兜底色] 命中,整座城变成单色。
这个后果界面上是看不出来的:色带下拉框还显示着"地形暖色",透明度滑块还能拖,只有画面悄悄变成了一种颜色。稳妥的做法是切档时同步换属性名,或者按档位分别准备一套 conditions。
tileVisible 这个事件本身也值得说一句:它在每个瓦片变为可见时 触发一次,相机一移动就会连着触发几十上百次。demo 靠 if (inspected) return 把后续调用全挡掉了,所以实际开销可以忽略------但如果去掉那行早退,这里就变成每帧几十次 getFeature。
切档的完整链路是这样的(底座 use-white-model-tileset.ts):
ts
// L157-163 + 本篇 L205-207
async function switchLod(onTilesetReady?: OnTilesetReady): Promise<void> {
const v = viewer.value
if (!v || v.isDestroyed()) return
removeModel() // primitives.remove():连 GPU 资源一起销毁
statusMessage.value = `正在加载 ${LOD_OPTIONS[lodIndex.value].label} 建筑模型...`
await addModel(onTilesetReady) // 重新 fromUrl + 重设 SSE / skipLevelOfDetail
}
// addModel 末尾:onTilesetReady?.(v, model)
// → applyCustomization → applyStyle() + new CustomShader(...) + applyUniforms()
可以看到 applyStyle() 和 new CustomShader(...) 都在 onTilesetReady 里重新执行了,切档本身不会漏掉上色。真正的问题不是没重新上色,而是重新上色时用的还是 lod22 的属性名。
reset()(L171-178)走的是同一条路,所以"重置"按钮同样不会把 inspected 复位------面板上那三个数字依旧是最早读到的那一栋楼。
3DBAG 这份数据本身也值得多说一句:它是荷兰官方把二维房产登记(BAG)叠加高度信息后生成的三维建筑数据集,覆盖全荷兰一千多万栋建筑,按 LOD 1.2 / 1.3 / 2.2 三档发布。本例默认用 LOD 2.2(最精细),这正是属性名里 lod22 后缀的来源------同一份属性在不同 LOD 下的数值并不相同,因为简化后的建筑外形变了,算出来的体量自然跟着变。
于是还有一层隐患:就算 lod13 的数据里保留了同名属性,它的数值分布也和 lod22 不一样。所以"切档后沿用同一套阈值"这件事,即便属性名对得上,配色的含义也会漂移------同一栋楼在两档下可能落到不同的色带档位,你看到的不是同一张专题图。
最后补一件很实际的事:专题图要配图例 。这个 demo 有五套色带的下拉框,却没有图例------读者看到七种颜色,并不知道哪种对应多大体量。做正式的可视化时,把 PALETTES 里的 stops 直接渲成一条横向色带,标上阈值和单位,比任何文字说明都管用。数据结构里 min 和 color 都已经齐了,渲染图例是十几行 CSS 的事,本例缺的只是这一步。
顺便把那三个属性名的含义说清楚,因为它们都是荷兰语缩写:b3_volume_lod22 是 LOD 2.2 精度下算出的建筑体量(立方米),b3_opp_grond 取自 "oppervlakte grond"(占地面积,平方米),oorspronkelijkbouwjaar 是"原始建造年份"。前两个是 3DBAG 派生的几何量,第三个来自荷兰官方的房产登记(BAG)------也就是说,这个面板把"几何量"和"登记属性"混在一起展示了。
再补一句半透明。透明度低于 100% 时 toRgba 给颜色加上 alpha,cpuStylingStage 最后一行 material.alpha *= featureColor.a 把整栋楼变半透明,瓦片随之进 translucent pass。半透明渲染依赖排序,而 3D Tiles 的排序粒度是瓦片 不是三角形------同一个瓦片内部的楼之间没有排序保证。所以视角一动,可能出现"后面的楼画在前面的楼之上"的穿插闪烁。小范围调着玩没问题,真要做"半透明看内部",应该用裁剪面(ClippingPlane)而不是全局半透明。
要把"建筑属性"做成真正可用的查询,改动并不大:把 tileVisible 换成鼠标点击,用 scene.pick 拿到 Cesium3DTileFeature 再读属性;同时在 switchLod 和 reset 里把 inspected 复位,让新档位能重新取一次。
ts
// 改法:点哪栋楼就显示哪栋的属性(示意)
handler.setInputAction((movement: { position: Cartesian2 }) => {
const picked = viewer.scene.pick(movement.position)
if (picked instanceof Cesium3DTileFeature) {
const v = picked.getProperty('b3_volume_lod22')
if (typeof v === 'number') buildingVolume.value = Math.round(v)
}
}, ScreenSpaceEventType.LEFT_CLICK)
这样面板那行"建筑属性"才算名副其实------现在是"某栋楼的属性",改完才是"你点那栋楼的属性"。顺带把异常也报出来,别用空的 catch 把失败吞掉,否则属性名写错时你只会看到一行永远不动的"读取中"。### 面板上那三个"建筑属性",读的是任意一栋楼
ts
// L185-203
function inspectFeature(tile: { content: { getFeature: (i: number) => unknown } }): void {
if (inspected) return
try {
const feature = tile.content.getFeature(0) as { getProperty: (name: string) => unknown }
const volume = feature.getProperty('b3_volume_lod22')
const area = feature.getProperty('b3_opp_grond')
const year = feature.getProperty('oorspronkelijkbouwjaar')
if (typeof volume === 'number' && Number.isFinite(volume)) {
buildingVolume.value = Math.round(volume)
buildingArea.value = typeof area === 'number' ? Math.round(area) : null
buildingYear.value = typeof year === 'number' ? Math.round(year) : null
inspected = true
}
} catch { /* ignore inspection failures */ }
}
三件事值得注意:
取的是 getFeature(0) ,也就是当前瓦片里第 0 个要素,不是鼠标指的那栋楼。面板标题叫"建筑属性",但它显示的其实是"某个瓦片里第一栋楼的属性"。
inspected 一旦置真就再也不取了 (L186 的早退),而它在 onBeforeUnmount 里没有重置。所以切 LOD 之后(甚至 reset() 之后),这三个数字还是第一次成功读取的那一栋的,跟眼前的楼对不上。
异常被静默吞掉 (L200 的 catch {})。如果属性名不对或者瓦片还没准备好,面板会一直显示"建筑属性读取中...",没有任何提示。
要把它做成真正可用的属性查询,得改成鼠标拾取:scene.pick 拿到 Cesium3DTileFeature 再 getProperty,并在切档时把 inspected 复位。
拖一次透明度,重建一次 style
applyStyle 每次调用都 new Cesium3DTileStyle(...)(L141)。透明度滑块是 10% 到 100%、步长 5,共 19 档 ------从一头拖到另一头就是 19 次 style 重建、19 次 conditions 字符串重新解析。对比第 95 篇那个"改 uniform + requestRender()"的零重建写法,这里是重的一侧。
另外透明度 < 100% 时,toRgba 会给颜色加上 alpha,cpuStylingStage 里 material.alpha *= featureColor.a(L112116 最后一行)会把整栋楼变半透明。半透明意味着瓦片要进 translucent pass,随之而来的是排序问题:楼和楼之间的前后关系不再由深度缓冲保证,视角一动可能出现穿插闪烁。
六、搬到自己项目前要改的四处
1. 阈值要按你的数据重设。 7 档阈值(5000 / 2000 / 800 / 300 / 100 / 30 / 0)是针对 3DBAG 的建筑体量分布的。换一份数据是城中村还是摩天楼,分布完全不同------先统计一遍你的属性分布(最小值、分位数、最大值),再定档位。档位数量也不是越多越好:色带的辨识度在 5 到 7 档之间最好,超过 9 档人眼就分不出来了。
2. 色带里别放纯白。 前面说过,rgb(255,255,255) 会被 isDefaultStyleColor 判为"未着色"而跳过替换。想要白色请用 rgb(254,254,254)。
3. mix 的第三个参数。 高度渐变那行的 1.0 改成 nh,低处压暗那一半才会真正生效。顺便把 u_gradMin / u_gradMax 提成一 uniform,别写死 0 和 60------拿到有高层的数据时会直接失效。
4. 显式声明 lightingModel。 和第 95 篇同一个坑:CustomShader.lightingModel 默认是 undefined,实际按"几何有没有法线"判定(L125880-125884)。一旦落到 UNLIT,lightingStage 走 color = material.diffuse,虽然本篇的结果不受影响(因为我们本来就写 diffuse),但 tone mapping 那一步会被跳过------过曝的部分会以另一种方式显示,和你调参时的预期不一致。加一行就稳:
ts
shader = new CustomShader({
lightingModel: LightingModel.PBR, // 显式声明,别让管线去猜
uniforms: { /* ... */ },
fragmentShaderText: SHADER_FRAGMENT
})
5. 把量程提上来。 u_gradMin / u_gradMax 现在写死在 new CustomShader 的 uniforms 里(L162-163),面板上没有入口。最小改动是把它们变成两个 ref 并在 applyUniforms 里同步:
ts
// 建议:把量程也做成 uniform,别写死 0 和 60
const gradMin = ref(0)
const gradMax = ref(60)
// ...uniforms 里改成读 ref,applyUniforms 里补两行:
shader.setUniform('u_gradMin', gradMin.value)
shader.setUniform('u_gradMax', gradMax.value)
顺手把高度渐变那行的 1.0 也改掉。两处一起修完,核心就是这样一个版本:
glsl
// 修好的版本:低处压暗真正生效,量程可调,边缘光不再稀释色带
float nh = clamp((h - u_gradMin) / max(u_gradMax - u_gradMin, 1.0), 0.0, 1.0);
c = mix(c * (1.0 - 0.3 * u_gradStrength), c * (1.0 + 0.8 * u_gradStrength * nh), nh);
c = c * u_brightness;
material.emissive += rim * u_rimColor * u_rimStrength; // 边缘光走自发光
material.diffuse = c;
material.specular = vec3(0.0);
三处改动各自解决一个本文量出来的问题:nh 让低处压暗真正生效;量程可调让它不至于在高层城市失效;emissive 让边缘光不再吃掉色带层次。
6. 开销心里有数。 这段着色器每片元大约 20 条指令(2 次 normalize、1 次 pow、1 次 atan、1 次 sqrt、若干乘加),比第 95 篇那盏灯还便宜一点。真正贵的是 applyStyle------拖一次透明度就重建一次 Cesium3DTileStyle 并重新解析 7 条 conditions 字符串,19 档滑下来就是 19 次。档位更多(比如 12 档)或者要素数量很大时,这里就该改成"只改 alpha、不重建 style":把 alpha 做成一个 uniform,在着色器里乘上去。
改动清单收一下,一共六条:阈值按分布重算、色带别放纯白、mix 第三个参数改 nh、gradMin / gradMax 提成可调、边缘光挪到 emissive、透明度改成 uniform。前三条属于修 bug (不改就得不到你要的画面),后三条属于改设计(不改也能跑,只是可读性和性能有损失)。分清楚这两类,你就知道哪几条必须做、哪几条可以缓一缓。
最后把两条通道的分工再说清楚一遍,这是本篇最值得带走的东西:Cesium3DTileStyle 决定"每个要素是什么颜色",CustomShader 决定"每个像素再叠什么效果" 。前者在 materialStage 里就把颜色定好了,后者拿到的已经是涂完色带的 diffuse。只要 colorBlendMode 是 REPLACE 或 MIX,后面那次 cpuStylingStage 就是乘 1,不会回来覆盖你------你以为的"后涂的盖住先涂的",在这个管线里并不成立。


下一篇,我们撤掉 style,只留 CustomShader 一条通道:给白模做一条沿高度向上扫的光带。那时候要回答的问题变成------这部动画的时钟是谁给的,为什么它跑起来的速度和你想的不一样。
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在 src/cases/city-style-shader/ 目录下,核心就一个 CityStyleShaderDemo.vue,高度近似那段在 src/lib/white-model-glsl.ts。搬去自己项目时,盯住四处:conditions 的七档阈值要按你的属性分布重算;色带里别放 rgb(255,255,255)(会被当成"未着色"跳过);高度渐变那行 mix 的第三个参数要从 1.0 改成 nh,并把 u_gradMin/u_gradMax 提成可调;CustomShader 记得显式写 lightingModel: LightingModel.PBR。
第96篇 体量分级涂七色,最白一档落了空;
边缘描光六十米,亮度先输太阳功;
两条通道谁先后,material 里见行踪。