1. 每帧的物理账本:FixedUpdate节拍与PhysX的求解顺序
Unity物理引擎在3D侧用的是PhysX,2D侧用的是Box2D,这一点很多人用了几年都没搞清楚。它的工作范围其实很窄:算出谁和谁撞上了、撞上之后各自的位移和旋转该怎么变、约束和关节该怎么解。渲染、输入、游戏逻辑、UI,它一概不管。但就是这块"只算运动"的部分,决定了你项目里推箱子推得动不动、球弹得自不自然、角色会不会卡在墙角抖动。
我刚接手一个物流仿真项目时,第一反应是把所有能动的物件都挂上刚体,结果几百个托盘一进场,帧率直接从120掉到28。那次之后我才真正去读FixedUpdate和求解顺序的文档。搞明白一件事:物理引擎是按固定节拍结算的会计,而渲染是随时按快门的摄影师,两者节奏天生不同。不理解这一点,后面所有的抖动、穿透、卡顿问题都无从下手。
1.1 渲染帧和物理步为什么必须解耦
变帧率是游戏运行的常态,60fps、144fps、后台切回来掉到20fps都会发生。如果物理跟着渲染帧走,同样的加速度在不同帧率下积分出的位移就不一样,低帧率时物体一帧移动的距离变大,直接穿墙。Unity的做法是让物理以固定的Time.fixedDeltaTime推进,默认0.02秒,也就是50赫兹。
这个值对应的是物理模拟的"最小时间片"。在一个渲染帧里,物理可能跑0次,也可能跑3次:帧率高于50时有的帧不跑物理,帧率低于50时会补跑几步追上真实时间。所以你会看到FixedUpdate调用次数和Update次数完全不相等这个现象。
注意:
Input.GetKeyDown这类"只在一帧有效"的输入如果放在FixedUpdate里判断,高帧率下会漏掉按键。正确的做法是在Update里读输入、缓存成标志位,再到FixedUpdate里消费。
调fixedDeltaTime是有明确代价的。调到0.01(100Hz)会让物理更细腻,穿透概率下降,但每帧的计算量接近翻倍;调到0.033(30Hz)省CPU,代价是高速物体容易穿透、密集堆叠的物体会互相挤出去。我的经验是,常规项目保持0.02别动,只有做大量精细堆叠或布料级模拟时才考虑0.01。
1.2 追帧上限与Time.maximumDeltaTime的实际影响
有个参数容易被忽略:Time.maximumDeltaTime,默认0.3333秒。它限制的是"单个渲染帧最多允许物理追多少时间"。设想你切出去开了个网页,回来时游戏卡了2秒,如果没有保护,物理会试图连续跑100步把时间补回来,这2秒里游戏直接假死,然后所有物体因为巨大的delta而炸开。
设成0.3333意味着最多追0.33秒的物理时间,超出的部分直接丢弃——游戏世界的时间会"变慢"而不是"爆炸"。做移动端或者需要长时间后台运行的项目时,我会把它调到0.1左右,宁可时间线走慢一点,也不接受回来一瞬间的卡顿峰值。
// 移动端可考虑适当收紧追帧上限 void Awake() { Time.maximumDeltaTime = 0.1f; // 物理步长一般不动,除非确实需要更高精度 // Time.fixedDeltaTime = 0.01f; }1.3 回调发生在什么时刻:OnCollision系列与Update的先后关系
一次完整的物理结算大致是这样跑的:FixedUpdate里你改transform、加力、设速度;物理步内部做宽相位筛选、窄相位求交、接触生成、约束求解、位置积分;求解结束后,接触结果被派发出去,也就是OnCollisionEnter、OnCollisionStay、OnTriggerEnter这一批回调。
这些回调的执行时机在物理步之后、Update之前。理解这个顺序有个直接用途:如果你在OnCollisionEnter里读取Rigidbody.velocity,拿到的是本次求解之后的速度,而不是撞击前的速度。想拿到撞击冲量,得用Collision.impulse或者ContactPoint的数据,别自己用速度差去估。
void OnCollisionEnter(Collision col) { // 撞击瞬间的冲量,用来做音效强度分级很合适 float strength = col.impulse.magnitude; if (strength > 8f) PlayHardImpact(); else PlaySoftImpact(); }另外要注意,OnCollisionStay和OnTriggerStay是每个物理步都调用的,不是每帧。物理跑得比渲染快的时候,同样的视觉帧里Stay可能被调用多次;物理跑得比渲染慢时,一帧内可能一次都不调。把带状态的逻辑(比如计时器累加)写在Stay里,必须自己乘Time.fixedDeltaTime,不能想当然按帧算。
2. Rigidbody与Collider的搭配决策:一次选错能拖垮整帧
碰撞体负责"形状",刚体负责"动力学身份",这俩是解耦的。同一个盒子碰撞体,不带刚体就是一个永远不动的静态碰撞体,带上刚体就变成受力和重力支配的动态物体。很多人出问题的根源,就是没想清楚自己要的到底是哪一种身份。
2.1 静态碰撞体、动态刚体、运动学刚体的三副面孔
静态碰撞体(只有Collider,没有Rigidbody)是最便宜的一类。Unity在构建时会把这些碰撞体合并进PhysX的静态结构里,检测效率和数量关系不大,适合地形、建筑、围墙这类从出生到销毁都不动的东西。
动态刚体(Rigidbody + Collider,isKinematic = false)参与完整的力与约束求解,是开销最大的一类。它会休眠:当速度低于Physics.sleepThreshold并维持一段时间,PhysX会把它标记为睡眠状态,跳过后续计算,被新接触唤醒。合理利用休眠能省下大量CPU,前提是场景里的物体真的会停下来——如果一个平台上永远有轻微抖动的物体,它永远醒着,一直在烧CPU。
运动学刚体(Rigidbody + Collider,isKinematic = true)是最容易被误用的。它不受重力和力影响,但会被其他动态刚体推开,而且你必须用MovePosition/MoveRotation或者直接改transform来驱动它。移动平台、电梯、被脚本控制的门都该用它。
| 物件类型 | 需要Rigidbody | isKinematic | 驱动方式 | 相对开销 |
|---|---|---|---|---|
| 地形、建筑 | 否 | — | 不动 | 低 |
| 移动平台、电梯 | 是 | true | MovePosition | 中 |
| 被推动的箱子 | 是 | false | 物理求解 | 高 |
| 玩家(用物理) | 是 | false | AddForce / velocity | 高 |
| 玩家(用CharacterController) | 否 | — | controller.Move | 中 |
2.2 频繁移动静态碰撞体为什么会掉帧
这是我在仿真项目里踩得最狠的一个坑。当时场景里有一批货架需要按脚本平移,我图省事直接在Update里改它们的transform.position,而这些货架只有Collider没有Rigidbody。结果是每移动一次,PhysX都要更新静态结构里的AABB树,几百个物件每帧都在更新,Profiler里Physics.Processing直接飙到12毫秒。
静态结构一旦建立就假设你不会动它,动一次重建一次的代价非常高。正确做法是要么给它加个isKinematic = true的Rigidbody,用MovePosition移动;要么干脆把它从物理体系里摘出去,用距离判断之类的方式自己做碰撞。
// 移动平台的标准写法 public class MovingPlatform : MonoBehaviour { Rigidbody rb; Vector3 targetPos; void Awake() { rb = GetComponent<Rigidbody>(); rb.isKinematic = true; // 让站上去的动态物体随平台移动,勾选后必须配合MovePosition rb.interpolation = RigidbodyInterpolation.Interpolate; } void FixedUpdate() { rb.MovePosition(Vector3.Lerp(rb.position, targetPos, 0.1f)); } }提示:如果平台上有动态物体,最好用
Rigidbody.MovePosition而不是直接改transform。MovePosition走的是物理管线,接触和摩擦力能正确传递;直接改transform在物理引擎看来是"瞬移",站上去的箱子容易被甩出去。
2.3 触发器的使用边界与Stay回调的开销
IsTrigger勾上之后,碰撞体不再产生物理阻挡,只发重叠事件。有个硬性规则经常被忘:触发器要生效,参与的两个物体里至少要有一个带Rigidbody。两个纯静态碰撞体互相重叠是不会有任何回调的,我见过有人为了这个查了两小时。
触发器用得最狠的地方是拾取范围、伤害区域、区域检测。但OnTriggerStay是高频回调,区域里站着10个物体,每个物理步都要跑10次,累积起来很可观。我的习惯是:能用OnTriggerEnter/Exit加自己的状态表解决的,绝不用Stay。真要用,也在里面加个时间片判断,把逻辑降到10Hz。
float nextCheck; void OnTriggerStay(Collider other) { // 把高频回调降频,避免每物理步都做昂贵计算 if (Time.time < nextCheck) return; nextCheck = Time.time + 0.1f; // 复杂判定放这里 }3. 碰撞检测精度链:从离散检测到连续检测的取舍
穿透是物理里最典型的"看起来偶发、其实必然"的问题。一颗速度30m/s的子弹,在0.02秒的物理步里要移动0.6米,如果目标墙厚0.2米,离散检测只看起点和终点两个位置,中间过程被完全跳过,子弹直接飞过去。理解了这一点,选检测模式就不再是玄学。
3.1 四种碰撞检测模式各自解决的穿透场景
Rigidbody.collisionDetectionMode有四个取值,代价从低到高:
- Discrete:默认模式,只在每个物理步的起止位置做检测。绝大多数常规物体用它就够。
- Continuous:对动态刚体做连续检测,沿着运动轨迹扫掠检测。适合中高速物体,比如投掷物、快速移动的平台。
- Continuous Dynamic:在与静态或其他连续物体交互时也做连续检测,是最贵的一档。给子弹、玩家控制的高速抛射物用。
- Continuous Speculative:用预测性扫掠,比Continuous便宜,更适合运动学刚体(移动平台撞小物件)。缺点是在极高速下可能不如Continuous可靠。
我的分配原则很简单:全场景默认Discrete,只有子弹、弓箭、快速投掷的物件、玩家的高速冲刺体开启Continuous Dynamic,其他一律不动。曾经有个项目为了"保险"把所有动态物体都设成Continuous Dynamic,物理开销直接涨了3倍,最后回退到按需开启,问题反而没了。
| 检测模式 | 适用对象 | 相对开销 | 典型穿透风险 |
|---|---|---|---|
| Discrete | 常规动态物体 | 1x | 高速时较高 |
| Continuous | 中高速投掷物 | 2-3x | 低 |
| Continuous Dynamic | 子弹、高速玩家 | 4-5x | 极低 |
| Continuous Speculative | 运动学平台 | 2x | 低 |
3.2 求解迭代与接触偏移:影响抖动和穿透的两个隐藏参数
Physics.defaultSolverIterations默认是6,defaultSolverVelocityIterations默认是1。前者控制位置约束的迭代次数,后者控制速度约束。一堆箱子堆成塔之后互相挤压、缓慢位移,八成是位置迭代不够;碰撞后速度突变、物体弹得不自然,多半是速度迭代不够。
调这两个值是双刃剑。位置迭代从6提到12,堆叠稳定性明显改善,但物理开销可能翻倍。我一般只在有明确堆叠需求的场景(比如堆箱子玩法、货架仿真)局部提升,做法是运行时只给关键刚体改Rigidbody.solverIterations,而不是全局调高。
Physics.defaultContactOffset默认0.01,决定了碰撞体表面外多远开始生成接触点。调大能提前发现接触、减少穿透,但物体之间会出现肉眼可见的"缝隙"。调小会让检测更精确,但接触点生成变慢、抖动增加。这个值我基本不动,除非在做精密装配类的仿真。
// 只对需要稳定的特定刚体提升迭代,别全局改 void Awake() { var rb = GetComponent<Rigidbody>(); rb.solverIterations = 12; // 位置迭代 rb.solverVelocityIterations = 4; // 速度迭代 }3.3 Layer碰撞矩阵与Overlap查询的配合
Layer碰撞矩阵是零成本的优化手段,原理很直接:PhysX的宽相位阶段会用矩阵筛掉不需要检测的配对。子弹层和子弹层之间、UI层和地形层之间,本来就不该检测,把它们在Project Settings > Physics > Layer Collision Matrix里取消勾选,宽相位的候选对立刻减少。
场景里如果有大量同层物体(比如几百发子弹),先把它们之间的碰撞关掉,能省掉一大半的配对检测。唯一的代价是你得自己管理"子弹互不碰撞"这个逻辑,如果需要子弹互相弹开就得重新打开。
查询接口也一样。Physics.OverlapSphere每次调用都会分配数组,在Update里高频调用会持续产生GC垃圾。用OverlapSphereNonAlloc配合预分配的数组能彻底消掉这部分GC。查询时顺手传QueryTriggerInteraction.Ignore,避免把触发器算进来,既省时间又少一类bug。
Collider[] buffer = new Collider[16]; int count = Physics.OverlapSphereNonAlloc( transform.position, 5f, buffer, LayerMask.GetMask("Enemy"), QueryTriggerInteraction.Ignore); for (int i = 0; i < count; i++) { /* 处理 buffer[i] */ }4. 物理材质、ForceMode与关节:让运动结果符合直觉
前面三章解决的是"物理算得对不对",这一章解决的是"物理算得像不像"。两个物体同样相撞,为什么有的滑出去老远、有的原地不动、有的弹得离谱?答案基本都在物理材质和力的施加方式里。
4.1 摩擦与弹性的四种合并方式
新版引擎把PhysicMaterial更名为PhysicsMaterial,但参数逻辑没变。每个材质有动摩擦、静摩擦、弹性三个核心值,以及两个合并规则:frictionCombine和bounceCombine。合并规则决定了两个物体接触时最终用谁的参数:Average取平均、Minimum取小、Maximum取大、Multiply相乘。
这里有个非常容易踩的坑。地面材质设了0.6的摩擦,箱子材质设了0.4,你以为结果是0.5,实际取决于合并规则。如果地面的合并规则是Maximum,那结果就是0.6;如果箱子的是Multiply,结果可能就是0.24。两个物体谁的规则生效,取决于PhysX的固定优先级,不是你说了算。
我的做法是统一规则:全项目所有物理材质的合并方式都设成Average(默认值),需要特殊效果时只改数值不改规则。这样摩擦表现可预测,调参时改一个值就是线性变化,不用去猜组合结果。
弹性还有个隐藏门槛:Physics.bounceThreshold,默认2。物体的相对速度低于2m/s时不会反弹,直接停住。这个值的存在是为了防止所有物体在微小的速度下不停弹跳。做保龄球、弹珠类玩法时,把它降到0.5以下才能让慢速球也有反弹表现。
| 参数 | 默认值 | 调大的效果 | 调小的效果 |
|---|---|---|---|
| dynamicFriction | 0.6 | 滑动更快停下 | 更滑、滑更远 |
| staticFriction | 0.6 | 更不容易被推动 | 轻轻一碰就动 |
| bounciness | 0 | 弹得更高 | 几乎不弹 |
| bounceThreshold | 2 | 慢速也不反弹 | 慢速也会弹 |
4.2 ForceMode四种模式的单位差异与调用时机
AddForce的第二个参数ForceMode决定了力的解释方式,这四种模式的区别是新手最容易混淆的地方:
- Force:按质量缩放,按
fixedDeltaTime积分。适合持续推力,比如风、引擎推力。1单位力作用在1kg物体上,每秒增加1m/s速度。 - Acceleration:忽略质量,按
fixedDeltaTime积分。适合"所有物体都该有同样加速度"的场景,比如爆炸冲击波。 - Impulse:按质量缩放,不按时间积分。适合瞬间冲量,比如开枪后坐力、跳跃起跳。1单位冲量作用在1kg物体上瞬间增加1m/s。
- VelocityChange:忽略质量和时间,直接改变速度。适合需要精确控制速度增量的地方,比如二段跳固定给3m/s。
void FixedUpdate() { // 持续推力,走Force rb.AddForce(transform.forward * thrust, ForceMode.Force); } public void Jump() { // 起跳是瞬时冲量,走Impulse rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); }两个实操要点。第一,所有AddForce都必须在FixedUpdate里调用,写在Update里会导致帧率越高受力越频繁,物理表现完全失控。第二,ForceMode.Force和Impulse与质量相关,同一个力作用在重物上效果会明显变弱;Acceleration和VelocityChange与质量无关,适合做统一表现。我在做一个"多人推箱子"的玩法时,用Impulse推重箱子几乎推不动,改成Acceleration之后所有人手感一致,这就是选对模式的价值。
4.3 关节断裂与Ragdoll的常见配置
Unity提供的关节有FixedJoint、HingeJoint、SpringJoint、CharacterJoint、ConfigurableJoint几种。做门、铰链、绳索、布娃娃都会用到。最容易出问题的地方是断裂控制:把breakForce和breakTorque设成Mathf.Infinity表示永不断裂,一旦设了具体数值,达到阈值就会触发OnJointBreak。
这里有个坑:OnJointBreak回调返回的breakForce是断裂瞬间的力,而不是你设的阈值。做"关节断掉时播不同音效"这种需求时,别拿它跟breakForce比对。
做布娃娃时,CharacterJoint的摆动范围(lowTwist、highTwist、swing1、swing2)如果不设,人物死亡后会像面条一样扭成一团。合理的做法是参考人形关节的实际活动范围限制,同时给每段骨骼设置合理的质量,避免头部质量过大导致整个布娃娃失衡。
void OnJointBreak(float breakForce) { // 断裂时给个向下的额外速度,表现更自然 var rb = GetComponent<Rigidbody>(); if (rb != null) rb.AddForce(Vector3.down * 2f, ForceMode.VelocityChange); }5. 抖动、穿透、卡墙:物理异常的排查链路
物理类bug最烦的地方是"有时有,有时没有",因为它的触发往往依赖速度、帧率、求解迭代等多个变量的组合。我处理这类问题有一套固定的排查顺序,从不靠猜。
5.1 从Profiler定位是接触开销还是求解开销
打开Profiler,看Physics.Processing这一项。如果它是大头,点进去看细分:Physics.Simulate高说明求解阶段重,通常是刚体数量多、接触对多;Physics.Contacts高说明接触生成重,通常是碰撞体形状复杂或者碰撞对太多。
形状开销有个大致排序:Sphere < Capsule < Box < Convex Mesh < Non-Convex Mesh。非凸网格碰撞体最贵,而且它有个硬限制——不能挂在动态刚体上,只能用于静态或运动学物体。做地形的时候用非凸网格没问题,但如果是会动的物件,必须换成凸包或者组合的基础形状。
我做过一次优化,把一个场景里的几十个精细模型碰撞体从MeshCollider换成BoxCollider近似,物理开销从8毫秒降到1.2毫秒,视觉上玩家完全看不出差别。这就是"碰撞体和渲染模型本就不该共用一套几何"的典型收益。
5.2 抖动三连查:质量比、缩放、时间步
物体接触时莫名抖动,按这个顺序查:
第一查质量比。PhysX对质量差异极其敏感,两个接触物体的质量比超过100:1时,求解器就会开始吃力,表现为重物纹丝不动、轻物疯狂颤抖。我的做法是所有参与碰撞的动态物体质量控制在0.1到100之间,比例不超过10:1。一个1000kg的卡车撞一个1kg的箱子,不抖才奇怪。
第二查缩放。非均匀缩放(比如scale是1, 2, 1)的碰撞体在PhysX里会被"近似"处理,凸包和网格碰撞体尤其明显。我的习惯是把模型缩放导入前就烘焙好,运行时所有碰撞体保持scale为1,需要调整大小就改碰撞体的尺寸参数。
第三查时间步。如果前两项都没问题,抖动还在,尝试把Time.fixedDeltaTime从0.02降到0.01试试。如果抖动消失,说明你的场景里速度变化剧烈,50Hz不够用。这种情况通常出现在有高速旋转或者强弹簧约束的场景。
5.3 穿透与卡墙的修复顺序
穿透的修复优先级是:先确认碰撞检测模式,再确认碰撞体厚度,最后确认时间步。很多所谓的"穿透bug",实际是碰撞体太薄——一个0.01米厚的墙,碰撞体也是0.01米,高速物体穿过它是必然的。把墙的碰撞体加厚到0.1米以上,配一次CCD,问题基本就没了。
卡墙是另一类问题:角色贴着墙移动时被卡住或者滑不动,原因是摩擦力把角色的运动"锁"在了墙上。常见的修复方式是给角色的碰撞体材质把摩擦设低(0.1以下),或者干脆用PhysicMaterialCombine.Minimum让角色材料的低摩擦生效。如果角色用的是CharacterController,问题则出在slopeLimit和stepOffset的配合上,这个后面再讲。
提示:排查穿透时,先在
FixedUpdate里用Debug.DrawLine画出物体上一帧和当前帧的位置,能直观看到它到底"跳过"了多少距离。比盯着Inspector里的速度值猜要快得多。
6. 物理系统与动画、寻路、数字孪生场景的衔接
物理从来不是孤立运行的,它总得和动画、寻路、渲染配合。而"配合"这两个字,恰恰是项目里最容易出问题的地方——两个系统各自都对,合起来就不对。
6.1 CharacterController与Rigidbody的分工选择
角色用不用物理刚体,是个需要早做决策的问题。CharacterController本质上不参与物理求解,它用的是自己的一套胶囊体扫掠,通过Move和SimpleMove推进。优点是控制精确、不抖、不受重力异常影响,缺点是不会被物理力推动、不能做布娃娃的过渡、爬坡和台阶要自己调参数。
刚体角色则完全受物理支配,能被爆炸推开、能被平台带着走、能做物理化的受击反馈。代价是移动手感要费很大劲调,一点点摩擦、迭代、碰撞偏移的变化都会影响手感。
我的选择标准是看玩法:需要精确跑酷、平台跳跃的,用CharacterController;需要被物理推来推去、要做物理受击和布娃娃的,用刚体。至于两者混用(平时用控制器、死后切布娃娃),需要在切换时把位置、速度、朝向完整同步过去,否则会出现瞬移。
// 控制器平滑贴地,避免下坡时离开地面 void Update() { if (controller.isGrounded) verticalSpeed = -1f; else verticalSpeed -= gravity * Time.deltaTime; Vector3 move = horizontal * speed + Vector3.up * verticalSpeed; controller.Move(move * Time.deltaTime); }提到导航系统,有个认知需要纠正:烘焙NavMesh用的是渲染网格和NavMeshModifier标记,跟物理碰撞体没有直接关系。你把碰撞体做得再精细,也不会让寻路更准;反过来,把物理碰撞体简化成盒子,也不会影响寻路。这两个系统是平行的,别互相背锅。
6.2 插值设置与摄像机跟随的配合
物理按50Hz跑、渲染按144Hz刷,物理物体的位置在渲染帧之间就是"跳变"的,视觉上会抖。Rigidbody.interpolation就是解决这个的:Interpolate用上一物理步的位置做插值,Extrapolate用速度做外推预测。绝大多数情况选Interpolate,Extrapolate在物体突然转向时容易预测过头,产生"抽一下"的效果。
摄像机跟随物理物体时也有讲究。如果在Update里直接transform.position = target.position,摄像机跟的是物理位置在当前渲染帧的采样值,遇到插值开启时会有一帧的延迟感。更稳的做法是在LateUpdate里跟,并且用阻尼平滑,这样无论物理怎么跑,镜头都不会抖。
void LateUpdate() { // 用 SmoothDamp 做阻尼跟随,避免物理抖动直接传给镜头 Vector3 target = targetRb.position + offset; transform.position = Vector3.SmoothDamp( transform.position, target, ref vel, 0.08f); }6.3 数字孪生与大规模场景下的物理降级策略
在数字孪生或大场景仿真里,动辄几百上千个物件,全开物理是不现实的。我的降级策略分三层:
第一层是空间分区。把场景按区域切分,只对摄像机附近的区域开启物理,远处区域把刚体设成isKinematic或者直接禁用物理组件,靠近时再唤醒。这一层能砍掉60%以上的物理开销。
第二层是碰撞体简化。远处的设备用Box近似,只有需要精确交互时才替换成详细碰撞体。视觉上用LOD切换模型,物理上也要有对应的"物理LOD"。
第三层是物理频率降级。对于不需要精确反馈的物体,用Physics.autoSimulation = false配合手动Physics.Simulate,把非关键区域的物理刷新率降到10Hz甚至更低。
// 手动控制物理步进,便于做分区降频 void FixedUpdate() { // 关键区域正常模拟 Physics.Simulate(Time.fixedDeltaTime); // 非关键区域可累积到更低频率再统一推进 }这套策略我在一个产线仿真项目里跑过,物件数量从800降到实际同时激活的120,帧率从22稳定到60。关键在于:没有人会盯着屏幕角落的一个托盘看它的物理是否精确,把算力花在用户真正交互的物件上,这才是性价比最高的优化。
至于物理引擎未来会怎么演进,我不太关心。能把FixedUpdate的节拍吃透、把碰撞体身份分清楚、把该关的检测关掉,这三件事做好,绝大多数项目的物理表现和性能就已经够用了。剩下的,都是在具体场景里一次次调参调出来的手感。