3. 物理引擎配置
物理引擎配置
3.1 Godot物理引擎介绍
在开始让球动起来之前,我们需要先了解Godot的物理引擎是怎么工作的。
什么是物理引擎
生活化比喻
想象你是一位"导演",你在拍摄桌球比赛。你需要安排演员(球)在舞台上(球桌)表演。物理引擎就是你的"替身导演"——你只需要告诉它"这颗球往这个方向用这么大的力打出去",剩下的事情(球怎么滚动、碰到别的球怎么办、什么时候停下来)全部由物理引擎自动处理。
你不需要手动计算每一颗球每一帧的位置——物理引擎会帮你搞定一切!
Godot中的物理节点类型
Godot提供了多种物理节点,每种都有不同的用途:
| 节点类型 | 说明 | 类比 |
|---|---|---|
| RigidBody3D | 刚体——有物理属性,会受力和碰撞影响 | 真实的桌球 |
| StaticBody3D | 静态物体——不会移动,但能和其他物体碰撞 | 球桌边框 |
| Area3D | 区域——不参与物理模拟,但能检测物体进入/离开 | 球袋 |
| CharacterBody3D | 角色体——用于玩家控制的物体 | 不适用于桌球 |
| AnimatableBody3D | 可动画的静态体——可以通过动画移动 | 不适用于桌球 |
为什么球用RigidBody3D?
桌球需要在桌面上自由滚动、相互碰撞、被球杆击打——这些都需要真实的物理模拟。RigidBody3D(刚体)就是为这种需求设计的。它会在物理引擎的管理下自动计算运动、碰撞和反弹。
球桌边框用StaticBody3D(静态体),因为它不会移动,但需要让球碰到它时反弹。
3.2 球体物理参数
桌球的物理参数决定了球在桌面上的运动表现。这些参数需要仔细调整,才能让球的运动感觉真实。
创建球体节点
- 创建一个新场景,根节点选择 RigidBody3D
- 添加子节点 MeshInstance3D,设置Mesh为
SphereMesh - 设置球体半径为
0.028(标准桌球直径约5.7厘米,半径约2.85厘米) - 添加子节点 CollisionShape3D,设置Shape为
SphereShape3D,半径也是0.028
球体节点结构
Ball (RigidBody3D) ← 物理体
├── BallMesh (MeshInstance3D) ← 视觉外观(球形网格)
├── BallCollision (CollisionShape3D) ← 碰撞形状(球形碰撞体)
└── BallScript (C#/GDScript) ← 逻辑脚本RigidBody3D关键参数
选中 RigidBody3D 节点,在检查器中设置以下参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Mass | 0.17 | 质量(千克),标准桌球约170克 |
| Physics Material Override | 新建 | 自定义物理材质 |
| Gravity Scale | 0 | 不受重力影响(前面已关闭全局重力) |
| Contact Monitor | 开启 | 监控碰撞事件 |
| Max Contacts Reported | 4 | 最多报告4个碰撞 |
| Can Sleep | 开启 | 球停下后进入休眠状态 |
必须开启Contact Monitor
Contact Monitor是碰撞监控开关。如果不开启,我们就无法在代码中检测球碰到了什么东西。一定要记得开启!
Max Contacts Reported设置为一个合理的数字(比如4),表示球同时最多能和4个物体接触。
物理材质参数
在RigidBody3D的 Physics Material Override 中创建一个新的物理材质,设置以下参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Friction | 0.2 | 摩擦系数——影响球滚动的阻力 |
| Bounce | 0.9 | 弹性系数——0=不弹,1=完全弹性碰撞 |
| Rough | true | 粗糙表面,球滚动而非滑动 |
摩擦力和弹性怎么理解?
- 摩擦力(Friction):想象你在冰面上推一个球(摩擦力很小)vs 在砂纸上推一个球(摩擦力很大)。桌球桌面是布料做的,摩擦力适中,让球能滚动一段距离后慢慢停下来。
- 弹性(Bounce):想象一个橡皮球(弹性很高)vs 一个泥巴球(弹性为0)。桌球碰到边框或别的球后会反弹,所以弹性要设高一些。
滚动摩擦
Godot 4默认没有专门的"滚动摩擦"参数,但我们可以通过以下方式模拟:
- 线性阻尼(Linear Damp):让球的速度逐渐减小
- 角速度阻尼(Angular Damp):让球的旋转逐渐减慢
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Linear Damp | 0.5 | 线性速度衰减 |
| Angular Damp | 0.5 | 角速度衰减 |
线性阻尼 vs 摩擦力
- 摩擦力是接触面之间的力,只有球和桌面接触时才有
- 线性阻尼是空气阻力等全局性的减速,不管球在不在桌面上都有
在实际桌球中,球主要靠桌面摩擦力减速。但在Godot中,线性阻尼可以很好地模拟这个效果,而且更简单。
3.3 碰撞层设置
碰撞层(Collision Layer)和碰撞遮罩(Collision Mask)是Godot物理系统中非常重要的概念。它们决定了哪些物体之间会发生碰撞。
生活化比喻
想象一场化妆舞会,每个人戴着一个号码牌(碰撞层)。每个人手上有一个名单(碰撞遮罩),上面写着"我可以和哪些号码牌的人互动"。只有当你的名单上有对方的号码牌时,你们才能互动。
碰撞层分配表
我们用不同的层来区分不同类型的物体:
| 层编号 | 名称 | 包含的物体 | 说明 |
|---|---|---|---|
| 1 | Balls | 所有球(母球+目标球) | 球与球之间的碰撞 |
| 2 | Table | 球桌边框、缓冲垫 | 球撞到边框会反弹 |
| 3 | Pockets | 6个球袋 | 球进袋的检测区域 |
| 4 | Cue | 球杆(可选) | 球杆击球(可选) |
球的碰撞设置
对于每一颗球(RigidBody3D):
| 设置项 | 值 | 说明 |
|---|---|---|
| Collision Layer | 层1(Balls) | 球在"球"层上 |
| Collision Mask | 层1 + 层2 + 层3 | 球可以碰到球、边框、球袋 |
球桌边框的碰撞设置
对于球桌边框(StaticBody3D):
| 设置项 | 值 | 说明 |
|---|---|---|
| Collision Layer | 层2(Table) | 边框在"球桌"层上 |
| Collision Mask | 层1(Balls) | 边框只和球碰撞 |
球袋的碰撞设置
对于球袋(Area3D):
| 设置项 | 值 | 说明 |
|---|---|---|
| Collision Layer | 层3(Pockets) | 球袋在"球袋"层上 |
| Collision Mask | 层1(Balls) | 球袋只检测球进入 |
注意Area3D的特殊性
Area3D不是物理体,它不参与物理碰撞计算。它的作用是"检测"——当有物体进入它的区域时,它会发出信号。所以球袋用Area3D,不会影响球的运动,但能知道球什么时候进入了球袋区域。
3.4 物理空间参数
Godot的物理空间(Physics Space)有一些全局参数,影响整个物理模拟的精度和性能。
项目设置中的物理参数
在 项目 → 项目设置 → 通用 → 物理 → 3D 中:
| 参数 | 推荐值 | 默认值 | 说明 |
|---|---|---|---|
| Default Gravity Y | 0 | -9.8 | 关闭重力 |
| Solver/Solver Iterations | 16 | 8 | 碰撞求解迭代次数 |
| Solver/Contact Max Separation | 0.001 | 0.05 | 接触最大分离距离 |
| Solver/Contact Max Allowed Penetration | 0.001 | 0.01 | 允许的最大穿透深度 |
| Sleep/Enable Sleeping | true | false | 启用休眠 |
| Sleep/Linear Sleep Threshold | 0.05 | 0.1 | 线性休眠阈值 |
| Sleep/Angular Sleep Threshold | 0.05 | 0.1 | 角速度休眠阈值 |
| Sleep/Time to Sleep | 0.5 | 0.5 | 进入休眠的时间(秒) |
精度 vs 性能
物理参数的调整通常是精度和性能之间的权衡:
- Solver Iterations越高,碰撞越精确,但CPU消耗越大
- 对桌球来说,精确的碰撞非常重要,所以我们把迭代次数从默认的8提高到16
- 现代电脑处理这个级别的物理完全没问题
物理帧率
Godot的物理默认以每秒60帧的速率运行。你可以在项目设置中修改:
| 参数 | 值 | 说明 |
|---|---|---|
| Physics Common/Physics Ticks per Second | 60 | 物理模拟频率 |
不要随意修改物理帧率
除非你遇到性能问题,否则不要修改物理帧率。60fps的物理模拟对桌球来说已经足够了。提高帧率会提高精度,但会增加CPU负担;降低帧率可能导致球"穿墙"或碰撞不准确。
3.5 2.5D约束
桌球是在一个水平面上进行的运动——球只在X和Z方向上移动,不应该在Y方向(上下)上移动。我们需要添加约束来确保球只在桌面上滚动。
方法一:使用RigidBody3D的轴向约束
RigidBody3D有一个 Axis Lock 属性,可以锁定某个轴的运动:
| 参数 | 值 | 说明 |
|---|---|---|
| Axis Lock → Linear X | 关闭 | 允许X方向移动 |
| Axis Lock → Linear Y | 开启 | 锁定Y方向(上下) |
| Axis Lock → Linear Z | 关闭 | 允许Z方向移动 |
| Axis Lock → Angular X | 开启 | 锁定X轴旋转 |
| Axis Lock → Angular Y | 关闭 | 允许Y轴旋转(球在桌面上自转) |
| Axis Lock → Angular Z | 开启 | 锁定Z轴旋转 |
为什么锁定旋转?
如果允许球在X和Z轴上旋转,球可能会"翻跟头",这在桌球中是不应该发生的。桌球只会绕着垂直轴(Y轴)旋转(自转),所以我们要锁定X和Z轴的旋转。
方法二:在代码中约束(更灵活)
如果你需要在某些情况下允许球离开桌面(比如球飞出球桌的动画),可以在代码中手动约束。
using Godot;
/// <summary>
/// 球体物理脚本 - 控制球的物理行为
/// </summary>
public partial class BallPhysics : RigidBody3D
{
/// <summary>球的编号(0=母球,1-15=目标球)</summary>
[Export] public int BallNumber { get; set; } = 0;
/// <summary>桌面Y坐标(球应该停留的高度)</summary>
[Export] public float TableY { get; set; } = 0.828f;
/// <summary>球的半径</summary>
[Export] public float BallRadius { get; set; } = 0.028f;
/// <summary>球是否已进袋</summary>
public bool IsPocketed { get; private set; } = false;
/// <summary>球的颜色</summary>
public Color BallColor { get; private set; } = Colors.White;
/// <summary>球停下时发出的事件</summary>
[Signal] public delegate void BallStoppedEventHandler();
/// <summary>球进袋时发出的事件</summary>
[Signal] public delegate void BallPocketedEventHandler(int ballNumber);
private bool _wasMoving = false;
public override void _Ready()
{
// 设置物理参数
Mass = 0.17f;
GravityScale = 0.0f;
// 锁定Y轴移动,球只能在水平面移动
AxisLockLinearY = true;
// 锁定X和Z轴旋转,球只能绕Y轴自转
AxisLockAngularX = true;
AxisLockAngularZ = true;
// 设置线性阻尼(模拟滚动摩擦)
LinearDamp = 0.5f;
AngularDamp = 0.5f;
// 设置碰撞层
CollisionLayer = 1; // 层1:球
CollisionMask = 1 | 2 | 4; // 可以碰到球(1)、边框(2)、球袋(4)
// 设置球的颜色
SetBallColor(BallNumber);
GD.Print($"球 #{BallNumber} 物理初始化完成");
}
public override void _PhysicsProcess(double delta)
{
if (IsPocketed) return;
// 约束球只在桌面上运动
// 即使锁定了Y轴,也加一层保险
var pos = Position;
pos.Y = TableY;
Position = pos;
// 检测球是否从运动变为停止
bool isMoving = IsBallMoving();
if (_wasMoving && !isMoving)
{
GD.Print($"球 #{BallNumber} 已停止");
EmitSignal(SignalName.BallStopped);
}
_wasMoving = isMoving;
}
/// <summary>
/// 判断球是否在运动中
/// </summary>
public bool IsBallMoving()
{
// 当球的速度很小时,认为球已经停止
return LinearVelocity.Length() > 0.01f;
}
/// <summary>
/// 击球——给球施加力
/// </summary>
/// <param name="direction">击球方向(单位向量)</param>
/// <param name="power">力度(0到1之间)</param>
public void HitBall(Vector3 direction, float power)
{
if (IsPocketed) return;
// 确保方向是水平的
direction.Y = 0;
direction = direction.Normalized();
// 最大击球速度
float maxSpeed = 8.0f;
// 计算最终速度
float speed = maxSpeed * Mathf.Clamp(power, 0.0f, 1.0f);
// 施加冲量(Impulse),而非持续力
// 冲量会立即改变球的速度
ApplyCentralImpulse(direction * speed * Mass);
GD.Print($"球 #{BallNumber} 被击打,力度: {power:F2}, 速度: {speed:F2}");
}
/// <summary>
/// 球进袋
/// </summary>
public void Pocket()
{
if (IsPocketed) return;
IsPocketed = true;
GD.Print($"球 #{BallNumber} 进袋!");
// 让球"消失"——停止物理模拟
Sleeping = true;
Visible = false;
// 发出进袋信号
EmitSignal(SignalName.BallPocketed, BallNumber);
}
/// <summary>
/// 将球放回桌面(用于自由球)
/// </summary>
/// <param name="position">放置位置</param>
public void PlaceOnTable(Vector3 position)
{
IsPocketed = false;
Sleeping = false;
Visible = true;
// 设置位置(确保在桌面上)
Position = new Vector3(position.X, TableY, position.Z);
// 清除速度
LinearVelocity = Vector3.Zero;
AngularVelocity = Vector3.Zero;
}
/// <summary>
/// 根据球号设置颜色
/// </summary>
private void SetBallColor(int number)
{
BallColor = number switch
{
0 => Colors.White, // 母球
1 => Colors.Yellow, // 1号 - 黄色
2 => Colors.Blue, // 2号 - 蓝色
3 => Colors.Red, // 3号 - 红色
4 => new Color("6B3FA0"), // 4号 - 紫色
5 => Colors.Orange, // 5号 - 橙色
6 => new Color("006B3C"), // 6号 - 绿色
7 => new Color("8B0000"), // 7号 - 栗色
8 => Colors.Black, // 8号 - 黑色
_ => Colors.White
};
// 更新球体材质颜色
if (GetNode<MeshInstance3D>("BallMesh") is MeshInstance3D mesh)
{
var material = (StandardMaterial3D)mesh.MaterialOverride.Duplicate();
material.AlbedoColor = BallColor;
mesh.MaterialOverride = material;
}
}
}## 球体物理脚本 - 控制球的物理行为
extends RigidBody3D
## 球的编号(0=母球,1-15=目标球)
@export var ball_number: int = 0
## 桌面Y坐标(球应该停留的高度)
@export var table_y: float = 0.828
## 球的半径
@export var ball_radius: float = 0.028
## 球是否已进袋
var is_pocketed: bool = false
## 球的颜色
var ball_color: Color = Color.WHITE
## 球停下时发出的事件
signal ball_stopped
## 球进袋时发出的事件
signal ball_pocketed(number: int)
var _was_moving: bool = false
func _ready() -> void:
# 设置物理参数
mass = 0.17
gravity_scale = 0.0
# 锁定Y轴移动,球只能在水平面移动
axis_lock_linear_y = true
# 锁定X和Z轴旋转,球只能绕Y轴自转
axis_lock_angular_x = true
axis_lock_angular_z = true
# 设置线性阻尼(模拟滚动摩擦)
linear_damp = 0.5
angular_damp = 0.5
# 设置碰撞层
collision_layer = 1 # 层1:球
collision_mask = 1 | 2 | 4 # 可以碰到球(1)、边框(2)、球袋(4)
# 设置球的颜色
_set_ball_color(ball_number)
print("球 #%d 物理初始化完成" % ball_number)
func _physics_process(_delta: float) -> void:
if is_pocketed:
return
# 约束球只在桌面上运动
var pos = position
pos.y = table_y
position = pos
# 检测球是否从运动变为停止
var moving = is_ball_moving()
if _was_moving and not moving:
print("球 #%d 已停止" % ball_number)
ball_stopped.emit()
_was_moving = moving
## 判断球是否在运动中
func is_ball_moving() -> bool:
return linear_velocity.length() > 0.01
## 击球——给球施加力
func hit_ball(direction: Vector3, power: float) -> void:
if is_pocketed:
return
# 确保方向是水平的
direction.y = 0
direction = direction.normalized()
# 最大击球速度
var max_speed: float = 8.0
# 计算最终速度
var speed: float = max_speed * clampf(power, 0.0, 1.0)
# 施加冲量
apply_central_impulse(direction * speed * mass)
print("球 #%d 被击打,力度: %.2f,速度: %.2f" % [ball_number, power, speed])
## 球进袋
func pocket() -> void:
if is_pocketed:
return
is_pocketed = true
print("球 #%d 进袋!" % ball_number)
# 让球"消失"
sleeping = true
visible = false
# 发出进袋信号
ball_pocketed.emit(ball_number)
## 将球放回桌面(用于自由球)
func place_on_table(pos: Vector3) -> void:
is_pocketed = false
sleeping = false
visible = true
# 设置位置(确保在桌面上)
position = Vector3(pos.x, table_y, pos.z)
# 清除速度
linear_velocity = Vector3.ZERO
angular_velocity = Vector3.ZERO
## 根据球号设置颜色
func _set_ball_color(number: int) -> void:
ball_color = match number:
0: Color.WHITE # 母球
1: Color.YELLOW # 1号 - 黄色
2: Color.BLUE # 2号 - 蓝色
3: Color.RED # 3号 - 红色
4: Color("6B3FA0") # 4号 - 紫色
5: Color.ORANGE # 5号 - 橙色
6: Color("006B3C") # 6号 - 绿色
7: Color("8B0000") # 7号 - 栗色
8: Color.BLACK # 8号 - 黑色
_: Color.WHITE
# 更新球体材质颜色
var mesh = get_node("BallMesh") as MeshInstance3D
if mesh and mesh.material_override:
var material = mesh.material_override.duplicate() as StandardMaterial3D
material.albedo_color = ball_color
mesh.material_override = material3.6 球袋检测区域
球袋需要使用 Area3D 来检测球是否进入。当球滚入球袋区域时,球袋会发出信号通知游戏系统。
创建球袋节点
- 添加 Area3D 节点,命名为
Pocket1 - 添加子节点 CollisionShape3D,Shape设为
SphereShape3D - 球形半径设为
0.06(比球大一些,方便进球) - 添加子节点 MeshInstance3D(视觉上显示一个黑色圆形)
球袋参数
| 参数 | 值 | 说明 |
|---|---|---|
| Monitoring | 开启 | 监控进入的物体 |
| Monitorable | 关闭 | 不需要被其他Area3D监控 |
| Collision Layer | 层4 | 球袋层 |
| Collision Mask | 层1 | 只检测球 |
3.7 球桌边框物理
球桌边框使用 StaticBody3D 创建,为球提供反弹表面。
边框物理设置
| 参数 | 值 | 说明 |
|---|---|---|
| Physics Material Override | 新建 | 自定义材质 |
| Friction | 0.3 | 边框摩擦力 |
| Bounce | 0.8 | 边框弹性(比球略低) |
| Collision Layer | 层2 | 球桌层 |
| Collision Mask | 层1 | 只和球碰撞 |
边框弹性为什么要比球低?
如果边框弹性设为1.0(完全弹性),球撞到边框后会以100%的速度反弹,这可能让球弹得太远。设为0.8意味着每次反弹会损失20%的能量,更符合真实的桌球感觉。
3.8 物理参数调优指南
调参是桌球游戏开发中非常重要的一步。以下是调参的建议流程:
调参步骤
1. 先让球能正常滚动和碰撞
2. 调整摩擦力,让球在合理距离内停下来
3. 调整弹性,让碰撞感觉真实
4. 调整阻尼,让球的减速更自然
5. 反复测试和微调常见问题排查
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| 球永远不停 | 摩擦力太小/阻尼太小 | 增大LinearDamp |
| 球停得太快 | 摩擦力太大/阻尼太大 | 减小LinearDamp |
| 球穿过边框 | 碰撞层设置错误 | 检查CollisionMask |
| 球在Y轴上下跳动 | Y轴未锁定 | 开启AxisLockLinearY |
| 碰撞不准确 | Solver Iterations太低 | 提高到16 |
| 球"穿墙" | 物理帧率太低 | 保持60fps |
| 球卡在边框里 | 碰撞形状有间隙 | 调整碰撞形状 |
3.9 小结
在本章中,我们配置了桌球游戏的物理系统:
- 物理引擎基础:了解了RigidBody3D、StaticBody3D、Area3D的区别和用途
- 球体物理参数:质量、摩擦力、弹性、阻尼的设置
- 碰撞层设置:用层来区分球、边框、球袋,控制碰撞关系
- 物理空间参数:提高求解迭代次数,启用休眠
- 2.5D约束:锁定Y轴移动和X/Z轴旋转,确保球只在桌面滚动
- 球体物理脚本:完整的球的物理控制代码
物理系统是桌球游戏的"心脏"——它决定了球的运动是否真实、碰撞是否准确。花时间把物理参数调好,游戏体验会大大提升!
