下载 App
10/16案例分享
【案例分享】0.04m 的默认碰撞边距毁了我的推币机:小物体物理两个默认值 + 3D 角色控制器四件套实测
一句话结论
引擎的物理默认值是按「人形 1.8m」这个尺度调的,你做硬币、棋子就是拿米级的尺子去量厘米级的东西;而 3D 角色控制器少挂一个组件,角色会原地不动且一句报错都不给。
这篇把我踩过的两类坑摊开:小物体物理的两个默认值,和 3D 角色控制器的四件套依赖。两类的共同点是——配置错了不报错,功能静默失效。
一、硬币浮在半空,不是你的模型没落下去
推币机第一次跑起来,硬币堆在一起像飘着。硬币之间明显有缝,落到台面上也悬着一层。
我先怀疑模型原点不对,又怀疑是节点缩放。都不是。
根因在 Bullet 的 Collision Margin。默认值 0.04m。
margin 是什么?它是碰撞体表面向外扩张的一圈「安全垫」,用来让碰撞检测在离散时间步下更稳定。关键点是它两侧都扩——所以实际碰撞厚度等于:
实际厚度 = 视觉厚度 + 2 × margin
对一个人形角色,1.8m 的身高吃 0.08m 的膨胀,肉眼看不出来,这是 margin 设计的正常工况。但对一枚厚度 0.03m 的硬币,这 0.08m 是它的两倍多。
实测数据(硬币厚度 0.03m):
margin 默认 0.04m → 实际碰撞厚度 0.11m,相对视觉厚度偏差 +267%
margin 0.01m → 实际碰撞厚度 0.05m,相对视觉厚度偏差 +67%
0.11m 的碰撞厚度意味着两枚叠在一起的硬币在物理世界里根本没接触,中间隔着 0.05m 的空气。你看到的就是「漂浮」。
修法只有一行:
local shape = node:CreateComponent("CollisionShape")
shape:SetCylinder(diameter, height)
shape:SetMargin(0.01) -- 关键
为什么是 0.01 而不是更小:margin 太小会让快速移动的物体穿透薄壁(隧穿)。0.01m 对硬币尺度是个平衡点——既把误差压到 +67%,又保留足够的检测余量。再往下调,我开始看到硬币偶尔穿台面,就没继续了。
什么时候不用动它:物体尺寸在 0.5m 以上的(角色、载具、大箱子),默认 0.04m 完全够用,别乱改。这条只对小物体成立。判断标准是物体尺寸接近或小于默认 margin 时,必须显式设置——你不需要记住 0.04 这个数,只需要在建模时看一眼自己物体多厚。
二、硬币斜着不倒,或者直接弹飞
第二个坑更邪门。硬币被推倒后,有时候保持一个诡异的倾斜角卡在那儿,像被粘住了;有时候碰到边缘直接弹射出屏幕。
这是 rollingFriction 干的。
Rolling Friction 是滚动摩擦,设计场景是轮子——轮子侧面接触地面,接触点稳定,滚动摩擦用来模拟轮胎形变、地面阻力,让轮子该停的时候停下来。
硬币不是轮子。硬币圆面朝上平躺时接触面大,一旦被推倒,它靠边缘接触地面。边缘的曲率极小(理想情况下是一条线接触),接触点在不断移动,滚动摩擦的力矩计算在这里没有意义。Bullet 按轮子的模型去算一个边缘接触的圆柱体,算出来的力矩就会把物体锁死或者甩出去。
实测三档(硬币类,圆面朝上):
rollingFriction = 0(默认,不设置)→ 正常翻倒、正常停住
rollingFriction = 0.05 → 偶尔卡住,概率随推力变化
rollingFriction = 0.25+ → 斜着不倒 / 弹飞
修法就是别设它:
local body = node:CreateComponent("RigidBody")
body.friction = 0.7
body.angularDamping = 0.5
-- 不设置 rollingFriction(保持默认 0)
angularDamping 在这里承担了「让硬币停下来」的职责,替代了滚动摩擦的作用。0.3 到 0.5 之间我试过都可用,0.5 停得更干脆一点。
边界说清楚:这条只适用于圆面朝上的扁平圆柱体。轮子、滚筒、球体这些靠侧面滚动的物体,Rolling Friction 正是你要的,不要因为看了这篇就把项目里所有 rollingFriction 都删了。判断方法是问一句:这个物体的接触点是固定的滚动轴,还是在边缘上乱跑?前者留着,后者删掉。
三、小物体的黄金组合
把上面两条合起来,这是我推币机里每个硬币用的完整配置:
-- 硬币节点初始化
local body = node:CreateComponent("RigidBody")
body.mass = 0.05
body.friction = 0.7
body.angularDamping = 0.4
-- 不设置 rollingFriction
local shape = node:CreateComponent("CollisionShape")
shape:SetCylinder(1, 1) -- 不缩放
shape:SetMargin(0.01) -- 关键
SetCylinder(1, 1) 这里写 1 和 1,是因为节点缩放会乘进碰撞体缩放。我的硬币视觉尺寸是靠 node 缩放做的,如果这里再写真实尺寸,两者会相乘,碰撞体大小直接错掉。只有当你不用 node 缩放、完全靠 CollisionShape 定尺寸时,这里才填和视觉对齐的真实数值。
四、3D 角色控制器:为什么必须四件套
小物体那边是「参数不对」,角色这边是「组件不对」,但症状一样安静。
4.1 先别用 RigidBody 推速度
我第一版角色是动态刚体,每帧 SetLinearVelocity。平地跑没问题,跳起来碰到墙壁侧面就挂墙——角色贴着墙面停在空中,不掉下来。
因为刚体是被物理模拟驱动的,撞墙走的是反弹、摩擦那一套,人就被「粘」在墙上了。角色控制要的是「我想去哪」的意图,不是受力结果。
换 KinematicCharacterController(KCC)。
4.2 KCC 单独挂 = 完全静默失效
换上 KCC 之后,角色不走了。不是走得怪,是彻底不动——而且不受重力,站在空中。
没有任何报错。日志干干净净。
我在引擎 issue #1907 里挖到根因:KCC 内部要自己创建一个 controller 对象,这个 controller 需要从一个凸形 CollisionShape 拿碰撞体。没有 shape,controller 根本不会创建。
controller 不存在的时候,SetWalkDirection、Jump、KCC 自带的那个重力——全部静默 no-op。新版引擎会打一条一次性 WARNING 日志,但默认配置下你很容易错过它。
顺序也有要求:先建 CollisionShape,再建 KinematicCharacterController。脚本路径下顺序反了,引擎不会事后补救。
4.3 补上 shape 之后,角色还是「原地踏步」
这一层是最耗时间的。
物理世界里角色确实在走——但画面上一动不动。
原因:KCC 只负责算「物理幽灵该在哪」,它不会去移动 node。把物理位置同步回 node 的是 CharacterComponent,每个物理步执行一次。没有它,物理在模拟,视觉永远停在原地。
有人会想:那我每帧读 KCC 的位置,自己赋给 node 不就行了?
不行。Lua 读不到 KCC 的内部位置——GetPosition 没绑定,kcc.position 返回 nil。而 tolua++ 对 nil 属性赋值没有任何防护,node.position = kcc.position 会直接段错误。这不是「可能崩」,是必崩。
所以没有绕过的路子,CharacterComponent 必须挂。
4.4 四件套完整代码
-- 1. 创建刚体(禁用移动,仅用于碰撞事件)
local body = node:CreateComponent("RigidBody")
body:SetCollisionLayerAndMask(CollisionLayerCharacter, CollisionMaskCharacter)
body.mass = 1
body:SetLinearFactor(Vector3.ZERO) -- 关键:刚体不移动
body:SetAngularFactor(Vector3.ZERO)
body.collisionEventMode = COLLISION_ALWAYS
-- 2. 创建碰撞形状(胶囊)——必须在 KCC 之前
local shape = node:CreateComponent("CollisionShape")
shape:SetCapsule(0.7, 1.8, Vector3(0.0, 0.86, 0.0))
-- 3. 创建运动学角色控制器(实际控制移动)
local kinematicController = node:CreateComponent("KinematicCharacterController")
kinematicController:SetCollisionLayerAndMask(CollisionLayerKinematic, CollisionMaskKinematic)
kinematicController.jumpSpeed = 8.0
-- 4. 创建角色组件(高层封装 + 位置写回)
local character = node:CreateComponent("CharacterComponent")
character:SetAirControlFactor(0.6) -- 空中控制系数
四个组件各自的职责:
RigidBody:碰撞事件检测(LinearFactor = ZERO 禁用移动)。少了它收不到碰撞事件。
CollisionShape(凸形,如胶囊):KCC 内部 controller 的碰撞体来源。少了它 KCC 不创建 controller,全部静默失效。
KinematicCharacterController:实际控制移动、跳跃、台阶爬升。少了它回到挂墙的刚体方案。
CharacterComponent:输入处理、空中控制、动画参数,并把物理位置写回 node。少了它物理在走,画面永远不动。
CharacterComponent 上常用的几个:
controls:输入控制(yaw, pitch, buttons)
SetAirControlFactor(0-1):空中控制系数
OnGround():是否着地
IsJumping():是否在跳跃
GetMoveSpeed():移动速度(给动画用)
五、什么场景适合用物理,什么场景别用
物理引擎不是「有物体就该用」。用错场景,性能和控制精度都会出问题。
适合交给物理系统的:
物体数量不多(几十个量级),行为要真实(推币、堆叠、碰撞反弹)
玩家不需要精确控制每个物体的位置
有真实的力、摩擦、重力参与
不该用物理的:
几百上千个物体的密集碰撞:物理 step 的开销随物体数超线性增长。该用自己做空间网格 + 简单判定。
需要精确控制位置的(UI 元素、路径动画):物理是「模拟」,不是「指定位置」。该直接设 node 坐标 / 用补间。
只要「撞到就触发」的判定:建物理体纯属浪费。该用距离判定 / AABB 相交。
弹幕类海量小物体:每个都建 RigidBody 会拖垮帧率。该自己维护数组 + 每帧算位置。
判断依据一句话:你要的是「模拟结果」还是「我指定的结果」?要模拟,用物理;要精确控制,别用。
关于角色控制器的一个专门提醒:3D 角色控制应该用 KinematicCharacterController,而不是普通 RigidBody。区别在于——角色控制是「意图驱动」(我想去哪),刚体是「物理驱动」(受力后去哪)。拿刚体做角色,墙面碰撞会按物理反弹/摩擦处理,人就粘在墙上了。
六、这两类坑的判断方法
我把它们归成同一类问题:配置错了,功能静默失效。没有异常、没有崩溃、画面看着正常,只是你写的东西没生效。
对应的排查顺序:
「没反应」的时候,先查有没有生效,别急着调参数。参数调了半天没用,多半是这一层根本没跑起来。
物理层和渲染层不自动同步。任何「物理在动但看不见」的现象,先找有没有一个组件负责把结果写回 node。这不是引擎的缺陷,是分工——物理子系统不该知道渲染节点长什么样。
默认值是给典型尺寸调的。引擎的默认参数服务的是最常见的场景(人形角色、米级物体)。你的物体离典型尺寸越远,默认值越可能害你。物体尺寸接近或小于默认 margin 时,显式设置。
组件之间有隐藏的创建顺序。KCC 依赖 CollisionShape 存在,这类依赖不看代码看不出来,只能靠踩过或者靠文档。
还有一条最省时间的:把中间状态打出来。每帧 print 一次角色 node 的坐标——坐标在变而画面不动,是「没写回」;坐标根本不变,是「物理没在算」。一行 print,比对着代码猜两小时快得多。










