上回那堵会流动的墙,本猿在结尾留了三笔账:条纹沿墙长没有变化、速度随帧率漂移、参数改动要走全量重建。
这堵墙当然还不完美:条纹沿墙长没有变化、速度随帧率漂移、参数改动要走全量重建。但它是我们理解「着色器里的时间与坐标」最好的一块标本。
今天换个更花哨的------雷达波锥。三维特效线第二篇,主角从"贴在地上的一面"换成"立在空中的一个锥",动画从"贴图平移"换成"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------公式成立。由此得到两个直接推论:
- 锥面与底盖共用同一套 UV 。锥面上某点和底盖上水平位置相同的点,
st完全一样 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°。
根因有两层:
headingPitchRollQuaternion的 heading 是绕天顶 转,而几何的轴(源码 170551-170553 行normal.z = sin(atan2(bottomRadius − topRadius, length))可确认轴是局部 z)经 ENU 变换后恰好就是天顶------绕天顶转一个已经指向天顶的轴,等于没转- 就算真的转了也没用:锥体是旋转对称体,而
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 年秋深,愿我们写下的每一道波,都经得起拿尺子量