# Research: 设备状态特效在 Cesium 的可用手段、能力边界与开销 **Ticket:** [02 — 调研:设备状态特效在 Cesium 的可用手段与开销预算](../issues/02-research-device-status-effect-mechanisms.md) **Date:** 2026-09-18 **Scope:** 只查事实,不做实现。未改 `src/`,未装依赖,未跑 build/dev/serve(仓库规则 `AGENTS.md`)。 **引擎版本(本地实测):** `cesium@1.145.0`(`node_modules/cesium/package.json:3`)。包布局与 06 笔记一致:`cesium` 包是空壳,真实源码在 `node_modules/@cesium/engine/Source/`(`node_modules/cesium/Source/Cesium.js:8` 起全部 `export { … } from '@cesium/engine'`)。**本笔记的「路径:行号」在没有特别注明时都指 `node_modules/@cesium/engine/Source/…`。** ## 结论 1. **最省的原语是 `BillboardCollection`**(`Entity.billboard` 底层就是它):整个 collection 是**一次 draw call**(官方博客原话「render the entire billboard collection with a single drawElements call」,[Billboard Instancing](https://cesium.com/blog/2015/10/28/billboard-instancing/)),**每个 billboard 可按 `scale`/`color` 逐帧单独改**,改哪个只重传哪个(`_billboardsToUpdate` 脏列表,`BillboardCollection.js:152-153, 655-657, 1466-1507`)。可拾取(可关)。→ 10/50/200 个脉冲点用「同一张扩散圈图片 + `scale`/`color` 动画」是**低风险、量级安全**的做法。 2. **`Entity` + `CallbackProperty` 是所有路线里最贵的一条**:`isConstant=false` 的属性会让 `EllipseGeometryUpdater._isDynamic` 判真(`EllipseGeometryUpdater.js:222-239`),进而走 `DynamicGeometryBatch`(**每个实体一个 updater**,`DynamicGeometryBatch.js:14-22`),而 `DynamicGeometryUpdater.update()` **每帧先 `removeAndDestroy` 再 `new Primitive({asynchronous:false})` 重建**(`DynamicGeometryUpdater.js:76-85, 146-153`)。社区实测(2020 年、用户报告、非受控基准):25 个 CallbackProperty 圆柱 ≈8 FPS、200 个 ≈0 FPS([论坛帖](https://community.cesium.com/t/using-multiple-callbackproperty-kills-performance-drops-to-zero-fps-code-repaired/10608))。→ 200 个动画 `ellipse` **不可行**;「按需只给 1–3 个当前聚焦设备上动效」才成立。 3. **「一次 draw call 画 N 个脉冲」成立,且有两条实证路径**: - **自定义 `Primitive` + N 个 `GeometryInstance` + 同一个 `Appearance`**:几何实例先合并成一个几何,再建 batch table(`Primitive.js:538-543, 1160-1161`),draw command 数 = `_va.length`(**每个顶点数组一条**,`Primitive.js:1868`),逐实例数据(颜色/show/自定义属性)走 **batch table 纹理**在顶点着色器里按 `batchId` 取(`Primitive.js:887, 942-963`)。200 个实例 = 1 条命令。 - **`BufferPointCollection`(1.145 新增,`@experimental`)**:显式「N 个点 = 1 次 `DrawCommand`」,`count: collection.primitiveCount` 一次画完(`renderBufferPointCollection.js:278-294, 307`)。但它只支持**屏幕像素尺寸的点 + 颜色/描边**(`BufferPointMaterialOptions` 只有 `color/outlineColor/outlineWidth/size`,`Cesium.d.ts:28094-28105`),**没有时间 uniform、也没有自定义顶点属性入口**,做不了「设计感」的扩散圈。**且 `BufferPointCollection` / `BufferPrimitiveCollection` 是 `@experimental`,「not final and subject to change without Cesium's standard deprecation policy」(`BufferPointCollection.js:56-57`)**——本驾驶舱按 ADR 纪律不宜押注在它上面。 4. **与雾气后处理共存:可行,且顺序有确定规则。** 同一个 `scene.postProcessStages` 可以挂任意多个自建 stage(`PostProcessStageCollection.add()`,无上限、无 sort 参数)。执行顺序 = **加入顺序**:AO → Bloom → 色调映射 → 按 `_stages` 数组顺序跑自建 stage(每个吃前一个的输出)→ FXAA(`PostProcessStageCollection.js:799-834`;过滤在 `:583-593`;无插入索引 API,`_index = stages.length` 在 `:466`)。**两者互不干扰的关键约束:每个 stage 拿到的 `depthTexture` 都是「场景渲染输出的那一张」**(`execute()` 的 `depthTexture` 参数一路原样传递,`:801-827`),所以雾气 stage 与特效 stage **读的是同一份深度**,谁先谁后都能读;但**颜色是链式的**,后跑的 stage 会把前一个的输出当输入。→ 顺序必须显式对齐:**先特效、后雾气**(特效属于「被雾遮蔽的远处内容」),否则特效会盖在雾上面。 5. **`Context.depthTexture` 更正沿用 06 结论**:它是**布尔能力标志**(`Context.js:714-725`),不是纹理。设备特效若只想做「不穿模的点位特效」,**根本不必自己读深度**:用 `Primitive` / billboard 的正常深度测试即可。若确实要读深度(例如特效要按距离淡出),正确路径就是 `PostProcessStage` 默认提供的 `sampler2D depthTexture` uniform(`PostProcessStage.js:526-531`)+ `czm_readDepth(depthTexture, v_textureCoordinates)`,由 Cesium 自动注入 `LOG_DEPTH` 并解码(06 笔记 §2 第 4 条已核实)。 6. **色与形的单一来源可维持,但引擎侧有一条硬边界**:`tokens.css` 是 CSS 文件,**WebGL 渲染路径读不到 CSS 变量**。所以凡是引擎侧要的颜色,都必须像 `markerSvg()` 一样由**服务层从计算样式读出、烘焙成具体色值**再传下去(现成实现:`services/mapService.js:639-651` 的 `toneColors()` → `:670-676`)。引擎侧要 `Cesium.Color` / 数字 RGBA 的 API 有:`billboard.image`(data-URI SVG,色值必须烘进图片)、`Billboard.color`、`Material` uniform(`Material.js:1255-1278` 的 uniform 类型判定,Color 对象而非 CSS 串)、`PerInstanceColorAppearance` 的 `ColorGeometryInstanceAttribute`、`BufferPointMaterial.color`、`PostProcessStage` 的 `uniforms`。形状仍只来自 `pointStatus.js` 的 `SHAPES`(`pointStatus.js:42-59`)。 7. **拾取:特效是否吃点击,取决于「有没有 pickId」,而不是「透明不透明」**。选拾取通道时,只有带 `derivedCommands.picking`(由 `command.pickId` 派生)或 `derivedCommands.depth` 的命令才会画(`Scene.js:2379-2404`,即 `executeIdCommand`);**没有 pickId 的实体命令在拾取通道里被无条件跳过**。因此:`Primitive` + `allowPicking:false`(`Primitive.js:76, 2055-2059`)、`BufferPointCollection` 默认 `allowPicking=false`(`BufferPointCollection.js:159`)**天然不吃点击**;反过来说,**只要给特效挂了 pickId,它就会挡住背后监测点/设备的拾取**(`Scene.pick` 只回最靠近相机的一个,靠 `Scene.drillPick` 才能穿透,而 drillPick 是「拾一个藏一个再拾」的多次拾取,`Picking.js:895-941`)。 ## 1. 可用原语逐项核实 判定口径统一为四问:**能否按实体定位 / 能否只影响一个点 / 能否被拾取 / 是否每帧重算**。 ### 1.1 `Entity` + `CallbackProperty` 驱动的动画 billboard / ellipse / cylinder | 原语 | 按实体定位 | 只影响一个点 | 可拾取 | 每帧重算 | | --- | --- | --- | --- | --- | | `Entity.billboard` | 是(`position`) | 是 | 是(canvas 内非透明像素,`Billboard.getPickId` `Billboard.js:1104-1114`) | 否(除非属性非常量) | | `Entity.ellipse`(常量属性) | 是 | 是 | 是 | 否 | | `Entity.ellipse`(`CallbackProperty` 属性) | 是 | 是 | 是 | **是,且是「销毁 + 重建 Primitive」** | | `Entity.cylinder`(同上) | 是 | 是 | 是 | **是** | 依据: - `CallbackProperty` 的语义:`new CallbackProperty(callback, isConstant)`,`isConstant` 必须显式给(`CallbackProperty.js:12-19, 30-33, 72-86`);`getValue(time)` 惰性求值(`:59-63`)。**`isConstant=false` 意味着「每帧都要重新求值」**,也就是每帧都要造新值。 - 常量属性路径:`GeometryVisualizer` 按「geometry 类型 + appearance + shadowMode」分组进 `StaticGeometryColorBatch` / `StaticGeometryPerMaterialBatch` / `StaticOutlineGeometryBatch`(`GeometryVisualizer.js:56-120`),**多个实体可以合批**。 - 动态属性路径:`EllipseGeometryUpdater._isDynamic()` 只要求 `!ellipse.semiMajorAxis.isConstant` 等任一条件成立(`EllipseGeometryUpdater.js:222-239`),就走 `DynamicGeometryBatch`,而它是 **`this._dynamicUpdaters.set(updater.id, updater.createDynamicUpdater(...))`——每个实体一个 updater**(`DynamicGeometryBatch.js:14-22`)。 - 动态路径的每帧代价:`DynamicGeometryUpdater.prototype.update` **先 `primitives.removeAndDestroy(this._primitive)`,再 `new Primitive({ geometryInstances: createFillGeometryInstance(time), asynchronous: false })` 重新 add**(`DynamicGeometryUpdater.js:76-85, 139-153`)。`asynchronous:false` 还意味着几何在**主线程**生成。 - 社区实测(**用户报告的观测值,不是受控基准;2020 年、其本机硬件**):1 个 CallbackProperty 圆柱 ≈55 FPS → 25 个 ≈8 FPS → 200 个 ≈0 FPS([Using multiple CallbackProperty: kills performance](https://community.cesium.com/t/using-multiple-callbackproperty-kills-performance-drops-to-zero-fps-code-repaired/10608))。同帖引 Cesium 官方维护者在 [issue #11315](https://github.com/CesiumGS/cesium/issues/11315) 的说法:「The API expects non-constant callback properties to be updated every frame, hence request a new frame to render」——即非常量 CallbackProperty 会**持续打破 `requestRenderMode` 的按需渲染**。 - 关键推论(对「按状态放大/收缩的扩散圈」很重要):`semiMajorAxis` 是**几何输入**,不是着色器 uniform。想做出「世界尺度上不断扩大的圈」,就必须动几何 → 只能走动态路径。若只做「整体透明度/大小的呼吸」,不要用 `ellipse`,用 **billboard 的 `scale`/`color`**(见 1.5)。 **结论:`Entity` + `CallbackProperty` 适合「同时只有 1–3 个设备在播特效」的场景;绝不适合 200 个点常驻动画。** ### 1.2 `CustomDataSource` 与 `Primitive` + `GeometryInstance` + `Appearance` - **`CustomDataSource` 只是「一串 Entity + 一个名字」的组织容器**,自己不产生任何渲染原语。仓库已有用例:点位标记数据源 `POINT_MARKER_SOURCE`(`src/modules/cockpit/services/cesiumEngine.js:267, 286-301`),并且该文件 `:272-283` 记录了 `DataSourceCollection.add()` 异步注册的坑(同 tick 先写后读会建出第二个空数据源)。→ **设备特效若走 Entity/DataSource 路线,必须沿用同一份「WeakMap 缓存实例」的写法**,否则会重演「图例有计数、地图 0 个标记」。 - `Primitive` 的批量能力(**这是「一次 draw call 画 N 个」的正统路径**): - 构造参数 `geometryInstances` 可以是数组(`Primitive.js:67`)。 - N 个几何实例的几何会**合并成一个几何**(`Primitive.js:1160-1161, 1195-1214` 把实例分片喂给 worker 后合并),再为它们建一张 **batch table**(`createBatchTable`,`Primitive.js:538-543, 628-663`)。 - draw command 数 = `primitive._va.length`(**每个顶点数组一条**,`Primitive.js:1868`)——合并后通常就是 1 条。 - 逐实例数据(`color` / `show` / `pickColor` / `distanceDisplayCondition` / 自定义属性)**不是顶点属性,而是 batch table 纹理**,在顶点着色器里按 `batchId` 查(`Primitive.js:817-963`:`czm_batchTable_show`、`czm_batchTable_color`、`czm_batchTable_pickColor`、`czm_batchTable_distanceDisplayCondition` 等)。 - 逐实例数据可以在运行时改:`primitive.getGeometryInstanceAttributes(id)` 返回带 getter/setter 的对象(`Primitive.js:2379-2420`);setter 会走 `createSetFunction` → `batchTable.setBatchedAttribute`(`Primitive.js:2277-2304`)。 - **自建 `Appearance` 是可行的**:`MaterialAppearance` 的 `vertexFormat` 由 `materialSupport` 决定(`MaterialAppearance.js:55, 94, 197-199`,`MaterialSupport.TEXTURED` / `ALL` 分别给 `POSITION_NORMAL_AND_ST` / `VertexFormat.ALL`,`:288-323`),`Appearance` 也接受自定义 `fragmentShaderSource`(`Appearance.js:125-153`)。→ 想「shader 里按 attribute 驱动」是可行的,但**逐实例的那个 attribute 只能走 batch table 纹理这条路**(`czm_batchTable_*`),不是真正的 instanced vertex attribute。 - `allowPicking` 是构造参数,默认 `true`(`Primitive.js:76, 243`),`false` 时**不创建 pickId 且不写 `command.pickId`**(`Primitive.js:650-653, 2055-2059`),文档原话「GPU memory is saved」。 - **只影响一个点**:能。最省的做法是 **N=1 的 `GeometryInstance` 放在该设备的 ECEF 位置**(`Transforms.eastNorthUpToFixedFrame`,仓库已有用法 `cesiumEngine.js:452-455`)。 ### 1.3 `PostProcessStage` / `PostProcessStageCollection` - **本质是「全屏后处理」,不是「按实体定位」**:单个 stage 是一遍全屏 viewport-quad(`PostProcessStage.js:592` 的 `context.createViewportQuadCommand`),它无法知道「哪个像素属于哪个设备」。→ **不能只影响一个点**,除非你在 shader 里自己做「世界坐标 → 屏幕」的判定(要自己给 `uniforms` 传设备点位的屏幕坐标或世界坐标),或者用下面这条: - **`stage.selected = [features]` + shader 里 `czm_selected()`**:这是 Cesium 官方给「只影响被选中的对象」的机制(`PostProcessStage.js:71-94` 的示例、`:355-395` 的 `selected`、`:548-567` 注入的 `czm_selected`)。但它只对**参与 id 通道的对象**(3D Tiles feature 等)有效,**普通 Entity/Primitive 不在其列**——所以它救不了「只影响某一个设备图标」。对「只影响一个点」的需求,**不要选后处理**。 - **`uniforms` 支持每帧函数**:属性值可以是常量,也可以是函数,函数每帧执行前被调用(`PostProcessStage.js:237-245, 458-496`)。→ 「脉冲相位」这类每帧量可以这样喂。 - **能力探测有现成实现**:`PostProcessStage.prototype._isSupported` 用正则检查片元着色器里有没有 `uniform sampler2D depthTexture`,有则要求 `context.depthTexture` 为真,否则整级不执行(`PostProcessStage.js:397-404`)。**注意它是文本正则匹配**(`/uniform\s+sampler2D\s+depthTexture/g`),声明写法不同会影响判定。 - **可拾取**:后处理不参与拾取(它不是 primitive,`Scene.pick` 看不到它)。→ 「特效不该吃点击」这条需求,后处理天然满足;反问是**它也无法承载「点特效」**。 - **每帧重算**:一遍全屏 pass,是「每帧都画」的固定成本(与特效点数量无关)。`enabled` 开关是廉价的(`PostProcessStage.js:178-182`)。 ### 1.4 `ParticleSystem`(**全局,不是按实体**) - **挂在 `Scene.primitives` 上**:官方教程与 API 的用法就是 `viewer.scene.primitives.add(particleSystem)`([Introduction to Particle Systems](https://cesium.com/learn/cesiumjs-learn/cesiumjs-particle-systems/)、[ParticleSystem API](https://cesium.com/learn/cesiumjs/ref-doc/ParticleSystem.html));源码里它内部**自建一个 `BillboardCollection`** 来画粒子(`ParticleSystem.js:710`),每次 `update` 都会 `this._billboardCollection.update(frameState)`(`ParticleSystem.js:807`)。→ 一个 `ParticleSystem` ≈ 一个 billboard collection 的开销;**N 个点 = N 个 ParticleSystem = N 份粒子模拟 + N 次 billboard collection 更新**。 - **定位方式**:`modelMatrix`(把粒子系统从模型坐标变到世界坐标,`ParticleSystem.js:28, 97`)+ `emitterModelMatrix`(把发射器放到系统局部坐标里,`:29, 98-99, 213-223`)+ `emitter`(默认 `CircleEmitter(0.5)`,`:27, 89-93`)。**没有「position」参数,也没有 entity 关联**:它是场景级 primitive,要自己算 ECEF 矩阵。 - **能否只影响一个点**:机制上「一个系统只在一个位置发射」,但**代价是一个系统一份模拟**;无法用一个 `ParticleSystem` 表达多个不同颜色的点位特效(`startColor/endColor` 是系统级,不能逐粒子按状态区分)。 - **能否被拾取**:`ParticleSystem` 没有 `pickId` / `allowPicking` 相关成员(构造函数 `ParticleSystem.js:58-100` 与成员列表里都没有),它画出的粒子 billboard 也没有建立 pick 身份(`Particle.js` 里只有位置/速度/尺寸/颜色等)。→ 不参与拾取。**这一条对「特效不该吃点击」是有利的**,但换来的是「无法与设备点做拾取联动」。 - **是否每帧重算**:是,粒子模拟(emit/update/回收)逐帧在 CPU+GPU 上跑,`emissionRate` 默认 5/秒、`particleLife` 默认 5 秒(见 API 文档默认值表)。→ 开销随 `emissionRate × particleLife` 的粒子数增长。 ### 1.5 `BillboardCollection`(**本票结论里的首选,独立列出**) - 一次 draw call 画整个 collection:官方博客「we can render the entire billboard collection with a single drawElements call」([Billboard Instancing](https://cesium.com/blog/2015/10/28/billboard-instancing/))。该文给的对照数字是 **10,000 个 billboard**:instancing 静态 266 FPS / 动画 138 FPS,batched 静态 262 FPS / 动画 120 FPS(2015 年、官方作者、其测试机)——**量级参考:一万级的静态 billboard 在同屏是轻松的,动画减半**。 - 逐 billboard 改 size/color 只重传脏的那几个:`BillboardCollection.js:152-153`(`_billboardsToUpdate`)、`:655-657`(setter 里 push 脏索引)、`:1466-1507`(更新时只处理 `_billboardsToUpdateIndex` 个)。→ **每个特效点一套自己的相位是免费的**,不必重建整个 collection。 - 但**每帧改 200 个 billboard 的属性 = 每帧 200 次 setter + 200 个顶点属性重传**,CPU 侧仍有线性成本(动画 138 vs 静态 266 FPS 就是这部分的代价)。 - 可拾取:canvas 内非透明像素会命中(`Billboard.getPickId`,`Billboard.js:1104-1114`);`Entity.billboard` 的 `properties.pointCode` 正是仓库现有拾取判据(`cesiumEngine.js:417-431`)。**新增特效 billboard 时必须让它没有 `pointCode`,或者干脆不放拾取数据源里**,否则会污染 `pickPointMarkerAt()`。 ## 2. 批量做法与开销量级判断 ### 2.1 「一次 draw call 画 N 个脉冲」的路子 | 路子 | 一次 draw call 能否画 N 个 | 每帧成本发生在哪 | 判定 | | --- | --- | --- | --- | | `BillboardCollection` + 每点 `scale`/`color` 动画 | **能**(一个 collection = 一次 drawElements) | CPU 侧改 N 个脏 billboard 的顶点属性(`BillboardCollection.js:1466-1507`) | **推荐**:换来的表现力足以做「呼吸/脉冲/扩散」;N=200 属安全区 | | 自定义 `Primitive`(N 个 `GeometryInstance` + 同一 `Appearance`) | **能**(command 数 = `_va.length`,通常 1) | 逐实例数据走 batch table 纹理(`Primitive.js:817-963`);改单个实例走 `getGeometryInstanceAttributes()` setter | 可行;**表现力最强**(自建 GLSL),但也是最需要自己写 GLSL 的一条 | | `BufferPointCollection`(1.145 新,`@experimental`) | **能**(显式 `count: collection.primitiveCount`,`renderBufferPointCollection.js:290`) | 只更新脏区间(`_dirtyCount`,`:109-164`) | **不建议押注**:`@experimental`(`BufferPointCollection.js:56-57`)且只能改 size/color,做不出设计感 | | `Entity` + `CallbackProperty` 椭圆/圆柱 | 否(每个实体一个 updater、每帧重建 Primitive) | 每帧几何在**主线程**重建(`DynamicGeometryUpdater.js:139-153`) | **只用于 1–3 个聚焦设备** | | `ParticleSystem` | 每个系统自己一次 billboard collection update | 粒子模拟 + 粒子数(`emissionRate × particleLife`) | 适合「单个重点设备的一处烟雾/火花」,不适合 N 点 | ### 2.2 同屏 10 / 50 / 200 个特效点的量级判断 **先说清性质:下面是「依据绘制结构 + 时间预算 + 可引用的实测」做出的推断,不是在本机实测的数据。本次调研按仓库规则没有跑任何 build/dev/浏览器测量。** - **单帧预算**:1920×1080 @ 60 FPS = **16.6 ms** 全部时间;给特效的合理预算按 10% 估 ≈ **1.7 ms**。 - **10 个点**:任何一条路子都能过。最贵的 `CallbackProperty` 椭圆路线在这个量级也只剩「每帧 10 次几何重建」的噪声级成本(社区实测 25 个才掉到 8 FPS 那种量级的机器上)。 - **50 个点**:`BillboardCollection` 与自定义 `Primitive` 都在安全区(对照:官方 10,000 个静态 billboard 266 FPS,即约 **0.0037 ms/个** 的整帧摊派,50 个 ≈0.19 ms);**`CallbackProperty` 椭圆路线开始危险**(社区实测同机 25 个 ≈8 FPS = **125 ms/帧**)。 - **200 个点**:`BillboardCollection` 仍在安全区(官方实测 10,000 个动画 billboard 138 FPS ≈ **0.0007 ms/个** 摊派,200 个 ≈0.15 ms);自定义 `Primitive` 亦在同量级(1 条 draw command,成本主要是 batch table 纹理 200 个 texel);**`CallbackProperty` 椭圆路线不可行**(社区同机 200 个 ≈0 FPS)。 - **需要留意的共同成本**:每个特效点若还要**每帧重建 JS 对象**(新的 `Color`/`Cartesian3`/图片 data-URI),GC 压力会盖过绘制成本。官方对 `BufferPrimitiveCollection` 明确写了这条纪律:「be careful to avoid allocating "N" instances of any related JavaScript object … using the flyweight pattern」(`Cesium.d.ts:28686-28691`)。同理,**禁止每帧重新生成 SVG data-URI 当图片**(`markerSvg()` 是建图片用的,不是每帧调用的)。 - **与 requestRenderMode 的关系**:`CallbackProperty(isConstant=false)` 会持续请求新帧([issue #11315](https://github.com/CesiumGS/cesium/issues/11315) 引官方维护者原话),也就是说「常驻动画特效」会**让按需渲染失效**——这条对「低性能消耗」是硬约束,凡是常驻动效都要按 07 的门控来决定何时挂上。 ## 3. 与既有雾气后处理的共存(关键) ### 3.1 同一 `PostProcessStageCollection` 里再挂一个设备特效级 —— 可行 - 集合就是一个数组 + 名字表:`this._stages = []`(`PostProcessStageCollection.js:76`),`add(stage)` 只要求名字唯一并把 `_index` 设为当前长度(`:436-466`)。 - **没有任何插入/排序/优先级 API**:全文件搜 `_index` 只有 `:420`(移除后重排)、`:466`(加入时赋值)、`:499/:502/:517`(移除)。→ **顺序 = 加入顺序**,唯一「插到中间」的办法是移除后按新顺序重新 add。 - 每帧先过滤出活跃级:`stage.ready && stage.enabled && stage._isSupported(context)`(`:583-593`)。 ### 3.2 `execute` 的顺序语义(源码级) `PostProcessStageCollection.prototype.update` 收集活跃级(`:583-593`),`execute` 按固定骨架跑(`:768-835`): ``` initialTexture = colorTexture // 场景渲染结果 if (ao) { execute(ao, initialTexture, depthTexture, idTexture); initialTexture = ao.output } if (bloom) { … } if (autoExposure){ … } if (tonemapping) { execute(…); initialTexture = tonemapping.output } // —— 自建 stage 按加入顺序,链式传色 —— execute(activeStages[0], initialTexture, depthTexture, idTexture) for i in 1..length-1: execute(activeStages[i], getOutputTexture(activeStages[i-1]), depthTexture, idTexture) if (fxaa) { execute(fxaa, lastTexture, depthTexture, idTexture) } ``` 三条必须记住的语义: 1. **自建 stage 永远排在 AO/Bloom/色调映射之后、FXAA 之前**(`:799-834`;类文档也这么写:`:25-30`)。→ 后处理级的位置不可能比内置级更早。 2. **颜色是链式的**:第 i 个 stage 的 `colorTexture` 是第 i−1 个的输出(`:819-828`)。→ **特效级与雾气级的先后会直接改变观感**:谁后跑谁就在最上层。 3. **深度不是链式的**:`depthTexture` 参数从 `execute` 入口一路原样传给每一级(`:801-827` 每处都传同一个 `depthTexture`)。→ **两级读到的深度完全一致**,谁先谁后都不影响读深度。 ### 3.3 顺序的应然取法(本票只给事实与约束) - **雾气是「大气/远处衰减」,特效是「近处设备上的发光」**。若雾气后跑,雾会把特效一起按距离衰减(物理上更对,远处设备特效变淡);若特效后跑,远处设备特效会「穿透」雾气浮在最上层(观感上像 UI 标注,但会破坏「范围外被雾遮住」的 Q11 诉求)。 - **与本地图 07 的对齐点**:该图 07 的门控还要求「遮盖镜头旋转/侧视时露出的范围外场景、云层与地平线」(`.scratch/cockpit-primary-area-guardrails/issues/07-fog-implementation-spec.md:20-24`)。**设备特效必须排在雾气之前**才能被这份「遮盖」覆盖;否则侧视时会出现「雾把县城盖住了,但设备特效还亮在雾上面」。 - **两级的开关要能在一次 `update` 里原子地对齐**:`enabled` 是逐级的独立开关(`PostProcessStage.js:178-182`),集合自己不做联动。→ 「07 里显式对齐顺序与开关」这句(`map.md:19`、`map.md:57`)在实现上就是:**同一处创建、同一处 add、按固定顺序 add、按开关一起 enable/disable**。 ### 3.4 关于「设备特效也要读深度」的正确路径 - 06 已更正:`Context.depthTexture` 是**布尔能力标志**(`Context.js:714-725`),不是纹理(该更正沿用,[`research/three-fog-shader-reuse.md`](../../cockpit-primary-area-guardrails/research/three-fog-shader-reuse.md) 及其票面已核实)。 - **正确路径 = `PostProcessStage` 默认提供的 `depthTexture` uniform**:`PostProcessStage.js:32-33` 的文档写明默认 uniform 就是 `sampler2D colorTexture` + `sampler2D depthTexture` + `vec2 v_textureCoordinates`;uniform 注入在 `:526-531`(`depthTexture`、`depthTextureDimensions`)与 `:588-592`(`ShaderSource`)。读深度用 `czm_readDepth(depthTexture, v_textureCoordinates)`(`Shaders/Builtin/Functions/readDepth.glsl:1-4`,`LOG_DEPTH` 下自动反解,见 06 笔记 §2 第 4 条)。 - **但先问「需不需要」**:本票的场景(设备点位上的状态特效)几乎不需要自己读深度——`Primitive` / billboard 走正常深度测试就能「被建筑挡住」,`Entity.billboard` 现有的 `disableDepthTestDistance: POSITIVE_INFINITY`(`cesiumEngine.js:346`)是刻意「永不被挡」的选择。**只有「按距离淡出」这类需求才需要读深度**。 - **若要读深度,注意两条边界**:① 能力探测是**文本正则**(`PostProcessStage.js:397-404`),片元着色器里必须写成 `uniform sampler2D depthTexture` 才能被识别;② `depthTexture` 的原始内容是 D24S8 打包纹理(DEPTH_STENCIL + UNSIGNED_INT_24_8,`Renderer/FramebufferManager.js:189-196`,06 已核实),必须经 `czm_readDepth` 解码,不能当普通颜色采样。 ## 4. 色与形的单一来源 ### 4.1 现状(仓库只读复核) - **语义色只在 `styles/tokens.css` 声明一份**:`--lv1: #ff4d5e; --lv2: #ffa63d; --lv3: #4fd8ff; --lv4: #35e39a; --neutral: #6d92ae;`(`src/modules/cockpit/styles/tokens.css:31-38`,挂在 `.cockpit-shell` 上而不是 `:root`,理由写在 `:9-13`)。同一文件里 `--lv3` 与主色 `--cyan` 同值(`:28, :36`)。 - **状态 → 色键/形状/文字的唯一一张表**:`src/modules/cockpit/domain/pointStatus.js:78-85`(`POINT_STATUS_VISUALS`,六态各带 `tone`/`shape`/`label`/`priority`)、`:88`(`UNKNOWN_VISUAL`)、`:91`(`UNASSOCIATED_VISUAL`)、`:94-96`(`visualForStatus`)。形状片段只写 `currentColor`、**不含任何色值**(`:42-59`)。 - **色键词表与「只描述颜色深浅、不说业务」的纪律**:`domain/statusTones.js:14`(`TONES = ['lv1'..'neutral']`)、`:32-38`(`TONE_DESCRIPTIONS`)。 - **服务层是唯一读色值的地方**:`services/mapService.js:631-651`(`TONE_FALLBACK` + `toneColors()` 从 `.cockpit-shell` 的 `getComputedStyle` 读 `--lv1..--neutral`),`:670-676` 把它交给引擎(`colors[marker.tone] → markerSvg(shape, color, 18)`)。 - **`markerSvg()` 为什么必须烘焙色值**:注释写得很直白——「`color` 属性让片段里的 `currentColor` 有值:**SVG 作为图片加载时读不到页面 CSS 变量**」(`pointStatus.js:218-222`);`mapService.js:626-629` 重复了同一句。 - **引擎层不认识业务词**:`syncPointMarkers(viewer, markers, colorOf, imageOf)` 只收 `shape` 与 `color`(`cesiumEngine.js:303-319`)。→ 特效若新增「动画形态」,**应沿用同一模式:domain 出「形态键 + 色键」,服务层出「色值 + 素材」**,引擎层不新增第二张业务映射表。 ### 4.2 引擎侧**读不到 CSS 变量**、必须由服务层烘焙色值的具体 API CSS 自定义属性只在 DOM/CSS 世界解析,**WebGL 渲染路径(着色器 uniform、顶点属性、纹理)一律读不到**。逐项列出: | 引擎 API | 为什么读不到 CSS 变量 | 服务层该传什么 | | --- | --- | --- | | `BillboardCollection#add({ image })` / `Entity.billboard.image` | 图片是 data-URI/URL,**SVG 作为图片加载时不继承页面 CSS**(`pointStatus.js:218` 注释) | `markerSvg(shape, color)` 这样把色值写进 SVG 的 `color` 属性 | | `Billboard#color` / `Entity.billboard.color` | 要 `Cesium.Color`(数值 RGBA) | 由 `toneColors()` 的 `#rrggbb` 转 `Color.fromCssColorString()` | | `Material` 的 uniform | 要求 number / boolean / `Color` / `Cartesian2..4` / 纹理,字符串被当作图片或阶段输出名(`Material.js:1255-1278`) | `Color` 对象 | | `ColorGeometryInstanceAttribute`(`PerInstanceColorAppearance` 的逐实例颜色) | 数值 Uint8 RGBA | 同上 | | `BufferPointMaterial#color` / `#outlineColor` | `Color` 对象(`Cesium.d.ts:28094-28105`) | 同上 | | `PostProcessStage` 的 `uniforms` | 常量值或**每帧函数**(`PostProcessStage.js:237-245`),函数返回值是 GLSL 类型 | 由服务层闭包捕获烘焙好的 `Color` 再返回 | | 自建 GLSL 里的颜色常量 | 着色器源码是纯文本,不经过 CSS | 走 `uniforms`,不要硬编码 `#rrggbb` 进 shader | **结论:单一来源可以维持,但入口只有一个——「服务层调一次 `toneColors()`,把色值往下传」**。任何在引擎层或 shader 里写死色值的做法都会长出第二份定义。形状同理:`SHAPES` → `markerSvg()` → data-URI 是唯一通路;若要给特效做「扩散圈」素材,**素材本身应由 `shape` 派生或与 `shape` 一一对应**,不要在引擎里按状态名分支。 ## 5. 拾取与层级 ### 5.1 `Scene.pick` 与 `Scene.drillPick` 在多层叠加时的行为 - `Scene.pick(windowPosition, width, height)` 走 `this._picking.pick(...)`(`Scene.js:4770`),**只返回最靠近相机的一个对象**。 - `Scene.drillPick` 的实现是**反复拾取**:`drillPick(pickCallback, limit)` 先拾一次,把拾到的对象 `show = false` 藏起来,再拾下一次,直到拾满或拾不到,最后统一恢复 `show = true`(`Picking.js:895-941`)。→ **多层叠加时 drillPick 能全部拿到,但代价是 N 次 pick pass**;而且它靠改 `show` 实现,若对象本身 `show` 归别人管,会互相打架。 - 拾取本身是**离屏 pass + 读回 1×1 像素**:`Scene.pick` 用的是 `_picking` 的专用 framebuffer(`Scene.js:3104-3106` 附近给 `getPickDepth` 喂深度),所以**拾取开销与「屏幕上画了多少东西」无关,而与「拾了几次」成正比**。→ 走查里 `pickPointMarkerAt()` 每次点击一次 `scene.pick`(`cesiumEngine.js:417-431`),是廉价的。 ### 5.2 特效会不会挡住监测点与设备的拾取 **判据是「这条命令在拾取通道里有没有 pick 身份」,与「是不是半透明」无关**: - 拾取通道走 `performIdPass(...)`(`Scene.js:3147-3149`:CESIUM_3D_TILE / OPAQUE / TRANSLUCENT 三个 pass 都调它),每条命令进 `executeIdCommand`。 - `executeIdCommand`(`Scene.js:2379-2404`)只做两件事:有 `derivedCommands.pickingMetadata` / `picking` 就画对应的 pick 命令,否则**若只有 `depth` 派生命令就只画深度**,**两者都没有就直接 `return`(什么都不画)**。 - `derivedCommands.picking` 只在 `command.pickId` 有定义时才创建(`Scene.js:1810-1817`;`DerivedCommand.createPickDerivedCommand` 在 `DerivedCommand.js:315-334`)。 - 所以: - **特效命令没有 pickId** → 拾取通道里它**不画** → 既不吃点击,也不挡后面的对象。这正是 `Primitive` + `allowPicking:false`(`Primitive.js:2055-2059`)与 `BufferPointCollection` 默认 `allowPicking:false`(`BufferPointCollection.js:159`)的效果。 - **特效命令有 pickId** → 它会写拾取颜色 → `Scene.pick` 在它盖住的像素上返回**它**而不是后面的监测点。半透明的圈/粒子只要挂了 pickId 就会吃点击。 - `Scene.pickTranslucentDepth` 默认 `false`(`Scene.js:539`),文档说明开了它才会把 `pickPosition` 的深度算上半透明对象(`Scene.js:4999-5030`)——**这是 `pickPosition` 的语义,不影响 `pick` 返回哪个对象**。 ### 5.3 「特效不该吃点击」的做法(按代价从低到高) 1. **不给特效任何 pick 身份**(首选):`Primitive({ allowPicking: false, … })`(`Primitive.js:76`)、`BufferPointCollection({ allowPicking: false })`(默认就是 `false`)、`ParticleSystem`(本身没有拾取路径)、`PostProcessStage`(不是 primitive)。→ 零额外代码。 2. **放进不参与拾取的 collection**:把特效 billboard 放进一个**独立的 `BillboardCollection`**(不是 `POINT_MARKER_SOURCE` 数据源),并且**不要给它 `pointCode` 属性**。仓库现有拾取口径正是「属性里有 `pointCode` 才算点位标记」(`cesiumEngine.js:405-431`),所以这条能立刻生效。 3. **物理上让开**:把扩散圈做成**空心环**(中心透明),设备图标在环心就不会被盖住。注意 billboard 的拾取是「canvas 内非透明像素」,空心环天然只吃环上那一圈。 4. **靠 pass 顺序兜底**:**不透明(OPAQUE pass)命令即使没有 pickId,也有 `depthOnly` 派生命令**(`Scene.js:2400-2403` 的 `else if (defined(depth))`),也就是说**一个不透明的特效面片会在拾取通道里写深度,挡住后面的设备点**。→ 特效面片**必须走半透明**(`Appearance.translucent = true` / `Material.isTranslucent()`,`Appearance.js:152-153`),否则它既没有 pickId 又会挡住拾取。这是最容易踩的一条。 5. **`drillPick` 兜底(不推荐)**:若某天特效确实要可拾取(例如点特效弹菜单),用 `drillPick` 穿透取列表再筛——但它多次拾取、并且靠改 `show`(`Picking.js:923-934`),与「特效按状态独立开关」会互相干扰。 ## 查不到 / 未能核实的事项 - **没有任何「在 Cesium 1.145 + 本仓库实际场景下」的实测帧率数据**。本票没有跑 build/dev/浏览器(`AGENTS.md` 禁止),所以 §2.2 的 10/50/200 量级判断**是推断**,依据已在正文逐条列出(绘制结构 + 官方 10,000 billboard 的 2015 年对照 + 社区 200 个 CallbackProperty 的用户实测)。要拿到真数,得在实现票里用 `scene.debugShowFramesPerSecond` 或 `FrameRateMonitor` 实测。 - **「200 个 `CallbackProperty` 椭圆」在本仓库目标机型上的具体帧率**:查不到。社区那组数字是 2020 年、提问者本机、Sandcastle 环境,**只可作为量级参考**。 - **`BufferPrimitiveCollection` 的正式文档页(cesium.com ref-doc)**:抓取 `ParticleSystem.html` 时的侧边栏里已出现 `BufferPointCollection` 等条目,但本票**没有抓到它们的官方参考页正文**,结论全部来自随包 `Cesium.d.ts:28040-28820` 与源码 `Renderer/…`、`Scene/BufferPointCollection.js`、`Scene/renderBufferPointCollection.js`。其 `@experimental` 标注即来自源码 JSDoc(`BufferPointCollection.js:25, 56-57`)。 - **`ParticleSystem` 能否被 `Scene.pick` 拾取**:源码里没有任何 `pickId` / `allowPicking` 痕迹(构造参数表 `ParticleSystem.js:58-100`、成员表、内部 `BillboardCollection` 用法),但**没有找到官方明文写「不可拾取」**。判定为「机制上不参与拾取」,依据是源码缺失而非文档声明。 - **雾气级与特效级「谁先谁后更好看」的产品裁定**:本票只给顺序语义与约束(§3.3),**不做产品选择**——那属于本地图 07 与 `.scratch/cockpit-primary-area-guardrails` 07 的对齐范围。 - **`Entity.cylinder` 的 `_isDynamic` 具体判据**:本票只核了 `EllipseGeometryUpdater`(`EllipseGeometryUpdater.js:222-239`)。`CylinderGeometryUpdater` 未逐行读,但其 `length` 属性非常量时走同一套 `DynamicGeometryBatch` / `DynamicGeometryUpdater` 机制(`DynamicGeometryBatch.js:14-22`、`DynamicGeometryUpdater.js:68-85`),结论不变。 ## Sources **随包 CesiumJS 1.145.0(`node_modules/@cesium/engine/Source/`,行号见正文)** - `Scene/PostProcessStageCollection.js` —— stage 集合、`add`、`removeStages`、`_index`、`update` 的活跃级过滤、`execute` 的固定骨架与链式传色 - `Scene/PostProcessStage.js` —— 默认 uniform(`colorTexture` / `depthTexture` / `v_textureCoordinates`)、`uniforms` 每帧函数、`enabled`、`selected` + `czm_selected`、`_isSupported` 的 depthTexture 正则 - `Scene/BillboardCollection.js`、`Scene/Billboard.js` —— 脏列表更新、`getPickId` - `Scene/Primitive.js` —— `allowPicking`、batch table(`createBatchTable` / `czm_batchTable_*`)、`_va.length` 与 draw command 数、`getGeometryInstanceAttributes` - `Scene/BufferPrimitiveCollection.js`、`Scene/BufferPointCollection.js`、`Scene/renderBufferPointCollection.js`、`Shaders/BufferPointMaterialFS.js` —— 单 DrawCommand 画 N 点、`allowPicking=false` 默认、`@experimental` - `Scene/ParticleSystem.js` —— 内部自建 `BillboardCollection`、`modelMatrix` / `emitterModelMatrix`、无位置参数 - `Scene/Appearance.js`、`Scene/MaterialAppearance.js` —— `vertexFormat` / `materialSupport` / `translucent` - `Scene/Material.js` —— uniform 类型判定(Color 而非 CSS 串) - `Scene/Scene.js` —— 拾取通道 `performIdPass`、`executeIdCommand`、`pickId` → `derivedCommands.picking`、`pickTranslucentDepth` - `Scene/Picking.js` —— `drillPick` 的「拾一个藏一个」实现 - `Scene/DerivedCommand.js`、`Scene/DynamicGeometryBatch.js`、`Scene/DynamicGeometryUpdater.js`、`Scene/EllipseGeometryUpdater.js`、`DataSources/GeometryVisualizer.js` —— 动态几何的每帧重建链 - `Scene/CallbackProperty.js` —— `isConstant` 语义 - `Renderer/FramebufferManager.js` —— D24S8 深度纹理 - `node_modules/cesium/package.json:3`(版本)、`node_modules/cesium/Source/Cesium.d.ts:28040-28820`(`BufferPointCollection` / `BufferPrimitiveMaterial` / `BufferPrimitiveCollectionOptions`) **仓库内文件** - `src/modules/cockpit/services/cesiumEngine.js`(点位标记数据源、`syncPointMarkers`、`pickPointMarkerAt`、`frameAt`) - `src/modules/cockpit/services/mapService.js`(`TONE_FALLBACK` / `toneColors()` / `syncPointMarkersForSummary`) - `src/modules/cockpit/domain/pointStatus.js`(`SHAPES` / `POINT_STATUS_VISUALS` / `markerSvg`) - `src/modules/cockpit/domain/statusTones.js`、`src/modules/cockpit/styles/tokens.css` - `.scratch/cockpit-primary-area-guardrails/research/three-fog-shader-reuse.md`、该图 `issues/06`、`issues/07` - `.scratch/cockpit-point-locate-and-device-link/map.md`(Notes 现状事实 1–8、跨图耦合) **官方文档 / 官方博客 / 社区** - [ParticleSystem - Cesium 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/ParticleSystem.html) - [Introduction to Particle Systems - Cesium 官方教程](https://cesium.com/learn/cesiumjs-learn/cesiumjs-particle-systems/) - [Graphics Tech in Cesium - Billboard Instancing - Cesium 官方博客(2015-10-28)](https://cesium.com/blog/2015/10/28/billboard-instancing/)(10,000 billboard 的 instancing/batched、静态/动画 FPS 对照) - [PostProcessStageCollection - Cesium 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/PostProcessStageCollection.html)(「AO/Bloom 先于所有 stage,FXAA 后于所有 stage」) - [PostProcessStage - Cesium 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/PostProcessStage.html) - [Primitive - Cesium 官方文档](https://cesium.com/learn/ion-sdk/ref-doc/Primitive.html) - [Using multiple CallbackProperty: kills performance (drops to Zero FPS) - Cesium 社区(2020)](https://community.cesium.com/t/using-multiple-callbackproperty-kills-performance-drops-to-zero-fps-code-repaired/10608)(25 个 ≈8 FPS、200 个 ≈0 FPS 的用户实测) - [requestRenderMode property and CallbackProperty function conflicts · CesiumGS/cesium issue #11315](https://github.com/CesiumGS/cesium/issues/11315)(非恒定 CallbackProperty 会每帧请求新帧)