做动作游戏原型时,我遇到一个很典型的场景:手雷落地、Boss震地、角色放冲击波,都需要在"一个瞬间"判定一定范围内的所有单位,并给它们不同的反馈。以前我的第一反应是给爆炸物挂一个SphereCollider并设成Trigger,然后等物理引擎回调OnTriggerEnter,再去处理伤害。这种做法在持续型区域技能上还行,但遇到一次性的冲击波判定,就会出现回调时机不可控、重复触发、爆炸源头自己也被算进去这类问题。换用Unity Physics.OverlapSphere主动查询之后,整套逻辑一下子清晰了,判定变成"你给我一个圆心和半径,我把范围内的碰撞体一次性全部给你",由我自己决定怎么处理。这篇就把这套方案的原理、完整实现、性能优化和真实踩坑记录都梳理一遍,给还在用触发器做范围伤害判断的人一个参考。
1. 范围检测方案对比:为什么冲击波最后选了OverlapSphere
不少新手甚至做了一两年游戏的人,碰到"爆炸范围伤害"都是直接拉一个Trigger碰撞体,这是直觉做法,但不是最优解。要搞清楚OverlapSphere的价值,得先明白触发器方案和主动查询方案的本质差异。
1.1 触发器方案适合持续性区域,不适合瞬发爆炸
触发器的工作方式是这样的:物理引擎在FixedUpdate步进中检测到你的Trigger和其他Collider发生重叠,然后异步派发OnTriggerEnter、OnTriggerStay、OnTriggerExit事件。它适合做"持续性区域",比如火焰地面、毒雾圈、增益光环,因为这些区域需要在一段时间内反复影响进入的单位。
但冲击波是瞬发事件。如果我用Trigger来做,遇到的实际问题是:首先是响应顺序不可控,物理引擎什么时候派发回调不完全由你的游戏逻辑决定,它在FixedUpdate的固定间隔内触发,你要是追求"炸出去的瞬间立即结算伤害",会发现在某些帧率下延迟了几帧才能看到血量变化;其次是重复触发问题,只要Collider还在Trigger范围内,Stay事件会一帧帧地刷,我必须额外加一个"已经结算过该单位"的标记,用着很别扭。还有就是爆炸中心无法精确控制,Trigger响应的是"碰撞体之间的物理接触",哪怕你已经把EffectSource移动到别处,物理引擎里残留的重叠状态还是会把事件发出来。
OverlapSphere不是靠事件派发,而是"此刻我向物理世界发起一次主动查询"。调用一次,它立刻返回当前与球体相交的Collider数组,是你主动把控时机,不受FixedUpdate事件流的影响。对于"手雷爆炸瞬间清算敌人"这种需求,这才是正确工具。
1.2 SphereCast和数学遍历:看起来能用,实际各有缺陷
除了Trigger,我还试过另外两种思路。一个是Physics.SphereCast,它在指定的方向上发射一个球体,返回的是路径上第一个被挡住的碰撞体。这个API适合做子弹飞行、直线冲刺,不适合爆炸。爆炸是"以自己为圆心,所有方向同时扩散",SphereCast天然是单向的,除非你转着圈打十几条射线,那性能就完全失控了。
另一种思路是自己遍历场景里所有目标单位,计算它们到爆炸中心的距离,小于半径就结算。听着很合理,在小型Demo里也确实能用。问题是你要么维护一个"所有可打击单位"的列表,要么每次爆炸都去找一堆标签或者接口实例。单位少还好,单位一旦上百,每一次范围检测都是O(N)的遍历,而且这些单位不一定都挂在同一节点下,找齐它们本身就麻烦。
用一张表总结这几个方案的特点,方便你按场景选:
| 方案 | 原理 | 适合场景 | 主要缺点 |
|---|---|---|---|
| Trigger碰撞体 | 物理引擎事件派发 | 持续性区域、缓慢变化 | 瞬发事件响应延迟,易重复触发 |
| OverlapSphere | 主动球形范围查询 | 爆炸、范围攻击、震击 | 需要自己处理层级掩码和去重 |
| SphereCast | 沿方向扫一个球体 | 子弹飞行、直线冲刺 | 单向检测,无法覆盖球形扩散 |
| 遍历单位列表 | 逐个计算距离 | 单位数量极少的原型 | 单位多时性能差,维护成本高 |
结论很清楚:瞬发、全方向、大范围的一次性AOE,用OverlapSphere是最合适的。接下来看参数怎么给。
2. OverlapSphere的四个参数,哪一个都能让你白测半天
Physics.OverlapSphere的完整签名是Physics.OverlapSphere(Vector3 position, float radius, int layerMask = AllLayers, QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal)。每个参数看着都简单,实际用起来都有坑,逐个说。
2.1 LayerMask位运算:索引不等于掩码
LayerMask是新手翻车重灾区。很多人在Inspector里看到Layer有编号,就以为LayerMask.NameToLayer("Enemy")的返回值可以直接当成LayerMask传进去,于是写:Physics.OverlapSphere(pos, 5f, LayerMask.NameToLayer("Enemy"))。结果炸了半天啥也没检测到。
原因在于NameToLayer返回的是Layer索引,它是一个从0开始的整数。比如Enemy层编号是8,语法上这会被解读成"掩码值为8",换算成二进制是00000000 00000000 00000000 00001000,你命中的是第3位,对应的其实是Layer 3,而不是Layer 8。正确写法是左移一位:
int enemyLayerIndex = LayerMask.NameToLayer("Enemy"); int enemyMask = 1 << enemyLayerIndex; var hits = Physics.OverlapSphere(center, radius, enemyMask);如果你要同时命中多个层,用按位或组合:
int mask = (1 << LayerMask.NameToLayer("Enemy")) | (1 << LayerMask.NameToLayer("Destructible"));我在这块吃过一次大亏。当时在做一个爆炸物原型,掩码漏了左移,编辑器里测了十分钟,怎么炸都没有反应,最后Gizmos画出来的半径明明覆盖了整个测试区域,差点怀疑是物理引擎坏了。
2.2 QueryTriggerInteraction:默认会被Trigger干扰
第三个参数是查询时对Trigger碰撞体的处理方式,有三种枚举值:UseGlobal、Collide、Ignore。
它默认是UseGlobal,最终行为取决于物理设置里的Physics.queriesHitTriggers开关(这在Project Settings → Physics里配置)。如果全局设置是"Hit Triggers",那么OverlapSphere会把Trigger碰撞体也算进结果里。这带来的问题是:那些只负责触发逻辑、没有实际体积的Trigger碰撞体,比如收集品检测圈、NPC对话区域,也会被你炸到。你真正做到半路就会发现"为什么炸弹能炸掉空气里的某个Trigger方块"。
稳妥做法是显式声明:
var hits = Physics.OverlapSphere(center, radius, mask, QueryTriggerInteraction.Ignore);当然,有时候你又确实需要检测Trigger碰撞体。比如一个爆炸冲击波要同时震开敌人和地上的可拾取物,可拾取物如果用的是Trigger+刚体方案,那你就得传Collide。我的建议是:不要依赖全局设置,每次调用都明确传入参数,因为同一个项目里模块多了之后,你根本不可能记得住谁改过全局配置。
2.3 position和radius的时机问题
这两个参数看起来直白,但有一个很容易忽略的细节:检测中心点是不是你真正想要的那个点。
如果你要做"延迟炸弹",弹体扔出去之后需要经过2秒才爆炸。如果在协程里只拿初始位置作为爆炸中心,炸弹飞过一段距离后,实际爆炸位置还是起点,冲击波在原地炸了个寂寞。正确做法是在爆炸结算的瞬间读取弹体的当前位置:
IEnumerator DelayedBoom(Vector3 center, float radius, float fuseTime) { yield return new WaitForSeconds(fuseTime); Vector3 actualCenter = transform.position; // 结算时才取 var hits = Physics.OverlapSphere(actualCenter, radius); // 处理命中 }同理,如果爆炸特效是一个慢慢变大的光球,而判定应该发生"特效到达最大范围"的瞬间,你最好在协程的对应节点取值,而不是在一开始就计算完。物理检测这个问题上,"什么时候问"和"在哪里问"同等重要。
3. 一颗手雷的完整判定:伤害、击飞、衰减一锅端
参数搞清楚以后,就可以把整套冲击波逻辑写出来了。我一般会把判定逻辑抽成一个独立方法,因为冲击波这种东西在项目里会被多处调用——手雷、炸弹桶、Boss的AOE技能,本质上都是同一套逻辑,只是半径、伤害、力度不同。
3.1 先距离衰减,再取组件
最核心的一步是去重。OverlapSphere返回的是Collider[],一个怪物身上可能有多个Collider,比如BoxCollider(身体)加SphereCollider(头部判定区域),一次爆炸会同时命中两个Collider,但它们属于同一个单位。这种情况下,如果你对每个Collider都结算一次,怪物会被炸掉两管血。
去重的做法是取Transform或者根节点作为唯一标识:
private static readonly HashSet<Transform> _processedSet = new HashSet<Transform>(); public static void ApplyExplosion(Vector3 center, float radius, int baseDamage, float baseForce) { _processedSet.Clear(); Collider[] hits = Physics.OverlapSphere(center, radius, _hitMask, QueryTriggerInteraction.Ignore); foreach (Collider hit in hits) { if (!_processedSet.Add(hit.transform)) { continue; // 这个单位已经结算过了 } float distance = Vector3.Distance(center, hit.transform.position); float falloff = 1f - Mathf.Clamp01(distance / radius); if (hit.transform.TryGetComponent<IDamageable>(out var damageable)) { damageable.TakeDamage(Mathf.RoundToInt(baseDamage * falloff)); } if (hit.transform.TryGetComponent<Rigidbody>(out var rb)) { Vector3 direction = (hit.transform.position - center).normalized; rb.AddForce(direction * (baseForce * falloff), ForceMode.Impulse); } } }我习惯先算距离、再取组件,顺序不能反。因为TryGetComponent在Unity新版里虽然不再分配GC,但组件查找依然有开销;距离计算是纯数学,便宜得多。先用距离把明显不在有效范围里的过滤掉,再用组件来找伤害接口,虽然代码表面上只是两行的顺序变化,在命中几十个单位时能明显减少组件查找次数。
关于IDamageable接口,我建议给它定义成接口而不是直接依赖具体的Monobehaviour。敌人、Boss、可破坏物件都实现同一个接口,冲击波逻辑就不用关心"我炸到的是敌人还是箱子",它只需要知道"能不能受伤"。
3.2 击飞方向与力度衰减的选择
击飞方向有一个细节:不要直接用Vector3.zero。如果某个单位站在爆炸正中心,hit.transform.position - center算出来可能是零向量,进一步normalized会得到(0,0,0),单位原地不动,这不合理。实际爆炸中,中心点的单位至少应该受到向上的力,因为冲击波会把它顶起来。
我处理的方式是给方向向量一个最小保险值:
Vector3 direction = hit.transform.position - center; if (direction.sqrMagnitude < 0.0001f) { direction = Vector3.up; } else { direction.Normalize(); }衰减曲线也要选对。最简单的线性衰减1 - distance / radius直观,看调参时容易理解。但物理上更合理的是距离衰减近似平方反比,也就是1 / (1 + distance * distance)。不过平方反比的曲线衰减太快,在游戏里手感往往不好——玩家会觉得"爆炸中心把人炸飞三丈,边缘的敌人纹丝不动",没有爽感。我现在的项目用的是两者混合:先用线性衰减保证边缘也有反馈,再叠加一个Mathf.SmoothStep把曲线压得更平滑,具体哪个系数顺手得在Inspector里多拖几个值试出来。
3.3 视觉上的冲击波:Scale脉冲和特效同步
判定做完,视觉不能拉胯。冲击波最常见的视觉表现是"一圈光波扩散"和"爆炸中心物体短暂放大再回弹"。前者我一般交给粒子特效,这里只说代码层面的Scale脉冲。
做一个协程,让爆炸点的某块视觉表现(比如一个SphereMesh)从原始大小先放大再缩回:
private IEnumerator PulseScale(Transform target, float peakScale, float duration) { Vector3 original = target.localScale; float half = duration * 0.5f; float t = 0f; while (t < duration) { t += Time.deltaTime; float k = t < half ? Mathf.SmoothStep(1f, peakScale, t / half) : Mathf.SmoothStep(peakScale, 1f, (t - half) / half); target.localScale = original * k; yield return null; } target.localScale = original; }这个脉冲要和OverlapSphere判定同步触发才显得是真的。我的做法是:子弹飞行类爆炸,先播放粒子特效和Scale脉冲,同时立刻做OverlapSphere判定;Boss类蓄力冲击波则反过来,判定在特效扩散到最大范围的瞬间发生。两种时机手感完全不同,得多试几次才能定下来。
4. 性能和GC:把OverlapSphere换成NonAlloc版本
大多数教程到上面那一步就结束了,但放到真实项目里,还得处理性能和内存分配问题。OverlapSphere虽然方便,但有个隐患——每一次调用都会返回一个新的Collider[]数组,这在高频调用下会造成不小的GC压力。
4.1 GC Alloc从哪里来
一个很容易被忽略的知识点:Physics.OverlapSphere内部会为返回结果分配一个数组。如果你在一个每帧执行的方法里调用它(比如检测玩家周围的敌人),每一帧都会产生堆内存分配。一两次调用感觉不出来,但一旦子弹类技能频率高,或者场景里多个AI同时做感知检测,GC Alloc就会让Profiler里的曲线直线上升,最后导致周期性卡顿。
要验证这一点,打开Window → Analysis → Profiler,切到Memory分类,跑一段包含OverlapSphere的代码,你会看到Physics.OverlapSphere相关的GC Alloc记录。真到生产环境,这个数字是绝对不能忽略的。
4.2 NonAlloc缓冲数组:复用、扩容和脏数据
对应方案是Physics.OverlapSphereNonAlloc。它把结果写入一个你预先创建的数组,不会产生新的堆分配:
private static Collider[] _hitBuffer = new Collider[32]; private static readonly HashSet<Transform> _processedSet = new HashSet<Transform>(); public static void ApplyExplosion(Vector3 center, float radius, int baseDamage, float baseForce) { int count = Physics.OverlapSphereNonAlloc(center, radius, _hitBuffer, _hitMask, QueryTriggerInteraction.Ignore); _processedSet.Clear(); for (int i = 0; i < count; i++) { Collider hit = _hitBuffer[i]; if (!_processedSet.Add(hit.transform)) { continue; } float distance = (hit.transform.position - center).magnitude; if (distance > radius) { continue; // 缓冲数组里的数据可能来自上一次调用 } float falloff = 1f - Mathf.Clamp01(distance / radius); // 伤害和击飞处理同上 } }这里有个非常关键的点:缓冲区可能包含上一次调用残留的数据。Unity保证的是数组的[0, count)区间是本次查询有效的碰撞体,越过count的旧数据没人帮你清理。所以循环必须用count当上限,同时我还加了一个距离判断兜底,因为某些边缘情况下同一个Collider可能处于"刚刚离开范围但还没被物理系统移除"的状态,多一层判断更稳妥。
缓冲区大小选择也有讲究。固定32个小了怎么办?我见过有人直接定成1024一劳永逸,但那样大部分时候都在白白浪费内存。更合理的做法是初始给64,检测到命中数打满时自动扩容:
int count = Physics.OverlapSphereNonAlloc(center, radius, _hitBuffer, _hitMask); if (count == _hitBuffer.Length) { Array.Resize(ref _hitBuffer, _hitBuffer.Length * 2); }扩容频率极低,不影响运行时表现,又能保证最坏情况下不丢命中。
4.3 单位数量上百时的降频策略
如果场景里大量AI单位都会定期做范围检测,即使NonAlloc没有GC,每帧几十次物理查询也会拖累CPU。我常用的策略是错峰检测。
举个例子,假设场景里有60个AI,它们每隔0.2秒检测一次周围的玩家。把60个AI分成4组,每帧只让15个执行感知检测,帧间隔错开。这样单帧的物理查询量直接摊薄了,单位感知多了最多0.05秒的延迟,玩家根本感觉不出来。用固定时间偏移分配组别即可:
int groupCount = 4; int groupIndex = (int)(Time.time * 5f) % groupCount; if (Mathf.Abs(transform.GetInstanceID()) % groupCount == groupIndex) { // 本帧执行该AI的感知检测 }均匀取舍很重要。冲击波这种稀发事件本身不追求每帧检测,但AI单位身上的雷达、近距离检测、领地判定常常是持续运行的。给它们统一走一个错峰调度器,项目规模大了之后收益非常明显。
5. 五个真实踩坑案例:从现象到根因的排查记录
工具用久了总会碰到一些能把人搞到怀疑人生的Bug。这里记录我实际遇到过的五个OverlapSphere相关案例,每个都按"现象—排查—修复"的思路写,你可以当排错手册用。
5.1 爆炸半径内一个人都没炸到:掩码写成了Layer索引
现象:手雷爆炸,Gizmos画的白色爆炸球清清楚楚覆盖了三个敌人,但没有任何伤害数字跳出来。排查过程:
- 先检查半径,没问题。
- 直接在OverlapSphere调用处断点,看返回的
hits数组长度,结果是0。 - 怀疑物理层配置,改传默认
AllLayers,立刻能检测到,问题锁定在LayerMask。 - 检查代码,发现
Physics.OverlapSphere(center, radius, LayerMask.NameToLayer("Enemy"))。
修复前文已经说了:左移一位。这个事故的教训是,凡遇到"范围检测啥也没返回",第一反应就去检查LayerMask是不是索引和掩码搞混了,十个里面有八个是这个原因。
5.2 同一个敌人收到两次伤害:多个Collider的重复命中
现象:一个模型比较精细的敌人身上挂了多个Collider(身体一个胶囊体,武器一个Box),被炸一次后血量掉了两次。排查过程:
- 给
ApplyExplosion里的结算逻辑加日志,打印每个命中Collider的name。 - 发现同一帧内同一个敌人的
Body_Primary和Weapon_Collider都出现在结果里。 - 确认是单位自身有多个Collider导致的重复命中,而不是物理查询被调用了多次。
修复就是前面代码里的HashSet<Transform>去重。注意这里用Transform做Key还不够彻底:如果两个敌人都是同一个Prefab实例化,Transform是各不相同的,没问题;但如果你把身体和武器挂在同一Transform下,那去重逻辑会把两个Collider当成同一个单位,这种情况得根据实际层级结构调整,比如始终取hit.transform.root。
5.3 炸弹把自己炸飞了:掩码没排除自身所在层
现象:玩家扔出手雷,爆炸的一瞬间,玩家自己也受到全额伤害和巨大击飞力,直接被自己炸出地图。
排查过程:
- 看层级配置,玩家在Player层,敌人在Enemy层。
- 调用时传的掩码是
(1 << LayerMask.NameToLayer("Player")) | (1 << LayerMask.NameToLayer("Enemy")),本来是想同时炸到敌我双方。 - 问题不在于掩码,而在于没有区分爆炸来源。手雷由玩家投出,爆炸点离玩家很近,玩家自己的Collider自然在半径内。
这种问题有几种解法,取决于设计意图。如果明确要求"友军伤害"那就不管;如果手雷不应该炸到自己,就要引入"爆炸归属者"概念,在结算前判断命中的是不是弹药持有者的根节点。我的做法是给爆炸逻辑传一个Transform owner,命中时如果hit.transform.root == owner就直接跳过。总之别想着"把玩家设成不参与物理查询"这种一刀切方案,后续肯定后悔。
5.4 敌人明明在范围内却纹丝不动:刚体休眠
现象:冲击波判定返回了敌人Collider,伤害掉了,但敌人没有被击飞,连位置都不变。
排查过程:
- 打印
TryGetComponent<Rigidbody>的返回值,确认刚体存在。 - 检查
rb.AddForce的参数,力度和方向都对。 - 打开Physics可视化调试,发现目标的Rigidbody状态是Sleeping。
问题出在刚体休眠机制。没有外力作用一段时间后,Rigidbody会进入休眠以节约物理计算资源。你直接用一个较大的Impulse力是可以唤醒它的,但有时候如果你用的是自定义的衰减算法把力算得很小,或者先修改了位置又没及时唤醒,就有可能出现"检测到了但没反应"。
修复方式是在应用冲击力前显式唤醒刚体:
rb.WakeUp(); rb.AddForce(direction * (baseForce * falloff), ForceMode.Impulse);这个坑很阴险,因为它只针对"长时间静止的物体"出现,测试时随手扔一个动态箱子不会触发,但场景里摆放的静态装饰、站桩的怪物就容易中招。建议所有通过物理范围检测施加影响的地方,都要在结算前主动唤醒,不要依赖物理引擎的默认行为。
5.5 延迟引信的爆炸点偏了:协程里取错位置
现象:投掷类炸弹带2秒引信,投出去后飞行了一小段距离落到地面,2秒后爆炸特效出现在落地位置,但伤害判定却在投掷起点发生。
排查过程:
- 检查爆炸结算代码,发现
center参数是在调用StartCoroutine的那一刻就计算好的。 - 炸弹飞行期间,
transform.position持续变化,但协程里的闭包变量还保留着最初的起点坐标。 - 表现上就是"特效在脚下炸,判定在空中炸"。
修复方式此前已经展示过,在yield return null之后再取一次transform.position。这里补充一个进阶细节:如果炸弹还发生了反弹、撞到墙壁改变方向,务必在结算时重新计算爆炸点的朝向和位置。我之前见过一个项目,炸弹撞墙后爆炸判定还在墙壁另一侧,玩家隔着墙被炸到,这就是没有结合物理碰撞来更新判定中心导致的。
6. 让冲击波更耐玩:复用、调试与扩展方向
核心逻辑跑通只是第一步。游戏开发里,一个功能如果只能在特定脚本里用,那它迟早会变成一坨复制粘贴代码。我通常会花一点时间把爆炸/冲击波逻辑整理成通用工具,并设计好调试手段。
6.1 抽成通用AOE工具类,技能系统直接复用
我最终的做法是把ApplyExplosion放到一个静态类里,参数全部可配置。这样无论是手雷脚本、Boss技能还是场景爆炸物,都只调用一个入口:
public static class AoEUtility { private static Collider[] _hitBuffer = new Collider[64]; private static readonly HashSet<Transform> _processedSet = new HashSet<Transform>(); private static int _hitMask; public static void Configure(int hitMask) { _hitMask = hitMask; } public static int ApplyExplosion(Vector3 center, float radius, int baseDamage, float baseForce, Transform owner = null) { int count = Physics.OverlapSphereNonAlloc(center, radius, _hitBuffer, _hitMask, QueryTriggerInteraction.Ignore); _processedSet.Clear(); int hitUnitCount = 0; for (int i = 0; i < count; i++) { Collider hit = _hitBuffer[i]; Transform root = hit.transform.root; if (owner != null && root == owner) { continue; } if (!_processedSet.Add(root)) { continue; } float distance = (root.position - center).magnitude; float falloff = Mathf.Clamp01(1f - distance / radius); if (root.TryGetComponent<IDamageable>(out var damageable)) { damageable.TakeDamage(Mathf.RoundToInt(baseDamage * falloff)); hitUnitCount++; } if (root.TryGetComponent<Rigidbody>(out var rb)) { vb.WakeUp(); Vector3 direction = root.position - center; if (direction.sqrMagnitude < 0.0001f) direction = Vector3.up; else direction.Normalize(); rb.AddForce(direction * (baseForce * falloff), ForceMode.Impulse); } } return hitUnitCount; } }把owner作为可选参数传入,既保证了可复用性,又不会让手雷逻辑把自己炸飞。返回命中数量,方便调用方做"播放命中音效""触发连击计数"这类后续操作。
6.2 Gizmos调试半径:别让看不见的范围坑你
调范围攻击的半径时,靠肉眼观察"感觉炸到没炸到"极其不准。我的习惯是在工具类里留一个调试开关和Gizmos绘制:
#if UNITY_EDITOR public static Color DebugColor = Color.red; public static void DrawExplosionDebug(Vector3 center, float radius) { Gizmos.color = DebugColor; Gizmos.DrawWireSphere(center, radius); } #endif如果不方便在工具类里挂OnDrawGizmos,可以在爆炸源组件的OnDrawGizmosSelected里调用这个方法。编辑器里选中手雷时,爆炸范围立刻变成可视的白色线框。调衰减曲线的时候,我还会在击中点上画一条短线表示击飞方向,方向变红了说明哪里算错了,肉眼就能看出来。
6.3 组合扩展:OverlapBox、OverlapCapsule与组合判定
OverlapSphere不是唯一的主动查询API。做冲击波时用球体,但很多技能不是圆形的,比如剑气是矩形,冲刺是胶囊。Unity提供了Physics.OverlapBox和Physics.OverlapCapsule,参数逻辑和OverlapSphere几乎一样,只是形状几何不同。
我自己项目里,时常把几种查询组合起来用。比如一个Boss技能是"向前方扇形区域释放冲击波",单靠Sphere是圆,不够精确;可以做三个胶囊体查询再合并去重,或者用球体查询后用角度过滤:
Vector3 toTarget = hit.transform.position - center; float angle = Vector3.Angle(forwardDirection, toTarget); if (angle > halfAngle) continue;这种"球形查询+角度裁剪"的方式,能在不引入复杂物理体的情况下模拟扇形攻击,性能也还保持在可接受范围。类似的思路还能应用在"身后攻击""半圆形护盾"等场景,本质都是OverlapSphere先粗筛,再用数学计算精筛。
关于后续扩展方向,我觉得还可以考虑:命中后播放材质裂纹特效、敌人被击飞时进入"倒地状态"、不同爆炸半径配合不同音效衰减距离、以及用Physics.SyncTransforms来强制同步刚体变换后再做查询等。每一条都是能单独写一篇文章的点,但扯远了。
回到最核心的那句话:瞬发、全方向、大范围的范围攻击判定,优先考虑Physics.OverlapSphere及其NonAlloc版本,这比触发器方案可控得多,也比自建单位列表优雅得多。我把这个方法沉淀为通用工具后,新技能接入只需要一行调用,整个项目的范围攻击逻辑都清爽了不少。如果你也在做类似的功能,希望这篇文章能帮你少走我走过的那些弯路。