最近在做一套动作游戏的技能系统需要实现一个以释放点为中心、向四周瞬间扩散、并把敌人炸飞的冲击波技能。翻来覆去折腾了几个晚上最终回到了 Unity 物理系统自带的 Physics.OverlapSphere 上。这个东西说白了就是一次球形范围检测给定圆心、半径一帧之内把所有撞进这个球里的 Collider 全部捞出来然后你对这些对象做伤害、做推力、做特效冲击波的效果就立住了。这篇文章我就把这几天踩过的坑、调过的参数、最终整理出来的一套可复用方案完整记录下来思路和代码都会给到适合正在做技能系统、爆炸物、道具交互或者 Boss 范围技能的 Unity 开发者参考。1. 先把“冲击波”这三个字拆成可执行的技术需求很多人做冲击波第一反应是去搞视觉特效——扩散光环、粒子爆开、地面尘土。但这些只是表现层冲击波这个玩法需求真正硬核的部分是一个空间查询问题以某个点为中心在某一瞬间拿到半径范围内的所有对象然后对这些对象统一施加伤害和物理力。Unity 里专职干这件事的 API 就是 Physics.OverlapSphere。1.1 为什么偏偏是 OverlapSphere而不是 Trigger 或 SphereCast我做这套技能之前也试过几种别的实现路径逐个说下取舍。一是 OnTriggerEnter / OnTriggerExit。它是持续检测的思路需要先在场景里挂一个球形 Collider等物体进进出出。冲击波是一次性的爆发技能我不想为了它常驻一个触发体而且 Trigger 的进出事件跟技能释放时机很难同步尤其是瞬发技能很容易出现 Collider 还没生成完、该打的人已经跑掉了的情况。二是 Physics.SphereCast。它是沿着一条射线方向去扫适合激光、子弹、穿刺这类定向攻击。冲击波是三百六十度全方向扩散SphereCast 一次只能处理一个方向想铺满整个球面你得转着圈射好几根射线效率和精度都很难看。三是自己手动遍历场景里所有物体用 Vector3.Distance 跟半径比较。小 Demo 里确实能跑但项目里场景物体一多每帧全量遍历的开销就上来了而且这种纯 CPU 计算完全绕过了物理引擎的碰撞管理得不偿失。除非你的目标集合已经预先维护好否则不建议。最终选定 Physics.OverlapSphere是因为它的几何语义和冲击波天然匹配一个点一个半径一次调用返回所有范围内的 Collider。物理引擎自己已经做了空间划分内部有宽相阶段的加速结构查询效率有保障。它天生就是干这个的。1.2 OverlapSphere 的两个版本性能差别比想象中大Unity 的这个 API 有两个重载版本新手很容易忽略。第一个是基础版本返回 Collider[]Collider[] hits Physics.OverlapSphere(center, radius);第二个是 NonAlloc 版本把结果写入你预先准备好的数组int count Physics.OverlapSphereNonAlloc(center, radius, buffer);基础版本每次调用都会在托管堆上分配一个新的数组。爆炸技能本身调用频率不高偶尔用一次问题不大但如果你做的是连发技能、多段爆炸、或者场景里同时有好几个冲击波在释放频繁分配数组的话GC 压力会明显上来。实测下来同样的爆炸连发场景从 OverlapSphere 换成 NonAlloc 版本之后一次角色技能连招打完GC 分配量能降一大截。所以我的建议很直接就算你只做一次爆炸也养成用 NonAlloc 版本的习惯。多写一个数组的代码量几乎为零但换来了长期稳定的性能表现。2. OverlapSphere 的核心参数每一个都得掰扯清楚这个 API 的完整签名长这样public static Collider[] OverlapSphere( Vector3 position, float radius, int layerMask -1, QueryTriggerInteraction queryTriggerInteraction QueryTriggerInteraction.UseGlobal );每个参数都有讲究特别是后面这两个很多坑都是从这里出来的。2.1 LayerMask 不是 Layer Index是位掩码Unity 里每个 GameObject 可以指定一个 Layer从 0 到 31一共 32 层。LayerMask 这个东西虽然名字里带 Layer但它存的不是“第几层”这个整数而是一个 32 位的整型位掩码每一位对应一个 Layer。也就是说如果你想把第 6 层纳入检测范围不能写// 这是错的int 6 表示的是第 1 层和第 2 层这两位不是第 6 层 int mask 6;正确写法是int mask 1 6;或者干脆用 Unity 提供的工具方法int mask LayerMask.GetMask(Enemy, Obstacle);我在项目里最省事的做法是声明一个 public LayerMask 字段。这样 Inspector 面板会直接显示一个多选框美术和策划不用碰代码就能配置哪些层能被冲击波命中不容易出错。public LayerMask targetMask;用的时候直接传给 OverlapSphere 就行。注意这里踩过一个很深的坑。有次我图省事直接在代码里传了 6 这个整数结果所有敌人一个都炸不到反而场景里的地面和墙体全被炸飞了。原因就是 6 这个值的二进制是 000110对应的是第 1 层和第 2 层而当时场景里地面刚好在第 2 层。物理检测参数的配置问题后来我基本都是用 Inspector 拖选加打印日志双重确认不再凭记忆写数字。2.2 QueryTriggerInteractionTrigger 到底算不算目标这个参数决定 OverlapSphere 是否返回带 Trigger 的 Collider。默认值是 UseGlobal也就是跟随 Project Settings 里 Physics 面板的 Queries Hit Triggers 开关。很多项目这个开关是关着的所以 OverlapSphere 默认检测不到 Is Trigger 的 Collider。这对应一个很实际的需求反差冲击波技能很多时候需要命中的目标比如陷阱区域、传送门、可交互 NPC它们的碰撞体就设成了 Trigger。如果你用默认参数去查这些对象会全部漏掉。处理方式是在调用时显式指定Physics.OverlapSphereNonAlloc(center, radius, buffer, targetMask, QueryTriggerInteraction.Collide);反过来如果你不希望冲击波触发那些纯 Trigger 的区域比如技能指示器范围圈就传 QueryTriggerInteraction.Ignore。这个参数别看它不起眼配置错了经常出现“明明在范围内却打不到东西”的诡异问题。2.3 NonAlloc 缓冲数组的容量设计NonAlloc 版本需要你自备一个 Collider 数组。这里有个很容易被忽略的点如果场景里实际命中的 Collider 数量超过了数组长度多出来的对象不会被报错而是被直接丢弃。我第一次用的时候数组开的 32然后一个爆炸炸了 40 个碎片对象最后只有前 32 个有反应排查了好久才发现是缓冲区不够。给个经验值角色技能冲击波32 到 64 基本够用如果是爆炸桶连锁引爆、场景破坏之类动辄上百碎片的场景直接用 128 甚至 256。数组里存的是引用不是结构体拷贝开大一点的内存代价可以接受换来的是不会“悄悄吞对象”的安心感。private Collider[] buffer new Collider[128];2.4 返回值里的 Collider 没有排序需要自己处理OverlapSphere 返回的 Collider 数组顺序是不保证的。它取决于物理引擎内部的空间划分和碰撞体存储顺序不是你脑子里以为的“从近到远”。所以如果你想做距离衰减的伤害不要指望数组第一个就是离爆炸中心最近的物体。正确做法是在拿到结果之后自己遍历一次用 Vector3.Distance 或者 (其他物体位置 - 中心点).sqrMagnitude 去做排序或者逐个体积衰减计算。后面第三部分我会给出具体的衰减处理代码。3. 徒手写一个可复用的冲击波工具类这一块才是正文。我把工具类拆成几层检测层负责捞 Collider、衰减层负责算倍数、施力层负责推物体、事件层负责通知外部表现。每层各干各的后续维护起来不头大。3.1 完整的冲击波工具类代码using System; using System.Collections.Generic; using UnityEngine; public class ImpactWave : MonoBehaviour { [Header(范围与伤害)] public float radius 10f; public float baseDamage 50f; public AnimationCurve falloffCurve AnimationCurve.Linear(0f, 1f, 1f, 0f); [Header(物理推力)] public float pushForce 30f; public ForceMode forceMode ForceMode.Impulse; public float upwardModifier 0.2f; [Header(检测配置)] public LayerMask targetMask -1; public bool hitTriggers false; public bool obstacleCheck false; public LayerMask obstacleMask -1; public int bufferSize 128; private Collider[] _buffer; private ListCollider _targets new ListCollider(64); private RaycastHit[] _obstacleHit new RaycastHit[1]; public event ActionCollider, float OnWaveHit; public event ActionVector3 OnWaveFired; private void Awake() { _buffer new Collider[bufferSize]; } public void Fire(Vector3 center) { _targets.Clear(); QueryTriggerInteraction query hitTriggers ? QueryTriggerInteraction.Collide : QueryTriggerInteraction.Ignore; int count Physics.OverlapSphereNonAlloc( center, radius, _buffer, targetMask, query ); for (int i 0; i count; i) { Collider col _buffer[i]; if (col null || !col.gameObject.activeInHierarchy) continue; _targets.Add(col); } OnWaveFired?.Invoke(center); ApplyEffect(center); } private void ApplyEffect(Vector3 center) { for (int i 0; i _targets.Count; i) { Collider col _targets[i]; Vector3 targetPos col.transform.position; Vector3 dir targetPos - center; float distance dir.magnitude; if (distance radius) continue; float normalizedDist distance / radius; float factor falloffCurve.Evaluate(normalizedDist); if (obstacleCheck IsBlocked(center, targetPos)) { continue; } float damage baseDamage * factor; OnWaveHit?.Invoke(col, factor); ApplyPhysicsForce(col, center, factor); } } private bool IsBlocked(Vector3 center, Vector3 targetPos) { Vector3 origin center Vector3.up * 0.15f; Vector3 dest targetPos Vector3.up * 0.5f; Vector3 direction dest - origin; float distance direction.magnitude; int hitCount Physics.RaycastNonAlloc( origin, direction.normalized, _obstacleHit, distance, obstacleMask ); return hitCount 0; } private void ApplyPhysicsForce(Collider col, Vector3 center, float factor) { Rigidbody rb col.attachedRigidbody; if (rb null) return; Vector3 dir col.transform.position - center; if (dir.sqrMagnitude 0.001f) { dir Vector3.up; } dir.Normalize(); Vector3 force dir * (pushForce * factor); force.y upwardModifier * pushForce * factor; rb.AddForce(force, forceMode); } }这套类的设计思路是这样的检测跟表现完全分离。Fire 方法只负责执行一次环形扫描把命中的 Collider 和衰减倍数通过事件抛出去。爆炸特效、伤害飘字、音效、相机震动全部挂在外部监听 OnWaveHit 的模块里冲击波类本身完全不关心这些好扩展也不容易互相污染。3.2 衰减曲线的选择别写死公式用动画曲线伤害衰减我用的不是固定公式而是 AnimationCurve。默认初始化的 Linear 曲线是 0 到 1 线性衰减好处是直观。但实际调试的时候你会发现线性衰减在近处太凶、远处太弱手感并不好。比如技能想做成“近处直接秒杀中距离重创边缘擦伤”线性曲线就很难调。这时候 AnimationCurve 的优势就出来了直接在 Inspector 面板里拉曲线不用重新编译。我把曲线也放到 FalloffCurve 字段里让策划自己调。你甚至可以调成凹曲线让边缘伤害比例更高模拟那种“波压到边缘突然炸开”的特殊手感。技巧想让曲线在不同距离的效果可视化可以在 OnDrawGizmosSelected 里用 Gizmos.DrawWireSphere 画一下范围再配合 Debug.Log 打印每个目标的衰减倍数调试效率会高很多。3.3 物理推力AddExplosionForce 和自己算方向我选后者Unity 自带一个 AddExplosionForce专门做爆炸力rb.AddExplosionForce(pushForce, center, radius, upwardModifier, ForceMode.Impulse);它内部已经做了距离衰减也会自动把物体沿着“从爆炸中心指向物体”的方向推出去。乍一看很省事但我用下来发现两个问题。第一它自带一个向上的额外分量upwardModifier 默认就是 0但实际设置稍大一点物体就会飞得老高。比如你要做一个贴地冲击波希望敌人被横向推飞而不是抛到天上AddExplosionForce 得把 upwardModifier 调到负数才行但负数又会导致物体往地下钻手感很怪。第二它和我们的 AnimationCurve 衰减是两套衰减逻辑。如果我在 ApplyEffect 里已经用 factor 衰减了一次伤害再传给 AddExplosionForce 一个已经衰减过的力它内部又会按距离衰减一次最后结果是双重衰减同一个技能在不同距离的力度衰减得特别快远处物体根本没反应。所以最终我选择了自己算方向加力的方案。代码里就是先求方向向量归一化再乘以 pushForce * factor并且把向上分量单独用 upwardModifier 控制。这个方案按说应该会有更多的物理计算量但实际测试下来并没有额外效率问题因为是对每个目标执行一次 AddForce跟 AddExplosionForce 的调用成本基本一致。优点是全部参数都看得见、调得动不会出现“不明原因的双重衰减”。3.4 遮挡判定避免隔墙炸到人冲击波能不能穿墙是设计上要拍板的一件事。我在工具类里预留了一个 obstacleCheck 开关打开后每个目标都要过一遍遮挡检测从爆炸中心向目标位置发一根射线射线被障碍层挡住就视作“被墙隔开”跳过该目标。这里有个小细节射线起点和终点都不要直接用爆炸中心和物体中心。爆炸点可能埋在地面下或者贴着墙面射线起点如果不做偏移一发射出去就撞到地面导致所有目标都被判定为“被遮挡”。实战里我给起点加了 0.15 的上抬目标点加了 0.5 的上抬同时终点取的是物体位置不是 Collider 的 bounds.center这样射线在水平方向上更接近真实视线竖直方向上不会因为地面问题误杀。不过要提醒一点这个遮挡检测用的是逐目标射线目标数量多的时候会增加额外开销。如果一次爆炸命中了几十个目标每发 3 根射线几十根射线发出去了性能消耗需要留意。如果场景里遮挡物不多、或者本身就想要“穿墙波”的设计这个开关可以直接关掉。4. 让冲击波“看起来像冲击波”表现层的完整拼图物理检测和受力做完了冲击波已经具备“把物体震开”的能力。但玩家感知上还缺很多东西这一块我做表现层时走了不少弯路也总结出几个必须做、做了立刻见效的项。4.1 事件回调是实现表现层解耦的关键工具类里预留的 OnWaveHit 事件作用在这一步彻底体现出来。冲击波只管命中谁、衰减多少倍。至于这个敌人该掉多少血、该弹出什么伤害数字、该播放什么受击动画、脚下该出现什么特效由外部系统监听事件自己去处理。举个例子我在技能系统里加了一个监听wave.OnWaveHit OnImpactTarget; private void OnImpactTarget(Collider target, float factor) { Damageable dmg target.GetComponentDamageable(); if (dmg ! null) { dmg.TakeDamage(Mathf.RoundToInt(baseDamage * factor)); } VFXManager.Instance.PlayHitEffect( target.transform.position, factor ); }这样技能释放者不需要知道目标到底是个有血条的怪物、还是场景里的可破坏物监听方根据自身需求各取所需。做新表现的时候我只需要再挂一个监听脚本不需要动冲击波这个类。4.2 视觉波纹比想象中简单得多冲击波扩散光圈的视觉实现最省事的方案是用一个 Plane 或者 Quad 加一个 Shader 做同心圆扩散。你在 Shader 里传一个 _WaveOffset 参数用 fract 和 smoothstep 控制一个圆环从内向外扩散。另一个更常用的方案是用粒子系统一个 Circle 形状的粒子发射器让粒子在发射瞬间向外扩张配合一个快速淡出的特效材质。实测下来粒子方案的性价比最高引擎自带工具就能做不需要额外写 Shader。4.3 相机震动几行代码就能提升打击感冲击波释放的瞬间相机哪怕只震 0.15 秒手感上限都会明显提升。我自己写了段极简的震屏协程public IEnumerator ShakeCamera(float intensity, float duration) { Vector3 origin transform.localPosition; float elapsed 0f; while (elapsed duration) { float t elapsed / duration; float noise intensity * (1f - t); transform.localPosition origin Random.insideUnitSphere * noise; elapsed Time.deltaTime; yield return null; } transform.localPosition origin; }这只是一段极简版本。正式项目里震动曲线建议做成 AnimationCurve前 20% 的帧振幅拉大后面快速回落视觉上比匀速衰减更有冲击力。4.4 音效和慢动作压垮骆驼的最后两根稻草Low 频鼓点音、远处延迟传来的碎裂声、爆炸瞬间短暂的时间缩放Time.timeScale 调到 0.1 持续 0.05 秒再拉回来——这几个手段叠加起来冲击波的整体表现就算立住了。时间缩放可以借助 DOTween 或者协程实现注意在联机环境或者 UI 动画里尽量别碰全局时间否则会连同其他系统的动画一起被拖慢。5. 常见问题与排障实录这些坑我都替你踩过了5.1 冲击波放出去敌人一点反应都没有这是最高频的问题。排查步骤按顺序走第一个查 LayerMask。反射到代码里检查传进去掩码是否包含目标所在的层最简单的方法是点击场景里的目标物体看 Inspector 右上角的 Layer 是什么再用 LayerMask.NameToLayer 打印确认。第二个查 QueryTriggerInteraction。如果目标身上的 Collider 是 Is Trigger默认情况下 OverlapSphere 是检测不到的需要显式传 QueryTriggerInteraction.Collide。我建议直接在工具类里把 hitTriggers 开关打开然后逐个排除。第三个查 Collider 和 Rigidbody 是否正常。有 Collider 没 Rigidbody 的物体物理查询能查到但不会受到力有 Rigidbody 但 Collider 是静态的也不会动。冲击波要炸飞东西目标身上必须有 Rigidbody。5.2 检测到了目标但伤害没生效伤害不生效通常是回调链路的问题。确认外部监听是否挂在正确的实例上确认 OnWaveHit 事件是否在 Awake 里完成订阅。另一个隐形坑是OverlapSphere 命中的 Collider 可能引用已经销毁的物体如果没有判空就直接访问 transform会在控制台刷一堆 MissingReferenceException。我在 Fire 方法里做了 activeInHierarchy 判断但仍然建议在监听回调里也做一次判空。5.3 爆炸把场景炸得到处都是碎片物理完全失控如果想让冲击波“破坏”一个稳定的场景但又不希望物体被推得太离谱可以在被影响物体上加一个最大速度限制。Rigidbody 有一个 maxAngularVelocity 属性线性速度限制则要手动控制。简单做法是在 FixedUpdate 里判断 rb.velocity.magnitude超过阈值就做规格化缩放到阈值。另一个思路是给受击物体增加线性阻尼drag让冲击波带来的初速度快速衰减物体飞出去很快就停下来。5.4 隔墙依然炸到人遮挡检测形同虚设这个问题的根源大概率出在射线起点和终点的偏移量上。爆炸点紧贴墙面时起点在墙的碰撞体内部Raycast 会直接命中自己脚下的地面或墙体。把起点和终点都适当抬高并让终点略高于目标位置能有效减少这类误判。如果还不行就在遮挡检测结果出来时打印一根 Debug.DrawLine可视化看一眼射线到底有没有穿过墙。5.5 OverlapSphere 的检测结果好像滞后了一帧物理引擎的碰撞状态是在 FixedUpdate 步进中更新的。如果你把 OverlapSphere 放在 Update 里调用读取到的碰撞体位置信息可能是上一物理帧的旧数据。对于一次性爆炸技能影响不大但如果要做持续性伤害区域、或者需要精确追踪快速移动的目标建议把检测放到 FixedUpdate 里或者在调用前主动 Sync 物理结果。我实测下来把连续检测逻辑切到 FixedUpdate 后目标漏检率明显下降。5.6 性能问题爆炸一多就掉帧常见原因有三个方向。一是用了基础版 OverlapSphere 导致 GC 分配频繁换成 NonAlloc 并预热缓冲区就解决大半。二是缓冲区数组开得太小命中对象被丢弃后会表现为表现缺失反复排查会浪费大量时间建议开大后再压测。三是遮挡检测射线太多如果命中目标特别多射线数量和物理查询次数会指数级上升这种情况建议缓存遮挡检测结果、或者对目标数量做上限限制。下面把高频问题和排查方式整理成速查表方便现场救火时直接对着看症状最可能的原因解决方式目标完全无反应LayerMask 配置错误、Trigger 未命中用 LayerMask.GetMask 或 Inspector 拖选显式指定 QueryTriggerInteraction检测到却没有力缺少 Rigidbody、或 Collider 为静态给目标加 Rigidbody检查 Collider 类型有伤害但没被推飞力臂衰减计算重复不要同时使用 AddExplosionForce 和手动衰减场景物体满天飞upwardModifier 设置过火调小或设负值给 Rigidbody 增加 drag爆炸后掉帧使用基础 OverlapSphere、GC 分配换 NonAlloc 版本预热缓冲区结果数量不对像“吞”了目标缓冲区数组长度不够加大缓冲区到 128/256隔墙伤人的 Bug 复现不了射线起点偏移不合理抬高起点和目标点打印 Debug.DrawLine 观察6. 进阶玩法从“能用”到“好玩”的几种扩展基础冲击波做完了想让它更进一档有几个方向都是 OverlapSphere 顺手就能扩展出来的。6.1 多段波纹扩散如果是想表现“波从中心逐渐向外推”的感觉可以在短时间内连续多次调用 Fire每次把 radius 从小到大递增。比如在 0.3 秒内依次调用 radius 4、8、12 三发中间间隔 0.1 秒。这样做出来的效果是冲击波像一圈圈声呐波纹一样推出去伴随粒子表现会很带感。代价是多次物理查询但间隔开来的调用频率完全可以接受。6.2 可破坏物链式反应当 OverlapSphere 命中一个带“可破坏”标签的物体时那个物体触发自身爆炸再以它为圆心调用一次新的 Fire。这样的链式扩散会让场景像多米诺骨牌一样连环炸开。实现上要注意对同一物体去重别让 A 引爆 B 后 B 又引爆 A 死循环加一个 HashSet 记录已引爆对象就能解决。6.3 布娃娃物理的反馈角色被冲击波命中时如果是布娃娃系统可以让每个骨骼的 Rigidbody 都施加爆炸力。做法是先用 OverlapSphere 找到角色的根碰撞体再一次性获取它的 Ragdoll 子物体列表并逐个加力。这个比直接作用于根节点要自然得多角色会以更真实的姿态被掀翻飞起。6.4 大批量目标场景下用 Job System 优化如果场景里同时存在数百上千个可被冲击波影响的对象物理查询本身开销并不大真正瓶颈在于后续逐个施加力时的托管层调用成本。这种场景下可以考虑把命中筛选、距离计算放到 IJobParallelFor 里做主线程只需要根据作业结果执行最终表现。不过这属于进阶策略常规项目做到 NonAlloc 合理缓冲区就已经足够稳定了。我在实际使用中发现冲击波这套东西真正把效果拉开差距的不是检测代码本身而是衰减曲线和力的作用手感。有一次我把 falloffCurve 调成前段平缓、后段陡降的形状配合推力的 upwardModifier 设成 0.1原本平平无奇的爆炸突然有了“厚重感”远处敌人被震飞、近处敌人被狠狠砸进墙体手感完全不一样。你要是自己上手调试建议先别动代码光调曲线和力度参数就能折腾出很多花样。最后再分享一个小技巧做冲击波调试的时候在 OnDrawGizmosSelected 里把检测半径画出来把每个目标的衰减倍数用 Debug.Log 打出来一次释放就能看见“从近到远谁该掉多少血”比起凭感觉调参要靠谱得多。如果后续还想接技能编辑器的模板配置把 radius、falloffCurve、pushForce 这几个字段做成可以序列化的配置资产一套冲击波工具就能服务于好几个技能复用率直接拉满。