第98篇:三维特效-材质线效果——给一条线换十种材质,虚线的图案只填了一半

本文目标:换到一条完全独立的技术线上------Cesium 的 Fabric 材质系统 。看十种线材质是怎么用一段段 czm_getMaterial 写出来的、它们和前几篇那个 CustomShader 究竟是不是一回事、为什么把"虚线图案"滑到最大也只能画出一半的实线、以及切到"栅栏线"时默认参数为什么会撞车,让整条线只剩一层轮廓

💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。


前面四篇我们一直泡在同一条管线上:3D Tiles 的 Model 片元着色器里那个 customShaderStage,往 material.diffuse 和 material.emissive 里写东西。白城、点光源、色带、扫描光带,四篇换的都是"写什么",管线本身没动过。

本篇换个方向。回到地面上,看 Cesium 怎么给一条折线 上材质------这里用的是另一套完全不同的系统:Fabric 材质。

"三维特效"这个分类在本系列里指的是不依赖业务数据、纯靠视觉手法做出来的效果:流动、闪烁、发光、扫描、围栏。它们通常不回答"这是什么",只回答"看这里"。而"材质线"是这类效果里最基础的一种------线是最常用的表达载体(路径、边界、流向、连线),给线加上材质,等于给所有这些表达都配上了一套视觉语言。

本篇这个 demo 把十种线材质放在同一个面板上,本质上是一份材质样例册 。你要的不是"背下十种材质",而是看懂它们的共同骨架------看完你会发现,十种材质里真正不同的只有最后三到五行 GLSL,前半截全都一样。它不叫 fragmentMain,叫 czm_getMaterial;它不给你 czm_modelMaterial,给你 czm_material 和 czm_materialInput;它不是挂在 tileset 上的 customShader,而是一个 MaterialProperty,塞进 entity 的 polyline.material。

两套系统长得很像,都能写 GLSL、都有 uniform、都能逐片元算颜色,但它们的注册方式、更新机制和坐标系完全不是一回事。本篇就把这十种线材质拆开,顺带把两套系统的差别讲清楚。

demo 是 src/cases/line-material/LineMaterialDemo.vue(343 行),材质库在 src/cases/polyline-effects-lib/(materials.ts 776 行 + entities.ts 171 行)。本文数字来自两条路径:把 GLSL 逐行直译成 Node 跑数,以及读 node_modules/cesium/Build/CesiumUnminified/Cesium.js(1.144.0)。

还有个背景值得交代:Cesium 自带的 PolylineDashMaterialProperty、PolylineArrowMaterialProperty、PolylineGlowMaterialProperty 已经覆盖了虚线、箭头、发光三种常见需求。本库的十种是在此之上的扩展------PolylineFence(带轮廓的栅栏)、PolylineMultiArrow(沿线多个箭头)、PolylineDirection(周期性方向箭头)都是官方没有的。所以动手前先看看自带那几个够不够用,能省就省。

一、这不是 CustomShader,是另一套系统

先把两套系统的差别摆出来,不然后面每一行代码都会看着眼熟却对不上号:

CustomShader(#95~#97) Fabric 材质(本篇)
入口函数 void fragmentMain(FragmentInput, inout czm_modelMaterial) czm_material czm_getMaterial(czm_materialInput)
结构体 czm_modelMaterial(diffuse / emissive / specular / roughness) czm_material(diffuse / specular / emission / alpha)
挂在哪 tileset.customShader(3D Tiles / Model) entity 的 polyline.material(任何几何)
注册方式 new CustomShader({...}),公开 API Material._materialCache.addMaterial(...),私有缓存
改参数的代价 setUniform() 改一个槽位,零重建 改属性触发 definitionChanged 事件
st 从哪来 fsInput.attributes materialInput.st(几何自带)

第一条最重要的区别是作用域 :CustomShader 只挂在 3D Tiles / Model 上,而 Fabric 材质是 Cesium 所有几何通用的------折线、面、墙体、球,只要能画出来就能贴材质。所以本篇这一套 GLSL 可以原样搬到任意一个几何上,这是 CustomShader 做不到的。

注册这段值得单独看,因为它用的是私有 API:

ts 复制代码
// polyline-effects-lib/materials.ts L385-481
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
  const entries: Array<{ type: string; source: string; uniforms: Record<string, unknown> }> = [
    { type: POLYLINE_TRAIL_TYPE, source: POLYLINE_TRAIL_SOURCE,
      uniforms: { color: new Color(0.2, 0.6, 1.0, 0.7), speed: 1 } },
    // ... 其余九种
  ]
  for (const entry of entries) {
    if (cache.getMaterial(entry.type)) continue
    cache.addMaterial(entry.type, {
      fabric: { type: entry.type, uniforms: entry.uniforms, source: entry.source },
      translucent: () => true
    })
  }
}

Material._materialCache 是 Cesium 的私有字段(第 16 篇我们靠它核对过 Material.fromType 的报错原文)。用 as unknown as 强转才能访问,说明这不在公开 API 上 ------升级 Cesium 时这里是最先可能崩的地方。更稳的做法是用公开的 Material.fromType(type, uniforms),它内部走的也是同一套 fabric 机制。

注意两点:一是 registered 标志让它只注册一次 (缓存在 Material 上是全局的,重复注册会覆盖);二是 translucent: () => true 把所有材质都标成半透明,于是十种线全部进 translucent pass------随之而来的是排序问题,多条线交叉时可能出现前后错乱。

材质本身通过一个 MaterialProperty 挂到 entity 上:

ts 复制代码
// entities.ts L30-42
export function createPolylineEntity(viewer: Viewer, options: PolylineEntityOptions): Entity {
  removePolylineEntity(viewer, options.id)                 // 先删旧的,避免叠加
  const id = `${POLYLINE_ENTITY_PREFIX}${options.id}`      // 前缀 'dc-polyline-'
  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
    }
  })
}

PolylineMaterialProperty 是标准的 Cesium 属性类三件套:getType() 返回材质类型名,getValue(time, result) 每帧把 uniform 值填进 result,isConstant 返回 false 表示"每帧都要取"。

DEFAULT_LINE_POSITIONS 这条默认折线是五个点,从北京(116.391, 39.907)经天津、鲁西南、郑州到武汉(114.305, 30.593),高度全部是 1000 米 。选 1000 米是为了让线浮在地形之上、不被楼房遮挡------Cesium 的折线没有碰撞检测,压到 0 米很容易有一段被山挡掉。相机放在 fromDegrees(115.73, 36.2, 2600000),即华北上空 2600 公里,五个点一屏装下。

为什么要交代这个:本篇所有"观感"类的结论都绑定在这个视角上。折线在屏幕上横跨上千像素,所以第四节的 16 像素虚线看起来是密的、第二节的箭头是正常的------换成一张只显示北京的图,同一套参数的结论会反过来。

关键在于每个 setter 都会触发 definitionChanged:

ts 复制代码
// materials.ts L562-566(以 color 为例,其余十几个字段同构)
set color(value: Color) {
  if (this._color.equals(value)) return
  this._color = value
  this._definitionChanged.raiseEvent(this)
}

这是本篇和前三篇最本质的差别 。CustomShader 改参数走的是 setUniform()------改一个槽位,下一帧闭包取到新值,零重建。而 Fabric 材质改参数会抛 definitionChanged 事件,Cesium 收到后要重新走一遍材质/几何的更新链路(第 46 篇我们量过同样的机制:动态墙改一个颜色就销毁重建整批 Primitive)。

所以对使用姿势的影响很直接:用 CustomShader 你可以放心让滑块每帧调用,用 Fabric 材质则要考虑节流 。本篇 demo 用的是 watch 监听全部十二个参数,拖一次滑块的每一小步都会触发一次更新。

fabric 是 Cesium 描述材质的 JSON,四个字段各司其职:

字段 作用
type 材质类型名,也是缓存 key
uniforms 传给着色器的 uniform 及其默认值
source GLSL 源码,必须实现 czm_getMaterial
components (可选)声明式组合,用来替代 source

本例只用了前三个。每份 source 的骨架都一样:

glsl 复制代码
czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);
  // ... 你的计算
  return material;
}

czm_getDefaultMaterial 返回一个填好默认值的 czm_material,你只改自己关心的字段。这个结构体有四个字段:

字段 含义
diffuse 漫反射颜色(会参与光照)
specular 高光强度
emission 自发光(直接加到结果上,不参与光照)
alpha 不透明度

czm_getDefaultMaterial 只填了六个字段,默认值都是可以从源码里逐行读出来的:

字段 默认值 含义
diffuse vec3(0.0) 漫反射颜色(会参与光照)
specular 0.0 高光强度
shininess 1.0 高光锐度
normal materialInput.normalEC 法线(默认直接沿用几何给的)
emission vec3(0.0) 自发光(直接加到结果上,不参与光照)
alpha 1.0 不透明度

第一行的 vec3(0.0) 值得停一下 :默认漫反射是纯黑。也就是说,一个材质如果只改了 alpha 没动 diffuse,画出来就是一根看不见的黑线------这正是"写了材质却什么都没出现"最常见的成因。反过来,想要"绝对亮"就写 emission,它默认也是零,但加多少亮多少,不经过光照。

这里也有 diffuse 和 emission 的分野,和第 95 篇讲的 czm_modelMaterial 是同一个设计------想绕开光照就写 emission,想参与光照就写 diffuse 。本篇十种材质里只有 PolylineFence 写的是 material.emission,其余九种都写 diffuse。这一条差别有很直观的后果:栅栏线转到建筑物背光面时依旧亮,而其它九种会跟着场景明暗一起暗下去。做"永远要看得见"的标注线时,这就是选型依据。

顺带说,specular、shininess、normal 这三个字段本篇一种材质都没碰过------对折线这种没有真实表面的几何,高光和法线基本没有意义,写不写都是那三个默认值。

至于 czm_materialInput,它提供的是几何给材质的信息:

字段 含义
st 纹理坐标,本例 st.s 沿线长、st.t 沿线宽
positionToEyeEC 片元到相机的向量(眼坐标)
normalEC 眼坐标法线
height / slope 高度与坡度(部分几何才有)

对折线来说最有用的就是 st:st.s ∈ [0,1] 是沿线长度方向,st.t ∈ [0,1] 是线宽方向(0 和 1 是两条边、0.5 是中心)。十种材质几乎都在拿这两个分量做文章------st.s 管"沿线方向的花纹",st.t 管"线宽方向的形状"。把这条记住,看任何线材质都能一眼猜出它在干什么。把这条记住,看任何线材质都能一眼猜出它在干什么。

值得补充的是 st 到底是谁算出来的。折线的纹理坐标不是材质自己产的,而是 PolylineGeometry 在建顶点时就写进 attribute 里的:st.s 按整条折线的累计长度归一化(起点 0、终点 1),st.t 则横跨线宽(0 和 1 是两侧边缘、0.5 是中线)。材质拿到的只是一个 [0,1] × [0,1] 的坐标,它并不知道这条线在地球上有多长 ------所以想按"每 100 米一段"做花纹,光靠 st 算不出来,得把线长传进去。这也是第四节的虚线最后退到屏幕空间(gl_FragCoord)的原因之一:屏幕空间至少和相机是绑定的,而 st 和真实长度之间还隔着一个未知比例。

二、十种材质,以及那个漏掉的参数

十种材质和它们各自的参数(L51-75):

材质 面板标签 专属参数 该材质默认值
PolylineTrail 材质线(分段尾迹) 流动速度 speed 1.5
PolylineFlow 流动线 速度 / 段长 / 渐变强度 2 / 0.03 / 0.15
PolylineFence 栅栏线 虚线长度 / 图案 / 外轮廓宽 16 / 255 / 8
PolylineMultiArrow 多箭头线 箭头重复 repeatFactor 4
PolylineDashArrow 虚线箭头线 虚线长度 / 图案 16 / 255
PolylineDirection 方向线 外轮廓宽 / 方向颜色 2 / 白
PolylineLighting 发光线 无 ---
PolylineFlicker 闪烁线 流动速度 3
PolylineImageTrail 图片轨迹线 速度 / 重复次数 3 / 8
PolylineLightingTrail 发光轨迹线 流动速度 5

面板用一组 showXxx() 函数控制每个参数的可见性(L83-92),切换材质时只显示相关参数------这个设计很好,避免了"拖了半天发现没效果"的困惑。这个设计很好,避免了"拖了半天发现没效果"的困惑。

完整链路是三段:buildOptions() 把十二个 ref 拼成材质要的参数对象(L108-133),createPolylineEntity() 拿它去 new entity(entities.ts L30-42),updateEffect() 负责增量更新。三者分工是"构造一次、后续打补丁"------这也是 Cesium entity 的标准用法:能打补丁就不要重建,重建一次 entity 意味着整条折线的 Primitive 都要重新提交。

这里有一个容易忽略的细节:buildOptions() 每次重建都会把当前所有参数原样带上,所以"切材质"这个动作实际是删旧 entity + 建新 entity ,而不是换个材质名。好处是状态一定干净(不会残留上一个材质的 uniform),代价是每次切换都有一次完整的几何重建。十种材质逐个试一遍,就是十次重建------本地 demo 无所谓,但如果材质列表是动态拉取的(比如几十种),就得考虑改成只改 polyline.material 的属性。

切换时还会把参数重置成该材质的默认值:

ts 复制代码
// LineMaterialDemo.vue L157-172
function onKindChange(): void {
  const defaults = KIND_DEFAULTS[materialKind.value]
  if (defaults) {
    if (defaults.speed !== undefined) speed.value = defaults.speed
    if (defaults.percent !== undefined) percent.value = defaults.percent
    if (defaults.gradient !== undefined) gradient.value = defaults.gradient
    if (defaults.repeatX !== undefined) repeatX.value = defaults.repeatX
    if (defaults.dashLength !== undefined) dashLength.value = defaults.dashLength
    if (defaults.dashPattern !== undefined) dashPattern.value = defaults.dashPattern
    if (defaults.outlineWidth !== undefined) outlineWidth.value = defaults.outlineWidth
  }
  // ... 重建 entity
}

仔细数一下:KIND_DEFAULTS 里一共出现过七种字段------speed / percent / gradient / repeatX / dashLength / dashPattern / outlineWidth------再加上 repeatFactor 和 image,一共九种。而上面这段重置逻辑处理了七种 ,漏了 repeatFactor 和 image。

image 漏了无所谓,它由 buildOptions() 在构造时单独注入(L119),每次重建都会带上。但 repeatFactor 是真的漏了 :KIND_DEFAULTS.PolylineMultiArrow 明明白白写着 { repeatFactor: 4 },可 onKindChange 从头到尾没读过它。

后果是:从别的材质切到"多箭头线"时,repeatFactor 不会被设成 4,而是保持上一个值 ------如果你之前没动过它,那就是构造器里的默认 2(materials.ts L547)。也就是说,面板显示的"箭头重复 2"和 KIND_DEFAULTS 里写的 4 永远对不上,除非你手动拖一下滑块。这种"配置写了但不生效"的问题最难受,因为代码看起来完全正确。

修法是一行:

ts 复制代码
// 补上漏掉的那一行
if (defaults.repeatFactor !== undefined) repeatFactor.value = defaults.repeatFactor

顺着 repeatFactor 往下挖一层,会看到一件更值得警惕的事:同一个参数在三处写了三个不同的默认值。

位置 代码 repeatFactor
材质注册表(materials.ts L447) uniforms: { color: Color.WHITE, repeatFactor: 1, antiClockWise: true } 1
属性类构造器(materials.ts L547) this._repeatFactor = options.repeatFactor ?? 2 2
面板默认值(LineMaterialDemo.vue L68) PolylineMultiArrow: { repeatFactor: 4 } 4

三个数都不一样,而 onKindChange 一个都不读。实际生效的是构造器那个 2------因为你不动滑块,repeatFactor.value 就停在初始值 ref(2)(L41),buildOptions() 原样传下去。

color 同样分裂:注册表里是 Color.WHITE(纯白),属性类构造器里是 new Color(0.2, 0.6, 1.0, 0.7)(浅蓝)。面板上默认显示的是后者。

这类"默认值漂移"在库 + demo 的分层结构里很常见:注册表要保证"单独用这个材质时也能跑",构造器要保证"不传参数时有合理值",面板要保证"用户第一眼看到的效果"。三层各自演进,就各自长出了一套。识别方法也简单:grep 一遍参数名,数出现几次------出现三次以上就该问"到底谁说了算"。

顺带一提,这里还有个 Vue 层面的小浪费:onKindChange 改了若干 ref,而这些 ref 都被 watch 监听着(L182-184),于是重置之后还会再触发一次 updateEffect()。功能上没错(第二次更新是幂等的),但每次切材质都会多跑一轮参数下发。

PolylineMaterialProperty 一共定义了 16 个字段 :kind / color / speed / percent / gradient / repeatX / repeatY / image / dashLength / dashPattern / maskLength / outlineWidth / outlineColor / directionColor / repeatFactor / antiClockWise。而面板只暴露了 12 个 ref,对不上号的这四个各有原因:

字段 默认值 无面板入口 影响
maskLength 20 改不了 第四节说的"Fence 是 20 段"在当前 UI 下是固定的,想和 DashArrow 的 16 对齐必须改源码
antiClockWise true 改不了 只作用于多箭头线(st.s = 1.0 - st.s 翻转箭头朝向),等于方向永远固定
repeatY 1 改不了 只作用于图片轨迹线的贴图纵向重复,横向 repeatX 能调、纵向不能
image --- 由 buildOptions() 按材质硬编码 换贴图得改代码(KIND_DEFAULTS 里写死了 /images/lighting.png 等三个路径)

maskLength 这一条直接改了第四节的结论------那里建议"把 Fence 的 maskLength 和 DashArrow 对齐",听起来是个调参动作,实际上它没有 UI 入口,是改代码的活。写建议时得说清楚是哪一种,否则读者会去找一个不存在的滑块。

十种材质按"在 st 上做了什么"可以分成三组。

第一组:只动 st.s (尾迹、流动、闪烁、图片轨迹、发光轨迹)。它们不碰 st.t,所以线宽方向的截面是均匀的------整条线一起变亮或变暗。

  • PolylineTrail:alpha = color.a * fract(st.s - time)。fract 是一个锯齿,沿线方向从 0 线性涨到 1 再突然归零,看起来就是"一节一节的尾迹"。
  • PolylineFlicker:scalar = smoothstep(0.0, 1.0, time),然后 diffuse 和 alpha 一起乘。整条线同步呼吸,没有位置差异。
  • PolylineImageTrail:先用 repeat 把 st 放大,再采样一张箭头贴图,st.s - time 让贴图沿线滚动。

第二组:在 st.t 上切形状(栅栏、方向、多箭头)。

  • PolylineFence:用 step 在 st.t 上切出中间一段当"内部",两侧当"轮廓"。
  • PolylineMultiArrow:先 mod(st.s, 1.0 / repeatFactor) 把线切成 repeatFactor 段,每段内再用两条直线(getPointOnLine)切出一个三角形。- PolylineMultiArrow:先 mod(st.s, 1.0 / repeatFactor) 把线切成 repeatFactor 段,每段内再用两条直线(getPointOnLine)切出一个三角形。

这个材质里藏着一行十种材质中最聪明的代码:

glsl 复制代码
float base = 1.0 - abs(fwidth(st.s)) * 10.0 * czm_pixelRatio;

fwidth(st.s) 是屏幕空间导数------"相邻两个像素之间 st.s 变了多少"。注意此时 st.s 已经被 mod 归一化到"每段 0,1",所以每段内 st.s 跨 1.0,对应的屏幕像素数是 L / N(L 是折线在屏幕上的总长,N 是 repeatFactor),于是 fwidth(st.s) = N / L,整行化简成:

base = 1 − 10 × N / L(DPR = 1 时)

base 是"箭杆占每段的屏幕比例",而那 10.0 的含义是:箭杆恒定占据 10 个像素宽。这是用屏幕导数做自适应的标准手法------不管线拉远拉近,箭杆都不会被压成一条细缝。代价是它有临界点:

N DPR 箭杆消失的临界线长 L=1000px L=200px L=50px L=20px L=10px
2(当前实际默认) 1 20 px 0.980 0.900 0.600 0.000 −1.000
4(KIND_DEFAULTS 想给的) 1 40 px 0.960 0.800 0.200 −1.000 −3.000
4 2 80 px 0.920 0.600 −0.600 −3.000 −7.000
8 1 80 px 0.920 0.600 −0.600 −3.000 −7.000
20(滑块上限) 1 200 px 0.800 0.000 −3.000 −9.000 −19.000

一旦 base <= 0,1.0 - step(base, st.s) 恒为 0,箭杆整个消失,只剩箭头尖 。而临界线长正比于 repeatFactor------箭头越多,越容易退化。滑块上限 20 时,只要折线在屏幕上短于 200 像素,多箭头线就变成一排小三角。

这也解释了一个观感上的困惑:demo 默认相机看的是中国东部全景(折线屏幕长度上千像素),箭头很正常;一旦你拉近去看某一段,反而"箭头没了"。不是渲染出错,是 base 算出了负数。

  • PolylineDirection:在 st.t 上切内部 / 轮廓(同 Fence),再在 st.s 上按 fragLength = 100 的屏幕周期切出方向箭头。- PolylineDirection:在 st.t 上切内部 / 轮廓(同 Fence),再在 st.s 上按 fragLength = 100 的屏幕周期切出方向箭头。

fragLength 这几个常量值得摊开看,因为它们直接决定了"箭头多长、多久出现一个":

glsl 复制代码
const float fragLength = 100.0;    // 屏幕周期:每 100 像素一个箭头
const float startPosition = 0.45;  // 箭头起点(周期内的比例)
const float endPosition = 0.55;    // 箭头终点

换算下来就是:每 100 个屏幕像素出现一个箭头,箭头本身占 10 个像素(100 × (0.55 − 0.45)) 。和 PolylineMultiArrow 的 base 一样,这是屏幕空间的量,和折线的实际长度、地理跨度都没有关系------拉远拉近,箭头的屏幕尺寸不变,变的只是"每公里出现几个"。

这里还藏着一个和 MultiArrow 相反的设计选择:Direction 用常量 100 和 0.45/0.55,而 MultiArrow 用 fwidth 自适应。常量的好处是可预测(开发者知道每个屏幕周期一定有 10 像素箭头),坏处是缩放极端时会退化(线很短、不到 100 像素时,可能一个箭头都放不下);fwidth 的好处是永不消失,坏处是箭杆长度会随缩放变化,且 base 能算出负数(第二节那个表格)。两种取向没有对错,但要知道自己选的是哪一种。

第三组:只管亮度 (发光线)。PolylineLighting 采样一张发光贴图,alpha = colorImage.a * color.a * 3.0、diffuse = max(fragColor + colorImage.rgb, fragColor)。那个 3.0 和 max 都是为了把亮度顶上去做出"发光"感,代价是极易过曝。第三组:只管亮度(发光线)。PolylineLighting 采样一张发光贴图,alpha = colorImage.a * color.a * 3.0、diffuse = max(fragColor + colorImage.rgb, fragColor)。那个 3.0 和 max 都是为了把亮度顶上去做出"发光"感,代价是极易过曝。

分组时还有一条没进表格的线索:谁做了抗锯齿 。用 grep 数一下 czm_antialias 和 czm_gammaCorrect 的出现位置,规律非常干净------

材质 czm_antialias czm_gammaCorrect 分组
PolylineFence 有 有 线宽形状
PolylineMultiArrow 有 有 线宽形状
PolylineDashArrow 有 有 线宽形状
PolylineDirection 有 有 线宽形状
PolylineLighting 无 有 亮度增强
PolylineTrail / Flow / Flicker / ImageTrail / LightingTrail 无 无 沿线花纹

四种切形状的都抗锯齿了,五种只改 alpha 的都没有 ------这不是疏漏,是合理的工程判断:step 切出来的硬边在低分辨率或斜线上会明显锯齿,而 alpha 渐变的边界柔和不敏感。作者显然清楚自己写的是什么。

顺带解释了另一种现象:五种沿线花纹的线在斜向时会有轻微毛边,而四种切形状的线不会。如果做对比测试时觉得"尾迹线的边缘比栅栏线糙",那是设计差异,不是渲染出错。

另一处值得留意的代价是 discard:它在四种材质里出现了四次(图片轨迹的白/黑贴图判定、发光轨迹、虚线箭头的间隔色)。discard 会打断 GPU 的 Early-Z 优化------被丢弃的片元已经跑完了片元着色器,深度缓冲的提前剔除就失效了。少量使用无所谓,但如果把整条线的绝大部分像素都 discard 掉(比如虚线占空比极低时),性能会比不 discard 的等价写法差一截。

这么一分,十种材质其实只有三个骨架:沿线花纹、线宽形状、亮度增强 。要写第十一种,先想清楚属于哪一类,再挑一个相近的改。这么一分,十种材质其实只有三个骨架:沿线花纹、线宽形状、亮度增强。要写第十一种,先想清楚属于哪一类,再挑一个相近的改。

第三组那两个 3.0 的代价可以精确算出来。alpha 的上限是 1,所以 colorImage.a * color.a * 3.0 只要超过 1/3 ≈ 0.333 就会被 clamp------也就是说透明度滑条的上三分之二对发光线完全无效:

透明度 colorImage.a × color.a(假设贴图 alpha=1) ×3 实际 alpha
0.1 0.100 0.300 0.300
0.3 0.300 0.900 0.900
0.34 0.340 1.020 1.000(分界)
0.7(默认) 0.700 2.100 1.000
1.0 1.000 3.000 1.000

从 0.34 开始,拖滑块不再有任何变化。这种"为效果牺牲可控性"的取舍本身没错,但至少该让面板反映出真实生效区间 ------把滑块上限改成 0.33,或者把那句 * 3.0 改成一个独立 uniform,都比现在这样诚实。

参数改动的下发路径也值得看清楚,它和 CustomShader 的 setUniform 完全不同:

ts 复制代码
// entities.ts L44-84(节选)
export function updatePolylineEntity(viewer, id, patch): void {
  const entity = viewer.entities.getById(`${POLYLINE_ENTITY_PREFIX}${id}`)
  if (!entity) return
  const polyline = entity.polyline
  if (patch.width !== undefined) polyline.width = new ConstantProperty(patch.width)
  if (patch.material) {
    const material = polyline.material
    if (material instanceof PolylineMaterialProperty) {
      if (patch.material.color) material.color = patch.material.color   // → 触发 definitionChanged
      if (patch.material.speed !== undefined) material.speed = patch.material.speed
      // ... 其余字段同构
    }
  }
}

每一步赋值都走 setter、触发 definitionChanged,而 Cesium 的几何更新器监听这个事件来决定要不要重建。所以改一个颜色和改十个参数是同样的代价------都是一次事件、一次更新。

这也是本篇和 #95~#97 的又一个对照:那三篇的 applyUniforms 在一次调用里改八到十个 uniform,全是零重建;本篇改一个颜色就抛一次事件。两套系统的更新成本差了一整个数量级------做高频参数联动时,这个差别会被放大成肉眼可见的卡顿。

三、流动的快慢,还是帧率说了算

十种材质里有五种是"动"的,它们的动画时钟写法完全一致:

glsl 复制代码
// PolylineTrail(L14-26),其余几种只是后面几行不同
uniform vec4 color;
uniform float speed;

czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);
  vec2 st = materialInput.st;
  float time = fract(czm_frameNumber * speed / 1000.0);   // ★ 分母写死 1000
  material.diffuse = color.rgb;
  material.alpha = color.a * fract(st.s - time);
  return material;
}

看到 czm_frameNumber 就该警觉了------这正是第 97 篇刚量过的坑。那里写的是 u_scanPeriod * 60.0(把秒换算成帧,假设 60 fps),这里写的是 speed / 1000.0(把帧换算成进度,同样隐含 60 fps)。形式不同,病根一样。形式不同,病根一样。

先把 czm_frameNumber 到底是什么说准:它是 AutomaticUniform,取值走 uniformState.frameState.frameNumber------Cesium 自己维护的场景帧计数 ,不是浏览器的时间。这意味着两件事:一是它随场景暂停/恢复而停走(viewer.clock 拖到别的时间点,动画照样在跑),二是掉帧时它跳得慢、高刷屏上它跳得快,总之"帧"和"秒"之间没有稳定比例。所有拿它当时钟的写法,都隐含了一个"稳定 60 帧"的假设,而这个假设在今天 144 Hz 屏遍地都是的环境下基本不成立。

判断一段着色器有没有这个毛病,有个很简单的特征:出现 czm_frameNumber 就当作帧率依赖处理。本系列前几篇已经踩到过三次(第 68 篇罗盘、第 69 篇气泡弹窗、第 97 篇扫描带),加上本篇是第四次------同一个坑换个载体就能再踩一次,说明它值得变成一条肌肉记忆。

周期的解析式很简单:time 每帧推进 speed / 1000,走完一个 fract 周期需要 1000 / speed 帧,再除以真实帧率就是秒数:

周期 = 1000 / (speed × fps) 秒

speed 30 fps 60 fps 75 fps 120 fps 144 fps
0.5 66.67 s 33.33 s 26.67 s 16.67 s 13.89 s
1.5(默认) 22.22 s 11.11 s 8.89 s 5.56 s 4.63 s
3 11.11 s 5.56 s 4.44 s 2.78 s 2.31 s
5 6.67 s 3.33 s 2.67 s 1.67 s 1.39 s
10 3.33 s 1.67 s 1.33 s 0.83 s 0.69 s

以默认 speed = 1.5 为例,相对 60 fps 的偏差是:30 fps +100% (慢一倍)、75 fps −20%、120 fps −50%、144 fps −58.3%(快 2.4 倍)。

和 #97 的数字一模一样------因为它们是同一个误差的两种写法。有意思的是,#97 那个写死 60.0 至少还把"60"明明白白写在源码里,本篇这个 1000.0 藏得更深:你得先意识到 1000 / 60 ≈ 16.67,才能看出它其实假定了 60 fps。

修法也一样:别用帧号,用真实时间。Fabric 材质里可以直接用 Cesium 提供的 czm_time?不行------那是场景时钟,会跟着 Cesium 的倍速和暂停走。想要"真实秒数",得自己在 JS 侧累加:

ts 复制代码
// 改法:JS 侧累计真实时间,通过 uniform 传进去
let elapsed = 0
let last = 0
viewer.scene.preUpdate.addEventListener(() => {
  const now = performance.now() / 1000
  if (last > 0) elapsed += now - last
  last = now
})
// 材质里把 speed 保留为"每秒进度",新增一个 u_time uniform
shaderTime = elapsed

着色器对应改成 float time = fract(u_time * speed / 16.67);------或者干脆把 speed 重新定义成"每秒推进多少个周期",写成 fract(u_time * speed),语义最干净。

五种会动的材质里,PolylineFlow 的几何最值得单独看一眼:

glsl 复制代码
float t = fract(czm_frameNumber * speed / 1000.0);
t *= (1.0 + percent);
float alpha = smoothstep(t - percent, t, st.s) * step(-t, -st.s);
alpha += gradient;

step(-t, -st.s) 等价于"当 st.s <= t 时为 1",所以光带只在 st.s 小于当前进度的那一段出现------是一个"从线头往线尾推进的亮段",段长由 percent 控制(默认 0.03,即全长的 3%)。

最后那个 alpha += gradient 值得留意:它是加分项 ,直接叠在 alpha 上。结果就是整条线永远有一层 gradient 的底噪:

gradient 光带峰值 alpha 底噪 alpha 实际对比度
0 1.000 0.000 1.000
0.15(默认) 1.000(被 clamp) 0.150 0.850
0.30 1.000 0.300 0.700
0.50(滑块上限) 1.000 0.500 0.500

峰值超过 1 的部分会被丢弃,而底噪是实打实抬上去的。所以 gradient 滑得越大,光带反而越不明显:滑块拉满时对比度只剩一半。它真正的用途是"让线在光带经过之外也保持可见",而不是"让光带更亮"。

再补一句 fract 的特性:它不只是"周期",还是突变 。time 从 0.999 跳回 0 的那一帧,光带位置会瞬间从线尾跳回线头。对"尾迹"和"流动"这种单向推进的效果,这个突变正好是想要的(就是"重新扫一遍");但对 PolylineFlicker 这种整体呼吸的效果,smoothstep(0.0, 1.0, time) 在 time 归零时也会有同样的跳变------亮度从最亮直接掉到最暗,看到的就是"闪一下"而不是"呼吸"。

想要平滑呼吸,得把 fract 换成三角波(和第 97 篇给扫描带的改法一样):

glsl 复制代码
// 想要平滑呼吸,而不是"闪一下"
float t = abs(1.0 - fract(czm_frameNumber * speed / 1000.0) * 2.0);
float scalar = smoothstep(0.0, 1.0, t);

五种动画材质在 60 fps 下的默认周期:尾迹 11.11 秒(speed 1.5)、流动 8.33 秒(speed 2)、闪烁 5.56 秒(speed 3)、图片轨迹 3.33 秒(speed 3)、发光轨迹 3.33 秒(speed 5)。跨度从三秒到十一秒,都是慢悠悠的节奏------这也侧面说明作者调参时确实是在 60 fps 上盯着看的。

四、虚线图案只填了一半

这一节是本篇最反直觉的部分,出在"栅栏线"和"虚线箭头线"共用的那段虚线逻辑上:

glsl 复制代码
// PolylineDashArrow(L231-260 节选);Fence 同构,maskLength 不同
const float maskLength = 16.0;

czm_material czm_getMaterial(czm_materialInput materialInput) {
  // ...
  vec2 pos = rotate(v_polylineAngle) * gl_FragCoord.xy;
  float dashPosition = fract(pos.x / (dashLength * czm_pixelRatio));
  float maskIndex = floor(dashPosition * maskLength);
  float maskTest = floor(dashPattern / pow(2.0, maskIndex));
  vec4 fragColor = (mod(maskTest, 2.0) < 1.0) ? gapColor : color;
  // ...
}

虚线图案 dashPattern 是一个位掩码 :把它除以 2^maskIndex 再取个位(mod(maskTest, 2)),是 1 就画实线色、是 0 就画间隔色。maskIndex 从 0 递增到 maskLength - 1,每一位决定虚线周期里对应那一小段是实还是空。

关键就在 maskLength:它决定这个图案一共分多少段。而 demo 的 dashPattern 滑块范围是 1 到 255 ,也就是最多只能表达 8 位(2⁸ = 256)。

于是:

maskLength 滑块 1~255 能填的位 最大占空比
PolylineDashArrow 16(源码写死) 低 8 位 50.0%
PolylineFence 20(默认) 低 8 位 40.0%

高 8 位永远填不上去,恒为 0,也就是虚线的后半段永远是空的。 你就算把滑块拖到最大(255 = 二进制 11111111),得到也只是"前一半实、后一半空"。

实测几个典型值(以 PolylineDashArrow 的 16 段为例,1 为实):

dashPattern 二进制 16 段的位序列 占空比
1 00000001 1000000000000000 6.3%
15 00001111 1111000000000000 25.0%
85 01010101 1010101000000000 25.0%
170 10101010 0101010100000000 25.0%
255 11111111 1111111100000000 50.0%

想要"实多于虚"的虚线------比如大部分实线、偶尔断一下------在这套 UI 下根本做不到 ,因为那需要第 9 位以上。想要 100% 实线需要 dashPattern = 65535(16 位全 1),而滑块到不了。

顺带一个"同名不同义"的坑:同一个 dashPattern = 255,在两种材质下画出来的虚线不一样 。因为它们的 maskLength 不同:

材质 maskLength 实段数 空段数 占空比
PolylineDashArrow 16 8 8 50.0%
PolylineFence 20 8 12 40.0%

同一个数字,一个画出"实空各半",另一个画出"实四空六"。如果你在两个材质之间切换时觉得虚线变了密,不是错觉。同一个数字,一个画出"实空各半",另一个画出"实四空六"。如果你在两个材质之间切换时觉得虚线变了密,不是错觉。

往深一层看,"滑块范围 1~255 对 16 位掩码"这类控件量程和能力位宽不匹配 的问题,比它看起来更常见。它的特点是:滑块合法、值在范围内、着色器也正常执行,唯独有一部分能力永远摸不到。识别方法就一句话------把滑块的上下限和二进制位宽对一下:255 是 8 位、1023 是 10 位、65535 是 16 位,位数不够时高 8 位恒为零,等价于那半段被永久关掉。

对照一下官方自带的 PolylineDashMaterialProperty,它的 dashPattern 文档同样按"16 位掩码"描述,而默认构造下这个属性是 undefined(着色器内部再取默认值)。也就是说官方把这 16 位完整交给了用户,本库的滑块反而砍掉了一半。要做扩展,最省事的参照物就是官方那个属性类。

最后说一句这段虚线的坐标系 :它用的是 gl_FragCoord.xy(屏幕像素)经过旋转,不是 st.s(沿线长度)。所以虚线的物理长度随相机缩放变化 ------拉远时每一段虚线覆盖的地面距离变长,拉近时变短。这是"屏幕空间虚线"的通病,好处是任何缩放下虚线看起来都一样长(视觉稳定),坏处是它不对应任何实际距离。想做"每 100 米一个虚线",得改用 st.s 和线的实际长度去算。

czm_pixelRatio 这一项值得单说。它让 dashLength 的物理像素长度 在不同 DPI 屏幕上保持一致:DPR = 2 时 dashLength * czm_pixelRatio 翻倍,虚线段的设备像素数翻倍、CSS 像素数不变。这是正确写法------很多自己写屏幕空间特效的人会漏掉它,结果在高分屏上虚线密一倍。这是正确写法------很多自己写屏幕空间特效的人会漏掉它,结果在高分屏上虚线密一倍。

czm_pixelRatio 同样是 AutomaticUniform,取值走 uniformState.pixelRatio,也就是 viewer.scene.pixelRatio------它默认跟随设备的 devicePixelRatio(可以在 Viewer 构造时用 useBrowserRecommendedResolution: false 压成 1)。所以"高分屏上虚线密一倍"这个现象严格来说是:DPR 2 的屏幕上,同样 16 CSS 像素的虚线用了 32 个设备像素来画,密度不变、但抗锯齿后的观感更细 ;真正会变密的是那些忘了除 czm_pixelRatio 的写法------它们的 dashLength 直接被当成 CSS 像素用,在高 DPR 下每个虚线段的设备像素数翻倍,视觉上就密了一倍。

rotate(v_polylineAngle) 则是把屏幕坐标旋转到与线平行 。v_polylineAngle 是 Cesium 提供的 varying,表示该片元处线的屏幕走向;旋转之后 pos.x 就是"沿线方向"的屏幕距离,用它做虚线就不用管线是斜的还是直的。这两行是屏幕空间虚线的标准做法,可以直接抄走。

代价前面说过:虚线长度随缩放变化。这也意味着在默认视角下虚线可能密到看不清 ------demo 把相机放在 fromDegrees(115.73, 36.2, 2600000),看的是中国东部全景,那条折线在屏幕上只有几百像素长,dashLength = 16 像素的虚线会非常密集。想看清虚线的疏密,得先拉近。

五、默认值撞车:栅栏线的内部宽度为 0

"栅栏线"的定位是"中间一条实线、两侧留一圈外轮廓",代码里这么算:

glsl 复制代码
// PolylineFence(L126-168 节选)
in float v_polylineAngle;
in float v_width;

czm_material czm_getMaterial(czm_materialInput materialInput) {
  vec2 st = materialInput.st;
  float halfInteriorWidth = 0.5 * (v_width - outlineWidth) / v_width;
  float b = step(0.5 - halfInteriorWidth, st.t);
  b *= 1.0 - step(0.5 + halfInteriorWidth, st.t);
  // ...
  vec4 currentColor = mix(outlineColor, color, b);
  // ...
}

halfInteriorWidth 是"内部(非轮廓)区域占线宽的一半",b 是"当前片元在不在内部"的 0/1 判定,然后用 mix 在轮廓色和内部色之间选。

现在看数字。KIND_DEFAULTS.PolylineFence 把 outlineWidth 设成 8 ,而线宽 lineWidth 的默认也是 8:

复制代码
halfInteriorWidth = 0.5 * (8 - 8) / 8 = 0

内部宽度为零。 于是 b 恒为 0,整条线全部取 outlineColor(默认纯白)------你看到的不是"带轮廓的栅栏",而是一条纯白实线 ,栅栏效果完全消失。内部宽度为零。 于是 b 恒为 0,整条线全部取 outlineColor(默认纯白)------你看到的不是"带轮廓的栅栏",而是一条纯白实线,栅栏效果完全消失。

注意这里取的是 outlineColor 而不是 color:撞车时整条线会变成轮廓色。而 outlineColor 的默认值恰好是纯白,在深色地球背景下会亮得刺眼------两个默认值撞在一起,还顺手把画面也毁了。如果只想快速验证,把外轮廓宽拖到 0 就能回到"内部实线"状态,比重建 entity 快得多。

mix(outlineColor, color, b) 这个写法本身是标准做法,问题全在默认值上。参数面板最怕的就是这种:每个滑块单独看都合理,组合起来是废的 。可用的防御手段有两个------一是在切材质时校验参数组合(发现 outlineWidth >= 线宽 就给个提示),二是让默认值之间留足余量。

把参数组合列一下就更清楚了:

线宽 外轮廓宽 内部半宽 内部占比 轮廓占比
8 0 0.500 100% 0%
8 2 0.375 75% 25%
8(默认) 8(默认) 0.000 0% 100%
8 16 0.000 0% 100%
40 8 0.400 80% 20%
40 30 0.125 25% 75%
16 8 0.250 50% 50%

规律是:只要 outlineWidth >= 线宽,内部就没了,整条线只剩轮廓色。而 demo 里两个滑块的范围是线宽 1~40、外轮廓宽 0~30,撞车很容易发生------线宽 4 + 轮廓 10 就已经是这样了。

这类"默认值撞车"在参数面板里特别隐蔽:两个滑块各自都在合法范围里,画面却完全不是预期。想让栅栏线一上来就有栅栏的样子,把 KIND_DEFAULTS.PolylineFence 的 outlineWidth 从 8 降到 2 就行(线宽 8 时内部占 75%)。

还有一处永远走不到的分支

PolylineImageTrail 和 PolylineLightingTrail 里都有这样一个判断:

glsl 复制代码
// PolylineImageTrail(L61-84 节选)
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);
}

作者的意思是"透明度为 0 时直接用图片原色"。但 demo 的透明度滑块范围是 0.1 到 1 (template L246),color.a 永远不为 0------这个分支永远走不到。

这不算 bug(else 分支的效果是合理的),但它意味着"用图片原色"这个能力在面板上根本摸不到。想让它生效,得把透明度滑块的下限改成 0,或者换个表达方式(比如用一个布尔 uniform)。

PolylineLighting 那个 * 3.0 也值得一提:

glsl 复制代码
material.alpha = colorImage.a * color.a * 3.0;
material.diffuse = max(fragColor.rgb + colorImage.rgb, fragColor.rgb);

alpha 乘 3 之后,只要 colorImage.a * color.a > 0.333 就会被 clamp 到 1。而 demo 的透明度默认 0.7、滑块范围 0.1~1 ------ 也就是说透明度滑块的上半段(0.34 以上)对发光线基本无效,怎么拖都是不透明。这是"为发光效果牺牲可控性"的典型取舍,和前几篇量过的过曝是同一类现象。

PolylineImageTrail 的 else 分支里同样有 max(color.rgb * material.alpha * 3.0, color.rgb)。语义是"用 alpha 调制亮度,但不低于线条自身颜色",所以图片轨迹线在 alpha 很低时依然看得见轮廓------设计上说得通,但同样会把高 alpha 区推到过曝。

六、搬走前要改的七处

在列改动清单之前,先给一个选择题:你的效果该用哪套系统? 三条判据,按顺序问------

  1. 目标是 3D Tiles / Model 的内部渲染 (改瓦片自己长什么样、接 PBR、接阴影)?→ CustomShader,只有它能进模型片元管线。
  2. 目标是一条独立的几何(线、面、墙体)?→ Fabric 材质,通用且不需要碰模型。
  3. 需要同时改模型内部和外面包一层?→ 两套拼起来(本篇开头说过,"更麻烦的一类"就是这个), uniforms 各传各的。

本篇属于第 2 条,所以走 Fabric。这个判据表比"记十种材质"更值得带走。

1. 给动画换时钟。 五种会动的材质都用 czm_frameNumber * speed / 1000.0,周期随帧率漂移(第三节的表格)。这是本篇唯一一条"不改就是 bug",改法也是那句老话:自己累计真实时间。

2. 补上 repeatFactor 的重置。 onKindChange 漏读了 KIND_DEFAULTS 里的 repeatFactor,导致切到多箭头线时箭头密度不是默认值。加一行的事。

3. 把虚线图案放开到 16 位。 滑块上限从 255 提到 65535,dashPattern 这个 16 位掩码才算完整可用。同时建议把 Fence 的 maskLength 和 DashArrow 对齐(16 或 20 选一个),否则同一个数字在两种材质下含义不同。

4. 调开默认值的撞车。 KIND_DEFAULTS.PolylineFence 的 outlineWidth 从 8 降到 2;或者反过来把默认线宽提到 20。总之别让 outlineWidth >= 线宽。

5. 私有 API 要留后路。 Material._materialCache 是私有字段。若不想冒险,改用公开的 Material.fromType(type, uniforms) 注册------它内部走的同样是 fabric,只是不用直接摸缓存。无论哪种,升级 Cesium 时这里都该第一时间回归测试。

6. 半透明排序要有心理准备。 十种材质全部标了 translucent: () => true,意味着它们都进 translucent pass。多条线交叉、或线与其它半透明物体重叠时,前后关系由 Cesium 的排序决定,不保证正确。做"多条线组成一张网"这类效果时要留意;实在不行,就把不参与动画的线换成不透明材质。6. 半透明排序要有心理准备。 十种材质全部标了 translucent: () => true,意味着它们都进 translucent pass。多条线交叉、或线与其它半透明物体重叠时,前后关系由 Cesium 的排序决定,不保证正确。做"多条线组成一张网"这类效果时要留意;实在不行,就把不参与动画的线换成不透明材质。

7. 多箭头线补一个下限保护。 base 可能算出负数(第二节的表格),拉近时箭杆会整个消失。稳妥做法是加一行 base = max(base, 0.05),保底留 5% 的箭杆;或者把 10.0 改成可调 uniform,让使用者自己权衡"箭杆像素数"和"箭头密度"。

8. 把三套默认值合成一套。 repeatFactor 在注册表、构造器、KIND_DEFAULTS 里分别是 1 / 2 / 4,color 也有纯白和浅蓝两套。挑一个作为唯一事实源(建议是 KIND_DEFAULTS,因为它离用户最近),另外两处改成引用它。

这八条里有六条是"默认值/参数范围"问题,只有两条是真·渲染 bug。做 demo 的人容易高估后者的比例------绝大多数"效果不对"最后都查到一个滑块上限或一个漏读的默认值上,而不是着色器写错了。

最后回扣一下本篇开头的那个对比:Fabric 材质和 CustomShader 是两套并行的系统,不是新旧关系 。CustomShader 强在"接管 3D Tiles 的片元管线、能拿到 positionWC / normalEC 这些几何信息";Fabric 强在"通用、能贴到任何几何上、有现成的 st 和 czm_material"。做三维特效(流动线、围栏、箭头、扫描)用 Fabric 更顺手;做模型级的定制化渲染用 CustomShader。两条路都记住,碰到需求时才知道该往哪边走。两条路都记住,碰到需求时才知道该往哪边走。

如果只记一句话,我希望是这句:Cesium 的"材质"是个多义词 。MaterialProperty(Fabric)、CustomShader(模型片元)、ImageBasedLighting(IBL)是三套不同的东西,命名上却都带着"材质"两个字,文档还都在讲 GLSL。第一次读的时候很容易以为它们是同一套系统的不同入口,然后花半天时间找"为什么我的 czm_getMaterial 拿不到 positionWC"------答案很简单:它不在模型片元里,那里归 CustomShader 管。分清这三者的边界,比学会其中任何一个都更省时间。分清这三者的边界,比学会其中任何一个都更省时间。

最后留一个可复用的小习惯。本篇几乎所有结论都不是"读文档读出来的",而是同一套动作:把 GLSL 逐行直译成 JS → 枚举参数跑数 → 找到突变点 。帧率那张表是这么来的,虚线占空比那张表是这么来的,base 的临界线长也是。图形学的经验参数("调到 40 好像差不多")一旦被翻译成 base = 1 − 10N/L 这种式子,就从"手感"变成了"可预测"------这也是这个系列量了这么多反直觉数字的同一个方法。

这条线往后的站还很多:电弧球体、发光轨迹线、以及更麻烦的一类------同时要几何又要纹理坐标的效果,那往往要把两套系统拼起来用。我们一步一步来。

下一篇把镜头从折线挪到整片天空:看一段从 Shadertoy 搬过来的闪电着色器,怎么用 PostProcessStage 把整个画面打成一片雷雨------顺带量一下"频率"那个滑块到底在改几件事。


🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)

仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX

代码在 src/cases/line-material/ 目录下,材质库在 src/cases/polyline-effects-lib/(materials.ts 776 行是十种 czm_getMaterial 的源码,entities.ts 是装配与更新)。搬去自己项目时,盯住五处:五种动画材质的 czm_frameNumber * speed / 1000.0 要换成真实时间;onKindChange 补上 repeatFactor 的重置;虚线图案滑块从 255 放开到 65535;PolylineFence 的 outlineWidth 别和线宽撞车;Material._materialCache 是私有字段,升级 Cesium 时优先回归这里。


第98篇 一线十质各不同,虚线图案只填半;

帧号当时钟又见,轮廓撞车内部空;

两套系统分并行,几何纹理各西东。

相关推荐
10share18 分钟前
基于大模型的 i18n 方案:把中文原文当 Key,轻松搞定多语言维护
前端·vue.js
田甲22 分钟前
做一款 Type-C 母座读卡器:GL3224 双卡方案实测
c语言·开发语言
严肃小白兔39 分钟前
MATLAB 绘图与科研论文插图制作:图形设计、统计表达与矢量导出
开发语言·matlab·科研绘图
OpsEye40 分钟前
混合云 AI 架构,如何统一做敏感内容拦截?
javascript·ai编程
Funlang2 小时前
给机器写的 JavaScript 造个笼子
开发语言·javascript·ecmascript
普通人62 小时前
PHP文件读写完全指南
开发语言·php·1024程序员节
古茗前端团队3 小时前
由 key 引发的思考
前端·javascript·react.js
kyriewen3 小时前
我扫了 422 个仓库,近一半 CI 在裸装依赖
前端·javascript·程序员
鹏多多3 小时前
AI 到底能不能独立做个产品?我拿一个小程序验证了
前端·微信小程序·ai编程