From 3317e470094b0f63ccd3e2a4ee0a90953e107cb4 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Wed, 15 Apr 2026 11:58:27 +0800 Subject: [PATCH] feat(physics): add runtime physics scaffolding --- ...物理模块架构与分阶段实施计划_2026-04-15.md | 846 ++++++++++++++++++ engine/CMakeLists.txt | 39 + .../Components/BoxColliderComponent.h | 23 + .../Components/CapsuleColliderComponent.h | 31 + .../XCEngine/Components/ColliderComponent.h | 46 + .../include/XCEngine/Components/GameObject.h | 5 + .../XCEngine/Components/RigidbodyComponent.h | 44 + .../Components/SphereColliderComponent.h | 23 + .../include/XCEngine/Physics/PhysicsTypes.h | 28 + .../include/XCEngine/Physics/PhysicsWorld.h | 56 ++ engine/include/XCEngine/Physics/RaycastHit.h | 17 + engine/include/XCEngine/Scene/Scene.h | 4 + engine/include/XCEngine/Scene/SceneRuntime.h | 11 + .../src/Components/BoxColliderComponent.cpp | 66 ++ .../Components/CapsuleColliderComponent.cpp | 76 ++ engine/src/Components/ColliderComponent.cpp | 90 ++ .../Components/ComponentFactoryRegistry.cpp | 9 + engine/src/Components/GameObject.cpp | 12 + engine/src/Components/RigidbodyComponent.cpp | 75 ++ .../Components/SphereColliderComponent.cpp | 43 + engine/src/Physics/PhysX/PhysXCommon.h | 5 + .../src/Physics/PhysX/PhysXWorldBackend.cpp | 157 ++++ engine/src/Physics/PhysX/PhysXWorldBackend.h | 28 + engine/src/Physics/PhysicsWorld.cpp | 185 ++++ engine/src/Scene/SceneRuntime.cpp | 33 + tests/CMakeLists.txt | 1 + .../test_component_factory_registry.cpp | 12 + tests/Physics/CMakeLists.txt | 32 + tests/Physics/test_physics_world.cpp | 37 + tests/Scene/CMakeLists.txt | 4 + tests/Scene/test_scene_runtime.cpp | 82 ++ 31 files changed, 2120 insertions(+) create mode 100644 docs/plan/PhysX物理模块架构与分阶段实施计划_2026-04-15.md create mode 100644 engine/include/XCEngine/Components/BoxColliderComponent.h create mode 100644 engine/include/XCEngine/Components/CapsuleColliderComponent.h create mode 100644 engine/include/XCEngine/Components/ColliderComponent.h create mode 100644 engine/include/XCEngine/Components/RigidbodyComponent.h create mode 100644 engine/include/XCEngine/Components/SphereColliderComponent.h create mode 100644 engine/include/XCEngine/Physics/PhysicsTypes.h create mode 100644 engine/include/XCEngine/Physics/PhysicsWorld.h create mode 100644 engine/include/XCEngine/Physics/RaycastHit.h create mode 100644 engine/src/Components/BoxColliderComponent.cpp create mode 100644 engine/src/Components/CapsuleColliderComponent.cpp create mode 100644 engine/src/Components/ColliderComponent.cpp create mode 100644 engine/src/Components/RigidbodyComponent.cpp create mode 100644 engine/src/Components/SphereColliderComponent.cpp create mode 100644 engine/src/Physics/PhysX/PhysXCommon.h create mode 100644 engine/src/Physics/PhysX/PhysXWorldBackend.cpp create mode 100644 engine/src/Physics/PhysX/PhysXWorldBackend.h create mode 100644 engine/src/Physics/PhysicsWorld.cpp create mode 100644 tests/Physics/CMakeLists.txt create mode 100644 tests/Physics/test_physics_world.cpp diff --git a/docs/plan/PhysX物理模块架构与分阶段实施计划_2026-04-15.md b/docs/plan/PhysX物理模块架构与分阶段实施计划_2026-04-15.md new file mode 100644 index 00000000..aad5ba67 --- /dev/null +++ b/docs/plan/PhysX物理模块架构与分阶段实施计划_2026-04-15.md @@ -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()` +- `RemoveComponent()` +- `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 1:PhysX 启动与 `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` 主线。 diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index ac700d1d..651f718e 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -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 diff --git a/engine/include/XCEngine/Components/BoxColliderComponent.h b/engine/include/XCEngine/Components/BoxColliderComponent.h new file mode 100644 index 00000000..1b4ca2f2 --- /dev/null +++ b/engine/include/XCEngine/Components/BoxColliderComponent.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +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 diff --git a/engine/include/XCEngine/Components/CapsuleColliderComponent.h b/engine/include/XCEngine/Components/CapsuleColliderComponent.h new file mode 100644 index 00000000..f71713f6 --- /dev/null +++ b/engine/include/XCEngine/Components/CapsuleColliderComponent.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +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 diff --git a/engine/include/XCEngine/Components/ColliderComponent.h b/engine/include/XCEngine/Components/ColliderComponent.h new file mode 100644 index 00000000..2a36072c --- /dev/null +++ b/engine/include/XCEngine/Components/ColliderComponent.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/engine/include/XCEngine/Components/GameObject.h b/engine/include/XCEngine/Components/GameObject.h index 011ab417..5abd137f 100644 --- a/engine/include/XCEngine/Components/GameObject.h +++ b/engine/include/XCEngine/Components/GameObject.h @@ -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(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); diff --git a/engine/include/XCEngine/Components/RigidbodyComponent.h b/engine/include/XCEngine/Components/RigidbodyComponent.h new file mode 100644 index 00000000..ee8a2fac --- /dev/null +++ b/engine/include/XCEngine/Components/RigidbodyComponent.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +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 diff --git a/engine/include/XCEngine/Components/SphereColliderComponent.h b/engine/include/XCEngine/Components/SphereColliderComponent.h new file mode 100644 index 00000000..1ccdfd79 --- /dev/null +++ b/engine/include/XCEngine/Components/SphereColliderComponent.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +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 diff --git a/engine/include/XCEngine/Physics/PhysicsTypes.h b/engine/include/XCEngine/Physics/PhysicsTypes.h new file mode 100644 index 00000000..991b00fe --- /dev/null +++ b/engine/include/XCEngine/Physics/PhysicsTypes.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +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 diff --git a/engine/include/XCEngine/Physics/PhysicsWorld.h b/engine/include/XCEngine/Physics/PhysicsWorld.h new file mode 100644 index 00000000..e4a19097 --- /dev/null +++ b/engine/include/XCEngine/Physics/PhysicsWorld.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include +#include +#include + +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 m_backend; +}; + +} // namespace Physics +} // namespace XCEngine diff --git a/engine/include/XCEngine/Physics/RaycastHit.h b/engine/include/XCEngine/Physics/RaycastHit.h new file mode 100644 index 00000000..80a8efb4 --- /dev/null +++ b/engine/include/XCEngine/Physics/RaycastHit.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +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 diff --git a/engine/include/XCEngine/Scene/Scene.h b/engine/include/XCEngine/Scene/Scene.h index f09ba80c..381a59f4 100644 --- a/engine/include/XCEngine/Scene/Scene.h +++ b/engine/include/XCEngine/Scene/Scene.h @@ -71,6 +71,8 @@ public: Core::Event& OnGameObjectCreated() { return m_onGameObjectCreated; } Core::Event& OnGameObjectDestroyed() { return m_onGameObjectDestroyed; } + Core::Event& OnComponentAdded() { return m_onComponentAdded; } + Core::Event& OnComponentRemoved() { return m_onComponentRemoved; } private: GameObject* FindInChildren(GameObject* parent, const std::string& name) const; @@ -108,6 +110,8 @@ private: Core::Event m_onGameObjectCreated; Core::Event m_onGameObjectDestroyed; + Core::Event m_onComponentAdded; + Core::Event m_onComponentRemoved; friend class GameObject; friend class SceneManager; diff --git a/engine/include/XCEngine/Scene/SceneRuntime.h b/engine/include/XCEngine/Scene/SceneRuntime.h index 526b2f8c..e0e7fda5 100644 --- a/engine/include/XCEngine/Scene/SceneRuntime.h +++ b/engine/include/XCEngine/Scene/SceneRuntime.h @@ -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 m_uiRuntime; + std::unique_ptr m_physicsWorld; Scene* m_scene = nullptr; bool m_running = false; }; diff --git a/engine/src/Components/BoxColliderComponent.cpp b/engine/src/Components/BoxColliderComponent.cpp new file mode 100644 index 00000000..2842529f --- /dev/null +++ b/engine/src/Components/BoxColliderComponent.cpp @@ -0,0 +1,66 @@ +#include "Components/BoxColliderComponent.h" + +#include +#include +#include + +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 diff --git a/engine/src/Components/CapsuleColliderComponent.cpp b/engine/src/Components/CapsuleColliderComponent.cpp new file mode 100644 index 00000000..d69d5210 --- /dev/null +++ b/engine/src/Components/CapsuleColliderComponent.cpp @@ -0,0 +1,76 @@ +#include "Components/CapsuleColliderComponent.h" + +#include +#include +#include + +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(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(std::stoi(value))); + } + } +} + +} // namespace Components +} // namespace XCEngine diff --git a/engine/src/Components/ColliderComponent.cpp b/engine/src/Components/ColliderComponent.cpp new file mode 100644 index 00000000..4abbf143 --- /dev/null +++ b/engine/src/Components/ColliderComponent.cpp @@ -0,0 +1,90 @@ +#include "Components/ColliderComponent.h" + +#include +#include +#include + +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 diff --git a/engine/src/Components/ComponentFactoryRegistry.cpp b/engine/src/Components/ComponentFactoryRegistry.cpp index 71196b24..603addd0 100644 --- a/engine/src/Components/ComponentFactoryRegistry.cpp +++ b/engine/src/Components/ComponentFactoryRegistry.cpp @@ -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); RegisterFactory("AudioSource", &CreateBuiltInComponent); RegisterFactory("AudioListener", &CreateBuiltInComponent); + RegisterFactory("Rigidbody", &CreateBuiltInComponent); + RegisterFactory("BoxCollider", &CreateBuiltInComponent); + RegisterFactory("SphereCollider", &CreateBuiltInComponent); + RegisterFactory("CapsuleCollider", &CreateBuiltInComponent); RegisterFactory("MeshFilter", &CreateBuiltInComponent); RegisterFactory("MeshRenderer", &CreateBuiltInComponent); RegisterFactory("GaussianSplatRenderer", &CreateBuiltInComponent); diff --git a/engine/src/Components/GameObject.cpp b/engine/src/Components/GameObject.cpp index ac4acc9e..18c5cf2e 100644 --- a/engine/src/Components/GameObject.cpp +++ b/engine/src/Components/GameObject.cpp @@ -58,6 +58,18 @@ std::unordered_map& 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()) { diff --git a/engine/src/Components/RigidbodyComponent.cpp b/engine/src/Components/RigidbodyComponent.cpp new file mode 100644 index 00000000..5c280977 --- /dev/null +++ b/engine/src/Components/RigidbodyComponent.cpp @@ -0,0 +1,75 @@ +#include "Components/RigidbodyComponent.h" + +#include +#include +#include + +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(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(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 diff --git a/engine/src/Components/SphereColliderComponent.cpp b/engine/src/Components/SphereColliderComponent.cpp new file mode 100644 index 00000000..abf7c92d --- /dev/null +++ b/engine/src/Components/SphereColliderComponent.cpp @@ -0,0 +1,43 @@ +#include "Components/SphereColliderComponent.h" + +#include +#include + +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 diff --git a/engine/src/Physics/PhysX/PhysXCommon.h b/engine/src/Physics/PhysX/PhysXCommon.h new file mode 100644 index 00000000..908b16e8 --- /dev/null +++ b/engine/src/Physics/PhysX/PhysXCommon.h @@ -0,0 +1,5 @@ +#pragma once + +#if XCENGINE_ENABLE_PHYSX +#include +#endif diff --git a/engine/src/Physics/PhysX/PhysXWorldBackend.cpp b/engine/src/Physics/PhysX/PhysXWorldBackend.cpp new file mode 100644 index 00000000..3f61aff6 --- /dev/null +++ b/engine/src/Physics/PhysX/PhysXWorldBackend.cpp @@ -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& 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 diff --git a/engine/src/Physics/PhysX/PhysXWorldBackend.h b/engine/src/Physics/PhysX/PhysXWorldBackend.h new file mode 100644 index 00000000..281623b3 --- /dev/null +++ b/engine/src/Physics/PhysX/PhysXWorldBackend.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include + +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 m_native; +}; + +} // namespace Physics +} // namespace XCEngine diff --git a/engine/src/Physics/PhysicsWorld.cpp b/engine/src/Physics/PhysicsWorld.cpp new file mode 100644 index 00000000..f1a22e30 --- /dev/null +++ b/engine/src/Physics/PhysicsWorld.cpp @@ -0,0 +1,185 @@ +#include + +#include "Physics/PhysX/PhysXWorldBackend.h" + +#include +#include +#include +#include + +#include + +namespace XCEngine { +namespace Physics { + +namespace { + +void ApplyTrackedCountDelta(size_t& value, int delta) { + if (delta > 0) { + value += static_cast(delta); + return; + } + + const size_t magnitude = static_cast(-delta); + value = magnitude > value ? 0u : (value - magnitude); +} + +void VisitGameObjectHierarchy( + Components::GameObject* gameObject, + const std::function& 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(); + 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()) { + TrackComponent(rigidbody, delta); + } + + for (Components::ColliderComponent* collider : gameObject->GetComponents()) { + TrackComponent(collider, delta); + } +} + +void PhysicsWorld::TrackComponent(Components::Component* component, int delta) { + if (!component) { + return; + } + + if (dynamic_cast(component) != nullptr) { + ApplyTrackedCountDelta(m_trackedRigidbodyCount, delta); + } + + if (dynamic_cast(component) != nullptr) { + ApplyTrackedCountDelta(m_trackedColliderCount, delta); + } +} + +} // namespace Physics +} // namespace XCEngine diff --git a/engine/src/Scene/SceneRuntime.cpp b/engine/src/Scene/SceneRuntime.cpp index b5bf8d13..e3fef3b7 100644 --- a/engine/src/Scene/SceneRuntime.cpp +++ b/engine/src/Scene/SceneRuntime.cpp @@ -1,5 +1,6 @@ #include "Scene/SceneRuntime.h" +#include #include "Scripting/ScriptEngine.h" #include @@ -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::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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8f0e5a5d..251fe48f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/Components/test_component_factory_registry.cpp b/tests/Components/test_component_factory_registry.cpp index 51b9835c..4e394087 100644 --- a/tests/Components/test_component_factory_registry.cpp +++ b/tests/Components/test_component_factory_registry.cpp @@ -2,13 +2,17 @@ #include #include +#include #include +#include #include #include #include #include #include #include +#include +#include #include 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(registry.CreateComponent(&gameObject, "Light")), nullptr); EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "AudioSource")), nullptr); EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "AudioListener")), nullptr); + EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "Rigidbody")), nullptr); + EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "BoxCollider")), nullptr); + EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "SphereCollider")), nullptr); + EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "CapsuleCollider")), nullptr); EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "MeshFilter")), nullptr); EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "MeshRenderer")), nullptr); EXPECT_NE(dynamic_cast(registry.CreateComponent(&gameObject, "GaussianSplatRenderer")), nullptr); diff --git a/tests/Physics/CMakeLists.txt b/tests/Physics/CMakeLists.txt new file mode 100644 index 00000000..bc4979ff --- /dev/null +++ b/tests/Physics/CMakeLists.txt @@ -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) diff --git a/tests/Physics/test_physics_world.cpp b/tests/Physics/test_physics_world.cpp new file mode 100644 index 00000000..4a6d0eda --- /dev/null +++ b/tests/Physics/test_physics_world.cpp @@ -0,0 +1,37 @@ +#include + +#include +#include + +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 diff --git a/tests/Scene/CMakeLists.txt b/tests/Scene/CMakeLists.txt index c5bfb3cc..d140f19d 100644 --- a/tests/Scene/CMakeLists.txt +++ b/tests/Scene/CMakeLists.txt @@ -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) diff --git a/tests/Scene/test_scene_runtime.cpp b/tests/Scene/test_scene_runtime.cpp index af7872d7..369c63c7 100644 --- a/tests/Scene/test_scene_runtime.cpp +++ b/tests/Scene/test_scene_runtime.cpp @@ -1,5 +1,10 @@ #include +#include +#include +#include +#include +#include #include #include #include @@ -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(); + + GameObject* player = runtimeScene->CreateGameObject("Player"); + player->AddComponent(); + player->AddComponent(); + + GameObject* trigger = runtimeScene->CreateGameObject("Trigger", player); + trigger->AddComponent(); + + 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(); + firstActor->AddComponent(); + + runtime.Start(firstScene); + + auto secondScene = std::make_unique("SecondScene"); + GameObject* secondActor = secondScene->CreateGameObject("SecondActor"); + secondActor->AddComponent(); + secondActor->AddComponent(); + + 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(); + SphereColliderComponent* sphereCollider = actor->AddComponent(); + actor->AddComponent(); + + 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");