From 71240931af88af355dbede5f6cb73f097171a05e Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 30 Jul 2026 16:12:40 +0800 Subject: [PATCH] =?UTF-8?q?perf(homing):=20=E9=80=80=E9=81=BF=E5=88=B0?= =?UTF-8?q?=E6=9C=9F=E6=97=B6=E5=88=BB=E5=8A=A0=E6=8A=96=E5=8A=A8=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E6=AF=8F=2012=20=E5=B8=A7=E5=A4=8D=E5=8F=91?= =?UTF-8?q?=E7=9A=84=E9=9B=86=E4=BD=93=E9=87=8D=E8=AF=95=E5=B0=96=E5=B3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 二次发现:固定 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 --- scripts/autoloads/bullet_manager.gd | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/autoloads/bullet_manager.gd b/scripts/autoloads/bullet_manager.gd index a22ffb8..f112cc0 100644 --- a/scripts/autoloads/bullet_manager.gd +++ b/scripts/autoloads/bullet_manager.gd @@ -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)