第96篇:三维数据加载-城市建筑白模样式+着色器——让色带按体量上色,最白的那档却涂不上去

本文目标:把 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)的真实执行顺序是:

  1. materialStage 用 style 的颜色把 diffuse 换掉(这是真正的"上色");
  2. customShaderStage 拿到的是已经涂好色带的 material.diffuse,在它上面叠边缘光和渐变;
  3. cpuStylingStage 乘 1,被完全跳过;
  4. 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 里见行踪。

相关推荐
Hilaku1 小时前
你们公司有 35 岁以上的前端吗?
前端·javascript·程序员
徐小夕1 小时前
开源Vector-Graph-RAG:向量+图谱双引擎,打破纯向量RAG的效果天花板
前端·vue.js·github
SameX1 小时前
HarmonyOS ArkTS 把组件截图保存成 PNG 再调系统分享面板的做法
前端
Swot1 小时前
SvelteKit SSG 发布后,为什么老访客非要强刷才能看到新内容?
前端
鸿蒙开发1 小时前
小程序存档写到一半怎么办?双槽保存与损坏恢复实战
前端·javascript·react.js
卸任3 小时前
AI 编程工具越用越多,模型配置越来越乱:用 Magpie 把它们管起来
前端·ai编程
前端一课3 小时前
凝神诀
前端
前端双越老师3 小时前
程序员 1v1 访谈系列第十三期:大龄已育空窗一年如何破局
前端·ai编程·全栈
恋猫de小郭4 小时前
Flutter 大小核优化又被回滚,然后又开新提案?
android·前端·flutter