docs(spec): §5 补「朴素退避会同步而非打散」二次发现 + 抖动方案 + 单帧峰值对比
新增 §5.4「二次发现」:固定退避下尖峰每 12 帧复发(附逐帧耗时序列证据), 根因是同帧失败的子弹到期时刻相同、被退避锁进同相;这与「退避能摊薄尖峰」的 直觉相反,且摊薄均值会掩盖它 —— 后来人写类似退避/重试逻辑会踩同一个坑,故写清。 含抖动实现、为何用占比式而非 bullet_idx % 窗口宽度、HOMING_RETRY_MS 一值两用的理由。 §5.5 抖动前后单帧峰值对比表(稳态峰值全部回到帧预算内,超预算帧数 0/39)。 §5.6 记录残余:初次锁定帧(1500 弹 16.2/22.0ms)非退避问题,而是「首次锁定即时」 手感属性的直接结果,压下它需牺牲手感,属设计决策,未擅自处理。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -211,21 +211,52 @@ MODIFIER(`type 1`)的 meta schema 加:
|
||||
| ② 1500 弹 | 2.915 ms | 22.150 ms | 4.518 ms |
|
||||
| ② 300 弹 | 0.570 ms | 4.340 ms | 0.884 ms |
|
||||
|
||||
### 5.3 已启用的方案 3(退避)
|
||||
### 5.3 已启用的方案 3(退避 + 抖动)
|
||||
|
||||
重选失败时往冷数据写 `homing_retry_at = Time.get_ticks_msec() + HOMING_RETRY_MS`(**200 ms**),窗口内子弹直行且不再查询,60fps 下把最坏情况摊薄约 **12×**。要点:
|
||||
重选失败时往冷数据写 `homing_retry_at`,窗口内子弹直行且不再查询,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 未收敛的残余(待定,需决策)
|
||||
### 5.4 二次发现:**朴素退避会同步,而非打散**(本次最值得记住的一条)
|
||||
|
||||
摊薄后 300 弹规模已回到预算内(0.78 / 0.88 ms),但 **1500 弹规模的摊薄值 3.9 / 4.5 ms 仍高于 2.0 ms**,且更值得注意的是**重选帧本身仍是 15.9 / 22.2 ms 的单帧尖峰**。
|
||||
落地固定 200 ms 退避后按真实 60fps 起搏复测(每轮补足 16.67 ms 墙钟,只对 `_gd_integrate` 计时),发现**尖峰并未消失,只是变成周期性复发**。1500 弹 `visited` 场景逐帧耗时(ms):
|
||||
|
||||
原因是**退避会同步而非打散**:同一帧失败的所有子弹拿到同一个 `now`,退避期一致,12 帧后又整齐地一起重试。同一波齐射的子弹天然同相,此后不会自行错开。
|
||||
```
|
||||
抖动前: 21.9 2.8 2.9 2.9 2.9 2.9 2.9 2.9 2.9 2.9 2.8 3.1 | 21.8 2.8 ... | 21.0 2.9 ... | 20.9 3.7 3.9 3.8
|
||||
↑第1帧 ↑第13帧 ↑第25帧 ↑第37帧
|
||||
```
|
||||
|
||||
候选解(评审提出,**尚未实现,待决策**):按 `(Engine.get_physics_frames() + bullet_idx) % 12` 错帧重选,把重试分散到 12 帧上消除尖峰;或更小改动 —— 给退避时长加与 `bullet_idx` 相关的抖动。1500 颗同时归航且同时重选失败属压力上限而非典型局面,故未擅自扩大本次改动范围。
|
||||
**恰好每 12 帧复发一次,永不衰减。** 根因:同一帧失败的所有子弹拿到同一个 `now`,到期时刻完全相同,一个退避周期后又整齐地一起重试。**退避把子弹锁进同相,而不是打散它们** —— 同波齐射本就天然同相,此后再无机会错开。设计退避/重试逻辑时若不显式加抖动,默认结果就是同步而非分散;这与「退避能摊薄尖峰」的直觉相反,摊薄后的均值数字恰恰会掩盖它。
|
||||
|
||||
**修法:抖动到期时刻**(`_apply_homing` 失败路径):
|
||||
|
||||
```gdscript
|
||||
var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count)
|
||||
cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS
|
||||
```
|
||||
|
||||
到期时刻均匀铺满 `[200, 400)` ms ≈ 恰好一个退避周期 ≈ 12 帧。**用「`bullet_idx` 在活跃弹数中的占比」而非 `bullet_idx % HOMING_RETRY_MS`**:后者在弹数少于窗口宽度时只能铺开 `_active_count` 毫秒(20 颗弹 → 20 ms ≈ 1.2 帧,等于没打散),占比式则不论弹数多少都铺满整窗。`HOMING_RETRY_MS` 一值两用且刻意耦合 —— 既是基础退避时长也是抖动窗口宽度,「铺满恰好一个退避周期」正是消峰所需的性质,拆成两个常量反而掩盖这层关系。
|
||||
|
||||
实测打散质量:1500 颗同帧集体失败 → 到期偏移 200~415 ms、1493 个互异值、覆盖 14 帧、单帧最多 120 颗(理想 125)。
|
||||
|
||||
### 5.5 抖动前后单帧峰值对比(60fps 起搏 · 40 帧)
|
||||
|
||||
| 场景 · 规模 | 抖动前·复发尖峰 | 抖动后·稳态峰值 | 稳态超 16.67ms 帧数 |
|
||||
| :-- | --: | --: | --: |
|
||||
| ① 全射程外 · 1500 | 13.6 ~ 15.7 ms(每 12 帧复发) | **4.40 ms** | 0/39 |
|
||||
| ① 全射程外 · 300 | 3.2 ms | **0.92 ms** | 0/39 |
|
||||
| ② 全 visited · 1500 | 20.9 ~ 21.9 ms(每 12 帧复发) | **5.61 ms** | 0/39 |
|
||||
| ② 全 visited · 300 | 4.6 ms | **1.15 ms** | 0/39 |
|
||||
|
||||
复发尖峰彻底消除,稳态全部回到帧预算内。
|
||||
|
||||
### 5.6 残余:初次锁定帧(待决策,非退避问题)
|
||||
|
||||
抖动后仍有**一个**高耗时帧 —— 第 1 帧,1500 弹 `far` 16.2 ms / `visited` 22.0 ms。它**不是**退避尖峰,而是「§2.4 首次锁定即时、不受退避约束」的直接结果:1500 颗归航子弹在同一帧被创建时,它们的首次 `query_circle` 必然落在同一帧。抖动按设计不作用于此(已断言首帧即时锁定且不写退避键,手感不回退)。
|
||||
|
||||
触发条件苛刻:需**同一帧**创建约 1500 颗归航子弹且首次选目标全部失败。典型局面下齐射规模远小于此,且失败还需叠加「全射程外」或「全 visited」。**未擅自处理** —— 压下它必须牺牲「首次锁定即时」这一手感属性(如首帧也抖动、或连续失败 N 次后彻底放弃归航),属设计决策而非性能调优,留待需要时立项。
|
||||
|
||||
## 6. 非目标(YAGNI)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user