From 195eb73a7307ac97db5a3f8336d812fe32ef19bd Mon Sep 17 00:00:00 2001 From: Anemunt <69436164+darkresident55@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:02:05 -0500 Subject: [PATCH] Yeah! PhysX!!! --- CMakeLists.txt | 50 +++- Resources/imgui.ini | 6 + src/Camera.cpp | 1 - src/Engine.cpp | 212 +++++++++++++++-- src/Engine.h | 13 + src/EnginePanels.cpp | 364 +++++++++++++++++++++++++++- src/PhysicsSystem.cpp | 529 +++++++++++++++++++++++++++++++++++++++++ src/PhysicsSystem.h | 60 +++++ src/ProjectManager.cpp | 68 +++++- src/SceneObject.h | 41 ++++ src/ScriptRuntime.cpp | 22 ++ src/ScriptRuntime.h | 4 + 12 files changed, 1336 insertions(+), 34 deletions(-) create mode 100644 src/PhysicsSystem.cpp create mode 100644 src/PhysicsSystem.h diff --git a/CMakeLists.txt b/CMakeLists.txt index dca231b..a7f796e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,34 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# ==================== Compiler cache (ccache / sccache) ==================== + +option(MODULARITY_USE_COMPILER_CACHE "Enable compiler cache if available" ON) + +if(MODULARITY_USE_COMPILER_CACHE) + find_program(CCACHE_PROGRAM ccache) + find_program(SCCACHE_PROGRAM sccache) + + if(CCACHE_PROGRAM) + message(STATUS "Using compiler cache: ccache (${CCACHE_PROGRAM})") + set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + + elseif(SCCACHE_PROGRAM) + message(STATUS "Using compiler cache: sccache (${SCCACHE_PROGRAM})") + set(CMAKE_C_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") + + # Optional (helps some MSVC setups): ensure environment is used consistently + # set(ENV{SCCACHE_IDLE_TIMEOUT} "0") + + else() + message(STATUS "Compiler cache enabled, but neither ccache nor sccache was found.") + endif() +else() + message(STATUS "Compiler cache disabled (MODULARITY_USE_COMPILER_CACHE=OFF).") +endif() + # ==================== WINDOWS FIXES (only active on Windows) ==================== if(WIN32) add_compile_definitions( @@ -16,11 +44,14 @@ endif() # ==================== Compiler flags ==================== if(MSVC) - add_compile_options(/W4 /O2 /permissive- /MP) + set(MODULARITY_WARNING_FLAGS /W4 /O2 /permissive- /MP) else() - add_compile_options(-Wall -Wextra -Wpedantic -O2) + set(MODULARITY_WARNING_FLAGS -Wall -Wextra -Wpedantic -O2) endif() +# ==================== Optional PhysX ==================== +option(MODULARITY_ENABLE_PHYSX "Enable PhysX physics integration" ON) + # ==================== Third-party libraries ==================== add_subdirectory(src/ThirdParty/glfw EXCLUDE_FROM_ALL) @@ -73,7 +104,9 @@ file(GLOB_RECURSE ENGINE_HEADERS CONFIGURE_DEPENDS ) list(FILTER ENGINE_SOURCES EXCLUDE REGEX ".*/ThirdParty/assimp/.*") +list(FILTER ENGINE_SOURCES EXCLUDE REGEX ".*/ThirdParty/PhysX/.*") list(FILTER ENGINE_HEADERS EXCLUDE REGEX ".*/ThirdParty/assimp/.*") +list(FILTER ENGINE_HEADERS EXCLUDE REGEX ".*/ThirdParty/PhysX/.*") add_library(core STATIC ${ENGINE_SOURCES} ${ENGINE_HEADERS}) set(ASSIMP_WARNINGS_AS_ERRORS OFF CACHE BOOL "Disable Assimp warnings as errors" FORCE) @@ -85,10 +118,23 @@ target_include_directories(core PUBLIC ${PROJECT_SOURCE_DIR}/src/ThirdParty/assimp/include ) target_link_libraries(core PUBLIC glad glm imgui imguizmo) +target_compile_options(core PRIVATE ${MODULARITY_WARNING_FLAGS}) +if(MODULARITY_ENABLE_PHYSX) + set(PHYSX_ROOT_DIR ${PROJECT_SOURCE_DIR}/src/ThirdParty/PhysX/physx CACHE PATH "PhysX root directory") + set(TARGET_BUILD_PLATFORM "linux" CACHE STRING "PhysX build platform (linux/windows)") + # PhysX build system expects output locations when using the GameWorks layout. + set(PX_OUTPUT_LIB_DIR ${CMAKE_BINARY_DIR}/physx CACHE PATH "PhysX output lib directory") + set(PX_OUTPUT_BIN_DIR ${CMAKE_BINARY_DIR}/physx/bin CACHE PATH "PhysX output bin directory") + add_subdirectory(${PHYSX_ROOT_DIR}/compiler/public ${CMAKE_BINARY_DIR}/physx) + target_include_directories(core PUBLIC ${PHYSX_ROOT_DIR}/include) + target_compile_definitions(core PUBLIC MODULARITY_ENABLE_PHYSX PX_PHYSX_STATIC_LIB) + target_link_libraries(core PUBLIC PhysX PhysXCommon PhysXFoundation PhysXExtensions PhysXCooking) +endif() # ==================== Executable ==================== add_executable(Modularity src/main.cpp) +target_compile_options(Modularity PRIVATE ${MODULARITY_WARNING_FLAGS}) # Link order matters on Linux if(NOT WIN32) diff --git a/Resources/imgui.ini b/Resources/imgui.ini index 27d96c2..98994db 100644 --- a/Resources/imgui.ini +++ b/Resources/imgui.ini @@ -77,6 +77,12 @@ Size=784,221 Collapsed=0 DockId=0x00000006,1 +[Window][Game Viewport] +Pos=306,46 +Size=1265,737 +Collapsed=0 +DockId=0x00000002,1 + [Docking][Data] DockSpace ID=0xD71539A0 Window=0x3DA2F1DE Pos=0,46 Size=1920,960 Split=X DockNode ID=0x00000007 Parent=0xD71539A0 SizeRef=1509,1015 Split=Y diff --git a/src/Camera.cpp b/src/Camera.cpp index 3f627a2..1cb2a73 100644 --- a/src/Camera.cpp +++ b/src/Camera.cpp @@ -127,6 +127,5 @@ void ViewportController::update(GLFWwindow* window, bool& cursorLocked) { viewportFocused = false; manualUnfocus = true; cursorLocked = false; - glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_NORMAL); } } diff --git a/src/Engine.cpp b/src/Engine.cpp index 0d00092..721f51a 100644 --- a/src/Engine.cpp +++ b/src/Engine.cpp @@ -273,17 +273,6 @@ void Engine::run() { continue; } - // Enforce cursor lock state every frame to avoid backends restoring it. - int desiredMode = cursorLocked ? GLFW_CURSOR_DISABLED : GLFW_CURSOR_NORMAL; - if (glfwGetInputMode(editorWindow, GLFW_CURSOR) != desiredMode) { - glfwSetInputMode(editorWindow, GLFW_CURSOR, desiredMode); - if (cursorLocked && glfwRawMouseMotionSupported()) { - glfwSetInputMode(editorWindow, GLFW_RAW_MOUSE_MOTION, GLFW_TRUE); - } else if (!cursorLocked && glfwRawMouseMotionSupported()) { - glfwSetInputMode(editorWindow, GLFW_RAW_MOUSE_MOTION, GLFW_FALSE); - } - } - float currentFrame = glfwGetTime(); deltaTime = currentFrame - lastFrame; lastFrame = currentFrame; @@ -295,12 +284,13 @@ void Engine::run() { handleKeyboardShortcuts(); } - viewportController.update(editorWindow, cursorLocked); - - if (!viewportController.isViewportFocused() && cursorLocked) { + if (gameViewCursorLocked) { cursorLocked = false; - glfwSetInputMode(editorWindow, GLFW_CURSOR, GLFW_CURSOR_NORMAL); - camera.firstMouse = true; + viewportController.setFocused(false); + } + viewportController.update(editorWindow, cursorLocked); + if (!isPlaying) { + gameViewCursorLocked = false; } // Scroll-wheel speed adjustment while freelook is active @@ -318,9 +308,21 @@ void Engine::run() { camera.processKeyboard(deltaTime, editorWindow); } - // Run script tick/update even when the object is not selected. + // Run scripts only in play/spec/test modes to avoid edit-time side effects (e.g., cursor grabs) if (projectManager.currentProject.isLoaded) { - updateScripts(deltaTime); + bool runScripts = isPlaying || specMode || testMode; + if (runScripts) { + updateScripts(deltaTime); + } + } + + bool simulatePhysics = physics.isReady() && ((isPlaying && !isPaused) || (!isPlaying && specMode)); + if (simulatePhysics) { + physics.simulate(deltaTime, sceneObjects); + } + + if (isPlaying) { + updatePlayerController(deltaTime); } if (!showLauncher && projectManager.currentProject.isLoaded && rendererInitialized) { @@ -329,9 +331,19 @@ void Engine::run() { if (aspect <= 0.0f) aspect = 1.0f; glm::mat4 proj = glm::perspective(glm::radians(FOV), aspect, NEAR_PLANE, FAR_PLANE); +#ifdef GL_POLYGON_MODE + GLint prevPoly[2] = { GL_FILL, GL_FILL }; + glGetIntegerv(GL_POLYGON_MODE, prevPoly); + glPolygonMode(GL_FRONT_AND_BACK, collisionWireframe ? GL_LINE : GL_FILL); +#endif + renderer.beginRender(view, proj, camera.position); renderer.renderScene(camera, sceneObjects, selectedObjectId); renderer.endRender(); + +#ifdef GL_POLYGON_MODE + glPolygonMode(GL_FRONT_AND_BACK, prevPoly[0]); +#endif } if (firstFrame) { @@ -367,6 +379,7 @@ void Engine::run() { } renderViewport(); + if (showGameViewport) renderGameViewportWindow(); renderDialogs(); } @@ -391,6 +404,18 @@ void Engine::run() { glfwMakeContextCurrent(backup_current_context); } + // Enforce cursor lock state at the end of the frame based on latest flags. + bool anyLock = cursorLocked || gameViewCursorLocked; + int desiredMode = anyLock ? GLFW_CURSOR_DISABLED : GLFW_CURSOR_NORMAL; + if (glfwGetInputMode(editorWindow, GLFW_CURSOR) != desiredMode) { + glfwSetInputMode(editorWindow, GLFW_CURSOR, desiredMode); + if (anyLock && glfwRawMouseMotionSupported()) { + glfwSetInputMode(editorWindow, GLFW_RAW_MOUSE_MOTION, GLFW_TRUE); + } else if (!anyLock && glfwRawMouseMotionSupported()) { + glfwSetInputMode(editorWindow, GLFW_RAW_MOUSE_MOTION, GLFW_FALSE); + } + } + glfwSwapBuffers(editorWindow); if (firstFrame) { @@ -407,6 +432,9 @@ void Engine::shutdown() { saveCurrentScene(); } + physics.onPlayStop(); + physics.shutdown(); + ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplGlfw_Shutdown(); ImGui::DestroyContext(); @@ -656,7 +684,11 @@ void Engine::handleKeyboardShortcuts() { ctrlNPressed = false; } - bool cameraActive = cursorLocked || viewportController.isViewportFocused() && cursorLocked; + bool cameraActive = cursorLocked || (viewportController.isViewportFocused() && cursorLocked); + if (!isPlaying && gameViewCursorLocked) { + // Prevent edit-mode freelook from conflicting with game view capture + gameViewCursorLocked = false; + } if (!cameraActive) { if (ImGui::IsKeyPressed(ImGuiKey_Q)) mCurrentGizmoOperation = ImGuizmo::TRANSLATE; if (ImGui::IsKeyPressed(ImGuiKey_W)) mCurrentGizmoOperation = ImGuizmo::ROTATE; @@ -669,6 +701,11 @@ void Engine::handleKeyboardShortcuts() { } } + if (ImGui::IsKeyPressed(ImGuiKey_3)) { + collisionWireframe = !collisionWireframe; + addConsoleMessage(std::string("Collision wireframe ") + (collisionWireframe ? "enabled" : "disabled"), ConsoleMessageType::Info); + } + static bool snapPressed = false; static bool snapHeldByCtrl = false; static bool snapStateBeforeCtrl = false; @@ -709,6 +746,10 @@ void Engine::handleKeyboardShortcuts() { if (glfwGetKey(editorWindow, GLFW_KEY_Y) == GLFW_RELEASE) { redoPressed = false; } + + if (ImGui::IsKeyPressed(ImGuiKey_Escape) && gameViewCursorLocked) { + gameViewCursorLocked = false; + } } void Engine::updateScripts(float delta) { @@ -729,6 +770,113 @@ void Engine::updateScripts(float delta) { } } +void Engine::updatePlayerController(float delta) { + if (!isPlaying) return; + + SceneObject* player = nullptr; + for (auto& obj : sceneObjects) { + if (obj.hasPlayerController && obj.playerController.enabled) { + player = &obj; + activePlayerId = obj.id; + break; + } + } + if (!player) { + activePlayerId = -1; + return; + } + + auto& pc = player->playerController; + // Maintain capsule sizing and collider defaults + if (pc.pitch == 0.0f && pc.yaw == 0.0f && (glm::length(player->rotation) > 0.01f)) { + pc.pitch = player->rotation.x; + pc.yaw = player->rotation.y; + } + player->hasCollider = true; + player->collider.type = ColliderType::Capsule; + player->collider.convex = true; + player->collider.boxSize = glm::vec3(pc.radius * 2.0f, pc.height, pc.radius * 2.0f); + player->hasRigidbody = true; + player->rigidbody.enabled = true; + player->rigidbody.useGravity = true; + player->rigidbody.isKinematic = false; + + // Mouse look when game viewport is focused + if (gameViewportFocused || gameViewCursorLocked) { + ImGuiIO& io = ImGui::GetIO(); + pc.yaw -= io.MouseDelta.x * 50.0f * pc.lookSensitivity * delta; + pc.pitch -= io.MouseDelta.y * 50.0f * pc.lookSensitivity * delta; + pc.pitch = std::clamp(pc.pitch, -89.0f, 89.0f); + } + + // Movement input aligned to camera facing (-Z forward convention) + auto key = [&](int k) { return glfwGetKey(editorWindow, k) == GLFW_PRESS; }; + glm::quat q = glm::quat(glm::radians(glm::vec3(pc.pitch, pc.yaw, 0.0f))); + glm::vec3 forward = glm::normalize(q * glm::vec3(0.0f, 0.0f, -1.0f)); + glm::vec3 right = glm::normalize(q * glm::vec3(1.0f, 0.0f, 0.0f)); + glm::vec3 planarForward = glm::normalize(glm::vec3(forward.x, 0.0f, forward.z)); + glm::vec3 planarRight = glm::normalize(glm::vec3(right.x, 0.0f, right.z)); + if (!std::isfinite(planarForward.x) || glm::length(planarForward) < 1e-3f) { + planarForward = glm::vec3(0, 0, -1); + } + if (!std::isfinite(planarRight.x) || glm::length(planarRight) < 1e-3f) { + planarRight = glm::vec3(1, 0, 0); + } + + glm::vec3 move(0.0f); + if (key(GLFW_KEY_W)) move += planarForward; + if (key(GLFW_KEY_S)) move -= planarForward; + if (key(GLFW_KEY_D)) move += planarRight; + if (key(GLFW_KEY_A)) move -= planarRight; + if (glm::length(move) > 0.001f) move = glm::normalize(move); + + float targetSpeed = pc.moveSpeed; + glm::vec3 velocity(move * targetSpeed); + + // Simple gravity and jump + float capsuleHalf = std::max(0.1f, pc.height * 0.5f); + glm::vec3 physVel; + bool havePhysVel = physics.getLinearVelocity(player->id, physVel); + if (havePhysVel) pc.verticalVelocity = physVel.y; + + // Ground check via PhysX scene query so mesh colliders work, not just the plane + glm::vec3 hitPos; + glm::vec3 hitNormal; + float hitDist = 0.0f; + float probeDist = capsuleHalf + 0.4f; + glm::vec3 rayStart = player->position + glm::vec3(0.0f, 0.1f, 0.0f); + bool hitGround = physics.raycastClosest(rayStart, glm::vec3(0.0f, -1.0f, 0.0f), probeDist, + player->id, &hitPos, &hitNormal, &hitDist); + bool grounded = hitGround && hitNormal.y > 0.25f && hitDist <= capsuleHalf + 0.2f && pc.verticalVelocity <= 0.35f; + if (!hitGround) { + // Fallback to simple height check to avoid regressions if queries fail + grounded = player->position.y <= capsuleHalf + 0.12f && pc.verticalVelocity <= 0.35f; + } + + if (grounded) { + pc.verticalVelocity = 0.0f; + if (hitGround) { + player->position.y = std::max(player->position.y, hitPos.y + capsuleHalf); + } else { + player->position.y = capsuleHalf; + } + if (key(GLFW_KEY_SPACE)) { + pc.verticalVelocity = pc.jumpStrength; + } + } else { + pc.verticalVelocity += -9.81f * delta; + } + velocity.y = pc.verticalVelocity; + velocity.y = std::clamp(velocity.y, -30.0f, 30.0f); + + // Apply yaw to physics actor and keep collider aligned + physics.setActorYaw(player->id, pc.yaw); + player->rotation = glm::vec3(pc.pitch, pc.yaw, 0.0f); + + if (!physics.setLinearVelocity(player->id, velocity)) { + player->position += velocity * delta; + } +} void Engine::OpenProjectPath(const std::string& path) { try { if (projectManager.loadProject(path)) { @@ -746,6 +894,10 @@ void Engine::OpenProjectPath(const std::string& path) { return; } + if (!physics.isReady() && !physics.init()) { + addConsoleMessage("Warning: PhysX failed to initialize; physics disabled for this session", ConsoleMessageType::Warning); + } + loadRecentScenes(); fileBrowser.setProjectRoot(projectManager.currentProject.projectPath); fileBrowser.currentPath = projectManager.currentProject.projectPath; @@ -779,6 +931,10 @@ void Engine::createNewProject(const char* name, const char* location) { return; } + if (!physics.isReady() && !physics.init()) { + addConsoleMessage("Warning: PhysX failed to initialize; physics disabled for this session", ConsoleMessageType::Warning); + } + sceneObjects.clear(); clearSelection(); nextObjectId = 0; @@ -950,6 +1106,12 @@ void Engine::duplicateSelected() { newObj.light = it->light; newObj.camera = it->camera; newObj.postFx = it->postFx; + newObj.hasRigidbody = it->hasRigidbody; + newObj.rigidbody = it->rigidbody; + newObj.hasCollider = it->hasCollider; + newObj.collider = it->collider; + newObj.hasPlayerController = it->hasPlayerController; + newObj.playerController = it->playerController; sceneObjects.push_back(newObj); setPrimarySelection(id); @@ -1071,6 +1233,18 @@ void Engine::markProjectDirty() { projectManager.currentProject.hasUnsavedChanges = true; } +bool Engine::setRigidbodyVelocityFromScript(int id, const glm::vec3& velocity) { + return physics.setLinearVelocity(id, velocity); +} + +bool Engine::getRigidbodyVelocityFromScript(int id, glm::vec3& outVelocity) { + return physics.getLinearVelocity(id, outVelocity); +} + +bool Engine::teleportPhysicsActorFromScript(int id, const glm::vec3& position, const glm::vec3& rotationDeg) { + return physics.setActorPose(id, position, rotationDeg); +} + void Engine::compileScriptFile(const fs::path& scriptPath) { if (!projectManager.currentProject.isLoaded) { addConsoleMessage("No project is loaded", ConsoleMessageType::Warning); diff --git a/src/Engine.h b/src/Engine.h index 2d898a6..b4b8a6f 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -9,6 +9,7 @@ #include "MeshBuilder.h" #include "ScriptCompiler.h" #include "ScriptRuntime.h" +#include "PhysicsSystem.h" #include "../include/Window/Window.h" void window_size_callback(GLFWwindow* window, int width, int height); @@ -90,7 +91,11 @@ private: bool isPlaying = false; bool isPaused = false; bool showViewOutput = true; + bool showGameViewport = true; int previewCameraId = -1; + bool gameViewCursorLocked = false; + bool gameViewportFocused = false; + int activePlayerId = -1; MeshBuilder meshBuilder; char meshBuilderPath[260] = ""; char meshBuilderFaceInput[128] = ""; @@ -105,12 +110,14 @@ private: MeshEditSelectionMode meshEditSelectionMode = MeshEditSelectionMode::Vertex; ScriptCompiler scriptCompiler; ScriptRuntime scriptRuntime; + PhysicsSystem physics; bool showCompilePopup = false; bool lastCompileSuccess = false; std::string lastCompileStatus; std::string lastCompileLog; bool specMode = false; bool testMode = false; + bool collisionWireframe = false; // Private methods SceneObject* getSelectedObject(); @@ -141,11 +148,13 @@ private: void renderInspectorPanel(); void renderConsolePanel(); void renderViewport(); + void renderGameViewportWindow(); void renderDialogs(); void renderProjectBrowserPanel(); Camera makeCameraFromObject(const SceneObject& obj) const; void compileScriptFile(const fs::path& scriptPath); void updateScripts(float delta); + void updatePlayerController(float delta); void renderFileBrowserToolbar(); void renderFileBrowserBreadcrumb(); @@ -206,4 +215,8 @@ public: void markProjectDirty(); // Script-accessible logging wrapper void addConsoleMessageFromScript(const std::string& message, ConsoleMessageType type); + // Script-accessible physics helpers + bool setRigidbodyVelocityFromScript(int id, const glm::vec3& velocity); + bool getRigidbodyVelocityFromScript(int id, glm::vec3& outVelocity); + bool teleportPhysicsActorFromScript(int id, const glm::vec3& position, const glm::vec3& rotationDeg); }; diff --git a/src/EnginePanels.cpp b/src/EnginePanels.cpp index 7a9274a..4c6e2ce 100644 --- a/src/EnginePanels.cpp +++ b/src/EnginePanels.cpp @@ -296,6 +296,60 @@ namespace FileIcons { } } +void Engine::renderGameViewportWindow() { + gameViewportFocused = false; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f)); + ImGui::Begin("Game Viewport", &showGameViewport, ImGuiWindowFlags_NoScrollbar); + + bool windowFocused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows); + ImVec2 avail = ImGui::GetContentRegionAvail(); + int width = std::max(160, (int)avail.x); + int height = std::max(120, (int)avail.y); + + const SceneObject* playerCam = nullptr; + for (const auto& obj : sceneObjects) { + if (obj.type == ObjectType::Camera && obj.camera.type == SceneCameraType::Player) { + playerCam = &obj; + break; + } + } + + if (!isPlaying) { + gameViewCursorLocked = false; + } + + if (playerCam && rendererInitialized) { + unsigned int tex = renderer.renderScenePreview( + makeCameraFromObject(*playerCam), + sceneObjects, + width, + height, + playerCam->camera.fov, + playerCam->camera.nearClip, + playerCam->camera.farClip + ); + + ImGui::Image((void*)(intptr_t)tex, ImVec2((float)width, (float)height), ImVec2(0, 1), ImVec2(1, 0)); + bool hovered = ImGui::IsItemHovered(); + bool clicked = hovered && isPlaying && ImGui::IsMouseClicked(ImGuiMouseButton_Left); + + if (clicked && !gameViewCursorLocked) { + gameViewCursorLocked = true; + } + if (gameViewCursorLocked && (!isPlaying || !windowFocused || ImGui::IsKeyPressed(ImGuiKey_Escape))) { + gameViewCursorLocked = false; + } + + gameViewportFocused = windowFocused && gameViewCursorLocked; + ImGui::TextDisabled(gameViewCursorLocked ? "Camera captured (ESC to release)" : "Click to capture"); + } else { + ImGui::TextDisabled("No player camera found (Camera Type: Player)."); + gameViewportFocused = ImGui::IsWindowFocused(); + } + + ImGui::End(); + ImGui::PopStyleVar(); +} void Engine::renderFileBrowserPanel() { ImGui::Begin("Project", &showFileBrowser); ImGuiStyle& style = ImGui::GetStyle(); @@ -1307,7 +1361,23 @@ void Engine::renderMainMenuBar() { } if (ImGui::BeginMenu("Scripts")) { - ImGui::MenuItem("Spec Mode (run Script_Spec)", nullptr, &specMode); + auto toggleSpec = [&](bool enabled) { + if (specMode == enabled) return; + if (enabled && !physics.isReady() && !physics.init()) { + addConsoleMessage("PhysX failed to initialize; spec mode disabled", ConsoleMessageType::Warning); + specMode = false; + return; + } + specMode = enabled; + if (!isPlaying) { + if (specMode) physics.onPlayStart(sceneObjects); + else physics.onPlayStop(); + } + }; + bool specValue = specMode; + if (ImGui::MenuItem("Spec Mode (run Script_Spec)", nullptr, &specValue)) { + toggleSpec(specValue); + } ImGui::MenuItem("Test Mode (run Script_TestEditor)", nullptr, &testMode); ImGui::EndMenu(); } @@ -1353,18 +1423,43 @@ void Engine::renderMainMenuBar() { bool playPressed = ImGui::Button(isPlaying ? "Stop" : "Play"); ImGui::SameLine(0.0f, 6.0f); bool pausePressed = ImGui::Button(isPaused ? "Resume" : "Pause"); + ImGui::SameLine(0.0f, 6.0f); + bool specPressed = ImGui::Button(specMode ? "Spec On" : "Spec Mode"); ImGui::PopStyleVar(); if (playPressed) { - isPlaying = !isPlaying; - if (!isPlaying) { + bool newState = !isPlaying; + if (newState) { + if (physics.isReady() || physics.init()) { + physics.onPlayStart(sceneObjects); + } else { + addConsoleMessage("PhysX failed to initialize; physics disabled for play mode", ConsoleMessageType::Warning); + } + } else { + physics.onPlayStop(); isPaused = false; + if (specMode && (physics.isReady() || physics.init())) { + physics.onPlayStart(sceneObjects); + } } + isPlaying = newState; } if (pausePressed) { isPaused = !isPaused; if (isPaused) isPlaying = true; // placeholder: pausing implies we’re in play mode } + if (specPressed) { + bool enable = !specMode; + if (enable && !physics.isReady() && !physics.init()) { + addConsoleMessage("PhysX failed to initialize; spec mode disabled", ConsoleMessageType::Warning); + enable = false; + } + specMode = enable; + if (!isPlaying) { + if (specMode) physics.onPlayStart(sceneObjects); + else physics.onPlayStop(); + } + } float rightX = ImGui::GetWindowWidth() - 220.0f; if (rightX > ImGui::GetCursorPosX()) { @@ -1882,6 +1977,170 @@ void Engine::renderInspectorPanel() { ImGui::PopStyleColor(); + if (obj.hasCollider) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.35f, 0.5f, 0.35f, 1.0f)); + if (ImGui::CollapsingHeader("Collider", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Indent(10.0f); + bool changed = false; + + if (ImGui::Checkbox("Enabled", &obj.collider.enabled)) { + changed = true; + } + + const char* colliderTypes[] = { "Box", "Mesh", "Convex Mesh", "Capsule" }; + int colliderType = static_cast(obj.collider.type); + if (ImGui::Combo("Type", &colliderType, colliderTypes, IM_ARRAYSIZE(colliderTypes))) { + obj.collider.type = static_cast(colliderType); + changed = true; + } + + if (obj.collider.type == ColliderType::Box) { + if (ImGui::DragFloat3("Box Size", &obj.collider.boxSize.x, 0.01f, 0.01f, 1000.0f, "%.3f")) { + obj.collider.boxSize.x = std::max(0.01f, obj.collider.boxSize.x); + obj.collider.boxSize.y = std::max(0.01f, obj.collider.boxSize.y); + obj.collider.boxSize.z = std::max(0.01f, obj.collider.boxSize.z); + changed = true; + } + if (ImGui::SmallButton("Match Object Scale")) { + obj.collider.boxSize = glm::max(obj.scale, glm::vec3(0.01f)); + changed = true; + } + } else if (obj.collider.type == ColliderType::Capsule) { + float radius = std::max(0.05f, std::max(obj.collider.boxSize.x, obj.collider.boxSize.z) * 0.5f); + float height = std::max(0.1f, obj.collider.boxSize.y); + if (ImGui::DragFloat("Radius", &radius, 0.01f, 0.05f, 5.0f, "%.3f")) { + obj.collider.boxSize.x = obj.collider.boxSize.z = radius * 2.0f; + changed = true; + } + if (ImGui::DragFloat("Height", &height, 0.01f, 0.1f, 10.0f, "%.3f")) { + obj.collider.boxSize.y = height; + changed = true; + } + ImGui::TextDisabled("Capsule aligned to Y axis."); + } else { + if (ImGui::Checkbox("Use Convex Hull (required for Rigidbody)", &obj.collider.convex)) { + changed = true; + } + ImGui::TextDisabled("Uses mesh from the object (OBJ/Model). Non-convex is static-only."); + } + + ImGui::Spacing(); + if (ImGui::Button("Remove Collider", ImVec2(-1, 0))) { + obj.hasCollider = false; + changed = true; + } + + if (changed) { + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::Unindent(10.0f); + } + ImGui::PopStyleColor(); + } + + if (obj.hasPlayerController) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.35f, 0.45f, 0.7f, 1.0f)); + if (ImGui::CollapsingHeader("Player Controller", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Indent(10.0f); + bool changed = false; + + if (ImGui::Checkbox("Enabled", &obj.playerController.enabled)) { + changed = true; + } + if (ImGui::DragFloat("Move Speed", &obj.playerController.moveSpeed, 0.1f, 0.1f, 100.0f, "%.2f")) { + obj.playerController.moveSpeed = std::max(0.1f, obj.playerController.moveSpeed); + changed = true; + } + if (ImGui::DragFloat("Look Sensitivity", &obj.playerController.lookSensitivity, 0.01f, 0.01f, 2.0f, "%.2f")) { + obj.playerController.lookSensitivity = std::clamp(obj.playerController.lookSensitivity, 0.01f, 2.0f); + changed = true; + } + if (ImGui::DragFloat("Height", &obj.playerController.height, 0.01f, 0.5f, 3.0f, "%.2f")) { + obj.playerController.height = std::clamp(obj.playerController.height, 0.5f, 3.0f); + obj.scale.y = obj.playerController.height; + obj.collider.boxSize.y = obj.playerController.height; + changed = true; + } + if (ImGui::DragFloat("Radius", &obj.playerController.radius, 0.01f, 0.2f, 1.2f, "%.2f")) { + obj.playerController.radius = std::clamp(obj.playerController.radius, 0.2f, 1.2f); + obj.scale.x = obj.scale.z = obj.playerController.radius * 2.0f; + obj.collider.boxSize.x = obj.collider.boxSize.z = obj.playerController.radius * 2.0f; + changed = true; + } + if (ImGui::DragFloat("Jump Strength", &obj.playerController.jumpStrength, 0.1f, 0.1f, 30.0f, "%.1f")) { + obj.playerController.jumpStrength = std::max(0.1f, obj.playerController.jumpStrength); + changed = true; + } + + if (ImGui::Button("Remove Player Controller", ImVec2(-1, 0))) { + obj.hasPlayerController = false; + changed = true; + } + + if (changed) { + obj.hasCollider = true; + obj.collider.type = ColliderType::Capsule; + obj.collider.convex = true; + obj.hasRigidbody = true; + obj.rigidbody.enabled = true; + obj.rigidbody.useGravity = true; + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::Unindent(10.0f); + } + ImGui::PopStyleColor(); + } + + if (obj.hasRigidbody) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.45f, 0.45f, 0.25f, 1.0f)); + if (ImGui::CollapsingHeader("Rigidbody", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Indent(10.0f); + bool changed = false; + + if (ImGui::Checkbox("Enabled", &obj.rigidbody.enabled)) { + changed = true; + } + ImGui::SameLine(); + ImGui::BeginDisabled(true); + ImGui::Checkbox("Collider (mesh type)", &obj.rigidbody.enabled); // placeholder label to hint geometry from mesh + ImGui::EndDisabled(); + + if (ImGui::DragFloat("Mass", &obj.rigidbody.mass, 0.05f, 0.01f, 1000.0f, "%.2f")) { + obj.rigidbody.mass = std::max(0.01f, obj.rigidbody.mass); + changed = true; + } + if (ImGui::Checkbox("Use Gravity", &obj.rigidbody.useGravity)) { + changed = true; + } + if (ImGui::Checkbox("Kinematic", &obj.rigidbody.isKinematic)) { + changed = true; + } + if (ImGui::DragFloat("Linear Damping", &obj.rigidbody.linearDamping, 0.01f, 0.0f, 10.0f)) { + obj.rigidbody.linearDamping = std::clamp(obj.rigidbody.linearDamping, 0.0f, 10.0f); + changed = true; + } + if (ImGui::DragFloat("Angular Damping", &obj.rigidbody.angularDamping, 0.01f, 0.0f, 10.0f)) { + obj.rigidbody.angularDamping = std::clamp(obj.rigidbody.angularDamping, 0.0f, 10.0f); + changed = true; + } + + ImGui::Spacing(); + if (ImGui::Button("Remove Rigidbody", ImVec2(-1, 0))) { + obj.hasRigidbody = false; + changed = true; + } + + if (changed) { + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::Unindent(10.0f); + } + ImGui::PopStyleColor(); + } + if (obj.type == ObjectType::Camera) { ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.45f, 0.35f, 0.65f, 1.0f)); @@ -2230,6 +2489,51 @@ void Engine::renderInspectorPanel() { ImGui::OpenPopup("AddComponentPopup"); } if (ImGui::BeginPopup("AddComponentPopup")) { + if (!obj.hasRigidbody && ImGui::MenuItem("Rigidbody")) { + obj.hasRigidbody = true; + obj.rigidbody = RigidbodyComponent{}; + materialChanged = true; + } + if (!obj.hasPlayerController && ImGui::MenuItem("Player Controller")) { + obj.hasPlayerController = true; + obj.playerController = PlayerControllerComponent{}; + obj.hasCollider = true; + obj.collider.type = ColliderType::Capsule; + obj.collider.boxSize = glm::vec3(obj.playerController.radius * 2.0f, obj.playerController.height, obj.playerController.radius * 2.0f); + obj.collider.convex = true; + obj.hasRigidbody = true; + obj.rigidbody.enabled = true; + obj.rigidbody.useGravity = true; + obj.rigidbody.isKinematic = false; + obj.scale = glm::vec3(obj.playerController.radius * 2.0f, obj.playerController.height, obj.playerController.radius * 2.0f); + materialChanged = true; + } + if (!obj.hasCollider && ImGui::BeginMenu("Collider")) { + if (ImGui::MenuItem("Box Collider")) { + obj.hasCollider = true; + obj.collider = ColliderComponent{}; + obj.collider.boxSize = glm::max(obj.scale, glm::vec3(0.01f)); + materialChanged = true; + addComponentButtonShown = true; + } + if (ImGui::MenuItem("Mesh Collider (Triangle)")) { + obj.hasCollider = true; + obj.collider = ColliderComponent{}; + obj.collider.type = ColliderType::Mesh; + obj.collider.convex = false; + materialChanged = true; + addComponentButtonShown = true; + } + if (ImGui::MenuItem("Mesh Collider (Convex)")) { + obj.hasCollider = true; + obj.collider = ColliderComponent{}; + obj.collider.type = ColliderType::ConvexMesh; + obj.collider.convex = true; + materialChanged = true; + addComponentButtonShown = true; + } + ImGui::EndMenu(); + } if (ImGui::MenuItem("Script")) { obj.scripts.push_back(ScriptComponent{}); materialChanged = true; @@ -2435,6 +2739,52 @@ void Engine::renderInspectorPanel() { ImGui::OpenPopup("AddComponentPopup"); } if (ImGui::BeginPopup("AddComponentPopup")) { + if (!obj.hasRigidbody && ImGui::MenuItem("Rigidbody")) { + obj.hasRigidbody = true; + obj.rigidbody = RigidbodyComponent{}; + projectManager.currentProject.hasUnsavedChanges = true; + } + if (!obj.hasPlayerController && ImGui::MenuItem("Player Controller")) { + obj.hasPlayerController = true; + obj.playerController = PlayerControllerComponent{}; + obj.hasCollider = true; + obj.collider.type = ColliderType::Capsule; + obj.collider.boxSize = glm::vec3(obj.playerController.radius * 2.0f, obj.playerController.height, obj.playerController.radius * 2.0f); + obj.collider.convex = true; + obj.hasRigidbody = true; + obj.rigidbody.enabled = true; + obj.rigidbody.useGravity = true; + obj.rigidbody.isKinematic = false; + obj.scale = glm::vec3(obj.playerController.radius * 2.0f, obj.playerController.height, obj.playerController.radius * 2.0f); + projectManager.currentProject.hasUnsavedChanges = true; + addComponentButtonShown = true; + } + if (!obj.hasCollider && ImGui::BeginMenu("Collider")) { + if (ImGui::MenuItem("Box Collider")) { + obj.hasCollider = true; + obj.collider = ColliderComponent{}; + obj.collider.boxSize = glm::max(obj.scale, glm::vec3(0.01f)); + projectManager.currentProject.hasUnsavedChanges = true; + addComponentButtonShown = true; + } + if (ImGui::MenuItem("Mesh Collider (Triangle)")) { + obj.hasCollider = true; + obj.collider = ColliderComponent{}; + obj.collider.type = ColliderType::Mesh; + obj.collider.convex = false; + projectManager.currentProject.hasUnsavedChanges = true; + addComponentButtonShown = true; + } + if (ImGui::MenuItem("Mesh Collider (Convex)")) { + obj.hasCollider = true; + obj.collider = ColliderComponent{}; + obj.collider.type = ColliderType::ConvexMesh; + obj.collider.convex = true; + projectManager.currentProject.hasUnsavedChanges = true; + addComponentButtonShown = true; + } + ImGui::EndMenu(); + } if (ImGui::MenuItem("Script")) { obj.scripts.push_back(ScriptComponent{}); projectManager.currentProject.hasUnsavedChanges = true; @@ -3919,19 +4269,11 @@ void Engine::renderViewport() { if (mouseOverViewportImage && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { viewportController.setFocused(true); cursorLocked = true; - glfwSetInputMode(editorWindow, GLFW_CURSOR, GLFW_CURSOR_DISABLED); - if (glfwRawMouseMotionSupported()) { - glfwSetInputMode(editorWindow, GLFW_RAW_MOUSE_MOTION, GLFW_TRUE); - } camera.firstMouse = true; } if (cursorLocked && !ImGui::IsMouseDown(ImGuiMouseButton_Right)) { cursorLocked = false; - glfwSetInputMode(editorWindow, GLFW_CURSOR, GLFW_CURSOR_NORMAL); - if (glfwRawMouseMotionSupported()) { - glfwSetInputMode(editorWindow, GLFW_RAW_MOUSE_MOTION, GLFW_FALSE); - } camera.firstMouse = true; } if (cursorLocked) { diff --git a/src/PhysicsSystem.cpp b/src/PhysicsSystem.cpp new file mode 100644 index 0000000..7d949bd --- /dev/null +++ b/src/PhysicsSystem.cpp @@ -0,0 +1,529 @@ +#include "PhysicsSystem.h" + +#ifdef MODULARITY_ENABLE_PHYSX +#include "PxPhysicsAPI.h" +#include "ModelLoader.h" +#include +#include +#include "extensions/PxRigidBodyExt.h" + +using namespace physx; + +namespace { +PxVec3 ToPxVec3(const glm::vec3& v) { + return PxVec3(v.x, v.y, v.z); +} + +PxQuat ToPxQuat(const glm::vec3& eulerDeg) { + glm::vec3 radians = glm::radians(eulerDeg); + glm::quat q = glm::quat(radians); + return PxQuat(q.x, q.y, q.z, q.w); +} + +glm::vec3 ToGlmVec3(const PxVec3& v) { + return glm::vec3(v.x, v.y, v.z); +} + +glm::vec3 ToGlmEulerDeg(const PxQuat& q) { + glm::quat gq(q.w, q.x, q.y, q.z); + return glm::degrees(glm::eulerAngles(gq)); +} +} // namespace + +namespace { +struct IgnoreActorFilter : PxQueryFilterCallback { + PxRigidActor* ignore = nullptr; + explicit IgnoreActorFilter(PxRigidActor* actor) : ignore(actor) {} + + PxQueryHitType::Enum preFilter(const PxFilterData&, + const PxShape* shape, + const PxRigidActor* actor, + PxHitFlags&) override { + if (actor == ignore) return PxQueryHitType::eNONE; + // Keep default blocking behaviour + if (shape && shape->getFlags().isSet(PxShapeFlag::eTRIGGER_SHAPE)) { + return PxQueryHitType::eNONE; + } + return PxQueryHitType::eBLOCK; + } + + PxQueryHitType::Enum postFilter(const PxFilterData&, + const PxQueryHit&, + const PxShape*, + const PxRigidActor*) override { + return PxQueryHitType::eBLOCK; + } +}; +} // namespace + +bool PhysicsSystem::init() { + if (isReady()) return true; + + mFoundation = PxCreateFoundation(PX_PHYSICS_VERSION, mAllocator, mErrorCallback); + if (!mFoundation) return false; + + mPhysics = PxCreatePhysics(PX_PHYSICS_VERSION, *mFoundation, PxTolerancesScale(), true, nullptr); + if (!mPhysics) return false; + + mDispatcher = PxDefaultCpuDispatcherCreate(2); + if (!mDispatcher) return false; + + PxTolerancesScale scale = mPhysics->getTolerancesScale(); + mCookParams = PxCookingParams(scale); + mCookParams.meshPreprocessParams |= PxMeshPreprocessingFlag::eDISABLE_ACTIVE_EDGES_PRECOMPUTE; + mCookParams.meshPreprocessParams |= PxMeshPreprocessingFlag::eWELD_VERTICES; + + PxSceneDesc sceneDesc(mPhysics->getTolerancesScale()); + sceneDesc.gravity = PxVec3(0.0f, -9.81f, 0.0f); + sceneDesc.cpuDispatcher = mDispatcher; + sceneDesc.filterShader = PxDefaultSimulationFilterShader; + sceneDesc.flags |= PxSceneFlag::eENABLE_CCD; + mScene = mPhysics->createScene(sceneDesc); + if (!mScene) return false; + + mDefaultMaterial = mPhysics->createMaterial(0.9f, 0.9f, 0.0f); + + return mDefaultMaterial != nullptr; +} + +bool PhysicsSystem::isReady() const { + return mFoundation && mPhysics && mScene && mDefaultMaterial; +} + +void PhysicsSystem::createGroundPlane() { + if (!isReady()) return; + if (mGroundPlane) { + mScene->removeActor(*mGroundPlane); + mGroundPlane->release(); + mGroundPlane = nullptr; + } + mGroundPlane = PxCreatePlane(*mPhysics, PxPlane(0.0f, 1.0f, 0.0f, 0.0f), *mDefaultMaterial); + if (mGroundPlane) { + mScene->addActor(*mGroundPlane); + } +} + +bool PhysicsSystem::gatherMeshData(const SceneObject& obj, std::vector& vertices, std::vector& indices) const { + const OBJLoader::LoadedMesh* meshInfo = nullptr; + if (obj.type == ObjectType::OBJMesh && obj.meshId >= 0) { + meshInfo = g_objLoader.getMeshInfo(obj.meshId); + } else if (obj.type == ObjectType::Model && obj.meshId >= 0) { + meshInfo = getModelLoader().getMeshInfo(obj.meshId); + } + if (!meshInfo || meshInfo->triangleVertices.empty()) { + return false; + } + + vertices.reserve(meshInfo->triangleVertices.size()); + indices.resize(meshInfo->triangleVertices.size()); + + for (size_t i = 0; i < meshInfo->triangleVertices.size(); ++i) { + const glm::vec3& v = meshInfo->triangleVertices[i]; + vertices.emplace_back(v.x, v.y, v.z); + indices[i] = static_cast(i); + } + + return !vertices.empty() && (indices.size() % 3 == 0); +} + +PxTriangleMesh* PhysicsSystem::cookTriangleMesh(const std::vector& vertices, + const std::vector& indices) const { + if (vertices.empty() || indices.size() < 3) return nullptr; + + PxTriangleMeshDesc desc; + desc.points.count = static_cast(vertices.size()); + desc.points.stride = sizeof(PxVec3); + desc.points.data = vertices.data(); + desc.triangles.count = static_cast(indices.size() / 3); + desc.triangles.stride = 3 * sizeof(uint32_t); + desc.triangles.data = indices.data(); + + PxDefaultMemoryOutputStream buf; + if (!PxCookTriangleMesh(mCookParams, desc, buf)) { + return nullptr; + } + PxDefaultMemoryInputData input(buf.getData(), buf.getSize()); + return mPhysics->createTriangleMesh(input); +} + +PxConvexMesh* PhysicsSystem::cookConvexMesh(const std::vector& vertices) const { + if (vertices.size() < 4) return nullptr; + + PxConvexMeshDesc desc; + desc.points.count = static_cast(vertices.size()); + desc.points.stride = sizeof(PxVec3); + desc.points.data = vertices.data(); + desc.flags = PxConvexFlag::eCOMPUTE_CONVEX | PxConvexFlag::eCHECK_ZERO_AREA_TRIANGLES; + desc.vertexLimit = 255; + + PxDefaultMemoryOutputStream buf; + if (!PxCookConvexMesh(mCookParams, desc, buf)) { + return nullptr; + } + PxDefaultMemoryInputData input(buf.getData(), buf.getSize()); + return mPhysics->createConvexMesh(input); +} + +bool PhysicsSystem::attachPrimitiveShape(PxRigidActor* actor, const SceneObject& obj, bool isDynamic) const { + (void)isDynamic; + if (!actor) return false; + PxShape* shape = nullptr; + auto tuneShape = [](PxShape* s, float minDim, bool /*swept*/) { + if (!s) return; + float contact = std::clamp(minDim * 0.2f, 0.02f, 0.2f); + float rest = contact * 0.15f; + s->setContactOffset(contact); + s->setRestOffset(rest); + }; + + switch (obj.type) { + case ObjectType::Cube: { + PxVec3 halfExtents = ToPxVec3(glm::max(obj.scale * 0.5f, glm::vec3(0.01f))); + shape = mPhysics->createShape(PxBoxGeometry(halfExtents), *mDefaultMaterial, true); + tuneShape(shape, std::min({halfExtents.x, halfExtents.y, halfExtents.z}) * 2.0f, isDynamic); + break; + } + case ObjectType::Sphere: { + float radius = std::max({obj.scale.x, obj.scale.y, obj.scale.z}) * 0.5f; + radius = std::max(radius, 0.01f); + shape = mPhysics->createShape(PxSphereGeometry(radius), *mDefaultMaterial, true); + tuneShape(shape, radius * 2.0f, isDynamic); + break; + } + case ObjectType::Capsule: { + float radius = std::max(obj.scale.x, obj.scale.z) * 0.5f; + radius = std::max(radius, 0.01f); + float cylHeight = std::max(0.05f, obj.scale.y - radius * 2.0f); + float halfHeight = cylHeight * 0.5f; + shape = mPhysics->createShape(PxCapsuleGeometry(radius, halfHeight), *mDefaultMaterial, true); + if (shape) { + // PhysX capsules default to the X axis; rotate to align with Y (character up) + shape->setLocalPose(PxTransform(PxQuat(PxHalfPi, PxVec3(0, 0, 1)))); + } + tuneShape(shape, std::min(radius * 2.0f, halfHeight * 2.0f), isDynamic); + break; + } + default: + break; + } + + if (!shape) return false; + actor->attachShape(*shape); + shape->release(); + return true; +} + +bool PhysicsSystem::attachColliderShape(PxRigidActor* actor, const SceneObject& obj, bool isDynamic) const { + if (!actor || !obj.hasCollider || !obj.collider.enabled) return false; + + PxShape* shape = nullptr; + auto tuneShape = [](PxShape* s, float minDim, bool /*swept*/) { + if (!s) return; + float contact = std::clamp(minDim * 0.12f, 0.015f, 0.12f); + float rest = contact * 0.2f; + s->setContactOffset(contact); + s->setRestOffset(rest); + }; + float minDim = 0.1f; + if (obj.collider.type == ColliderType::Box) { + glm::vec3 half = glm::max(obj.collider.boxSize * 0.5f, glm::vec3(0.01f)); + shape = mPhysics->createShape(PxBoxGeometry(ToPxVec3(half)), *mDefaultMaterial, true); + minDim = std::min({half.x, half.y, half.z}) * 2.0f; + } else if (obj.collider.type == ColliderType::Capsule) { + float radius = std::max({obj.collider.boxSize.x, obj.collider.boxSize.z}) * 0.5f; + radius = std::max(radius, 0.01f); + float cylHeight = std::max(0.05f, obj.collider.boxSize.y - radius * 2.0f); + float halfHeight = cylHeight * 0.5f; + shape = mPhysics->createShape(PxCapsuleGeometry(radius, halfHeight), *mDefaultMaterial, true); + if (shape) { + // Rotate capsule so its axis matches the engine's Y-up expectation + shape->setLocalPose(PxTransform(PxQuat(PxHalfPi, PxVec3(0, 0, 1)))); + } + minDim = std::min(radius * 2.0f, halfHeight * 2.0f); + } else { + std::vector verts; + std::vector indices; + if (!gatherMeshData(obj, verts, indices)) { + return false; + } + + bool useConvex = obj.collider.convex || obj.collider.type == ColliderType::ConvexMesh || isDynamic; + glm::vec3 boundsMin(FLT_MAX); + glm::vec3 boundsMax(-FLT_MAX); + for (auto& v : verts) { + boundsMin.x = std::min(boundsMin.x, v.x * obj.scale.x); + boundsMin.y = std::min(boundsMin.y, v.y * obj.scale.y); + boundsMin.z = std::min(boundsMin.z, v.z * obj.scale.z); + boundsMax.x = std::max(boundsMax.x, v.x * obj.scale.x); + boundsMax.y = std::max(boundsMax.y, v.y * obj.scale.y); + boundsMax.z = std::max(boundsMax.z, v.z * obj.scale.z); + } + minDim = std::max(0.01f, std::min({boundsMax.x - boundsMin.x, boundsMax.y - boundsMin.y, boundsMax.z - boundsMin.z})); + if (useConvex) { + PxConvexMesh* convex = cookConvexMesh(verts); + if (!convex) return false; + PxConvexMeshGeometry geom(convex, PxMeshScale(ToPxVec3(obj.scale), PxQuat(PxIdentity))); + shape = mPhysics->createShape(geom, *mDefaultMaterial, true); + convex->release(); + } else { + PxTriangleMesh* tri = cookTriangleMesh(verts, indices); + if (!tri) return false; + PxTriangleMeshGeometry geom(tri, PxMeshScale(ToPxVec3(obj.scale), PxQuat(PxIdentity))); + shape = mPhysics->createShape(geom, *mDefaultMaterial, true); + tri->release(); + } + } + + tuneShape(shape, std::max(0.01f, minDim), isDynamic || obj.hasPlayerController); + + if (!shape) return false; + actor->attachShape(*shape); + shape->release(); + return true; +} + +PhysicsSystem::ActorRecord PhysicsSystem::createActorFor(const SceneObject& obj) const { + ActorRecord record; + + const bool wantsDynamic = obj.hasRigidbody && obj.rigidbody.enabled; + const bool wantsCollider = obj.hasCollider && obj.collider.enabled; + if (!wantsDynamic && !wantsCollider) { + return record; + } + + PxTransform transform(ToPxVec3(obj.position), ToPxQuat(obj.rotation)); + + PxRigidActor* actor = wantsDynamic + ? static_cast(mPhysics->createRigidDynamic(transform)) + : static_cast(mPhysics->createRigidStatic(transform)); + + if (!actor) return record; + + record.actor = actor; + record.isDynamic = wantsDynamic; + record.isKinematic = wantsDynamic && obj.rigidbody.isKinematic; + + bool attached = false; + // Keep actor facing initial yaw (ignore pitch/roll) + if (PxRigidDynamic* dyn = actor->is()) { + PxTransform pose = dyn->getGlobalPose(); + pose.q = PxQuat(static_cast(glm::radians(obj.rotation.y)), PxVec3(0, 1, 0)); + dyn->setGlobalPose(pose); + } else { + PxTransform pose = actor->getGlobalPose(); + pose.q = PxQuat(static_cast(glm::radians(obj.rotation.y)), PxVec3(0, 1, 0)); + actor->setGlobalPose(pose); + } + if (wantsCollider) { + attached = attachColliderShape(actor, obj, wantsDynamic); + } + if (!attached) { + attached = attachPrimitiveShape(actor, obj, wantsDynamic); + } + + if (!attached) { + actor->release(); + record.actor = nullptr; + return record; + } + + if (PxRigidDynamic* dyn = actor->is()) { + dyn->setAngularDamping(obj.rigidbody.angularDamping); + dyn->setLinearDamping(obj.rigidbody.linearDamping); + dyn->setRigidBodyFlag(PxRigidBodyFlag::eKINEMATIC, obj.rigidbody.isKinematic); + dyn->setActorFlag(PxActorFlag::eDISABLE_GRAVITY, !obj.rigidbody.useGravity); + dyn->setRigidDynamicLockFlags(PxRigidDynamicLockFlag::eLOCK_ANGULAR_X | PxRigidDynamicLockFlag::eLOCK_ANGULAR_Z); + if (obj.hasPlayerController) { + dyn->setRigidBodyFlag(PxRigidBodyFlag::eENABLE_CCD, true); + dyn->setMaxDepenetrationVelocity(1.5f); + } + if (!obj.rigidbody.isKinematic) { + PxRigidBodyExt::updateMassAndInertia(*dyn, std::max(0.01f, obj.rigidbody.mass)); + } + } + + return record; +} + +void PhysicsSystem::clearActors() { + for (auto& [id, rec] : mActors) { + if (rec.actor && mScene) { + mScene->removeActor(*rec.actor); + rec.actor->release(); + } + } + mActors.clear(); + + if (mGroundPlane && mScene) { + mScene->removeActor(*mGroundPlane); + mGroundPlane->release(); + mGroundPlane = nullptr; + } +} + +void PhysicsSystem::onPlayStart(const std::vector& objects) { + if (!isReady()) return; + + clearActors(); + createGroundPlane(); + + for (const auto& obj : objects) { + ActorRecord rec = createActorFor(obj); + if (!rec.actor) continue; + mScene->addActor(*rec.actor); + mActors[obj.id] = rec; + } +} + +void PhysicsSystem::onPlayStop() { + clearActors(); +} + +bool PhysicsSystem::setLinearVelocity(int id, const glm::vec3& velocity) { +#ifdef MODULARITY_ENABLE_PHYSX + auto it = mActors.find(id); + if (it == mActors.end()) return false; + ActorRecord& rec = it->second; + if (!rec.actor || !rec.isDynamic) return false; + if (PxRigidDynamic* dyn = rec.actor->is()) { + dyn->setLinearVelocity(ToPxVec3(velocity)); + return true; + } +#endif + return false; +} + +bool PhysicsSystem::setActorYaw(int id, float yawDegrees) { +#ifdef MODULARITY_ENABLE_PHYSX + auto it = mActors.find(id); + if (it == mActors.end()) return false; + ActorRecord& rec = it->second; + if (!rec.actor) return false; + PxTransform pose = rec.actor->getGlobalPose(); + PxQuat yawQuat(static_cast(glm::radians(yawDegrees)), PxVec3(0, 1, 0)); + pose.q = yawQuat; + rec.actor->setGlobalPose(pose); + if (PxRigidDynamic* dyn = rec.actor->is()) { + dyn->setRigidDynamicLockFlags(PxRigidDynamicLockFlag::eLOCK_ANGULAR_X | PxRigidDynamicLockFlag::eLOCK_ANGULAR_Z); + } + return true; +#endif + return false; +} + +bool PhysicsSystem::getLinearVelocity(int id, glm::vec3& outVelocity) const { +#ifdef MODULARITY_ENABLE_PHYSX + auto it = mActors.find(id); + if (it == mActors.end()) return false; + const ActorRecord& rec = it->second; + if (!rec.actor || !rec.isDynamic) return false; + if (const PxRigidDynamic* dyn = rec.actor->is()) { + PxVec3 v = dyn->getLinearVelocity(); + outVelocity = glm::vec3(v.x, v.y, v.z); + return true; + } +#endif + return false; +} + +bool PhysicsSystem::setActorPose(int id, const glm::vec3& position, const glm::vec3& rotationDeg) { +#ifdef MODULARITY_ENABLE_PHYSX + auto it = mActors.find(id); + if (it == mActors.end()) return false; + ActorRecord& rec = it->second; + if (!rec.actor) return false; + PxTransform pose(ToPxVec3(position), ToPxQuat(rotationDeg)); + rec.actor->setGlobalPose(pose); + return true; +#else + (void)id; (void)position; (void)rotationDeg; + return false; +#endif +} + +bool PhysicsSystem::raycastClosest(const glm::vec3& origin, const glm::vec3& dir, float distance, + int ignoreId, glm::vec3* hitPos, glm::vec3* hitNormal, float* hitDistance) const { +#ifdef MODULARITY_ENABLE_PHYSX + if (!isReady() || distance <= 0.0f) return false; + PxVec3 unitDir = ToPxVec3(glm::normalize(dir)); + if (!unitDir.isFinite()) return false; + + PxRaycastBuffer hit; + PxQueryFilterData fd(PxQueryFlag::eSTATIC | PxQueryFlag::eDYNAMIC | PxQueryFlag::ePREFILTER); + IgnoreActorFilter cb(nullptr); + + auto it = mActors.find(ignoreId); + if (it != mActors.end()) { + cb.ignore = it->second.actor; + } + + bool result = mScene->raycast(ToPxVec3(origin), unitDir, distance, hit, + PxHitFlag::ePOSITION | PxHitFlag::eNORMAL, + fd, cb.ignore ? &cb : nullptr); + if (!result || !hit.hasBlock) return false; + + if (hitPos) *hitPos = ToGlmVec3(hit.block.position); + if (hitNormal) *hitNormal = ToGlmVec3(hit.block.normal); + if (hitDistance) *hitDistance = hit.block.distance; + return true; +#else + (void)origin; (void)dir; (void)distance; (void)ignoreId; (void)hitPos; (void)hitNormal; (void)hitDistance; + return false; +#endif +} + +void PhysicsSystem::simulate(float deltaTime, std::vector& objects) { + if (!isReady() || deltaTime <= 0.0f) return; + + // Sync actors to authoring transforms before stepping + for (auto& [id, rec] : mActors) { + if (!rec.actor) continue; + auto it = std::find_if(objects.begin(), objects.end(), [id](const SceneObject& o) { return o.id == id; }); + if (it == objects.end()) continue; + if (PxRigidDynamic* dyn = rec.actor->is()) { + if (dyn->getRigidBodyFlags().isSet(PxRigidBodyFlag::eKINEMATIC)) { + dyn->setKinematicTarget(PxTransform(ToPxVec3(it->position), ToPxQuat(it->rotation))); + } + } else { + // Static actors follow their authoring transform so scripted moves/rotations take effect + rec.actor->setGlobalPose(PxTransform(ToPxVec3(it->position), ToPxQuat(it->rotation))); + } + } + + mScene->simulate(deltaTime); + mScene->fetchResults(true); + + for (auto& [id, rec] : mActors) { + if (!rec.actor || !rec.isDynamic || rec.isKinematic) continue; + PxTransform pose = rec.actor->getGlobalPose(); + auto it = std::find_if(objects.begin(), objects.end(), [id](const SceneObject& o) { return o.id == id; }); + if (it == objects.end()) continue; + + it->position = ToGlmVec3(pose.p); + it->rotation.y = ToGlmEulerDeg(pose.q).y; + } +} + +void PhysicsSystem::shutdown() { + clearActors(); + + if (mScene) { mScene->release(); mScene = nullptr; } + if (mDispatcher) { mDispatcher->release(); mDispatcher = nullptr; } + if (mPhysics) { mPhysics->release(); mPhysics = nullptr; } + if (mFoundation) { mFoundation->release(); mFoundation = nullptr; } + mDefaultMaterial = nullptr; +} + +#else // MODULARITY_ENABLE_PHYSX + +bool PhysicsSystem::init() { return false; } +void PhysicsSystem::shutdown() {} +bool PhysicsSystem::isReady() const { return false; } +bool PhysicsSystem::setLinearVelocity(int, const glm::vec3&) { return false; } +bool PhysicsSystem::setActorYaw(int, float) { return false; } +bool PhysicsSystem::getLinearVelocity(int, glm::vec3&) const { return false; } +void PhysicsSystem::onPlayStart(const std::vector&) {} +void PhysicsSystem::onPlayStop() {} +void PhysicsSystem::simulate(float, std::vector&) {} + +#endif diff --git a/src/PhysicsSystem.h b/src/PhysicsSystem.h new file mode 100644 index 0000000..1cb3ecd --- /dev/null +++ b/src/PhysicsSystem.h @@ -0,0 +1,60 @@ +#pragma once + +#include "Common.h" +#include "SceneObject.h" +#include +#include + +#ifdef MODULARITY_ENABLE_PHYSX +#include "PxPhysicsAPI.h" +#include "cooking/PxCooking.h" +#endif + +class PhysicsSystem { +public: + bool init(); + void shutdown(); + bool isReady() const; + bool setLinearVelocity(int id, const glm::vec3& velocity); + bool setActorYaw(int id, float yawDegrees); + bool getLinearVelocity(int id, glm::vec3& outVelocity) const; + bool setActorPose(int id, const glm::vec3& position, const glm::vec3& rotationDeg); + bool raycastClosest(const glm::vec3& origin, const glm::vec3& dir, float distance, + int ignoreId, glm::vec3* hitPos = nullptr, + glm::vec3* hitNormal = nullptr, float* hitDistance = nullptr) const; + + void onPlayStart(const std::vector& objects); + void onPlayStop(); + void simulate(float deltaTime, std::vector& objects); + +private: +#ifdef MODULARITY_ENABLE_PHYSX + struct ActorRecord { + physx::PxRigidActor* actor = nullptr; + bool isDynamic = false; + bool isKinematic = false; + }; + + physx::PxDefaultAllocator mAllocator; + physx::PxDefaultErrorCallback mErrorCallback; + physx::PxFoundation* mFoundation = nullptr; + physx::PxPhysics* mPhysics = nullptr; + physx::PxDefaultCpuDispatcher* mDispatcher = nullptr; + physx::PxScene* mScene = nullptr; + physx::PxMaterial* mDefaultMaterial = nullptr; + physx::PxRigidStatic* mGroundPlane = nullptr; + physx::PxCookingParams mCookParams{physx::PxTolerancesScale()}; + + std::unordered_map mActors; + + void clearActors(); + void createGroundPlane(); + ActorRecord createActorFor(const SceneObject& obj) const; + bool attachColliderShape(physx::PxRigidActor* actor, const SceneObject& obj, bool isDynamic) const; + bool attachPrimitiveShape(physx::PxRigidActor* actor, const SceneObject& obj, bool isDynamic) const; + bool gatherMeshData(const SceneObject& obj, std::vector& vertices, std::vector& indices) const; + physx::PxTriangleMesh* cookTriangleMesh(const std::vector& vertices, + const std::vector& indices) const; + physx::PxConvexMesh* cookConvexMesh(const std::vector& vertices) const; +#endif +}; diff --git a/src/ProjectManager.cpp b/src/ProjectManager.cpp index af6b0dc..e4d2e37 100644 --- a/src/ProjectManager.cpp +++ b/src/ProjectManager.cpp @@ -258,7 +258,7 @@ bool SceneSerializer::saveScene(const fs::path& filePath, if (!file.is_open()) return false; file << "# Scene File\n"; - file << "version=4\n"; + file << "version=7\n"; file << "nextId=" << nextId << "\n"; file << "objectCount=" << objects.size() << "\n"; file << "\n"; @@ -272,6 +272,31 @@ bool SceneSerializer::saveScene(const fs::path& filePath, file << "position=" << obj.position.x << "," << obj.position.y << "," << obj.position.z << "\n"; file << "rotation=" << obj.rotation.x << "," << obj.rotation.y << "," << obj.rotation.z << "\n"; file << "scale=" << obj.scale.x << "," << obj.scale.y << "," << obj.scale.z << "\n"; + file << "hasRigidbody=" << (obj.hasRigidbody ? 1 : 0) << "\n"; + if (obj.hasRigidbody) { + file << "rbEnabled=" << (obj.rigidbody.enabled ? 1 : 0) << "\n"; + file << "rbMass=" << obj.rigidbody.mass << "\n"; + file << "rbUseGravity=" << (obj.rigidbody.useGravity ? 1 : 0) << "\n"; + file << "rbKinematic=" << (obj.rigidbody.isKinematic ? 1 : 0) << "\n"; + file << "rbLinearDamping=" << obj.rigidbody.linearDamping << "\n"; + file << "rbAngularDamping=" << obj.rigidbody.angularDamping << "\n"; + } + file << "hasCollider=" << (obj.hasCollider ? 1 : 0) << "\n"; + if (obj.hasCollider) { + file << "colliderEnabled=" << (obj.collider.enabled ? 1 : 0) << "\n"; + file << "colliderType=" << static_cast(obj.collider.type) << "\n"; + file << "colliderBox=" << obj.collider.boxSize.x << "," << obj.collider.boxSize.y << "," << obj.collider.boxSize.z << "\n"; + file << "colliderConvex=" << (obj.collider.convex ? 1 : 0) << "\n"; + } + file << "hasPlayerController=" << (obj.hasPlayerController ? 1 : 0) << "\n"; + if (obj.hasPlayerController) { + file << "pcEnabled=" << (obj.playerController.enabled ? 1 : 0) << "\n"; + file << "pcMoveSpeed=" << obj.playerController.moveSpeed << "\n"; + file << "pcLookSensitivity=" << obj.playerController.lookSensitivity << "\n"; + file << "pcHeight=" << obj.playerController.height << "\n"; + file << "pcRadius=" << obj.playerController.radius << "\n"; + file << "pcJumpStrength=" << obj.playerController.jumpStrength << "\n"; + } file << "materialColor=" << obj.material.color.r << "," << obj.material.color.g << "," << obj.material.color.b << "\n"; file << "materialAmbient=" << obj.material.ambientStrength << "\n"; file << "materialSpecular=" << obj.material.specularStrength << "\n"; @@ -433,6 +458,47 @@ bool SceneSerializer::loadScene(const fs::path& filePath, ¤tObj->scale.x, ¤tObj->scale.y, ¤tObj->scale.z); + } else if (key == "hasRigidbody") { + currentObj->hasRigidbody = std::stoi(value) != 0; + } else if (key == "rbEnabled") { + currentObj->rigidbody.enabled = std::stoi(value) != 0; + } else if (key == "rbMass") { + currentObj->rigidbody.mass = std::stof(value); + } else if (key == "rbUseGravity") { + currentObj->rigidbody.useGravity = std::stoi(value) != 0; + } else if (key == "rbKinematic") { + currentObj->rigidbody.isKinematic = std::stoi(value) != 0; + } else if (key == "rbLinearDamping") { + currentObj->rigidbody.linearDamping = std::stof(value); + } else if (key == "rbAngularDamping") { + currentObj->rigidbody.angularDamping = std::stof(value); + } else if (key == "hasCollider") { + currentObj->hasCollider = std::stoi(value) != 0; + } else if (key == "colliderEnabled") { + currentObj->collider.enabled = std::stoi(value) != 0; + } else if (key == "colliderType") { + currentObj->collider.type = static_cast(std::stoi(value)); + } else if (key == "colliderBox") { + sscanf(value.c_str(), "%f,%f,%f", + ¤tObj->collider.boxSize.x, + ¤tObj->collider.boxSize.y, + ¤tObj->collider.boxSize.z); + } else if (key == "colliderConvex") { + currentObj->collider.convex = std::stoi(value) != 0; + } else if (key == "hasPlayerController") { + currentObj->hasPlayerController = std::stoi(value) != 0; + } else if (key == "pcEnabled") { + currentObj->playerController.enabled = std::stoi(value) != 0; + } else if (key == "pcMoveSpeed") { + currentObj->playerController.moveSpeed = std::stof(value); + } else if (key == "pcLookSensitivity") { + currentObj->playerController.lookSensitivity = std::stof(value); + } else if (key == "pcHeight") { + currentObj->playerController.height = std::stof(value); + } else if (key == "pcRadius") { + currentObj->playerController.radius = std::stof(value); + } else if (key == "pcJumpStrength") { + currentObj->playerController.jumpStrength = std::stof(value); } else if (key == "materialColor") { sscanf(value.c_str(), "%f,%f,%f", ¤tObj->material.color.r, diff --git a/src/SceneObject.h b/src/SceneObject.h index 9a3ac83..e11894e 100644 --- a/src/SceneObject.h +++ b/src/SceneObject.h @@ -98,6 +98,41 @@ struct ScriptComponent { std::vector activeIEnums; // function pointers registered via IEnum_Start }; +struct RigidbodyComponent { + bool enabled = true; + float mass = 1.0f; + bool useGravity = true; + bool isKinematic = false; + float linearDamping = 0.05f; + float angularDamping = 0.05f; +}; + +enum class ColliderType { + Box = 0, + Mesh = 1, + ConvexMesh = 2, + Capsule = 3 +}; + +struct ColliderComponent { + bool enabled = true; + ColliderType type = ColliderType::Box; + glm::vec3 boxSize = glm::vec3(1.0f); + bool convex = true; // For mesh colliders: true = convex hull, false = triangle mesh (static only) +}; + +struct PlayerControllerComponent { + bool enabled = true; + float moveSpeed = 6.0f; + float lookSensitivity = 0.12f; + float height = 1.8f; + float radius = 0.4f; + float jumpStrength = 6.5f; + float verticalVelocity = 0.0f; + float pitch = 0.0f; + float yaw = 0.0f; +}; + class SceneObject { public: std::string name; @@ -124,6 +159,12 @@ public: PostFXSettings postFx; // Only used when type is PostFXNode std::vector scripts; std::vector additionalMaterialPaths; + bool hasRigidbody = false; + RigidbodyComponent rigidbody; + bool hasCollider = false; + ColliderComponent collider; + bool hasPlayerController = false; + PlayerControllerComponent playerController; SceneObject(const std::string& name, ObjectType type, int id) : name(name), type(type), position(0.0f), rotation(0.0f), scale(1.0f), id(id) {} diff --git a/src/ScriptRuntime.cpp b/src/ScriptRuntime.cpp index 167d69d..9b6be50 100644 --- a/src/ScriptRuntime.cpp +++ b/src/ScriptRuntime.cpp @@ -39,6 +39,28 @@ void ScriptContext::SetScale(const glm::vec3& scl) { } } +bool ScriptContext::HasRigidbody() const { + return object && object->hasRigidbody && object->rigidbody.enabled; +} + +bool ScriptContext::SetRigidbodyVelocity(const glm::vec3& velocity) { + if (!engine || !object || !HasRigidbody()) return false; + return engine->setRigidbodyVelocityFromScript(object->id, velocity); +} + +bool ScriptContext::GetRigidbodyVelocity(glm::vec3& outVelocity) const { + if (!engine || !object || !HasRigidbody()) return false; + return engine->getRigidbodyVelocityFromScript(object->id, outVelocity); +} + +bool ScriptContext::TeleportRigidbody(const glm::vec3& pos, const glm::vec3& rotDeg) { + if (!engine || !object) return false; + object->position = pos; + object->rotation = NormalizeEulerDegrees(rotDeg); + MarkDirty(); + return engine->teleportPhysicsActorFromScript(object->id, pos, object->rotation); +} + std::string ScriptContext::GetSetting(const std::string& key, const std::string& fallback) const { if (!script) return fallback; auto it = std::find_if(script->settings.begin(), script->settings.end(), diff --git a/src/ScriptRuntime.h b/src/ScriptRuntime.h index 8d470cd..f0ae3e3 100644 --- a/src/ScriptRuntime.h +++ b/src/ScriptRuntime.h @@ -28,6 +28,10 @@ struct ScriptContext { void SetPosition(const glm::vec3& pos); void SetRotation(const glm::vec3& rot); void SetScale(const glm::vec3& scl); + bool HasRigidbody() const; + bool SetRigidbodyVelocity(const glm::vec3& velocity); + bool GetRigidbodyVelocity(glm::vec3& outVelocity) const; + bool TeleportRigidbody(const glm::vec3& pos, const glm::vec3& rotDeg); // Settings helpers (auto-mark dirty) std::string GetSetting(const std::string& key, const std::string& fallback = "") const; void SetSetting(const std::string& key, const std::string& value);