perf(homing): 退避到期时刻加抖动,消除每 12 帧复发的集体重试尖峰

二次发现:固定 200ms 退避并没有消除尖峰,只是让它变成周期性复发。按真实 60fps
起搏复测,1500 弹 visited 场景逐帧耗时在第 1/13/25/37 帧稳定出现 ~21ms,永不衰减:
  21.9 2.8 2.9 ... 3.1 | 21.8 2.8 ... | 21.0 2.9 ... | 20.9 3.7 3.9 3.8
根因是朴素退避会把子弹锁进同相而非打散 —— 同帧失败的子弹拿到同一个 now,到期时刻
完全相同,一个退避周期后又整齐地一起重试;同波齐射本就天然同相,此后再无机会错开。
摊薄后的均值数字恰恰掩盖了这一点。

修法是抖动到期时刻,铺满 [200,400) ms ≈ 恰好一个退避周期 ≈ 12 帧。
用「bullet_idx 在活跃弹数中的占比」而非 bullet_idx % HOMING_RETRY_MS:后者在弹数少于
窗口宽度时只铺开 _active_count 毫秒(20 颗弹 → 20ms ≈ 1.2 帧,等于没打散)。
实测打散质量:1500 颗同帧集体失败 → 互异到期值 1493 个、覆盖 14 帧、单帧最多 120 颗
(理想 125)。

单帧峰值(60fps 起搏 40 帧,稳态即排除初次锁定帧):
  ① 全射程外 1500  13.6~15.7ms 复发 → 4.40ms   300  3.2ms → 0.92ms
  ② 全 visited 1500  20.9~21.9ms 复发 → 5.61ms  300  4.6ms → 1.15ms
稳态超 16.67ms 的帧数均为 0/39。

首次锁定即时的行为未受影响(抖动只作用于失败后的重试),速率守恒、单帧转角上限、
退避窗口内零查询、到期后重试成功并 erase 键,断言全绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:12:40 +08:00
co-authored by Claude Opus 5
parent 3bf04c9fc0
commit 71240931af
+14 -2
View File
@@ -10,7 +10,10 @@ extends Node
const BULLET_STRIDE: int = 12 # 禁止裸整数 12(跨切片约束)
const MAX_BULLETS: int = 2048 # 预分配 SoA 大小
const HOMING_RETRY_MS: int = 200 # 归航重选失败后的退避时长(60fps 下把最坏情况摊薄约 12×)
# 归航重选失败后的退避时长(60fps 下把最坏情况摊薄约 12×)
# 一值两用且刻意耦合:既是基础退避时长,也是抖动窗口宽度 —— 到期时刻均匀铺满
# 「恰好一个退避周期」正是消除集体重试尖峰所需的性质,拆成两个常量反而会掩盖这层关系。
const HOMING_RETRY_MS: int = 200
# SoA 热数组(由 BulletManagerCs 直接读写)
var _data: PackedFloat32Array = PackedFloat32Array()
@@ -249,7 +252,16 @@ func _apply_homing(bullet_idx: int, base: int, delta: float) -> void:
cold["homing_target_id"] = tid
_bullet_contexts[bullet_idx] = cold
if tid < 0:
cold["homing_retry_at"] = now + HOMING_RETRY_MS
# 抖动「到期时刻」而非让所有子弹共用固定 HOMING_RETRY_MS:同帧集体失败的子弹
# 拿到同一个 now,退避期完全一致,12 帧后又整齐地一起重试 —— 退避会同步而非
# 打散,同波齐射天然同相故尖峰永不消散(实测 1500 弹 visited 场景 40 帧里 4 帧
# 达 22.5ms,直接击穿 16.67ms 帧预算)。
# 用「bullet_idx 在活跃弹数中的占比」而非 bullet_idx % HOMING_RETRY_MS:后者在
# 弹数少于窗口宽度时只铺开 _active_count 毫秒(20 颗弹 → 20ms ≈ 1.2 帧,等于没
# 打散),占比式则不论弹数多少都铺满整窗。取模仅作越界兜底(循环内 bullet_idx
# < _active_count 已保证不越界),使到期时刻无条件落在 [200, 400) ms。
var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count)
cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS
return
cold.erase("homing_retry_at")
tpos = EnemyManager.get_pos_by_id(tid)