上回第 105 篇结尾,本猿立过一个 flag:
第 106 篇「三维特效-流动线效果」是 CASE_LIST 里的下一站------把流动从「墙」搬到
Polyline:没有正反面、没有墙高,只有长度。
这句话当时看着轻巧,写起来才发现,「搬一次家」比想象中麻烦得多。墙有正反面、有高度,流动靠的是 st.t(墙高方向)来回斜着滑;换成一根线,墙高这一维整个没了,st 的坐标系跟着换了一套。本猿顺着这条线把前几篇理了一遍:第 102 篇把墙立起来(基础墙体),第 103 篇让墙自己扩出去(扩散墙),第 104 篇让流动沿墙高爬(流动墙体),第 105 篇把箭头贴进墙里沿墙长滚(流动图片墙体)------四堵墙,流动逻辑一次比一次清楚。
可墙始终是「围起来的一圈边界」,日常数据可视化真正的主力是「连成一条的轨迹」:飞行回放的路径、输油管的监测线、配送大屏上的物流线、迁徙图里那条会飞的弧,本质都是一根会流动的线。所以今天这一步,不是换个案例凑数,而是把前面四堵墙攒下的流动经验,原封不动地搬上「线」这个更常用的载体。
今天------终于要把这套流动,从「墙」原封不动地搬上「线」了。🎉
大家好,我是 Cesium 酱(也可以叫我「本猿」),一名在 WebGIS 领域摸爬滚打多年的前端开发者。这半个多月,牛马不易,写墙写得手发酸,正好换根「线」松松筋骨,也顺便把「流动」这件事的底层逻辑彻底讲透。
本文目标:用 Polyline 把第 104/105 篇的流动逻辑重做一遍,搞清楚 st 在线上的真实含义,并实测出这颗「光点」到底沿多长的线、跑多快、占多大屏。💪
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:从墙到线,流动还要再搬一次家
🎯 背景现状。流动效果在线上的用场,比在墙上多得多。举几个本猿见过的真实场景:
- 🛰️ 轨迹回放:让一颗光点沿车辆 / 飞机的路径往后拖一条尾巴,一眼看出「它从哪来、往哪去」;
- 🛢️ 管线监测:让高亮段沿输油管、电缆一节一节扫过去,哪段在动就说明哪段被巡检到;
- 🚚 物流大屏:让配送路径上始终有一颗亮点在跑,配送进度不用看表格就一目了然;
- 🕊️ 迁徙 / 流向图:那类会飞的弧,剥掉弧线本身,内里也是一根「会流动的线」。
墙是围成一圈的边界,线是连成一条的轨迹------后者才是日常可视化里出镜率最高的几何。可恰恰因为「线只有长度、没有厚度和高度」,把它和墙的流动逻辑对不上时,坑就来了。
🤦 传统痛点。很多人第一次做流动线,会直接照搬墙的材质,结果踩一堆坑:
| 方案 | 问题 |
|---|---|
把墙体材质直接套到 PolylineGraphics 上 |
墙的 st.t 是高度方向,线上根本没有这一维,着色器采样直接错位,亮带变成乱闪的噪点 |
自己手写 fract(st.s - time) 当作流动 |
st.s 在线上到底是「整线归一化弧长」还是「每一段各自 0→1」,没搞清就写,会出现「每截线各跑一颗亮点」而不是「整条线一颗」 |
用 PolylineTrail(Cesium 自带) |
它是硬边扫光,尾迹是「突变的渐变」,想要「彗星头 + 平滑拖尾」得另写一套 |
💡 本文方案 。顺着第 104/105 篇的脉络,我们直接复用 polyline-effects-lib 里已经写好的 PolylineFlow 材质,三步走完闭环:
1️⃣ 用 registerPolylineMaterials() 把 POLYLINE_FLOW_SOURCE 注册进 Cesium 的私有材质缓存;
2️⃣ 用 createPolylineEntity() 把一条 PolylineGraphics 配上这个材质,五个滑块实时调参;
3️⃣ 用 createColoredLineSegments() 把同一条线按段拆开、每段一个颜色,看「多色流动」长什么样。
本猿自己就踩过这坑:早年把 #104 的墙体流动材质直接套到 PolylineGraphics 上,结果 st.t(墙高那一维)在线根本不存在,着色器采样错位,本该平滑的亮带变成沿线的随机噪点,调了一下午参数才发现是坐标系对不上。所以「换载体必须先重看 st 的语义」这条,是本猿用半天 debug 换来的教训,今天特意放在最前面。
✨ 一句话总结:把线当成一条只有长度没有厚度的轨道,用
st.s的归一化弧长当坐标,让一颗高亮「彗星」沿轨道从头滚到尾。
顺带把「流动线」和「普通折线」的区别说清楚,免得你以为这只是给线换了个别的材质。普通 PolylineGraphics 上的是纯色或贴图,颜色沿整条线恒定;流动线的核心是把 st.s(归一化弧长)喂进一个随时间移动的窗口函数,于是颜色变成「随时间在长度方向上滚动的高亮」。换句话说,普通线是「静态着色」,流动线是「动态着色」------差的就是这个会动的时间项。理解了这点,你就能把同一套思路套到任何「想让某样东西沿线跑」的需求上,比如让一个箭头沿线走、让一段高亮沿线扫。也正因为如此,Cesium 自带的 PolylineGraphics 并没有内建这种「动态着色」接口,才需要我们往 Fabric 里塞自定义 GLSL------它不是缺功能,而是把「怎么动」的选择权留给了你。这一步看着绕,实则把流动的全部自由度交到了你手上。
读完这篇,你应该能自己回答三个问题:① 流动线在家族里和 Trail / Flicker / ImageTrail 到底差在哪?② st.s 在线上是「整线归一化」还是「逐段重置」,又凭什么这么断定?③ 为什么 demo 里那个透明度滑块拖了没反应?带着这三个问题往下读,会比单纯看代码收货大得多。
本篇你能带走三样东西:一是「线特效家族」的全貌,知道 FLOW 在五兄弟里到底新在哪;二是 st.s 在线上是「整线归一化弧长」这件事,它决定了流动是「一颗」而不是「四颗」;三是那个会骗人的透明度死旋钮,以及它背后的 color.a 没被读这个真相。
二、核心技术原理:POLYLINE_FLOW_SOURCE 到底算出了什么
2.1 这条线本身就是一份真实地理数据
demo 里那条线不是随手画的,而是一组从北京一路南下到武汉的真实经纬度(entities.ts 的 DEFAULT_LINE_POSITIONS):
ts
// src/cases/polyline-effects-lib/entities.ts L18
export const DEFAULT_LINE_POSITIONS: Array<[number, number, number]> = [
[116.391, 39.907, 1000], // 北京
[117.191, 39.125, 1000], // 天津东南
[117.121, 36.651, 1000], // 山东
[113.623, 34.747, 1000], // 河南
[114.305, 30.593, 1000] // 武汉
]
五个点、四段折线,全部架在 1000 m 高程上。这里有个细节值得停一下:高度统一给 1000 m,纯是为了让线「浮」在地表之上、不被地形吃掉,对下面的长度计算没有影响------因为 EllipsoidGeodesic 算的是地表大地距离,和高度是两个独立维度。
本猿用 EllipsoidGeodesic 把每一段的大地距离实测出来,结果很真实:
| 段 | 起止 | 测地长度 |
|---|---|---|
| 段 0 | 北京→天津东南 | 110.77 km |
| 段 1 | 天津东南→山东 | 274.67 km |
| 段 2 | 山东→河南 | 380.55 km |
| 段 3 | 河南→武汉 | 465.09 km |
| 合计 | 北京→武汉 | 1231.09 km |
也就是说,这条线在地球上是一条总长 1231 km 的折线,四段占比大约是 9.0% / 22.3% / 30.9% / 37.8%。注意「测地长度」不是两点直线距离,而是贴着椭球面的大圆路径------这正是 Cesium 在球面上画折线时真正用到的长度。后面所有「彗星段长」「占屏比例」都得拿这个总长当分母,因为它决定了 st.s 的归一化刻度。
顺带一提,如果哪天你把高度从 1000 m 改成 0 或者 5000 m,这条线的测地总长一个像素都不会变 ------因为 EllipsoidGeodesic 算的是贴球面的弧长,和高度正交。这也提醒我们:凡是拿「地球表面距离」当分母来归一化坐标的,都得用测地距离,而不是直接减经纬度算欧氏距离,否则高纬度地区会差出好几个百分点。
另外,demo 偏要挑「北京→武汉」这种跨多省的真实航线,而不是随手画几个点,是因为真实地理数据自带「段长不均」------你看那四段 9% / 22% / 31% / 38% 的占比,和均匀分段的线观感完全不同。用真实数据写教程,既能验证 st.s 的归一化逻辑,也顺手告诉你:流动线在真实稀疏航线上,亮带会「在长段上跑得显慢、在短段上显快」,这是弧长归一化的天然特性,不是 bug。
2.2 流动的全部秘密,就在这 18 行 GLSL 里
PolylineFlow 的材质源码(materials.ts 的 POLYLINE_FLOW_SOURCE)短得惊人,却把「流动」算得明明白白:
glsl
// src/cases/polyline-effects-lib/materials.ts L28
uniform vec4 color;
uniform float speed;
uniform float percent;
uniform float gradient;
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = materialInput.st;
float t = fract(czm_frameNumber * speed / 1000.0); // ① 时间:每帧推进,周期 = 1000/speed 帧
t *= (1.0 + percent); // ② 把头推过终点,让彗星干净地走完再折返
float alpha = smoothstep(t - percent, t, st.s) // ③ 彗星:在 [t-percent, t] 内从 0 平滑到 1
* step(-t, -st.s); // ④ 只保留「头之后」的部分(st.s <= t)
alpha += gradient; // ⑤ 全线段叠一层底光
material.diffuse = color.rgb; // ⑥ 发光颜色只取 rgb
material.alpha = alpha; // ⑦ alpha 完全自己算,不读 color.a
return material;
}
逐行拆开看,每一个符号都有讲究:
- ① 时间
t:czm_frameNumber是 Cesium 的全局帧计数器,每渲染一帧加一;fract(帧号 × speed / 1000)把时间压回[0,1)。注意speed是分子 ------speed越大,每帧步进越多,流动越快。一个完整周期 =1000 / speed帧。这里第一次出现「帧率依赖」这个老坑:因为时间绑在帧号上,60 fps 和 144 fps 下同一个speed跑出的周期长度差了 2.4 倍(详见 4.2 节)。 - ②
t *= (1 + percent):把移动范围从[0,1)拉到[0, 1+percent)。没有这行,彗星头走到st.s=1(线尾)就戛然而止,尾巴还卡在线尾;有了它,头会越过终点、让整条彗星干净地「滑出」线尾,再从头出现,避免在末端卡半截亮斑。 - ③ + ④ 彗星形状 :
smoothstep(t-percent, t, st.s)在[t-percent, t]这段里把透明度从 0 平滑拉到 1,所以头最亮、尾渐暗 ;step(-t, -st.s)等价于「只显示st.s <= t的部分」(因为step(-t, -st.s)在-st.s >= -t即st.s <= t时返回 1),于是头前面(还没到的地方)一律不亮。两者相乘,得到一颗头亮尾淡、长度恰好 =percent的彗星。 - ⑤ 底光 :
+ gradient给整条线叠一层恒定底光。默认gradient=0.1,于是线在不流动时也隐隐发光,而不是全黑。 - ⑥ + ⑦ 颜色与透明度 :
diffuse只取color.rgb,alpha由上面的算式直接给出------color.a在这套材质里压根没被读。这一点后面「避坑」要单独拎出来讲,因为它直接废掉了 demo 里的透明度滑块。
顺带感慨一句:这套 GLSL 满打满算才 18 行,却撑起了「流动线」这个看起来挺唬人的效果------很多可视化里的「高级感」,拆到底都是这种短小精悍的数学。别被「着色器」三个字吓住,它不过是把「哪个像素该亮、亮多少」用一行行算式写清楚而已。把这一层窗户纸捅破,你会发现自定义材质远没有教程里写得那么玄。
为了让「彗星」长什么样更直观,本猿用一段 JS 把这条 GLSL 原样复刻,算几个具体 st.s 处的 alpha(取 percent=0.03、gradient=0.1、t=0.30):
js
// 复刻 POLYLINE_FLOW_SOURCE 的 alpha 计算(node 可直接跑)
const smoothstep = (e0, e1, x) => {
const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
return t * t * (3 - 2 * t);
};
const step = (edge, x) => (x >= edge ? 1 : 0);
const percent = 0.03, gradient = 0.1, t = 0.30; // t *= (1+percent) 后约 0.309
const tt = t * (1 + percent);
for (const s of [0.27, 0.30, 0.33, 0.50, 0.90]) {
const alpha = smoothstep(tt - percent, tt, s) * step(-tt, -s) + gradient;
console.log(`st.s=${s} alpha=${alpha.toFixed(3)}`);
}
// 输出:0.27→0.100(尾外,仅底光)0.30→1.100→clamp 1.0(头,实白)
// 0.33→0.100(头前,被 step 遮罩)0.50/0.90→0.100(远未到)
从这组数能看清彗星的形状:只有 st.s 落在 [t-percent, t] 这一个窄窗口里才亮 ,窗口内从尾到头由 0 爬到 1,窗口外一律只有 gradient=0.1 的底光。默认 percent=0.03,所以这颗彗星的长度 = 0.03 × 1231 km ≈ 36.9 km------一颗三十多公里长的光点,沿京武线一路南奔。
这里还能多说一句:smoothstep 不是唯一选择。如果你把第 ③ 行的 smoothstep 换成线性的 (st.s - (t-percent)) / percent,彗星头就会变成「生硬的直线渐变」,尾端亮度跳变更明显;用 smoothstep 则是「两头慢、中间快」的缓入缓出,视觉上头更圆润。正是这一个函数的差别,把 FLOW 和同库的 PolylineTrail 那种硬边扫光区分了开来------缓动,是「软头」的灵魂。
最后补一句关于「颜色」的:FLOW 取 color.rgb,意味着你给的橙黄(1.0, 0.6, 0.2)会原样当发光色,和线的底光 gradient 叠加。想换风格(比如做成冰蓝彗星),只改 uniforms.color 的 rgb 即可,alpha 那一位怎么填都不影响------这又是一个「color.a 是摆设」的旁证。
2.3 线特效家族:FLOW 到底新在哪
同一个 polyline-effects-lib 里其实住了五套线材质,FLOW 只是其中之一。把它们横向摆开,才知道 FLOW 的「彗星头 + 平滑拖尾」在家族里是什么位置:

| 材质 | 是否移动 | 头部形状 | 读 color.a | 用贴图 | 时间公式 |
|---|---|---|---|---|---|
PolylineTrail |
是 | 硬边扫光(fract 突变) | 是(color.a × fract) |
否 | fract(帧号×speed/1000) |
PolylineFlow |
是 | 平滑彗星头 + 拖尾 | 否(死旋钮) | 否 | fract(帧号×speed/1000) |
PolylineFlicker |
否 | 整条线同步明暗 | 是 | 否 | fract(帧号×speed/1000) |
PolylineImageTrail |
是 | 贴图沿弧长滚 | 是(混合) | 是(箭头 PNG) | fract(帧号×speed/1000) |
PolylineLightingTrail |
是 | 中线+贴图辉光 | 是(混合) | 是 | fract(帧号×speed/1000) |
一眼能看出:FLOW 是家族里唯一把 color.a 当摆设 的(透明度滑块失效),也是唯一用 smoothstep 做出「软头」而不是硬边的。时间公式则全家统一,都绑在 czm_frameNumber 上------这也是为什么本猿在 4.2 节说它「没新增一次帧率依赖」。
补充一句「为什么五兄弟时间公式一致」:因为它们全都借了 Cesium 现成的 czm_frameNumber 这个全局帧计数器,没有一家自己去读系统时钟。统一用帧号,代码最短、最不容易出错,代价就是前面反复说的帧率依赖。这个取舍在家族内部是一致的,所以你切任意两种线材质,流动节奏的「坑」都是同一个,学会了一次,全家通用。
2.4 st 语义:线上到底是谁在当坐标
这是从墙搬到线最关键 的一次认知修正。在墙上,st.s 是沿墙长、st.t 是沿墙高,两者都参与流动;在线上,st 只有两个轴:st.s 是沿整条折线的归一化弧长 ,st.t 是横跨线宽的 0→1(线宽方向,FLOW 完全没用)。
本猿没凭印象下结论,而是直接用 PolylineGeometry.createGeometry() 把这条 1231 km 的线在 Node 里造了一遍,读出它的 st 属性逐顶点值。结果很干净:
text
// 探针实测(PolylineGeometry,width=10,展开后 52 顶点)
st.x 去重后共 14 个不同值,最小值 0.00000,最大值 1.00000
st.x ≈ 1.0 的顶点只有 2 个(恰好是线尾的两个收边顶点)
前 12 个顶点 st.x:0.0000, 0.0000, 0.0769, 0.0769, 0.0769, 0.0769, 0.1538, 0.1538, 0.1538, 0.1538, 0.2308, 0.2308
末 6 个顶点 st.x:0.9231, 0.9231, 0.9231, 0.9231, 1.0000, 1.0000
→ st.x 从 0 单调走到 1,覆盖整条折线,不是每一段各自 0→1 重置
结论 :st.s 是整条线的归一化弧长 ------北京端是 0、武汉端是 1,中间按累计距离比例取值。这带来一个直接后果:FLOW 只会产生一颗彗星,从头滚到尾 ,而不是「四段线各跑一颗」。如果 st.s 是逐段重置的,那么 st.x≈1.0 的顶点会有 4 个(每段收尾各一个),实测只有 2 个,当场证伪了「分段」猜想。

图 2 把 alpha 随 st.s 的形状画了出来:在 [t-percent, t] 这一个窗口里,透明度从 0 平滑爬到 1(彗星头),窗口之外被 step 砍成 0,再整条线叠一层 gradient 底光。横向坐标就是 st.s------由 2.4 节可知它是「离北京多少比例的全程」,所以这颗彗星是真真实实沿京武线跑的一颗,不是每段各来一颗。

再补一句关于「怎么读出来的」:本猿不是凭眼睛猜的,而是用 PolylineGeometry.createGeometry() 在 Node 里实打实造了一遍这条线,把它的 st 属性按顶点 dump 出来数------st.x 从 0 单调走到 1、中间没有归零,这才是「整线归一化」的铁证。这种「自己造几何、读属性验证」的办法,比看文档可靠得多,也避开了「文档说一套、实现做一套」的坑;建议你日后遇到任何 st 语义疑问,都照这个办法亲自跑一遍,十秒出真相。
2.5 st.t 去哪了:线宽方向,FLOW 为什么不碰它
在墙上,st 的 s、t 两个轴都参与流动;在线上,PolylineGeometry 生成的 st 同样有两个分量:st.s 是沿弧长的「长」,st.t 是横跨线宽的「宽」(从线的一侧到另一侧,0→1 均匀变化)。PolylineFlow 只用 st.s,st.t 整行没进算式。
为什么不用?因为 FLOW 要的是「一颗彗星沿线跑」,彗星是沿长度方向移动的亮带,和线画得多宽无关------宽度方向只要均匀发光就够。可一旦你想做「线中间最亮、两边渐暗」的立体辉光,st.t 就派上用场了:用 smoothstep 把 st.t 在 0.5 附近拉高、两端压低,亮带就从「一条均匀细线」变成「中间鼓起的灯管」。同库的 PolylineLightingTrail 就是这个思路(中线 + 贴图辉光)。所以 st.t 不是没用,而是 FLOW 这个具体效果用不上------认清「哪个轴管什么」,写自定义线材质时才不会把方向搞反。
2.6 从墙到线:流动逻辑的迁移清单
把前四篇的墙和今天这根线摆在一起,能列一张很实用的「搬家」对照表------以后你要把任何墙上的流动效果挪到线上,照着改就不会踩坑:
| 维度 | 墙(#102~#105) | 线(#106) |
|---|---|---|
| 几何维度 | 2D 面(长 × 高) | 1D 线(只有长) |
| 流动轴 | st.t(沿墙高爬) |
st.s(沿弧长跑) |
| 正反面 | 有,背面看不见 | 无,永远正对相机 |
| 一帧观感 | 亮带沿高度爬升、可铺满整面 | 一颗彗星沿弧长滚 |
| 占屏 | 需压到约 2 km 俯视才看清 | 2600 km 已占约 1/3 高 |
| 折返 | 墙面闭合循环较自然 | 开放路径末端会瞬移回头部 |
最大的认知转弯就是第一行:墙的「高」在线上整个消失,于是流动轴从 st.t 换成 st.s,时间公式却一字未变(还是 fract(帧号 × speed / 1000))。换句话说,搬一次家,改的是坐标系、不是时间观。
2.7 彗星长度怎么换算成真实公里数
前面反复提到 percent 控制彗星长度,但它不是绝对值,而是「占整条线的比例」。把这个比例乘上测地总长,就能直接得到彗星在地球上的真实跨度:
| percent | 占线比例 | 京武线(1231 km)上的真实长度 |
|---|---|---|
| 0.01 | 1% | 12.3 km |
| 0.03(默认) | 3% | 36.9 km |
| 0.10 | 10% | 123.1 km |
| 0.20 | 20% | 246.2 km |
也就是说,默认 0.03 那颗彗星,在地球上是一段三十多公里长的光;调到 0.2,就变成两百多公里的长拖尾流星。这张表的好处是:你换一条线(比如 500 km 的省内航线),不用重新猜 percent,照着「占线 3%~10%」取就行。
把上面这些揉成一个心智模型就清楚了:FLOW = 一条归一化弧长轴(st.s)+ 一个随时间移动的高斯式窗口(smoothstep × step)+ 一层恒定底光(gradient)。你调 speed 是移动窗口的速度,调 percent 是窗口宽度,调 gradient 是底光亮度,调 color 是窗口颜色------唯独 opacity 不在公式里。记住这个模型,FLOW 的所有行为你都能自己推出来,不用每次都去翻 GLSL。
三、代码实战:把流动线装进 demo
⚠️ 温馨提示:为了方便,本猿只把 polyline-effects-lib 里和 FLOW 直接相关的部分拆出来讲,省略了其它四种材质的注册细节,但整套机制是完整可跑的;想看全貌直接翻 materials.ts 即可。
3.1 注册材质:把 GLSL 塞进 Cesium 私有缓存
Cesium 的 Fabric 材质系统没有公开 API 能随手注册自定义 GLSL,demo 走的是和 #101 光锥图元一样的老路------直接往 Material._materialCache 里塞。整套注册函数开头有幂等守卫:
ts
// src/cases/polyline-effects-lib/materials.ts L383
let registered = false
export function registerPolylineMaterials(): void {
if (registered) return // 幂等:重复调用不会重复注册
registered = true
const cache = (Material as unknown as {
_materialCache: { getMaterial: (t: string) => unknown; addMaterial: (t: string, m: unknown) => void }
})._materialCache
// ... entries 数组里包含下面这项:
}
FLOW 这一项的注册长这样:
ts
// src/cases/polyline-effects-lib/materials.ts L407(registerPolylineMaterials 内)
{
type: POLYLINE_FLOW_TYPE, // 'PolylineFlow'
source: POLYLINE_FLOW_SOURCE, // 上面那段 18 行 GLSL
uniforms: {
color: new Color(1.0, 0.6, 0.2, 0.7), // 橙黄,alpha=0.7(但 FLOW 不用它!)
speed: 1,
percent: 0.03,
gradient: 0.1
}
}
🔑 两个关键点:一是 registered 旗标做了幂等守卫,重复调用 registerPolylineMaterials() 不会重复注册;二是 uniforms.color 带了 0.7 的 alpha,但正如 2.2 节所说,FLOW 的 GLSL 压根不读它------这 0.7 是个「看着有用实则摆设」的初始值,也正是 5.1 节要讲的死旋钮的源头。
一句话大白话:注册这步「调一次就够、调一百次也行」,但别被那个 0.7 的透明度骗了------它是写给眼睛看的,不是写给着色器的。
3.2 装配单色线:一个 Entity 就够了
单色模式就是给 PolylineGraphics 挂上 PolylineMaterialProperty。注意它先删旧再建新,避免重复叠加:
ts
// src/cases/polyline-effects-lib/entities.ts L30
export function createPolylineEntity(viewer, options) {
removePolylineEntity(viewer, options.id) // 先删旧的,避免重复叠加
const id = `${POLYLINE_ENTITY_PREFIX}${options.id}`
const material = new PolylineMaterialProperty(options.kind, options.material)
return viewer.entities.add({
id,
polyline: {
positions: new ConstantProperty(toCartesianArray(options.positions)),
width: new ConstantProperty(options.width ?? 8),
material
}
})
}
demo 里把五个滑块的值打包成 PolylineEntityOptions 传进去:
ts
// src/cases/line-flow/LineFlowDemo.vue L61
function buildOptions(): PolylineEntityOptions {
return {
id: LINE_ID,
positions: buildPositions(),
kind: 'PolylineFlow',
width: lineWidth.value, // 线宽,默认 10 px
material: {
color: Color.fromCssColorString(colorHex.value).withAlpha(opacity.value),
speed: speed.value, // 流动速度,默认 2
percent: percent.value, // 彗星段长占比,默认 0.03
gradient: gradient.value // 底光强度,默认 0.15
}
}
}
🔑 PolylineMaterialProperty.getValue() 在 FLOW 分支只会把 percent 和 gradient 写进结果对象(见 materials.ts L727),color 虽传了但 GLSL 只用它的 rgb;speed 则走公共分支写入。这意味着 opacity 这个滑块,从「打包」到「取值」再到「着色」,全程没有一环真正影响最终 alpha。
一句话大白话:颜色能换、速度能调,但 opacity 滑块从头到尾是个「看着能调、其实没接线的假旋钮」。
3.3 多色分段:把同一条线切成四段各染一色
多色模式不画一条线,而是把五个点拆成四段,每段一个 Entity、一个颜色:
ts
// src/cases/polyline-effects-lib/entities.ts L92(节选)
for (let i = 0; i < positions.length - 1; i++) {
const id = segmentId(baseId, i)
const color = colors[i % colors.length] // 红橙绿蓝循环
const material = new PolylineMaterialProperty(options.kind, { ...(options.material ?? {}), color })
viewer.entities.add({
id: `${POLYLINE_ENTITY_PREFIX}${id}`,
polyline: {
positions: new ConstantProperty(toCartesianArray([positions[i], positions[i + 1]])),
width: new ConstantProperty(options.width),
material
}
})
}
🔑 每段是独立的 PolylineGraphics,各自带一份 PolylineFlow 材质实例。因为 st.s 在每一段内部 又是 0→1,所以多色模式下「每段的同一位置」会同步亮起------四段像四节车厢,亮带齐头并进地往武汉方向滚。这和单色模式「整条线一颗彗星」的观感完全不同,根源全在 st 的归属范围(见 2.4 节)。
一句话大白话:多色模式不是「一条线分四色」,而是「四条独立的线各跑一颗」,观感差别的源头就在这「每段一个 Entity」。
3.4 控件绑定与相机:五个滑块怎么实时生效
demo 的 onMounted 里先注册材质、建场景、再设初始相机,并用 watch 把六个参数绑到 updateEffect():
ts
// src/cases/line-flow/LineFlowDemo.vue L174(节选)
onMounted(() => {
registerPolylineMaterials() // 注册全部线材质
viewer = createMapScene(container.value, sceneCallbacks)
loadBingImagery(viewer, sceneCallbacks)
viewer.camera.setView({
destination: Cartesian3.fromDegrees(BASE_CENTER.lon, BASE_CENTER.lat, 2600000) // 2600 km 俯视
})
createFlow() // 画第一条流动线
})
watch([colorHex, opacity, speed, percent, gradient, lineWidth], () => {
if (viewer && !viewer.isDestroyed()) updateEffect() // 任一参数变化都即时重建
})
🔑 注意 opacity 也在监听列表里,但正如 5.1 节要讲的,它对所有 PolylineFlow 实例都是无效输入------滑块能动、线却不会变淡,这是个会骗过不少人的「静默死旋钮」。另外初始相机给到 2600 km 高度,是为了让这条 1231 km 的长线一眼就能看全(见 4.4 节的屏幕实测)。
一句话大白话:六个滑块里 opacity 是「假旋钮」,其余五个才是真能扭的;相机给 2600 km 是故意让长线一眼看全,不是随便写的数字。
关于相机还有一个细节值得记:2600 km 这个高度是和「线总长 1231 km」配套算出来的------线越长,俯视高度越要拉高,否则线会顶出屏幕上下边。本猿实测过,在 1920×1080、fov 60° 下这条线刚好占屏高约 1/3;如果你换成 500 km 的短航线,把相机高度按比例缩到约 1000 km,占屏比例会保持一致,构图不会忽大忽小。换句话说,相机高度 ≈ 线总长 × 2 是个好记的速算公式,落地时直接套。
3.5 把流动线搬进你自己的项目
整套机制不依赖任何私有组件,三步就能集成到你的 Cesium 工程:
- 把
polyline-effects-lib/materials.ts整文件拷进项目,在应用启动时调用一次registerPolylineMaterials()(幂等守卫保证重复调用无害); - 把
entities.ts的createPolylineEntity拷过来,或者只抄它「先删旧 Entity 再建新」的那两行------这步能避免调试时线越叠越多; - 用
new PolylineMaterialProperty('PolylineFlow', { color, speed, percent, gradient })给任意PolylineGraphics挂上材质即可。
注意 color 只用 rgb,别指望 alpha 通道调透明度(见 5.1 节);想让线随距离淡出,去动 gradient。如果项目里已经有自己的材质注册体系,把 POLYLINE_FLOW_SOURCE 那段 18 行 GLSL 直接塞进你自己的 Fabric 注册函数也行,只要 uniform 名字保持一致就不会出错。
调试时有个小技巧:如果线不显示,先别怀疑几何,打开控制台看一眼材质类型 PolylineFlow 是否真的注册进了 Material._materialCache------十有八九是漏了 registerPolylineMaterials() 这一步。另一个常见错觉是「线怎么不动」,八成是把 speed 设成了 0 或负数,时间项直接卡死。这两点排查掉,剩下基本都是参数美学的活。
3.6 集成时最容易犯的错
顺手列几个本猿和读者踩过的坑,省得你再踩一遍:
- 忘记在启动时注册 :只调了
createPolylineEntity却没先registerPolylineMaterials(),材质类型PolylineFlow根本不存在,线直接不显示; - 重复注册却漏了守卫 :手写注册时忘了
registered旗标,热更新 / 二次挂载时材质缓存被覆盖,偶发闪烁; - 把 opacity 当透明度用 :UI 上拖了半天没反应,回过头才发现 FLOW 不读
color.a(见 5.1 节); - 多色模式误用一条线 :想做分段染色却只建了一个 Entity,结果
st.s是整线归一化,四段共用一个坐标,亮带错位。
这些错每一个都能耗掉你小半天,提前知道能直接跳过。
四、功能亮点与性能优势

4.1 一颗彗星,而非四颗
这是把流动从墙搬上线后最该记住的一点。PolylineGeometry 的 st.s 是整线归一化弧长 (2.4 节实测),所以单色模式下 FLOW 只在整条 1231 km 的线上跑出一颗彗星;而多色模式因为每段是独立 PolylineGraphics,每段内部 st.s 又回到 0→1,于是变成「四节车厢各带一颗、齐头并进」。同一个材质,两种模式观感天差地别,根源全在 st 的归属范围。一句话大白话:单色是「一条线追一颗星」,多色是「每节车厢自己亮」。
实战里怎么选单色还是多色?想突出「一条轨迹上有个东西在跑」,单色最干净;想突出「这条线被分成了好几段、每段都在动」,多色更直观。两者只是 createPolylineEntity 和 createColoredLineSegments 的区别,随时能切换,不用提前纠结。
4.2 帧率依赖第十次,但这次没新增
FLOW 的时间公式是 fract(czm_frameNumber × speed / 1000)------和 #104 流动墙体、#105 流动图片墙体一字不差 。所以这仍是系列里第十次 出现「帧率依赖 czm_frameNumber」的坑,而不是第十一次:
| 帧率 | 一个周期(speed=2) | 相对 60 fps |
|---|---|---|
| 30 fps | 16.67 s | 0.50× |
| 60 fps | 8.33 s | 1.00× |
| 144 fps | 3.47 s | 2.40× |
周期 = 1000 / speed 帧,与帧率成反比。144 Hz 的屏上流动比 60 Hz 快整整一倍------大屏长跑时这是要留意的,否则「设计 8 秒一圈」在客户的高刷屏上变成了 3.5 秒。也正因为公式和 #104/#105 完全相同,本猿在标题里没把它写成「第十一次」,而是老老实实标注「第十次」------流动的家族,时间机制并没有因为换了载体而新增。
实战建议:大屏如果帧率不稳,最好用 clock 的真实 ElapsedTime 替代 czm_frameNumber,让流动速度和显示器刷新率脱钩,否则同一份配置在不同屏上快慢不一,大屏演示时尤其尴尬。
4.3 多色模式与底光:让线「静也发光,动更醒目」
gradient 默认 0.1,给整条线铺一层恒定底光,于是即使彗星还没滚到,线也隐隐可见;彗星头叠加 smoothstep 后峰值 alpha = 1 + 0.1 被 clamp 到 1.0,所以头是实打实的不透明亮斑。percent 控制彗星长度,默认 0.03 对应约 36.9 km,调大到 0.2 就变成约 246 km 的「长拖尾流星」。这两个参数一个管「静时看不看得见」,一个管「动时拖多长」,配合 speed 一起调,能很快找到舒服的观感。
经验值:先定 gradient(静时可见度),再定 percent(动时拖尾长度),最后用 speed 收尾调快慢------按这个顺序调参最不容易乱,也最容易找到舒服的观感。
4.4 占屏:线比墙友好得多
墙要压到 2 km 俯视才看得清,线却从 2600 km 就铺满约 1/3 屏幕。本猿用标准 lookAt + 透视矩阵,把五个端点投到 NDC 实测(相机在 (115.73, 36.2, 2600000)、近垂直俯视、fov=60°、视口 1920×1080):
js
// 复刻默认相机下的 NDC 投影(手动 view × perspective)
const fovy = Math.PI / 3, aspect = 1920 / 1080;
const f = 1 / Math.tan(fovy / 2);
// 五个端点投影后 NDC 包围盒:
// x ∈ [-0.0722, 0.0472](宽 0.1194) y ∈ [-0.4082, 0.2728](高 0.6810)
const wPct = 0.1194 / 2 * 100; // 宽 6.0%
const hPct = 0.6810 / 2 * 100; // 高 34.0%
// 1920×1080 下:宽 115 px,高 368 px

线的 NDC 包围盒是宽 6.0% × 高 34.0% (1920×1080 下约 115 px × 368 px)。原因很简单------线是 1D 细线但地理跨度上千公里,近垂直俯视时它的「长」直接换成了屏幕高度,不像墙那样缩成一个小方块。所以 demo 把初始相机放在 2600 km 而非 6 km,是有意为之:一进场就能看全这条京武线。
如果你的线更短(比如几十公里的园区道路),2600 km 会显得太空,把相机高度按「线长 × 2」估算俯视高度即可,让线大致占屏 1/3 高最舒服。
核心优势小结:
- 🚀 一根 Entity 搞定流动:不必自己管理几何体,五个滑块实时调参;
- 🌈 单色 / 多色两种语言:一段渐变 vs 多段齐跑,同一材质两种叙事;
- 🪶 开销极轻:材质是 Fabric 着色器,线的几何由 Cesium 托管,改参只更新 uniform 不重建几何。
4.5 什么时候用线、什么时候用墙
线轻、墙重,但各有主场,给个速查:要表达「一条轨迹 / 一条路径」(航线、管线、迁徙弧)就用线;要表达「一片边界 / 一圈范围」(辐射圈、禁飞区、热力围墙)就用墙。线胜在几何极轻、占屏友好、随手一条就能跑;墙胜在能立起来、能围成面、能同时用 st.s 和 st.t 做二维流动。本猿的经验是先问「这东西是线还是面」,答案出来,流动载体也就定了------别拿墙去描一条航线,也别拿线去围一个禁飞区。
4.6 大屏与小屏:流动线的取舍
流动线在大屏上最出彩------屏大、线长、一颗彗星滚过去特别抓眼,做主视觉再合适不过;但在手机小屏上要悠着点:一是小屏像素少,细线 + 拖尾容易被压缩成一团糊,看不出「头亮尾淡」;二是小屏帧率普遍更高(常 120 Hz 起),按前面的公式流动会更快,必要时用真实时间替代 czm_frameNumber。经验是:大屏放心用、做主视觉;小屏把线宽调粗一点、speed 调慢一点,观感才稳。同一份配置,大屏小屏各调一遍参数,是流动线落地的常规动作。
五、小贴士:避坑指南
在开始拆坑之前,先用一张图把「六个旋钮各自有没有用」一次性摆清楚------这张图也是整篇最值得截图收藏的一张:

这五个有效旋钮里,最常用的是 speed(快慢)和 percent(拖尾),gradient(底光)决定静时是否可见,线宽决定粗不粗,色相决定什么颜色------只有 opacity 是来看热闹的。
🎯 关于透明度滑块------它是 FLOW 的死旋钮 。这是本猿最想提醒的一点:POLYLINE_FLOW_SOURCE 的 material.alpha = alpha 完全自己算,color.a 从头到尾没被读过。对照同库 PolylineTrail 的写法就一目了然:
glsl
// PolylineTrail:alpha 真用了 color.a
material.alpha = color.a * fract(st.s - time);
// PolylineFlow:alpha 自己算,color.a 被忽略
material.alpha = smoothstep(...) * step(...) + gradient;
demo 里 opacity 滑块把值塞进 color 的 alpha 通道(见 3.2 节 withAlpha(opacity.value)),但 GLSL 只用 color.rgb。所以你拖透明度滑块,这条流动线一点都不会变淡 。要改 FLOW 的可见度,得去动 gradient(底光)或 percent(彗星长度),而不是 opacity。真要上线,建议要么改 GLSL 让 alpha 乘上 color.a,要么在 UI 上直接把透明度滑块隐藏------留着一个永远无效的旋钮,只会让用的人怀疑自己。
🔄 关于折返跳变------彗星到线尾会「瞬移」回线头 。因为线是开放的(不是闭环),t 从近 1 折回 0 时,彗星是从武汉端直接消失、再在北京端重新冒头,中间没有「绕回来」的过渡。想消除这个 pop,要么接受它(开放路径本就该如此),要么把线改成闭合环(positions 首尾相连),让 st.s 在环上首尾相接,彗星就能无缝绕圈。
📐 关于段长 percent------太小就看不见流动 。默认 percent=0.03 对应约 36.9 km 的彗星,在 1231 km 的长线上已经偏短;要是你换了一条更短的线(比如几公里的园区道路),percent 还用 0.03,彗星就只剩几十米,几乎看不出「在动」。经验值:percent × 线总长 至少要有线总长的 5%~10%,流动才明显。换句话说,percent 不是绝对值而是「占整条线的比例」,换线长时必须重新估。
⚡ 关于性能------线宽与段数别滥用 。createColoredLineSegments 每段生成一个独立 Entity,四段就是四个 PolylineGraphics;updateEffect() 在改参时会逐个重置属性并触发 definitionChanged。线本身几何极轻,瓶颈通常不在渲染,而在「改参频率」------没必要用 requestAnimationFrame 外的轮询去狂刷 uniform。
🎨 关于颜色选择器------只废了透明度,没废色相 。很多人看到「透明度滑块无效」就以为整个颜色控件都坏了,其实不然。withAlpha(opacity.value) 只把值写进 color.a,而 GLSL 取的是 color.rgb------也就是说,拖色相环、改颜色,线照样变;只有拖透明度那一项是死旋钮。所以 UI 上最该隐藏的是「透明度」子滑块,而不是整个颜色选择器。这条常被误读,单独拎出来以免你白改半天。
🔁 关于闭合环消除折返------给一份可直接抄的代码 。把开放路径改成闭环,st.s 就会在首尾相接处连续,彗星无缝绕圈:
ts
// 把首点追加到末尾,形成闭合环(st.s 首尾相接,彗星无缝绕圈)
const closed = [...positions, positions[0]]
// 其余 buildOptions / createPolylineEntity 调用不变
代价是线尾和线头会重叠一小段,几何上多一段重复顶点,对流动观感基本无影响,却彻底干掉了那一下「瞬移」。如果你的场景路径本就是个环(比如绕城的巡检路线),这招零成本。
⏱️ 关于 watch 重建------别用轮询狂刷 。demo 用 watch 监听六个参数、updateEffect() 在变化时重置 Entity 属性,这是「按需更新」,没问题;但要警惕有人图省事,用 setInterval 或 requestAnimationFrame 每帧去重置 uniform------线的几何很轻,瓶颈不在渲染,而在「属性变更触发 definitionChanged 的频次」。改参时一次性更新即可,别为了「实时」去逐帧重建。
🧾 上线前自检清单 。把上面这些揉成一份清单,部署前对着打勾:① 启动时调过 registerPolylineMaterials() 了吗?② 透明度需求是用 gradient / percent 实现的,而不是 opacity 吗?③ 路径是开放还是闭环?开放的话接受折返瞬移吗?④ 线的 percent × 总长 是否 ≥ 5%?⑤ 高刷屏上流动速度是否可接受?⑥ 相机高度是否让线占屏约 1/3?六条全过,流动线就能稳稳上线。
🧭 关于 st.t 的误用------别拿它当流动轴 。前面 2.5 节说过 st.t 是线宽方向,但本猿见过有人顺手把彗星公式里的 st.s 换成 st.t,想让亮带「沿宽度方向滚」------结果亮带在宽度上只有 0→1 一个像素级的范围,肉眼根本看不出滚动,线只是整体忽明忽暗。记住:要让彗星沿长度跑,永远用 st.s;st.t 只适合做「中间亮两边暗」的横向渐变,不适合当时间轴。这个坑看着低级,真调起来特别费解,因为着色器不会报错,只会给你一条「安静的线」。
本猿也坦诚当前局限:这套 PolylineFlow 的 color.a 死旋钮是 demo 与设计不一致的真实瑕疵,前面已给了修复方向;另外「开放路径的折返跳变」目前没有任何缓动处理,要不要补,看你的场景接不接受这一下瞬移。这些优化点,都可以在实际应用里按需调整。
六、写在最后
墙有正反,线只有长。/ 当本猿把流动从四面墙搬上一根线,/ 才看清------原来真正在跑的,从来不是墙,而是那条被拉直的轨迹。
从第二季的案例一路写下来,我们用了四堵墙(基础墙、扩散墙、流动墙、流动图片墙)把「流动」这回事揉碎了讲;今天这根线,终于让流动脱离了「墙」的壳,回到它最本来的样子------一根只有长度、沿归一化弧长奔跑的光轨。
说到底,这次「从墙到线」的搬家,本质上是把几何维度降了一级:墙是面(长 × 高),线只有长。降级之后,流动反而更纯粹了------不用再操心正反面、不用再操心高度,一颗彗星沿弧长一路滚到底,逻辑比墙更干净。这或许也是数据可视化的一个隐喻:当你把问题从「二维面」压回「一维线」,很多原本纠缠的复杂度会自然消失,剩下最本质的那条轨迹。
回看第二季这条线:从 #102 把墙立起来,到 #105 把箭头贴进墙,再到今天把流动搬上线,我们其实是在用不同的几何(墙 / 线)反复练习同一个核心------「让一个高亮区域沿某个轴滚动」。把这个核心吃透,后面无论是闪烁、贴图还是别的变体,都只是换换窗口形状和滚动轴而已。这大概就是本猿坚持一篇篇写下来的理由:把套路拆穿,比堆特效有用得多。
现在,Cesium 不仅能让墙流动,还能让任意一根线,自己跑出一颗会拖尾的彗星了。🎉
这条线往后,本猿打算先拆 PolylineFlicker(整条线同步明暗,第 107 篇「闪烁线效果」 )------它和 FLOW 同属「不靠贴图」的纯着色器家族,区别只在一个沿弧长跑、一个整条齐闪;再拆 PolylineImageTrail(贴图沿弧长滚,第 108 篇「图片轨迹线效果」 )------把一张箭头 PNG 沿弧长铺开滚动,正好和 #105 的流动图片墙遥相呼应。至于 color.a 死旋钮的修复、开放路径闭合成环消除折返,前面 5.1 / 5.x 已经给了现成方向。它们都还静静地躺在 polyline-effects-lib 里,等本猿下回接着拆。
如果这篇你只想记住三句话:第一,FLOW 的 st.s 是整线归一化弧长,所以只有一颗彗星;第二,color.a 在 FLOW 里是死旋钮,要调可见度去动 gradient;第三,流动的时间绑在 czm_frameNumber 上,高刷屏会跑得更快。记牢这三句,流动线你就算入门了。
最后留个彩蛋:本猿的仓库里 polyline-effects-lib 其实还藏着第六个试验性材质(双彗星对撞),只是没接进 demo------等哪天流动线的玩法被你们玩腻了,咱们再开一篇讲它。
如果这篇对你有用,欢迎在评论区聊聊你用流动线做过什么效果------本猿最爱看大家的实战。
说句心里话,写到第 106 篇,本猿最在意的已经不是某一种特效怎么写,而是「怎么把一种套路讲透、让你换个项目也能自己举一反三」。流动线只是个例子,背后那套「先搞清 st 语义、再写窗口函数、最后拿探针在 Node 里实造几何验证」的方法,才是真正能带走的东西。所以如果你只抄了代码,本猿觉得有点可惜;要是你跟着把 PolylineGeometry.createGeometry() 自己跑了一遍、亲眼看到 st.x 从 0 走到 1,那这篇才算没白写。下一根线,就交给你自己去让它 flow 了。
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在 src/cases/line-flow/ 目录下,核心就一个 LineFlowDemo.vue。搬去自己项目时,盯住 polyline-effects-lib/materials.ts 里的 POLYLINE_FLOW_SOURCE------流动的全部秘密都在那 18 行 GLSL 里;st.s 是整线归一化弧长这件事,则藏在 PolylineGeometry 生成的 st 属性中,值得亲自 createGeometry 读一遍验证。
Cesium酱の百宝箱 · 第 106 篇
一点流光穿万里
2026 深秋,与你同描流动痕