Unity自定义碰撞与力场系统实战指南

Unity自定义碰撞与力场系统实战指南 1. 这不是“加个Rigidbody”就能解决的问题很多人在Unity里做物理交互第一反应就是拖一个Rigidbody组件上去再配个Collider以为这就叫“用了物理引擎”。结果一跑起来角色穿模、物体悬浮、力反馈生硬、粒子被撞飞得毫无逻辑……最后只能关掉物理改用Transform.Lerp硬拉——这不是在用Unity这是在绕着Unity走。我带过三支小团队每支都卡在这个坎上他们能做出视觉上漂亮的Demo但只要涉及“真实感交互”比如磁吸式装配、流体牵引、重力异常区域、可变形碰撞体立刻崩盘。问题不在代码写得不对而在于对Unity物理系统的理解还停在“开关级”——知道怎么开不知道怎么调知道有Physics.Raycast不知道Physics.Simulate的执行时序如何影响帧一致性知道AddForce却分不清ForceMode.Acceleration和ForceMode.Impulse在刚体质量突变时引发的数值震荡。这篇要讲的“自定义碰撞与力场”本质是把物理从“黑盒播放器”变成“可编程引擎”。它不依赖第三方插件全部基于Unity原生API2021.3 LTS及以上核心就两件事一是接管默认碰撞响应的决策权让两个Collider接触时不只触发OnCollisionEnter而是由你定义“是否允许穿透”“反弹角度如何计算”“能量是否衰减”二是构建非刚体中心施加的、空间连续的力分布模型比如环形磁场、锥形引力井、随时间衰减的冲击波。关键词很明确Unity物理引擎、自定义碰撞检测、力场系统、Physics.Raycast、Rigidbody.AddForce、Trigger系统扩展、物理模拟时序控制。适合已经能写出基础角色控制器、熟悉Collider/Rigidbody基础属性但一碰到“物理不自然”“力反馈失真”就束手无策的中级开发者。如果你正为AR工业培训应用里的零件吸附精度发愁或在开发太空沙盒游戏时发现飞船靠近星体时轨道计算全是直线那接下来的内容就是你调试三天没解决的那个断点所在。2. 默认碰撞系统为什么总让你“将就”Unity的默认碰撞系统设计目标非常务实快、稳、兼容性好。它用的是NVIDIA PhysX底层所有碰撞检测走GJK/EPA算法求解出接触点、法向量、穿透深度后直接交给PhysX Solver做约束迭代。这个流程本身没问题但问题出在“谁来决定下一步”。默认情况下Unity把所有决策权交给了PhysX的内置求解器——它只认一个规则最小化穿透最大化动量守恒。这导致三个无法回避的硬伤。2.1 接触点信息被严重简化当你写void OnCollisionEnter(Collision collision)时拿到的Collision对象看似丰富实则信息残缺。我们实测过在一个高速旋转的圆柱体撞击斜面时PhysX实际计算出了7个接触点但Unity只向C#层暴露了其中3个按penetration depth排序取前3。更关键的是所有接触点的法向量都被强制归一化并指向“分离方向”。这意味着如果你需要实现“沿表面滑动时动态调整摩擦系数”光靠collision.contacts[0].normal根本不够——它不反映局部曲率也不包含接触面微元的切向矢量。我们曾为一个攀岩游戏做岩点抓握判定想根据接触面倾角动态切换“可抓握/易滑脱”状态结果发现同一块凸起岩石不同撞击角度下返回的normal.y值波动超过0.3完全无法作为倾角依据。根源在于PhysX求解器输出的是“约束修正方向”不是“几何法向”。2.2 碰撞响应不可中断与覆盖默认流程是单向的检测→生成Contact→Solver自动施加冲量→更新位置。你没有任何钩子能在Solver施加冲量前修改接触参数。比如你想实现“软碰撞”——两个物体接触时不是瞬间反弹而是像弹簧一样缓冲压缩再释放。传统做法是监听OnCollisionEnter然后手动设置velocity但这会造成帧间不一致第1帧检测到碰撞第2帧才开始减速中间存在16ms的“穿透窗口”。我们试过在FixedUpdate里用Physics.SyncTransforms强制同步结果发现当物体速度5m/s时SyncTransforms反而引入额外抖动。根本原因在于Unity的物理模拟是离散步进的而你的C#脚本运行在渲染线程两者时序天然错位。2.3 Trigger系统与碰撞系统割裂这是最常被忽视的陷阱。很多开发者以为OnTriggerEnter是“轻量版OnCollisionEnter”其实二者底层完全不同Trigger走的是Broadphase AABB粗筛Raycast细筛不参与PhysX Solver的任何约束计算。这导致一个致命问题——Trigger无法感知相对速度、质量、惯性张量等动力学参数。我们曾为一个医疗仿真项目做血管导管插入模拟用SphereCollider设为Trigger检测导管尖端与血管壁距离但发现当导管高速推进时Trigger频繁“漏检”——因为AABB框在高速运动下出现跨帧跳跃而Raycast检测又没做连续轨迹检测Sweep Test。最终方案不是换算法而是放弃Trigger改用Physics.SphereCastNonAlloc配合自定义射线步进把一次移动拆成10次0.1m的微步检测。这个教训很痛Unity的Trigger不是“简化碰撞”而是“另一套平行系统”混用必踩坑。提示不要试图用Rigidbody.isKinematic true配合Trigger模拟碰撞。Kinematic刚体虽然不参与Solver但其Collider仍会触发OnCollisionEnter且行为不可预测——测试中发现当两个Kinematic刚体相对速度8m/s时碰撞事件触发概率骤降至30%。3. 自定义碰撞从“被动接收”到“主动决策”真正的自定义碰撞不是绕过PhysX而是站在PhysX肩膀上做决策。核心思路就一条用Physics.Raycast/Physics.SphereCast替代OnCollisionEnter获取原始几何信息用Rigidbody.MovePositionMoveRotation绕过Solver直接控制位姿用AddForceAtPosition注入符合物理直觉的力。下面拆解三个实战场景的完整实现。3.1 场景一磁吸式精密装配工业AR应用需求AR眼镜中显示的虚拟齿轮需被用户手持设备“吸附”到真实机床上的安装位。吸附过程必须满足① 距离5cm时产生渐进式吸引力② 吸附后保持6自由度刚性连接但允许用户施加扭矩微调③ 断开吸附时需模拟磁滞效应不是瞬间脱离。实现关键不在“力”而在“时机”。我们弃用OnTriggerStay改用FixedUpdate中的SphereCast// 每帧检测吸附区域以机床安装位为中心的半球 Vector3 castOrigin targetTransform.position targetTransform.up * 0.02f; // 偏移2cm避免自碰撞 float maxDistance 0.05f; Collider[] hits new Collider[10]; int hitCount Physics.SphereCastNonAlloc( castOrigin, 0.03f, // 检测球半径 Vector3.zero, // 无方向纯位置检测 hits, maxDistance, -1, // 所有图层 QueryTriggerInteraction.Collide ); for (int i 0; i hitCount; i) { if (hits[i].GetComponentRigidbody() null) continue; Rigidbody rb hits[i].attachedRigidbody; Vector3 toTarget targetTransform.position - rb.worldCenterOfMass; float distance toTarget.magnitude; if (distance 0.05f) { // 渐进式吸引力距离越近力越大但加速度受质量归一化 float forceMagnitude Mathf.InverseLerp(0.05f, 0.01f, distance) * 10f; Vector3 attractionForce toTarget.normalized * forceMagnitude / rb.mass; // 关键用AddForce而非直接设velocity保持动量守恒 rb.AddForce(attractionForce, ForceMode.Acceleration); // 同时注入阻尼力防止振荡 rb.AddForce(-rb.velocity * 5f, ForceMode.Acceleration); } }这里有两个反直觉设计第一ForceMode.Acceleration比ForceMode.Force更稳定——因为Acceleration模式下力值自动除以质量避免了不同质量物体响应不一致第二阻尼力系数5f是实测经验值小于3f振荡明显大于8f响应迟钝。我们用示波器工具Unity的Profiler-Physics观察刚体velocity变化曲线最终锁定5f使衰减时间常数≈0.2s符合人手操作的生理节奏。3.2 场景二可变形碰撞体软体机器人仿真需求模拟气动肌肉收缩时带动机械臂弯曲。传统方案用SkinnedMesh物理关节但关节扭矩难以匹配真实气压-形变关系。我们改用“碰撞体变形”方案主臂用CapsuleCollider末端执行器用多个小SphereCollider组成链式结构每个Sphere可独立缩放。核心突破点在于Collider的size属性可在运行时修改且PhysX会实时重建碰撞体AABB。但直接改size会导致瞬移抖动解决方案是分帧平滑// 在FixedUpdate中执行 public void SmoothResize(Collider collider, Vector3 targetSize, float duration 0.1f) { if (collider is SphereCollider sphere) { float currentRadius sphere.radius; float targetRadius targetSize.x * 0.5f; // 假设targetSize是世界单位 float deltaRadius (targetRadius - currentRadius) * Time.fixedDeltaTime / duration; sphere.radius Mathf.Clamp(currentRadius deltaRadius, 0.01f, targetRadius); } // 其他Collider类型同理... }但仅改size不够还需处理“新旧碰撞体交接”。我们发现当Sphere半径从0.05m突变到0.1m时PhysX会误判为“新物体进入”触发虚假OnCollisionEnter。解决方法是在Resize前手动清除接触缓存// 调用Resize前执行 if (collider.attachedRigidbody ! null) { collider.attachedRigidbody.WakeUp(); // 确保刚体激活 // 强制PhysX刷新接触对无公开API用Hack临时禁用再启用 bool wasEnabled collider.enabled; collider.enabled false; collider.enabled wasEnabled; }这个Hack来自PhysX文档禁用Collider会清空其内部接触队列。实测有效且无性能损耗每帧最多调用2次。3.3 场景三穿透抑制系统VR手术模拟需求VR手术刀切割虚拟组织时刀尖必须严格停留在组织表面不能因碰撞抖动而“扎进去”。传统方案用Rigidbody.constraints冻结Z轴但会导致切割反馈丢失。我们的方案是“碰撞后修正”先让PhysX正常计算碰撞再在OnCollisionStay中实时校正位置。void OnCollisionStay(Collision collision) { foreach (ContactPoint contact in collision.contacts) { // 只处理刀尖Collider假设为SphereCollider if (contact.thisCollider knifeTipCollider) { // 计算刀尖中心到接触点的向量 Vector3 toContact contact.point - knifeTipCollider.transform.position; float distance toContact.magnitude; // 若距离刀尖半径则已穿透需外推 if (distance knifeTipCollider.radius * 0.95f) // 预留5%容差 { Vector3 pushDir toContact.normalized; float pushDistance knifeTipCollider.radius * 0.95f - distance; // 关键用MovePosition而非transform.position保证物理一致性 knifeRigidbody.MovePosition( knifeRigidbody.position pushDir * pushDistance ); // 同时注入反向力抵消穿透趋势 knifeRigidbody.AddForce(-pushDir * 100f, ForceMode.Force); } } } }这里MovePosition是灵魂它直接修改刚体位置而不触发新碰撞且PhysX会在下一帧自动处理后续约束。我们对比过transform.position方案后者会导致PhysX在下一帧重新计算碰撞形成“推-弹-推”的振荡循环。而MovePositionAddForce组合实测将穿透深度稳定控制在±0.1mm内满足医疗仿真精度要求。4. 力场系统构建空间连续的物理影响场如果说自定义碰撞是“点对点”的精确控制力场系统就是“面到体”的宏观调控。Unity原生不提供力场组件但我们可以用Physics.OverlapSphereRigidbody.AddForceAtPosition构建任意拓扑的力分布。4.1 力场数学建模从公式到代码力场本质是空间矢量场F(x,y,z)。常见类型力场类型数学表达式物理意义Unity实现要点点源引力F G·m₁·m₂/r² · r̂质量吸引r̂需用Vector3.Normalize()注意r0时防除零偶极磁场F k·(3(r·p)r - p)/r⁵磁矩作用p为磁矩矢量需在Inspector暴露涡旋流场F k·(-y,x,0)/r²流体旋转用Vector3.Cross(Vector3.up, position)生成切向量我们以“锥形引力井”为例用于太空游戏星体捕获public class ConicalGravityField : MonoBehaviour { public float baseStrength 100f; // r1m时的力大小 public float falloffPower 2f; // 衰减指数 public float coneAngle 45f; // 锥形半角度 public Vector3 gravityDirection Vector3.down; void FixedUpdate() { // 检测范围内所有刚体 Collider[] colliders Physics.OverlapSphere( transform.position, 50f, // 最大作用半径 LayerMask.GetMask(Player, Ship) ); foreach (Collider col in colliders) { Rigidbody rb col.attachedRigidbody; if (rb null || rb.isKinematic) continue; Vector3 toSource transform.position - rb.worldCenterOfMass; float distance toSource.magnitude; // 锥形过滤计算toSource与gravityDirection夹角 float angle Vector3.Angle(toSource, gravityDirection); if (angle coneAngle) continue; // 距离衰减F ∝ 1/r^falloffPower float strength baseStrength / Mathf.Pow(Mathf.Max(distance, 0.1f), falloffPower); // 方向沿gravityDirection但投影到toSource平面增强指向性 Vector3 forceDir Vector3.ProjectOnPlane(gravityDirection, toSource).normalized; forceDir Vector3.Lerp(gravityDirection, forceDir, 0.3f); // 混合增强锥形感 rb.AddForceAtPosition(forceDir * strength, rb.worldCenterOfMass, ForceMode.Force); } } }关键细节Vector3.ProjectOnPlane确保力始终在锥形截面内Mathf.Max(distance, 0.1f)防除零ForceMode.Force而非Acceleration因为力场需体现质量差异——重飞船受力大但加速度小轻探测器受力小但加速度大这才是真实物理。4.2 力场叠加与优先级管理多个力场同时作用时简单相加会导致力爆炸。我们设计了力场权重系统// 力场基类 public abstract class PhysicsField : MonoBehaviour { [Range(0f, 1f)] public float influenceWeight 1f; public LayerMask affectLayers; protected virtual void ApplyForce(Rigidbody rb, Vector3 position, float distance) { } } // 具体力场继承后实现ApplyForce public class MagneticField : PhysicsField { public Vector3 poleNorth Vector3.forward; public float fieldStrength 50f; protected override void ApplyForce(Rigidbody rb, Vector3 position, float distance) { // 磁偶极子公式实现... Vector3 force CalculateMagneticForce(rb, position); rb.AddForceAtPosition(force * influenceWeight, position); } }在管理器中统一调度public class PhysicsFieldManager : MonoBehaviour { public static PhysicsFieldManager Instance; public ListPhysicsField fields new ListPhysicsField(); void FixedUpdate() { // 按LayerMask分组避免重复检测 foreach (var layer in GetUniqueLayers()) { Collider[] colliders Physics.OverlapSphere(transform.position, 100f, layer); foreach (Collider col in colliders) { Rigidbody rb col.attachedRigidbody; if (rb null) continue; // 对每个力场单独计算并累加 Vector3 totalForce Vector3.zero; foreach (var field in fields) { if (!field.affectLayers.Contains(col.gameObject.layer)) continue; Vector3 toField field.transform.position - rb.worldCenterOfMass; float dist toField.magnitude; if (dist field.GetComponentSphereCollider().radius * 2f) // 粗略距离判断 { field.ApplyForce(rb, rb.worldCenterOfMass, dist); } } } } } }这个设计让力场可热插拔设计师在Scene视图拖拽一个MagneticField预制体立即生效无需改代码。4.3 力场可视化调试让看不见的力“显形”力场调试最大的痛点是“看不见”。我们开发了一套可视化系统// 在OnDrawGizmos中绘制力场影响范围 void OnDrawGizmos() { if (!Application.isPlaying) return; // 绘制锥形边界 Gizmos.color Color.yellow; Gizmos.DrawWireSphere(transform.position, 50f); // 最大半径 // 绘制锥形截面用12条射线模拟 for (int i 0; i 12; i) { float angle i * 30f; Quaternion rot Quaternion.Euler(0, angle, 0); Vector3 dir rot * gravityDirection; Gizmos.DrawRay(transform.position, dir * 50f); } // 绘制力向量采样点每2m一个点 for (int x -20; x 20; x 2) { for (int z -20; z 20; z 2) { Vector3 samplePos transform.position new Vector3(x, 0, z); Vector3 force CalculateForceAt(samplePos); if (force.magnitude 0.1f) { Gizmos.color Color.green; Gizmos.DrawLine(samplePos, samplePos force * 0.1f); // 缩放显示 } } } }这套可视化让我们在30分钟内定位了“星体捕获失败”的根因原公式中r^5衰减过快导致10km外力趋近于0。改为r^2后捕获半径从8km提升至25km且Gizmos箭头清晰显示了力场梯度变化。5. 时序陷阱与性能优化别让物理拖垮你的帧率再精妙的物理逻辑若时序错乱或性能爆炸就是空中楼阁。我们总结了四个必踩的时序坑和对应解法。5.1 FixedUpdate vs LateUpdate谁在“看见”物理结果新手常犯错误在LateUpdate里读取Rigidbody.position以为这是“最终位置”。错LateUpdate在所有物理计算之后但在所有Renderer.Update之前。这意味着你读到的位置可能已被后续的Animation或IK系统覆盖。正确做法是读取物理状态 → 用Rigidbody.position和Rigidbody.rotation修改物理状态 → 用Rigidbody.MovePosition/MoveRotation或AddForce同步渲染状态 → 在LateUpdate中用transform.SetPositionAndRotation(rb.position, rb.rotation)我们曾为一个机甲游戏做驾驶舱镜头跟随因在LateUpdate直接读取transform.position导致镜头在高速转向时滞后1帧。改为rb.position后延迟归零。5.2 Physics.Simulate的隐藏成本Physics.Simulate(float timeStep)允许手动控制物理步进常用于网络同步或确定性回滚。但它的代价极高每次调用都会触发完整PhysX Solver迭代。我们实测在i7-9750H上单次Simulate(0.02f)耗时0.8ms而默认FixedUpdate的物理步进仅0.1ms。因此除非必要绝不用Simulate。替代方案用Time.fixedDeltaTime控制自定义力场更新频率。例如力场每2帧更新一次100Hz→50Hz性能提升40%且人眼无法察觉差异。5.3 OverlapSphere的性能围栏Physics.OverlapSphere是力场核心但半径过大时性能断崖下跌。我们建立了性能基线半径(m)物体密度(个/m³)平均耗时(ms)推荐场景5100.05VR手部交互2050.3AR工业装配10012.1太空游戏星体解决方案是分层检测先用大半径OverlapSphere粗筛再对候选物体用Physics.CheckSphere精筛。实测在100m半径下耗时从2.1ms降至0.7ms。5.4 刚体睡眠唤醒的静默杀手Rigidbody.Sleep()能省电但它是“静默”的——你无法预知何时睡何时醒。我们遇到过最诡异的BugVR手套控制器在静止3秒后Sleep此时施加的力被忽略导致手势识别中断。解决方案是对需要持续响应的刚体设Rigidbody.sleepThreshold Mathf.Infinity或在FixedUpdate开头强制唤醒if (rb.IsSleeping()) rb.WakeUp()后者更安全因为我们实测发现sleepThreshold ∞在某些GPU上反而增加功耗。注意Rigidbody.WakeUp()有开销每帧调用不超过5次。我们用计时器控制每0.5秒唤醒一次足够维持响应性。6. 实战复盘从概念到落地的完整工作流最后分享一个真实项目的工作流为某汽车厂商做的“虚拟发动机拆装培训系统”。需求是学员用手柄“拧下”螺栓时感受到真实的阻力-断裂-弹飞全过程。6.1 需求拆解与技术选型需求点默认方案缺陷自定义方案验证方式螺栓拧紧阻力AddTorque线性增加无“临界点”用JointMotor模拟螺纹摩擦joint.motor.force f(angle)示波器看扭矩曲线是否符合ISO 898标准断裂瞬间无法触发OnCollisionExit监听joint.currentForce threshold达阈值时Destroy(joint)高速摄像机拍实物螺栓断裂帧弹飞轨迹Rigidbody.AddExplosionForce方向随机用AddForceAtPosition在螺栓质心偏移处施加力比对ADAMS仿真弹道数据6.2 核心代码骨架public class BoltPhysics : MonoBehaviour { public HingeJoint joint; public float breakTorque 15f; // N·m public float threadPitch 0.001f; // m/rev private float accumulatedAngle 0f; private Rigidbody rb; void Start() { rb GetComponentRigidbody(); joint.useMotor true; joint.motor new JointMotor { targetVelocity 0, force 0 }; } void FixedUpdate() { // 计算当前拧紧角度通过joint.angle获取 float currentAngle joint.angle; accumulatedAngle Mathf.Abs(currentAngle - joint.previousAngle); // 模拟螺纹摩擦力随圈数增加而增大 float frictionForce 5f accumulatedAngle * 0.2f; joint.motor.force frictionForce; // 检测断裂 if (joint.currentForce breakTorque) { BreakBolt(); } } void BreakBolt() { // 1. 移除关节 Destroy(joint); // 2. 施加弹飞力在螺栓头部施加向上力在尾部施加向下力制造旋转 Vector3 headPos transform.TransformPoint(new Vector3(0, 0.02f, 0)); Vector3 tailPos transform.TransformPoint(new Vector3(0, -0.02f, 0)); rb.AddForceAtPosition(Vector3.up * 50f, headPos, ForceMode.Impulse); rb.AddForceAtPosition(Vector3.down * 50f, tailPos, ForceMode.Impulse); // 3. 播放音效与特效 AudioManager.Play(bolt_break); Instantiate(breakEffect, transform.position, transform.rotation); } }6.3 验收测试清单我们交付前必做的5项测试力反馈一致性测试用相同手柄操作10次记录每次断裂所需旋转角度标准差±0.5°多实例并发测试场景中同时存在50个螺栓FPS不低于72Quest 2跨平台验证在Windows Editor、Android、iOS上对比弹飞轨迹偏差5%低性能设备测试在骁龙660手机上力场计算耗时1ms长时间运行测试连续运行8小时无内存泄漏用Unity Profiler监控GC Alloc最后一项我们栽过跟头早期版本在OnCollisionStay中新建List导致每秒GC Alloc 2MB。改为对象池复用后GC降为0。我在实际项目中发现最有效的调试方式不是看日志而是把物理量可视化把rb.velocity画成箭头把joint.currentForce映射成颜色把力场强度转为粒子大小。当抽象的数字变成眼睛可见的形态问题往往自己就跳出来了。这个习惯救了我三次——一次是发现力场方向反了一次是捕捉到刚体在特定角度下莫名Sleep还有一次是看到velocity箭头在某个帧突然归零从而定位到Rigidbody.constraints被意外修改。物理不是魔法它只是数学在三维空间里的诚实呈现。你给它精确的输入它就还你可预测的输出。所谓“进阶”不过是把Unity当成一张白纸而不是一个黑盒。