Unity追踪导弹制导算法:纯追踪、前置量与比例导引实战

发布时间:2026/10/26 10:28:11
Unity追踪导弹制导算法:纯追踪、前置量与比例导引实战

简介:面向Unity3D游戏开发者的导弹追踪算法演示工程,适合飞行射击、军事模拟等玩法方向的初中级开发者,用于理解目标锁定、轨迹预测与飞行控制的完整实现路径。压缩包共183个文件、约29.8MB,包含93个dll运行库、61个xml配置与文档、8个config配置文件,以及若干aspx、browser文件和Unity的assets、globalgamemanagers等工程数据,兼顾可运行构建与源码查阅。已有2196人学习下载。项目围绕目标检测、目标运动分析、发射参数计算、PID式飞行修正和碰撞检测结束等环节展开,读者可据此拆解导弹动态追踪的实现方式,并扩展到多目标、机动目标等情形。工程内场景、脚本、模型与纹理等资源组织清晰,便于按模块对照源码复现实验,也可作为课程设计或个人练习的二次开发基础。

1. 从"导弹总在目标旁边打转"说起

做塔防或者空战 Demo 的时候,最容易被玩家一眼看穿的细节,就是导弹会不会拐弯。直着飞的叫火箭弹,能贴着掩体绕过去、还带一点提前量的才叫追踪导弹。这份 Unity 导弹追踪算法演示把这件事拆成了几套可切换的制导逻辑——纯追踪、前置量追踪、比例导引,再配上一整套锁定、命中、回收的发射链路。它适合正在做弹幕、塔防、空战原型、想直接把追踪弹塞进自己项目的开发者,也适合被"我的导弹为什么绕着目标画圈"折磨过的人。下面按"这东西怎么组织的、算法怎么写、链路怎么跑、坑在哪、怎么改成自己的"顺序拆。

2. 项目结构与技术选型:一套制导逻辑该拆成几个脚本

先把结论摆出来:追踪导弹这个需求,用一句"每帧让导弹看向目标"是写不出来的。真正能进项目的写法,必须把"选谁打""往哪拐""拐多快""打中了怎么算"这四件事拆开,各自有独立的脚本和独立的可调参数。这一章讲清楚这份 Demo 的分层方式和工程约定,后面几章的代码都挂在这个骨架上。

2.1 从"导弹追目标"这句需求里拆出四个可替换的角色

我一般把追踪弹拆成四层,每一层只暴露一两个接口给上层,这样换算法的时候不用动发射器,换目标筛选规则的时候不用动制导。

第一层是发射与目标获取。它负责回答"这发弹该打谁",输入是发射点、朝向、搜索锥角和层遮罩,输出是一个Transform。这一层是纯查询逻辑,不持有状态,方便在编辑期直接调用做调试。

第二层是制导算法。输入是弹体当前的位置、速度、目标位置、目标速度,输出是一个加速度向量或者一个期望朝向。注意这里的关键设计:制导层不应该直接改transform.position,它只输出"我想往哪个方向加速多少",怎么落地由物理层决定。这一层是整份 Demo 的核心,也是三种算法切换的地方。

第三层是弹体物理与视觉。它把制导层给的加速度通过Rigidbody.AddForce施加,同时负责让模型朝向跟随速度而不是跟随目标——这一点新手特别容易写错,模型永远盯着目标会让导弹看起来像在"侧滑"。

第四层是命中判定与生命周期。包括扫掠检测、爆炸结算、对象池回收。这层看着像打杂的,但它决定了 Demo 能不能扛住一屏几十发弹的压力。

四层之间的耦合点只有两个:制导层拿到的目标Transform,和物理层拿到的加速度。把这个边界守住了,后面加"诱饵弹""多目标切换""激光制导"都是加一个类的事。

2.2 为什么用 Rigidbody 而不是直接改 Transform

新手最常见的写法是transform.LookAt(target); transform.Translate(Vector3.forward * speed * Time.deltaTime);。这段代码在空旷场景里跑起来"看着像那么回事",但它有三个致命问题,我在不止一个原型里见过它翻车。

第一,它没有惯性。LookAt是瞬时朝向,导弹可以在单帧内掉头 180 度,视觉上会看到弹体像被一只无形的手瞬间掰过去,完全没有导弹该有的"重量感"。第二,它绕过了物理系统,意味着你的导弹不会和任何Rigidbody产生真实的力学交互,撞到东西时要么直接穿过去,要么产生很假的位移。第三,它和帧率强绑定。你用了Time.deltaTime补偿位移,但LookAt的转向是瞬时的,帧率一变,同一套参数下弹道会完全不一样——这就是那种"编辑器里很准、打包出去打不中"的玄学来源。

用Rigidbody的写法,转向通过MoveRotation或者角速度施加,位移通过velocity或者AddForce施加,物理引擎会帮你处理时间步长、碰撞和惯性。代价是你必须理解FixedUpdate和Time.fixedDeltaTime,以及ForceMode的区别。这份 Demo 选了后者,是对的。

2.3 目录与脚本职责清单

按这类演示工程的常规拆法,脚本一般分成这么几块。名字可能和你的版本有出入,但职责划分基本一致:

脚本职责二次开发时改它的频率
MissileLauncher发射入口,冷却、连发、绑定制导策略中,加弹种时会动
TargetManager视锥筛选 + 打分选目标高,换索敌规则就要改
PurePursuitGuidance / LeadPursuitGuidance / ProportionalNavigationGuidance三种制导算法的实现低,写对了基本不动
MissileMotor施加加速度、限制角速度、驱动模型朝向中,调手感时天天动
MissileHitDetectorSphereCast 扫掠、命中回调低
MissilePool对象池、状态重置低
MissileGizmos场景里画弹道、画拦截点调试期天天用

场景结构上,通常是一个MissileDemo空物体挂着 Launcher 和 Pool,敌人是带Rigidbody+Collider的预制体,导弹预制体上挂Rigidbody(isKinematic = false、useGravity = false)、CapsuleCollider和制导脚本。制导脚本之间是互斥的,同一时刻只启用一个。

提示:导弹的Rigidbody一定要关掉重力(useGravity = false),并且把drag和angularDrag设成 0。很多人调不出手感就是因为drag默认是 0.1,低速时几乎没感觉,高速时把速度吃掉了 20%,参数怎么调都对不上。

2.4 跑起来的第一步:场景搭建与最小可运行配置

拿到工程后别急着运行,先做三件事,能省掉一半的调试时间。

第一,检查Project Settings > Time > Fixed Timestep。默认是 0.02(也就是 50Hz 物理步),对追踪导弹来说偏粗,高速弹一个物理步能飞出两米多,扫掠检测的采样会很稀。我一般调到 0.01,同时把Maximum Allowed Timestep保持在 0.333 以下,避免卡帧时物理直接放弃模拟。

第二,确认导弹和目标的Layer已经配好。制导需要拿到目标的Rigidbody.velocity,命中检测又需要排除自己,如果所有物体都在 Default 层,SphereCast第一帧就会打到自己身上,表现是"导弹刚出膛就炸了"。

第三,给导弹预制体加一个空子物体当"模型根节点",弹体网格挂在它下面,制导脚本只旋转这个子节点,不旋转挂Rigidbody的父物体。这样物理胶囊体的朝向不会被视觉旋转带偏,命中判定的胶囊位置始终是规整的。

配置完成后,把目标拖进场景,给目标加一个简单的匀速移动脚本(比如沿 X 轴来回),然后点运行。如果你看到导弹从发射点出发、画一条弧线逐渐贴近目标并最终撞上,说明基础链路是通的,可以进下一章看算法细节了。

3. 核心算法拆解:纯追踪、前置量与比例导引的三段代码

三种算法解决的其实是同一个问题的三个精度层级。纯追踪只顾眼前,前置量追踪会算一笔"未来账",比例导引则从控制论角度直接压制视线角速度。这一章逐个给出可抄的代码,每段后面说明参数该怎么调、失败时会看到什么现象。

3.1 纯追踪 Pure Pursuit:把速度方向一点点拧过去

纯追踪的逻辑最朴素:每一帧算出从导弹指向目标的向量,然后让当前速度方向朝这个向量旋转一个受限的角度。它的关键不是"看向目标",而是旋转速率受限。

using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class PurePursuitGuidance : MonoBehaviour { [Header("目标与飞行参数")] public Transform target; // 目标挂点,优先取敌方中心而不是脚底 public float speed = 35f; // 巡航速度,固定值,纯追踪不改速度大小 [Header("转向限制")] [Tooltip("最大转向角速度(度/秒)。给到 720 就会变成瞬移拐弯")] public float maxTurnRate = 120f; [Tooltip("离目标多远开始失效,防止末段抖动")] public float minRange = 1.5f; Rigidbody rb; void Awake() { rb = GetComponent<Rigidbody>(); rb.useGravity = false; rb.drag = 0f; } void FixedUpdate() { if (target == null) return; Vector3 toTarget = target.position - rb.position; if (toTarget.sqrMagnitude < minRange * minRange) return; // 1. 期望朝向:直接指向目标 Quaternion targetRot = Quaternion.LookRotation(toTarget.normalized); // 2. 每物理步最多转过 maxTurnRate * dt 度 Quaternion nextRot = Quaternion.RotateTowards( rb.rotation, targetRot, maxTurnRate * Time.fixedDeltaTime); // 3. 只改朝向,速度大小保持不变 rb.MoveRotation(nextRot); rb.velocity = nextRot * Vector3.forward * speed; } }

逻辑上分三步走:算期望朝向、限幅旋转、把速度重新对齐到新朝向。Quaternion.RotateTowards是这里的关键,它保证单步旋转角度不超过给定值,从而产生"转弯半径"这个物理概念。maxTurnRate给 120 度/秒时,35 m/s 的弹速对应的最小转弯半径大约是35 / (120 * π / 180) ≈ 16.7米,这个数字你可以直接拿来和场景尺寸对照,判断参数合不合理。

minRange是为了防末段抖动。当导弹非常接近目标时,toTarget的方向会因为目标微小位移而剧烈变化,LookRotation会算出跳变的角度,表现是导弹在目标脸上疯狂摇头。加一个近距离失效判断,或者改成"距离小于阈值就直接朝目标直线冲",都能缓解。

纯追踪的固有缺陷是追尾。它永远朝着目标当前位置飞,所以打移动目标时弹道是一条不断向后弯的弧线,最终从目标屁股后面咬上去。目标速度越快、导弹速度优势越小,这条弧线越长,命中率越低。它适合打静止目标或者慢速目标,也适合当"低难度模式"的教学对照。

3.2 前置量追踪:解一个近似的拦截三角形

前置量追踪的思路是:不打目标现在的位置,打它"我飞到那儿的时候它会在的位置"。这就变成了一个几何问题——已知我的位置、目标位置、目标速度、我的速度,求拦截点。

严格解是个二次方程,但在游戏里没必要。用几次迭代逼近就够了,收敛快、代码短、还能自然处理"追不上"的情况。

using UnityEngine; public static class InterceptSolver { /// <summary> /// 迭代求解近似拦截点 /// </summary> /// <param name="shooterPos">发射/弹体当前位置</param> /// <param name="targetPos">目标当前位置</param> /// <param name="targetVel">目标当前速度(世界坐标)</param> /// <param name="missileSpeed">导弹速度标量</param> /// <param name="iterations">迭代次数,3 次足够,别写 while(true)</param> public static Vector3 Solve(Vector3 shooterPos, Vector3 targetPos, Vector3 targetVel, float missileSpeed, int iterations = 3) { Vector3 delta = targetPos - shooterPos; // 迭代起点:假设目标静止,先用直线距离估一个飞行时间 float t = delta.magnitude / Mathf.Max(missileSpeed, 0.01f); for (int i = 0; i < iterations; i++) { Vector3 predicted = targetPos + targetVel * t; // 目标 t 秒后的位置 t = (predicted - shooterPos).magnitude / missileSpeed; // 用新距离重算时间 } return targetPos + targetVel * t; } }

这段代码的核心是那个t。第一次估算用的是"目标不动"的假设,所以t偏小;用这个t算出预测位置后,距离变长了,新的t变大;再算一轮,逐步逼近真实拦截时间。三次迭代在绝大多数场景下误差已经小于 1%,再多就是浪费。

有个边界必须处理:如果导弹速度小于目标速度,且目标在远离你,这个迭代会发散,t会越来越大,预测点飞到天边。实战里表现为导弹朝一个莫名其妙的方向飞走。解决办法是给t设上限,比如t = Mathf.Min(t, 8f),超过就退化成纯追踪。这个判断在制导脚本里做,比在求解器里做更合适,因为退化策略是业务逻辑。

拿到拦截点之后,剩下的和纯追踪完全一样——朝那个点转,限幅,对齐速度。所以工程上我会把"算拦截点"和"朝某点飞"拆成两个函数,前置量追踪只是在纯追踪的基础上换了个目标点。

还有一个容易被忽略的点:targetVel不能每帧重新GetComponent<Rigidbody>().velocity,目标多了会很费。常见做法是目标自己维护一个"当前速度",或者制导层在锁定时缓存一次引用然后每FixedUpdate读一次。后者简单可靠,除非你的目标会瞬间变向。

3.3 比例导引:用视线角速度算出过载指令

比例导引(Proportional Navigation)是真实导弹里用得最多的一类制导律,它的核心思想不是"朝目标飞",而是压制视线角速度。直觉上理解:如果目标和我的连线在旋转,说明我现在的航向迟早会打偏;那我就朝"让这条连线不转"的方向施加加速度。连线不转的那一刻,就是碰撞航向。

数学形式是a = N * ω × V,其中ω是视线角速度向量,V是弹体速度,N是导航常数。

using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class ProportionalNavigationGuidance : MonoBehaviour { public Transform target; [Header("导航参数")] [Tooltip("导航常数 N,常用 3~5。越大越激进,小于 2 容易脱靶")] public float navigationConstant = 4f; [Tooltip("最大横向过载(m/s^2),决定最小转弯半径")] public float maxLateralAccel = 80f; [Tooltip("进入末段后解除限幅,全力扑上去")] public float terminalRange = 15f; Rigidbody rb; Rigidbody targetRb; // 缓存,别每帧 GetComponent Vector3 targetVelocity; // 目标丢失时的惯性速度 void Awake() => rb = GetComponent<Rigidbody>(); public void SetTarget(Transform t) { target = t; targetRb = t != null ? t.GetComponent<Rigidbody>() : null; } void FixedUpdate() { if (target == null) return; // 目标速度:有刚体就读刚体,没有就沿用上一次的值 if (targetRb != null) targetVelocity = targetRb.velocity; Vector3 r = target.position - rb.position; // 视线向量 R Vector3 vRel = targetVelocity - rb.velocity; // 相对速度 float r2 = Mathf.Max(r.sqrMagnitude, 0.01f); // 视线角速度 ω = (R × Vrel) / |R|^2 Vector3 omega = Vector3.Cross(r, vRel) / r2; // 比例导引指令:a = N * ω × V Vector3 accel = navigationConstant * Vector3.Cross(omega, rb.velocity); // 限幅,没有这条弹道会像抽风一样抖 if (r.magnitude > terminalRange) accel = Vector3.ClampMagnitude(accel, maxLateralAccel); rb.AddForce(accel, ForceMode.Acceleration); // 视觉朝向跟速度走,不跟目标走 if (rb.velocity.sqrMagnitude > 1f) rb.MoveRotation(Quaternion.LookRotation(rb.velocity)); } }

逐段说明。r是视线向量,vRel是相对速度,两者叉乘得到的是一个垂直于视线的向量,大小与视线旋转的快慢成正比,再除以|R|^2就是在修正距离带来的量纲问题——这套推导在目标很近时特别容易数值爆炸,所以r2用了Mathf.Max兜底。

Vector3.Cross(omega, rb.velocity)这一步是整段代码的灵魂。它给出的加速度方向垂直于速度方向,也就是说它只改变航向,不改变速度大小,这正是导弹该有的行为。如果你把它写成Vector3.Cross(omega, r),方向会不对,弹道会呈现奇怪的螺旋。

ForceMode.Acceleration意味着传进去的值被当作加速度直接处理,Rigidbody的质量不参与计算。这是对的,因为过载参数应该和弹体质量解耦——你换个更重的模型不该改变弹道。千万不要在这里再乘Time.fixedDeltaTime,AddForce在物理步内已经做了时间积分,再乘一次等于把过载缩了 50 倍,这是血泪经验里最常见的一条。

terminalRange那段限幅解除,是为了解决末段响应不足的问题。比例导引的指令大小和视线角速度成正比,当导弹已经非常接近目标、视线几乎不转时,accel会趋近于零,导弹就靠惯性直飞了。近距离下目标稍微一动就脱靶。所以进入末段后放开限幅,让它有能力做最后的机动修正。

3.4 三种算法的参数对照与选型建议

维度纯追踪前置量追踪比例导引
计算量最低低(3 次迭代)中(两次叉乘)
打静止目标好好好
打匀速移动目标差,明显追尾好好
打机动目标差一般最好
参数直观性高,一个转向速率中低,N 值需要试
典型最小转弯半径由 maxTurnRate 决定同左由 maxLateralAccel 决定
适合场景教学、慢速弹、火箭弹塔防、固定路径敌人空战、追逐战、Boss 弹幕

选型上的经验:如果你的项目里目标基本走固定路线、速度不快,前置量追踪性价比最高,参数好调、表现也够看;如果目标会蛇皮走位、需要"贴脸转弯",上比例导引,但要做好N值和过载上限的调参准备。纯追踪我一般只留作对照演示,或者用在"新手难度"的敌人弹上。

4. 发射链路:目标锁定、扫掠命中与对象池回收

算法写对了只是弹道好看,真正决定这个 Demo 能不能改成可用模块的,是发射链路的三件事:打谁、怎么算打中、打完之后怎么收拾。这一章按发射顺序讲。

4.1 锁定目标:视锥筛选加威胁度打分

最省事的写法是GameObject.FindWithTag("Enemy")取第一个,但那是原型阶段的自欺欺人。正确做法是一次OverlapSphere拿候选,然后做两层过滤:角度过滤和打分排序。

using UnityEngine; public class TargetManager : MonoBehaviour { public LayerMask targetMask; public float searchRange = 60f; [Tooltip("搜索锥半角,180 表示全向")] public float searchHalfAngle = 60f; // 复用的缓冲区,避免每次查询都产生 GC readonly Collider[] _buffer = new Collider[32]; public Transform Acquire(Vector3 origin, Vector3 forward) { int count = Physics.OverlapSphereNonAlloc( origin, searchRange, _buffer, targetMask, QueryTriggerInteraction.Ignore); Transform best = null; float bestScore = float.MaxValue; for (int i = 0; i < count; i++) { Vector3 toTarget = _buffer[i].transform.position - origin; // 第一步:视锥过滤,背后的目标直接丢掉 if (Vector3.Angle(forward, toTarget) > searchHalfAngle) continue; // 第二步:打分。这里用距离,也可以叠加血量、威胁等级 float score = toTarget.magnitude; if (score < bestScore) { bestScore = score; best = _buffer[i].transform; } } return best; } }

OverlapSphereNonAlloc和复用的_buffer是关键。用OverlapSphere会每次分配一个新数组,一屏几十发弹同时索敌时,GC 压力会肉眼可见地体现在帧率曲线上。缓冲数组开 32 个够用,超出部分直接截断——真到了 32 个目标挤在一个球里的情况,选谁其实都差不多。

打分函数是可扩展点。距离优先适合"就近攻击",如果要做"优先打治疗兵",把score换成距离 / 威胁权重就行,威胁权重从目标身上的配置读。这套结构我用了很多次,改动成本很低。

注意:forward传的应该是发射器的朝向而不是弹体的朝向。发射瞬间弹体可能还没转正,用弹体朝向做视锥过滤会漏掉正前方的目标。

4.2 高速弹体的命中判定:SphereCast 扫掠

这是最容易翻车的一环。默认的碰撞检测是离散的,物理引擎只在每个物理步的结束时检查两个碰撞体是否重叠。一发 80 m/s 的导弹,物理步 0.02 秒,单步位移 1.6 米;如果目标是个半径 0.5 米的小怪,导弹完全可能"这一步在它前面、下一步在它后面",中间穿过去,什么都不触发。

正确的做法是自己做扫掠检测,用SphereCast从上一步位置打到当前位置:

using UnityEngine; public class MissileHitDetector : MonoBehaviour { public LayerMask hitMask; public float castRadius = 0.35f; public float damage = 30f; Vector3 _lastPos; Rigidbody _rb; void Awake() { _rb = GetComponent<Rigidbody>(); _lastPos = transform.position; } void FixedUpdate() { Vector3 current = _rb.position; Vector3 delta = current - _lastPos; float dist = delta.magnitude; if (dist > 0.001f) { // 以上一步位置为球心,沿位移方向扫一个半径为 castRadius 的球 if (Physics.SphereCast(_lastPos, castRadius, delta.normalized, out RaycastHit hit, dist, hitMask, QueryTriggerInteraction.Ignore)) { OnHit(hit.collider, hit.point); return; } } _lastPos = current; } void OnHit(Collider other, Vector3 point) { // 结算伤害、播放爆炸、回池,具体实现按项目走 var damageable = other.GetComponentInParent<IDamageable>(); damageable?.TakeDamage(damage, point); MissilePool.Instance.Release(gameObject); } }

_lastPos记录的是上一个物理步结束时的位置,所以扫掠的起点和终点之间正好覆盖了整个位移。castRadius给得比实际弹体稍大一点(比如弹体半径 0.25 就给 0.35),能吃掉一部分数值误差,代价是贴着墙飞时容易提前触发。

如果命中判定用在Update里,会出现"渲染帧比物理帧密"导致同一段位移被重复扫两次的问题,所以整套逻辑必须待在FixedUpdate。这一点和制导脚本保持一致,整个链路的时序才是对齐的。

4.3 对象池回收:别让复用弹带着上一发的速度飞出去

Demo 里一发一发Instantiate看不出问题,一旦做到"一屏五十发"就开始卡。对象池本身不难,难的是状态重置,这是真正容易出 bug 的地方。

using System.Collections.Generic; using UnityEngine; public class MissilePool : MonoBehaviour { public static MissilePool Instance; public GameObject prefab; public int prewarm = 20; readonly Stack<GameObject> _idle = new Stack<GameObject>(); void Awake() { Instance = this; for (int i = 0; i < prewarm; i++) { var go = Instantiate(prefab, transform); go.SetActive(false); _idle.Push(go); } } public GameObject Get(Vector3 pos, Quaternion rot, Transform target) { GameObject go = _idle.Count > 0 ? _idle.Pop() : Instantiate(prefab, transform); // 先摆位再激活,避免激活瞬间触发一次错误的碰撞 go.transform.SetPositionAndRotation(pos, rot); go.SetActive(true); // 关键:清掉上一发残留的运动状态 var rb = go.GetComponent<Rigidbody>(); rb.velocity = Vector3.zero; rb.angularVelocity = Vector3.zero; rb.position = pos; rb.rotation = rot; // 拖尾不清会从上一发的爆炸点拉一条线过来 var trail = go.GetComponentInChildren<TrailRenderer>(); if (trail != null) trail.Clear(); // 重置制导状态和目标 var guidance = go.GetComponent<ProportionalNavigationGuidance>(); guidance.SetTarget(target); return go; } public void Release(GameObject go) { go.SetActive(false); go.transform.SetParent(transform); _idle.Push(go); } }

三个重置动作缺一不可。velocity和angularVelocity清零,否则复用的弹会带着上一次爆炸前的速度从池子里窜出去,现象是"导弹从地图角落突然飞出来"。TrailRenderer.Clear(),否则新弹出生时拖尾会从上一发的死亡位置拉一条长线。SetTarget重设,否则新弹会去追上一发的目标,运气不好那个目标已经被销毁了,直接空引用。

Get里先SetPositionAndRotation再SetActive(true)的顺序也有讲究。如果先激活再摆位,激活的那一帧弹体还在池子的父物体原点,会和其他东西发生一帧的错误碰撞,偶尔表现为"导弹刚发射就爆炸"。

5. 避坑与排查:追踪导弹最常见的五个翻车点

这一章全是踩坑记录,每条按"现象、原因、解决"写。如果你现在的导弹表现不对,先在这五条里对号入座。

5.1 导弹绕着目标画圈,永远差一点

现象:弹道是一条漂亮的圆弧,越来越接近目标,但就是咬不上,最后从目标旁边擦过去,然后回头再来一圈,如此往复。

原因:转向速率或过载上限给得太小,最小转弯半径大于导弹与目标之间的实际距离。当距离小于最小转弯半径时,几何上就不可能打中,导弹只能绕着目标做圆周运动。纯追踪里对应maxTurnRate太小,比例导引里对应maxLateralAccel太小。

解决:先算一下你的最小转弯半径R = speed / (turnRate * π/180),把这个数字和场景里目标尺寸、交战距离对比。如果 R 大于交战距离的三分之一,就必须加大转向能力,或者在末段加一条"近距离直冲"的保底逻辑。

5.2 弹速拉到 100 以上就开始穿模

现象:慢速时命中一切正常,把speed调到 100 以上,导弹开始时不时穿过目标而不触发爆炸,越是小目标越容易穿。

原因:物理引擎的碰撞检测是离散的,只在物理步结束时检查重叠。单步位移超过目标尺寸就会漏检。把Collision Detection改成Continuous Dynamic只能兜住一部分情况,因为它是针对Rigidbody之间的,且仍然受物理步长限制。

解决:自己上SphereCast做扫掠,如 4.2 节所示。同时把Fixed Timestep降到 0.01,双保险。改了之后如果还是偶发漏检,检查castRadius是不是给得比弹体还小。

5.3 目标中途死亡,导弹原地僵住或报空引用

现象:敌人被打死销毁后,飞行中的导弹要么停在半空不动了,要么控制台刷一片MissingReferenceException。

原因:制导脚本持有Transform target,Unity 的Transform被销毁后引用会变成"伪空"——target == null返回 true,但访问target.position就会抛异常。另外很多实现里if (target == null) return;直接返回,导弹就失去了所有控制指令,速度虽然有但朝向不变,看起来像僵住。

解决:目标销毁前主动通知所有指向它的导弹,或者在制导层缓存最后一次已知位置和速度,目标丢失后切到"惯性直飞"模式:继续朝最后位置飞,飞到之后自毁。这样视觉上是连贯的,不会出现导弹悬停的诡异画面。

// 目标丢失后的保底:朝最后已知点飞,到了就自毁 if (target == null) { if (_hasLastKnown) { Vector3 toLast = _lastKnownPos - rb.position; if (toLast.sqrMagnitude < 4f) { MissilePool.Instance.Release(gameObject); return; } rb.MoveRotation(Quaternion.RotateTowards(rb.rotation, Quaternion.LookRotation(toLast), 90f * Time.fixedDeltaTime)); } return; }

5.4 编辑器里很准,真机上完全两回事

现象:编辑器里命中率九成,打包到手机上之后导弹明显"迟钝",转弯变慢,命中率掉一半。

原因:两件事叠在一起。一是帧率下降导致Update里的逻辑跑得少了,如果制导逻辑写在Update里,物理步数不变但制导指令更新频率变了,表现就是反应慢。二是物理步长和渲染帧率解耦后,Time.deltaTime在某些帧会非常大,如果代码里有AddForce(accel * Time.deltaTime)这种写法,力的大小就会随时变化。

解决:所有和物理相关的逻辑(制导、命中、运动)全部搬进FixedUpdate,AddForce一律不乘deltaTime。转向限幅用Time.fixedDeltaTime而不是Time.deltaTime。改完之后在低帧率下手动跑一遍,用Application.targetFrameRate = 30模拟一下。

5.5 池子复用后导弹从头顶飞过

现象:连续发射多发导弹,前几发正常,后面的偶尔会从发射点直接朝天上飞,或者一出生就爆炸。

原因:对象池没有完全重置状态。常见的漏项是没清angularVelocity(上一次爆炸前在旋转),或者没重置TrailRenderer,还有一种是Rigidbody.position设了但transform.position没同步,导致第一帧的SphereCast起点是旧位置。

解决:在Release里做完整重置,在Get里再兜一遍。重置清单固定成五行——velocity、angularVelocity、position、rotation、Trail.Clear()。每次往池子里加新字段都回头补这张清单,别指望"我记得住"。

6. 二次开发:把演示脚本改成能塞进项目的制导模块

演示工程和项目代码之间差一层抽象。你现在手里的脚本大概率是把目标、参数、算法全写在一个类里,改动一次弹种就要复制一份文件。这一章讲怎么把它抽成能复用的模块,以及怎么验证改动没有把命中率改坏。

6.1 抽一个 IGuidance 接口

三种算法共同的输入输出完全可以统一:输入是弹体状态和目标状态,输出是一个加速度向量。抽成接口之后,发射器只认接口,不认具体算法。

using UnityEngine; public struct GuidanceContext { public Vector3 position; public Vector3 velocity; public Vector3 targetPosition; public Vector3 targetVelocity; public bool hasTarget; } public interface IGuidance { // 每物理步调用一次,返回世界坐标下的加速度指令 Vector3 ComputeAcceleration(in GuidanceContext ctx, float fixedDeltaTime); void ResetState(); }

三个实现类各自包一层,内部复用第 3 章的算法。弹体上的MissileMotor只依赖IGuidance:

void FixedUpdate() { var ctx = new GuidanceContext { position = _rb.position, velocity = _rb.velocity, targetPosition = _target != null ? _target.position : _lastKnown, targetVelocity = _targetVelocity, hasTarget = _target != null }; Vector3 accel = _guidance.ComputeAcceleration(ctx, Time.fixedDeltaTime); _rb.AddForce(accel, ForceMode.Acceleration); if (_rb.velocity.sqrMagnitude > 1f) _rb.MoveRotation(Quaternion.LookRotation(_rb.velocity)); }

这么拆的好处是加弹种不用改发射器。要做"先直线飞行 0.5 秒再开机追踪",写一个DelayedGuidance包装类,内部持有真正的算法实例,前 0.5 秒返回零向量就行。要做"多级助推",也是同样的套路。参数配置建议用ScriptableObject存,一个资产对应一种弹,策划改数值不用碰代码。

6.2 用批量打靶验证命中率

改完算法最怕的是"感觉好像变准了"。感觉靠不住,得有数字。我一般写一个临时测试脚本,在编辑器里批量生成目标和导弹,统计命中率。

测试项目标运动弹速采样发数观察指标
基线匀速直线40100命中率、平均命中时间
机动目标正弦摆动40100命中率、脱靶距离
高速弹匀速直线100100漏检次数(应为 0)
近距离交战静止40100是否出现绕圈
目标中途死亡15 秒后销毁4050是否僵住、是否报错

跑完一张表再对比改动前后的数字,比任何主观判断都可靠。测试脚本里记得把随机种子固定住,不然两次跑的结果没法比。

还有一个小技巧:在MissileMotor里加一个静态计数器,记录总发射数、命中数、超时自毁数,用一个OnGUI或者Debug.Log定期打出来。开发期挂在屏幕上,弹道一旦变得奇怪,一眼就能从数字上看出是算法退化还是命中判定漏了。

提示:批量测试记得关掉爆炸特效和音效,一百发同时炸会让编辑器卡到怀疑人生,而且很容易误判成"性能问题"。

这套东西我是被坑出来的。早期做弹幕原型时,我调参数全靠肉眼看,改一次跑一次,觉得"这次好像顺一点"就提交了。后来一次打包测试,同一套参数在低帧率设备上脱靶率高了三成,回头查了半天才发现制导逻辑写在Update里。从那以后我每次改完制导参数,都强制走一遍批量打靶加低帧率模拟,两个数字对不上就不提交。希望帮到你。

本文还有配套的精品资源,点击获取