一提到 Antigravity,代码圈的老哥第一反应多半是 Python 里那个import antigravity彩蛋,回车之后浏览器会弹出一张漫画。作为一个常年泡在物理引擎里的人,我反而对字面意义上的那个意思更感兴趣:让物体无视重力约束,稳稳悬在半空。前阵子给一个悬浮载具 Demo 做反重力效果,我先后试了两种做法——直接把刚体的useGravity关掉,和专门写一个反重力力场组件。两者的体验差距非常大,这篇文章就绕开“关重力”这条偷懒路径,用 Unity 引擎把“力场悬浮”的思路完整捋一遍,包括力场模型、完整脚本、稳定悬浮的参数整定,以及我调试时踩过的几个坑。适合想做悬浮载具、悬浮平台、磁悬浮类效果,或者想搞明白“恢复力、阻尼力到底怎么配比”的朋友参考。
1. 反重力不是关掉重力,而是让力场替重力“背锅”
1.1 直接关重力的后果
先说我第一次的做法。把Rigidbody.useGravity设为 false 以后,物体确实不再往下掉了,但问题接踵而至:它也不再有“往下掉”这个趋势,稍微给它一个水平冲量,就会沿着直线一路滑出场景。更麻烦的是,没有任何机制把它拉回某个位置,它只是在场景里做一个无重力的幽灵。
这个直觉很容易理解:重力虽然让我们头疼,但它同时也是“向下”的锚点。把重力关掉,等于把锚点也拔掉了。真正的反重力不是消除引力,而是用一个方向可控、量级可调的力,把重力对冲掉。磁悬浮列车就是个很直观的类比:地球引力还在,但磁场产生的向上力足以让列车悬浮,并且轨道结构天然提供了横向的约束。
所以在反重力效果里,我们需要的是一个“力场”,而不是一个“开关”。这个力场有明确的中心点、作用半径和力的大小,进入场内的物体受到向上的悬浮力;离开中心时,还会受到一个把物体推回中心的恢复力。
1.2 力场三件套:悬浮力、恢复力、阻尼力
我最开始实现的时候,脑子里只有“向上给一个力”这一个概念,结果物体在中心附近乱蹦。后来把模型拆成了三个力的叠加,一切才变得可控。公式长这样,Unity 的物理引擎里都是持续施力的方式,所以这些是瞬时力的计算基准:
F_levitation = m * g * weight * levitationStrength F_restoring = -k * (P - C) * weight F_damping = -c * v * weight三个力的区别很清晰:
- 悬浮力:负责把重力抵消掉,让物体不会往下沉。它只关注竖直方向,大小等于物体质量乘当前重力加速度。
- 恢复力:相当于一个弹簧。物体偏离场中心 C 之后,施加一个指向中心、大小和偏移量成正比的力,把它拉回来。
- 阻尼力:相当于减震器。它和物体当前速度成正比,方向相反,作用是消耗掉多余动能,否则物体就会像一根被拉开的弹簧一样永远弹跳。
为什么悬浮力要用m * g而不是写死9.8?因为你不知道场景里的物体质量是多少。质量 1 的物体和质量 5 的物体,同样给 10 牛顿的力,前者飞上天,后者纹丝不动。用m * g作基础,再乘一个可调的levitationStrength,才能保证不同质量的物体都有同样的悬浮手感。
weight 这个系数也很好理解:它是“该点受场影响的程度”。我在代码里用的是1 - distance / radius,中心处是 1,边缘是 0。这样力在中心区域最强,越靠近边界越弱,就不会出现物体在力场边缘被“一把拽过去”的突兀感。
参数初值可以参考这个表,具体数值后面要按场景微调:
| 参数 | 作用 | 参考初值 |
|---|---|---|
| radius | 力场作用半径 | 悬浮物尺寸的2-3倍 |
| levitationStrength | 抵消重力的比例 | 1.0 |
| springStiffness | 恢复力刚度(越大越弹) | 15 |
| damping | 阻尼系数(越大越“黏”) | 8 |
提示:这些初值是在物体质量 1-2、场景尺度数米的前提下给出的。如果你的场景是 1:1 的汽车模型,或者缩放到零件级别,数值可能要整体差好几个数量级。
2. 在 Unity 里搭一个可交互的反重力场
2.1 场景搭建与组件配置
我习惯把力场挂在一个空物体上,而不是挂在悬浮物本身。原因很直接:多个悬浮物可以共享同一个力场区域,也可以让力场固定在某个平台位置,悬浮物从旁边滑进来才被“吸住”。
搭建步骤:
- 在场景里创建一个空物体,挂上稍后要写的 AntiGravityField 脚本。
- 给它添加一个 Sphere Collider,并勾选 IsTrigger。这个 Trigger 不是必须的,但用来标识区域非常直观,调试时能看地方。
- 创建悬浮目标,我一般用一个 Cube 或者胶囊体,挂上 Rigidbody。质量先设成 2,Drag 和 Angular Drag 保持默认数值,初学先不要改动。
- 不要关闭重力。重要的事说三遍:Rigidbody 的 Use Gravity 保持勾选,我们的力场是在和重力对抗,而不是替引擎关掉重力。
有些同学会问,既然力场在对抗重力,为什么不让刚体自带的重力失效,减少一部分计算?我的建议是:别这么干。一旦useGravity=false,引擎内部对下落、反弹、碰撞的很多物理假设都会失效。力场方案里重力还在,悬浮力只是把它的效果抵消,这样物体一旦离开力场区域,还能正常坠落,行为非常自然。
2.2 力场脚本完整实现
脚本核心不需要很复杂,十几行就够了。完整版本如下:
using UnityEngine; public class AntiGravityField : MonoBehaviour { [Header("力场参数")] public float radius = 3f; public float levitationStrength = 1f; public float springStiffness = 15f; public float damping = 8f; public bool active = true; private void FixedUpdate() { if (!active) return; Collider[] colliders = Physics.OverlapSphere(transform.position, radius); foreach (Collider col in colliders) { Rigidbody rb = col.attachedRigidbody; if (rb == null) continue; Vector3 offset = rb.position - transform.position; float distance = offset.magnitude; if (distance > radius) continue; float weight = Mathf.Clamp01(1f - distance / radius); // 悬浮力:向上抵消重力 float g = Mathf.Abs(Physics.gravity.y); Vector3 levitation = Vector3.up * (g * rb.mass * levitationStrength * weight); // 恢复力:把物体拉回场中心,同时处理水平偏移 Vector3 restoring = -springStiffness * offset * weight; // 阻尼力:抑制速度,防止振荡 Vector3 dampingForce = -damping * rb.velocity * weight; rb.AddForce(levitation + restoring + dampingForce, ForceMode.Force); } } private void OnDrawGizmosSelected() { Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(transform.position, radius); } }解释几个关键点。
Physics.OverlapSphere每次在半径内找所有碰撞器,然后通过attachedRigidbody拿到对应的刚体。它比给单个 Collider 写OnTriggerStay更灵活,力场的边界可以运行时调整,场景里多个物体共享一个场也不用写一堆触发器。
offset是从场中心指向物体的向量。恢复力直接取-springStiffness * offset,意味着物体偏上就往下拉,偏下就往上推,偏左偏右也会被拉回来。这里没有把恢复力限制在竖直方向,悬浮载具才会稳。如果只处理竖直方向,物体一受水平扰动就会越漂越远。
weight一定要Clamp01。虽然1 - distance / radius在 distance 不超过 radius 时不会变负,但在极少数浮点计算误差下可能出现微小负数,顺手一夹更安全。顺手说一下ForceMode.Force,这个模式以牛顿为单位施加连续力,适合力场这种每帧都要计算的场景。不要用Impulse,它是一次性冲量,会做出“踢一脚”的效果。
力施加的时机选择FixedUpdate而不是Update,是因为 Unity 的物理系统在固定时间步长里推进,FixedUpdate每个物理帧都会执行且次数可能和渲染帧不一致。一个常见的误区是,只需要一点力,放在 Update 里用AddForce,会发现结果时快时慢,原因就是渲染帧间隔不稳定。
2.3 开关、力度调节与可视化
一个反重力场没有开关说不过去。我通常加一个active字段,并在 Update 里监听空格键切换:
private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { active = !active; } }开关的作用不只是演示。做交互时,玩家进入区域触发反重力,离开区域关闭,这就是一个很有沉浸感的谜题机关。把active换成公开属性,外部脚本也能轻松控制开启时间。
运行时调试还有两个很实用的手段:
- 在 Inspector 里实时拖
levitationStrength、springStiffness、damping,边拖边看物体的反应,比改代码重跑效率高得多。 - 选中力场物体,
OnDrawGizmosSelected会画出半径线框。我把 Gizmos 颜色设成半透明青色的线框,这样进入场景模式后,力场边界和物体位置一眼就能对上。
注意:
OnDrawGizmosSelected只有在编辑器的 Scene 视图里选中物体时才显示,不会进游戏画面。想要在 Player 运行时也看到,可以改成OnDrawGizmos,不过建议只用于调试,不要留在发布包里。
3. 悬浮稳定性和参数整定:振荡问题的完整排查链路
3.1 振荡到底从哪来
第一次把力场脚本跑起来时,我看到的画面是:方块悬浮起来了,但一直在上下跳,而且幅度越来越大,偶尔还会弹飞出去。
这不是脚本写错了,而是物理上必然会出现的情况。恢复力本质上是一个线性弹簧力:偏移越大,拉力越大。一个纯弹簧系统没有能量消耗,物体会在中心附近往复振荡,而且如果恢复力做的是正功,甚至可能越荡越厉害。要让系统稳定,必须引入阻尼力,让能量被“吃掉”。
另一个振荡来源是力场边界。当物体在边缘内外反复进出时,weight会在 0 和接近 1 之间剧烈跳变,受力也跟着跳变。这个原因容易被忽略,排查时先看物体是不是在 radius 边界附近抖动,如果是,多半是边界效应,而不是阻尼不够。
3.2 参数整定:我用的调试顺序
调参这件事,最忌讳一次动好几个参数。我踩过几次之后总结出一套顺序,每次都有效:
- 先把
springStiffness和damping都设为 0,只留悬浮力。这时物体应该悬停住,但有轻微漂移是正常的。如果物体很快下沉,说明levitationStrength小于 1 或者weight在当前位置太低;如果物体往上飞,说明超过了 1。把悬浮力调到“基本不升不降”的状态。 - 从 0 开始加
springStiffness,每加一点就把物体往旁边推一下,看它能不能被拉回中心。加到能明显回正但不会猛冲过头的程度,一般是一个接近 10-20 的数。 - 最后加
damping。从 0 开始,每 2-3 个单位试一次,观察来回摆动的衰减速度。理想状态是“回正过程中有一两次轻微过冲,然后在几秒内稳定下来”。
为什么是这个顺序?因为先调悬浮力确定了竖直方向的平衡点,恢复力决定稳定性,阻尼负责收敛。如果一开始就同时调恢复力和阻尼,物体不稳定的很难判断是哪个参数的问题。
排查时我常用这个表做参照:
| 现象 | 可能原因 | 调整方向 |
|---|---|---|
| 上下大幅振荡,像弹簧停不下来 | 阻尼太小或没有阻尼 | 增大 damping,观察衰减 |
| 响应迟钝,推一下半天不动 | 阻尼过大,恢复力不足 | 减小 damping,适当增大 springStiffness |
| 物体一进力场就被弹飞 | springStiffness 过大 | 减小恢复力刚度 |
| 物体悬浮但持续下沉 | levitationStrength 不够 | 提高到 1.0 以上,或检查物体是否在半径边界 |
| 在力场边缘抖动 | radius 和物体尺寸接近 | 增大 radius,让力场温和过渡 |
有一次我拿质量 2 的方块做测试,参数是k=30, c=5,方块简直像只受惊的兔子,在场上蹦了两秒后直接翻出触发器。改成k=18, c=12后明显平稳,最后定在k=15, c=8比较舒服。这个结果只对当前模型有效,但方向很有参考价值:刚度从大往小降,阻尼从小往大加,通常比反过来调更快。
3.3 多物体与多个场叠加
一个力场天然支持多个物体。OverlapSphere会返回所有半径内的碰撞器,逐个施加力即可。实际项目里更常见的情况是多个力场同时存在,物体进入一个场之后,又跑到另一个场的边缘。
我的处理策略很简单:默认把所有力场的力叠加起来。两侧都有场时,物体会向合力方向移动,这适合做连续的多段悬浮区域;如果希望区域是互斥的,比如两个相反的力场不能同时作用于一个物体,那可以每个物体记录“权重最大的场”作为唯一来源。两种方案各有适用场景,没有绝对的优劣,关键是提前想清楚需求。
多场叠加带来的性能问题也不可忽视。每个场每帧都调OverlapSphere,场景里 10 个场就是每秒 500 次球形检测,CPU 压力不小。优化思路有两个方向:一是缓存Collider[],用一个足够大的数组复用,避免反复分配;二是尽量用 Trigger 的OnTriggerStay事件替代逐帧搜索,只在有物体进入时才计算力。前者适合小型 Demo,后者适合正式项目。
4. 从“悬浮方块”到“反重力移动平台”
4.1 给悬浮载具加移动控制
反重力效果做稳定之后,下一步自然是让它变成能开的悬浮载具。移动控制比在地面上的车辆要简单,因为没有轮胎和地面的摩擦力,本质上就是往刚体上加一个水平方向的力。
我用的控制代码大致是这样:
public float horizontalForce = 10f; private void FixedUpdate() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 move = Vector3.zero; if (Camera.main != null) { move = Camera.main.transform.right * h + Camera.main.transform.forward * v; } else { move = new Vector3(h, 0f, v); } move.y = 0f; rb.AddForce(move.normalized * horizontalForce, ForceMode.Acceleration); }注意这里用的是ForceMode.Acceleration,因为水平推力不需要考虑物体质量,纯加速度控制手感更一致。悬浮力那块仍然走力场脚本,移动控制只负责水平分量。
另一个经验是转向。悬浮平台没有地面摩擦,直接用AddTorque做硬转向,平台很容易甩出去。我的做法是只让平台缓慢朝向移动方向:用一个水平向量作为目标方向,每帧做一次Quaternion.Slerp旋转,并且只在位移速度大于阈值的条件下转动。这样转弯有个自然的倾侧感,不会显得飘。
4.2 物理更新频率与性能取舍
有人为了让悬浮效果更“丝滑”,会把Time.fixedDeltaTime调小,比如从默认的 0.02 改成 0.005。我试过一次,物理子步数多了四倍,一二十个刚体的场景直接让测试设备发热,而悬浮的抖动并没有明显改善。
问题的根源不在物理更新频率,而在阻尼和边界的处理。默认 50Hz 的物理更新足够覆盖悬停这种低频运动,视觉上卡顿是渲染帧的问题,应该通过插值解决,而不是让物理仿真的频率提升到 200Hz。所以我的建议是:保持默认fixedDeltaTime,把精力花在参数整定上。
性能方面还有一个常见开销是每一帧都调用OverlapSphere。小型 Demo 无所谓,但如果场景里有几十个力场,就要考虑用触发器来分流:把力场物体的 Collider 设成 Trigger,在OnTriggerEnter和OnTriggerExit时维护一个进入区域的刚体列表,FixedUpdate里只对这个列表做受力计算,不再每次全场景扫描。这样能省下大量物理查询。
4.3 我踩过的三个坑
复盘一下这次调试中印象最深的问题。
第一个坑就是开头说的useGravity=false。最初为了图快,打算先关重力再手动加力,结果物体被推一下会一直滑出去。后来我才意识到,严格物理里是“力场抵消重力”而非“移除重力”。虽然视觉结果差不多,但行为差异巨大——物体离开力场后仍然会自然下落,才符合玩家直觉。
第二个坑是阻尼开太大。理论上一味增大 damping 可以让振荡快速衰减,但实际调到 40 的时候,平台像泡在粘稠液体里,推一下要两秒才提速,松开后几乎瞬间停住,手感非常差。阻尼是减震器,不是刹车。它的目标是消耗振荡动能,而不是把运动也一起按死。
第三个坑是把重力写死成-9.8。在一个项目里这么干没事,换到另一个改了Physics.gravity的项目,所有悬浮物都歪了。正确写法永远是Mathf.Abs(Physics.gravity.y),用引擎当前的重力值去算悬浮力。这样即使美术调整了全局重力,力场也不会失效。
做这个小 Demo 的过程中,我最大的感受是:反重力这种听起来很科幻的效果,落到引擎里其实就是三个力学量——抵消重力、弹簧回中、阻尼减震。把三者的关系想通,换到任何引擎和框架都能做出来,物理语言是通用的。最后再分享一个小习惯:每次调反重力参数之前,先把OnDrawGizmosSelected里画出的力场半径调淡一点,确保半径和视线里的悬浮物尺寸匹配,很多边界抖动在动手之前就能看出来。这个习惯救了我好多次。