第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算

本文目标:把风格撤掉,只留 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篇 光带沿高次第升,快慢却由帧率定;

半宽十四实廿八,加青反使红变轻;

三环管线今走遍,白模一线此收声。

相关推荐
上海懒图科技37 分钟前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue
西安小哥1 小时前
端侧跑大模型,真正的门槛不在参数量:一次 596 秒到 10 秒的压缩路径复盘
前端·人工智能
陈随易1 小时前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员
恋猫de小郭2 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter
严肃小白兔3 小时前
TanStack Router 更新构建工具链:Node.js 兼容性、路由分割与前端工程实践
前端·node.js·状态模式
IT_陈寒3 小时前
Vue这个奇葩的响应式问题让我加班到凌晨
前端·人工智能·后端
风骏时光牛马3 小时前
功能可用性自动化测试验证
前端
LucianaiB13 小时前
【元宝搜索开发者征文大赛】AI时代,学得慢就不用学?用元宝搜索打造一个教程保质期,确保每一个教程符合你的现状
前端·人工智能·ai编程
excel13 小时前
JavaScript 对象池:减少高频对象创建与 GC 压力
前端