docs(spec): R-H1 由「暂不实现」改为「已实测触发并启用」,补两种病态状态实测数据

如实记录:
- 原风险分析只预见「敌人全在射程外」,漏掉「射程内敌人全在 visited_targets 里」——
  后者经 bounce+homing 组合是该子弹的永久状态而非瞬态,且成本更高,是本次发现;
  原文把 R-H1 定性为瞬态尖峰不准确。
- 实测两种场景在 300 弹规模已达 3.14 / 4.34 ms,越过本节自定的 2.0 ms 触发线,
  故方案 3(0.2s 退避)随 Task 2 落地,摊薄约 12×。
- 残余未收敛项另立 §5.4:1500 弹摊薄后仍 3.9 / 4.5 ms,且退避会使同波齐射的
  子弹同步重试、留下 15.9 / 22.2 ms 单帧尖峰;错帧方案待决策,本次未擅自扩大范围。
§2.4 补一句指向 §5,避免「一生只查 1–2 次」与失败路径重试自相矛盾。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:04:03 +08:00
co-authored by Claude Opus 5
parent c9786eba50
commit 3bf04c9fc0
@@ -78,6 +78,8 @@ SoA 布局(`BULLET_STRIDE=12`**不变**。归航是稀疏特性,进冷数
**空场守卫**:重选前先判 `EnemyManager.get_active_count() == 0` 直接跳过,挡掉「清场瞬间所有在飞子弹集体重选且都查不到」这一最常见的病态帧。
> **补充(实测后修订)**:上面「一生只查 1–2 次」只在**重选成功**时成立;重选**失败**时会每帧重试,空场守卫挡不住。实测已越过触发线,故 §5 的退避方案(`homing_retry_at`)已随本期落地 —— 失败路径退避 200 ms 后再试。详见 §5。
复用现成的 `_find_nearest_unvisited`(bounce 期引入)—— 它已经滤除哨兵与已访问目标。纯归航子弹传 `cold.get("visited_targets", [])`(无该键时为空数组)。
### 2.5 转向积分(`bullet_manager._gd_integrate`,循环体顶部、位置积分之前)
@@ -175,13 +177,55 @@ MODIFIER`type 1`)的 meta schema 加:
9. **商店与设计器**`modifier_homing` 可在商店买到;设计器 `homing`/`bounce` 字段回读往返一致(对真实 `data/spells.json`)。
10. `validate_script` 通过;纯数据驱动;无目标时不崩。
## 5. 记录在案的风险与逃生方案
## 5. R-H1 选目标频率尖峰 —— 已实测触发并启用逃生方案
**R-H1 · 选目标频率尖峰**:§2.4 只在目标失效时查询,但「敌人存在却都在 `homing_range` 外」时,每颗归航子弹每帧都会查一次 `query_circle`(约 196 格 + 一次数组分配)。空场守卫挡不住这种情况
> **状态更新(2026-07-30Task 2 代码质量评审后)**:本节原写「方案 3 暂不实现」,等验收期实测再定。评审在 Task 2 上直接实测,**两种病态状态在 300 弹规模就已越过本节写死的 2.0 ms 触发线**,故方案 3 已随 Task 2 落地,不再等待。以下如实记录,含原始风险分析的不完整之处
**逃生方案(方案 3,暂不实现)**:重选失败时往冷数据写 `homing_retry_at``Time.get_ticks_msec()` 时间戳),0.2s 内不再重试,把最坏情况摊薄约 12 倍(60fps)。改动为冷数据加一个字段 + 重选前一处时间比较,约 5 行。
### 5.1 病态状态(其二为本次新发现)
**暂不实现的理由**:CLAUDE.md 明定「先测量后优化,不做无凭据的过早优化」。当前 `_physics_process` 仅占帧预算 3.6%500 敌 + 1500 弹 ≈ 0.6ms),方案 3 是在为尚未测到的瓶颈增加复杂度。**触发条件**:验收第 6 项附近若实测到「敌人在场但均超出射程」时出现帧耗时尖峰(`_physics_process` 超过 2ms),立即启用。
重选**失败**时原实现不留任何记录,于是该子弹余生每帧都重跑 `_find_nearest_unvisited``query_circle(pos, 400)`(约 196 格 + 一次 `PackedInt32Array` 分配)。`get_active_count() == 0` 空场守卫只挡住「全场清空」—— 而这是本游戏里最罕见的状态,两种日常状态都没挡住:
1. **敌人在场但全在 `homing_range` 外**(玩家朝空处开火)—— 本节原文已预见。
2. **射程内敌人全在 `visited_targets` 里** —— **原风险分析漏掉了这一种,是本次发现,且比第 1 种更糟**bounce+homing 组合弹打空一个小簇后 `visited_targets` 只增不减,对那颗子弹是**永久状态**而非瞬态;同时 `visited` 的线性 `in` 扫描叠加在 `query_circle` 之上,单弹成本更高。
原分析把 R-H1 定性为「瞬态尖峰」是不准确的 —— 存在使其长期化的路径。
### 5.2 实测(启用前 vs 启用后)
测法:运行时直调 `BulletManager._gd_integrate(1/60)` 计时,20 帧取均值。30 个敌人置于环上(`far` 环半径 3000`visited` 环半径 300 —— 在 `homing_range` 400 内但在碰撞半径 20 外),子弹簇于原点附近,`homing_strength=3.0``homing_range=400`。对照基线为同规模无冷数据普通子弹。
| 场景 | 1500 弹(启用前) | 300 弹(启用前) | 1500 弹(启用后·摊薄) | 300 弹(启用后·摊薄) |
| :-- | --: | --: | --: | --: |
| 基线(普通子弹,无归航) | 1.630 ms | 0.324 ms | 1.642 ms | 0.324 ms |
| ① 敌人全在射程外 | **15.790 ms** | **3.135 ms** | 3.939 ms | 0.784 ms |
| ② 射程内全在 visited | **21.965 ms** | **4.341 ms** | 4.518 ms | 0.884 ms |
单弹成本:① 10.53 µs/帧、② 14.64 µs/帧。两种场景在 300 弹规模分别为 3.14 / 4.34 ms**均已越过本节 2.0 ms 触发线**,故触发条件成立。
「启用后」的摊薄值按 60fps 下 12 帧中 1 帧付全价计算 `(重选帧 + 11 × 退避帧) / 12`。分解:
| 场景 · 规模 | 退避帧(零查询) | 重选帧(全价) | 摊薄 |
| :-- | --: | --: | --: |
| ① 1500 弹 | 2.856 ms | 15.851 ms | 3.939 ms |
| ① 300 弹 | 0.567 ms | 3.167 ms | 0.784 ms |
| ② 1500 弹 | 2.915 ms | 22.150 ms | 4.518 ms |
| ② 300 弹 | 0.570 ms | 4.340 ms | 0.884 ms |
### 5.3 已启用的方案 3(退避)
重选失败时往冷数据写 `homing_retry_at = Time.get_ticks_msec() + HOMING_RETRY_MS`(**200 ms**),窗口内子弹直行且不再查询,60fps 下把最坏情况摊薄约 **12×**。要点:
- **首次锁定仍是即时的** —— 无该键时 `get` 返回 0 必小于 `now`,退避只作用于失败路径,§2.0 的手感不回退(运行时已断言首帧即锁定且不写退避键)。
- 重选**成功**即 `erase("homing_retry_at")`,恢复 §2.4 的零查询稳态。
- 顺带同期落地:`homing_strength` 判别提到 `_gd_integrate` 调用方,带冷数据但不归航的子弹(纯 pierce/bounce/状态/荷载,真实 build 里占多数)不再付一次完整函数调用 —— 1500 纯 pierce 弹 2.107 → 1.812 ms。
### 5.4 未收敛的残余(待定,需决策)
摊薄后 300 弹规模已回到预算内(0.78 / 0.88 ms),但 **1500 弹规模的摊薄值 3.9 / 4.5 ms 仍高于 2.0 ms**,且更值得注意的是**重选帧本身仍是 15.9 / 22.2 ms 的单帧尖峰**。
原因是**退避会同步而非打散**:同一帧失败的所有子弹拿到同一个 `now`,退避期一致,12 帧后又整齐地一起重试。同一波齐射的子弹天然同相,此后不会自行错开。
候选解(评审提出,**尚未实现,待决策**):按 `(Engine.get_physics_frames() + bullet_idx) % 12` 错帧重选,把重试分散到 12 帧上消除尖峰;或更小改动 —— 给退避时长加与 `bullet_idx` 相关的抖动。1500 颗同时归航且同时重选失败属压力上限而非典型局面,故未擅自扩大本次改动范围。
## 6. 非目标(YAGNI