feat(physics): add runtime physics scaffolding

This commit is contained in:
2026-04-15 11:58:27 +08:00
parent d17ddffdef
commit 3317e47009
31 changed files with 2120 additions and 0 deletions

View File

@@ -0,0 +1,846 @@
# PhysX 物理模块架构与分阶段实施计划
日期: `2026-04-15`
## 1. 文档定位
这份文档是当前 `XCEngine` 物理模块主线的正式计划。
本计划只针对一条路线:
- 只集成 `PhysX`
- 只做 `3D` 物理
- 只做一个后端
- 不做 `Jolt`
- 不做类似 `RHI` 的双后端抽象
这份文档的目标不是讨论“物理引擎选型”,而是把当前工程里怎样把 `PhysX` 正式接进运行时、编辑器、序列化、测试链路,拆成可执行的阶段计划。
## 2. 当前工程事实
### 2.1 运行时已经具备固定步入口
当前运行时已经有较成熟的固定步循环:
- `engine/src/Scene/RuntimeLoop.cpp`
- `engine/src/Scene/SceneRuntime.cpp`
当前 `SceneRuntime::FixedUpdate()` 的顺序是:
1. `ScriptEngine::OnFixedUpdate()`
2. `Scene::FixedUpdate()`
这意味着物理模块的首个正式接入点是明确的:物理 step 应该插在当前 fixed-step 主链上,而不是另起一个临时循环或旁路更新器。
### 2.2 Play 模式已经有场景克隆与恢复链路
当前编辑器 Play 模式不是直接改编辑态场景,而是通过运行时场景快照/恢复工作:
- `editor/src/Core/PlaySessionController.cpp`
- `editor/src/Managers/SceneManager.cpp`
这意味着物理世界不应该成为 `Scene` 的持久化运行时负担,而应该是运行时侧临时构建、停止播放后销毁的对象。
### 2.3 组件生命周期目前不够支撑正式物理注册链
当前 `GameObject::AddComponent()` 只负责创建组件并塞入 `m_components`,不会自动补齐运行时注册链:
- `engine/include/XCEngine/Components/GameObject.h`
当前 `GameObject` 已经有:
- `AddComponent<T>()`
- `RemoveComponent<T>()`
- `RemoveComponent(Component*)`
但当前 `Scene` 只有:
- `OnGameObjectCreated()`
- `OnGameObjectDestroyed()`
没有:
- `OnComponentAdded()`
- `OnComponentRemoved()`
这会直接影响物理模块,因为 `Rigidbody` / `Collider` 属于组件级对象,不是 `GameObject` 级对象。
### 2.4 反序列化后没有现成的物理补注册链
当前 `Scene::DeserializeFromString()` 会重建 `GameObject` 和组件,并调用组件 `Deserialize()`,但没有正式的物理补注册入口:
- `engine/src/Scene/Scene.cpp`
这意味着物理模块首版不能只依赖组件 `Awake()` 完成注册,必须在运行时启动时做一次全场景扫描建表。
### 2.5 组件工厂和 Inspector 还没有物理组件入口
当前内建组件注册点是:
- `engine/src/Components/ComponentFactoryRegistry.cpp`
- `editor/src/ComponentEditors/ComponentEditorRegistry.cpp`
这两处目前都还没有物理相关组件:
- `Rigidbody`
- `Collider`
- `BoxCollider`
- `SphereCollider`
- `CapsuleCollider`
因此物理模块不能只写运行时,还必须同步补齐工厂、编辑器和序列化入口。
### 2.6 任务系统当前不适合直接承接物理并发
当前线程基础模块存在明确缺口:
- `engine/src/Threading/TaskSystem.cpp``TaskSystem::Wait()` 为空实现
- `engine/include/XCEngine/Threading/TaskSystem.h``ParallelFor()` 只提交任务,不等待完成
- `engine/src/Threading/TaskGroup.cpp` / `engine/include/XCEngine/Threading/TaskGroup.h``m_pendingCount` 只看到了增加路径,没有完整的完成回收闭环
这意味着当前工程的任务系统暂时不能作为物理 step 的可信并发底座。
结论很明确:
- 线程问题是真问题
- 但它不是 `PhysX v1` 的主阻塞项
- `PhysX v1` 首版必须按单线程/同步 step 路线设计
- 不能在 `v1` 阶段把 `XCEngine::Threading::TaskSystem` 接到物理关键路径里
### 2.7 构建系统当前还没有物理第三方接入位
当前 `engine/CMakeLists.txt` 已经是显式 vendored third-party 风格:
- `GLAD`
- `stb`
- `assimp`
- `kissfft`
因此 `PhysX` 最顺的接法不是额外做一套复杂包管理,而是沿用当前工程习惯,以 `engine/third_party/physx/` 的形式落地。
## 3. 总体目标
这次物理模块建设的总体目标是:
1. 在当前引擎里建立正式的 `PhysX` 物理子系统,而不是 demo 级接入。
2. 对外提供 Unity 风格的 `Rigidbody + Collider + FixedUpdate + Query + Collision/Trigger` 体验。
3. 物理模块必须打通运行时、编辑器、序列化、Play 模式、测试链路。
4. 首版优先保证正确性、稳定性和可维护性,不追求并行优化和高级特性广度。
## 4. 核心架构决策
### 4.1 只做 `PhysX`,不预留双后端实现
当前计划里不做:
- `IPhysicsBackend`
- `PhysXBackend + JoltBackend`
- `类似 RHI 的多后端切换`
当前只做:
- 引擎自己的物理概念层
- 一套 `PhysX` 实现
这里的“引擎自己的物理概念层”不是为了多后端,而是为了不把 `Px*` 类型泄露到公共头文件和脚本/编辑器接口中。
### 4.2 只做 `3D` 物理
当前路线不包含:
- `2D`
- `Box2D`
后续如果要做 `2D`,必须作为独立项目线,而不是在这条 `PhysX 3D` 计划里夹带实现。
### 4.3 首版单线程
当前物理模块首版固定为:
- 主线程创建物理世界
- 主线程同步 step
- 主线程收集和派发物理事件
不做:
- 物理内部 job system 接桥
- 自研任务系统并行 broadphase / query
- 并行回调派发
### 4.4 `SceneRuntime` 持有 `PhysicsWorld`
当前建议所有权如下:
- `Scene` 只持有可序列化的场景和组件数据
- `SceneRuntime` 持有运行时 `PhysicsWorld`
- `PhysicsWorld` 持有 `PhysX Foundation / Physics / Scene / Material / Dispatcher` 等原生对象
这样可以和当前 Play 模式克隆/恢复模型自然对齐。
### 4.5 运行时物理对象与组件解耦
当前建议把物理对象分成两层:
1. 可序列化层
组件数据,例如:
- `RigidbodyComponent`
- `ColliderComponent`
- `BoxColliderComponent`
- `SphereColliderComponent`
- `CapsuleColliderComponent`
2. 运行时绑定层
运行时 `PhysicsWorld` 内维护:
- `GameObject ID -> PhysX actor/shape binding`
- `PhysX actor/shape -> GameObject ID`
- 变更脏标记
- 最近一次同步的 transform 快照
这意味着组件不直接拥有 `PxRigidActor*``PxShape*` 的公开生命周期。
### 4.6 `GameObject.layer` 直接作为 v1 过滤入口
`v1` 不做项目级碰撞矩阵资产系统。
`v1` 先直接复用现有:
- `GameObject.layer`
后续再扩展成:
- layer collision matrix
- query mask
- trigger special-case mask
### 4.7 物理事件必须先入队,再主线程派发
不能在 `PhysX` 回调里直接调脚本或原生用户逻辑。
正确路线是:
1. `PhysX` 原始 callback 收集 contact/trigger 信息
2. 写入引擎自己的事件缓冲
3. 在 step 完成后,回到主线程统一翻译并派发
### 4.8 先做运行时启动全量扫描,再补组件级事件
由于当前反序列化与组件生命周期链还不完整,首版必须:
1. 运行时启动时扫描整个 scene
2. 为所有 `Rigidbody/Collider` 建立运行时绑定
然后再补:
- 组件新增
- 组件删除
- 运行时临时创建/销毁对象
### 4.9 不把 `Px*` 类型泄露到公共 API
公共头文件中禁止暴露:
- `PxRigidActor`
- `PxShape`
- `PxScene`
- `PxMaterial`
- 其他 `Px*` SDK 类型
`PhysX` 相关包含和具体实现必须收口在:
- `engine/src/Physics/PhysX/`
## 5. 首版范围与边界
### 5.1 首版必须完成
`v1` 必须完成以下能力:
- `RigidbodyComponent`
- `ColliderComponent` 抽象基类
- `BoxColliderComponent`
- `SphereColliderComponent`
- `CapsuleColliderComponent`
- `Static / Dynamic / Kinematic` 三类刚体模式
- 基础重力与力学 step
- `Raycast`
- 至少一种基础 `Overlap`
- `Collision / Trigger` 事件缓冲与主线程派发
- Inspector 编辑
- 场景序列化 / 反序列化
- Play 模式进入/退出时的物理世界创建与销毁
- 基础自动化测试
### 5.2 首版明确限制
为了保证第一条线能闭环,`v1` 主动限制为:
- 每个 `GameObject` 只支持一个 `RigidbodyComponent`
- 每个 `GameObject` 首版只支持一个 `ColliderComponent`
- 不做 child-collider compound 组装
- 不做 `MeshCollider`
- 不做 joint
- 不做 character controller
- 不做 vehicle
- 不做 `PhysicsMaterial` 资产系统
- 不做复杂 runtime cook
- 不做多线程 step
这不是说后续永远不支持,而是明确防止 `v1` 范围失控。
### 5.3 首版的简单语义
首版建议采用以下简单语义:
- `Collider` 但没有 `Rigidbody`:创建 static actor
- `Rigidbody + Collider`:创建 dynamic actor 或 kinematic actor
- `Rigidbody` 没有 `Collider`:允许存在,但不创建可碰撞 shape并输出警告日志
- 运行时移动 static collider先定义为“允许但会触发重建或警告”不在 `v1` 追求完美优化
## 6. 建议的模块结构
### 6.1 公共头文件
建议新增:
- `engine/include/XCEngine/Physics/PhysicsTypes.h`
- `engine/include/XCEngine/Physics/RaycastHit.h`
- `engine/include/XCEngine/Physics/PhysicsQuery.h`
- `engine/include/XCEngine/Physics/PhysicsEvents.h`
- `engine/include/XCEngine/Physics/PhysicsWorld.h`
### 6.2 PhysX 实现目录
建议新增:
- `engine/src/Physics/PhysicsWorld.cpp`
- `engine/src/Physics/PhysicsQuery.cpp`
- `engine/src/Physics/PhysicsEvents.cpp`
- `engine/src/Physics/PhysX/PhysXCommon.h`
- `engine/src/Physics/PhysX/PhysXWorld.cpp`
- `engine/src/Physics/PhysX/PhysXShapeBuilder.cpp`
- `engine/src/Physics/PhysX/PhysXEventListener.cpp`
- `engine/src/Physics/PhysX/PhysXQueryAdapter.cpp`
### 6.3 组件层
建议新增:
- `engine/include/XCEngine/Components/RigidbodyComponent.h`
- `engine/include/XCEngine/Components/ColliderComponent.h`
- `engine/include/XCEngine/Components/BoxColliderComponent.h`
- `engine/include/XCEngine/Components/SphereColliderComponent.h`
- `engine/include/XCEngine/Components/CapsuleColliderComponent.h`
- 对应 `engine/src/Components/*.cpp`
### 6.4 编辑器层
建议新增:
- `editor/src/ComponentEditors/RigidbodyComponentEditor.cpp`
- `editor/src/ComponentEditors/BoxColliderComponentEditor.cpp`
- `editor/src/ComponentEditors/SphereColliderComponentEditor.cpp`
- `editor/src/ComponentEditors/CapsuleColliderComponentEditor.cpp`
### 6.5 测试层
建议新增:
- `tests/Physics/CMakeLists.txt`
- `tests/Physics/test_physics_world.cpp`
- `tests/Physics/test_rigidbody_component.cpp`
- `tests/Physics/test_collider_component.cpp`
- `tests/Physics/test_physics_queries.cpp`
- `tests/Physics/test_physics_events.cpp`
必要时补充:
- `tests/Scene/`
- `tests/Editor/`
- `tests/Scripting/`
## 7. 运行时数据模型与职责分工
### 7.1 `PhysicsWorld` 的职责
`PhysicsWorld` 负责:
- 初始化和销毁 `PhysX`
- 创建和销毁 runtime `PxScene`
- 维护 `GameObject` 与物理 actor/shape 的双向绑定
- 同步 transform
- 执行 step
- 执行 query
- 缓存和派发事件
`PhysicsWorld` 不负责:
- 组件序列化格式定义
- Inspector UI
- 编辑器 undo/redo
### 7.2 `RigidbodyComponent` 的职责
`RigidbodyComponent` 负责声明可序列化的刚体属性,例如:
- body type
- mass
- use gravity
- linear drag
- angular drag
- constraints
- is kinematic
- is trigger 不应该放在刚体里
### 7.3 `ColliderComponent` 的职责
`ColliderComponent` 负责声明可序列化的碰撞体属性,例如:
- trigger
- center
- size / radius / height
- friction / restitution 的简化参数
- 运行时 dirty 标记
### 7.4 transform 同步方向
`v1` 建议采用明确的单向职责:
- dynamic rigidbody`PhysX -> Transform`
- kinematic rigidbody`Transform -> PhysX`
- static collider初始化时 `Transform -> PhysX`,运行时若变更则标记重建
### 7.5 transform 变更检测策略
当前 `TransformComponent` 只有内部 `m_dirty`,它主要服务矩阵缓存,不是对外事件总线。
因此 `v1` 不建议先补全局 transform 事件。
`v1` 采用更稳妥的做法:
- 物理绑定缓存最近一次同步的 world transform
- 每个 fixed-step 前,对受物理影响的对象做快照比对
- 只有发现变更时才同步到 `PhysX`
这样可以避免为了物理模块先重构整个 transform 通知系统。
## 8. 关键接入链路
### 8.1 Runtime fixed-step 接入
当前建议在 `SceneRuntime::FixedUpdate()` 中接入物理 step目标顺序为
1. `ScriptEngine::OnFixedUpdate(fixedDeltaTime)`
2. `Scene::FixedUpdate(fixedDeltaTime)`
3. `PhysicsWorld::Step(fixedDeltaTime)`
4. 写回 dynamic rigidbody transform
5. 派发 `Collision / Trigger` 事件
这条顺序与 Unity 经典 `FixedUpdate -> Physics Simulation` 的高层心智基本一致。
### 8.2 Runtime start/stop 接入
当前建议:
- `SceneRuntime::Start()` 或等价 runtime 启动点创建 `PhysicsWorld`
- 启动时扫描 scene 全量注册物理组件
- `SceneRuntime::Stop()` 销毁 `PhysicsWorld`
### 8.3 运行时对象新增/销毁接入
利用当前已经存在的:
- `Scene::OnGameObjectCreated()`
- `Scene::OnGameObjectDestroyed()`
处理:
- 运行时新建带物理组件的对象
- 运行时销毁对象时的物理解绑
### 8.4 组件增删接入
这里是首个必须补的基础设施点。
建议新增:
- `Scene::OnComponentAdded()`
- `Scene::OnComponentRemoved()`
并让:
- `GameObject::AddComponent()`
- `GameObject::RemoveComponent()`
在有 `Scene` 挂接时通知 scene 事件。
只有这样,运行时临时添加 `Rigidbody``Collider` 才能被物理系统正式接收。
### 8.5 序列化接入
当前做法建议是:
- 物理组件完全走现有组件序列化体系
- `PhysicsWorld` 本身不序列化
- 进入 play 时从场景数据重建 runtime 物理世界
### 8.6 Inspector 接入
Inspector 需要完成:
- 组件创建入口
- 字段编辑
- dirty scene 标记
- 运行时模式下对物理对象的即时重建或同步
### 8.7 Scripting 接入
脚本接入分两步:
1. `v1a`
先保证脚本 `FixedUpdate` 修改 transform 可以影响 kinematic rigidbody。
2. `v1b`
再暴露 managed 侧基础 API例如
- `Physics.Raycast`
- `Rigidbody.AddForce`
- `OnCollisionEnter/Exit`
- `OnTriggerEnter/Exit`
脚本桥接不应阻塞 native 物理核心落地。
## 9. 必须先处理的基础设施问题
### 9.1 必须优先解决:组件级注册链
这是物理模块首个真实 blocker。
没有组件级事件,就没有正式的 runtime add/remove 注册链。
临时在 `ComponentFactoryRegistry` 或编辑器命令里补逻辑都不对,因为:
- 场景反序列化会绕过它
- 脚本运行时添加组件也会绕过它
- 后续测试会出现路径不一致
### 9.2 不必阻塞首版:任务系统修复
线程模块存在问题,但它不是 `PhysX v1` 的阻塞项。
本轮物理计划的原则是:
- 承认任务系统当前不可靠
- 不把它接进 `PhysX v1`
- 单独列为后续基础设施整修任务
### 9.3 需要明确策略transform 通知缺失
当前没有统一 transform 变更事件。
本轮不建议为了物理模块先重构全局 transform 事件系统,而是采用缓存比对方案。
### 9.4 需要明确策略:运行时静态体变更
Unity 里移动 static collider 本来也不是理想路径。
`v1` 建议明确定义:
- 若 static collider transform 发生变更,物理系统执行重建或输出警告
- 不在首版追求最优成本
## 10. 分阶段执行计划
### Phase 0基础设施收口与边界建立
目标:
- 把物理模块需要的“最低限度基础链”补齐
任务:
1.`Scene` 增加组件级事件
2.`GameObject::AddComponent()` / `RemoveComponent()` 通知 `Scene`
3. 新建 `Physics` 目录、CMake 条目、测试目录骨架
4. 明确 `PhysX-only` 宏和 build 配置
5. 定义物理模块公共类型和命名规范
完成标志:
- 可以稳定收到组件增删事件
- 物理模块可以作为正式 engine 模块参与编译
- 没有把 `Px*` 暴露到公共 API
### Phase 1PhysX 启动与 `PhysicsWorld` 最小闭环
目标:
- 在运行时建立一套最小可工作的 `PhysicsWorld`
任务:
1. 接入 `PhysX` 第三方目录和 CMake
2. 初始化 `foundation / physics / scene / material`
3. 建立 `PhysicsWorld` 生命周期
4.`SceneRuntime` 的 start/stop 路径中创建/销毁世界
5. 在 fixed-step 中插入 `PhysicsWorld::Step()`
完成标志:
- 空场景可正常创建和销毁物理世界
- fixed-step 中可执行空 step 且不破坏现有 runtime loop
- Play 模式进出不泄漏物理资源
### Phase 2刚体与碰撞体组件接入
目标:
- 把最基础的 `Rigidbody + Collider` 跑通
任务:
1. 新增 `RigidbodyComponent`
2. 新增 `ColliderComponent` 基类
3. 新增 `Box / Sphere / Capsule` 三种碰撞体
4.`ComponentFactoryRegistry` 注册组件
5. 在组件序列化中支持物理字段
6. 实现 `Collider without Rigidbody => static actor`
7. 实现 `Rigidbody + Collider => dynamic/kinematic actor`
完成标志:
- 编辑器可添加这些组件
- 场景保存/加载后组件数据完整恢复
- 运行时能生成对应 `PhysX` actor/shape
### Phase 3运行时注册链与 transform 同步
目标:
- 解决“启动时有物理对象”和“运行时新增/删除物理对象”两条链
任务:
1. 运行时启动时全量扫描 scene
2.`OnGameObjectCreated/Destroyed` 建立注册/解绑逻辑
3.`OnComponentAdded/Removed` 建立注册/解绑逻辑
4. 建立 `GameObject ID <-> actor/shape` 双向绑定
5. 实现 pre-step 与 post-step transform 同步
6. 对 static collider 运行时变更采用重建或警告策略
完成标志:
- 运行时创建/删除物体可以正确影响物理世界
- kinematic transform 修改能正确驱动物理
- dynamic rigidbody 的仿真结果能稳定写回 transform
### Phase 4查询、过滤与事件
目标:
- 把 gameplay 最核心的 physics query 和事件做出来
任务:
1. 实现 `Raycast`
2. 实现至少一种 `Overlap`
3. 复用 `GameObject.layer` 做碰撞/查询过滤
4. 接入 `PhysX` contact / trigger callback
5. 建立引擎自己的事件缓冲
6. 在 step 后统一派发 `Collision / Trigger` 事件
完成标志:
- 可通过 API 执行稳定 raycast
- layer 过滤能生效
- trigger 与 collision 事件能按主线程顺序派发
### Phase 5编辑器与 Play 模式正式化
目标:
- 让物理模块成为编辑器里的正式一等公民
任务:
1. 新增物理组件 Inspector
2. 支持组件属性编辑和 scene dirty 标记
3. Play 模式下 runtime scene 正确构建 physics world
4. Stop Play 后编辑态 scene 不被 runtime 结果污染
5. 补充最小物理调试可视化方案
完成标志:
- 在 Inspector 中可稳定编辑物理组件
- Play / Stop 后 scene 状态恢复正确
- 物理模块不再依赖临时 debug 路径才能观测
### Phase 6脚本桥接与 API 收口
目标:
- 把物理能力正式接到 managed 层
任务:
1. 暴露 `Physics.Raycast`
2. 暴露 `Rigidbody` 基础方法
3. 暴露碰撞/触发回调
4. 对脚本侧文档和示例进行最小补齐
完成标志:
- C# 脚本可执行基础 query
- C# 脚本可感知基础碰撞/触发事件
- 脚本层没有直接依赖 `Px*`
### Phase 7稳定性、限制收口与二期预留
目标:
- 在不扩大范围的前提下,把首版边界收紧
任务:
1. 完成日志、断言、错误提示收口
2. 明确静态体移动、缺 collider 刚体、非法参数等限制
3. 评估二期项目:
- 多 collider
- compound shape
- mesh collider
- joints
- physics material asset
- 任务系统修复后再考虑的并行 step
完成标志:
- `v1` 边界稳定、行为可解释、测试可重复
- 二期需求不再反向污染 `v1` 核心结构
## 11. 构建与第三方接入策略
当前建议沿用已有 third-party 风格:
- `engine/third_party/physx/include`
- `engine/third_party/physx/lib`
- `engine/third_party/physx/bin`
建议首版只支持:
- Windows
- MSVC
- x64
构建侧任务包括:
1. `engine/CMakeLists.txt` 增加 include path
2. 增加 PhysX 相关 link libraries
3. editor/tests 复制 PhysX 运行时 DLL
4. Debug / Release 配置分离
当前不建议首版同时做:
- 多平台打包脚本
- 自动下载 SDK
- 复杂 find-package 兼容层
## 12. 测试计划
### 12.1 单元测试
建议覆盖:
- 组件工厂可创建物理组件
- 物理组件序列化/反序列化
- `PhysicsWorld` 创建/销毁
- raycast 命中
- layer 过滤
- trigger / collision 事件翻译
### 12.2 运行时测试
建议覆盖:
- fixed-step 中重力驱动刚体下落
- dynamic rigidbody 写回 transform
- kinematic transform 驱动物理体移动
- 运行时新增/删除物体或组件
### 12.3 编辑器测试
建议覆盖:
- Play 模式启动后运行时场景生成物理世界
- Stop Play 后恢复编辑态场景
- Inspector 可添加和移除物理组件
### 12.4 脚本测试
建议覆盖:
- 脚本 fixed update 修改 kinematic 目标
- managed `Physics.Raycast`
- managed 碰撞回调
## 13. 与线程基础模块的关系
当前结论必须明确写死:
- `PhysX v1` 不依赖 `XCEngine::Threading::TaskSystem`
- `PhysX v1` 不以修复任务系统为前置阻塞项
- 任务系统修复是后续基础设施整修项目,不并入当前物理主线
后续只有在以下条件满足后,才考虑物理并行化:
1. `TaskSystem::Wait()` 有完整实现
2. `ParallelFor()` 具备同步闭环
3. `TaskGroup` 完成计数与回调链修复
4. 物理 `v1` 的行为语义已经稳定
在这之前,不允许把“先做多线程框架”变成物理模块的前置阻塞。
## 14. 风险与提前约束
### 14.1 最大风险不是 SDK 接入,而是生命周期接入
如果组件增删、反序列化补注册、运行时对象创建销毁链没打通,物理模块会表现成“看起来能跑,但一操作就坏”。
### 14.2 最大范围风险是功能蔓延
最容易拖垮首版的需求是:
- 多 collider
- mesh collider
- joints
- character controller
- 物理材质资产
- 多线程 step
这些都必须严格压到二期。
### 14.3 最大实现风险是把 `PhysX` 类型泄露到各层
一旦 `Px*` 进入:
- 公共头文件
- 脚本桥接
- 编辑器组件编辑器
后续维护成本会快速失控。
## 15. 完成标准
满足以下条件,才能认为 `PhysX v1` 收口:
1. 编辑器里可以为对象添加 `Rigidbody` 和基础 `Collider`
2. Scene 保存/加载后物理组件数据不丢失
3. Play 模式启动后物理世界能从 runtime scene 正式构建
4. fixed-step 中 dynamic rigidbody 能稳定仿真并写回 transform
5. kinematic rigidbody 能响应 transform 驱动
6. `Raycast` 与基础 `Overlap` 可用
7. `Collision / Trigger` 事件可用
8. Stop Play 后编辑态 scene 不被 runtime 污染
9. 自动化测试能覆盖最小闭环
10. 整个链路只依赖 `PhysX` 一个后端
## 16. 一句话结论
这条线的正确做法不是“先做一个可替换的物理后端框架”,而是“先把 `PhysX-only` 的正式物理子系统在当前引擎里打穿”。
先收口生命周期、运行时绑定、编辑器和测试链,再考虑二期扩展;线程基础模块的问题先认账,但不让它阻塞 `PhysX v1` 主线。

View File

@@ -163,6 +163,15 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/src/Threading/TaskGroup.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Threading/TaskSystem.cpp
# Physics
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Physics/PhysicsTypes.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Physics/RaycastHit.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Physics/PhysicsWorld.h
${CMAKE_CURRENT_SOURCE_DIR}/src/Physics/PhysX/PhysXCommon.h
${CMAKE_CURRENT_SOURCE_DIR}/src/Physics/PhysX/PhysXWorldBackend.h
${CMAKE_CURRENT_SOURCE_DIR}/src/Physics/PhysX/PhysXWorldBackend.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Physics/PhysicsWorld.cpp
# Debug
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Debug/Debug.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Debug/LogLevel.h
@@ -451,6 +460,11 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/Component.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/TransformComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/GameObject.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/RigidbodyComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/ColliderComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/BoxColliderComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/SphereColliderComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/CapsuleColliderComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/CameraComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/LightComponent.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Components/GaussianSplatRendererComponent.h
@@ -463,6 +477,11 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/Component.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/TransformComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/GameObject.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/RigidbodyComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/ColliderComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/BoxColliderComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/SphereColliderComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/CapsuleColliderComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/CameraComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/LightComponent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Components/GaussianSplatRendererComponent.cpp
@@ -760,6 +779,16 @@ if(XCENGINE_HAS_NANOVDB)
)
endif()
if(XCENGINE_ENABLE_PHYSX)
target_include_directories(XCEngine PRIVATE
${XCENGINE_PHYSX_INCLUDE_DIR}
)
target_link_libraries(XCEngine PUBLIC
${XCENGINE_PHYSX_LINK_TARGETS}
)
endif()
target_link_libraries(XCEngine PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/third_party/assimp/lib/assimp-vc143-mt.lib
opengl32
@@ -840,6 +869,16 @@ target_compile_definitions(XCEngine PRIVATE
XCENGINE_SUPPORT_VULKAN
)
if(XCENGINE_ENABLE_PHYSX)
target_compile_definitions(XCEngine PRIVATE
XCENGINE_ENABLE_PHYSX=1
)
else()
target_compile_definitions(XCEngine PRIVATE
XCENGINE_ENABLE_PHYSX=0
)
endif()
if(XCENGINE_HAS_NANOVDB)
target_compile_definitions(XCEngine PRIVATE
XCENGINE_HAS_NANOVDB=1

View File

@@ -0,0 +1,23 @@
#pragma once
#include <XCEngine/Components/ColliderComponent.h>
namespace XCEngine {
namespace Components {
class BoxColliderComponent : public ColliderComponent {
public:
std::string GetName() const override { return "BoxCollider"; }
const Math::Vector3& GetSize() const { return m_size; }
void SetSize(const Math::Vector3& value);
void Serialize(std::ostream& os) const override;
void Deserialize(std::istream& is) override;
private:
Math::Vector3 m_size = Math::Vector3::One();
};
} // namespace Components
} // namespace XCEngine

View File

@@ -0,0 +1,31 @@
#pragma once
#include <XCEngine/Components/ColliderComponent.h>
namespace XCEngine {
namespace Components {
class CapsuleColliderComponent : public ColliderComponent {
public:
std::string GetName() const override { return "CapsuleCollider"; }
float GetRadius() const { return m_radius; }
void SetRadius(float value);
float GetHeight() const { return m_height; }
void SetHeight(float value);
ColliderAxis GetAxis() const { return m_axis; }
void SetAxis(ColliderAxis value);
void Serialize(std::ostream& os) const override;
void Deserialize(std::istream& is) override;
private:
float m_radius = 0.5f;
float m_height = 2.0f;
ColliderAxis m_axis = ColliderAxis::Y;
};
} // namespace Components
} // namespace XCEngine

View File

@@ -0,0 +1,46 @@
#pragma once
#include <XCEngine/Components/Component.h>
#include <XCEngine/Core/Math/Vector3.h>
#include <cstdint>
namespace XCEngine {
namespace Components {
enum class ColliderAxis : uint8_t {
X = 0,
Y = 1,
Z = 2
};
class ColliderComponent : public Component {
public:
bool IsTrigger() const { return m_isTrigger; }
void SetTrigger(bool value) { m_isTrigger = value; }
const Math::Vector3& GetCenter() const { return m_center; }
void SetCenter(const Math::Vector3& value);
float GetStaticFriction() const { return m_staticFriction; }
void SetStaticFriction(float value);
float GetDynamicFriction() const { return m_dynamicFriction; }
void SetDynamicFriction(float value);
float GetRestitution() const { return m_restitution; }
void SetRestitution(float value);
protected:
void SerializeBase(std::ostream& os) const;
bool DeserializeBaseField(const std::string& key, const std::string& value);
private:
bool m_isTrigger = false;
Math::Vector3 m_center = Math::Vector3::Zero();
float m_staticFriction = 0.6f;
float m_dynamicFriction = 0.6f;
float m_restitution = 0.0f;
};
} // namespace Components
} // namespace XCEngine

View File

@@ -53,6 +53,7 @@ public:
component->m_gameObject = this;
T* ptr = component.get();
m_components.emplace_back(std::move(component));
NotifyComponentAdded(ptr);
return ptr;
}
@@ -120,6 +121,7 @@ public:
for (auto it = m_components.begin(); it != m_components.end(); ++it) {
if (T* casted = dynamic_cast<T*>(it->get())) {
NotifyComponentRemoving(casted);
m_components.erase(it);
return;
}
@@ -139,6 +141,7 @@ public:
return false;
}
NotifyComponentRemoving(component);
m_components.erase(it);
return true;
}
@@ -213,6 +216,8 @@ public:
void Deserialize(std::istream& is);
private:
void NotifyComponentAdded(Component* component);
void NotifyComponentRemoving(Component* component);
void NotifyComponentsBecameActive();
void NotifyComponentsBecameInactive();
void PropagateActiveInHierarchyChange(bool oldParentActiveInHierarchy, bool newParentActiveInHierarchy);

View File

@@ -0,0 +1,44 @@
#pragma once
#include <XCEngine/Components/Component.h>
#include <XCEngine/Physics/PhysicsTypes.h>
namespace XCEngine {
namespace Components {
class RigidbodyComponent : public Component {
public:
std::string GetName() const override { return "Rigidbody"; }
Physics::PhysicsBodyType GetBodyType() const { return m_bodyType; }
void SetBodyType(Physics::PhysicsBodyType value) { m_bodyType = value; }
float GetMass() const { return m_mass; }
void SetMass(float value);
float GetLinearDamping() const { return m_linearDamping; }
void SetLinearDamping(float value);
float GetAngularDamping() const { return m_angularDamping; }
void SetAngularDamping(float value);
bool GetUseGravity() const { return m_useGravity; }
void SetUseGravity(bool value) { m_useGravity = value; }
bool GetEnableCCD() const { return m_enableCCD; }
void SetEnableCCD(bool value) { m_enableCCD = value; }
void Serialize(std::ostream& os) const override;
void Deserialize(std::istream& is) override;
private:
Physics::PhysicsBodyType m_bodyType = Physics::PhysicsBodyType::Dynamic;
float m_mass = 1.0f;
float m_linearDamping = 0.0f;
float m_angularDamping = 0.05f;
bool m_useGravity = true;
bool m_enableCCD = false;
};
} // namespace Components
} // namespace XCEngine

View File

@@ -0,0 +1,23 @@
#pragma once
#include <XCEngine/Components/ColliderComponent.h>
namespace XCEngine {
namespace Components {
class SphereColliderComponent : public ColliderComponent {
public:
std::string GetName() const override { return "SphereCollider"; }
float GetRadius() const { return m_radius; }
void SetRadius(float value);
void Serialize(std::ostream& os) const override;
void Deserialize(std::istream& is) override;
private:
float m_radius = 0.5f;
};
} // namespace Components
} // namespace XCEngine

View File

@@ -0,0 +1,28 @@
#pragma once
#include <XCEngine/Core/Math/Vector3.h>
#include <cstdint>
namespace XCEngine {
namespace Components {
class GameObject;
class Scene;
} // namespace Components
namespace Physics {
enum class PhysicsBodyType : uint8_t {
Static = 0,
Dynamic,
Kinematic
};
struct PhysicsWorldCreateInfo {
Components::Scene* scene = nullptr;
Math::Vector3 gravity = Math::Vector3(0.0f, -9.81f, 0.0f);
};
} // namespace Physics
} // namespace XCEngine

View File

@@ -0,0 +1,56 @@
#pragma once
#include <XCEngine/Physics/PhysicsTypes.h>
#include <cstddef>
#include <cstdint>
#include <memory>
namespace XCEngine {
namespace Components {
class Component;
} // namespace Components
} // namespace XCEngine
namespace XCEngine {
namespace Physics {
class PhysXWorldBackend;
class PhysicsWorld {
public:
PhysicsWorld();
~PhysicsWorld();
static bool IsPhysXAvailable();
bool Initialize(const PhysicsWorldCreateInfo& createInfo);
void Shutdown();
void Step(float fixedDeltaTime);
bool IsInitialized() const { return m_initialized; }
const PhysicsWorldCreateInfo& GetCreateInfo() const { return m_createInfo; }
size_t GetTrackedRigidbodyCount() const { return m_trackedRigidbodyCount; }
size_t GetTrackedColliderCount() const { return m_trackedColliderCount; }
private:
void AttachSceneEventHandlers(Components::Scene* scene);
void DetachSceneEventHandlers();
void RebuildTrackedSceneState();
void TrackGameObjectComponents(Components::GameObject* gameObject, int delta);
void TrackComponent(Components::Component* component, int delta);
PhysicsWorldCreateInfo m_createInfo;
bool m_initialized = false;
uint64_t m_componentAddedSubscriptionId = 0;
uint64_t m_componentRemovedSubscriptionId = 0;
uint64_t m_gameObjectDestroyedSubscriptionId = 0;
size_t m_trackedRigidbodyCount = 0;
size_t m_trackedColliderCount = 0;
std::unique_ptr<PhysXWorldBackend> m_backend;
};
} // namespace Physics
} // namespace XCEngine

View File

@@ -0,0 +1,17 @@
#pragma once
#include <XCEngine/Physics/PhysicsTypes.h>
namespace XCEngine {
namespace Physics {
struct RaycastHit {
Components::GameObject* gameObject = nullptr;
Math::Vector3 point = Math::Vector3::Zero();
Math::Vector3 normal = Math::Vector3::Zero();
float distance = 0.0f;
bool isTrigger = false;
};
} // namespace Physics
} // namespace XCEngine

View File

@@ -71,6 +71,8 @@ public:
Core::Event<GameObject*>& OnGameObjectCreated() { return m_onGameObjectCreated; }
Core::Event<GameObject*>& OnGameObjectDestroyed() { return m_onGameObjectDestroyed; }
Core::Event<GameObject*, Component*>& OnComponentAdded() { return m_onComponentAdded; }
Core::Event<GameObject*, Component*>& OnComponentRemoved() { return m_onComponentRemoved; }
private:
GameObject* FindInChildren(GameObject* parent, const std::string& name) const;
@@ -108,6 +110,8 @@ private:
Core::Event<GameObject*> m_onGameObjectCreated;
Core::Event<GameObject*> m_onGameObjectDestroyed;
Core::Event<GameObject*, Component*> m_onComponentAdded;
Core::Event<GameObject*, Component*> m_onComponentRemoved;
friend class GameObject;
friend class SceneManager;

View File

@@ -16,6 +16,12 @@ struct UISystemFrameResult;
} // namespace Runtime
} // namespace UI
namespace Physics {
class PhysicsWorld;
} // namespace Physics
} // namespace XCEngine
namespace XCEngine {
@@ -50,9 +56,14 @@ public:
bool IsRunning() const { return m_running; }
Scene* GetScene() const { return m_scene; }
Physics::PhysicsWorld* GetPhysicsWorld() const { return m_physicsWorld.get(); }
private:
void CreatePhysicsWorldForScene(Scene* scene);
void DestroyPhysicsWorld();
std::unique_ptr<UI::Runtime::UISceneRuntimeContext> m_uiRuntime;
std::unique_ptr<Physics::PhysicsWorld> m_physicsWorld;
Scene* m_scene = nullptr;
bool m_running = false;
};

View File

@@ -0,0 +1,66 @@
#include "Components/BoxColliderComponent.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace XCEngine {
namespace Components {
namespace {
float SanitizeExtent(float value) {
return std::isfinite(value) && value > 0.0f ? value : 0.001f;
}
bool TryParseVector3(const std::string& value, Math::Vector3& outValue) {
std::string normalized = value;
std::replace(normalized.begin(), normalized.end(), ',', ' ');
std::istringstream stream(normalized);
stream >> outValue.x >> outValue.y >> outValue.z;
return !stream.fail();
}
} // namespace
void BoxColliderComponent::SetSize(const Math::Vector3& value) {
m_size = Math::Vector3(
SanitizeExtent(value.x),
SanitizeExtent(value.y),
SanitizeExtent(value.z));
}
void BoxColliderComponent::Serialize(std::ostream& os) const {
SerializeBase(os);
os << "size=" << m_size.x << "," << m_size.y << "," << m_size.z << ";";
}
void BoxColliderComponent::Deserialize(std::istream& is) {
std::string token;
while (std::getline(is, token, ';')) {
if (token.empty()) {
continue;
}
const size_t eqPos = token.find('=');
if (eqPos == std::string::npos) {
continue;
}
const std::string key = token.substr(0, eqPos);
const std::string value = token.substr(eqPos + 1);
if (DeserializeBaseField(key, value)) {
continue;
}
if (key == "size") {
Math::Vector3 parsedSize = Math::Vector3::One();
if (TryParseVector3(value, parsedSize)) {
SetSize(parsedSize);
}
}
}
}
} // namespace Components
} // namespace XCEngine

View File

@@ -0,0 +1,76 @@
#include "Components/CapsuleColliderComponent.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace XCEngine {
namespace Components {
namespace {
float SanitizePositive(float value, float fallback) {
return std::isfinite(value) && value > 0.0f ? value : fallback;
}
} // namespace
void CapsuleColliderComponent::SetRadius(float value) {
m_radius = SanitizePositive(value, 0.5f);
m_height = std::max(m_height, m_radius * 2.0f);
}
void CapsuleColliderComponent::SetHeight(float value) {
m_height = std::max(SanitizePositive(value, 2.0f), m_radius * 2.0f);
}
void CapsuleColliderComponent::SetAxis(ColliderAxis value) {
switch (value) {
case ColliderAxis::X:
case ColliderAxis::Y:
case ColliderAxis::Z:
m_axis = value;
break;
default:
m_axis = ColliderAxis::Y;
break;
}
}
void CapsuleColliderComponent::Serialize(std::ostream& os) const {
SerializeBase(os);
os << "radius=" << m_radius << ";";
os << "height=" << m_height << ";";
os << "axis=" << static_cast<int>(m_axis) << ";";
}
void CapsuleColliderComponent::Deserialize(std::istream& is) {
std::string token;
while (std::getline(is, token, ';')) {
if (token.empty()) {
continue;
}
const size_t eqPos = token.find('=');
if (eqPos == std::string::npos) {
continue;
}
const std::string key = token.substr(0, eqPos);
const std::string value = token.substr(eqPos + 1);
if (DeserializeBaseField(key, value)) {
continue;
}
if (key == "radius") {
SetRadius(std::stof(value));
} else if (key == "height") {
SetHeight(std::stof(value));
} else if (key == "axis") {
SetAxis(static_cast<ColliderAxis>(std::stoi(value)));
}
}
}
} // namespace Components
} // namespace XCEngine

View File

@@ -0,0 +1,90 @@
#include "Components/ColliderComponent.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace XCEngine {
namespace Components {
namespace {
float SanitizeFinite(float value, float fallback) {
return std::isfinite(value) ? value : fallback;
}
float SanitizeNonNegativeFinite(float value, float fallback) {
return std::isfinite(value) && value >= 0.0f ? value : fallback;
}
bool TryParseVector3(const std::string& value, Math::Vector3& outValue) {
std::string normalized = value;
std::replace(normalized.begin(), normalized.end(), ',', ' ');
std::istringstream stream(normalized);
stream >> outValue.x >> outValue.y >> outValue.z;
return !stream.fail();
}
} // namespace
void ColliderComponent::SetCenter(const Math::Vector3& value) {
m_center = Math::Vector3(
SanitizeFinite(value.x, 0.0f),
SanitizeFinite(value.y, 0.0f),
SanitizeFinite(value.z, 0.0f));
}
void ColliderComponent::SetStaticFriction(float value) {
m_staticFriction = SanitizeNonNegativeFinite(value, 0.6f);
}
void ColliderComponent::SetDynamicFriction(float value) {
m_dynamicFriction = SanitizeNonNegativeFinite(value, 0.6f);
}
void ColliderComponent::SetRestitution(float value) {
m_restitution = std::isfinite(value) ? std::clamp(value, 0.0f, 1.0f) : 0.0f;
}
void ColliderComponent::SerializeBase(std::ostream& os) const {
os << "isTrigger=" << (m_isTrigger ? 1 : 0) << ";";
os << "center=" << m_center.x << "," << m_center.y << "," << m_center.z << ";";
os << "staticFriction=" << m_staticFriction << ";";
os << "dynamicFriction=" << m_dynamicFriction << ";";
os << "restitution=" << m_restitution << ";";
}
bool ColliderComponent::DeserializeBaseField(const std::string& key, const std::string& value) {
if (key == "isTrigger") {
m_isTrigger = (std::stoi(value) != 0);
return true;
}
if (key == "center") {
Math::Vector3 parsedCenter = Math::Vector3::Zero();
if (TryParseVector3(value, parsedCenter)) {
SetCenter(parsedCenter);
}
return true;
}
if (key == "staticFriction") {
SetStaticFriction(std::stof(value));
return true;
}
if (key == "dynamicFriction") {
SetDynamicFriction(std::stof(value));
return true;
}
if (key == "restitution") {
SetRestitution(std::stof(value));
return true;
}
return false;
}
} // namespace Components
} // namespace XCEngine

View File

@@ -2,12 +2,17 @@
#include "Components/AudioListenerComponent.h"
#include "Components/AudioSourceComponent.h"
#include "Components/BoxColliderComponent.h"
#include "Components/CameraComponent.h"
#include "Components/CapsuleColliderComponent.h"
#include "Components/ColliderComponent.h"
#include "Components/GaussianSplatRendererComponent.h"
#include "Components/GameObject.h"
#include "Components/LightComponent.h"
#include "Components/MeshFilterComponent.h"
#include "Components/MeshRendererComponent.h"
#include "Components/RigidbodyComponent.h"
#include "Components/SphereColliderComponent.h"
#include "Components/VolumeRendererComponent.h"
#include "Scripting/ScriptComponent.h"
@@ -33,6 +38,10 @@ ComponentFactoryRegistry::ComponentFactoryRegistry() {
RegisterFactory("Light", &CreateBuiltInComponent<LightComponent>);
RegisterFactory("AudioSource", &CreateBuiltInComponent<AudioSourceComponent>);
RegisterFactory("AudioListener", &CreateBuiltInComponent<AudioListenerComponent>);
RegisterFactory("Rigidbody", &CreateBuiltInComponent<RigidbodyComponent>);
RegisterFactory("BoxCollider", &CreateBuiltInComponent<BoxColliderComponent>);
RegisterFactory("SphereCollider", &CreateBuiltInComponent<SphereColliderComponent>);
RegisterFactory("CapsuleCollider", &CreateBuiltInComponent<CapsuleColliderComponent>);
RegisterFactory("MeshFilter", &CreateBuiltInComponent<MeshFilterComponent>);
RegisterFactory("MeshRenderer", &CreateBuiltInComponent<MeshRendererComponent>);
RegisterFactory("GaussianSplatRenderer", &CreateBuiltInComponent<GaussianSplatRendererComponent>);

View File

@@ -58,6 +58,18 @@ std::unordered_map<GameObject::ID, GameObject*>& GameObject::GetGlobalRegistry()
return *registry;
}
void GameObject::NotifyComponentAdded(Component* component) {
if (m_scene && component) {
m_scene->m_onComponentAdded.Invoke(this, component);
}
}
void GameObject::NotifyComponentRemoving(Component* component) {
if (m_scene && component) {
m_scene->m_onComponentRemoved.Invoke(this, component);
}
}
void GameObject::NotifyComponentsBecameActive() {
for (auto& comp : m_components) {
if (comp->IsEnabled()) {

View File

@@ -0,0 +1,75 @@
#include "Components/RigidbodyComponent.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace XCEngine {
namespace Components {
namespace {
float SanitizePositiveFinite(float value, float fallback) {
return std::isfinite(value) && value > 0.0f ? value : fallback;
}
float SanitizeNonNegativeFinite(float value, float fallback) {
return std::isfinite(value) && value >= 0.0f ? value : fallback;
}
} // namespace
void RigidbodyComponent::SetMass(float value) {
m_mass = SanitizePositiveFinite(value, 1.0f);
}
void RigidbodyComponent::SetLinearDamping(float value) {
m_linearDamping = SanitizeNonNegativeFinite(value, 0.0f);
}
void RigidbodyComponent::SetAngularDamping(float value) {
m_angularDamping = SanitizeNonNegativeFinite(value, 0.05f);
}
void RigidbodyComponent::Serialize(std::ostream& os) const {
os << "bodyType=" << static_cast<int>(m_bodyType) << ";";
os << "mass=" << m_mass << ";";
os << "linearDamping=" << m_linearDamping << ";";
os << "angularDamping=" << m_angularDamping << ";";
os << "useGravity=" << (m_useGravity ? 1 : 0) << ";";
os << "enableCCD=" << (m_enableCCD ? 1 : 0) << ";";
}
void RigidbodyComponent::Deserialize(std::istream& is) {
std::string token;
while (std::getline(is, token, ';')) {
if (token.empty()) {
continue;
}
const size_t eqPos = token.find('=');
if (eqPos == std::string::npos) {
continue;
}
const std::string key = token.substr(0, eqPos);
const std::string value = token.substr(eqPos + 1);
if (key == "bodyType") {
m_bodyType = static_cast<Physics::PhysicsBodyType>(std::stoi(value));
} else if (key == "mass") {
SetMass(std::stof(value));
} else if (key == "linearDamping") {
SetLinearDamping(std::stof(value));
} else if (key == "angularDamping") {
SetAngularDamping(std::stof(value));
} else if (key == "useGravity") {
m_useGravity = (std::stoi(value) != 0);
} else if (key == "enableCCD") {
m_enableCCD = (std::stoi(value) != 0);
}
}
}
} // namespace Components
} // namespace XCEngine

View File

@@ -0,0 +1,43 @@
#include "Components/SphereColliderComponent.h"
#include <cmath>
#include <sstream>
namespace XCEngine {
namespace Components {
void SphereColliderComponent::SetRadius(float value) {
m_radius = std::isfinite(value) && value > 0.0f ? value : 0.001f;
}
void SphereColliderComponent::Serialize(std::ostream& os) const {
SerializeBase(os);
os << "radius=" << m_radius << ";";
}
void SphereColliderComponent::Deserialize(std::istream& is) {
std::string token;
while (std::getline(is, token, ';')) {
if (token.empty()) {
continue;
}
const size_t eqPos = token.find('=');
if (eqPos == std::string::npos) {
continue;
}
const std::string key = token.substr(0, eqPos);
const std::string value = token.substr(eqPos + 1);
if (DeserializeBaseField(key, value)) {
continue;
}
if (key == "radius") {
SetRadius(std::stof(value));
}
}
}
} // namespace Components
} // namespace XCEngine

View File

@@ -0,0 +1,5 @@
#pragma once
#if XCENGINE_ENABLE_PHYSX
#include <PxPhysicsAPI.h>
#endif

View File

@@ -0,0 +1,157 @@
#include "Physics/PhysX/PhysXWorldBackend.h"
#include "Physics/PhysX/PhysXCommon.h"
namespace XCEngine {
namespace Physics {
#if XCENGINE_ENABLE_PHYSX
namespace {
physx::PxVec3 ToPxVec3(const Math::Vector3& value) {
return physx::PxVec3(value.x, value.y, value.z);
}
} // namespace
struct PhysXWorldBackend::NativeState {
physx::PxDefaultAllocator allocator;
physx::PxDefaultErrorCallback errorCallback;
physx::PxFoundation* foundation = nullptr;
physx::PxPhysics* physics = nullptr;
physx::PxDefaultCpuDispatcher* dispatcher = nullptr;
physx::PxScene* scene = nullptr;
physx::PxMaterial* defaultMaterial = nullptr;
bool extensionsInitialized = false;
};
#else
struct PhysXWorldBackend::NativeState {};
#endif
PhysXWorldBackend::PhysXWorldBackend() = default;
PhysXWorldBackend::~PhysXWorldBackend() {
Shutdown();
}
bool PhysXWorldBackend::Initialize(const PhysicsWorldCreateInfo& createInfo) {
Shutdown();
m_createInfo = createInfo;
#if XCENGINE_ENABLE_PHYSX
m_native = std::make_unique<NativeState>();
NativeState& native = *m_native;
native.foundation = PxCreateFoundation(
PX_PHYSICS_VERSION,
native.allocator,
native.errorCallback);
if (!native.foundation) {
Shutdown();
return false;
}
native.physics = PxCreatePhysics(
PX_PHYSICS_VERSION,
*native.foundation,
physx::PxTolerancesScale());
if (!native.physics) {
Shutdown();
return false;
}
native.extensionsInitialized = PxInitExtensions(*native.physics, nullptr);
if (!native.extensionsInitialized) {
Shutdown();
return false;
}
native.dispatcher = physx::PxDefaultCpuDispatcherCreate(0);
if (!native.dispatcher) {
Shutdown();
return false;
}
physx::PxSceneDesc sceneDesc(native.physics->getTolerancesScale());
sceneDesc.gravity = ToPxVec3(createInfo.gravity);
sceneDesc.cpuDispatcher = native.dispatcher;
sceneDesc.filterShader = physx::PxDefaultSimulationFilterShader;
if (!sceneDesc.isValid()) {
Shutdown();
return false;
}
native.scene = native.physics->createScene(sceneDesc);
if (!native.scene) {
Shutdown();
return false;
}
native.defaultMaterial = native.physics->createMaterial(0.5f, 0.5f, 0.6f);
if (!native.defaultMaterial) {
Shutdown();
return false;
}
m_initialized = true;
#else
m_initialized = false;
#endif
return m_initialized;
}
void PhysXWorldBackend::Shutdown() {
#if XCENGINE_ENABLE_PHYSX
if (m_native) {
if (m_native->scene) {
m_native->scene->release();
m_native->scene = nullptr;
}
if (m_native->defaultMaterial) {
m_native->defaultMaterial->release();
m_native->defaultMaterial = nullptr;
}
if (m_native->dispatcher) {
m_native->dispatcher->release();
m_native->dispatcher = nullptr;
}
if (m_native->extensionsInitialized) {
PxCloseExtensions();
m_native->extensionsInitialized = false;
}
if (m_native->physics) {
m_native->physics->release();
m_native->physics = nullptr;
}
if (m_native->foundation) {
m_native->foundation->release();
m_native->foundation = nullptr;
}
m_native.reset();
}
#else
m_native.reset();
#endif
m_initialized = false;
m_createInfo = PhysicsWorldCreateInfo{};
}
void PhysXWorldBackend::Step(float fixedDeltaTime) {
if (!m_initialized || !m_native || !m_native->scene || fixedDeltaTime <= 0.0f) {
return;
}
m_native->scene->simulate(fixedDeltaTime);
m_native->scene->fetchResults(true);
}
} // namespace Physics
} // namespace XCEngine

View File

@@ -0,0 +1,28 @@
#pragma once
#include <XCEngine/Physics/PhysicsTypes.h>
#include <memory>
namespace XCEngine {
namespace Physics {
class PhysXWorldBackend {
public:
PhysXWorldBackend();
~PhysXWorldBackend();
bool Initialize(const PhysicsWorldCreateInfo& createInfo);
void Shutdown();
void Step(float fixedDeltaTime);
private:
struct NativeState;
PhysicsWorldCreateInfo m_createInfo = {};
bool m_initialized = false;
std::unique_ptr<NativeState> m_native;
};
} // namespace Physics
} // namespace XCEngine

View File

@@ -0,0 +1,185 @@
#include <XCEngine/Physics/PhysicsWorld.h>
#include "Physics/PhysX/PhysXWorldBackend.h"
#include <XCEngine/Components/ColliderComponent.h>
#include <XCEngine/Components/GameObject.h>
#include <XCEngine/Components/RigidbodyComponent.h>
#include <XCEngine/Scene/Scene.h>
#include <functional>
namespace XCEngine {
namespace Physics {
namespace {
void ApplyTrackedCountDelta(size_t& value, int delta) {
if (delta > 0) {
value += static_cast<size_t>(delta);
return;
}
const size_t magnitude = static_cast<size_t>(-delta);
value = magnitude > value ? 0u : (value - magnitude);
}
void VisitGameObjectHierarchy(
Components::GameObject* gameObject,
const std::function<void(Components::GameObject*)>& visitor) {
if (!gameObject) {
return;
}
visitor(gameObject);
for (Components::GameObject* child : gameObject->GetChildren()) {
VisitGameObjectHierarchy(child, visitor);
}
}
} // namespace
PhysicsWorld::PhysicsWorld() = default;
PhysicsWorld::~PhysicsWorld() {
Shutdown();
}
bool PhysicsWorld::IsPhysXAvailable() {
#if XCENGINE_ENABLE_PHYSX
return true;
#else
return false;
#endif
}
bool PhysicsWorld::Initialize(const PhysicsWorldCreateInfo& createInfo) {
Shutdown();
m_createInfo = createInfo;
AttachSceneEventHandlers(m_createInfo.scene);
RebuildTrackedSceneState();
m_backend = std::make_unique<PhysXWorldBackend>();
m_initialized = m_backend->Initialize(createInfo);
return m_initialized;
}
void PhysicsWorld::Shutdown() {
if (m_backend) {
m_backend->Shutdown();
m_backend.reset();
}
DetachSceneEventHandlers();
m_initialized = false;
m_trackedRigidbodyCount = 0;
m_trackedColliderCount = 0;
m_createInfo = PhysicsWorldCreateInfo{};
}
void PhysicsWorld::Step(float fixedDeltaTime) {
if (!m_backend || !m_initialized) {
return;
}
m_backend->Step(fixedDeltaTime);
}
void PhysicsWorld::AttachSceneEventHandlers(Components::Scene* scene) {
if (!scene) {
return;
}
m_componentAddedSubscriptionId = scene->OnComponentAdded().Subscribe(
[this](Components::GameObject*, Components::Component* component) {
TrackComponent(component, 1);
});
m_componentRemovedSubscriptionId = scene->OnComponentRemoved().Subscribe(
[this](Components::GameObject*, Components::Component* component) {
TrackComponent(component, -1);
});
m_gameObjectDestroyedSubscriptionId = scene->OnGameObjectDestroyed().Subscribe(
[this](Components::GameObject* gameObject) {
TrackGameObjectComponents(gameObject, -1);
});
}
void PhysicsWorld::DetachSceneEventHandlers() {
Components::Scene* const scene = m_createInfo.scene;
if (!scene) {
m_componentAddedSubscriptionId = 0;
m_componentRemovedSubscriptionId = 0;
m_gameObjectDestroyedSubscriptionId = 0;
return;
}
if (m_componentAddedSubscriptionId != 0) {
scene->OnComponentAdded().Unsubscribe(m_componentAddedSubscriptionId);
scene->OnComponentAdded().ProcessUnsubscribes();
m_componentAddedSubscriptionId = 0;
}
if (m_componentRemovedSubscriptionId != 0) {
scene->OnComponentRemoved().Unsubscribe(m_componentRemovedSubscriptionId);
scene->OnComponentRemoved().ProcessUnsubscribes();
m_componentRemovedSubscriptionId = 0;
}
if (m_gameObjectDestroyedSubscriptionId != 0) {
scene->OnGameObjectDestroyed().Unsubscribe(m_gameObjectDestroyedSubscriptionId);
scene->OnGameObjectDestroyed().ProcessUnsubscribes();
m_gameObjectDestroyedSubscriptionId = 0;
}
}
void PhysicsWorld::RebuildTrackedSceneState() {
m_trackedRigidbodyCount = 0;
m_trackedColliderCount = 0;
Components::Scene* const scene = m_createInfo.scene;
if (!scene) {
return;
}
for (Components::GameObject* rootGameObject : scene->GetRootGameObjects()) {
VisitGameObjectHierarchy(
rootGameObject,
[this](Components::GameObject* gameObject) {
TrackGameObjectComponents(gameObject, 1);
});
}
}
void PhysicsWorld::TrackGameObjectComponents(Components::GameObject* gameObject, int delta) {
if (!gameObject) {
return;
}
for (Components::RigidbodyComponent* rigidbody : gameObject->GetComponents<Components::RigidbodyComponent>()) {
TrackComponent(rigidbody, delta);
}
for (Components::ColliderComponent* collider : gameObject->GetComponents<Components::ColliderComponent>()) {
TrackComponent(collider, delta);
}
}
void PhysicsWorld::TrackComponent(Components::Component* component, int delta) {
if (!component) {
return;
}
if (dynamic_cast<Components::RigidbodyComponent*>(component) != nullptr) {
ApplyTrackedCountDelta(m_trackedRigidbodyCount, delta);
}
if (dynamic_cast<Components::ColliderComponent*>(component) != nullptr) {
ApplyTrackedCountDelta(m_trackedColliderCount, delta);
}
}
} // namespace Physics
} // namespace XCEngine

View File

@@ -1,5 +1,6 @@
#include "Scene/SceneRuntime.h"
#include <XCEngine/Physics/PhysicsWorld.h>
#include "Scripting/ScriptEngine.h"
#include <XCEngine/UI/Runtime/UISceneRuntimeContext.h>
@@ -28,6 +29,7 @@ void SceneRuntime::Start(Scene* scene) {
}
m_scene = scene;
CreatePhysicsWorldForScene(scene);
m_running = true;
m_uiRuntime->Reset();
Scripting::ScriptEngine::Get().OnRuntimeStart(scene);
@@ -35,12 +37,14 @@ void SceneRuntime::Start(Scene* scene) {
void SceneRuntime::Stop() {
if (!m_running) {
DestroyPhysicsWorld();
m_uiRuntime->Reset();
m_scene = nullptr;
return;
}
Scripting::ScriptEngine::Get().OnRuntimeStop();
DestroyPhysicsWorld();
m_uiRuntime->Reset();
m_running = false;
m_scene = nullptr;
@@ -48,12 +52,14 @@ void SceneRuntime::Stop() {
void SceneRuntime::ReplaceScene(Scene* scene) {
if (!m_running) {
DestroyPhysicsWorld();
m_scene = scene;
m_uiRuntime->Reset();
return;
}
m_scene = scene;
CreatePhysicsWorldForScene(scene);
m_uiRuntime->Reset();
Scripting::ScriptEngine::Get().OnRuntimeSceneReplaced(scene);
}
@@ -66,6 +72,9 @@ void SceneRuntime::FixedUpdate(float fixedDeltaTime) {
// Scripts run first so their state changes are visible to native components in the same frame.
Scripting::ScriptEngine::Get().OnFixedUpdate(fixedDeltaTime);
m_scene->FixedUpdate(fixedDeltaTime);
if (m_physicsWorld) {
m_physicsWorld->Step(fixedDeltaTime);
}
}
void SceneRuntime::Update(float deltaTime) {
@@ -115,5 +124,29 @@ void SceneRuntime::ClearQueuedUIInputEvents() {
m_uiRuntime->ClearQueuedInputEvents();
}
void SceneRuntime::CreatePhysicsWorldForScene(Scene* scene) {
if (!scene) {
DestroyPhysicsWorld();
return;
}
DestroyPhysicsWorld();
auto physicsWorld = std::make_unique<Physics::PhysicsWorld>();
Physics::PhysicsWorldCreateInfo createInfo = {};
createInfo.scene = scene;
physicsWorld->Initialize(createInfo);
m_physicsWorld = std::move(physicsWorld);
}
void SceneRuntime::DestroyPhysicsWorld() {
if (!m_physicsWorld) {
return;
}
m_physicsWorld->Shutdown();
m_physicsWorld.reset();
}
} // namespace Components
} // namespace XCEngine

View File

@@ -54,6 +54,7 @@ add_subdirectory(Memory)
add_subdirectory(Threading)
add_subdirectory(Debug)
add_subdirectory(Components)
add_subdirectory(Physics)
add_subdirectory(Scene)
add_subdirectory(Scripting)
add_subdirectory(Rendering)

View File

@@ -2,13 +2,17 @@
#include <XCEngine/Components/AudioListenerComponent.h>
#include <XCEngine/Components/AudioSourceComponent.h>
#include <XCEngine/Components/BoxColliderComponent.h>
#include <XCEngine/Components/CameraComponent.h>
#include <XCEngine/Components/CapsuleColliderComponent.h>
#include <XCEngine/Components/ComponentFactoryRegistry.h>
#include <XCEngine/Components/GaussianSplatRendererComponent.h>
#include <XCEngine/Components/GameObject.h>
#include <XCEngine/Components/LightComponent.h>
#include <XCEngine/Components/MeshFilterComponent.h>
#include <XCEngine/Components/MeshRendererComponent.h>
#include <XCEngine/Components/RigidbodyComponent.h>
#include <XCEngine/Components/SphereColliderComponent.h>
#include <XCEngine/Components/VolumeRendererComponent.h>
using namespace XCEngine::Components;
@@ -22,6 +26,10 @@ TEST(ComponentFactoryRegistry_Test, BuiltInTypesAreRegistered) {
EXPECT_TRUE(registry.IsRegistered("Light"));
EXPECT_TRUE(registry.IsRegistered("AudioSource"));
EXPECT_TRUE(registry.IsRegistered("AudioListener"));
EXPECT_TRUE(registry.IsRegistered("Rigidbody"));
EXPECT_TRUE(registry.IsRegistered("BoxCollider"));
EXPECT_TRUE(registry.IsRegistered("SphereCollider"));
EXPECT_TRUE(registry.IsRegistered("CapsuleCollider"));
EXPECT_TRUE(registry.IsRegistered("MeshFilter"));
EXPECT_TRUE(registry.IsRegistered("MeshRenderer"));
EXPECT_TRUE(registry.IsRegistered("GaussianSplatRenderer"));
@@ -38,6 +46,10 @@ TEST(ComponentFactoryRegistry_Test, CreateBuiltInComponentsByTypeName) {
EXPECT_NE(dynamic_cast<LightComponent*>(registry.CreateComponent(&gameObject, "Light")), nullptr);
EXPECT_NE(dynamic_cast<AudioSourceComponent*>(registry.CreateComponent(&gameObject, "AudioSource")), nullptr);
EXPECT_NE(dynamic_cast<AudioListenerComponent*>(registry.CreateComponent(&gameObject, "AudioListener")), nullptr);
EXPECT_NE(dynamic_cast<RigidbodyComponent*>(registry.CreateComponent(&gameObject, "Rigidbody")), nullptr);
EXPECT_NE(dynamic_cast<BoxColliderComponent*>(registry.CreateComponent(&gameObject, "BoxCollider")), nullptr);
EXPECT_NE(dynamic_cast<SphereColliderComponent*>(registry.CreateComponent(&gameObject, "SphereCollider")), nullptr);
EXPECT_NE(dynamic_cast<CapsuleColliderComponent*>(registry.CreateComponent(&gameObject, "CapsuleCollider")), nullptr);
EXPECT_NE(dynamic_cast<MeshFilterComponent*>(registry.CreateComponent(&gameObject, "MeshFilter")), nullptr);
EXPECT_NE(dynamic_cast<MeshRendererComponent*>(registry.CreateComponent(&gameObject, "MeshRenderer")), nullptr);
EXPECT_NE(dynamic_cast<GaussianSplatRendererComponent*>(registry.CreateComponent(&gameObject, "GaussianSplatRenderer")), nullptr);

View File

@@ -0,0 +1,32 @@
# ============================================================
# Physics Tests
# ============================================================
set(PHYSICS_TEST_SOURCES
test_physics_world.cpp
)
add_executable(physics_tests ${PHYSICS_TEST_SOURCES})
if(MSVC)
set_target_properties(physics_tests PROPERTIES
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
)
endif()
target_link_libraries(physics_tests PRIVATE
XCEngine
GTest::gtest
GTest::gtest_main
)
target_include_directories(physics_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
)
if(WIN32 AND XCENGINE_ENABLE_PHYSX)
xcengine_copy_physx_runtime_dlls(physics_tests)
endif()
include(GoogleTest)
gtest_discover_tests(physics_tests)

View File

@@ -0,0 +1,37 @@
#include <gtest/gtest.h>
#include <XCEngine/Physics/PhysicsWorld.h>
#include <XCEngine/Scene/Scene.h>
namespace {
TEST(PhysicsWorld_Test, DefaultWorldStartsUninitialized) {
XCEngine::Physics::PhysicsWorld world;
EXPECT_FALSE(world.IsInitialized());
}
TEST(PhysicsWorld_Test, InitializeStoresCreateInfoWithoutMarkingWorldReadyYet) {
XCEngine::Components::Scene scene("PhysicsScene");
XCEngine::Physics::PhysicsWorld world;
XCEngine::Physics::PhysicsWorldCreateInfo createInfo;
createInfo.scene = &scene;
createInfo.gravity = XCEngine::Math::Vector3(0.0f, -3.5f, 0.0f);
const bool expectedInitialized = XCEngine::Physics::PhysicsWorld::IsPhysXAvailable();
EXPECT_EQ(world.Initialize(createInfo), expectedInitialized);
EXPECT_EQ(world.IsInitialized(), expectedInitialized);
EXPECT_EQ(world.GetCreateInfo().scene, &scene);
EXPECT_EQ(world.GetCreateInfo().gravity, XCEngine::Math::Vector3(0.0f, -3.5f, 0.0f));
}
TEST(PhysicsWorld_Test, StepWithoutInitializationIsNoOp) {
XCEngine::Physics::PhysicsWorld world;
world.Step(0.016f);
EXPECT_FALSE(world.IsInitialized());
}
} // namespace

View File

@@ -27,5 +27,9 @@ target_include_directories(scene_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
)
if(WIN32 AND XCENGINE_ENABLE_PHYSX)
xcengine_copy_physx_runtime_dlls(scene_tests)
endif()
include(GoogleTest)
gtest_discover_tests(scene_tests)

View File

@@ -1,5 +1,10 @@
#include <gtest/gtest.h>
#include <XCEngine/Components/BoxColliderComponent.h>
#include <XCEngine/Components/CapsuleColliderComponent.h>
#include <XCEngine/Components/RigidbodyComponent.h>
#include <XCEngine/Components/SphereColliderComponent.h>
#include <XCEngine/Physics/PhysicsWorld.h>
#include <XCEngine/Scene/SceneRuntime.h>
#include <XCEngine/Scripting/IScriptRuntime.h>
#include <XCEngine/Scripting/ScriptComponent.h>
@@ -319,6 +324,83 @@ TEST_F(SceneRuntimeTest, StartAndStopForwardToScriptEngine) {
EXPECT_EQ(runtime.GetScene(), nullptr);
}
TEST_F(SceneRuntimeTest, StartCreatesPhysicsWorldAndScansScenePhysicsComponents) {
Scene* runtimeScene = CreateScene("RuntimeScene");
GameObject* ground = runtimeScene->CreateGameObject("Ground");
ground->AddComponent<BoxColliderComponent>();
GameObject* player = runtimeScene->CreateGameObject("Player");
player->AddComponent<RigidbodyComponent>();
player->AddComponent<SphereColliderComponent>();
GameObject* trigger = runtimeScene->CreateGameObject("Trigger", player);
trigger->AddComponent<CapsuleColliderComponent>();
runtime.Start(runtimeScene);
XCEngine::Physics::PhysicsWorld* physicsWorld = runtime.GetPhysicsWorld();
ASSERT_NE(physicsWorld, nullptr);
EXPECT_EQ(physicsWorld->GetCreateInfo().scene, runtimeScene);
EXPECT_EQ(
physicsWorld->IsInitialized(),
XCEngine::Physics::PhysicsWorld::IsPhysXAvailable());
EXPECT_EQ(physicsWorld->GetTrackedRigidbodyCount(), 1u);
EXPECT_EQ(physicsWorld->GetTrackedColliderCount(), 3u);
runtime.Stop();
EXPECT_EQ(runtime.GetPhysicsWorld(), nullptr);
}
TEST_F(SceneRuntimeTest, ReplaceSceneRebuildsPhysicsWorldAgainstNewScene) {
Scene* firstScene = CreateScene("FirstScene");
GameObject* firstActor = firstScene->CreateGameObject("FirstActor");
firstActor->AddComponent<RigidbodyComponent>();
firstActor->AddComponent<BoxColliderComponent>();
runtime.Start(firstScene);
auto secondScene = std::make_unique<Scene>("SecondScene");
GameObject* secondActor = secondScene->CreateGameObject("SecondActor");
secondActor->AddComponent<SphereColliderComponent>();
secondActor->AddComponent<CapsuleColliderComponent>();
runtime.ReplaceScene(secondScene.get());
XCEngine::Physics::PhysicsWorld* physicsWorld = runtime.GetPhysicsWorld();
ASSERT_NE(physicsWorld, nullptr);
EXPECT_EQ(physicsWorld->GetCreateInfo().scene, secondScene.get());
EXPECT_EQ(physicsWorld->GetTrackedRigidbodyCount(), 0u);
EXPECT_EQ(physicsWorld->GetTrackedColliderCount(), 2u);
runtime.Stop();
}
TEST_F(SceneRuntimeTest, RuntimePhysicsWorldTracksComponentAndGameObjectRemoval) {
Scene* runtimeScene = CreateScene("RuntimeScene");
runtime.Start(runtimeScene);
XCEngine::Physics::PhysicsWorld* physicsWorld = runtime.GetPhysicsWorld();
ASSERT_NE(physicsWorld, nullptr);
EXPECT_EQ(physicsWorld->GetTrackedRigidbodyCount(), 0u);
EXPECT_EQ(physicsWorld->GetTrackedColliderCount(), 0u);
GameObject* actor = runtimeScene->CreateGameObject("Actor");
actor->AddComponent<RigidbodyComponent>();
SphereColliderComponent* sphereCollider = actor->AddComponent<SphereColliderComponent>();
actor->AddComponent<BoxColliderComponent>();
EXPECT_EQ(physicsWorld->GetTrackedRigidbodyCount(), 1u);
EXPECT_EQ(physicsWorld->GetTrackedColliderCount(), 2u);
actor->RemoveComponent(sphereCollider);
EXPECT_EQ(physicsWorld->GetTrackedRigidbodyCount(), 1u);
EXPECT_EQ(physicsWorld->GetTrackedColliderCount(), 1u);
runtimeScene->DestroyGameObject(actor);
EXPECT_EQ(physicsWorld->GetTrackedRigidbodyCount(), 0u);
EXPECT_EQ(physicsWorld->GetTrackedColliderCount(), 0u);
}
TEST_F(SceneRuntimeTest, FrameOrderRunsScriptLifecycleBeforeNativeComponents) {
Scene* runtimeScene = CreateScene("RuntimeScene");
GameObject* host = runtimeScene->CreateGameObject("Host");