主题
物理系统(Physics System)
物理系统是 WasiCore 游戏框架中处理碰撞检测、刚体模拟和物理约束的核心系统。框架提供 3D 物理和 2D 物理两套 API。
> 运行端状态:3D 物理和 2D 物理均已双端可用。服务端可创建 PhysicsWorld / RigidBody / CollisionShape 或 PhysicsWorld2D / RigidBody2D / CollisionShape2D 作为权威物理端;脚本创建的 replicated SceneGraph 发布给玩家后,客户端接收服务端同步的节点 transform。客户端也可继续使用物理系统做本地表现、预览或 Canvas/GameUI 映射。
> SCE 场景静态碰撞查询:客户端如果要对当前 SCE Volumetric3D 场景中的地形、墙壁或平台自行执行物理查询,应在首次查询前调用 Scene.EnsureStaticMeshColliders()。该 API 会显式加载场景静态碰撞信息并挂载到客户端 PhysicsWorld;如果没有先加载,Raycast / SphereCast 可能因静态碰撞尚未挂载而穿透墙壁。重复调用安全,成功后返回 Loaded 或 AlreadyLoaded。
目录
坐标系统
WasiCore 使用左手坐标系。物理系统的所有坐标运算均遵循此约定。
| 轴 | 方向 | 说明 |
|---|---|---|
| X | 水平(左右) | 地面平面分量 |
| Y | 水平(前后) | 地面平面分量 |
| Z | 垂直(上下) | 高度轴,正方向向上 |
- 地面 = XY 平面(Z = 0)
- 重力沿 Z 轴负方向作用
- 详见 坐标系统指南
3D 物理系统
命名空间:GameGraph.NodeSystem.Component.Physics
3D 物理 API 在客户端和服务端均可使用。服务端创建的物理对象可以用于权威碰撞/运动模拟;如果节点位于 replicated SceneGraph 或 SCE 默认同步场景中,节点 transform 可同步到客户端。客户端也可以继续使用 3D 物理做本地预览、表现或纯客户端原型。
PhysicsWorld
PhysicsWorld 是场景级别的物理世界组件,提供射线检测和区域查询能力。
射线与区域查询 API
| 方法 | 签名 | 说明 |
|---|---|---|
Raycast | Raycast(Vector3 pos, Vector3 dir, float maxDist, uint mask) | 射线检测,返回最近命中结果 |
RaycastAll | RaycastAll(Vector3 pos, Vector3 dir, float maxDist, uint mask) | 射线检测,返回所有命中结果 |
SphereCast | SphereCast(Vector3 pos, Vector3 dir, float radius, float maxDist, uint mask) | 球形扫掠检测 |
OverlapSphere | OverlapSphere(Vector3 center, float radius, uint mask) | 球形区域重叠查询 |
OverlapBox | OverlapBox(Vector3 center, Vector3 halfExtents, Quaternion rotation, uint mask) | 矩形区域重叠查询 |
> 注意:CollisionMask.All、CollisionMask.Default 若在文档或示例中出现,可能是项目内自定义的常量,并非框架内置 API。请查阅项目定义或使用 uint 掩码值(如 0xFFFFFFFF 表示全部)。
csharp
#if CLIENT
// 从角色位置向前方发射射线,检测 500 单位距离内的物体
var origin = unit.Position;
var direction = unit.Forward;
// mask: 项目定义的 uint 碰撞掩码常量
var hit = physicsWorld.Raycast(origin, direction, 500f, collisionMask);
if (hit.HasValue)
{
Game.Logger.LogInformation("命中物体距离: {Dist}", hit.Value.Distance);
}
// 检测某点周围 200 单位半径内的所有刚体(mask 为项目定义的 uint 常量)
var overlaps = physicsWorld.OverlapSphere(center, 200f, collisionMask);
#endifRigidBody
RigidBody 是挂载在节点上的刚体组件,控制物体的物理行为。
属性
| 属性 | 类型 | 说明 |
|---|---|---|
LinearVelocity | Vector3 | 线速度 |
AngularVelocity | Vector3 | 角速度 |
Mass | float | 质量 |
Friction | float | 摩擦系数 |
Restitution | float | 弹性系数(0 = 无弹性,1 = 完全弹性) |
UseGravity | bool | 是否受重力影响 |
Kinematic | bool | 是否为运动学刚体(不受物理力影响,仅手动控制位置) |
IsTrigger | bool | 是否为触发器(不产生物理碰撞,仅触发事件) |
CollisionLayer | uint | 碰撞层 |
CollisionMask | uint | 碰撞掩码(决定与哪些层发生碰撞) |
方法
| 方法 | 签名 | 说明 |
|---|---|---|
ApplyForce | ApplyForce(Vector3 force) | 施加持续力 |
AddForceAtPosition | AddForceAtPosition(Vector3 force, Vector3 position) | 在指定世界坐标位置施加力 |
ApplyTorque | ApplyTorque(Vector3 torque) | 施加扭矩 |
ApplyImpulse | ApplyImpulse(Vector3 impulse) | 施加瞬时冲量 |
ApplyImpulseAtPosition | ApplyImpulseAtPosition(Vector3 impulse, Vector3 position) | 在指定位置施加瞬时冲量 |
ApplyTorqueImpulse | ApplyTorqueImpulse(Vector3 torque) | 施加瞬时扭矩冲量 |
WakeUp | WakeUp() | 唤醒休眠中的刚体 |
Sleep | Sleep() | 使刚体进入休眠 |
IsSleep | IsSleep() | 查询刚体是否处于休眠状态 |
SetCollisionFilter | SetCollisionFilter(RigidBody.CollisionFilter? filter) | 设置接触点过滤器;回调返回 Ignore 会过滤本次接触点 |
csharp
#if CLIENT
// 配置一个动态刚体
var body = node.GetComponent<RigidBody>();
body.Mass = 10f;
body.Friction = 0.5f;
body.Restitution = 0.3f;
body.UseGravity = true;
body.Kinematic = false;
// 向上施加跳跃冲量(Z 轴正方向)
body.ApplyImpulse(new Vector3(0, 0, jumpForce));
// 在物体顶部施加偏移力,产生旋转效果
body.AddForceAtPosition(
new Vector3(100, 0, 0),
body.Node.WorldPosition + new Vector3(0, 0, 1)
);
#endif碰撞形状(ShapeType)
ShapeType 枚举定义了 3D 物理支持的碰撞体形状。
| 枚举值 | 说明 | 典型用途 |
|---|---|---|
Box | 长方体 | 箱子、墙壁、平台 |
Sphere | 球体 | 弹丸、拾取物 |
StaticPlane | 无限静态平面 | 地面 |
Cylinder | 圆柱体 | 柱子、树干 |
Capsule | 胶囊体 | 角色碰撞体 |
Cone | 圆锥体 | 特殊造型 |
TriangleMesh | 三角网格 | 复杂静态地形 |
ConvexHull | 凸包 | 不规则动态物体 |
Terrain | 地形 | 高度图地形 |
碰撞事件模式(CollisionEventMode)
控制刚体是否产生碰撞事件回调。
| 枚举值 | 说明 |
|---|---|
Never | 不产生碰撞事件 |
Active | 仅当刚体处于活动(非休眠)状态时产生事件 |
Always | 始终产生碰撞事件 |
约束类型(ConstraintType)
物理约束用于连接两个刚体,限制它们之间的相对运动。
| 枚举值 | 说明 | 典型用途 |
|---|---|---|
Point | 点约束(球窝关节) | 绳索连接点、悬挂物 |
Hinge | 铰链约束 | 门、翻盖 |
Slider | 滑动约束 | 抽屉、活塞 |
ConeTwist | 锥形扭转约束 | 布娃娃关节 |
碰撞回调
3D 物理碰撞事件有两种接收方式:
方式一:Node 事件(推荐)
通过 Node 上的事件订阅碰撞,可获取完整的碰撞接触信息(接触点位置、法线、穿透深度、冲量等)。订阅事件会自动启用底层监听,取消所有订阅会自动禁用,无需手动管理。
| 事件 | 回调参数 | 触发时机 |
|---|---|---|
OnCollisionBegin | CollisionContact | 碰撞开始(首帧) |
OnCollisionStay | CollisionContact | 碰撞持续(每物理帧) |
OnCollisionEnd | CollisionContact | 碰撞结束 |
CollisionContact 主要成员:
| 成员 | 类型 | 说明 |
|---|---|---|
Self | Node | 发生碰撞的自身节点 |
Other | Node? | 发生碰撞的另一个节点 |
BodySelf | RigidBody? | 自身节点的刚体组件 |
BodyOther | RigidBody? | 另一个节点的刚体组件 |
IsTrigger | bool | 是否为触发器碰撞(至少一方为 Trigger) |
ContactPoints | ContactPoint3D[]? | 碰撞接触点数组(最多 4 个),碰撞结束时为 null |
ContactPoint3D 结构:
| 字段 | 类型 | 说明 |
|---|---|---|
WorldPosition | Vector3 | 接触点世界坐标 |
WorldNormal | Vector3 | 接触点法线方向 |
Distance | float | 穿透深度(负值) |
Impulse | float | 碰撞冲量 |
csharp
#if CLIENT
var node = unit.GetNode();
node.OnCollisionBegin += (Node.CollisionContact contact) =>
{
Game.Logger.LogInformation("碰撞开始,对方: {Other}", contact.Other?.Name);
if (contact.ContactPoints is { } points)
{
foreach (var pt in points)
{
Game.Logger.LogInformation("接触点: {Pos}, 法线: {N}, 冲量: {I}",
pt.WorldPosition, pt.WorldNormal, pt.Impulse);
}
}
};
node.OnCollisionEnd += (Node.CollisionContact contact) =>
{
Game.Logger.LogInformation("碰撞结束,对方: {Other}", contact.Other?.Name);
};
#endifAllowDuplicateContactEvents 属性:
当碰撞双方都订阅了碰撞事件时,默认只触发一次(性能优化)。设为 true 可让双方都收到事件:
csharp
node.AllowDuplicateContactEvents = true;方式二:ScriptComponent 回调
通过 ScriptComponent 的生命周期回调接收碰撞,信息较少(仅传递对方 Node)。需要将 IsTrigger 设为 true 以启用触发器模式,或通过 CollisionEventMode 配置碰撞事件。
| 回调方法 | 参数 | 触发时机 |
|---|---|---|
OnTriggerEnter | Node node | 另一个碰撞体首次进入触发区域 |
OnTriggerStay | Node node | 另一个碰撞体持续处于触发区域内 |
OnTriggerExit | Node node | 另一个碰撞体离开触发区域 |
csharp
#if CLIENT
public class DamageZone : ScriptComponent
{
public override void OnTriggerEnter(Node node)
{
Game.Logger.LogInformation("碰撞体进入伤害区域");
}
public override void OnTriggerStay(Node node)
{
// 持续造成伤害
}
public override void OnTriggerExit(Node node)
{
Game.Logger.LogInformation("碰撞体离开伤害区域");
}
}
#endif> 推荐:需要接触点细节(位置、法线、冲量等)时使用 Node 事件;仅需知道"谁碰了谁"时使用 ScriptComponent 回调。
2D 物理系统(双端)
命名空间:GameGraph.NodeSystem.Component.Physics.Physics2D
2D 物理系统在客户端和服务端均可用。服务端可用于 Box2D 权威模拟、碰撞判定、射线和 AABB 查询;客户端可用于本地表现、预览、Canvas/GameUI 映射或轻量预测。需要让客户端看到服务端 2D 物理结果时,仍要通过 replicated Node/SceneGraph 或业务消息同步,并在客户端绘制表现。
下面的触摸、Canvas 或本地预览示例仍放在 #if CLIENT 中;服务端权威模拟代码应放在 #if SERVER 或双端共享逻辑中,按项目同步方案决定。
PhysicsWorld2D
2D 物理世界组件,管理 2D 场景的物理模拟。
属性与方法
| 成员 | 类型/签名 | 说明 |
|---|---|---|
Gravity | Vector2 | 2D 世界重力(默认方向向下) |
RaycastSingle | RaycastSingle(Vector2 start, Vector2 end, ...) | 2D 射线检测,返回最近命中 |
RaycastAll | RaycastAll(Vector2 start, Vector2 end, ...) | 2D 射线检测,返回所有命中 |
GetRigidBody | GetRigidBody(Vector2 point) | 获取指定世界坐标点处的 2D 刚体 |
GetRigidBody | GetRigidBody(float screenX, float screenY) | 获取屏幕坐标位置处的 2D 刚体 |
QueryAABB | QueryAABB(Vector2 lowerBound, Vector2 upperBound) | AABB 矩形区域查询 |
csharp
#if CLIENT
// 设置 2D 重力
physicsWorld2D.Gravity = new Vector2(0, -9.8f);
// 从触摸点获取 2D 刚体
var body = physicsWorld2D.GetRigidBody(touchScreenX, touchScreenY);
if (body != null)
{
Game.Logger.LogInformation("点击到了 2D 物体");
}
// 2D 射线检测
var hit = physicsWorld2D.RaycastSingle(startPoint, endPoint);
#endifRigidBody2D
2D 刚体组件,控制 2D 物体的物理行为。
属性
| 属性 | 类型 | 说明 |
|---|---|---|
BodyType | BodyType2D | 刚体类型 |
Mass | float | 质量 |
LinearVelocity | Vector2 | 线速度 |
AngularVelocity | float | 角速度(弧度/秒) |
LinearDamping | float | 线性阻尼 |
AngularDamping | float | 角阻尼 |
GravityScale | float | 重力缩放因子 |
FixedRotation | bool | 是否锁定旋转 |
Bullet | bool | 是否启用 CCD(连续碰撞检测,防止高速穿透) |
BodyType2D 枚举
| 枚举值 | 说明 |
|---|---|
Static | 静态刚体,不参与物理运动(地面、墙壁) |
Kinematic | 运动学刚体,仅通过代码控制位置(移动平台) |
Dynamic | 动态刚体,完全参与物理模拟 |
2D 碰撞形状
以下碰撞形状组件可挂载到拥有 RigidBody2D 的节点上。
| 组件 | 说明 | 关键属性 |
|---|---|---|
CollisionBox2D | 矩形碰撞体 | Size(Vector2)、Center(Vector2) |
CollisionCircle2D | 圆形碰撞体 | Radius(float)、Center(Vector2) |
CollisionEdge2D | 线段碰撞体 | Vertex1、Vertex2(Vector2) |
CollisionChain2D | 链条碰撞体(开放或闭合) | Vertices(Vector2[])、Loop(bool) |
CollisionPolygon2D | 多边形碰撞体 | Vertices(Vector2[]) |
2D 约束类型
2D 物理支持丰富的约束类型,用于连接两个 2D 刚体。
| 约束 | 说明 | 典型用途 |
|---|---|---|
Distance | 距离约束,保持两点间固定距离 | 绳索、弹簧 |
Friction | 摩擦约束 | 表面摩擦模拟 |
Gear | 齿轮约束,联动两个旋转约束 | 齿轮组 |
Motor | 马达约束,驱动旋转或平移 | 电机、风扇 |
Mouse | 鼠标约束,将刚体拖向目标点 | 拖拽交互 |
Prismatic | 棱柱约束(滑轨) | 滑块、电梯 |
Pulley | 滑轮约束 | 滑轮系统 |
Revolute | 旋转约束 | 铰链、轮轴 |
Rope | 绳索约束,限制最大距离 | 绳索、链条 |
Weld | 焊接约束,固定连接 | 刚性连接 |
Wheel | 车轮约束(旋转 + 弹簧) | 车辆悬挂 |
csharp
#if CLIENT
// 创建一个可拖拽的 2D 动态物体
var body2D = node.GetComponent<RigidBody2D>();
body2D.BodyType = BodyType2D.Dynamic;
body2D.Mass = 1f;
body2D.GravityScale = 1f;
body2D.FixedRotation = false;
// 添加圆形碰撞体
var circle = node.GetComponent<CollisionCircle2D>();
circle.Radius = 32f;
// 用鼠标约束实现拖拽
var mouseJoint = node.CreateComponent<ConstraintMouse2D>();
mouseJoint.Target = touchWorldPosition;
mouseJoint.MaxForce = 1000f * body2D.Mass;
#endif2D 碰撞事件
2D 碰撞事件通过 Node 上的事件订阅,提供完整的接触信息。
| 事件 | 回调参数 | 触发时机 |
|---|---|---|
OnCollisionBegin2D | CollisionContact2D | 2D 碰撞开始 |
OnCollisionEnd2D | CollisionContact2D | 2D 碰撞结束 |
OnCollisionPreSolve2D | CollisionContact2D, ref bool isActive | 碰撞求解前,可设置 isActive = false 禁用本次碰撞响应 |
CollisionContact2D 主要成员:
| 成员 | 类型 | 说明 |
|---|---|---|
Self | Node | 自身节点 |
Other | Node? | 另一个节点 |
BodySelf | RigidBody2D? | 自身 2D 刚体 |
BodyOther | RigidBody2D? | 对方 2D 刚体 |
ShapeSelf | CollisionShape2D? | 自身碰撞形状 |
ShapeOther | CollisionShape2D? | 对方碰撞形状 |
ContactPoints | ContactPoint2D[]? | 接触点数组(最多 2 个) |
csharp
#if CLIENT
node.OnCollisionBegin2D += (Node.CollisionContact2D contact) =>
{
Game.Logger.LogInformation("2D碰撞开始: {Other}", contact.Other?.Name);
};
// PreSolve: 可在碰撞响应前动态禁用碰撞
node.OnCollisionPreSolve2D += (Node.CollisionContact2D contact, ref bool isActive) =>
{
// 例如:单向平台——只从下方穿过
if (contact.BodyOther?.LinearVelocity.Y > 0)
{
isActive = false;
}
};
#endif2D 碰撞事件常见陷阱
以下是在实际 2D 平台跳跃游戏(如马里奥类游戏)开发中总结的常见陷阱和推荐做法。
陷阱 1:用 WorldNormal 判断碰撞方向
ContactPoint2D.WorldNormal 在 2D 碰撞中不可靠用于判断"从哪个方向碰撞"(如:玩家是否从上方踩敌人、是否从下方顶砖块)。法线方向受物理引擎求解顺序、碰撞体形状边缘、穿透深度等因素影响,实际值可能与直觉不符。
csharp
#if CLIENT
// 错误:用 WorldNormal 判断踩踏
if (contact.ContactPoints?[0].WorldNormal.Y < -0.5f)
{
// 以为是从上方踩到,但实际可能误判
}
// 正确:用双方位置判断
float playerCenterY = playerNode.LocalPosition.Y;
float enemyCenterY = otherNode.LocalPosition.Y;
if (playerCenterY > enemyCenterY) // 物理坐标 Y 朝上
{
// 玩家在敌人上方 → 踩踏
}
#endif> 推荐:需要判断碰撞方向时,始终使用 Node.LocalPosition 的相对位置比较,不依赖法线。
陷阱 2:用 LinearVelocity 判断运动方向
在 OnCollisionBegin2D 回调中读取 RigidBody2D.LinearVelocity,其值可能已被物理引擎在碰撞求解阶段修改,不一定反映碰撞发生前一瞬间的真实速度。
csharp
#if CLIENT
// 不可靠:碰撞回调中的速度可能已被求解修改
if (playerBody.LinearVelocity.Y < 0)
{
// 以为玩家在下落,但值可能已被碰撞响应修改
}
// 更可靠:结合位置判断 + 速度作为辅助条件
float playerY = playerNode.LocalPosition.Y;
float enemyY = otherNode.LocalPosition.Y;
if (playerY > enemyY && playerBody.LinearVelocity.Y < 0f)
{
// 玩家在上方且下落中 → 高置信度的踩踏判定
}
#endif陷阱 3:IsTrigger vs OnCollisionPreSolve2D
两种方式都可以实现"检测碰撞但不产生物理阻挡",但适用场景不同:
| 方式 | 行为 | 适用场景 |
|---|---|---|
IsTrigger = true | 碰撞形状不参与物理求解,仅触发事件 | 金币、旗帜、拾取物、伤害区域 |
OnCollisionPreSolve2D + isActive = false | 参与物理检测但禁用碰撞响应 | 单向平台(从下方穿过、从上方站立) |
不要对需要事件但不需要阻挡的对象使用 OnCollisionPreSolve2D + isActive = false。这会干扰 OnCollisionBegin2D 的触发行为,且每物理帧都会调用 PreSolve 回调,有性能开销。直接使用 IsTrigger = true 更简洁可靠。
csharp
#if CLIENT
// 推荐:金币/拾取物用 IsTrigger
var coinShape = coinNode.CreateComponent<CollisionCircle2D>();
coinShape.IsTrigger = true;
coinShape.CategoryBits = Cat.Coin;
coinShape.MaskBits = Cat.Player;
// 推荐:敌人用 Kinematic + IsTrigger(不阻挡玩家,仅触发伤害/踩踏判定)
var enemyBody = enemyNode.CreateComponent<RigidBody2D>();
enemyBody.BodyType = BodyType2D.Kinematic;
var enemyShape = enemyNode.CreateComponent<CollisionBox2D>();
enemyShape.IsTrigger = true;
enemyShape.CategoryBits = Cat.Enemy;
enemyShape.MaskBits = Cat.Player;
#endif陷阱 4:着地检测使用 WorldNormal
判断玩家是否站在地面上时,WorldNormal 对于平坦地面通常可用(法线指向 Y 正方向),但对于地面边缘、斜面和多碰撞体交汇处可能不稳定。推荐同时使用接触计数:
csharp
#if CLIENT
private int groundContactCount = 0;
private bool isOnGround = false;
void OnCollisionBegin2D(Node.CollisionContact2D contact)
{
if (IsGroundNode(contact.Other))
{
var pts = contact.ContactPoints;
// WorldNormal.Y < -0.5f 对于平坦地面可靠(法线向下表示地面向上推玩家)
if (pts != null && pts.Length > 0 && pts[0].WorldNormal.Y < -0.5f)
{
groundContactCount++;
isOnGround = true;
}
}
}
void OnCollisionEnd2D(Node.CollisionContact2D contact)
{
if (IsGroundNode(contact.Other))
{
groundContactCount = Math.Max(0, groundContactCount - 1);
if (groundContactCount == 0) isOnGround = false;
}
}
#endif> 关键:使用计数而非布尔值追踪着地状态,因为玩家可能同时接触多个地面碰撞体。
陷阱 5:AllowDuplicateContactEvents 默认行为吞掉碰撞事件
当碰撞双方的 Node 都订阅了 OnCollisionBegin2D(或 3D 的 OnCollisionBegin),AllowDuplicateContactEvents 默认为 false,引擎只会向其中一个 Node 发送碰撞事件。哪个 Node 收到事件取决于 Box2D 内部的代理创建顺序(通常先创建的 Node 优先),这个顺序对用户不可控且不可预测。
csharp
#if CLIENT
// ❌ 危险模式:两个 Node 都订阅了碰撞事件
// 但 AllowDuplicateContactEvents 默认 false,只有一方收到事件
enemyNode.OnCollisionBegin2D += OnEnemyHit; // 可能收到
bulletNode.OnCollisionBegin2D += OnBulletContact; // 可能被吞掉!
// 更糟:如果"被选中"的那一侧 handler 是空的或只做日志,
// 另一侧的关键逻辑永远不会执行
enemyNode.OnCollisionBegin2D += (contact) => { }; // 空 handler 抢走了事件
bulletNode.OnCollisionBegin2D += OnBulletDamage; // 永远不会触发!
#endif最佳实践:碰撞处理逻辑只在一方订阅 OnCollisionBegin2D,另一方不要订阅。这样不需要 AllowDuplicateContactEvents,也不存在事件被吞的风险。
csharp
#if CLIENT
// ✅ 推荐:只在需要处理碰撞的一方订阅
// 子弹/攻击触发器负责处理命中逻辑
bulletNode.OnCollisionBegin2D += OnBulletContact;
// 敌人不订阅 OnCollisionBegin2D(不需要 AllowDuplicateContactEvents)
#endif如果确实需要双方都收到事件,必须显式设置 AllowDuplicateContactEvents = true:
csharp
#if CLIENT
// ✅ 如果双方都需要处理碰撞事件
enemyNode.AllowDuplicateContactEvents = true;
enemyNode.OnCollisionBegin2D += OnEnemyHit;
bulletNode.AllowDuplicateContactEvents = true;
bulletNode.OnCollisionBegin2D += OnBulletContact;
// 注意:此时双方都会收到事件,要避免重复处理(如双重伤害)
#endif陷阱 6:晚订阅碰撞事件导致漏掉初始接触
OnCollisionBegin 和 OnCollisionBegin2D 是“接触开始”事件,不是当前重叠状态查询。事件订阅会打开底层监听,但不会回放订阅前已经发生的 Begin 事件。如果先创建并激活 Node、RigidBody、CollisionShape,再注册碰撞事件,而物理世界已经推进过一帧,已经发生的初始接触就会被漏掉。
csharp
#if CLIENT
var node = graph.CreateChild("Trigger");
// ✅ 先订阅,再创建/激活物理组件
node.OnCollisionBegin += OnCollisionBegin;
var body = node.CreateComponent<RigidBody>();
body.IsTrigger = true;
var shape = node.CreateComponent<CollisionShape>();
shape.SetSphere(100);
#endif这条规则同样适用于 OnCollisionBegin2D、服务端权威物理和客户端本地物理。推荐让事件注册早于对象进入活动物理模拟,至少早于第一次可能的物理步进。若对象在注册前就可能与其他碰撞体重叠,应在完成订阅后额外使用 OverlapSphere / OverlapBox 或项目自己的重叠查询,显式处理初始状态;不要依赖 OnCollisionBegin 补偿历史接触。
碰撞类别分组推荐(2D 平台游戏)
csharp
#if CLIENT
static class Cat
{
public const int Ground = 1 << 0; // 地面/平台/砖块
public const int Player = 1 << 1; // 玩家
public const int Enemy = 1 << 2; // 敌人
public const int Coin = 1 << 3; // 金币/拾取物
public const int Flag = 1 << 4; // 终点/检查点
}
// 玩家:与所有类别碰撞
playerShape.CategoryBits = Cat.Player;
playerShape.MaskBits = Cat.Ground | Cat.Enemy | Cat.Coin | Cat.Flag;
// 地面:仅与玩家碰撞(敌人用代码控制移动边界,不需要物理碰撞)
groundShape.CategoryBits = Cat.Ground;
groundShape.MaskBits = Cat.Player;
// 金币/旗帜:IsTrigger + 仅与玩家碰撞
coinShape.IsTrigger = true;
coinShape.CategoryBits = Cat.Coin;
coinShape.MaskBits = Cat.Player;
#endif使用示例
3D 射线检测实现瞄准
csharp
#if CLIENT
public class AimingHelper
{
public Node? FindTarget(PhysicsWorld physicsWorld, Vector3 origin, Vector3 direction, float range, uint mask)
{
var hit = physicsWorld.Raycast(origin, direction, range, mask);
if (!hit.HasValue) return null;
var rigidBody = GameGraph.NodeSystem.ExtenstionMethod.GetRigidBody(hit.Value);
return rigidBody?.Node;
}
public RigidBody[] FindBodiesInArea(PhysicsWorld world, Vector3 center, float radius, uint mask)
{
return world.OverlapSphere(center, radius, mask);
}
}
#endif如果预瞄准或落点检测发现客户端射线穿透墙壁,先检查当前场景是否已经成功调用 EnsureStaticMeshColliders(),以及场景的 PhysicsCollision.dat 是否随客户端资源发布;静态碰撞确认加载后,再排查射线方向、距离和碰撞掩码。
3D 触发区域(进入/离开检测)
使用 Node 事件(推荐,可获取接触点信息):
csharp
#if CLIENT
var zoneNode = unit.GetNode();
var body = zoneNode.GetComponent<RigidBody>();
body.IsTrigger = true;
body.Kinematic = true;
zoneNode.OnCollisionBegin += (Node.CollisionContact contact) =>
{
Game.Logger.LogInformation("节点进入治疗区域: {Other}", contact.Other?.Name);
};
zoneNode.OnCollisionEnd += (Node.CollisionContact contact) =>
{
Game.Logger.LogInformation("节点离开治疗区域: {Other}", contact.Other?.Name);
};
#endif或使用 ScriptComponent 回调(仅传递对方 Node):
csharp
#if CLIENT
public class HealingZone : ScriptComponent
{
public override void OnStart()
{
var body = Node.GetComponent<RigidBody>();
body.IsTrigger = true;
body.Kinematic = true;
}
public override void OnTriggerEnter(Node node)
{
Game.Logger.LogInformation("节点进入治疗区域");
}
public override void OnTriggerExit(Node node)
{
Game.Logger.LogInformation("节点离开治疗区域");
}
}
#endif2D 平台跳跃角色控制(客户端)
csharp
#if CLIENT
public class PlatformerController2D : ScriptComponent
{
private RigidBody2D _body;
private bool _isGrounded;
public override void OnStart()
{
_body = Node.GetComponent<RigidBody2D>();
_body.BodyType = BodyType2D.Dynamic;
_body.FixedRotation = true;
_body.GravityScale = 3f;
}
public void Move(float horizontalInput)
{
var velocity = _body.LinearVelocity;
velocity.X = horizontalInput * 300f;
_body.LinearVelocity = velocity;
}
public void Jump()
{
if (!_isGrounded) return;
_body.LinearVelocity = new Vector2(_body.LinearVelocity.X, 500f);
_isGrounded = false;
}
}
#endifAPI 速查表
3D 物理(双端)
| 类 | 关键成员 | 运行端 |
|---|---|---|
PhysicsWorld | Raycast, RaycastAll, SphereCast, OverlapSphere, OverlapBox | SERVER / CLIENT |
RigidBody | Mass, LinearVelocity, ApplyForce, ApplyImpulse, IsTrigger, Kinematic | SERVER / CLIENT |
CollisionShape / ShapeType | Box, Sphere, Capsule, Cylinder, Cone, TriangleMesh, ConvexHull, Terrain, StaticPlane | SERVER / CLIENT |
CollisionEventMode | Never, Active, Always | SERVER / CLIENT |
ConstraintType | Point, Hinge, Slider, ConeTwist | SERVER / CLIENT |
2D 物理(双端)
| 类 | 关键成员 | 运行端 |
|---|---|---|
PhysicsWorld2D | Gravity, RaycastSingle, RaycastAll, GetRigidBody, QueryAABB | SERVER / CLIENT |
RigidBody2D | BodyType, Mass, LinearVelocity, AngularVelocity, GravityScale, Bullet | SERVER / CLIENT |
CollisionBox2D | Size, Center | SERVER / CLIENT |
CollisionCircle2D | Radius, Center | SERVER / CLIENT |
CollisionEdge2D | Vertex1, Vertex2 | SERVER / CLIENT |
CollisionChain2D | Vertices, Loop | SERVER / CLIENT |
CollisionPolygon2D | Vertices | SERVER / CLIENT |
Node 碰撞事件(双端)
| 事件 | 回调参数 | 说明 |
|---|---|---|
OnCollisionBegin | CollisionContact | 3D 碰撞开始 |
OnCollisionStay | CollisionContact | 3D 碰撞持续 |
OnCollisionEnd | CollisionContact | 3D 碰撞结束 |
OnCollisionBegin2D | CollisionContact2D | 2D 碰撞开始 |
OnCollisionEnd2D | CollisionContact2D | 2D 碰撞结束 |
OnCollisionPreSolve2D | CollisionContact2D, ref bool | 2D 碰撞预求解 |
AllowDuplicateContactEvents | bool 属性 | 允许碰撞双方都收到事件 |
ScriptComponent 碰撞回调(双端)
| 回调 | 参数 | 说明 |
|---|---|---|
OnTriggerEnter | Node node | 碰撞体进入 |
OnTriggerStay | Node node | 碰撞体停留 |
OnTriggerExit | Node node | 碰撞体离开 |
注意事项与最佳实践
运行端限制
| 内容 | 服务端 | 客户端 |
|---|---|---|
| 3D 物理(PhysicsWorld、RigidBody 等) | 可用,可作为权威物理端 | 可用,可作表现/预览/本地物理 |
| 2D 物理(PhysicsWorld2D、RigidBody2D 等) | 可用,可作为 Box2D 权威模拟端 | 可用,可作本地表现/预览/Canvas 映射 |
> 同步边界:服务端 2D/3D 物理本身不会自动把任意本地图同步给客户端。需要客户端看到服务端物理结果时,应把物理节点放在 SCE 默认同步场景中,或放在 SceneGraphReplication.ReplicatedUnpublished 的脚本创建图中并 Publish(player);客户端仍需负责可视化、音效、UI 或 Canvas 映射。详见 服务端 GameGraph SceneGraph 发布与同步。
> 验证状态:2026-06-01 的 published_scenegraph_sync_smoke 已验证服务端 replicated Node + local RigidBody / local CollisionShape / local PhysicsWorld 的发布图链路。服务端物理推进后的节点 transform 可同步到已订阅客户端;未订阅玩家不会收到该 SceneGraph。完整流程见 服务端 GameGraph SceneGraph 发布与同步 - 已验证的 smoke 流程。
服务端权威 3D 物理
服务端权威 3D 物理的推荐目标是:服务端负责真实碰撞、运动和游戏判定;客户端负责接收同步结果并做可视化、音效、UI 反馈。不要把渲染组件、音频组件和材质状态当作服务端权威物理的一部分。
推荐分层
| 对象类型 | 服务端 | 客户端 | 推荐复制语义 |
|---|---|---|---|
| 静态地形/障碍 | 创建本地 CollisionShape / 静态刚体,用于碰撞查询和模拟 | 加载同一 SCE 场景资产或创建本地表现对象 | 通常双端 local;如果需要动态出现/消失再同步状态 |
| 动态权威物体 | 创建 replicated Node,服务端本地 RigidBody 驱动物理 | 接收 replicated Node transform,创建或绑定本地表现 | NodeReplication.Replicated,物理组件通常 ComponentReplication.Local |
| 需要客户端知道的碰撞形状 | 服务端创建并参与模拟 | 客户端需要调试/预测/可视化时接收形状信息 | CollisionShape 可按需要 Replicated,不需要时保持 local |
| 纯表现模型/粒子/音效 | 不创建 | 客户端根据 replicated 节点或事件创建 | 客户端 local |
| 触发区/检测区 | 服务端创建本地触发刚体或查询逻辑 | 客户端只显示范围提示或 UI | 逻辑 local,表现 local;必要时用 Node.SetSyncVar(...) 同步轻量状态 |
推荐做法
- 动态对象复制 Node,不复制所有物理细节 -- 多数情况下客户端只需要看到服务端权威结果。将动态物体的
Node设为Replicated,让 transform 同步到客户端;RigidBody保持服务端Local,避免客户端误以为也要参与权威模拟。 - 视觉平滑在客户端调 -- replicated transform 默认会在客户端通过
SmoothedTransform平滑到最新网络目标。需要更快追随或更少硬切时,调整对应SceneGraph的TransformSmoothingConstant和TransformSnapThreshold。这不会改变服务端权威物理结果,也不会从服务端自动同步到客户端。 - 静态障碍优先双端 local -- 地形、墙体、不可移动障碍通常来自同一 SCE 场景资产,服务端和客户端可以各自本地创建/加载。除非障碍会运行时变化,否则没有必要把它们作为 replicated 节点同步。
- 轻量节点状态用 SyncVar -- 机关阶段、对象类型、阵营、调试标记等少量节点级状态,可以由服务端
SetSyncVar(...)写入,客户端TryGetSyncVar(...)读取。不要用 SyncVar 存复杂对象、大字符串、背包、配置表或高频大 payload。 - 权威判定只在服务端发生 -- 伤害、命中、拾取、触发器、机关状态变更应由服务端物理结果或服务端查询决定,再通过 GameCore 事件、属性同步或 replicated GameGraph 节点传给客户端。
- 表现组件放客户端 --
StaticMeshComponent、AnimatedModelComponent、材质、粒子、音效、Spine 等表现层组件应在客户端创建或由 Actor/Unit 表现体系管理。服务端不要依赖这些组件做逻辑。 - 发布图不必总是进 Viewport -- 客户端收到 replicated
SceneGraph不等于画面可见,也不要求一定渲染。它可以只作为服务端权威物理/状态的数据源,客户端把节点位置绘制到 Canvas、小地图或调试 UI。若该图要直接显示为 3D 画面,再准备Octree、local 表现组件、localCameraComponent,并用Renderer.SetupMainViewport(...)或项目自己的 viewport 策略绑定视口。SceneGraphMainViewportController只是可选 helper,用于临时接管主视口时复用保存/恢复逻辑;若主画面仍应显示 SCE 地图,应把表现挂到当前 SCE 场景 / Actor / Unit 体系,或保持发布图为数据源,而不是让临时发布图长期占用主视口。 - 调试 mesh 提供 normal -- 用
MeshBuilder或自定义网格做服务端物理调试可视化时,应写入 normal,避免常规光照下发黑或亮度异常。 - 选择合适场景归属 -- 随地图生命周期存在的物理对象放在 SCE 默认场景;临时副本、机关组、调试对象、非 Unit 的独立同步物件可以放在服务端发布的
SceneGraph。 - 先同步少量高价值对象 -- 对大量碎片、弹壳、装饰物等非关键物理,优先客户端 local 表现或事件驱动生成。只把会影响玩法判定或其他玩家可见结果的对象纳入服务端权威同步。
- 客户端预测谨慎使用 -- 目前文档推荐服务端权威 + 客户端接收 transform。需要预测/回滚/插值策略时,应先为具体玩法建立测试闭环,再固化为项目模式。
场景归属选择
| 需求 | 建议 |
|---|---|
物体属于当前地图,随 Scene.Load() / Unload() 存亡 | 使用 SCE Scene,通过 scene.GetSyncRoot() / scene.AddSyncNode(...) 挂载 |
| 物体是临时同步图、可单独发给部分玩家、或一个客户端需要同时接收多个图 | 服务端创建 SceneGraphReplication.ReplicatedUnpublished,按玩家 Publish(player) |
| 物体只用于客户端预览或单机原型 | 客户端 local SceneGraph,不发布 |
推荐做法
- 碰撞层合理分组 -- 使用
CollisionLayer和CollisionMask将不同类型的物体(角色、弹丸、地形、触发区域)分到不同碰撞层,减少不必要的碰撞检测开销。 - 优先使用运动学刚体 -- 对于不需要物理模拟的物体(如移动平台、NPC),将
Kinematic设为true,通过代码直接控制位置。 - 触发器与刚体分离 -- 将触发区域(
IsTrigger = true)和物理碰撞体分开使用,避免混淆逻辑。 - 休眠管理 -- 不要频繁唤醒大量刚体。对于临时不参与模拟的物体,让物理引擎自动管理休眠。
- 射线检测掩码 -- 始终传入适当的碰撞掩码以缩小查询范围,避免对场景中所有物体进行检测。
- 2D/3D 不混用 -- 同一场景中不要同时使用 3D 和 2D 物理组件;2D 物理专用于纯 2D 游戏场景。
常见错误
| 错误 | 说明 |
|---|---|
| 服务端创建了本地 3D 物理对象但客户端看不到 | 节点所在 SceneGraph 没有发布,或节点/组件使用了 Local 复制语义 |
| 客户端收到 replicated 节点但画面仍看不到 | 独立 SceneGraph 没有客户端 local 表现组件、没有 Octree / CameraComponent / Viewport,或主视口被恢复/切走 |
| 服务端 2D 物理运行了但客户端画面没变化 | 物理模拟只推进服务端节点和碰撞状态;客户端需要接收 replicated Node 或业务消息,并用 Canvas/GameUI/渲染对象绘制表现 |
| 将 Y 轴当作高度轴 | WasiCore 中 Z 轴是高度轴,重力沿 Z 轴负方向 |
| 对触发器施加力 | IsTrigger = true 的刚体不参与力学模拟 |
| 忽略碰撞掩码 | 导致射线或区域查询检测到不相关物体 |