第92篇:三维特效-圆(逐渐消逝)——从圆心开始,让一个洞把整片光啃到只剩外圈

本文目标:把「圆(逐渐消逝)」这个听着像「整片光慢慢淡出画面」的效果,用实测拆开------它到底淡没淡、消的是哪一部分、边界怎么动、为什么亮度是均匀的,最后给一份把它改成真正会淡出的清单。

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


一、先把「圆(逐渐消逝)」这件事想清楚

上一篇,我们把模糊圆拆到了底:那个「把边界揉成一团柔光」的说法里,藏着一个指数 10,把本来柔和的过渡压成了针尖。收尾时本猿留了一句:

下一篇,我们沿着「圆」这条母题继续往前走,看圆(逐渐消逝)效果怎么让一整个圆环自己淡出画面。

今天------终于要交作业了!🎉

说到交作业,这一篇有点特殊------本猿要当着读者的面收回上篇说过的一个词。上篇预告里写的是「让一整个圆环自己淡出画面」,其中的「淡出」是顺着名字想当然补上去的。等真把这段着色器逐行跑完,才发现整段代码里压根没有一处让透明度随时间衰减。后面你会看到,这片光自始至终完全不透明,它消失的方式是被人从中间挖走。

为了把这件事说死,本猿先给一个最简单的判据:把整段着色器里所有跟 alpha 有关的赋值找出来,看它是不是常数。本篇只有一句 material.alpha = color.a,右边没有 per、没有 dis、没有 czm_frameNumber。只要这个式子不含时间,就绝无可能「逐渐」淡出------这是纯语法层面的判断,不用跑探针就知道。

大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。

这条线写到现在,本猿养成了一个习惯:拿到一段着色器,先不看名字、也不猜意图,而是把它逐行直译成能在 Node 里跑的标量数学,然后按帧扫一遍。本篇尤其适合这么干------因为它只有一个自变量 dis 和一个参数 per,数学上是一维的,所有结论都能算出闭式解,一个数字都不用估。

不过开工之前,本猿得先认个错:上篇那句预告里用错了词。跑完这一篇的探针之后我发现,「淡出」这两个字从头到尾都没发生过 ------这片光一次都没有变淡过,它是被从圆心开始一点点挖空的。

从 #86 扩散圆、#88 雷达线、#89 螺旋圆、#90 脉冲圆、#91 模糊圆一路写下来,这条「三维特效」线的骨架始终没变:拿一个正圆椭圆当画布,几何建一次就冻住,动的全在 Fabric 材质里------每秒六十次问同一批片元「你现在该不该出现」。本篇的装配和前五篇一字不差,真正不同的是材质里那十八行 GLSL。

直觉第一时间告诉你:逐渐消逝,那应该是一整片光慢慢变淡,透明度从 1 均匀降到 0,最后悄无声息地融进底图。名字也这么暗示着------「消逝」嘛,就是淡去。

而且「直接不画」在渲染管线里其实有两层含义:discard 是在片元着色器里主动放弃;还有一种更彻底的做法是在几何层面就不要那块三角形。本篇选的是前者------因为椭圆几何的三角形是扇形排布的,想只挖掉中间那块得重建几何,那可比一句 discard 贵多了。

这里其实藏着这条线一直在守的一条原则:动画写进材质,几何保持静态。一旦为了挖洞去重建几何,就等于破了这条原则------每帧重建顶点、每帧重传缓冲,代价比一句 discard 高几个数量级。所以本篇用 discard 虽然牺牲了早期深度测试,整体仍是最省的一条路。

再把账算完整一点:本篇每帧从 CPU 传到 GPU 的只有五个浮点数,几何一次建好就不动,剩下唯一的额外成本就是被 discard 掉的那些片元跑了一遍很短的着色器,外加早期深度测试的失效。对一个小盘子来说,这几乎可以忽略。

但「小盘子」这个前提得说清楚。本猿算过:默认 1500 m 半径、8000 m 相机高度下,盘子在屏幕上大概只占几百像素见方;一旦把相机拉低、或者半径拉到 5000 m,覆盖的片元数会按平方涨上去,那时候 discard 的代价就不能不看了。

而本篇的源码里,压根没有「透明度渐变」这个动作。

这不是偶然。绝大多数「消失」效果都走 alpha 路线,因为 alpha 能做出连续的过渡,调起来也直观------改个系数就能从硬边变柔边。discard 则是二进制的:要么画、要么不画,做不出任何过渡。它用的是 discard------一个在 Fabric 材质里相当少见的关键字:符合条件的片元直接丢弃,既不写颜色也不写深度。留下的部分,透明度从头到尾是一个常数。

而「干净的洞」这个需求,用 alpha 也能做------把洞内 alpha 设成 0 就行。区别只在代价和边缘质感:alpha 路线多一遍混合但边缘可做羽化,discard 路线省混合但边缘必然是硬的。本篇选了后者,代价就是第三节里那条「永远留一圈」和边上那圈硬邦邦的锯齿。

但把这段着色器原原本本跑一遍,你会发现三件和直觉对不上的事:

第一,它不是在变淡 ,而是在被挖空。中间那块从圆心开始一点点被扔掉,剩下的部分亮度纹丝不动。

第二,先没的是圆心,外边界从头到尾一动不动。你可能以为整片一起变小,实际是外圈钉死在盘子边缘,动的是内边界------而且可见面积掉得比你想的慢得多:时间过去七成才只剩一半。

第三,整片光的亮度是均匀的 。diffuse 里没有半径、也没有时间,所以这片光没有中心亮、边缘暗那一说,它是一整块同样亮的颜色------并且整体过曝了 1.5 倍。

第三条其实还有个推论:既然亮度不含位置变量,那么本篇的边缘就完全由 discard 那一下决定------没有羽化、没有抗锯齿的过渡,放大看会是硬邦邦的一圈。这是 discard 方案在低分辨率下最容易暴露的短板。

这三件事,没有一件能从「逐渐消逝」这个名字里读出来。下面我们就从源码开始,一行一行把它跑出来。

先交代一句方法:本猿把这段 GLSL 逐行直译成 Node 里的标量数学------fract 自己写一个,distance 就是勾股定理,discard 在数学上等价于「这一项不参与统计」。然后按帧把 per、洞半径、环宽、可见面积全扫一遍。因为本篇的判决条件是个简单不等式,每个量都有闭式解,探针只是用来验算和出表的。

这一点跟 #89/#90 很不一样。那两篇的图案函数里有 sin、abs、pow 叠在一起,环的位置得靠二分法解,跑一遍探针是必需的。本篇则可以直接写出「洞半径 = per × radius」------闭式解的好处是你能立刻看出参数之间的依赖关系,比如「周期与 radius 无关」这种结论,看一眼公式就知道。

二、那片光是怎么被「消」掉的

这一节做两件事:先确认坐标系,再把十八行逐行拆开。顺序不能倒------不先说清 dis 怎么换算成米,后面「洞半径等于 per × radius」这条就落不了地。

先把源码原样摆出来(src/cases/dc-effects-lib/materials.ts,CIRCLE_FADE_SOURCE,共十八行):

glsl 复制代码
uniform vec4 color;
uniform float speed;

czm_material czm_getMaterial(czm_materialInput materialInput) {
    czm_material material = czm_getDefaultMaterial(materialInput);
    material.diffuse = 1.5 * color.rgb;
    vec2 st = materialInput.st;
    float dis = distance(st, vec2(0.5, 0.5));
    float per = fract(czm_frameNumber * speed / 1000.0);
    if (dis > per * 0.5) {
      material.alpha = color.a;
    } else {
      discard;
    }
    return material;
}
glsl 复制代码
// ===== CIRCLE_FADE_SOURCE 逐行拆解 =====
uniform vec4  color;                      // 两个分量都用上:rgb 进 diffuse,a 进 alpha
uniform float speed;                      // 名义含义:1000 帧走几轮

czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);
  material.diffuse = 1.5 * color.rgb;     // ★ 写最前、不含 dis 也不含时间 → 整片同一个亮度
  vec2  st  = materialInput.st;
  float dis = distance(st, vec2(0.5, 0.5));              // 到圆心的归一化距离,盘内 0 ~ 0.5
  float per = fract(czm_frameNumber * speed / 1000.0);   // 0~1 的进度,直接当半径比例用
  if (dis > per * 0.5) {                  // 保留外圈、丢弃中心 → 洞从圆心往外啃
    material.alpha = color.a;             // ★ 常数:与位置无关、与时间无关
  } else {
    discard;                              // ★ 真·不写颜色也不写深度,洞是真空
  }
  return material;
}

先说坐标系,这是后面全部推演的地基。

很多讲着色器的文章会跳过这一步。本猿不跳------因为「dis 等于 0.5 时是多少米」这个换算不落地,后面所有数字都只是无量纲的比例,读者没法把它和屏幕上看到的东西对上。本篇尤其如此:洞半径、环宽、面积占比,全靠这条换算才能变成米。materialInput.st 是 Cesium 给椭圆几何算好的纹理坐标,横竖都从 0 到 1,圆心落在 (0.5, 0.5)。本猿用 Node 建了一个半径 1500 m 的真实 EllipseGeometry,把 336 个顶点的 st 和它们离圆心的米数一对:边缘处 st 到中心的距离是 0.50002(理论值 0.5)。

于是换算关系一句话说清:盘面内 dis 从 0(圆心)线性涨到 0.5(边缘),0.5 个 st 距离就等于半径的米数。这条与 #88 到 #91 完全相同,本猿每篇都重测一遍,因为一旦想当然,后面所有米数都会错。

为什么非要重测?因为 st 的口径在不同几何上是不同的。本猿在 #76 比例符号那篇就吃过亏:PolygonGeometry 的 st 是相对外接矩形,而不是相对半径归一化。椭圆几何这里恰好是「边缘 dis = 0.5」,但那是从 336 个真顶点里量出来的,不是从文档里读出来的。

然后逐行拆。

第一步,material.diffuse = 1.5 * color.rgb。 注意这一行写在最前面,而且既不含 dis 也不含时间------它是个常数。整片光从圆心到边缘、从第一帧到最后一帧,颜色都是同一个值:原色的 1.5 倍。

这一行的位置也值得注意:它写在 discard 之前,所以被丢弃的那部分片元同样算了一遍 diffuse------算完就扔。这是社区着色器里很常见的写法,因为把公共计算提到分支前面读起来更顺;代价是被扔掉的片元白算了一次。本篇的 diffuse 只有一次乘法,无所谓;要是你把这里换成一段噪声采样,这处浪费就该挪进分支里了。

不过要注意 GLSL 里的分支代价:GPU 是 SIMD 执行的,一个 warp 里只要有一个片元走了另一条分支,两条路径都得跑。所以把 diffuse 挪进 if 之后,per 小的时候(几乎不 discard)省不到多少,per 大的时候(几乎全 discard)才省得到。收益是周期性的,不是恒定的。

第二步,dis = distance(st, vec2(0.5, 0.5))。

vec2(0.5, 0.5) 是硬编码的圆心。#91 用的是同样的写法,只是那里先声明了一个 center 变量再传给 distance,本篇直接把字面量写进了参数里。两种写法在 GLSL 里完全等价,区别只在可读性------本猿更推荐 #91 那种,因为圆心这个概念在本篇的推导里要反复出现。

顺带说一句,vec2(0.5, 0.5) 这个圆心是纹理空间 的圆心,不是地球上的圆心。本篇的椭圆是正圆,semiMajorAxis 与 semiMinorAxis 同值,所以两者重合;如果你把本篇改成椭圆(两个半轴不等),纹理空间里的圆心依然是 (0.5, 0.5),但它对应的形状就不再是正圆了。

具体会变成什么样?st 空间里的圆对应到长短轴不等的椭圆上,会被沿短轴方向压扁。所以本篇若改成椭圆,那个洞也会跟着变成椭圆------而洞和外框的形状始终是一致的(两者都是 dis 的等值线),不会出现「洞是圆的、外框是椭圆」这种错配。 片元到圆心的归一化距离,圆盘内 0 到 0.5。

第三步,per = fract(czm_frameNumber * speed / 1000.0)。

变量名从 #91 的 time 换成了 per,含义也从「时间」变成了「进度百分比」------这个改名其实是对的,因为 fract 之后的量确实是一个 0 到 1 的进度,而不是秒数。本篇里它没有再被送进 sin,而是直接当半径比例用,反倒避开了 #91 那个「数域错配」的坑。 节拍器还是老熟人 czm_frameNumber(掉帧就变速),fract 取小数部分,把 per 夹回 [0, 1)。

第四步,if (dis > per * 0.5)。 这是本篇全篇的开关。成立则留下、alpha 取 color.a;不成立则 discard。

per * 0.5 这个换算很干净:因为 dis 的上界就是 0.5,乘 0.5 把 [0, 1) 的 per 映射到 [0, 0.5),正好覆盖整个盘面半径。于是被丢弃的那个洞,半径(米)恰好等于 per × radius ------per 从 0 涨到 1,洞从 0 涨到整个盘子。

这条换算干净得有点可爱,本猿第一次算出来时还特意回头验了一遍:dis 的上界是 0.5,对应的米数是 radius;per * 0.5 对应的米数就是 per * radius。是的,就这么直接。于是本篇所有几何量都能用 per 一句话写出来:洞半径 per × radius、环宽 (1 - per) × radius、可见面积 1 - per²。

三条公式里最值得记的是最后一条。前两条是线性的,看一眼就懂;第三条是二次的,而二次正是本篇「前七成时间还剩一半」这个反直觉观感的全部来源。本猿建议在改这类效果之前,先把「可见面积随时间怎么变」这条曲线画出来------它往往比边界位置更能反映观感。

原因很简单:人眼对面积的敏感度远高于对半径的敏感度。一条边界匀速推进,你会觉得它「后半段突然变快」------其实速度一点没变,变的是单位时间损失的面积。这条错觉在很多扫描、填充、进度类动效里都会出现,值得记下来。

反过来说,如果你想让一个进度动效看起来匀速,就不能让边界匀速走------得让面积 匀速,也就是让半径按 √t 走,因为面积与半径的平方成正比。这是很多加载动画、水波填充都在用的手法,本篇恰好是它的反面教材。

换句话说,本篇的「逐渐」是线性的半径、二次的面积,看起来就会前半段慢、后半段快。想要观感上匀速消逝,把 per 换成 sqrt(per) 再送进阈值即可------一行的事,因为这样一来面积比就变成 1 - per,是标准的线性衰减。

整条数据流的形状见 图 1。

图 1 里值得留意的是那个分岔:判决之后只有两条路,一条把 alpha 设成 color.a 然后正常输出,另一条直接 discard。这两条路之间没有任何中间态------这正是本篇「没有渐变」的根源所在。这里先记一条本篇的独门之处:material.alpha = color.a。 前五篇(#88 到 #91)全都把 color.a 丢在一边不用,本篇是这条线里第一个真正把它读进来的。可惜的是,demo 里这个"复活"看不出来------原因我们下一节说。

先把结论摆在这儿:本篇的 color.a 在代码层面是被尊重的,只是 demo 喂给它的值永远是 1。这跟 #88 到 #91 那种「压根不读」是两回事------那种是改了也没用,本篇是改了有用、但面板不给机会改。

排查的办法很土但有效:在着色器里临时把 color.a 换成硬编码的 0.3 跑一次。如果画面立刻变半透明,说明链路是通的,问题在输入;如果没变化,那才是材质本身的事。一句话定位。

三、三个反直觉:它和我们脑子里想的差太多

反直觉一:不是变淡,是被挖空

本猿把这一条放在第一位,是因为它决定了后面两条的基调:既然透明度是个常数,那「消逝」就只能靠几何来演,而几何只有边界可动------于是第二条讲的「动的是内边界」就成了必然。

换句话说,本篇的三个反直觉不是三条独立的发现,而是一条因果链:discard 决定了没有渐变、没有渐变就只能靠几何演消逝、几何只有两条边界可动、本篇选了内边界。看清楚这条链,你就能预测任何类似着色器的行为,不用每次都从头跑一遍。

先看透明度。material.alpha = color.a------一个与位置无关、与时间无关的常数 。整段着色器里没有任何一处让 alpha 随 per 衰减。

再看 demo 里这个常数到底是多少。CircleFadeDemo.vue 传的颜色来自 Color.fromCssColorString(colorHex.value),而面板上那个 input[type="color"] 只能给出 6 位十六进制。本猿实测:

输入 解析出的 alpha
#ff4d4f(面板能给的唯一形态) 1.0
#ff4d4f80(8 位,面板给不出) 0.502
材质注册默认值 Color(1, 0, 0, 0.7) 0.7(demo 里用不上)
js 复制代码
// 面板只给 6 位十六进制,实测 Color.fromCssColorString 解出来的 alpha
const Cesium = require('../node_modules/cesium')
for (const s of ['#ff4d4f', '#ff4d4f80', '#ff4d4fcc', 'rgba(255,77,79,0.3)']) {
  const c = Cesium.Color.fromCssColorString(s)
  console.log(s.padEnd(22), '-> alpha =', c.alpha.toFixed(3))
}
// 6 位 hex 一律 alpha = 1;想让透明度可控,就得换控件、或者另设一根滑杆

也就是说在 demo 里,alpha 恒等于 1.0,整片光从头到尾完全不透明 。注册默认值里那个 0.7 只有在你自己构造 Color 时才生效,面板上根本够不着。

那「消逝」是怎么发生的?答案是 discard------把中间那块片元整个扔掉,扔掉的部分既不写颜色也不写深度,底图直接透出来。

js 复制代码
// 把 materials.ts 的十个 SOURCE 全抠出来,正则数 discard ------ 纯语法定量,不用跑渲染
const src = require('fs').readFileSync('src/cases/dc-effects-lib/materials.ts', 'utf8')
const blocks = src.match(/const [A-Z_]+_SOURCE = `[\s\S]*?`/g) || []
console.log('SOURCE 总数', blocks.length)
for (const b of blocks) {
  const name = b.match(/const ([A-Z_]+_SOURCE)/)[1]
  const hits = (b.match(/\bdiscard\b/g) || []).length
  const alphaConst = /material\.alpha\s*=\s*color\.a\s*;/.test(b)
  if (hits || alphaConst) {
    console.log('  ', name, '| discard ×', hits, '| alpha = color.a:', alphaConst)
  }
}
// 结果:十个里只有 CIRCLE_FADE_SOURCE 命中 discard,也只有它把 alpha 写成常数

扫的方法很简单:把 materials.ts 里每个 const XXX_SOURCE 块抠出来,正则搜 discard。结果是十个里只有一个命中。这个统计本身也说明了本篇在这条线里的特殊位置------它是唯一一个不靠「把值算到 0」来隐藏片元的。discard 只出现了一次,就在 CIRCLE_FADE_SOURCE 。它前面的九个、后面的所有圆,用的都是「把 alpha 算到 0」这种软办法;本篇是这条线里唯一一个动用 discard 硬扔片元的。

这两种做法的差别不只是写法:

  • alpha = 0(软):片元照常参与混合,只是混完之后等于没画。好处是能做出连续的过渡,代价是每个透明片元都要走一遍混合。
  • discard(硬) :片元直接不写。好处是省掉混合、洞是真洞;代价是边缘不可能有过渡------一个片元要么留、要么扔,没有中间态,而且它会让 GPU 的早期深度测试优化失效。

早期深度测试的原理是先比深度、后跑片元着色器,能提前剔除被挡住的片元;一旦着色器里出现 discard,GPU 就没法在跑完着色器之前确定这个片元到底会不会被扔掉,这项优化只能关掉。对一个铺满屏幕的大盘子来说,这个代价不算小。所以要不要用 discard 不是风格问题,是要权衡的。

具体到本篇的场景:一个半径 1500 m 的盘子,在 8000 m 的相机高度下大概占屏幕一小块,被 discard 掉的片元也不算多。所以本篇这个选择的代价其实可以接受。真正要小心的是把半径拉到 5000 m、或者同屏放好几个------那时候就该认真权衡了。

一个折中方案是把 discard 换成 alpha = 0 并配上 smoothstep 羽化:既拿回了早期深度测试,又能要软边,代价是多了逐片元混合。到底哪个快,得在你的实际场景里量------本猿没法给一个放之四海皆准的结论,这也是「实测纪律」存在的理由。

所以「逐渐消逝」这四个字里,「渐」是靠洞一圈圈扩大实现的几何渐变,而不是颜色上的渐变。颜色从来没淡过一帧。

如果你非要一个淡出的效果,改法只有一行,第六节第一条会给。但本猿想先说清楚一件事:本篇现在的写法并不是错的,它只是做的是另一件事。挖空和淡出是两种不同的语义------前者是「范围在收缩」,后者是「存在感在降低」。需求说清楚之前,别急着改。

本猿自己就在这上面栽过:拿到一个效果,第一反应是「这里写得不对,我改」,改完发现原作者要的本来就是另一种语义,白改。所以现在的流程是------先跑一遍、搞清它现在在做什么,再问「我要的是不是这个」,最后才动手。

反直觉二:先没的是圆心,外边界一动不动

这一条是本猿觉得本篇最有价值的部分,因为它给出了一个很实用的判断:这类效果只需要看不等式方向,就能知道动的是哪条边界。dis > 阈值 保留外部、dis < 阈值 保留内部,一眼的事,比盯着动画看半天准。

再补一层:只看不等式方向能定「动哪条边界」,但定不了「动得多快」。本篇的阈值是 per * 0.5,per 匀速,所以内边界匀速推进;#91 的阈值藏在 pow(a, 10) 里,边界位置是非线性的。想知道速度曲线,就得看阈值表达式对时间的导数------这一步通常得靠探针。

dis > per * 0.5 这个不等式,保留的是外面 、丢弃的是里面。于是:

  • 外边界 固定在 dis = 0.5,也就是半径 radius 处,从头到尾钉死不动;
  • 内边界(那个洞的边)从 0 一路涨到 0.5。

对照一下 #86 扩散圆:那篇动的是外边界,一圈光从圆心向外推,内边界基本不动。本篇正好反过来。于是两篇虽然都是「圆加 fract 加一圈光」,观感却完全不同------#86 是「东西在扩散」,本篇是「范围在失效」。做需求的时候,这两个语义不能互换。

再举一个更贴近业务的例子:做污染扩散预警,你要的是 #86 那种「范围在扩大」;做警戒圈解除,你要的是本篇这种「范围在收缩」。两个效果的代码差别可能只有不等式方向,但传达给用户的意思完全相反。

所以本猿的建议是:先写语义,再选效果。把你想要的语义写成一句话------「这个圈在收缩」「这个圈在扩张」「这个点在呼吸」------再按语义去翻 CASE_LIST 找对应的圆。反过来做,先看到个好看的效果再想它能表达什么,很容易选错。

js 复制代码
// _tmp/_probe92.cjs ------ 本篇全是一维闭式解:洞半径、环宽、面积都是 per 的显式函数
const speed = 3, R = 1500, FPS = 60
const fr = (x) => x - Math.floor(x)
for (const n of [0, 100, 167, 233, 300, 333]) {
  const per = fr(n * speed / 1000)
  console.log('帧', n, '| per', per.toFixed(3),
              '| 洞半径', (per * R).toFixed(1), 'm',
              '| 环宽', ((1 - per) * R).toFixed(1), 'm',
              '| 可见面积', ((1 - per * per) * 100).toFixed(2) + '%')
}
// 面积减半与半径、speed 都无关:1 - per^2 = 0.5 → per = 1/√2 = 0.7071
console.log('面积减半 per =', (1 / Math.SQRT2).toFixed(4),
            '| speed=3 时约第', (1000 * (1 / Math.SQRT2) / 3).toFixed(1), '帧')

换算成米(半径 1500 m、speed = 3、60 fps):洞半径 = per × 1500,环宽 = 1500 × (1 - per)。本猿把一个周期扫了一遍:

帧号 per 洞半径 环宽 可见面积
0 0.000 0.0 m 1500.0 m 100.00%
100 0.300 450.0 m 1050.0 m 91.00%
167 0.501 751.5 m 748.5 m 74.90%
233 0.699 1048.5 m 451.5 m 51.14%
300 0.900 1350.0 m 150.0 m 19.00%
333 0.999 1498.5 m 1.5 m 0.20%

洞的推进速度是每帧 radius × speed / 1000 = 4.5 m,也就是 270.0 m/s------匀速,不快不慢。

注意这个速度是线性的、匀速的,因为它就是 per 匀速增长的直接后果。本篇没有任何一处对这个进度做过缓动------没有 smoothstep,没有 pow,没有 easing。所以洞的扩张从头到尾一个速度,观感上会显得有点机械。想要先慢后快或者先快后慢,就得在 per 上再套一层映射。

最简单的映射是 pow(per, k):k 大于 1 就先慢后快,k 小于 1 就先快后慢。想要更精细的缓动,用 smoothstep(0.0, 1.0, per) 替换 per,洞就会两头慢、中间快------这是最常用的一条缓动曲线,代价只有一个函数调用。

但面积不是匀速掉的 。可见面积占比是 1 - per²(外圆面积减内洞面积,再除以外圆面积),是二次曲线。于是有几个反直觉的时刻:

可见面积 per 第几帧 第几秒
剩 75% 0.500 167 2.778 s
剩 50% 0.707 236 3.928 s
剩 19% 0.900 300 5.000 s
剩 10% 0.949 316 5.271 s
剩 1% 0.995 332 5.528 s

时间过去七成,面积还剩一半------因为外圈那圈面积本来就比内圈大得多。

这个 0.707 是怎么来的?解 1 - per² = 0.5 得 per = 1/√2 ≈ 0.7071。它跟半径无关、跟 speed 无关,是个纯几何常数------只要形状是「圆里挖同心圆」,这个数就永远是 0.7071。所以不管你怎么调滑杆,「前七成时间还剩一半面积」这个观感都改不掉,除非你换掉圆这个形状。

这也解释了为什么那个 0.7071 跟半径无关、跟 speed 也无关------它压根不依赖任何参数,只依赖「洞是外框的等比缩放」这一件事。能一眼看出某个数字是几何常数还是参数产物,是读这类代码最实用的能力。所以你盯着屏幕看,前三分之二的时间会觉得「好像没怎么消」,最后三秒突然就没了。这是二次曲线的典型观感,跟线性推进的洞半径完全不是一回事。

顺带说周期。speed 这个滑杆(1 到 20,步长 0.5)的名义含义还是那句:1000 帧内走几轮,就等于 speed 。speed = 3 就是 1000 帧三轮。但真重复周期在 1000 / speed 不是整数时是另一回事------speed = 3 时真周期是 1000 帧(16.667 秒) ,不是 333 帧;speed = 12 时真周期是 250 帧(4.167 秒),也不是 83 帧。这条与 #90/#91 同源,属于 fract 节拍器的通病。

还有一点值得单独拎出来:周期只由 speed 决定,与 radius 完全无关。

这条对调参很实用:你嫌它消得太快,只该动 speed;你嫌盘子太小,只该动 radius。两个旋钮互不干扰,不会出现「调了半径结果周期也变了」这种联动。这一点在 #91 里是不成立的------那篇的亮斑半径是 radius 的固定比例,动 radius 会连亮斑一起动。

更准确地说:两篇的可见特征都随 radius 等比缩放,但本篇的洞能占满整个盘子(0% 到 99.9%),所以 radius 就是「影响范围」本身,设多少就是多少;#91 的亮团永远只占盘子的 15.8% 到 24.66%,想让亮团有 500 m,得把 radius 反过来设到 3165 m 左右------中间这层换算很容易搞错。把半径从 300 m 拉到 5000 m,一轮始终是 5.556 秒;变的只是洞的推进速度(54 m/s → 900 m/s)和最后残留的那圈宽度。

反直觉三:亮度是均匀的,整片一起过曝 1.5 倍

第三条是三处里唯一一条「改与不改都不影响消逝节奏」的,所以可以放心单独调。它也是最容易被误判的一条------很多人看到颜色不对,第一反应是底图或者光照的问题,其实是 diffuse 越界了。

判断方法跟 #91 一样:把颜色三个通道的最大值拿出来跟 1/1.5 比。本篇的系数比 #91 温和,所以过曝没那么扎眼,但性质是一样的------凡是看到 diffuse 后面跟一个大于 1 的裸常数,第一反应就该是「它会不会越界」。

material.diffuse = 1.5 * color.rgb------没有 dis,没有 per,没有 pow。所以这片光没有中心亮、边缘暗那一说,从圆心到盘边是完完全全同一个亮度。

把两篇的 diffuse 并排放在一起看很有意思:#91 是 color.rgb * a * 3.0,本篇是 1.5 * color.rgb。前者乘的是一个随半径衰减的量,后者乘的是一个常数。就这么一点差别,一个出「中心亮到烧白的光斑」,一个出「一整块同样亮的色片」。这说明决定观感的不只是系数大小,更是这个系数乘在什么上面。

这也是本篇改造清单第三条要给 diffuse 加 min 的原因------加了 min 之后,系数就只能决定「有多亮」,不能再决定「哪里更亮」。想要后者,就得把系数挂到某个变量上。两件事得分开做,别指望一个系数同时解决。

具体到本篇:想让洞的边缘有一圈更亮的光,就把亮度挂到 dis 与 per * 0.5 的差值上;想让整片随 per 一起变暗,就把亮度挂到 per 上。两个需求对应两个不同的变量,各写一行,别混。

这是本猿改这类效果时最常犯的错:想同时解决两个问题,结果把两个变量乘进同一个系数里,改一个就动另一个,最后哪个都调不好。分开写,多两行代码,省一半调试时间。#91 的 diffuse = color.rgb * a * 3.0 里带着 a,亮度随半径一路衰减、中心最狠;本篇则是一整块平板。

js 复制代码
// 表的「亮度变化」按 Rec.709 相对亮度算:L = 0.2126R + 0.7152G + 0.0722B
const L = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
const clamp1 = (c) => c.map((v) => Math.min(v, 1))
const hex = (s) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255)
for (const [name, s] of [['红', '#ff4d4f'], ['蓝', '#2f80ed'], ['绿', '#42b983'],
                         ['白', '#ffffff'], ['暗蓝', '#0a1a34']]) {
  const c = hex(s)
  const g = c.map((v) => v * 1.5)      // ×1.5 增益
  const k = clamp1(g)                  // 显示管线把超过 1 的分量截掉
  console.log(name, s, '| ×1.5 后', g.map((v) => v.toFixed(3)).join(', '),
              '| 截到 1 后', k.map((v) => v.toFixed(3)).join(', '),
              '| 亮度变化', (L(k) / L(c)).toFixed(3) + '×')
}

1.5 这个系数会让亮色通道越界。本猿把几种常见颜色跑了一遍(表里的「亮度变化」按 Rec.709 相对亮度 0.2126R + 0.7152G + 0.0722B 算,不是三通道简单平均):

颜色 原始 rgb ×1.5 后 截到 1 后 被截通道 亮度变化
红 #ff4d4f(demo 默认) 1.000, 0.302, 0.310 1.500, 0.453, 0.465 1.000, 0.453, 0.465 1/3 1.264×
蓝 #2f80ed 0.184, 0.502, 0.929 0.276, 0.753, 1.394 0.276, 0.753, 1.000 1/3 1.439×
绿 #42b983 0.259, 0.725, 0.514 0.388, 1.088, 0.771 0.388, 1.000, 0.771 1/3 1.397×
白 #ffffff 1, 1, 1 1.5, 1.5, 1.5 1, 1, 1 3/3 1.000×
暗蓝 #0a1a34 0.039, 0.102, 0.204 0.059, 0.153, 0.306 0.059, 0.153, 0.306 0/3 1.500×

顺手给一个判断技巧:想知道某个颜色会不会被 1.5 拍平,只要看它的最大通道值------大于 1/1.5 ≈ 0.667 就会被截。红的 R 是 1.0、蓝的 B 是 0.929、绿的 G 是 0.725,三个都超过 0.667,所以都是 1/3 通道被截;暗蓝的最大通道只有 0.204,一个都不截。

规律和 #91 一样:越亮的颜色越容易烧白,越暗的颜色越是纯提亮。白色三个通道全被截,亮度变化 1.000 倍------也就是说选白色时,1.5 这个增益等于完全没起作用。

差别在于幅度:#91 用的是 * 3.0,本篇是 * 1.5,过曝温和了一半(亮度比在 1.0 到 1.5 之间,而 #91 是 1.0 到 3.0)。但两者都没有 min 兜底,超过 1 的部分照样是被显示管线悄悄截掉的。

补一句:本篇的过曝虽然存在,但因为它是一片均匀的,视觉上并不刺眼------你只会觉得「这片光挺艳」,不会觉得「中心有个白斑」。#91 那种中心最亮、边缘最暗的分布才会暴露过曝。所以同样是越界,均匀分布的容忍度要高得多。

还有一件小事:它理论上永远不会完全消失

fract 取的是小数部分,永远取不到 1 。本猿扫了 20000 帧,per 的最大值是 0.999。

于是洞半径最大只到 0.999 × radius,盘面上永远留着一圈 radius × 0.001 宽的环------半径 1500 m 时是 1.5 m,半径 5000 m 时是 5 m。在 8000 m 的相机高度下,这点宽度不到一个像素,肉眼确实看不见。但数学上它一直在那儿。

这个「永远留一圈」的问题,本质上还是 fract 的半开区间造成的------它跟 #90/#91 里那道断点是同一个源头。区别在于:那两篇断的是「大小」,本篇断的是「存不存在」,所以本篇的断点更刺眼------从几乎全空到整个实心盘,一帧之间。

真正刺激的是下一帧:per 复位到 0 附近,那一圈 1.5 m 的环瞬间变回一整个 1500 m 的实心盘。这是本篇幅度最大的一次跳变,而且和 #91 正好反过来------#91 是慢慢涨大、一帧缩回;本篇是慢慢被挖空、一帧填满。

本猿把这两篇的跳变放在一起看,发现了一个挺有意思的对称:#91 的跳变是收缩,本篇的跳变是回填;一个让你觉得光团被抽走,一个让你觉得盘子突然长回来。两者的观感都不太舒服,但都可以通过在 per 接近边界时加一段过渡来缓解。

四、和模糊圆比:一个在缩小,一个在挖空

本猿习惯在每篇里留一节横向对照。这条线从 #86 写到 #92,七个圆;如果只看名字,它们像是七种完全不同的东西。可把着色器并排摆开,你会发现它们是同一套坐标系、同一套节拍器、同一批数学工具的不同组合。

#91 和本篇是这条线上挨得最近的两个,装配一模一样,连 fract 节拍器和默认值都相同。但它们让光"变少"的方式,是两条完全不同的路。

#91 模糊圆 #92 消逝圆
让光变少的手段 pow(a, 10) 压低 alpha discard 直接扔片元
透明度 随半径连续衰减(有渐变) 常数 color.a,无渐变
动的是哪条边界 外边界(光团半径) 内边界(洞的半径),外边界钉死
亮度分布 中心亮、边缘暗 整片均匀
过曝系数 * 3.0 * 1.5
color.a 完全没用 读进来了(但 demo 里恒为 1)

一句话概括:#91 是把光团缩小,本篇是把中心挖空。

再往前推一步:这条线上所有让光变少的效果,其实只有三种手法------把外边界往内收(#91)、把内边界往外推(本篇)、把亮度整体压低(#90 的暗场)。三种手法对应三种语义,选错了语义,再怎么调参数也调不出想要的感觉。

这也决定了它们各自适合什么场景。要做「一个点在那里轻轻呼吸」,用 #91(前提是修掉锯齿);要做「一圈范围从内向外失效」,比如信号覆盖收缩、影响范围衰减,本篇更贴切------因为它的外边界是稳定的,观感上「范围」这个概念不会乱跑。

这里补一句本篇独有的代价:discard 会让早期深度测试失效,而 #91 用的 alpha = 0 不会。所以在盘子面积很大、或者同屏有好几个消逝圆的时候,本篇的填充开销会比同尺寸的模糊圆高一些。这是「洞是真洞」要付的价钱。节拍都是 czm_frameNumber,掉帧就变速;fract 都把 per 夹在 [0, 1),不会像 #88/#89 那样挂机久了让 float32 退化;几何都是静态的,改颜色、速度、半径里只有半径会触发几何重建。

五、装配:和前几篇一模一样

这一节对追更的读者是复习,对第一次点进来的读者则是「这一整套东西到底是怎么搭起来的」。本猿尽量写得能独立看懂,不依赖前几篇。

本篇的装配代码和 #88 到 #91 逐字相同,本猿逐行比对过,一个字符都没变。这不是偷懒------这条线的装配本来就该是稳定的,变的只有材质。如果你的项目里已经接过前一个圆,本篇只要换一个 type 字符串就能用。

案例目录 src/cases/circle-fade/,index.ts 里注册 id: 'circle-fade'、title: '三维特效-圆(逐渐消逝)'、category: 'effects'、tag: '逐渐消逝'、updatedAt: '2026-08-31',与 CASE_LIST.md 第 92 行一字不差。

CircleFadeDemo.vue 和 #88 到 #91 是同一个模子刻出来的:

ts 复制代码
const colorHex = ref('#ff4d4f')
const speed = ref(3)
const radius = ref(1500)
const height = ref(0)

function place(lon: number, lat: number): void {
  if (!viewer || viewer.isDestroyed()) return
  removeDcEffectEntity(viewer, CIRCLE_FADE_TYPE)
  createDcEffectEntity(viewer, {
    type: CIRCLE_FADE_TYPE,
    lon,
    lat,
    height: height.value,
    radius: radius.value,
    color: Color.fromCssColorString(colorHex.value),
    speed: speed.value
  })
}
ts 复制代码
// 装配接线:几何与材质分两条路,改半径重建几何、改颜色/速度只挪 uniform
function updateEffect(): void {
  const entity = viewer.entities.getById(`dc-effect-${CIRCLE_FADE_TYPE}`)
  if (!entity) { place(DEFAULT_LON, DEFAULT_LAT); return }
  updateDcEffectEntity(viewer, CIRCLE_FADE_TYPE, {
    radius: radius.value, height: height.value,
    color: Color.fromCssColorString(colorHex.value),   // alpha 恒为 1,见第三节
    speed: speed.value
  })
}
watch([colorHex, speed, radius, height], () => { updateEffect() })

createDcEffectEntity(dc-effects-lib/entities.ts)这一侧也一字未改:一个 entity、id 固定 dc-effect-CircleFadeMaterial、正圆椭圆(semiMajorAxis 与 semiMinorAxis 同值)、height 走 ellipse.height、material 是 DcCircleMaterialProperty。所有属性都是 ConstantProperty,没有任何 CallbackProperty,也没有 stRotation------几何建一次就再也不动,每帧变化的只有材质里那两个 uniform。

这两个 uniform 一个叫 color(vec4)、一个叫 speed(float),加起来五个浮点数------这就是本篇每帧从 CPU 送到 GPU 的全部数据量。注意本篇的 color 是完整参与的:color.rgb 进 diffuse,color.a 进 alpha,一个分量都没浪费。

speed 的默认值在本篇同样三处一致 :demo 的 ref(3)、materials.ts 注册的 speed: 3、createDcEffectEntity 的兜底 options.speed ?? 3。这已经是连续第二篇一致了(#91 也是 3/3/3),而 #90 是三处打架的 8 / 12 / 3。

本猿的建议还是那句:拿到一个效果,先把三处默认值对齐再开始调。否则你会遇到「面板显示 3、实际按 12 跑」这种查不出原因的怪事------尤其是当你只抄了 materials.ts 而没抄 demo 的时候。

放置交互还是老三样:开「放置」开关 → LEFT_CLICK → pickPosition 三级拾取(scene.pickPosition → globe.pick → camera.pickEllipsoid)→ 拿到经纬度后重建实体。

注意 place 里是先 remove 再 create,而不是 update。这意味着每次点击都会走一遍「销毁几何 + 重建几何」的完整流程。对单个实体来说这点开销无所谓,但如果你要做「点一次加一个」的多实例场景,这里就得改成增量添加,并且给每个实例一个唯一 id------现在的 id 是 dc-effect-${type} 写死的,同类型只能存在一个。

六、把它改成一片真正会淡出的光

最后这一节是本篇的可带走部分。前五节是拆解,这一节是改造,每一条都给出改哪个位置、改成什么样、以及改完会得到什么。

第一,让淡出真的是淡出。 把常数 alpha 换成随 per 衰减:

glsl 复制代码
material.alpha = color.a * (1.0 - per);

这一行加上之后,整片光会真的从 1 均匀降到 0。注意这时候就别再 discard 了------想让洞和淡出同时存在,把两者相乘即可:

step(per * 0.5, dis) 返回 0 或 1,等价于原来的 if 判断,好处是可以和其他因子连乘。相乘之后,per 小的时候整片接近 color.a、per 大的时候整体变淡,洞则照常扩张------两种语义叠在一起,观感上会丰富不少。

glsl 复制代码
float keep = step(per * 0.5, dis);          // 洞外的部分留着
material.alpha = color.a * keep * (1.0 - per * 0.6);

第二,给洞的边缘加个过渡。 discard 做不出柔和的边界,要柔边就得换成 alpha:

glsl 复制代码
material.alpha = color.a * smoothstep(per * 0.5, per * 0.5 + 0.08, dis);

那个 0.08 是过渡带的宽度,单位是 st 距离(0.5 对应整个盘面半径),换算过来大约是盘面半径的 16%。调大它,洞的边界就柔和;调到 0,就退回本篇的硬边。

换算一下:0.08 的过渡带对应 0.08 / 0.5 = 16% 的盘面半径,半径 1500 m 时是 240 m,这个宽度肉眼很明显。本猿试下来,0.03 到 0.08 之间比较自然------再小就退回硬边,再大就糊成一团、看不出那是个洞了。代价是放弃了 discard,每个透明片元都要走一遍混合。

第三,给 diffuse 加个上限。 1.5 那个增益没有兜底:

glsl 复制代码
material.diffuse = min(1.5 * color.rgb, vec3(1.0));

第四,让 color.a 真的可调。 面板上的 input[type="color"] 只给 6 位十六进制,fromCssColorString 解出来 alpha 恒为 1。想让透明度变成可用参数,要么把输入框换成能填 8 位色值的文本框,要么单独加一个「不透明度」滑杆,把值写进 Color 的 alpha 分量。

本猿更推荐单开一个滑杆。理由很实在:8 位十六进制色值对大多数人来说是陌生的,而且它会把颜色和透明度混在一个控件里,改一个就得小心别动另一个。分开之后,两个语义各自独立,调试时也能一眼看出是哪个在起作用。

第五,修掉「永远留一圈」。 既然 fract 取不到 1,就在接近 1 时手动收尾:

glsl 复制代码
if (per > 0.98) { discard; }

这一句要让它在最前面执行。加上之后,每个周期末尾会有一段真正的空场,观感上「消逝」才完整。

阈值取多少取决于你的场景。0.98 意味着最后 2% 的周期(speed = 3 时约 0.11 秒)是空的;想让空场更长就往下调,比如 0.9 会给出约 0.56 秒的空场。注意别调太低,否则每轮里有一大段时间什么都没有,节奏会显得拖沓。

第六,面板上的周期别算错。 speed 名义上是「1000 帧走几轮」,但真周期在 1000 / speed 不是整数时是另一回事(speed = 3 → 16.667 秒;speed = 12 → 4.167 秒)。要显示「一轮几秒」,得找最小的 N 使 N * speed / 1000 为整数,再用 N / 60,直接写 1000 / speed / 60 会对不上。

speed = 3 时,1000 / 3 / 60 = 5.556 秒是一轮的时长,而真周期是 1000 / 60 = 16.667 秒------差了三倍。如果你想在界面上标注「完整循环一次需要多久」,该用后者。

glsl 复制代码
// 改造版:真的会淡出、边界柔和、亮度有上限、color.a 可调
uniform vec4  color;
uniform float speed;
uniform float edge;              // 新增:洞边的羽化宽度(st 距离,0.5 = 整个盘面半径)

czm_material czm_getMaterial(czm_materialInput materialInput) {
  czm_material material = czm_getDefaultMaterial(materialInput);
  vec2  st  = materialInput.st;
  float dis = distance(st, vec2(0.5, 0.5));
  float per = fract(czm_frameNumber * speed / 1000.0);
  if (per > 0.98) { discard; }                        // ① 补上真正的空场,消逝才完整
  material.diffuse = min(1.5 * color.rgb, vec3(1.0)); // ② 加上限,不再被管线悄悄截断
  // ③ 用 smoothstep 取代 if/discard,洞边有了羽化;④ 再叠一层整体淡出
  material.alpha = color.a
                 * smoothstep(per * 0.5, per * 0.5 + edge, dis)
                 * (1.0 - per);
  return material;
}

把这几条合起来,你会得到一片真的在淡出、边界柔和、亮度可控的光------到那时候,它才配得上「逐渐消逝」这四个字。

当然,你也可以选择不改。本篇现在的写法有个独特的价值:洞是真洞 。底图、地形、甚至别的特效都能从洞里透出来,这是 alpha 渐变做不到的------渐变再低也是一层膜。要做「范围失效」这类语义,本篇的原样反而是最优解。

写到这里,这一篇想交给你的东西其实很朴素:一段十八行的社区着色器,靠实测拆出了三处名字里读不出来的行为------不是变淡而是挖空、不是整体缩小而是内边界推进、不是有渐变而是一片均匀。

把这三处反直觉翻过来,又是一份「审查任何社区着色器」的清单:第一问,「消失」是怎么实现的 ------看到 discard 就要明白边缘不可能有过渡,看到 alpha = 0 才可能是软边,两者不能混着期待;

这一问是本篇新加的,前几篇的清单里没有------因为前几篇全都是 alpha 路线,只有本篇碰上了 discard。顺着这条往下想,你会发现一个规律:凡是追求硬边界的效果,都倾向于用 discard;凡是追求柔和过渡的,都倾向于用 alpha。前者像剪纸,后者像喷枪。第二问,动的是哪条边界 ------把不等式方向看清楚,是保留内部还是保留外部,这决定了观感是「缩小」还是「挖空」;第三问,输出项里有没有位置/时间变量 ------diffuse 里不含 dis 就是整片均匀,含了才有渐变。

讲到这里,#92 该交代的都交代了。它是这条线里唯一动用 discard 的一个,也是把「名字 vs 数学」写得最直接的一个:名字说「淡出」,实测一片都没淡过;你以为整片在缩小,实际是圆心先没了;你以为中心最亮,实际整片一样亮。三处反直觉,三处都是「数学说了算、名字靠边站」。

本猿写完这一篇,回头再看自己上篇那句「淡出」的预告,倒也不觉得太尴尬------因为它恰好证明了这条线的价值:名字给的是一幅画,代码写的是另一幅,而我们的活儿就是把两幅画的差别量出来。这一篇量出来的差别,比前面几篇都大。

「圆」这条母题到这里也就告一段落了。下一篇,我们按 CASE_LIST 的序号换一条线------第 93 号是深度图洪水模拟,从「在盘子上画光」回到「让水往低处漫」。


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

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

代码在 src/cases/circle-fade/ 目录下,核心就一个 CircleFadeDemo.vue。搬去自己项目时,盯住 materials.ts 里的 CIRCLE_FADE_SOURCE------那短短十八行就是全部;装配侧的 createDcEffectEntity 与 #88~#91 完全共用,换个 type 就能换效果。


第92篇 洞自圆心向外漫,边留盘畔不曾还;

面积随方二次断,一帧填满旧时颜;

名说渐消偏不淡,量完才见被挖残。

相关推荐
vjmap3 小时前
AI 地图动画-以真实地图为画布的 AI 视频编辑器-唯杰 AI 地图动画正式上线
人工智能·编辑器·音视频·gis·cad
普通人63 小时前
CSS-box-sizing-使用指南
开发语言·前端·css·1024程序员节
女神下凡3 小时前
芯参谋(83):AliOS Things_驱动开发指南
arm开发·驱动开发·单片机·嵌入式硬件·设计规范·1024程序员节
鬼手点金4 小时前
Skills:领域能力的可组合单元
java·人工智能·笔记·自动化·1024程序员节
总要学点什么13 小时前
第89篇:三维特效-螺旋圆效果——让螺旋臂卷起一圈圈外扩的涟漪
开发语言·javascript·ecmascript·webgl·cesium·空间分析·1024程序员节
喵了几个咪15 小时前
AI 时代的危与机:当潮水退去,技术人如何自处
1024程序员节
LMT流氓兔17 小时前
第五单元——第十六课:给书店助手加上网页界面
java·开发语言·jvm·人工智能·oracle·langchain·1024程序员节
vjmap18 小时前
Cesium + MCP:在 Cursor、Claude Code 里一句话生成地图视频,唯杰 AI 地图动画上线
gis·cesium·mcp
开开心心就好18 小时前
音频降噪剪辑工具,音量裁剪一键处理
运维·服务器·前端·spring·ocr·音视频·1024程序员节