第47篇:三维特效-雷达波效果——波纹一圈圈往外推,一半却埋在地下

上回那堵会流动的墙,本猿在结尾留了三笔账:条纹沿墙长没有变化、速度随帧率漂移、参数改动要走全量重建。

这堵墙当然还不完美:条纹沿墙长没有变化、速度随帧率漂移、参数改动要走全量重建。但它是我们理解「着色器里的时间与坐标」最好的一块标本。

今天换个更花哨的------雷达波锥。三维特效线第二篇,主角从"贴在地上的一面"换成"立在空中的一个锥",动画从"贴图平移"换成"shader 里算出来的同心圆"。

本猿本来以为这篇要量的只是"波纹跑多快"。结果一读 CylinderGeometry 的 st 生成代码,发现了一件更根本的事:这个锥体的纹理坐标压根没算高度 。它把整个锥面投影到了 XY 平面------也就是说,波纹不是沿锥面斜着排的,而是一圈圈水平等高圆 ;再顺着往下推,锥体以实体位置为中心上下各撑一半,于是一半的波纹埋在椭球面以下,永远看不见。

我是 Cesium酱,一只在 WebGIS 领域摸爬滚打多年的前端猿。这个系列写到第 47 篇,规矩没变:只要面板上有数字,就得拿进 GPU 里对一遍账。牛马不易,忙忙停停,但每次量出"原来你算的是这个",那点兴奋劲还在。

本文目标 :从一个雷达波锥出发,把 CylinderGeometry 的 st 语义、自定义 MaterialProperty 的注册与每帧求值、以及"改一个滑块到底重建了多少东西"这三笔账一次算清。💪

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


一、痛点与场景:雷达锥为什么值得单写一篇?

🎯 背景现状:在三维场景里画一个"往外发射的电磁波",是信号覆盖、探测范围、扫描演示、威胁半径这些需求的通用表达。它比一个圆多一个高度维度,比一根线多一个"扇形张开"的语义------一眼就能看出"源在哪、朝哪、能打多远"。

典型场景:

  • 📡 信号覆盖:基站的覆盖锥,锥底半径就是覆盖半径,锥长就是挂高
  • 🛰️ 探测范围:雷达 / 光电设备的探测锥,配合方位角表示"当前朝哪扫"
  • ⚠️ 威胁半径:干扰源、禁飞区的作用范围,要求远远就能看见一层流动的能量壳
  • 🎬 态势演示:指挥类大屏上的"扫描特效",要能动、要好看、参数要能现场调

🤦 传统痛点:

方案 问题
Ellipse / Circle 贴地面 只有"地面的圈",没有锥体;看不出高度与张开角
PolylineVolume 自己挤一圈线 线没有面,st 沿周长 + 线长,同心圆根本排不出来
Entity + CylinderGraphics + 内置材质 内置那几种材质(Color / Image / Stripe...)做不出"随时间推进的同心波纹"
Primitive + 自定义 Material 能用,但丢了 Entity 的坐标系管理(位置 / 朝向 / 拾取全要自己算)

💡 本文方案:

1️⃣ Entity + CylinderGraphics 出几何 :topRadius: 0 直接得到锥体,位置与朝向交给 Entity 管

2️⃣ MaterialProperty 子类推时间 :实现 getType / getValue / isConstant 三件套,getValue 每帧被 Cesium 回调,time uniform 自己往前走

3️⃣ 注册到 Material._materialCache :让 Material.fromType('RadarPrimitiveMaterial') 能找到我们的 fabric,从而接进 Entity 的材质体系

✨ 一句话总结:几何交给 Entity,动画交给 MaterialProperty,剩下的"同心圆"在 shader 里用一行 distance(st, vec2(0.5)) 算出来。


二、核心技术原理:MaterialProperty 是怎么被每帧调用的

2.1 三件套与"注册"

Cesium 的 Entity 材质体系只认 MaterialProperty。想让一个自定义 fabric 走进来,得走两步:

ts 复制代码
// radar-lib/radar-material.ts(节选)
export const RADAR_PRIMITIVE_TYPE = 'RadarPrimitiveMaterial'

export class RadarPrimitiveMaterialProperty {
  get isConstant () { return false }        // 🔑 非常量 → Cesium 每帧重新求值
  getType () { return RADAR_PRIMITIVE_TYPE }

  getValue (time: unknown, result?: Record<string, unknown>) {
    if (!defined(result)) result = {}
    const now = new Date().getTime()        // 🔑 墙钟,不是传进来的 time
    result.color = Color.clone(this.color, result.color as Color | undefined)
    result.time = ((now - this._time) % this._duration) / this._duration / 10
    result.repeat = this._repeat
    result.offset = this._offset
    result.thickness = this._thickness
    return result
  }
}

export function registerRadarMaterial (): void {
  const cache = (Material as unknown as { _materialCache: { getMaterial: Function; addMaterial: Function } })._materialCache
  if (cache.getMaterial(RADAR_PRIMITIVE_TYPE)) return
  cache.addMaterial(RADAR_PRIMITIVE_TYPE, { fabric: { ... }, translucent: () => true })
}

🔑 关键点说明:

  • getType() 返回的字符串会被 MaterialProperty.getValue(未压缩产物 60409-60421 行)拿去 Material.fromType(type)。不注册就炸 ,实测报错原文是:material with type 'RadarPrimitiveMaterial' does not exist.
  • _materialCache 是 私有 API (Material._materialCache),本猿实测 getMaterial / addMaterial 两个方法都还在,但它是进程级全局------升级 Cesium 一旦改名就是静默失效,不是报错
  • isConstant = false 是能让动画跑起来的关键。但要注意:CylinderGeometryUpdater.prototype._isDynamic(171057-171066 行)判断"要不要走动态批次"时,只查 position / orientation / length / topRadius / bottomRadius / slices / outlineWidth / numberOfVerticalLines,压根没看材质 ------所以本 demo 走的是静态批次,不会每帧重建 Primitive(后面第五节会算这笔账)

2.2 完整流程

核心亮点:第 4 步"每帧调 getValue 推 uniform "是本文的命门 🔪------静态批次下 Batch2.prototype.update(187853-187864 行)每帧都会 MaterialProperty.getValue(...),所以 time 能一直往前走;但任何一次属性 setter 都会把整批打回重建(第 5 步),这是第五节的主角。

2.3 CylinderGeometry 的 st:只算 x/y,完全不算 z

这一节是本文最反直觉的地方。本猿一开始默认"圆柱的 st 应该是(方位角, 高度)",实测打脸。源码在 CylinderGeometry.createGeometry(未压缩产物 170664-170672 行):

js 复制代码
const rad = Math.max(topRadius, bottomRadius);
for (i = 0; i < numVertices; i++) {
  const position = Cartesian3.fromArray(positions, i * 3, positionScratch8);
  st[textureCoordIndex++] = (position.x + rad) / (2 * rad);
  st[textureCoordIndex++] = (position.y + rad) / (2 * rad);
}

st 就是局部坐标的 x/y 归一化,z(也就是沿轴的高度)一次都没参与。

本猿按这个公式反推了全部 512 个顶点的 st,与实测值的最大偏差是 2.89e-8------公式成立。由此得到两个直接推论:

  1. 锥面与底盖共用同一套 UV 。锥面上某点和底盖上水平位置相同的点,st 完全一样
  2. dis = distance(st, vec2(0.5)) 就是"到轴线的水平距离 ÷ (2·rad)" 。实测反推偏差 3.36e-8

所以 shader 里那句 float dis = distance(st, vec2(0.5)); 量的是归一化水平半径 ,波纹是一圈圈水平等高圆,不是沿锥面斜向排列的条纹。

顶点分组 z(沿轴,m) 水平半径 r(m) 顶点数 该处 dis
锥底外缘 −1000 1000 256 0.5000
锥尖 +1000 0 256 0.0000

(length = 2000、bottomRadius = 1000、topRadius = 0、slices = 128,共 512 顶点 / 1524 index / 508 三角面)


三、代码实战:三件事撑起一个雷达锥

⚠️ 温馨提示:为了方便,本篇只拆"材质注册 → 几何装配 → 参数回刷"这条主链路,点击定位复用第 40 篇量过的 pickPosition 三级降级,不再重复。

3.1 造实体

ts 复制代码
function createRadar (lon: number, lat: number): void {
  const position = Cartesian3.fromDegrees(lon, lat, 0)
  const orientation = Transforms.headingPitchRollQuaternion(
    position,
    new HeadingPitchRoll(CesiumMath.toRadians(headingDeg.value), 0, 0)
  )
  radarEntity = viewer.entities.add({
    position,
    orientation,
    cylinder: {
      length: length.value,          // 2000
      topRadius: 0,                  // 🔑 锥体
      bottomRadius: bottomRadius.value, // 1000
      material: makeMaterial()
    }
  })
  radarCount.value = 1
}

🔑 关键点说明:

  • topRadius: 0 让 CylinderGeometry 退化成锥体。实测 508 个三角面里有 254 个是零面积的退化面(锥尖那圈 + 半径 0 的顶盖),占了整整一半
  • 几何以实体位置为中心 :z ∈ [−1000, +1000]。宽端(r=1000)在 z = −1000 ,尖端在 z = +1000 。实体放在 height = 0,于是锥体从 −1000 m 撑到 +1000 m,一半埋在椭球面以下
  • orientation 用 headingPitchRollQuaternion 只传了 heading,pitch / roll 都是 0 ------ 第六节会量这个 heading 到底有没有用

3.2 二十二行 GLSL

glsl 复制代码
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, 0.5]
  float sp = 1.0 / repeat;               // 相邻波纹的 dis 间距

  float m = mod(dis + offset - time, sp);
  float edgeWidth = 0.02;                // ⚠️ 绝对值,不随 sp 缩放
  float edge = sp * (1.0 - thickness);   // ⚠️ thickness 越大,亮带越窄
  float a = 1.0 - smoothstep(edge - edgeWidth, edge, m);

  float distFade = pow(1.0 - dis, 1.5);
  float pulse = 0.5 + 0.5 * sin(time * 60.0);
  vec3 baseColor = color.rgb;
  vec3 edgeColor = baseColor * 1.8;
  vec3 finalColor = mix(baseColor, edgeColor, dis * 2.0);
  finalColor *= 1.0 + 0.2 * sin(time * 30.0 + dis * 10.0);

  material.diffuse = finalColor;                          // ⚠️ 没乘 a
  material.emission = finalColor * a * distFade * 0.6;
  material.alpha = a * color.a * (0.7 + 0.3 * pulse);
  material.shininess = 80.0;
  return material;
}

🔑 关键点说明:

  • material.diffuse 没有乘 a ------整片锥面都铺满 finalColor,暗缝靠 alpha 变透明抠出来,而不是靠"不画"
  • finalColor = mix(baseColor, baseColor * 1.8, dis * 2.0),dis 最大 0.5,所以锥底边缘恰好取满 1.8 倍色
  • edgeWidth = 0.02 是绝对值 ,而 sp = 1/repeat 会随滑块变化 ------ 这两个量的比例决定了亮环边缘的软硬,第四节 4.3 会算

3.3 参数回刷

ts 复制代码
watch([colorHex, headingDeg, length, bottomRadius, thickness, repeat, duration, offset], () => {
  if (viewer && !viewer.isDestroyed()) updateRadar()
})

function updateRadar (): void {
  for (const entity of viewer.entities.values) {
    const cylinder = entity.cylinder
    if (!cylinder || !(cylinder.material instanceof RadarPrimitiveMaterialProperty)) continue
    m.color = Color.fromCssColorString(colorHex.value)
    m.duration = duration.value
    m.repeat = repeat.value
    m.offset = offset.value
    m.thickness = thickness.value
    cylinder.length = length.value
    cylinder.bottomRadius = bottomRadius.value
    // ... 同步 orientation
  }
}

🔑 关键点说明:

  • 八个量里,五个走材质(颜色 / 周期 / 数量 / 偏移 / 厚度),三个走几何(方向角 / 锥长 / 底半径)
  • 五个材质 setter 每一个都会 raiseEvent(this._definitionChanged)------而 Cesium 在 Batch2 的构造函数里就挂了监听(187730-187738 行):
js 复制代码
this.removeMaterialSubscription = materialProperty.definitionChanged.addEventListener(
  Batch2.prototype.onMaterialChanged, this
);
Batch2.prototype.onMaterialChanged = function () { this.invalidated = true; };

一次 setter = 一次 invalidated = true = 整个 Primitive 打回重建。 第五节算这笔账。


四、参数实测:面板上的数字 vs GPU 里的数字

4.1 dis 是"归一化水平半径",波纹是水平等高圆

把 512 个顶点全量反推一遍,dis 与"水平半径 ÷ (2·rad)"的最大偏差 3.36e-8 ;dis 的取值范围是 0, 0.5,不是很多人以为的 0, 0.707。

由此得到本篇最要紧的一张表:

位置 z(m) 水平半径(m) dis distFade = pow(1−dis, 1.5)
锥尖 +1000 0 0.000 1.0000
z = +750 +750 125 0.125 0.8185
地面 0 500 0.250 0.6495
z = −750 −750 875 0.375 0.4941
锥底外缘 −1000 1000 0.500 0.3536

地面(z = 0)恰好落在 dis = 0.25。 按 repeat = 15(sp = 1/15)折算:

  • 整个锥面能排 0.5 × 15 = 7.50 圈 波纹
  • 地面以上只有 0.25 × 15 = 3.75 圈
  • 另外 3.75 圈整整齐齐埋在椭球面以下,一个像素都看不见

而且 distFade 也跟着被砍:可见部分的亮度系数只在 0.6495, 1.0 之间,源码里写的 0.3536 那一段(锥底外缘)同样在地下。

4.2 "波纹厚度"调大,亮环反而变细

源码第 21-23 行:

glsl 复制代码
float edge = sp * (1.0 - thickness);
float a = 1.0 - smoothstep(edge - edgeWidth, edge, m);

a = 1 的区间是 m ∈ [0, edge),所以亮带占一个周期的比例 = 1 − thickness。实测:

thickness 亮带占比 实际观感
0.10 90% 整片几乎全亮,看不出条纹
0.20 80% 整片几乎全亮
0.40(默认) 60% 亮暗各半
0.60 40% 亮暗各半
0.80 20% 细亮环 + 宽暗缝
0.90 10% 极细亮环

滑块叫"波纹厚度",但往右拖是让亮环变窄、暗缝变宽。 想要"更粗的波纹",得往左拖。

4.3 edgeWidth = 0.02 是绝对值,repeat 一拉大就把亮环抹平

edgeWidth 不随 sp 缩放,于是 repeat 越大,这 0.02 的渐变带在亮环里占的比例越高:

repeat sp = 1/repeat edge = 0.6·sp 渐变占亮带 m=0 处 alpha
2 0.5000 0.3000 7% 1.0000
15(默认) 0.0667 0.0400 50% 1.0000
30 0.0333 0.0200 100% 1.0000
50 0.0200 0.0120 100% 0.6480

thickness = 0.9(edge = 0.1·sp)时更极端:

repeat edge m=0 处 alpha
10 0.01000 0.5000
15 0.00667 0.2593
30 0.00333 0.0741
50 0.00200 0.0280

repeat = 50 + thickness = 0.9 时,亮环峰值只剩 0.0280,肉眼基本看不见了。 这不是"波纹变细",是整个特效消失了。

4.4 波纹圈数与锥体尺寸无关,只跟 repeat 有关

因为 dis 的最大值恒为 0.5(topRadius = 0 时 rad = bottomRadius,锥底边缘 dis = bottomRadius / (2·bottomRadius) = 0.5),所以:

repeat 全锥圈数 地面以上可见 相邻圈水平间距 Δr = 2R/repeat 沿锥面斜距 = 2√(R²+L²)/repeat
2 1.00 0.50 1000.00 m 2236.07 m
5 2.50 1.25 400.00 m 894.43 m
10 5.00 2.50 200.00 m 447.21 m
15(默认) 7.50 3.75 133.33 m 298.14 m
30 15.00 7.50 66.67 m 149.07 m
50 25.00 12.50 40.00 m 89.44 m

通式:沿锥面的物理间距 = 2√(R² + L²) / repeat(R = 底半径,L = 锥长)。

于是"锥体长度"滑块的真实作用是:一个圈数都不加,只把已有的圈沿斜面拉得更开。

锥体长度 圈数 沿锥面斜距
200 m 7.50 135.97 m
2000 m(默认) 7.50 298.14 m
8000 m 7.50 1074.97 m

4.5 time 只走 0→0.1,每 2 秒整批波纹跳半格

result.time = ((now − t0) % duration) / duration / 10 ------ 注意那个 /10 :一个扫描周期里 time 只从 0 走到 0.1,不是 0→1。

m = mod(dis + offset − time, sp),time 增大时保持 m 不变需要 dis 增大 → 波纹从锥尖往锥底跑 。一个周期推进 0.1 的 dis,折合 0.1 × repeat 个波纹周期:

repeat 每周期推进圈数 整数? 观感
10 1.00 是 首尾相接,看不出跳变
15(默认) 1.50 否 每 2 秒整批波纹瞬移半圈
20 2.00 是 无缝
25 2.50 否 每周期跳半圈
30 / 40 / 50 3.00 / 4.00 / 5.00 是 无缝

只有 repeat 是 10 的整数倍时才无缝,默认档 15 每 2 秒要跳一次。

速度方面(repeat = 15):

duration 圈/秒 dis/秒
500 ms 3.000 0.2000
2000 ms(默认) 0.750 0.0500
8000 ms 0.188 0.0125

dis/s 恒等于 0.1 / (duration/1000),与 repeat 无关 ------调"波纹数量"只改密度,不改图案推进速度;要改速度只能调"扫描周期"。默认档下,一道波纹从锥尖跑到锥底要 10.0 秒 = 5 个扫描周期。

顺带量了另外两个时间项:pulse 一个周期跑 0.9549 个完整正弦(首尾 alpha 0.8500 → 0.8081,每周期突跳 0.0419 ),shimmer 的 time 部分只跑 0.4775 个周期------两项都不是整数,同样在周期切换时跳。

4.6 "方向角"滑块:对画面零影响

面板上第一个滑块就是"方向角(°)"。实测把这个 heading 从 0° 拨到 360°:

heading 锥体轴(世界方向) 与天顶夹角
0° (−0.408731, 0.697222, 0.588914) 0.000000°
45° (−0.408731, 0.697222, 0.588914) 0.000000°
90° (−0.408731, 0.697222, 0.588914) 0.000000°
180° / 270° / 360° 同上 0.000000°

七个角度之间,锥体轴的最大夹角是 0.000000°。

根因有两层:

  1. headingPitchRollQuaternion 的 heading 是绕天顶 转,而几何的轴(源码 170551-170553 行 normal.z = sin(atan2(bottomRadius − topRadius, length)) 可确认轴是局部 z)经 ENU 变换后恰好就是天顶------绕天顶转一个已经指向天顶的轴,等于没转
  2. 就算真的转了也没用:锥体是旋转对称体,而 dis 只依赖水平半径,也是旋转对称的------绕轴转多少度,画面完全一样

想要"朝向某个方位",得用 pitch / roll 把轴从天顶掰下来,而不是 heading。

七个滑块量下来,这张体检表是本篇的收尾:


五、代价清单:改一次滑块,重建了多少东西

5.1 五个材质 setter,每一个都是一次整批重建

前面 3.3 已经埋了线索:材质属性的 setter 会 raiseEvent(this._definitionChanged),而 Batch2 构造时就把 onMaterialChanged 挂上去了(187730-187738 行),回调体只有一行 this.invalidated = true。

StaticGeometryPerMaterialBatch.prototype.update(188060-188068 行)的处理是:

js 复制代码
if (item.invalidated) {
  items.splice(i, 1);
  for (let h = 0; h < updatersLength; h++) { this.add(time, updaters[h]); }
  item.destroy();          // 🔑 旧批次销毁
}

而 Batch2.prototype.add 最后一行是 this.createPrimitive = true,于是下一帧走 Batch2.prototype.update 的创建分支:new Primitive({ show: false, asynchronous: true, ... })------新 Primitive 要等异步构造完(primitive.ready)才 show = true。

面板滑块档位实测:

滑块 范围 / 步长 档位
方向角 0--360 / 1 361
锥体长度 200--8000 / 100 79
底部半径 200--6000 / 100 59
波纹厚度 0.1--0.9 / 0.05 17
波纹数量 2--50 / 1 49
扫描周期 500--8000 / 100 76
偏移量 0--1 / 0.05 21

把"扫描周期"从 500 一路拖到 8000,是 76 次整批销毁 + 异步重建 (512 顶点的几何重建 + VBO 重传 + 每次都要等一帧 show = false)。而它本可以只是 uniforms.duration = x。

好消息是:_isDynamic(171057-171066 行)不检查材质,所以平时 走的是静态批次,getValue 每帧照常被调用(187853-187864 行),动画是流畅的------代价只在拖滑块的那几秒。

5.2 topRadius: 0 换来 254 个退化三角面

实测 508 个三角面里 254 个是零面积的(正好一半):锥尖那一圈侧面三角形塌成一个点,半径 0 的顶盖更是全部重合。它们照样走完顶点着色器、照样参与光栅化(虽然一个像素都不覆盖)。

想省掉这半份开销,要么给 topRadius 一个极小非零值(比如 1),要么接受它------对一个锥体来说这点量确实无所谓,但要知道它在这。

5.3 getValue 用墙钟,传进来的 time 被完全忽略

ts 复制代码
getValue (time: unknown, result?) {
  const now = new Date().getTime()   // 🔑 形参 time 一次都没用过
  result.time = ((now - this._time) % this._duration) / this._duration / 10
}

后果:

  • 暂停 Cesium 时钟、调 clock.multiplier、拖时间轴,对这个雷达波毫无影响------它只看墙钟
  • _time 是构造时 的墙钟快照。改 duration 会让 time 的换算基准突变,波纹相位当场跳一下(虽然 _time 本身没重置)
  • 切走标签页再回来,波纹会按真实流逝时间"补"到位,不会像 #43 那版自转那样靠 Math.min(delta, 0.1) 兜住

5.4 "重置默认位置"会叠加,计数永远显示 1

ts 复制代码
function createRadar (lon, lat) {
  radarEntity = viewer.entities.add({ ... })
  radarCount.value = 1      // 🔑 永远写 1
}

createRadar 不先清旧的。面板上的"重置默认位置"按钮直接调它,于是点几次就叠几个锥 (位置相同,看起来像一个,但实体数在涨)。而"清除雷达(n)"按钮是遍历全量移除的,所以功能没错,错的只是那个永远显示 1 的计数。

5.5 onTick → requestRender() 是空转

ts 复制代码
removeTick = viewer.clock.onTick.addEventListener(() => {
  if (placing.value) viewer?.scene.requestRender()
})

本猿翻了 createMapScene:没有设置 requestRenderMode ,默认是 false,即场景本来就每帧渲染。requestRender() 在 requestRenderMode = false 时是空操作------这段 onTick 白跑。(真要省电,得先开 requestRenderMode: true,那时候这段代码才有意义。)


六、小贴士:避坑指南

🎯 关于 CylinderGeometry 的 st

  • ⚠️ 重要提示 :st 只由局部 x/y 决定,与高度 z 无关(170664-170672 行)。任何"想沿锥面斜向排纹理"的写法都得自己传高度 uniform
  • distance(st, vec2(0.5)) 等于"到轴线的水平距离 ÷ (2·max(topRadius, bottomRadius))",topRadius = 0 时取值恒在 0, 0.5
  • 几何以实体位置为中心 ,topRadius: 0 的锥体宽端在 −length/2、尖端在 +length/2。想让尖端着地,把实体抬到 height = length / 2,或者改用 minimumHeights 那类思路

⚡ 关于时间源

  • ⚠️ 重要提示 :time 只走 0→0.1,一个周期推进 0.1 × repeat 个波纹。repeat 不是 10 的整数倍时,每个周期都会跳一次
  • getValue 里用墙钟意味着 Cesium 时钟管不着它。想要"跟场景时间同步"的动画,就用形参 time(JulianDate)算,别用 Date().getTime()
  • 想要"每秒 N 米"这种确定速度,先把 0.1 × repeat 配成整数,再按 dis/s = 0.1 / (duration/1000) 反解 duration

🗺️ 关于 Entity + 自定义材质

  • 自定义 fabric 必须注册进 Material._materialCache,否则 Material.fromType 直接抛 material with type 'X' does not exist.
  • 材质属性的 setter 会触发 invalidated → 整批 Primitive 销毁重建 。纯 uniform 的改动(颜色、速度、相位)别走 setter,直接改 material.uniforms
  • isConstant = false 能让 getValue 每帧被调用,但不会 把实体推进动态批次(圆柱的 _isDynamic 不查材质)------这反而是好事,静态批次才不会每帧重建几何

📦 关于半透明与退化面

  • translucent: () => true 让 isTranslucent() 为真,进而 BlendingState.ALPHA_BLEND + depthMask = false(第 46 篇量过同一套 getDefaultRenderState)
  • topRadius: 0 会换来一半的零面积三角面,量不大但要知道它在

七、写在最后

这个雷达锥里最值钱的不是那 22 行 GLSL,而是被它牵出来的那一行:st = (x + rad) / (2·rad)。

我们习惯性地以为"纹理坐标"是某种精心安排的参数化------方位角、弧长、高度,总得占一样。Cesium 在圆柱上给的答案很朴素:把顶点正投影到 XY 平面就完事了 。这个选择对"侧面贴一张图"够用,但对"想让纹理沿轴向变化"的需求是彻底失效的。而我们写的 distance(st, vec2(0.5)) 恰好踩在这个失效面上------它量到的不是"离锥尖多远",是"离轴线多远"。

再往下推一步就是那 3.75 圈埋在地下的波纹:几何以实体为中心,实体又被放在 height = 0。两个各自都合理的决定叠在一起,让一半的效果凭空消失,而且没人报错、没人警告,画面上只是"看着好像波纹少了点"。

三维特效这条线的第二篇,量的是一个和 #46 完全对称的问题:上篇是"时间源选错导致速度漂移",本篇是"坐标语义理解错导致一半效果消失"。一个是动态的量错了,一个是静态的量错了。

🎉 雷达波锥量完了------从"立一堵墙"到"立一个锥",特效线第二站收工。

它当然还能更好:把 time 的推进量配成整圈、把 thickness 的语义翻过来、把锥体抬到地面上、把 uniform 改动从重建里摘出来。但作为理解"纹理坐标到底在量什么"的一块标本,它比任何一段文档都直观。

如果你有更好的想法,或者在实际使用中遇到问题,欢迎来 GitHub 提 Issue!这条线往下还有雷达扫描的两个版本(Entity 版和 Primitive 版),同样的量法,也迟早要一个个过。


开源地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX

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

仓库公开,直接到 GitHub 取即可。本篇涉及的目录:

  • src/cases/radar/ ------ 演示面板与场景装配(258 行)+ index.ts(id: radar / category: effects / tag: 雷达波 / updatedAt: 2026-08-27)
  • src/cases/radar-lib/radar-material.ts ------ RadarPrimitiveMaterialProperty + RADAR_PRIMITIVE_SOURCE(171 行,含 22 行 GLSL)

Cesium酱の百宝箱 · 第 47 篇

一锥量纹分高下,半圈藏影辨沉浮

2026 年秋深,愿我们写下的每一道波,都经得起拿尺子量

相关推荐
Amos_Web18 小时前
Rspack 源码解析(十八):Tree Shaking 如何用 SideEffects 重写模块连接
前端·rust·前端框架
Shirley~~19 小时前
Three.js的基础概念
前端·3d
Java后端的Ai之路20 小时前
03_React_JSX
前端·react.js·前端框架
Zldaisy3d1 天前
GAMS 2026: 增材制造无国界
3d·制造
浦信仿真大讲堂1 天前
用好 CST View Options:把3D操作环境调到更顺手,也更高效
3d·cst软件·达索软件
oioihoii1 天前
Pascal Editor 3D建筑编辑:墙体门窗、2D与3D视图及WebGPU建模
3d
web3d5202 天前
Vue3 地图白屏排查清单:容器、弹窗与 ol-viewport 重复创建
vue.js·gis
千殇华来2 天前
3d打印的材料选择
3d·材料工程
Java后端的Ai之路2 天前
01-React基础教程
开发语言·前端·python·react.js·前端框架