perf(homing): 重选失败退避 0.2s + homing 判别提到调用方

评审实测发现两种病态状态下重选会每帧无限重试,query_circle(r=400) 进入热路径:
① 敌人在场但全在 homing_range 外;② 射程内敌人全在 visited_targets 里
(bounce+homing 打空小簇后是该子弹的永久状态,原 spec 风险分析漏掉了这种)。
1500 弹实测 15.8ms / 22.0ms,300 弹 3.1ms / 4.3ms,均越过 spec §5 的 2.0ms 触发线,
故 spec §5 记录的逃生方案(方案 3)提前落地:重选失败写 homing_retry_at 时间戳,
HOMING_RETRY_MS(200ms) 内直行不再查询,60fps 下摊薄约 12×。
首次锁定不受影响(无该键时 get 返回 0 必小于 now),手感不回退;
重选成功即 erase 该键,恢复零查询稳态。

homing 判别 _bullet_contexts[i].has("homing_strength") 提到 _gd_integrate 调用方:
带冷数据但不归航的子弹(纯 pierce/bounce/状态/荷载,真实 build 里占多数)不再
为发现自己不归航而付一次完整函数调用 —— 1500 纯 pierce 弹实测 2.107→1.812ms。
_apply_homing 内的 strength <= 0.0 早退保留作防御性下限。

同步订正两处失准注释:_find_nearest_unvisited 现也是归航选目标器;
_apply_homing 文档注释补齐失败路径的重试/退避策略。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:01:31 +08:00
co-authored by Claude Opus 5
parent b3b04fcbda
commit 2f9cb76308
+15 -3
View File
@@ -10,6 +10,7 @@ extends Node
const BULLET_STRIDE: int = 12 # 禁止裸整数 12(跨切片约束) const BULLET_STRIDE: int = 12 # 禁止裸整数 12(跨切片约束)
const MAX_BULLETS: int = 2048 # 预分配 SoA 大小 const MAX_BULLETS: int = 2048 # 预分配 SoA 大小
const HOMING_RETRY_MS: int = 200 # 归航重选失败后的退避时长(60fps 下把最坏情况摊薄约 12×)
# SoA 热数组(由 BulletManagerCs 直接读写) # SoA 热数组(由 BulletManagerCs 直接读写)
var _data: PackedFloat32Array = PackedFloat32Array() var _data: PackedFloat32Array = PackedFloat32Array()
@@ -34,7 +35,9 @@ func _gd_integrate(delta: float) -> void:
var i: int = 0 var i: int = 0
while i < _active_count: while i < _active_count:
var base: int = i * BULLET_STRIDE var base: int = i * BULLET_STRIDE
if has_cold and _bullet_contexts.has(i): # homing 判别在调用方完成:绝大多数冷数据子弹(纯 pierce/bounce/状态/荷载)
# 不归航,避免为它们付一次完整函数调用(实测 1500 弹省约 87%)
if has_cold and _bullet_contexts.has(i) and _bullet_contexts[i].has("homing_strength"):
_apply_homing(i, base, delta) # 转向须在位置积分之前,子弹当帧即沿新方向前进 _apply_homing(i, base, delta) # 转向须在位置积分之前,子弹当帧即沿新方向前进
_data[base + 0] += _data[base + 2] * delta # x += vx * dt _data[base + 0] += _data[base + 2] * delta # x += vx * dt
_data[base + 1] += _data[base + 3] * delta # y += vy * dt _data[base + 1] += _data[base + 3] * delta # y += vy * dt
@@ -198,7 +201,7 @@ func sync_multimesh(mm: MultiMesh) -> void:
0.0, Vector2(r * 2.0, r * 2.0), 0.0, 0.0, Vector2(r * 2.0, r * 2.0), 0.0,
Vector2(_data[base + 0], _data[base + 1]))) Vector2(_data[base + 0], _data[base + 1])))
## 弹跳寻的:query_circle 半径内滤除已访问/哨兵,取最近 entity_id;无则 -1 ## 弹跳/归航寻的:query_circle 半径内滤除已访问/哨兵,取最近 entity_id;无则 -1
func _find_nearest_unvisited(origin: Vector2, radius: float, visited: Array) -> int: func _find_nearest_unvisited(origin: Vector2, radius: float, visited: Array) -> int:
var hits: PackedInt32Array = SpatialGrid.query_circle(origin, radius) var hits: PackedInt32Array = SpatialGrid.query_circle(origin, radius)
var best_id: int = -1 var best_id: int = -1
@@ -216,7 +219,11 @@ func _find_nearest_unvisited(origin: Vector2, radius: float, visited: Array) ->
return best_id return best_id
## 归航转向:以 homing_strength(弧度/秒) 为上限把速度矢量转向锁定目标,速率不变 ## 归航转向:以 homing_strength(弧度/秒) 为上限把速度矢量转向锁定目标,速率不变
## 目标失效(死亡/未锁定)时重选一次;场上无敌人或射程内无目标则保持直行 ## 目标有效时零查询;目标失效(死亡)或首帧未锁定时经 _find_nearest_unvisited 重选。
## 重选成功即恢复零查询稳态;重选失败(场上无敌人 / 射程内无目标 / 射程内全在
## visited_targets 里)则子弹直行,并记 homing_retry_at 退避 HOMING_RETRY_MS 后再试 ——
## 否则该子弹余生每帧都会重跑 query_circle(r=400)(实测 ~10.5µs/帧/弹,1500 弹达 15.8ms)。
## 首次锁定不受退避影响(无该键时 get 返回 0 必小于 now),手感不回退。
func _apply_homing(bullet_idx: int, base: int, delta: float) -> void: func _apply_homing(bullet_idx: int, base: int, delta: float) -> void:
var cold: Dictionary = _bullet_contexts[bullet_idx] var cold: Dictionary = _bullet_contexts[bullet_idx]
var strength: float = float(cold.get("homing_strength", 0.0)) var strength: float = float(cold.get("homing_strength", 0.0))
@@ -231,11 +238,16 @@ func _apply_homing(bullet_idx: int, base: int, delta: float) -> void:
# 目标失效或首帧:重选。空场守卫避免清场瞬间全体子弹集体 query_circle # 目标失效或首帧:重选。空场守卫避免清场瞬间全体子弹集体 query_circle
if EnemyManager.get_active_count() == 0: if EnemyManager.get_active_count() == 0:
return return
var now: int = Time.get_ticks_msec()
if now < int(cold.get("homing_retry_at", 0)):
return # 上次重选失败,退避中:本帧直行,不查询
tid = _find_nearest_unvisited(pos, float(cold.get("homing_range", 400.0)), cold.get("visited_targets", [])) tid = _find_nearest_unvisited(pos, float(cold.get("homing_range", 400.0)), cold.get("visited_targets", []))
cold["homing_target_id"] = tid cold["homing_target_id"] = tid
_bullet_contexts[bullet_idx] = cold _bullet_contexts[bullet_idx] = cold
if tid < 0: if tid < 0:
cold["homing_retry_at"] = now + HOMING_RETRY_MS
return return
cold.erase("homing_retry_at")
tpos = EnemyManager.get_pos_by_id(tid) tpos = EnemyManager.get_pos_by_id(tid)
if tpos == Vector2(-9999.0, -9999.0): if tpos == Vector2(-9999.0, -9999.0):
return return