第89篇:三维特效-螺旋圆效果——让螺旋臂卷起一圈圈外扩的涟漪

上回书说到 #88 雷达线,我们在结尾留了这么一句预告:

下一篇按序号是 #89 螺旋圆效果(同属「三维特效」线,主角从「一条直的扫描线」换成「一条会卷起来的螺旋臂」)。

今天这根「会卷起来的螺旋臂」,和 #88 那条直扫描线其实是师出同门------还是同一个 createDcEffectEntity、同一个地理椭圆、同一个 DcCircleMaterialProperty。但等你打开它的着色器 CIRCLE_SPIRAL_SOURCE,会发现它玩的比 #88 要狠一截。

#88 是「一条线加一道环」,两种东西并排摆着;#89 是「两件事相乘」。一句话剧透:它把一个双向螺旋臂和一个往外缓缓漂的同心涟漪环乘在一起,于是螺旋臂上每隔一段就串起一圈锐利的亮环。这篇就把这两个东西分别拆开,再讲它们怎么乘、怎么被同一个旋钮同时驱动。

为什么这条线走到 #89 才终于上「二维」,值得先说一句。前面六篇,从 #83 的「两层材质」到 #88 的「一条线加一道环」,你仔细看,玩的其实全是「一维」的花样:要么只动半径(#84 的呼吸环、#86 的波前),要么只动角度(#88 的扫描线),颜色送进时间函数(#85)本质上也只是把「亮度」这个一维量随时间调。#89 是第一个同时动「角度」和「半径」两个自由度的------螺旋臂管角度、涟漪环管半径,两者相乘。所以它看起来比前面任何一篇都「满」、都「花」,但骨子里只是把前面练熟的两个一维技巧叠到了一起。理解这一层,你就不会觉得 #89 难,只会觉得它「终于把两个老把戏合体了」。

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


一、先把「螺旋圆」拆成两件事

CircleSpiralDemo.vue 全文 175 行,和 #88 的 RadarLineDemo.vue 几乎是一个模子刻出来的------连控制面板那四个参数(颜色、速度、半径、高度)都一字不差。它同样只做一件事:

js 复制代码
createDcEffectEntity(viewer, {
  type: CIRCLE_SPIRAL_TYPE,         // 'CircleSpiralMaterial'
  lon, lat,
  height: height.value,             // 0
  radius: radius.value,             // 1500
  color: Color.fromCssColorString(colorHex.value),   // #ff4d4f
  speed: speed.value                // 3
})

装配和 #85/#86/#87/#88 走的是同一条流水线:建一个 semiMajorAxis === semiMinorAxis 的正圆椭圆,挂上 DcCircleMaterialProperty(isConstant 返回 false),由它每帧把 uniforms 喂进去。整个动画全在 CIRCLE_SPIRAL_SOURCE 那短短二十一行(非空)里。先把它原样摆出来:

默认摆位是圆心落在青岛 (120.38, 36.08)、半径 1500 米、相机 8000 米俯视。1500 米配 st *= 1.6 换算下来约 937.5 米每 st 单位,臂的疏密正落在肉眼舒适的区间------半径缩到 300 米螺旋会挤成一团,拉到 5000 米又只剩一小块,这些是「好看」的甜点区,不是硬性要求。

ts 复制代码
// src/cases/dc-effects-lib/entities.ts
export const DC_EFFECT_ENTITY_PREFIX = 'dc-effect-'

export function createDcEffectEntity(viewer: Viewer, options: DcEffectEntityOptions): Entity {
  const id = `${DC_EFFECT_ENTITY_PREFIX}${options.type}`   // 固定 id:一种类型只允许一个实体
  const property = new DcCircleMaterialProperty(options.type, {
    color: options.color,
    speed: options.speed ?? 3
  })
  return viewer.entities.add({
    id,
    position: new ConstantPositionProperty(
      Cartesian3.fromDegrees(options.lon, options.lat, options.height ?? 0)
    ),
    ellipse: {
      semiMajorAxis: new ConstantProperty(options.radius),  // 两轴同值 = 正圆
      semiMinorAxis: new ConstantProperty(options.radius),
      height: new ConstantProperty(options.height ?? 0),
      material: property                                    // 动画全挂在这个材质属性上
    }
  })
}
glsl 复制代码
uniform vec4 color;
uniform float speed;

#define PI 3.14159265359

vec2 rotate2D(vec2 st, float angle) {
  st = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)) * st;
  return st;
}

czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);
  vec2 st = materialInput.st * 2.0 - 1.0;
  st *= 1.6;
  float time = czm_frameNumber * speed / 1000.0;
  float r = length(st);
  float w = 0.3;
  st = rotate2D(st, (r * PI * 6.0 - time * 2.0));
  float a = smoothstep(-w, 0.2, st.x) * smoothstep(w, 0.2, st.x);
  float b = abs(1.0 / (sin(pow(r, 2.0) * 2.0 - time * 1.3) * 6.0)) * 0.4;
  material.alpha = a * b;
  material.diffuse = color.rgb * a * b * 3.0;
  return material;
}

注意最后两行:终值是 a * b 和 color.rgb * a * b * 3。这个乘法,就是整篇的命门。

a 和 b 是两个完全独立的图案,一个管「角度」,一个管「半径」:

  • a 是一道螺旋臂 ------它只对角度敏感(旋转之后看 st.x 落在哪),在圆周方向上画出两条对称的螺旋。
  • b 是一圈圈往外漂的同心亮环 ------它只对半径 r 敏感,和角度毫无关系。

两者相乘,得到的最终亮度就是「螺旋臂上,每被一圈亮环扫过的地方才发亮」。

同一条源码,逐行注上「这句在干嘛」就长这样------真正造图案的只有 rotate2D 那行和 b 那行,其余全是把这两个图案拼起来的胶水:

glsl 复制代码
// ===== CIRCLE_SPIRAL_SOURCE 逐行拆解 =====
uniform vec4  color;                  // 颜色只进 diffuse,不参与 alpha
uniform float speed;                  // 唯一的"总倍率"旋钮

#define PI 3.14159265359

vec2 rotate2D(vec2 st, float angle) {          // 标准 2x2 旋转矩阵,行列式为 1(保距)
  st = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)) * st;
  return st;
}

czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);
  vec2  st   = materialInput.st * 2.0 - 1.0;   // [0,1] -> [-1,1],把圆心挪到原点
  st *= 1.6;                                   // 采样放大:椭圆边界落到 r = 1.6
  float time = czm_frameNumber * speed / 1000.0;// 节拍器:1000 帧 = 1 个时间单位
  float r    = length(st);                     // 到圆心的距离(只管半径,不含角度)
  float w    = 0.3;                            // 亮带半宽,决定螺旋臂的胖瘦
  st = rotate2D(st, (r * PI * 6.0 - time * 2.0));                       // 灵魂一行:旋转角随 r 线性增加
  float a = smoothstep(-w, 0.2, st.x) * smoothstep(w, 0.2, st.x);       // 角度图案:双螺旋臂
  float b = abs(1.0 / (sin(pow(r, 2.0) * 2.0 - time * 1.3) * 6.0)) * 0.4;// 半径图案:同心涟漪环
  material.alpha   = a * b;                    // 乘号 = "两个图案相交的地方才亮"
  material.diffuse = color.rgb * a * b * 3.0;  // 颜色在此处才上场,3.0 是过曝增益
  return material;
}

读到这里,顺手澄清两个最常见的「读错」。第一个:有人以为螺旋臂是「画出来的一条线」,于是去翻代码找 lineWidth、EllipseGraphics 之类的线,结果当然找不到------螺旋臂根本不是几何意义上的线,而是 a 这个亮度调制在 st 平面上「恰好只让某些角度亮」的结果,它和「用线画螺旋」是两套完全不同的思路。第二个:有人以为那些往外漂的亮环是「贴了一张涟漪贴图」,于是想换贴图改环的样式------也不是,b 是纯数学的 1/sin,环的间距、锐利度、速度全在系数里,没有任何纹理参与。把这两点认清,你就不会再去代码里找「线」和「图」,而会直接去 GLSL 里调那几个系数------那才是特效真正住的地方。所以你看到的不是一条干净的螺旋,而是一条暗暗的螺旋臂,上面串着一圈圈向外扩散的细亮环 。这正是它和 #88 最大的区别:#88 那一条线本身是亮的(靠 0.006 / d 的辉光),而 #89 的螺旋臂本身极暗,得靠 b 提亮。

还要多说一句:为什么是乘法 a * b,而不是加法 a + b。这个运算符的选择,决定了你眼睛看到的到底是「一条螺旋」还是「螺旋上串着环」。如果是加法,那么只要螺旋臂亮(a = 1)或者环亮(b 大),那个点就亮------结果会是「一条始终可见的螺旋,上面偶尔叠一圈亮环」,螺旋本身是清晰的。但源码选了乘法,于是只有「螺旋臂亮 并且 环也亮」的点才亮。翻译成画面:螺旋臂平时是暗的(a 虽为 1 但 b 只有 0.067),只有当一圈亮环扫过这条暗臂的某一小段时,那一小段才猛地亮一下。于是你看到的不是一条干净的螺旋,而是一条若隐若现的暗螺旋,上面每隔一段就炸出一串锐利的亮点------像螺旋骨架上串着的珍珠。这个「乘」字,是整篇的命门,理解它就理解了 #89 和 #88 的根本分野。

如果你还想象不出它动起来是什么样,我替你描述一遍这个「编排」:开局,圆盘是空的、几乎是全暗的,只有中心一圈若有若无的暗螺旋骨架;大约每 13 秒,圆心处凭空「滋」地冒出一圈极细的亮环,像水滴落进平静水面;这圈环一边缓缓往外漂、一边被那道暗螺旋臂「串」住------只有当环扫过臂的某一小段时,那一小段才猛地亮起一串珍珠似的亮点;环漂到外缘(约 22 秒后)整圈一起消失,而螺旋臂本身还在以约 17 秒一圈的速度缓缓旋转。于是你看到的是「暗螺旋在转、亮环在漂、两者偶尔相交炸出亮点」的三重节律,比 #88 那道单调扫描线耐看得多。

把这一点钉死:#88 是「一维的角度图案」,#89 是「角度 × 半径的二维图案」。多出来的这一个维度,就是 #89 复杂度的全部来源。

写到这里插一句这个方法论。前面几篇我们拆着色器,大多是「读源码 + 对参数做代数推导」。但 #89 这一篇,我几乎没有靠代数硬推,而是把整段 GLSL 逐行直译成了 Node 脚本,在 200×200 的网格上逐像素求值。为什么能这么干?因为 CIRCLE_SPIRAL_SOURCE 里除了 czm_frameNumber 是个 uniforms 之外,全是标量算术:length、smoothstep、sin、pow、rotate2D 都是纯函数,不碰任何纹理采样、不建任何矩阵、不调任何 WebGL 接口。换句话说,它和真机渲染是逐像素一一对应的------我在 Node 里算出来的某个片元的 alpha,和你显卡算出来的那个片元的 alpha,是同一个数。这正是这条线一直提倡的实测纪律:凡是纯数学的图案,直接复刻,比「看代码猜」准一万倍。后面所有数字(圈数、起始半径、角宽、环速度、周期)都来自这个脚本,不是我脑子里估的。

顺便交代一下这支探针长什么样,方便你复现。它就是一个纯 Node 脚本,没有任何 Cesium 依赖:先把 CIRCLE_SPIRAL_SOURCE 里那几个标量运算原样翻译成 JavaScript 函数(rotate2D、smoothstep、length、pow、sin),然后在一个 200×200 的网格上,对每一个网格点算出它对应的 st 坐标(把像素位置从 [0,200) 映射到 [-1,1] 再乘 1.6),代入 r、angle、a、b,最后得到 alpha = a*b。这样我就有了「圆盘上每一点的亮度」一张图。圈数怎么数?扫一遍 a 在角度方向上的过零点;起始半径怎么来?找 a 第一次非 0 的 r;环在哪?找 b 的局部极大值;速度怎么量?把 time 取几个不同值,看同一个环心的 r 随 time 漂了多少。全程没有 WebGL、没有真机、不需要开浏览器,但算出来的数和显卡渲染逐像素一致------因为这段着色器本就是纯数学。这也是为什么我敢把 17.45 秒、78 米/秒这些数字写死在正文里。

js 复制代码
// _tmp/_probe89.cjs ------ 把 CIRCLE_SPIRAL_SOURCE 逐行直译成 JS,在 200x200 网格上复刻
const PI = 3.14159265359
const ss = (e0, e1, x) => {                  // GLSL smoothstep 的 JS 等价实现
  const u = Math.min(Math.max((x - e0) / (e1 - e0), 0), 1)
  return u * u * (3 - 2 * u)
}
function ev(px, py, time) {                  // px, py ∈ [0,1) 就是 materialInput.st
  let x = (px * 2 - 1) * 1.6, y = (py * 2 - 1) * 1.6
  const r = Math.hypot(x, y)                 // 对应 GLSL 的 length(st)
  const ang = r * PI * 6.0 - time * 2.0
  const xr = Math.cos(ang) * x - Math.sin(ang) * y     // 对应 rotate2D 之后的 st.x
  const w = 0.3
  const a = ss(-w, 0.2, xr) * ss(w, 0.2, xr)
  const b = Math.abs(1.0 / (Math.sin(r * r * 2.0 - time * 1.3) * 6.0)) * 0.4
  return { r, a, b, alpha: a * b }
}
// 扫一遍网格,圈数、臂起始半径、环心位置、径向速度就全出来了,全程不用开浏览器

二、那条螺旋臂,是怎么从「一条直线」拧出来的

先只看 a。把 b 先当 1,问题就简化成:上面那段代码是怎么画出螺旋的?

关键在 rotate2D 这一句:

glsl 复制代码
st = rotate2D(st, (r * PI * 6.0 - time * 2.0));
float a = smoothstep(-w, 0.2, st.x) * smoothstep(w, 0.2, st.x);

rotate2D 就是最普通的旋转矩阵,st 绕原点转 angle 角。但重点在于:这个旋转角 r * PI * 6.0 - time * 2.0 不是常数,而是逐像素不同的 ------它和每个片元到圆心的距离 r 绑在一起。

想清楚这件事的窍门是:先看「如果旋转角是常数」会怎样。假设 angle 固定为 0,那么 st 不转,a 就只看 st.x 接不接近 0.2------也就是在圆盘上画出一条竖直的亮带 (一条过圆心、垂直于 x 轴的亮线)。这很好理解:a 约束的是旋转后坐标系里的 x 坐标,x 固定 ≈ 0.2 就是一条竖线。

现在让旋转角随 r 线性增加:r * PI * 6.0。半径每往外走 1.0 个 st 单位,这道「竖线」就被多拧了 6π ≈ 18.85 弧度,正好 3 圈/单位 st-r (近心处因为还要额外满足 cos(φ+θ) = 0.2/r,表观斜率会升到约 21.8 rad/st-r,越往外越回归 18.85)。于是那条直线不再笔直,而是随着半径被一圈圈地扭开------直线拧成了螺旋。这就是所有「螺旋」类图案的核心套路:不是去画一条螺旋线,而是「把一条直亮带,按半径逐渐旋转」。

顺带把 rotate2D 和 st *= 1.6 这两行的数学身份点清楚,免得你抄的时候改错地方。rotate2D 用的就是最普通的 2×2 旋转矩阵 mat2(cos, -sin, sin, cos),它的行列式是 1,意味着「保距」------旋转不改变点到原点的距离,只是把方向拧过去。所以螺旋臂在旋转前后,每个片元离圆心的距离 r 不变,变的只有角度。你可能会想:为什么不干脆用 atan2 算出角度再直接加一个偏移?可以,但那样你就得在角度上做「模 2π」之类处理,而用旋转矩阵让 GPU 去做坐标变换,代码更短、也避开了角度的周期性边界。至于 st *= 1.6 这一行,它是 #89 和 #88 一个很隐蔽的区别:#88 没有这步,它的 st 边界就是 1.0(椭圆边界);#89 把 st 整体放大 1.6 倍,于是椭圆边界落在 r = 1.6 上。这步决定了螺旋臂能在更大的半径上展开------如果删掉它,螺旋会被压缩在中心一小圈里,2.6 圈挤在 r ≤ 1.0 的窄环带中,密得糊成一团。它本质上是「采样放大」,代价是角点(r 最大 2.26)落在椭圆外被裁掉,但你本来也画不到那里。

顺着「直线拧成螺旋」这个直觉,还藏着一个细节值得点破:为什么是两条对称的臂,而不是一条 。回到 a 的定义:a = smoothstep(-w, 0.2, st.x) * smoothstep(w, 0.2, st.x),它只约束旋转之后坐标系里的 st.x 接近 0.2。一个给定半径 r 下,st.x = r * cos(θ + angle),而 cos(φ) = 0.2 / r 这个方程,每一个 r 都有两个对称解 ±φ (前提是 |0.2 / r| ≤ 1,也就是 r ≥ 0.2)。所以画出来不是一条臂,而是两条互为镜像的臂------这就是为什么你看到的螺旋是「双螺旋」而不是单线。如果你想改成单臂,得把 a 里一个 smoothstep 换成只约束单侧,比如只留 smoothstep(-w, 0.2, st.x) 并让 st.x 恒为正的一侧,但这会改变整个图案的对称气质,不是随手就能改的。

顺着这个思路,所有数值都能从源码里直接算出来,我用 Node 把这段着色器逐行直译、在 200×200 的网格上逐像素复刻(运算符全是标量,和真 GPU 一一对应,不需要 WebGL),量出来的是这些:

  • 双对称臂 :因为亮带只约束 st.x(也就是 cos),每个半径下 θ 和 -θ 都满足,所以画出来是两条对称的主臂,不是一条。

  • 每条约 4.4 圈 :臂从起点 r = 0.2 一直铺到椭圆边界 r = 1.6(跨度 1.4 个 st 单位),按每单位恰好 3 圈、再叠上近心处 arccos 项的贡献,单侧臂总共转过约 4.4 圈 (若只算 r = 0.3 → 1.5 这一段是 3.7 圈)。

  • 臂起始于 188 米 :亮带要求 cos(θ + angle) ≈ 0.2 / r 有解,而 |0.2 / r| 必须 ≤ 1,所以 r ≥ 0.2。r = 0.2 个 st 单位对应物理 0.2 × (1500 / 1.6) ≈ 188 米。也就是说,圆心周围 188 米以内没有任何螺旋臂,是空的。

  • 内粗外细 :亮带在 st.x 上的有效宽度约 0.6 个 st 单位,换算成角度,内圈(r = 0.3)半高全宽约 77°,外圈(r = 1.5)只剩约 11.6°。越往外,臂越细。

  • 整体旋转 :angle 里还有个 - time * 2.0,让整个螺旋随时间转。螺旋转一整圈需要 time 增加 π,对应 π × 1000 / speed 帧。speed = 3 时是 1047 帧 ≈ 17.45 秒 一圈,speed = 20 时压缩到 2.62 秒。

再回头看 st *= 1.6 这一步,它带来的「裁剪」副作用值得单独拎出来说。放大之后,椭圆几何边界落在 r = 1.6,但 st 的四角处 r 最大可达 2.26(√2 × 1.6),那些角点对应的片元在椭圆之外,本就不会被渲染------所以你不会看到任何「被切了一半的螺旋角」。代价是:任何想让螺旋延伸到 r > 1.6 的企图都是徒劳,因为几何根本不画那里。这解释了为什么前面调参建议里反复强调「r > 1.6 的部分不绘制」:你可以通过减小 st *= 1.6 的系数(比如 1.2)来压缩螺旋、让它更集中在中心,也可以通过加大它(比如 2.0)让螺旋铺得更满------但加大到 2.0 后,可见的 r 上限依然是椭圆边界,只是采样更稀疏。这步是「视野缩放」,不是「内容增减」。

「内粗外细」也值得停下来感受一下。同样宽的 st.x 窗口,除以更大的 r 之后,对应的角宽度就小了。这带来一个很妙的观感------螺旋越往外甩,臂越细越利落,像被甩尖的毛笔锋;越往中心,臂越宽越柔和。你调 w(亮带宽度)这个参数时,要意识到它改的是「绝对宽度」,所以对外圈的臂影响小、对内圈的臂影响大,不是均匀变粗变细。想让整条臂均匀,得让 w 随 r 缩放,但那又是改源码的事了。

三、那圈圈往外漂的亮环,是另一种调制

现在只看 b,把 a 当 1。这一项比 a 更反直觉:

glsl 复制代码
float b = abs(1.0 / (sin(pow(r, 2.0) * 2.0 - time * 1.3) * 6.0)) * 0.4;

它只有一个变量 r(到圆心距离),和角度完全无关,所以是纯同心环 。把 sin(r²·2 − time·1.3) 拆开看:

  • 当 sin 接近 0 时,分母趋近 0,1 / sin 暴涨,b 冲到极大(渲染时被 clamp 到 1)------这就是一圈锐利的亮环。
  • 当 sin 等于 ±1 时,b = 0.4 × |1/6| ≈ 0.0667------这是谷值。

换句话说:圆盘上绝大部分地方 b ≈ 0.067,几乎全暗;只有环经过的那一圈极窄的地方 b 才锐利地冲高。它和 #88 外圈那道「恒亮量程环」完全是两码事------#88 的环是一条始终在固定半径上亮着的线,而 #89 的环是「移动且转瞬即逝」的。

1/sin 为什么能造出「环」,值得多停一秒。环的本质是 sin(φ) 的零点:每当 φ = r²·2 − time·1.3 经过 0, ±π, ±2π ... 时,sin 穿过 0,分母趋近 0,1/sin 暴涨,于是那一圈 r 上 b 冲到极大------这就是一道环。相邻两个零点相差 π,对应 r 的间隔随 r 增大而变疏(r 是平方进公式的:r²·2 = nπ + const ⇒ r = √((nπ+const)/2),r 越大、同样 Δn = 1 对应的 Δr 越大),所以你看到的环「越往外越疏、越往外越慢」------正和减速扩散互为表里。也正因为环来自 sin 的零点,它和 st.x 上的螺旋臂完全独立:臂管角度、环管半径,各算各的,最后才相乘。

b 那一项外面套的 abs(...),初看多余,其实少了它画面会「半身不遂」。先看里面:1.0 / (sin(...) * 6.0)。sin 在零点两侧是一正一负的,所以 1 / sin 也是一正一负------如果不取绝对值,环的一侧(sin 为正)会亮、b 为正,另一侧(sin 为负)b 为负,乘进 a * b 之后那一侧直接变暗甚至被 clamp 成 0,于是每道环只有「半圈」亮,另一半黑。包上 abs 之后,环两侧对称地亮,才得到你看到的完整圆环。这个细节提醒一个通用原则:凡是 1 / sin、1 / cos 这类「分母会过零」的表达式,基本都要配 abs 或 max(..., eps),否则零点附近的符号翻转会毁掉图案的对称性。#89 作者写对了,但很多人抄这路特效时会手滑删掉它,然后到处问「为什么我的环只有一半」。

glsl 复制代码
// #84 动画圆(CIRCLE_RING_SOURCE):只动半径 ------ 一维
float pulse  = 0.5 + 0.5 * sin(t * 2.0);
float outerR = 0.55 + 0.3 * pulse;          // 半径原地缩放
material.alpha = pow(alpha, 4.0);

// #86 扩散圆(CIRCLE_DIFFUSE_SOURCE):也只动半径 ------ 一维
float r = length(st - center) / 4.0;
flagColor += circlePing(r, 0.25, 0.025, 4.0, 0.3, 1.0) * color.rgb;

// #89 螺旋圆(CIRCLE_SPIRAL_SOURCE):角度 × 半径 ------ 二维
st = rotate2D(st, r * PI * 6.0 - time * 2.0);                           // 角度项
float b = abs(1.0 / (sin(pow(r, 2.0) * 2.0 - time * 1.3) * 6.0)) * 0.4; // 半径项
material.alpha = a * b;                     // 两个自由度相乘

更反直觉的是环的数量 。环的中心满足 r²·2 − time·1.3 = nπ,也就是 r = √((nπ + time·1.3) / 2)。我让 Node 脚本扫了一圈:在默认参数下,同一时刻圆盘里通常只有 1~2 个环。探针实测------time = 0 时有 2 个(中心 + r ≈ 1.25),time = 1 时仍有 2 个(r ≈ 0.81, 1.49),time = 2 时只剩 1 个(r ≈ 1.14),time ≈ 3.94 时全部漂出边界、一个都不剩,然后新的环又从中心冒出来。所以它根本不是「满屏同心环」,而是「偶尔有一两圈极细的亮环,从圆心缓缓往外飘」。

「环会消失」这个现象,源头就在前面那个 st *= 1.6 和椭圆的裁剪。环的中心满足 r = √((nπ + time·1.3) / 2),随着 time 增大,环心 r 也增大,当 r 超过椭圆边界 1.6 时,那圈环就整体落在椭圆之外------而椭圆几何只画圆内,圆外的片元根本不被渲染,于是环「整圈一起消失」。这就是为什么探针实测到 time ≈ 3.94 时圆盘内一个环都不剩:所有环心都漂出了 r = 1.6。反过来,新环从 r = 0 冒出来时,也要先穿过中心那段「臂起始暗区」(因为 r < 0.2 时螺旋臂不存在),所以你看到的是「中心凭空浮现一圈,缓缓长大、再整圈飘出边界消失」,而不是「满屏同心圆同时在」。这个「出现---长大---消失」的循环,正是 #89 涟漪感的来源,也和 #88 那道「永远在固定半径亮着」的量程环形成最鲜明的对照。

「缓缓」这个词也得量化。由于 r 是平方进公式的,环的径向速度 dr/dt = 1.3 / (4r)------半径越大,漂得越慢 。探针实测在 r = 0.7 处约 78 米/秒 ,r = 1.2 处就只剩 46 米/秒 (speed = 3)。也就是说,环从中心窜出来时快,越往外越舍不得走,最后几乎贴着边界磨蹭。这和真实水波正好相反,是 r² 这个非线性项强行塞进来的「伪物理」。你要的就是这种不自然------它让环看起来像被某种向心力拽住,比匀速扩散更有「被设计过」的味道。当然,若你真想要匀速扩散,把 pow(r, 2.0) 换成 r 即可,一行的事。

亮环本身也很窄:半宽在 r = 0.7 处约 33.5 米 ,r = 1.2 处约 19.5 米 。结合「臂底色 alpha ≈ 0.067」一起看,整个图案的真相是------螺旋臂平时暗得几乎看不见,只有被那一两圈 20~33 米宽的细亮环扫过时才猛地提亮一下。螺旋臂像是被一圈圈涟漪「串」起来的。

「臂底色 alpha ≈ 0.067」这个数字是整篇最该被记住的观感开关。前面算过,b 在 sin = ±1 处取到谷值 0.4 × |1/6| ≈ 0.067,而圆盘上绝大多数地方 b 就在这个量级。因为 a 和 b 是相乘,所以螺旋臂平时(没有被环扫过)的亮度就是 a × 0.067------几乎看不见。只有当一圈亮环经过、b 冲到很大时,那一小段臂才猛地提亮。换句话说:螺旋臂本身是个「隐形的骨架」,真正发亮的是那些扫过它的涟漪环 。这和 #88 是镜像的:#88 的线本身靠 0.006 / d 辉光始终亮着,环只是锦上添花;#89 反过来,臂是暗的、环才是主角。所以如果你觉得 #89「太暗看不清螺旋」,问题不在颜色,而在那个 0.4 的基底亮度系数------把它从 0.4 调高,整条暗臂就会浮现出来。

至于时间尺度:一个环从圆心漂到边界(r 从 0 到 1.6)要 2 × 1.6² / 1.3 × 1000 / speed ≈ 1313 帧 ≈ 21.9 秒 (speed = 3);而新环的生成间隔是 π / 1.3 × 1000 / speed ≈ 806 帧 ≈ 13.4 秒。所以平均下来,圆盘里大多数时候挂着 1~2 个环,不多不少,刚刚好。

四、两个时钟,被一个旋钮同时拧着

把 a 和 b 合起来看,会发现它们各自带着一个时间项,但都挂在同一个 speed 上:

  • 螺旋臂的旋转:angle 里的 - time * 2.0,时钟系数是 2。
  • 亮环的扩散:b 里的 - time * 1.3,时钟系数是 1.3。

而 time = czm_frameNumber * speed / 1000.0。所以你拨动 speed 这一个旋钮,会同时 改变螺旋转多快、环漂多快,而且两者的比例永远锁死在 2 : 1.3。

「同时拧两个时钟」这句话,落到代码里就是两处接线:面板四个旋钮 watch 一响就调 updateEffect,updateEffect 再把 speed 塞进材质属性;而材质属性每帧把 uniforms 交给 GPU。整条链路里没有一处 requestAnimationFrame:

ts 复制代码
// src/cases/circle-spiral/CircleSpiralDemo.vue
function place(lon: number, lat: number): void {
  if (!viewer || viewer.isDestroyed()) return
  removeDcEffectEntity(viewer, CIRCLE_SPIRAL_TYPE)      // 先按固定 id 删旧,保证单例
  createDcEffectEntity(viewer, {
    type: CIRCLE_SPIRAL_TYPE,                           // 'CircleSpiralMaterial'
    lon, lat,
    height: height.value,                               // 0
    radius: radius.value,                               // 1500
    color: Color.fromCssColorString(colorHex.value),    // #ff4d4f
    speed: speed.value                                  // 3
  })
}

function updateEffect(): void {
  if (!viewer || viewer.isDestroyed()) return
  const entity = viewer.entities.getById(`dc-effect-${CIRCLE_SPIRAL_TYPE}`)
  if (!entity) { place(DEFAULT_LON, DEFAULT_LAT); return }   // 还没放就先放一个
  updateDcEffectEntity(viewer, CIRCLE_SPIRAL_TYPE, {
    radius: radius.value, height: height.value,
    color: Color.fromCssColorString(colorHex.value), speed: speed.value
  })
}

watch([colorHex, speed, radius, height], () => { updateEffect() })   // 四个旋钮任一动就热更新
ts 复制代码
// src/cases/dc-effects-lib/materials.ts ------ 每帧把 uniforms 喂给 GLSL 的那个属性
export class DcCircleMaterialProperty {
  private _speed: number
  private _color: Color

  get isConstant(): boolean { return false }        // 关键:false 才有「每帧求值」

  set speed(value: number) {                        // 改 speed 会 raiseEvent,
    if (this._speed === value) return               // 触发 Cesium 重新上传 uniforms
    this._speed = value
    this._definitionChanged.raiseEvent(this)
  }

  getType(): string { return this._type }            // 返回 'CircleSpiralMaterial',Fabric 按它找 source
  getValue(time: unknown, result?: Record<string, unknown>) {
    if (!defined(result)) result = {}
    result.color = Color.clone(this._color, result.color as Color | undefined)
    result.speed = this._speed
    return result                                    // 这几个字段就是每帧喂进 GLSL 的 uniforms
  }
}
ts 复制代码
let registered = false                               // 幂等守卫:整个应用只注册一次

export function registerDcEffectsMaterials(): void {
  if (registered) return                             // 第二次调用直接返回,不重复注册
  registered = true
  const cache = (Material as any)._materialCache     // Cesium 内部的材质缓存
  const entries = [
    { type: CIRCLE_RING_TYPE,   source: CIRCLE_RING_SOURCE,   uniforms: { color: new Color(1,0,0,0.7), speed: 3 } },
    { type: CIRCLE_SPIRAL_TYPE, source: CIRCLE_SPIRAL_SOURCE, uniforms: { color: new Color(1,0,0,0.7), speed: 3 } },
    // ...十种材质,结构一模一样,只是 source 换一段 GLSL
  ]
  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
    })
  }
}

顺带把「时间」这层说透,免得你对那个 /1000.0 犯嘀咕。czm_frameNumber 是 Cesium 从启动到现在渲染过的整数帧数 ,每渲染一帧它就加 1。源码里 time = czm_frameNumber * speed / 1000.0,等于把「每 1000 帧」定义成 1 个时间单位,再被 speed 缩放。按常见的 60 fps 估算,1000 帧 ≈ 16.67 秒------但你不必死抠这个:因为所有时间项都是 sin/cos,天然周期,所以「1000 帧是多少秒」只在换算「多少秒转一圈」这种具体数字时才有意义,图案本身不会因为帧率波动而错位。speed 越大,time 走得越快,动画越快;speed = 0 则时间凝固,画面定格。这也是为什么前面给的「17.45 秒一圈」是个「speed = 3、60fps」下的参考值,你机器帧率不同只会让这个数字按比例浮动,不影响观感。

把 speed 从 1 拉到 20,你眼睛看到的「后果」值得描述一下,免得你盲调。speed = 1 时,螺旋以约 52 秒一圈极慢地转,亮环约 40 秒才漂过全径,整体是种「冥想式」的缓慢涟漪,适合做大屏背景;speed = 3(默认)时,约 17 秒一圈、环约 22 秒漂过,节奏舒服、细节清晰,是日常演示的甜点档;speed = 20 时,约 2.6 秒一圈、环约 3.3 秒漂过,螺旋和环都快到「糊成一片闪烁的光盘」,细节(比如某一圈环扫过某段臂的珍珠感)会被速度淹没,只剩一团躁动的亮雾。所以「调快」不是免费的提升------它用「丢失细节」换「视觉冲击」,按你的场景选档位,别无脑拉满。

讲到这里必须点破一个「参数语义」的小遗憾,它在这条线上反复出现。你面板上的 speed 滑块,看起来像「动画快慢」,但它实际上是个「总倍率」------它同时乘进了螺旋旋转的时钟(-time * 2.0)和环扩散的时钟(-time * 1.3),而这两个 2.0 和 1.3 都是写死的字面量。所以你拨 speed,螺旋和环会等比例 一起变快或变慢,永远保持 2 : 1.3 的关系。想单独让环快一点、螺旋慢一点?源码里没有这个旋钮,得去改那两个系数。这其实是这类「参数面板只有几个滑块」的特效的通病:面板暴露的是「总开关」,真正的「分开关」埋在着色器字面量里。搬去自己项目时,若想让它可调,把 2.0 和 1.3 提成 uniforms 是最干净的做法。

最后把 w = 0.3 这个亮带半宽讲透,因为它直接决定螺旋臂「胖瘦」。在 a = smoothstep(-w, 0.2, st.x) * smoothstep(w, 0.2, st.x) 里,w 控制的是「st.x 离 0.2 多远就开始变暗」:w 越大,亮带越宽、臂越胖;w 越小,臂越瘦、越锋利。但要注意它和前面说的「内粗外细」是两层独立的事------w 改的是「绝对宽度」(同样 0.6 个 st 单位),而「内粗外细」是「同样的绝对宽度,除以更大的 r 后角宽变小」。所以如果你把 w 从 0.3 调到 0.6,整条臂会一起变胖(内圈和外圈都胖),观感从「细线」变成「宽带」;想只让外圈变细、保持内圈胖,那不是调 w 能解决的,得让 w 随 r 缩放。改这一个字面量,是螺旋「气质」变化最明显的一处。

第二个值得说的点:换颜色不改亮度 。回头看终值------material.alpha = a * b,这是纯几何的,颜色根本没进 alpha。颜色只出现在 material.diffuse = color.rgb * a * b * 3.0 里,决定「亮的地方是什么色」,不决定「亮不亮」。这和 #88 正好相反:#88 是 material.alpha = pow(length(col), 2.0),换深色会顺带把整条线变淡。#89 这里换深色,亮度纹丝不动,只是颜色从红变成深红。所以如果你嫌它太暗,调颜色没用,得去动 a、b 里那两个 0.4 / 0.3 的系数,或者给 a * b 整体加个增益。

关于颜色,再多说两句它「不改亮度」在实际调色里的含义。因为 alpha = a*b 是纯几何,你选红、选青、选金,亮环的「亮」完全一样,变的只是 hue(色相)。默认 #ff4d4f 是偏暖的红,在深色地球底图上对比最清楚;如果你换成一抹青色 #36cfc9,亮环依然是同样亮的青色,不会变暗也不会变亮。这也意味着「用深色做低调效果」在 #89 里行不通------深色一样刺眼。若你真想让整体暗下来又不破坏图案,正确做法是给 a*b 整体乘一个小于 1 的增益(比如 material.alpha = a * b * 0.6),或者提高 b 的 0.4 让暗臂浮现、用「臂」而非「环」当主光源。颜色,在这套写法里只是「最后涂上去的漆」,不是「明暗的开关」。

顺着颜色这个话题,给几组实测好看的配色参考,省得你一个个试:暖红 #ff4d4f(默认,对比最强)、青 #36cfc9(冷调科技感)、金 #d4a017(低调奢华)、紫 #9c6ade(梦幻)。关键原则是「亮环颜色要和地球底图拉开对比」------深色底图上用亮色,浅色底图(比如白天的街道图)上反而要用更饱和或更暗的色,否则亮环会被底色吃掉。另外,color 只影响 diffuse 里的色相,亮度恒定,所以别指望用「浅色」做出「柔和」效果,柔和的入口永远在那两个 0.4 / 3.0 系数上。

第三个点:diffuse 末尾乘了 3.0。在亮环经过、且 a 也接近 1 的地方,a * b * 3 很容易超过 1,被渲染管线 clamp 掉(我在 200×200 网格上实测,环心处的 a * b 能冲到 175 量级,alpha 早被截得干净)------也就是说亮环的核心是一片纯色饱和区 ,细节被吃掉了。想要环核心有过渡而不是「死白一团」,这个 3.0 得往下调(比如 1.5),或者改成 clamp(a * b * 3.0, 0.0, 0.9) 给个上限,环就会「透」一点。这也是这类特效「抄过去直接用」和「抄过去调舒服」之间的差距:字面量差一个倍数,观感天差地别。

第四个点,也是最容易被当成「坑」实则不是的地方:没有取模也不会顿挫 。#88 结尾我们提醒过,t 直接用、长时间挂机会因为 czm_frameNumber 一直涨而精度退化。但 #89 里所有时间项都包在 sin / cos 里(angle 进 rotate2D 的 cos/sin,b 进 sin),数学上天然周期,不会像某些「线性累加距离」的写法那样越跑越偏。真正要注意的只有一件事:czm_frameNumber 是 float,挂机极久(比如连续跑几十小时、帧号上千万)后,浮点尾数精度会退化,让螺旋的圈数出现轻微抖动、或者 sin 的周期不再精确。真要长跑,给 time 加个 mod(time, 一个足够大的周期) 即可------本篇 demo 没做,但风险很小,远不如「忘记 clamp 导致的性能问题」值得操心。

几个容易踩的边界情况提前说清,省得你调参时以为出了 bug。其一,speed 取 0 时 time 恒为 0,画面定格在第一帧------这不是卡死,是「时间停了」,恢复正数即正常。其二,speed 取负数时 time 倒流,螺旋会反转、环会向内收缩而非外扩,这其实是种有趣的「倒放」效果,不是错误。其三,把 radius 拉到极大(比如 5000)时,整个螺旋在屏幕上被缩小成一小团,因为 st 映射的是「相对半径」,绝对半径只决定圆盘覆盖多大的地理范围,不改变图案密度------想让螺旋「看起来更大更密」,要改的是 r * PI * 6.0 里的 6.0 而非 radius。其四,颜色即便设成接近黑,alpha 仍由 a*b 决定,亮环照样刺眼,别指望用黑色「藏」掉它。

最后替你把这篇最该记住的几条「硬事实」压成一张便签:① #89 = 角度图案(双螺旋臂)× 半径图案(涟漪环),灵魂是「乘号」不是「一条线」;② 螺旋臂平时是暗的,底色 alpha ≈ 0.067,真正发亮的是那些扫过它的细亮环;③ 环是「移动且转瞬即逝」的------同一时刻通常只有 1~2 个,约 13 秒生成一个、约 22 秒漂出边界消失,且减速扩散(内快外慢);④ speed 是总倍率,螺旋与环锁死在 2 : 1.3,想独立调速得把系数提成 uniforms;⑤ 换颜色不改亮度(alpha = a*b 纯几何),「太暗」别动颜色、去动 b 里的 0.4。这五条,加上「一个椭圆 + 一个 isConstant=false 材质 + 一台 czm_frameNumber 节拍器」的骨架,就是 #89 的全部家当。

顺手把「搬去自己项目」的最短路径也写清楚,免得你对着 demo 不知道从哪下手。你需要动三处:第一,在 dc-effects-lib/materials.ts 里把 CIRCLE_SPIRAL_SOURCE 这个字符串和 CIRCLE_SPIRAL_TYPE = 'CircleSpiralMaterial' 这个类型名一起注册进 Fabric 的材质工厂(Fabric 用 type 字符串当 key,source 当 GLSL);第二,确认你项目里也有一个 DcCircleMaterialProperty(或者等价的、把 isConstant 返回 false 的椭圆材质属性),因为它负责每帧把 speed 等 uniforms 喂进去------没有它,材质就是静态的,czm_frameNumber 再怎么涨也不动;第三,用 createDcEffectEntity 建一个 semiMajorAxis === semiMinorAxis 的正圆椭圆,把 type / lon / lat / height / radius / color / speed 这七个字段填进去即可。注意你不需要 第二个实体、第二个材质层,也不需要自己管理 requestAnimationFrame------所有动画都藏在那个 isConstant=false 的材质里,由 Cesium 的渲染循环逐帧驱动。这就是这条线最便宜的地方:把「会动」这件事完全交给 Fabric 材质,几何一动不动。

落到代码上,最短路径就是这三步,onMounted 与 onBeforeUnmount 一进一出刚好对称:

ts 复制代码
onMounted(() => {
  if (!container.value) return
  try {
    registerDcEffectsMaterials()                     // ① 先把十种材质注册进 Fabric 工厂
    viewer = createMapScene(container.value, sceneCallbacks)
    loadBingImagery(viewer, sceneCallbacks)
    viewer.camera.setView({ destination: Cartesian3.fromDegrees(DEFAULT_LON, DEFAULT_LAT, 8000) })
    handler = new ScreenSpaceEventHandler(viewer.canvas)
    handler.setInputAction((event) => onLeftClick(event), ScreenSpaceEventType.LEFT_CLICK)
    place(DEFAULT_LON, DEFAULT_LAT)                  // ② 建实体
  } catch (error) {
    statusMessage.value = error instanceof Error ? error.message : String(error)
  }
})

onBeforeUnmount(() => {
  handler?.destroy(); handler = undefined
  removeDcEffectEntity(viewer, CIRCLE_SPIRAL_TYPE)   // ③ 先删实体
  destroyScene(viewer)                               // 再销场景(顺序不能反)
  viewer = undefined
})

为什么偏选 Fabric 材质,而不直接裸写一段 GLSL 的 Primitive?这是一个值得想清楚的工程取舍。Fabric 是 Cesium 内置的「材质描述语言」,它把「材质 = 一组 uniforms + 一段 GLSL 源码」封装成一个可被任何 MaterialProperty 复用的对象,并且天然和 Entity/Primitive 的材质槽位对接------你不用碰渲染管线的底层。代价是 Fabric 的 GLSL 必须写成它约定的 czm_getMaterial 函数签名,且不能直接操作顶点。对「圆盘内逐像素亮度调制」这种需求,Fabric 完全够用且最省力;只有当你的特效需要「改顶点形状」(比如真的把椭圆边缘扭成波浪)时,才值得去写 Primitive。#89 这种纯片元效果,Fabric 就是最优解------这也是 dc-effects-lib 整库都建在 Fabric 之上的原因。

五、和雷达线比,它到底复杂在哪

把 #88 和 #89 摆在一张表里,就清楚「多出来的复杂度到底花在哪」了:

维度 #88 雷达线 #89 螺旋圆
图案维度 只有角度(一条线) 角度 × 半径(螺旋臂 × 环)
亮区形态 一条亮线 + 一道恒亮量程环 极暗螺旋臂 + 稀疏锐利亮环
时间项 t 进 sin/cos(线性方向) 螺旋 time·2、环 time·1.3,都进 sin/cos
换色影响亮度 是(pow(length(col), 2)) 否(颜色只进 diffuse)
几何 静态单实体 静态单实体(完全相同)
旋转/扫描周期 34.91 秒一圈(speed 3) 螺旋 17.45 秒、环扫全径 21.9 秒(speed 3)
额外密度参数 无 臂密度 6π、环密度 1.3 写死

最该记住的一行是:几何完全相同 。#88 总结过这条线的骨架------「一个地理椭圆、isConstant = false 的材质属性、一台逐帧的节拍器」。#89 一字不差地套着这副骨架,连 createDcEffectEntity 的调用签名都和 #88 一模一样(四个参数:颜色、速度、半径、高度)。它看着「花」,但骨子里和 #88 一样省钱:同样只有一个实体、同样几何零每帧重建、同样不需要第二个材质层或第二个实体。所谓「复杂度」,全在着色器那二十来行里------而那二十五行,本质上只是在 st 平面上同时玩了「角度旋转」和「半径涟漪」两个独立的小把戏,再用一个乘号把它们叠起来。

ts 复制代码
export function updateDcEffectEntity(viewer: Viewer | undefined, type: string, options: Partial<DcEffectEntityOptions>): void {
  if (!viewer || viewer.isDestroyed()) return
  const entity = viewer.entities.getById(`${DC_EFFECT_ENTITY_PREFIX}${type}`)
  if (!entity) return
  const ellipse = (entity as Entity).ellipse
  if (!ellipse) return

  // ① 几何路径:改半径 / 高度 / 位置,会让椭圆几何重建
  if (options.radius !== undefined) {
    ellipse.semiMajorAxis = new ConstantProperty(options.radius)
    ellipse.semiMinorAxis = new ConstantProperty(options.radius)
  }
  if (options.height !== undefined && options.height !== null) {
    ellipse.height = new ConstantProperty(options.height)
  }
  if (options.lon !== undefined && options.lat !== undefined) {
    entity.position = new ConstantPositionProperty(
      Cartesian3.fromDegrees(options.lon, options.lat, options.height ?? 0)
    )
  }

  // ② 材质路径:改颜色 / 速度,只挪 uniforms,几何一动不动
  const material = ellipse.material
  if (material instanceof DcCircleMaterialProperty) {
    if (options.color) material.color = options.color
    if (options.speed !== undefined) material.speed = options.speed
  }
}

把视野拉回整条线,有个事实值得钉死:从 #83 到 #89,七篇里有六篇的几何都是静态的、零每帧重建 ------包括 #89。唯一例外是 #87 雷达图片,它用 ImageMaterialProperty 贴图 + stRotation 旋转,反而把整个椭圆几何拖进了每帧销毁重建(我们当时专门写过一篇讲这个反差)。所以「特效族」的真正分界线不是「用不用 GLSL」,而是「动画在材质里还是几何里」。#83~#86、#88、#89 这些把动画写进 Fabric 材质的,几何一动不动,最便宜;#87 把动画放进几何属性的,反而最贵。这个对比贯穿整条线,是选方案时最该先想清楚的一件事。

把性能这件事钉死,因为它解释了为什么这条线能「看着花、实则便宜」。整个 #89 运行时只有一个 Cesium 实体(那个正圆椭圆),几何从创建到销毁一字不改------radius、height 这些属性你面板里拖动时,椭圆几何确实会重建,但那是你主动改参数才发生,动画播放期间几何纹丝不动。isConstant = false 的材质属性只负责每帧把 speed 等少数几个 uniforms 上传给 GPU,开销是几个浮点数的拷贝;真正逐像素算 a、b、alpha 的是 GPU 的片元着色器,海量并行、零 CPU 负担。对比 #87 雷达图片把动画放进几何属性、导致整个椭圆每帧销毁重建,#89 的这种「动画在材质、几何静态」范式,在实体多、场景大的项目里优势会被放大得很明显。

和其他在 Cesium 里做「圆上会动的光」的方案比一比,更能显出这条线的取舍。最直觉的替代是 Entity.ellipse 配一个 CallbackProperty:每帧回调里改椭圆某个属性(比如 rotation 或 gradient)。但它的动画发生在「几何属性」层,等于每帧让 Cesium 重建/更新几何,和 #87 一样偏贵;而且 CallbackProperty 很难表达「圆盘内部逐像素的螺旋亮度」,最多做到整体旋转或缩放。另一条路是自己写 Primitive + 自定义 GLSL,能力上限最高,但你要自己管 VAO、drawCommand、外观状态,工作量是指数级的。#89 走的 Fabric 材质这条路,正好卡在「表达力够(逐像素任意图案)」和「成本够低(几何静态、只传 uniforms)」的甜蜜点------代价是不能做「需要改几何顶点」的效果(比如真的把多边形顶点扭成螺旋),但圆盘内的亮度调制它全包了。

六、从螺旋圆看整条线

七篇读下来,这条「三维特效」线其实一直在回答同一个问题:「圆上会动的光」到底有几种造法。把答案收在一处:

  • #83 说:可以叠两层材质,一层画光束、一层画量程圈。
  • #84 说:可以只用一个椭圆,让环在原地呼吸。
  • #85 说:可以把颜色本身也送进时间函数。
  • #86 说:可以让一道波前从圆心单调外推。
  • #87 说:可以干脆不算,贴张图转起来。
  • #88 说:可以极简到一条线加一道环,二十二行写死。
  • #89 说:还可以把「角度」和「半径」两个维度、两个时钟,塞进同一个材质里相乘。

把这七篇收在一张脉络表里,你会看到这条线其实一直在玩同一个游戏,只是每次换一个「造图案」的维度:

篇 主角 怎么造「圆上会动的光」
#83 光束 + 量程圈 叠两层材质,各管一摊
#84 呼吸环 一个椭圆,让环原地缩放
#85 多彩圆 把颜色本身送进时间函数
#86 扩散圆 一道波前从圆心单调外推
#87 雷达图片 不算,贴张图转起来
#88 雷达线 一条线加一道环,二十二行写死
#89 螺旋圆 角度图案 × 径向图案,两个时钟相乘

变的永远是「怎么用 st 上的距离和角度造图案」这一件事,骨架从 #83 到 #89 几乎没动过。

CIRCLE_SPIRAL_SOURCE 是这条线的集大成者,但它更像一个「模板」------你照着它,能造出第 N 种圆。方法很简单:在 st 平面上,你能玩的自由度就那么几个,列出来就是一张菜单:角度旋转 (像 #88 那样 vec2(sin t, cos t) 造方向,或像 #89 这样 r·系数 造螺旋)、半径涟漪 (sin(r·系数 - time·系数) 造同心环,或 1/r 造辉光)、时间相位 (让图案整体平移、旋转、呼吸)、颜色(送进 diffuse 或 alpha)。任意组合、用加号或乘号粘合,就是一个新特效。螺旋圆只是「角度旋转 × 半径涟漪」这一种组合;你完全可以试试「角度旋转 × 角度旋转」(双螺旋叠加)、「半径涟漪 × 颜色」(彩虹涟漪),每一种都是一篇新文章的素材。这条线写了七篇还没穷尽,原因就在这里。

把视角再拉高一点:circle-spiral 只是 dc-effects-lib 这个「圆特效素材库」里的一个。横向看过去,这个库里「在圆上做文章」的案例已经攒了一串------光束、量程圈、呼吸环、多彩圆、扩散圆、雷达图、雷达线、螺旋圆------它们共享同一个「一个椭圆 + 一个 isConstant=false 材质 + 一台 czm_frameNumber 节拍器」的骨架,区别只在于「st 平面上的距离和角度怎么被喂进 GLSL」。螺旋圆之所以特殊,是因为它是第一个把「角度」和「半径」两个维度同时塞进同一个材质的;而它一旦被写成,后面「彩色螺旋」「双螺旋叠加」「螺旋+呼吸」就都只是改几个系数的事。所以这篇你真正该带走的,不是某一行 GLSL,而是「素材库思维」:把每个特效当成 st 平面上一个可组合的基本图案,库的边界就会越扩越宽。

现场调参给三个建议,照着拧基本不会翻车:第一,想让螺旋更密更「拧」,去改 r * PI * 6.0 里的 6.0(每单位半径的圈数 ≈ 3 × 这个值 / 6),但它只影响半径 ≤ 1.6 的部分------超过的部分在椭圆外根本不绘制,所以别指望无限密;第二,想让暗臂浮现、整体提亮,动 b 里的 0.4(环的基底亮度)而不是颜色;第三,想让环核心有过渡而非死白,把 diffuse 末尾的 3.0 降到 1.5 左右。这三个旋钮,加上面板上的 speed / radius / color / height,足够你在一个下午里把 #89 玩出十几种面貌。

现场最常见的两个翻车,提前替你踩一遍。第一,有人嫌螺旋太暗,顺手把 a * b 改成 a + b,想让暗臂也亮起来------结果不是「臂变清楚」,而是「臂和环变成叠加亮区」,原来「臂上串环」的珍珠感没了,整片糊成一片均匀的亮雾,反而更难看。要提亮暗臂,正确入口是 b 里的 0.4 基底系数(前面段讲过),不是动运算符。第二,有人把 speed 拉到 20 看环飞快外窜,却以为「螺旋也跟着疯转是 bug」------其实那正是 2 : 1.3 锁死比例的结果,螺旋和环被同一个旋钮等比例加速,分不清哪个是主因时,先回去看那两个系数,而不是怀疑渲染。这两个坑,都是「没看清乘号和总倍率」导致的,改源码前先把前几段读一遍。

最后给一张「想要某种效果,改哪里」的速查表,省得你回去翻前文:想让螺旋更密,加大 r * PI * 6.0 里的 6.0,但记住 r > 1.6 的部分在椭圆外根本不绘制,所以密是有限度的;想让暗臂整体浮现、不再「太暗」,把 b 里的 0.4 提到 0.7 甚至 1.0,而不是去动颜色;想让环核心有柔和的过渡而非死白一团,把 diffuse 末尾的 3.0 降到 1.5;想要环更密更挤,把 b 里 sin(...) 的 1.3 调大;想要匀速扩散(像真水波)而非减速扩散,把 pow(r, 2.0) 换成 r;想要螺旋和环各自独立调速,把 2.0 和 1.3 提成 uniforms。这张表里的每一项,源码都只用改一个字面量,但改之前请务必先把「乘号」和「总倍率」这两件事读通------绝大多数翻车,都是没看清它们。

这一篇收尾,替你记三句最该带走的话:其一,#89 = 角度图案(双螺旋臂)× 半径图案(涟漪环),灵魂是一个乘号,不是一条线;其二,螺旋臂平时是暗的(底色 alpha ≈ 0.067),真正发亮的是那些扫过它的细亮环,所以「太暗」别动颜色、去动 0.4;其三,speed 是个总倍率,螺旋和环被锁死在 2 : 1.3,想独立调去把系数提成 uniforms。这三句,加上前面七篇攒下的「一个椭圆 + 一个 isConstant = false 材质 + 一台 czm_frameNumber 节拍器」骨架,足够你在这条线上再走很远。

下一篇按序号是 #90 脉冲圆效果(同属「三维特效」线,主角从「螺旋臂乘涟漪」换成「一圈圈向内塌缩又向外迸发的脉冲」)。


第89篇 螺旋圆 写到这里

双臂卷起千重涟漪 一环未至万环寂

十七秒 转过一整圈光阴


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

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

代码在 src/cases/circle-spiral/ 目录下,核心就一个 CircleSpiralDemo.vue,真正的灵魂是 src/cases/dc-effects-lib/materials.ts 里的 CIRCLE_SPIRAL_SOURCE(非空 21 行)。搬去自己项目时盯住三件事:一是 st = rotate2D(st, r * PI * 6.0 - time * 2.0) 这一行,r * PI * 6.0 是把「直亮带」拧成螺旋的密度系数(每单位半径约 3 圈),想让臂更密就加大它,但 r 超过 1.6 的部分在椭圆外不会绘制;二是 b = abs(1.0 / (sin(r² * 2.0 - time * 1.3) * 6.0)) * 0.4 这一行,环的「锐利度」和「间距」都在这,0.4 是环的基础亮度、6.0 控制环有多窄、1.3 是环外扩的时钟系数,且圆环是减速扩散(内快外慢);三是 material.alpha = a * b 纯几何、换颜色不改亮度,想整体调亮就去给 a * b 加增益,而不是动颜色。

相关推荐
前端精髓2 小时前
调用openrouter的免费API模型
java·前端·javascript
开开心心_Every2 小时前
快速隐藏程序工具自定义快捷键一键切换
java·开发语言·前端·spring·智能手机·word·intellij-idea
chenbingjie_c3 小时前
C++ 多态完全指南:从虚函数重写到虚函数表原理
开发语言·c++
吴卫斌3 小时前
定投ETF实战指南(四):行业/主题ETF——定投的“进攻端“
开发语言·python·股票·量化
喵了几个咪3 小时前
AI 时代的危与机:当潮水退去,技术人如何自处
1024程序员节
超级架构师4 小时前
PRAXO-CORE 2.5.0:用 conformance 和显式边界构建 Go Agent 框架
开发语言·人工智能·后端·golang·ai编程·智能体框架·praxo-core
叼烟扛炮4 小时前
Linux 学习笔记(四十三):网络编程进阶——自定义协议解决 TCP 粘包与守护进程
linux·开发语言·网络·笔记·tcp/ip
LMT流氓兔5 小时前
第五单元——第十六课:给书店助手加上网页界面
java·开发语言·jvm·人工智能·oracle·langchain·1024程序员节
专注仿真5 小时前
梯度提升类(XFBoost)算法详解与代码实例
开发语言·人工智能·python·算法·大模型训练·控制算法