From 3bf04c9fc0046baf33698b930205c301c68aab49 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 30 Jul 2026 16:04:03 +0800 Subject: [PATCH] =?UTF-8?q?docs(spec):=20R-H1=20=E7=94=B1=E3=80=8C?= =?UTF-8?q?=E6=9A=82=E4=B8=8D=E5=AE=9E=E7=8E=B0=E3=80=8D=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E3=80=8C=E5=B7=B2=E5=AE=9E=E6=B5=8B=E8=A7=A6=E5=8F=91=E5=B9=B6?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E3=80=8D=EF=BC=8C=E8=A1=A5=E4=B8=A4=E7=A7=8D?= =?UTF-8?q?=E7=97=85=E6=80=81=E7=8A=B6=E6=80=81=E5=AE=9E=E6=B5=8B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 如实记录: - 原风险分析只预见「敌人全在射程外」,漏掉「射程内敌人全在 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 --- .../specs/2026-07-30-bullet-homing-design.md | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/docs_dev/specs/2026-07-30-bullet-homing-design.md b/docs_dev/specs/2026-07-30-bullet-homing-design.md index 2493c74..8b23aba 100644 --- a/docs_dev/specs/2026-07-30-bullet-homing-design.md +++ b/docs_dev/specs/2026-07-30-bullet-homing-design.md @@ -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-30,Task 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)