「
PolylineImageTrail把一张贴图当刷子沿弧长滚,repeat参数到底管不管用、贴图自身 alpha 和color.a怎么分工,一次看完。」
这三个问号排布得很讲究:第一个问"管不管用"(功能),第二个问"怎么分工"(语义),第三个是附赠的横向对照(架构)。本猿当时读到这句就在想,能把三个问题一句话串起来的案例不多,今天这个算一个------因为它同时踩了"参数语义""分支可达性""代码重复"三个坑,每个坑单独拿出来都能写半篇。
更巧的是,这三个问题的答案全都藏在同一处:materials.ts 那 21 行源码里。所以今天这篇的行文会和前几篇不太一样------本猿不打算先讲装配再讲着色器,而是先把那 21 行拍在桌上,再倒着往回问"每一行为什么这么写"。
先把进度摆一摆:#106 让一颗彗星沿整条线滚到头,#107 让整条线一起呼吸------加上 #104/#105 两堵墙,"线特效"这条支线已经铺到第五块砖。今天这块最特别:它是家族里唯一把一张位图请进着色器 的成员,也是唯一在 if/else 里给"贴图本色"留了座的成员。
今天------
我们就把这个座位掀开看看:那条分支不光进不去,就算强行挤进去,整条线还会当场消失。
我是 Cesium 酱。说实话,写这篇之前本猿一直以为"图片轨迹线"就是把 #106 的彗星换成一串箭头,属于收尾的轻松活。直到我把那张 342 字节的 PNG 解开数了数像素------30×21 个像素,630 个 texel,RGB 全是 255。那一刻本猿就知道,今天这篇绝不是轻松活。
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:为什么这件事这么难?
线特效讲到第五块砖,需求早就不满足于"亮一下、暗一下"了。本猿把这些年在群里被问到的、自己在项目里被要求的、以及客户在验收时拍桌子提的,归成三类------它们的共同点是"方向感":一条线画出来,光知道它连了哪两点不够,还得知道"事儿往哪头走"。
真实项目里最常见的三种诉求:
- 航线 / 管线的方向指示:一条几千公里的输油管道,要让人一眼看出"介质往哪头走";
- 轨迹回放:一辆车走过的路,要用箭头把走过的痕迹一点点标出来;
- 告警闪烁:某条线路异常,需要比纯色更抓眼的视觉权重。
传统做法与它们的问题:
| 方案 | 问题 |
|---|---|
| 纯色折线 + 手工算箭头位置 | 每加一个箭头就是一个 entity,几百个箭头直接把 entity 列表撑爆 |
用 PolylineDashArrow 之类内置虚线箭头 |
图案是 GLSL 现成算的,换不了自己的图标;而且 #98 已经拆过,dashPattern 只有 8 位能用到 |
| 沿折线铺一串 billboard 贴图 | 得自己算弧长、自己算朝向,地球曲率一大箭头就歪 |
用 #106 的 PolylineFlow 凑合 |
彗星头是纯色的,客户说"我要的是箭头,不是光点" |
上个表格里最容易被跳过的是第三行------"沿折线铺一串 billboard 贴图"。听起来这才是最直觉的做法:一个箭头一个 billboard,位置自己算,朝向自己给,颜色随便改。本猿起初也是这么想的,真去算了一遍才发现三个坑。
第一,弧长得自己算 。Cesium 没有公开的"沿折线等距取点"API,你要么自己按测地线累加(EllipsoidGeodesic 逐段跑,遇到跨日界线还得处理环绕),要么用 PolylinePipeline 那套内部工具------而它拿到的还是"细分后的顶点序号",不是米制。第二,朝向得自己给 。地球是曲面,箭头要贴着地表切向,每个点的 ENU 坐标系都不一样,Transforms.eastNorthUpToFixedFrame 得每个点建一次矩阵。第三,数量一多就崩。一条 1231 公里的线按每 150 公里放一支箭头就是 8 个,听着不多;可客户要的是"整张管网",几百条线就是几千个 billboard,每个都要单独算矩阵、单独进批次。
而着色器方案把这三件事全免了:PolylineGeometry 负责把折线摊成带、算好 st,片元着色器负责按 st.s 采样贴图。几何一个顶点都不用加,箭头数量由 repeatX 一个 uniform 决定------从 1 到 20 都是一个 draw call。这就是本篇方案存在的全部理由。
顺带说一句第四类需求------轨迹回放 。它和前三类的区别是"时间维度":箭头不是常驻的,而是随着播放进度一点点长出来的。这个需求今天这份 demo 满足不了(time 只驱动滚动,不驱动显隐),但它的实现路径和本篇是同一个底座:把 czm_frameNumber 换成一个外部传入的进度值,再让 alpha 乘上它即可。本猿把这个记在"后续方向"里,本篇不展开。
本文方案,三步:
1️⃣ 几何照旧交给 PolylineGeometry------一条折线、一个 width,别的不用管;
2️⃣ 材质注册一份自带的 Fabric 类型 PolylineImageTrail,把 sampler2D 请进着色器;
3️⃣ 在片元里用 fract(st.s - time) 采样贴图,让图案沿弧长滚起来。
✨ 一句话总结:箭头不是画出来的,是"贴图被折线反复采样"采样出来的------所以箭头的数量、大小、快慢,全由着色器里那几行决定,几何一个顶点都不用加。
二、核心技术原理:贴图怎么被一条线"滚"出来
2.1 数据源:五个折点,一千二百公里
demo 用的还是家族共用那份折线,src/cases/polyline-effects-lib/entities.ts L18-24:
ts
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 高度上。用 EllipsoidGeodesic 逐段累加,测地总长 1231.09 km------这个数字本猿已经在 #106 和 #107 量过两次,今天第三次,逐位相同。四段分别是:
| 段 | 起 → 讫 | 测地长度 | 占全线 |
|---|---|---|---|
| 0 | 116.391,39.907 → 117.191,39.125 | 110.77 km | 9.0% |
| 1 | 117.191,39.125 → 117.121,36.651 | 274.67 km | 22.3% |
| 2 | 117.121,36.651 → 113.623,34.747 | 380.55 km | 30.9% |
| 3 | 113.623,34.747 → 114.305,30.593 | 465.09 km | 37.8% |
最长段是最短段的 4.20 倍------记住这个数,2.3 节要用它。
顺手做个交叉验证:把五个折点按 Euclidean 距离连起来(不贴椭球),弦长总和 1231.10 km ,测地/弦 = 0.999992 。两者只差 8 米------说明这条折线每一段都很长(百公里量级),弦与弧的差异被稀释到可以忽略。本猿把这个数一起写进来,是因为单源数字不可信:EllipsoidGeodesic 走的是椭球法截面,万一某个版本改了内部近似,另一个数会立刻对不上。
五个点的高度全是 1000 m,这是 DEFAULT_LINE_POSITIONS 写死的。1000 米在 2600 公里的相机距离下完全看不出高低差------本篇的所有"屏幕占比"讨论,都可以当成一条贴地的线来看待。
还有一层:entities.ts L37 把 positions 包成了 new ConstantProperty(...)。这意味着几何是常量属性 ,PolylineGeometryUpdater 的 _isDynamic 六个条件(L531-538:positions / width / arcType / granularity / clampToGround / zIndex)一个都不触发,走的是静态批次 ------几何只建一次,之后每帧只更新 uniform。#102 已经拆过这套机制:静态批次的代价是"改参数要整批重建",但 ConstantProperty 也让"位置不变"这件事变成了显式声明。本篇 demo 的"点击地图平移"是重新 createPolylineEntity(内部先 removePolylineEntity),所以走的是"删了重建"而不是"改属性"------这在这个规模下无所谓,但如果你的项目要频繁平移,就该改成改 positions 属性。
2.2 核心流程:从 entity 到 17 行 GLSL
先把链路用文字走一遍,再看图。viewer.entities.add() 一个 polyline 图形,材质字段填一个 PolylineMaterialProperty 实例------这是 polyline-effects-lib 自己封的自定义 MaterialProperty,不是 Cesium 内置那几种。entity 层每次取值都会调它的 getValue(time, result),返回一个 plain object;这个 object 被 PolylineGeometryUpdater 转手交给 Material,后者按 fabric 里登记的 uniform 名字逐一比对,把 sampler2D、float、vec4、vec2 各就各位。
这条链子上有三个"静默失效点",本猿在 #106/#107 已经踩过两个,今天这三个全踩到了:
getValue不写的 key,uniform 就保持 fabric 默认值(今天:repeat只在 ImageTrail 这一支下发);updatePolylineEntity不传的字段,原值保留(今天:image不在补丁里,但也不会丢);- 传了但 GLSL 没读的 uniform,等于没传(今天:
color.a在分支 B 里被读、在分支 A 里被当开关用,都不是"当不透明度用")。
整条链子和 #106/#107 一模一样,本猿不再重复画一遍,直接上图。

五份 GLSL 全部只借 Cesium 现成的 czm_frameNumber 计时,差别全在"动什么":动位置必须读 st.s,动状态一概不碰。#108 是唯一先把 repeat 乘进 st 的------vec2 st = repeat * materialInput.st,读的仍然是 st.s,只是被放大了 repeatX 倍。
杀手锏在第三步 🔪:fract(st.s - time) 这一个 fract,同时干了两件事------让贴图沿线铺开 repeatX 次,又让整幅图案随时间往前滚。一个函数,两个职责,这也是本篇所有坑的根源。
2.3 第一个真值:st.s 不是弧长
这里必须纠正一个本猿在 #106 说错了一半的说法。
PolylineGeometry 生成 st 的源码在 Core/PolylineGeometry.js L489-490,只有两行:
js
if (vertexFormat.st) {
st[stIndex++] = j / (positionsLength - 1);
st[stIndex++] = Math.max(expandAndWidth[expandAndWidthIndex - 2], 0.0);
}
j 是顶点序号 ,positionsLength 是细分后的顶点数 。所以 st.s 是"序号占比",不是弧长占比。
那 #106 为什么说它像弧长?因为细分步长是固定的。demo 没传 arcType 和 granularity,PolylineGeometryUpdater.js L576-582 会把两者留成 undefined,于是走 Core/PolylineGeometry.js L131-132 的默认值:
js
this._arcType = options.arcType ?? ArcType.GEODESIC;
this._granularity = options.granularity ?? CesiumMath.RADIANS_PER_DEGREE;
granularity 默认 1° ,换算成弦长是 chordLength(1°, 6378137) ≈ 111 km。于是每一段都被切成等长大圆段,序号占比自然紧贴弧长占比。

实测:细分后一共 14 个点位、52 个顶点 ,st.s 只有 14 个取值------0, 1/13, 2/13 ... 1,严格等距。而真实的弧长占比在五个原始折点处是 0, 9.00%, 31.31%, 62.22%, 100%。两条轴首尾重合,中间最多差 1.31 个百分点(第一个折点:序号 7.69% vs 弧长 9.00%)。
后果很直接:每推进 1 个 st.s 单位,四段走的距离分别是 1440 / 1190 / 1237 / 1209 km ------最短段比最长段快 1.19 倍 。所以 #106 那颗彗星在短段更快、长段更慢,只是 1.19 倍的差别肉眼看不出来,本猿当时就顺手写成了"整线归一化弧长"。说对了一半,但不精确,这里正式修正。
本猿特意把这段写得这么细,是因为它是本篇唯一一处"推翻前篇结论"的地方。技术连载最怕的不是写错,是写错了没人纠正------读者照着旧结论去做精确标尺,踩了坑还得回来骂人。所以宁可多花三百字,也要把"序号占比"这四个字钉死。
把 14 个细分点全部列出来,偏差分布一眼看清:
| 点位 | st.s(序号占比) |
累计弧长 | 弧长占比 | 偏差 |
|---|---|---|---|---|
| 0 | 0.0000 | 0.00 km | 0.00% | 0.00 pp |
| 1 | 0.0769 | 110.79 km | 9.00% | −1.31 pp |
| 2 | 0.1538 | 202.36 km | 16.43% | −1.05 pp |
| 3 | 0.2308 | 293.93 km | 23.87% | −0.80 pp |
| 4 | 0.3077 | 385.50 km | 31.31% | −0.54 pp |
| 5 | 0.3846 | 480.65 km | 39.04% | −0.58 pp |
| 6 | 0.4615 | 575.80 km | 46.77% | −0.61 pp |
| 7 | 0.5385 | 670.96 km | 54.49% | −0.65 pp |
| 8 | 0.6154 | 766.11 km | 62.22% | −0.68 pp |
| 9 | 0.6923 | 859.14 km | 69.78% | −0.55 pp |
| 10 | 0.7692 | 952.17 km | 77.33% | −0.41 pp |
| 11 | 0.8462 | 1045.20 km | 84.89% | −0.27 pp |
| 12 | 0.9231 | 1138.24 km | 92.44% | −0.14 pp |
| 13 | 1.0000 | 1231.27 km | 100.00% | 0.00 pp |
规律很整齐:偏差全部为负,中段大、两头小 。原因是细分时每段都被切成 ceil(段长 / 111 km) 个等长间隔,最后那个不足一步的零头被并进了倒数第二个间隔------于是每个折点之前,序号都"落后"于弧长一点。折点处的对照更直观:
| 折点 | 弧长占比 | 所在细分序号 | 序号占比 | 偏差 |
|---|---|---|---|---|
| v0 | 0.00% | 0 | 0.00% | 0.00 pp |
| v1 | 9.00% | 1 | 7.69% | −1.31 pp |
| v2 | 31.31% | 4 | 30.77% | −0.54 pp |
| v3 | 62.22% | 8 | 61.54% | −0.68 pp |
| v4 | 100.00% | 13 | 100.00% | 0.00 pp |
还有个容易漏的细节:st.t 在顶点上只有 0 和 1 两个值(上一行代码里 Math.max(±1, 0)),靠插值铺满线宽。这意味着如果谁把 repeatY 调成 4,st.t 在顶点上就会变成 0 和 4,贴图会沿线宽方向平铺四次------demo 没暴露这个滑块,所以它永远是 1。
2.4 为什么用 repeat 而不是 mod
想沿弧长铺 N 张图,最直觉的写法是 mod(st.s * N, 1.0)。这份源码选的是先把 st 整体乘上 repeat,再对外层取 fract:
glsl
vec2 st = repeat * materialInput.st;
vec4 colorImage = texture(image, vec2(fract(st.s - time), st.t));
两条路在数学上等价,但工程上差别很大:repeat 是个 vec2,将来想沿线宽方向也铺(repeatY),改动量是零;而 mod 写法得把两个维度分开写。更关键的是 time 只减在 st.s 上------滚动方向、滚动速率和铺图密度三者被解耦开 ,这是这份源码最聪明的设计,也是 4.1 节要拆的陷阱。反过来说,这个设计也埋了个雷:repeat 是无条件乘在 st 上的,包括 st.t 。源码写的是 vec2 st = repeat * materialInput.st,repeat 是 Cartesian2(repeatX, repeatY)。只要 repeatY 保持 1,st.t 不受影响;可 PolylineMaterialProperty 的构造函数(materials.ts L539)明确支持 repeatY,只是 demo 没给它做滑块。谁要是从代码里传一个 repeatY: 3,贴图会沿线宽方向平铺三次,而 st.t 在顶点上只有 0 和 1、插值后跨度是 3------纹理会整个错乱,而且不会有任何报错。
本猿把这个记为"隐藏参数"类坑的又一例:面板上没有,源码里支持,语义上完全改变输出。和 #77 的 VoxelBoxShape 单位盒边长是 2 不是 1、#78 的 scaleZ 比 gz 更有效,是同一族问题。
三、代码实战:17 行 GLSL 与一张全白 PNG
先声明范围:本篇只拆 PolylineImageTrail 一种材质的装配与着色器,不涉及多色分段(那是 #106 拆过的 createColoredLineSegments),也不涉及 PolylineLightingTrail(#100 拆过它和 PolylineLighting 的关系)。
本猿把这个范围声明看得比较重,是因为 polyline-effects-lib 一口气注册了十种 Fabric 材质(materials.ts L391-469),全部挤在同一个 registerPolylineMaterials() 里。十种材质、十份 GLSL、一套共享的 PolylineMaterialProperty------不划清边界,一篇能写成三篇。今天只碰第七种,前六种(Trail / Flow / Flicker 及其墙体版)已经分别在 #100、#104~#107 拆完,剩下的三种(Fence / MultiArrow / DashArrow)在 #98 拆过,最后两种(Lighting / LightingTrail)在 #100 拆过。十种,一种不落,这是本猿给这条支线定的收口标准。
3.1 demo 装配:五个滑块与一个贴图路径
src/cases/line-image-trail/LineImageTrailDemo.vue L54-67,构造参数就这几行:
ts
function buildOptions(): PolylineEntityOptions {
return {
id: LINE_ID,
positions: buildPositions(),
kind: 'PolylineImageTrail',
width: lineWidth.value,
material: {
color: Color.fromCssColorString(colorHex.value).withAlpha(opacity.value),
speed: speed.value,
repeatX: repeatX.value,
image: '/images/polyline-arrow.png'
}
}
}
面板默认值(L34-38):颜色 #33cc66、透明度 0.7、速度 3、重复次数 8、线宽 12。
这里有个小事值得记一笔:BASE_CENTER 和 CENTER 的初值完全相同(L42-43),所以 buildPositions() 算出来的 dLon = dLat = 0------首次加载时"点击地图平移折线"这个功能等于没生效 ,只有用户真的点过地图之后 CENTER 才会变。不是 bug,但演示时会让人以为按钮坏了。
五个滑块的量程也值得抄一遍,因为它们直接决定了哪些参数"够用":
| 滑块 | 量程 | 步进 | 默认 | 备注 |
|---|---|---|---|---|
| 颜色 | 任意 | 连续 | #33cc66 |
Color.fromCssColorString |
| 透明度 | 0.1 ~ 1 | 0.05 | 0.7 | 最小值 0.1,造不出 0 |
| 流动速度 | 0.5 ~ 10 | 0.5 | 3 | 20 档,只有 8 档周期"诚实" |
| 重复次数 | 1 ~ 20 | 1 | 8 | 只管数量,不管速度 |
| 线宽(px) | 1 ~ 40 | 1 | 12 | 贴图原高 21 px 被压到 12 |
线宽上限 40 px 是个有意思的约束:超过它,30×21 的贴图就要被纵向拉伸超过 2 倍,箭头会明显发虚。想要大箭头,正确做法是换一张高一点的贴图,而不是拉线宽。
3.2 材质注册:四个 uniform,两个默认值
src/cases/polyline-effects-lib/materials.ts L397-406:
ts
{
type: POLYLINE_IMAGE_TRAIL_TYPE,
source: POLYLINE_IMAGE_TRAIL_SOURCE,
uniforms: {
image: Material.DefaultImageId,
speed: 1,
color: new Color(0.2, 0.8, 0.4, 0.7),
repeat: new Cartesian2(1, 1)
}
}
注册默认值和 demo 默认值是逐位一致 的:(0.2, 0.8, 0.4, 0.7) 正是 #33cc66 配 0.7 透明度。所以哪怕有人忘了传 material,跑出来的也是同一副样子。
image 的默认值是 Material.DefaultImageId------注意它不是一张图,是 Scene/Material.js L1393 的一个字符串哨兵:
js
Material.DefaultImageId = "czm_defaultImage";
而 PolylineMaterialProperty.getValue(materials.ts L719-726)对它是这么处理的:
ts
result.color = Color.clone(this._color, result.color as Color | undefined)
result.speed = this._speed
if (this._image) result.image = this._image
if (this._kind === 'PolylineImageTrail') {
result.repeat = new Cartesian2(this._repeatX, this._repeatY)
}
🔑 关键点:
repeat只在PolylineImageTrail这一支下发 ------换成PolylineFlow,repeatX传了也白发(getValue压根不写这个 key);image是条件转发 :没设image时result.image不出现,uniform 保持注册默认的那个字符串哨兵,createTexture2DUpdateFunction(L905、L923-941)于是把context.defaultTexture绑上去------你得到的是一条没有贴图的纯色线,不报错;color和speed无条件转发,所以这两个参数对全部十种材质都有效,是真正的"白名单"。
再把 repeat 这个 Cartesian2 说透。PolylineMaterialOptions 里对应的是两个独立字段 repeatX / repeatY(materials.ts L500-501),构造函数默认都是 1(L538-539),getValue 里合成一个 Cartesian2 塞给 repeat uniform。所以面板上那个叫"重复次数"的滑块,动的只是 repeat.x ;repeat.y 永远是 1,没有任何 UI 入口。这不是疏漏------v 方向铺图对一条 12 px 宽的线毫无意义------但它意味着 st.t 会被原样传给 texture() 的第二个参数,不做 fract。一旦将来有人给 repeatY 开了口子,就得同时给 st.t 补 fract,否则超出 1 的部分行为取决于纹理包裹模式,不可控。
3.3 更新路径:image 不在补丁里,但也不会丢
demo 的 updateEffect()(L84-91)只传了 width、color、speed、repeatX,没传 image 。乍看是个 bug,其实不是------entities.ts L64-83 的 updatePolylineEntity 是逐字段补丁:
ts
if (patch.material) {
const material = polyline.material
if (material instanceof PolylineMaterialProperty) {
if (patch.material.color) material.color = patch.material.color
if (patch.material.speed !== undefined) material.speed = patch.material.speed
if (patch.material.repeatX !== undefined) material.repeatX = patch.material.repeatX
if (patch.material.image !== undefined) material.image = patch.material.image
// ...其余十个字段同理
}
}
没传的字段压根不进 if,原值保留。所以滑块改一万次,贴图还在。但这留下一个** latent trap**:如果哪天你想在运行期换一张贴图,必须在补丁里显式带上 image------而 demo 没有这个入口,只能重新 place()。
这里还藏着一个本猿差点漏看的细节:updatePolylineEntity 的 patch.material 分支里,image 的判断是 !== undefined,而 color 的判断是 truthy 。也就是说 patch.material.color 如果是个 alpha 为 0 的 Color,truthy 判断照样通过(Color 对象永远 truthy),没问题;但要是谁写了 image: undefined 想"清空贴图",!== undefined 会拦住它------你想清空也清不掉 ,material.image 保持旧值。这种"半对对半错错"的判断混用,是本篇这类手写封装里最典型的代码气味:十三个字段,十二个用 !== undefined,一个用 truthy,肉眼根本分不出哪个是故意的。
3.4 正主:17 行 GLSL
materials.ts L61-84,含 uniform 声明一共 21 行,函数体 17 行:
glsl
uniform sampler2D image;
uniform float speed;
uniform vec4 color;
uniform vec2 repeat;
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = repeat * materialInput.st;
float time = fract(czm_frameNumber * speed / 1000.0);
vec4 colorImage = texture(image, vec2(fract(st.s - time), st.t));
if (color.a == 0.0) {
if (colorImage.rgb == vec3(1.0) || colorImage.rgb == vec3(0.0)) {
discard;
}
material.alpha = colorImage.a;
material.diffuse = colorImage.rgb;
} else {
material.alpha = colorImage.a * color.a;
material.diffuse = max(color.rgb * material.alpha * 3.0, color.rgb);
}
return material;
}
逐行拆:
- L69
vec2 st = repeat * materialInput.st:密度旋钮。repeatX=8时st.s被放大 8 倍,fract之后贴图沿线铺 8 次; - L70
time = fract(czm_frameNumber * speed / 1000.0):家族统一时钟,speed在分子上------拉到 10 比拉到 0.5 快 20 倍; - L71
texture(image, vec2(fract(st.s - time), st.t)):一个fract同时管铺图与滚动。注意st.t没有fract------因为repeatY恒为 1,st.t本来就在[0,1]; - L72
if (color.a == 0.0):精确相等,不是小于。这一行是本篇的题眼; - L73-75
discard:贴图像素只要纯白或纯黑,直接扔掉; - L76-77 分支 A:贴图用自己的 RGB 和 alpha,滑块颜色完全退场;
- L79-80 分支 B:
alpha = 贴图.a × 滑块.a,diffuse = max(滑块色 × alpha × 3, 滑块色)------贴图自己的 RGB 在这里被彻底丢弃,只借它的 alpha 当蒙版。
关于 L71 还有一个容易看漏的点:采样坐标的第一个分量是 fract(st.s - time),第二个是裸的 st.t 。st.s 被 fract 收进 [0,1),st.t 因为 repeatY=1 本来就在 [0,1],两边都安全。但这也意味着贴图的 u 方向承载了"铺图 + 滚动"双职责,v 方向只做了一次性的线宽映射------30×21 的贴图,横向 30 个像素要覆盖一整张箭头的长度,纵向 21 个像素被压到 12 px 的线宽里。信息密度全压在横向上,纵向是富余的。这解释了为什么箭头看起来总是"扁"的:不是渲染错了,是源图就被这么用。
至于 L73-75 那个 discard,本猿要特别提醒一句:colorImage.rgb == vec3(1.0) 是逐分量的浮点精确相等 。GPU 默认开线性过滤,边缘像素是相邻 texel 的加权平均,极少能精确落到 1.0;只有四面八邻都是纯白的内部像素才稳过。所以这个判据在真实渲染里比源码读起来"温柔"------但代价是不可预测:换一张贴图、改一次 mip 等级,行为就可能变。做混合特效时要留神。
3.5 把那张 PNG 拆开数像素
public/images/polyline-arrow.png 只有 342 字节。解开看:30×21、RGBA8、630 个 texel。用纯标准库写个解码脚本,把每个像素的 alpha 和 RGB 都数出来:
python
import struct, zlib, collections
d = open("public/images/polyline-arrow.png", "rb").read()
off, idat = 8, b""
while off < len(d):
ln = struct.unpack(">I", d[off:off + 4])[0]
typ = d[off + 4:off + 8].decode("latin1")
if typ == "IHDR":
w, h, bd, ct = struct.unpack(">IIBB", d[off + 8:off + 18])
print(f"{w}x{h} bitdepth={bd} colortype={ct}") # colortype 6 = RGBA
if typ == "IDAT":
idat += d[off + 8:off + 8 + ln]
off += 12 + ln
raw = zlib.decompress(idat)
bpp, stride = 4, w * 4
out, prev, pos = bytearray(), bytearray(stride), 0
for y in range(h): # PNG 五行滤波,逐个反解
f = raw[pos]; pos += 1
row = bytearray(raw[pos:pos + stride]); pos += stride
if f == 1:
for i in range(bpp, stride): row[i] = (row[i] + row[i - bpp]) & 255
elif f == 2:
for i in range(stride): row[i] = (row[i] + prev[i]) & 255
elif f == 3:
for i in range(stride):
a = row[i - bpp] if i >= bpp else 0
row[i] = (row[i] + ((a + prev[i]) >> 1)) & 255
elif f == 4:
for i in range(stride):
a = row[i - bpp] if i >= bpp else 0
b, c = prev[i], prev[i - bpp] if i >= bpp else 0
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
row[i] = (row[i] + pr) & 255
out += row; prev = row
tp, opaque = collections.Counter(), 0
for y in range(h):
for x in range(w):
i = y * stride + x * 4
r, g, b, a = out[i], out[i + 1], out[i + 2], out[i + 3]
if a == 0:
tp[(r, g, b)] += 1
else:
opaque += 1
white = sum(1 for y in range(h) for x in range(w)
if out[y * stride + x * 4] == 255 and out[y * stride + x * 4 + 1] == 255
and out[y * stride + x * 4 + 2] == 255)
print("全透明像素的 RGB:", dict(tp))
print(f"alpha>0 的像素: {opaque} / {w*h}")
print(f"RGB == (255,255,255) 的像素: {white} / {w*h}")
跑出来三行,第三行是重点:
text
全透明像素的 RGB: {(255, 255, 255): 141}
alpha>0 的像素: 489 / 630
RGB == (255,255,255) 的像素: 630 / 630
630 个 texel,RGB 无一例外全是 255------包括那 141 个全透明像素。这不是疏忽,是导出 PNG 时的常规行为:alpha 为 0 的区域 RGB 填什么都没人看。但这份 GLSL 会看。
再多说几个解码时看到的东西。这个文件除了 IHDR / IDAT / IEND,还带一个 pHYs chunk(9 字节,物理分辨率),IDAT 压缩后 264 字节、解压后 2541 字节------正好是 21 行 × (1 字节滤波 + 30×4 字节) = 2541,一行不差。alpha 的直方图也很干净:0 出现 141 次、255 出现 393 次,剩下 96 个分布在 16 到 240 之间,全是 16 的整数倍(16/32/48/64/80/96/112/128/144/160/176/192/208/224/240)------典型的导出工具按 16 级量化抗锯齿边缘,不是连续 alpha。
顺便交代一下方法:本猿这里没用 PIL,也没用任何第三方库,就 struct 加 zlib 两个标准库模块。理由很实在------解码 PNG 的过程本身就是论据的一部分 。这篇文章的核心结论是"630 个 texel 的 RGB 全是 255",如果这个数字来自一个你看不见内部实现的库,读者只能选择信或者不信;而五行滤波、每行开头那个滤波字节、IDAT 解压后 2541 字节的来历,全都在上面这段不到四十行的代码里摆着,任何人拷过去都能复现出同一组数。技术写作里"可复现"不是美德,是底线------尤其是本篇这种"整条线消失"的玄学结论,更得把每个字节的出处交代清楚。
这 96 个半透明像素就是 4.2 节那张表的原料:它们的 alpha 跨过了 0.4762 这条临界线,所以箭头边缘会"一半亮一半淡"。而 141 个全透明像素在分支 B 里贡献 alpha = 0、被混合公式自然抹掉,在分支 A 里则因为 RGB=255 被 discard------同一批像素,两条分支里死法完全不同。
3.6 探针复刻:st.s 到底是序号还是弧长
口头争论没有意义,直接把 PolylineGeometry 请进 Node 跑一遍。要点是完全复刻 entity 路径的参数 ------arcType 和 granularity 都不传,让它们走默认值:
js
const Cesium = require('cesium')
const { Cartesian3, PolylineGeometry } = Cesium
const positions = [
[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]
].map(([lon, lat, h]) => Cartesian3.fromDegrees(lon, lat, h))
// arcType / granularity 故意不传 ------ 与 PolylineGeometryUpdater 的实体路径一致
const poly = new PolylineGeometry({
positions,
width: 12,
vertexFormat: Cesium.VertexFormat.POSITION_AND_ST
})
const geom = PolylineGeometry.createGeometry(poly)
const posV = geom.attributes.position.values
const stV = geom.attributes.st.values
const nV = posV.length / 3
console.log('顶点数 =', nV)
const seen = new Set()
const seq = []
for (let i = 0; i < nV; i++) {
const sv = +stV[i * 2].toFixed(6)
if (seen.has(sv)) continue
seen.add(sv)
seq.push({ s: sv, p: new Cartesian3(posV[i * 3], posV[i * 3 + 1], posV[i * 3 + 2]) })
}
seq.sort((a, b) => a.s - b.s)
console.log('细分点位数 =', seq.length)
console.log('st.s 取值 =', seq.map(r => r.s.toFixed(4)).join(', '))
// 再按顶点位置累计真实弧长,与序号占比逐点对比
let acc = 0
for (let i = 0; i < seq.length; i++) {
if (i > 0) acc += Cartesian3.distance(seq[i - 1].p, seq[i].p)
const af = acc / 1231086.01
console.log(`点位 ${String(i).padStart(2)} st.s=${seq[i].s.toFixed(4)} ` +
`弧长=${(acc / 1000).toFixed(2)}km 占比=${(af * 100).toFixed(2)}% ` +
`偏差=${((seq[i].s - af) * 100).toFixed(2)}pp`)
}
跑出来:
text
顶点数 = 52
细分点位数 = 14
st.s 取值 = 0.0000, 0.0769, 0.1538, 0.2308, 0.3077, 0.3846, 0.4615,
0.5385, 0.6154, 0.6923, 0.7692, 0.8462, 0.9231, 1.0000
1/13 的等距步长,铁证。再把 52 这个数对回源码:Core/PolylineGeometry.js L477-479 给每个折点生成 startK = j === 0 ? 2 : 0 到 endK = j === positionsLength - 1 ? 2 : 4 个顶点------首尾各 2 个、中间 12 个折点各 4 个,2 + 12×4 + 2 = 52,一个不差。"14 个点位"是折点级的概念,"52 个顶点"是缓冲区级的概念,两者差在一个"每个折点左右各一个"的展开系数上,看代码时很容易混。
3.7 探针复刻:time 的真实周期
第二个探针算周期。fract(帧号 × speed / 1000) 的相位归零条件是"帧号 × speed 被 1000 整除",用分数精确求解,不猜:
js
function gcd(a, b) { a = Math.abs(a); b = Math.abs(b); while (b) [a, b] = [b, a % b]; return a }
const M = 10 * 1000 // speed 用一位小数表示 => 分母 10
const rows = []
for (let sp = 0.5; sp <= 10.0001; sp += 0.5) {
const num = Math.round(sp * 10) // speed = num / 10
const g = gcd(num, M)
const period = M / g // 最小正整数 N 使 N*num ≡ 0 (mod M)
const naive = M / num // 1000 / speed
rows.push({ sp, naive, period, mult: +(period / naive).toFixed(2) })
}
console.log('speed | 朴素帧数 | 真实回归 | 60fps周期 | 倍数')
for (const r of rows) {
console.log(`${String(r.sp).padStart(4)} | ${r.naive.toFixed(2).padStart(8)} | ` +
`${String(r.period).padStart(6)} | ${(r.period / 60).toFixed(2).padStart(7)}s | ` +
`${Math.abs(r.period - r.naive) < 1e-9 ? '一致' : 'x' + r.mult}`)
}
console.log('20 档中一致的档数 =', rows.filter(r => Math.abs(r.period - r.naive) < 1e-9).length)
结果:20 个档位里只有 8 个 (0.5 / 1 / 2 / 2.5 / 4 / 5 / 8 / 10)朴素值与真实周期一致;speed=3 真实 1000 帧(16.67 s)是朴素 333.33 帧(5.56 s)的 3 倍;speed=9.5 差 19 倍。这组数和 #107 逐位相同------因为公式一字不差,差别只在"看不看得见"。
四、功能亮点与性能优势

4.1 亮点一:color.a == 0.0 那条路,被两道门同时锁死
现在回到 3.4 节的 L72。这条分支的设计意图很清楚:让贴图用自己的颜色,滑块只当开关。听起来很合理,实际一步都走不通。

第一道门:滑块造不出 0。 demo 的透明度滑块(L159)是 min="0.1" max="1" step="0.05"------最左也只能到 0.1,color.a 永远是 0.1、0.15、0.20......精确的 0.0 永远造不出来。分支 A 在 UI 层面就是死的。
这道门值得多说两句,因为它不是"写错了",而是"设计时就没打算让你过去"。想想也对:透明度 0 的线在屏幕上什么都不是,做成滑块最左档毫无意义,所以作者把下限抬到了 0.1。但 GLSL 那边却用 color.a == 0.0 当模式开关 ------两边的假设对不上,于是就出现了一个"功能上存在、交互上不存在"的分支。这类"UI 与着色器各写各的"造成的死代码,本猿在 #103(_currentRadius > radius 在默认参数下是死代码)、#107(percent/gradient 传给 Flicker 静默丢失)都见过,今天是第三次。规律是:只要面板量程和 GLSL 判断条件不是同一份代码写的,就会出现这种缝。
第二道门:就算写死 0,整条线也会消失。 假设你绕过 UI,直接在代码里 new Color(0.2, 0.8, 0.4, 0)------进了分支 A,第一句就是 if (colorImage.rgb == vec3(1.0) || colorImage.rgb == vec3(0.0)) discard;。而 3.5 节刚数过:这张图 630/630 个 texel 的 RGB 都是 (255,255,255) ,于是每一个像素都命中 colorImage.rgb == vec3(1.0),全部 discard,整条线一个像素都不剩。
一道门是产品设计问题,一道门是素材问题,两道门各自独立、同时生效。本猿把这个结论写成一句可以抄走的话:这份 PolylineImageTrail 只有"染色模式",没有"本色模式"------想用贴图本色,得先改 GLSL,把 discard 的条件从"纯白或纯黑"改成"alpha 为 0"。
4.2 亮点二:max() 那行是穷人版 HDR,但有临界点
分支 B 里最耐人寻味的是 L80:
glsl
material.diffuse = max(color.rgb * material.alpha * 3.0, color.rgb);
alpha = colorImage.a × color.a。展开就是:贴图越不透明的地方,颜色被放得越亮,上限是基色的 3 × color.a 倍。用 demo 默认值(color.a = 0.7)算一遍:
| 贴图 alpha | 合成 alpha | diffuse 放大倍数 | 触发增益? |
|---|---|---|---|
| 0 | 0 | 1.000 | 否 |
| 0.25 | 0.175 | 1.000 | 否 |
| 0.4762 | 0.3333 | 1.000 | 临界 |
| 0.50 | 0.350 | 1.050 | 是 |
| 0.75 | 0.525 | 1.575 | 是 |
| 1.00 | 0.700 | 2.100 | 是 |
临界点精确落在 1 / (3 × 0.7) = 0.4762。这张箭头有 393 个 texel 的 alpha 是 255(实心)、96 个在 16~240 之间(软边)------所以实心部分吃满 2.10 倍增益,软边有一部分吃不到,箭头看起来"芯更亮、边更淡",立体感就是这么来的。
还有个副作用:把透明度滑块拉到最小 0.1,alpha ≤ 0.1 < 1/3,max() 永远取右边那项------整条线退化成没有任何增益的纯色细线 ,箭头软边全部消失。这是本篇第二个"滑块不接管线"的变体:#106 是完全不读 color.a,#108 是读了但只在高端区间起作用。
顺带把一个容易混的点说清:max() 这行在家族里不是孤例------#100 的 PolylineLighting 有 max(fragColor.rgb + colorImage.rgb, fragColor.rgb),#105 的墙体版有 max(color.rgb * material.alpha, color.rgb),全是同一招"穷人版 HDR":用 max 把高 alpha 区的颜色推出 0,1,让暗部保持原色、亮部过曝。区别只在增益系数:#100 是加法、#105 是 1 倍、本篇是 3 倍。系数越大,箭头"芯越亮、边越淡"的反差越强。
4.3 亮点三:repeatX 只管数量,不管速度
这是 #107 留的第一个问号。答案是:管用,但只管数量。
time 里只有 czm_frameNumber × speed,跟 repeatX 一点关系都没有。所以 repeatX 从 1 拉到 20,箭头的滚动快慢一模一样,只是屏幕上排开的支数变了。

按 1231.09 km 算,每张贴图跨的地面距离:
repeatX |
每张跨 | 屏幕上每张占 |
|---|---|---|
| 1 | 1231.09 km | 702 px |
| 4 | 307.77 km | 176 px |
| 8(默认) | 153.89 km | 88 px |
| 12 | 102.59 km | 59 px |
| 20 | 61.55 km | 35 px |
repeatX=1 时整条 702 px 屏幕弧长上只有一张 30×21 的箭头------被拉成一条细长的绿带;repeatX=20 时每张只剩 35 px,箭头挤成一条绿虚线。想要"箭头大一点",该动的是线宽和相机,不是重复次数。
再补一个从表里能读出来的结论:每张贴图的屏幕宽度 = 702 / repeatX px,与相机距离无关(近似)。因为整条线的屏幕弧长和每张占比是同一个比例关系的两端。所以"相机拉近"和"repeatX 调小"在屏幕上几乎等效------都能让单张箭头变大。但两者的物理含义完全不同:拉近相机是"看得更细",调小 repeatX 是"路上箭头变少"。做产品时要按语义选,别按效果选。
视速度也一起算出来(repeatX=8、60 fps):
speed |
视觉周期 | 每张走 | 视速度 |
|---|---|---|---|
| 0.5 | 33.33 s | 153.89 km | 4.62 km/s |
| 2 | 8.33 s | 153.89 km | 18.47 km/s |
| 3(默认) | 5.56 s | 153.89 km | 27.70 km/s |
| 10 | 1.67 s | 153.89 km | 92.33 km/s |
对照 #106:同一个周期里,PolylineFlow 的彗星要跑完整条 1231.09 km,speed=3 时是 221.6 km/s 。#108 每张只走 1/8,视觉上正好慢 8 倍。同一段时间,一个交给一颗彗星,一个交给八张图------这就是"数量旋钮"的代价。
4.4 亮点四:三倍周期陷阱,这次被周期性抹平了
time = fract(帧号 × speed / 1000),speed=3 时相位归零要求"帧号 × 3"被 1000 整除,最小正整数是 1000 帧 = 16.67 s ;而 1000/3 = 333.33 帧(5.56 s)只是"平均每次推进一张"的间隔。真实状态回归是朴素值的整整 3 倍------和 #107 的三倍陷阱同源。
20 个滑块档位里只有 8 个(0.5 / 1 / 2 / 2.5 / 4 / 5 / 8 / 10)两者一致,speed=9.5 时真实周期是朴素值的 19 倍。
但这一次,陷阱看不见 。原因很简单:fract(st.s - time) 里 time 跳一帧,等于整幅贴图平移一小段;而贴图本身以 1 为周期,平移量被周期性吞掉,视觉上无缝。#107 的 smoothstep 没有这个保护------scalar 从 0.999 掉到 0.002 是一帧之内的事,整条线当场全灭。
同一个公式,在有周期性的贴图族里是隐形bug,在无周期性的状态族里是显性bug。 这句话是本篇最值得带走的。
但"看不见"不等于"不存在",本猿必须补一句严谨的:被抹平的只是视觉上的跳变 ,不是数学上的周期。如果你的代码在 postRender 里读 czm_frameNumber 去做别的事情(比如同步另一个非周期控件、或者按帧计数触发逻辑),那个 3 倍差还在。#107 的教训在这里依然成立------周期这件事,只有"你依赖它"和"你不依赖它"两种状态,没有"看着像"这种中间态。
4.5 屏幕足迹:6.0% × 34.0%,第三次复现
相机和 #106/#107 一字不差:setView({ destination: Cartesian3.fromDegrees(115.73, 36.2, 2600000) })。手工搭 view 矩阵 + fov=60°、aspect=16:9 投影,1920×1080 下:

| 指标 | 数值 |
|---|---|
| NDC 包围盒 | 宽 6.0% × 高 34.0% |
| 像素(1920×1080) | 115 × 368 px |
| 折线屏幕弧长 | ≈ 702 px |
| 端点直线距离 | 657 px |
| 四段屏幕占比 | 9% / 27% / 27% / 44% |
三条读数与 #106/#107 逐位相同 ------三个 demo 共用同一份 DEFAULT_LINE_POSITIONS、同一个 setView,这不是巧合,是这条支线共同的底盘。所以"屏幕只占 6%"不是新坑,写这块 demo 的人从头到尾没打算让你在默认视角看清细节。
本猿把"怎么把这条线看清"也算了一遍,供你复现时参考:NDC 宽占比 6.0% 对应 115 px,要占到屏宽 30%(576 px)需要把相机压到约 520 km ;高度占比 34.0% 对应 368 px,看着不算小,但那是因为线是斜的------真正决定"能不能看清箭头"的是屏幕弧长 :2600 km 下整条 702 px,每张箭头 88 px,勉强能看出形状;压到 520 km 后整条约 3500 px、每张 440 px,箭头会大得糊成一片。合适的观察距离在 800~1500 km 之间,那时每张箭头 200~400 px,形状和数量都合适。这是本猿调出来的经验值,不是实测数据,你复现时以自己看着舒服为准。
顺带一个和 2.3 节呼应的数:屏幕弧长 702 px 比端点直线距离 657 px 只长 6.8%------折得并不多。但在弧长参数化下,四段屏幕占比 9%/27%/27%/44% 和地面占比 9.0%/22.3%/30.9%/37.8% 并不一致,投影本身也在重新分配长度。
4.6 横向对照:和 #105 墙体版是同一份思路的两次注册
把 #105 的 WallImageTrail 和今天这份并排看,会发现 GLSL 的骨架几乎一样:
glsl
// #105 WallImageTrail(墙)------ 摘自 wall-effects-lib/materials.ts
vec2 st = materialInput.st; // u 来自墙长 st.s,v 锁 st.t
vec4 colorImage = texture(image, vec2(fract(st.s - time), st.t));
if (color.a == 0.0) {
if (colorImage.rgb == vec3(1.0) || colorImage.rgb == vec3(0.0)) { discard; }
material.alpha = colorImage.a;
material.diffuse = colorImage.rgb;
} else {
material.alpha = colorImage.a * color.a;
material.diffuse = max(color.rgb * material.alpha * 3.0, color.rgb);
}
glsl
// #108 PolylineImageTrail(线)------ 摘自 polyline-effects-lib/materials.ts
vec2 st = repeat * materialInput.st; // 多了一个 repeat 乘子
vec4 colorImage = texture(image, vec2(fract(st.s - time), st.t));
if (color.a == 0.0) {
if (colorImage.rgb == vec3(1.0) || colorImage.rgb == vec3(0.0)) { discard; }
material.alpha = colorImage.a;
material.diffuse = colorImage.rgb;
} else {
material.alpha = colorImage.a * color.a;
material.diffuse = max(color.rgb * material.alpha * 3.0, color.rgb);
}
从 texture 那一行往后,一字不差。差别只在两处:
| 维度 | #105 墙体版 | #108 线版 |
|---|---|---|
| 几何来源 | WallGeometry(四边形墙) |
PolylineGeometry(折线带) |
st.s 语义 |
沿墙高的归一化序号 | 沿弧长的归一化序号(近似) |
| 密度控制 | 已有 repeat |
同样 repeat,且 repeatX 上了面板 |
| 相机 | 6000 m 俯视 | 2600 km 俯视 |
所以"同一份思路注册了两次"不是本猿的修辞,是字面事实。这也是本系列读源码时最该养成的习惯 :看到一段 GLSL,先去 grep 有没有第二个注册点------如果有,坑大概率也是双份的,改一处漏一处的故事就是这么发生的。
这里多付一点篇幅说"为什么值得专门 grep 一遍注册点"。同一份 GLSL 在两个文件里各存一份,本猿这次就付出了实打实的代价:一开始只读了 polyline-effects-lib/materials.ts,以为 repeat 是线版独有的设计,稿子都按"线版比墙版多一个乘子"写了半篇;直到翻 #105 的复盘记录找出墙体版源码,才发现乘子两边的代码一字不差,repeat 的真正分野不在 GLSL,而在 getValue 的白名单------墙体版早就把 repeat 下发下去了,线版才是后加的那一个。要不是回头 grep 了一遍,这篇就要把一个"家族共性"误写成"线版独创",错的还是结论性的一句。读源码时的每一次"我记得",都值得用一次 grep 去换。
五、小贴士:避坑指南
🎯 关于"贴图本色"
这是本篇最大的坑,值得单独一节。 想用自己的图标原色,记住三条:
- ⚠️ 别指望
color.a = 0能触发本色模式 :demo 滑块最小 0.1,而且代码里也得写精确的 0.0,写1e-6都不算; - ⚠️ 触发之前先检查贴图 :只要贴图里有纯白或纯黑像素,那些像素会被
discard。全白剪影(本例)等于整张图全丢; - ✅ 要改就改
discard的条件 :把colorImage.rgb == vec3(1.0) || colorImage.rgb == vec3(0.0)换成colorImage.a < 0.01,让"透明"而不是"白/黑"当裁剪依据------这才是带 alpha 通道的 PNG 该有的判据。
另外,那个 == 是浮点精确相等 。开了 mipmap 和线性过滤之后,边缘像素会被相邻像素插值,极少有像素能精确等于 1.0 或 0.0------所以这个 discard 在真实渲染里比源码看起来更"温柔",但也更不可预测。做混合特效时要留神。
本猿再补一个"改完之后怎么验证"的操作建议。改完 discard 条件,别只看截图------截图只能告诉你"线还在",告诉不了你"边缘漏了多少"。靠谱的验证是数像素:把同一段折线在改动前后各截一张图,读回像素统计 alpha 直方图,对比 alpha=0 的像素数是否等于贴图里 alpha=0 的 texel 数乘上覆盖次数。本猿这次就是用这个办法反推出"分支 A 会全丢"的------先看到截图上线没了,再回头数贴图像素,两边一对,答案就自己跳出来了。
还有一条更通用的:带 alpha 通道的 PNG 进 GLSL,永远先数一遍 RGB 。本猿这次数出来 630/630 全白,属于运气不好撞上了最坏情况;但哪怕不是全白,"纯黑背景"也是常见导出结果,一样会触发 colorImage.rgb == vec3(0.0)。这两条判据加在一起,覆盖面比想象中大得多。
还有一件事本猿必须提醒:别把"偏差只有 1.31 pp"当成可以忽略 。1.31 pp 在这条线上确实小,但它是个百分比------换成一条 10 公里的线,同样的参数化偏差对应的绝对误差可能只有几十米;换成一条 10 万公里的环球线,偏差会被放大到上千公里。这个坑的严重程度完全由数据尺度决定 ,而 GLSL 对此一无所知。所以本猿的建议是:每次用 st.s 做定位之前,先按自己数据的折点数算一遍 ceil(段长/111 km) 的小数部分,心里有个数。
🗺️ 关于弧长参数化
常识是"st.s 沿弧长从 0 到 1",本篇实测证明它是细分顶点序号占比。可操作的建议:
- 想让箭头严格等距,自己控制
granularity:把折线按等长预切点,或者给 entity 传arcType: ArcType.NONE(这样positionsLength就是你给的点数,st.s = j/(n-1),语义完全可预测); - 接受默认
GEODESIC + 1°也行,但要知道偏差量级:本例最大 1.31 pp、速度差 1.19 倍。做精确标尺类应用(比例尺、等距刻度)不能用它;
这里给个可迁移的判断方法:偏差量级 ≈ (每段零头长度) / (细分步长) × (每段步数)² / 2 。听起来复杂,其实只要看一件事------每段长度除以 111 km 之后的小数部分大不大。本例四段是 0.996、2.468、3.419、4.179 个步长,小数部分 0.996 / 0.468 / 0.419 / 0.179,最大接近 1,所以 v1 处偏差最大。折线段越碎、每段零头越大,st.s 离弧长越远。 如果你的数据是几十个短段(比如城市路网),这条近似会崩得很难看,那就必须自己传 granularity 或者改用 ArcType.NONE。
granularity是个隐藏参数 ------demo 面板上没有入口,但它直接决定st.s的语义。改它之前先想清楚"影响数据还是只影响呈现"。
⚡ 关于性能
- 贴图只有 30×21:一张 342 字节的 PNG,显存和采样开销可以忽略。但注意线宽默认 12 px,贴图原高 21 px 被纵向压掉 43%------想要清晰箭头,要么换高一点的贴图,要么把线宽拉到 20 以上;
- mipmap 会糊掉箭头 :30×21 的贴图在 88 px 的屏幕上属于轻微放大,但如果相机拉远到整条线只占 100 px,每张箭头就只剩 10 px 出头------这时 mipmap 会选更粗的层级,箭头顶端会被抹圆。要锐利,就在
Sampler里把minificationFilter换成NEAREST,代价是远处会闪; - 半透明排序 :材质注册时
translucent: () => true,走的是半透明批次,多条线并置时排序不保证稳定(#102 已经拆过这个机制); - 帧率依赖第十二次 :公式和 #104/#105/#106/#107 一字不差,
speed在分子上,144 Hz 比 60 fps 快 2.4 倍; czm_frameNumber是 GLSL float :整数精确上限 2^24 = 16,777,216 帧,60 fps 下约 3.24 天 ,超过之后每帧推进变成 2,动画开始加速。大屏长跑要记得周期性重置(viewer.clock.currentTime归零即可);- 当前局限 :本篇没有实测多线并置时的层叠关系,也没有把
phaseuniform 加进 demo(#107 提过的改进路子,这里同样适用)。要错峰,只能给不同线不同的speed------但那样周期也一起变了,不是干净的解法。
关于多线,这里补一个从源码推出来的结论:czm_frameNumber 是 Cesium 的全局帧计数器,同一场景里所有用它的材质共享同一个相位 。所以两条 PolylineImageTrail 只要 speed 相同,箭头就永远同步进退------画面像一条线,不像两条。想要错峰,干净的解法是往 GLSL 里加一个 phase uniform:
glsl
uniform float phase; // 新增
float time = fract(czm_frameNumber * speed / 1000.0 + phase);
再把 phase 加进 getValue 的白名单(materials.ts L719-726,注意它只转发写了的 key),每条线给不同的 phase 即可。#107 已经把这个路子写进了源码但没跑进 demo,今天同样如此------留给后续。
六、写在最后
一张 30×21 的白色剪影,被一条 1231 公里的折线反复采样,
铺成八支箭头,一路往前滚。
它自己的颜色从来没上过场------
但它确实,让这条线活了。
回看这条支线的七篇,其实是同一条曲线的七次采样:#100 第一次让折线亮起来,发现动的是一道暗缝;#102 把几何交给引擎,发现 st.s 是段序号不是弧长;#103 让墙动起来,发现 speed 是除数;#104 把流动搬上墙,发现只走了条对角线;#105 换成贴图,发现墙是穿透场;#106 搬到线上,发现 color.a 是死旋钮;#107 不读 st,发现周期差三倍;今天 #108 把贴图请进线,发现"本色模式"根本进不去。七篇没有一篇是靠读文档读出来的,全是跑出来的。 这就是本猿一直说的"库行为要跑出来再写"------Cesium 的材质系统里,st 的语义、uniform 的白名单、混合公式的贡献方式,三样都能单独看对、合起来看错。
第 108 篇到此,"线特效"这条支线正式铺完:#100 发光轨迹、#102~#108 墙与线的六块砖 ,从"亮一下"一路讲到了"把位图请进着色器"。五份 GLSL、同一个时钟、五种"读不读 st"的答案------本猿觉得这条线最值得带走的不是任何一招,而是那个反复出现的模式:每个看着像"装饰"的参数(repeat、color.a、granularity),背后都是一条独立的语义链,看着像旋钮,其实有的是数量、有的是开关、有的根本不是给你用的。
本篇也有没说透的地方,列个清单:多线并置时的半透明排序没实测;repeatY 这支只从源码推过、没跑进 demo;discard 的浮点相等在开了 mipmap 之后到底漏多少像素,也没逐档量;phase uniform 只写了源码没接进 demo;那段"把 video 进度当 time 用"的轨迹回放思路,更是一行没写。这些留给后续,也欢迎你先去仓库里魔改一版------仓库里改一张贴图、改一个 uniform,比改一百行业务代码有意思得多。
最后留个小小的私心:这张 30×21 的白色箭头,本猿越看越觉得像连载本身------像素小到看不清,颜色淡到没上过场,可它一旦开始沿弧长滚动,整条 1231 公里的线就有了方向。有些存在就是这样,不抢戏,但少了它,整场戏就不成立。
如果这篇帮你把"图片轨迹线"从"换个颜色的流动线"里救出来,欢迎点个"在看";有想看的 Case 或者踩过的坑,评论区聊。我是 Cesium 酱,咱们下一篇见!
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在 src/cases/line-image-trail/ 目录下,核心就一个 LineImageTrailDemo.vue,材质在 src/cases/polyline-effects-lib/materials.ts 的 POLYLINE_IMAGE_TRAIL_SOURCE(L61,函数体 17 行)。搬去自己项目时,盯住三处:registerPolylineMaterials() 的幂等注册、translucent: () => true 的半透明声明,以及 getValue 里 repeat 只在 PolylineImageTrail 这一支下发这个白名单。这条线往后------线特效这条支线到 #108 就算收口了;按 CASE_LIST 序号,下一站是 #109 数据可视化-平面视频,把一段视频贴到一个平面上,看看它和今天这位"贴图族"亲戚能差出多远。
Cesium酱の百宝箱 · 第 108 篇
一贴滚成八箭,本色关在门外
2026 深秋,与你在第十七行 GLSL 里相逢