diff --git a/CMakeLists.txt b/CMakeLists.txt index bfda6c0..c6678be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,7 @@ option(SEQUA_WARNINGS_AS_ERRORS "Avertissements traités comme erreurs" ON) option(SEQUA_CLANG_TIDY "Lancer clang-tidy pendant la compilation" OFF) set(SEQUA_SANITIZERS "" CACHE STRING "Sanitizers à activer (liste : address;undefined)") option(SEQUA_BUILD_PROTOTYPES "Construire les prototypes jetables (prototypes/)" OFF) +option(SEQUA_BUILD_GODOT_RUNTIME "Héberger Godot dans l'adaptateur (godot-cpp ; Godot construit par scripts/build-godot.ps1)" OFF) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -32,7 +33,7 @@ endif() add_subdirectory(core) add_subdirectory(3d) -add_subdirectory(engines/godot/adapter) # adaptateur seul : Godot lui-même n'est pas compilé ici +add_subdirectory(engines/godot/adapter) # Godot lui-même n'est pas compilé ici (SCons, scripts/build-godot.ps1) if(SEQUA_BUILD_SERVER) add_subdirectory(studio/host) # dépend d'IXWebSocket et nlohmann/json endif() diff --git a/CMakePresets.json b/CMakePresets.json index 9cd145a..6d57998 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -41,7 +41,7 @@ "name": "windows-msvc-prototypes", "displayName": "Windows MSVC Release + prototypes", "inherits": "windows-msvc-release", - "cacheVariables": { "SEQUA_BUILD_PROTOTYPES": "ON" } + "cacheVariables": { "SEQUA_BUILD_PROTOTYPES": "ON", "SEQUA_BUILD_GODOT_RUNTIME": "ON" } }, { "name": "linux-base", diff --git a/engines/godot/adapter/CMakeLists.txt b/engines/godot/adapter/CMakeLists.txt index 40c3240..02ac34e 100644 --- a/engines/godot/adapter/CMakeLists.txt +++ b/engines/godot/adapter/CMakeLists.txt @@ -1,18 +1,73 @@ # SPDX-License-Identifier: MPL-2.0 # -# Adaptateur SEQUA 3D vers Godot (côté SEQUA). Ne compile pas encore Godot : le moteur -# (sous-module ../vendor/godot-src, tag 4.7.2-stable) se construit avec SCons en LibGodot ; -# godot-cpp (../vendor/godot-cpp) sera ajouté quand l'hébergement sera écrit (ADR 0007). +# Adaptateur SEQUA 3D vers Godot (côté SEQUA). Godot lui-même se construit avec SCons en +# LibGodot (scripts/build-godot.ps1) ; avec SEQUA_BUILD_GODOT_RUNTIME, godot-cpp +# (../vendor/godot-cpp) est compilé ici pour piloter Godot depuis l'adaptateur (ADR 0007). -add_library(sequa_godot_adapter src/godot_adapter.cpp) +add_library(sequa_godot_adapter src/godot_adapter.cpp src/conversions.cpp) add_library(sequa::godot_adapter ALIAS sequa_godot_adapter) target_include_directories(sequa_godot_adapter PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") target_link_libraries(sequa_godot_adapter PUBLIC sequa::3d) sequa_target_defaults(sequa_godot_adapter) +if(SEQUA_BUILD_GODOT_RUNTIME) + set(_godot_src "${PROJECT_SOURCE_DIR}/engines/godot/vendor/godot-src") + set(SEQUA_GODOT_LIBRARY "${_godot_src}/bin/godot.windows.template_debug.x86_64.dll" + CACHE FILEPATH "Godot construit en bibliothèque (LibGodot)") + if(NOT WIN32) + message(FATAL_ERROR "SEQUA_BUILD_GODOT_RUNTIME : Windows seulement pour l'instant") + endif() + if(NOT EXISTS "${SEQUA_GODOT_LIBRARY}") + message(FATAL_ERROR "Godot absent (${SEQUA_GODOT_LIBRARY}) : lancer scripts/build-godot.ps1") + endif() + + # Liste de l'API extraite du fork (scripts/build-godot.ps1 -Target editor, décision de + # l'étape 3). À défaut, celle de godot-cpp pour la 4.7 : identique tant que le fork + # n'ajoute rien à l'API. + set(_godot_api "${PROJECT_SOURCE_DIR}/build/godot/extension_api.json") + if(EXISTS "${_godot_api}") + set(GODOTCPP_CUSTOM_API_FILE "${_godot_api}" CACHE FILEPATH "" FORCE) + set(GODOTCPP_GDEXTENSION_DIR "${_godot_src}/core/extension" CACHE PATH "" FORCE) + message(STATUS "godot-cpp : API du fork (${_godot_api})") + else() + set(GODOTCPP_CUSTOM_API_FILE "" CACHE FILEPATH "" FORCE) + set(GODOTCPP_API_VERSION "4.7" CACHE STRING "" FORCE) + message(STATUS "godot-cpp : API officielle 4.7 (extraire celle du fork : scripts/build-godot.ps1 -Target editor)") + endif() + set(GODOTCPP_TARGET "template_debug" CACHE STRING "" FORCE) + # Même bibliothèque d'exécution que le reste de SEQUA (/MD, /MDd en Debug) : godot-cpp + # choisirait sinon /MT pour tout le projet. + set(GODOTCPP_USE_STATIC_CPP OFF CACHE BOOL "" FORCE) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL" CACHE STRING "" FORCE) + + add_subdirectory("${PROJECT_SOURCE_DIR}/engines/godot/vendor/godot-cpp" + "${CMAKE_CURRENT_BINARY_DIR}/godot-cpp" EXCLUDE_FROM_ALL SYSTEM) + # godot-cpp écrirait sinon ses .pdb dans /bin. + set_target_properties(godot-cpp PROPERTIES PDB_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/godot-cpp/bin") + + target_sources(sequa_godot_adapter PRIVATE src/godot_host.cpp src/godot_scene.cpp) + target_link_libraries(sequa_godot_adapter PRIVATE godot::cpp) + # PROVISOIRE : chemins fixés à la construction, le processus de rendu les fournira (ADR 0008). + target_compile_definitions(sequa_godot_adapter PRIVATE + SEQUA_GODOT_RUNTIME=1 + SEQUA_GODOT_DEFAULT_LIBRARY="${SEQUA_GODOT_LIBRARY}" + SEQUA_GODOT_DEFAULT_PROJECT="${CMAKE_CURRENT_SOURCE_DIR}/project") + + # Démonstration de l'étape 3 : scène SIMULÉE poussée depuis SEQUA vers Godot. + add_executable(sequa_godot_demo demo/main.cpp) + target_link_libraries(sequa_godot_demo PRIVATE sequa::godot_adapter) + sequa_target_defaults(sequa_godot_demo) + if(SEQUA_BUILD_TESTS) + add_test(NAME sequa_godot_demo COMMAND sequa_godot_demo --frames 300) + set_tests_properties(sequa_godot_demo PROPERTIES LABELS "gpu;godot" TIMEOUT 120) + endif() +else() + target_compile_definitions(sequa_godot_adapter PRIVATE SEQUA_GODOT_RUNTIME=0) +endif() + if(SEQUA_BUILD_TESTS) - add_executable(sequa_godot_adapter_tests tests/test_godot_adapter.cpp) + add_executable(sequa_godot_adapter_tests tests/test_godot_adapter.cpp tests/test_conversions.cpp) target_link_libraries(sequa_godot_adapter_tests PRIVATE sequa::godot_adapter doctest::doctest) sequa_target_defaults(sequa_godot_adapter_tests) doctest_discover_tests(sequa_godot_adapter_tests) diff --git a/engines/godot/adapter/README.md b/engines/godot/adapter/README.md index 018496d..33ba9a9 100644 --- a/engines/godot/adapter/README.md +++ b/engines/godot/adapter/README.md @@ -11,7 +11,36 @@ Implémentation de `sequa::three_d::I3DEngineAdapter` (voir [3d/](../../../3d)) ## État -**PROVISOIRE** : l'adaptateur déclare ses capacités (couleur, profondeur, normales, vecteurs de mouvement, à vérifier sur la 4.7.2), mais `start()` renvoie `not_implemented` : l'hébergement par LibGodot et la construction de Godot en bibliothèque restent à écrire. +**PROVISOIRE.** Avec `SEQUA_BUILD_GODOT_RUNTIME=ON` (preset `windows-msvc-prototypes`, Windows seulement), `start()` charge la DLL de Godot construite par `scripts/build-godot.ps1`, démarre le projet `project/` et `apply()` crée les objets sous le nœud `SequaScene` ; `render_frame()` fait avancer Godot d'une image. Aucun tampon n'est encore lu (couleur affichée seulement) ; lumière et environnement sont fixes dans `project/main.tscn` en attendant l'éclairage du contrat. Sans l'option, `start()` renvoie `not_implemented` et l'adaptateur se construit sans Godot. + +| Fichier | Rôle | +|---|---| +| `src/godot_host.*` | LibGodot : chargement de la DLL, instance, godot-cpp en extension hôte, boucle | +| `src/godot_scene.*` | Nœuds Godot qui reflètent la scène SEQUA (deltas déjà validés par `SceneState`) | +| `include/sequa/godot/conversions.hpp` | Conversions pures (rotation, couleur sRGB, formes), testées sans moteur | + +### Décisions de l'étape 3 (2026-11-07) + +- **Frontière binaire reportée** (question ouverte de l'ADR 0008) : l'adaptateur reste une bibliothèque liée statiquement au programme qui l'utilise. Le passage en bibliothèque chargée, avec point d'entrée C versionné, se fera avec le processus de rendu (étape 4), en même temps que le choix du transport Core ↔ rendu. +- **API de godot-cpp extraite du fork** : `scripts/build-godot.ps1 -Target editor` construit l'éditeur du fork et écrit `build/godot/extension_api.json`, que CMake passe à godot-cpp (`GODOTCPP_CUSTOM_API_FILE`) ; l'interface GDExtension vient alors aussi du fork (`core/extension`). À défaut, la liste officielle 4.7 de godot-cpp est utilisée, identique tant que le fork ne touche pas à l'API. À relancer après chaque modification du fork qui change l'API. +- **Même bibliothèque d'exécution MSVC que SEQUA** (`/MD`, `/MDd`) : godot-cpp choisirait sinon `/MT` pour tout le projet. + +### Démonstration (scène SIMULÉE) + +`demo/main.cpp` pousse une petite scène (sol, boîtes, sphère, capsule, caméra) par deltas SEQUA 3D et fait tourner une boîte à chaque image ; la scène est écrite en dur, elle viendra de la simulation du Core à l'étape 4. + +```powershell +.\scripts\build-godot.ps1 # Godot en bibliothèque (une fois) +.\scripts\build-godot.ps1 -Target editor # API du fork pour godot-cpp (une fois, ~10 min) +.\scripts\build.ps1 -Preset windows-msvc-prototypes -BuildDir build\windows-msvc-prototypes +.\build\windows-msvc-prototypes\engines\godot\adapter\sequa_godot_demo.exe [--frames N] +``` + +Le test `sequa_godot_demo` (étiquettes `gpu;godot`) la lance pour 300 images. Résultat du 2026-11-07 (RTX 4090 Laptop) : `render_frame()` médiane 5,5 ms, p95 6,1 ms, synchronisation verticale comprise ; arrêt propre. + +### Constat : ordre de déchargement de Godot + +godot-cpp garde des tables globales (`ClassDB::instance_binding_callbacks`…) qui libèrent leur mémoire par l'allocateur de Godot à la fin du processus. Décharger la DLL de Godot dans `stop()` fait planter la sortie (`0xC0000005`), et la laisser à Windows aussi. `src/godot_host.cpp` détruit l'instance dans `stop()` mais ne décharge la DLL qu'à la toute fin, depuis un objet global construit avant ceux de godot-cpp (`#pragma init_seg(lib)` sous MSVC, `init_priority` ailleurs), donc détruit après eux. ### À prévoir diff --git a/engines/godot/adapter/demo/main.cpp b/engines/godot/adapter/demo/main.cpp new file mode 100644 index 0000000..aac4b54 --- /dev/null +++ b/engines/godot/adapter/demo/main.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Démonstration de l'étape 3 : une scène décrite par SEQUA (deltas SEQUA 3D) s'affiche +// dans Godot hébergé par l'adaptateur. SIMULÉ : la scène et son animation sont écrites +// ici en dur ; à l'étape 4 elles viendront de la simulation du Core. + +#include "sequa/godot/godot_adapter.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace sequa; +using three_d::Color; +using three_d::CreateObject; +using three_d::Primitive; +using three_d::Quat; +using three_d::SceneDelta; +using three_d::Transform; +using three_d::Vec3; +using three_d::Visual; + +namespace { + +constexpr three_d::ObjectId spinning_box = 2; + +CreateObject object(three_d::ObjectId id, Primitive primitive, Vec3 position, Vec3 size, Color color) +{ + return CreateObject{id, Transform{position, Quat{}, Vec3{1, 1, 1}}, Visual{primitive, size, color, 0}}; +} + +SceneDelta initial_scene() +{ + SceneDelta delta{1, {}}; + delta.commands.emplace_back(object(1, Primitive::plane, {0, 0, 0}, {20, 1, 20}, {0.25f, 0.35f, 0.2f, 1})); + delta.commands.emplace_back(object(spinning_box, Primitive::box, {0, 0.75f, 0}, {1, 1, 1}, {0.8f, 0.3f, 0.1f, 1})); + delta.commands.emplace_back(object(3, Primitive::sphere, {-2, 0.6f, 0.5f}, {1.2f, 1.2f, 1.2f}, {0.1f, 0.3f, 0.8f, 1})); + delta.commands.emplace_back(object(4, Primitive::capsule, {2, 1, 0.5f}, {0.8f, 2, 0.8f}, {0.7f, 0.7f, 0.2f, 1})); + delta.commands.emplace_back(object(5, Primitive::box, {0, 0.5f, -3}, {4, 1, 0.5f}, {0.5f, 0.5f, 0.5f, 0.6f})); + + // Caméra à (0, 2.5, 6), inclinée de 20° vers le bas (rotation autour de X). + const float half = -20.0f * 3.14159265f / 360.0f; + three_d::Camera camera; + camera.transform = Transform{{0, 2.5f, 6}, Quat{std::sin(half), 0, 0, std::cos(half)}, {1, 1, 1}}; + delta.commands.emplace_back(three_d::SetCamera{camera}); + return delta; +} + +// Rotation de la boîte autour de Y à l'image donnée (un tour toutes les 4 s à 60 images/s). +SceneDelta spin(std::uint64_t version, std::uint64_t frame) +{ + const float half = static_cast(frame % 240) * (3.14159265f / 240.0f); + const Transform transform{{0, 0.75f, 0}, Quat{0, std::sin(half), 0, std::cos(half)}, {1, 1, 1}}; + return SceneDelta{version, {three_d::SetTransform{spinning_box, transform}}}; +} + +} // namespace + +int main(int argc, char** argv) +{ + long frames = 0; // 0 : jusqu'à la fermeture de la fenêtre + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--frames" && i + 1 < argc) { + frames = std::strtol(argv[++i], nullptr, 10); + } else { + std::printf("usage : sequa_godot_demo [--frames N]\n"); + return arg == "--help" ? 0 : 2; + } + } + + godot::GodotEngineAdapter adapter; + three_d::EngineConfig config; + config.window_title = "SEQUA - démonstration adaptateur Godot (scène SIMULÉE)"; + if (auto error = adapter.start(config)) { + std::fprintf(stderr, "start : %s\n", error->message.c_str()); + return 1; + } + if (auto error = adapter.apply(initial_scene())) { + std::fprintf(stderr, "scène initiale : %s\n", error->message.c_str()); + return 1; + } + + std::vector cpu_ms; + std::uint64_t version = 1; + for (std::uint64_t frame = 0; frames == 0 || frame < static_cast(frames); ++frame) { + if (auto error = adapter.apply(spin(++version, frame))) { + std::fprintf(stderr, "delta %llu : %s\n", static_cast(version), error->message.c_str()); + return 1; + } + three_d::FrameRequest request; + request.frame_index = frame; + request.world_version = version; + request.width = config.width; + request.height = config.height; + const three_d::FrameOutcome outcome = adapter.render_frame(request); + if (const auto* error = std::get_if(&outcome)) { + std::printf("arrêt après %llu images : %s\n", static_cast(frame), + error->message.c_str()); + break; + } + const auto& result = std::get(outcome); + if (result.world_version != version) { + std::fprintf(stderr, "version de scène %llu au lieu de %llu\n", + static_cast(result.world_version), + static_cast(version)); + return 1; + } + cpu_ms.push_back(result.measurements.cpu_ms); + } + + if (!cpu_ms.empty()) { + std::sort(cpu_ms.begin(), cpu_ms.end()); + std::printf("%zu images, render_frame() médiane %.2f ms, p95 %.2f ms (attente de la synchro verticale incluse)\n", + cpu_ms.size(), cpu_ms[cpu_ms.size() / 2], cpu_ms[cpu_ms.size() * 95 / 100]); + } + adapter.stop(); + std::printf("arrêt propre\n"); + return 0; +} diff --git a/engines/godot/adapter/include/sequa/godot/conversions.hpp b/engines/godot/adapter/include/sequa/godot/conversions.hpp new file mode 100644 index 0000000..a60e40c --- /dev/null +++ b/engines/godot/adapter/include/sequa/godot/conversions.hpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include "sequa/3d/scene.hpp" +#include "sequa/3d/types.hpp" + +namespace sequa::godot::convert { + +// Conversions pures SEQUA 3D -> réglages Godot. Aucune dépendance à godot-cpp : +// testables sans le moteur. Les entrées peuvent être corrompues (NaN, inf, tailles +// nulles ou négatives) : les fonctions les rendent toujours sûres. + +// Colonnes de la matrice 3x3 : axes locaux X, Y, Z exprimés dans le repère parent. +struct Basis { + three_d::Vec3 x; + three_d::Vec3 y; + three_d::Vec3 z; +}; + +// Normalise q. Si une composante n'est pas finie, ou si la norme au carré est quasi +// nulle, renvoie l'identité (Quat{} : 0, 0, 0, 1). +[[nodiscard]] three_d::Quat normalized_rotation(const three_d::Quat& q) noexcept; + +// Équivalent de Godot Basis(Quaternion(q)).scaled_local(scale) : matrice de rotation du +// quaternion (convention main droite) avec chaque colonne multipliée par l'échelle +// correspondante. +[[nodiscard]] Basis basis_from(const three_d::Quat& rotation, const three_d::Vec3& scale) noexcept; + +// StandardMaterial3D interprète la couleur albedo en sRGB alors que SEQUA la transmet en +// linéaire. +[[nodiscard]] float linear_to_srgb(float c) noexcept; +[[nodiscard]] three_d::Color to_srgb(const three_d::Color& linear) noexcept; + +// Plancher imposé à chaque dimension pour éviter les volumes nuls côté Godot. +inline constexpr float min_dimension = 0.001f; + +struct MeshParams { + three_d::Primitive primitive = three_d::Primitive::box; + three_d::Vec3 box_size{1, 1, 1}; // box + float radius = 0.5f; // sphere, capsule + float height = 1.0f; // sphere, capsule + float plane_width = 1.0f; // plane (axe X) + float plane_depth = 1.0f; // plane (axe Z) +}; + +// Réglages des formes Godot (BoxMesh, SphereMesh, CapsuleMesh, PlaneMesh) : seuls les +// champs utiles à la primitive sont remplis, les autres gardent leur valeur par défaut. +[[nodiscard]] MeshParams mesh_params(const three_d::Visual& visual) noexcept; + +} // namespace sequa::godot::convert diff --git a/engines/godot/adapter/include/sequa/godot/godot_adapter.hpp b/engines/godot/adapter/include/sequa/godot/godot_adapter.hpp index 88324d7..6fec9ef 100644 --- a/engines/godot/adapter/include/sequa/godot/godot_adapter.hpp +++ b/engines/godot/adapter/include/sequa/godot/godot_adapter.hpp @@ -3,26 +3,34 @@ #include "sequa/3d/adapter.hpp" +#include + namespace sequa::godot { // Version de Godot visée (sous-module engines/godot/vendor/godot-src). inline constexpr const char* engine_version = "4.7.2-stable"; -// Capacités déclarées pour Godot en rendu Forward+ (docs §7.2). À VÉRIFIER sur la 4.7 : +// Capacités déclarées pour Godot en rendu Forward+ (docs §7.2, constats de l'ADR 0007) : // couleur, profondeur, normales et vecteurs de mouvement existent dans Forward+ ; albedo, // identifiants d'objets et masque sémantique demandent des passes supplémentaires et ne // sont pas déclarés tant qu'elles n'existent pas. [[nodiscard]] three_d::EngineCapabilities godot_capabilities(); -// Adaptateur SEQUA 3D vers Godot. Seul ce fichier et l'agent côté moteur connaissent -// Godot ; le Core et Studio ne voient que three_d::I3DEngineAdapter. +// Vrai si l'adaptateur a été construit avec l'hébergement de Godot (SEQUA_BUILD_GODOT_RUNTIME). +[[nodiscard]] bool runtime_available() noexcept; + +// Adaptateur SEQUA 3D vers Godot. Seul ce module connaît Godot ; le Core et Studio ne +// voient que three_d::I3DEngineAdapter. // -// Godot est hébergé par LibGodot (ADR 0007) : l'adaptateur appellera -// GodotInstance::iteration() dans render_frame(). PROVISOIRE : tant que l'hébergement -// n'est pas écrit, start() renvoie not_implemented et l'adaptateur ne fait que déclarer -// ses capacités. +// Godot est hébergé par LibGodot (ADR 0007) : start() charge et démarre le moteur, +// render_frame() appelle GodotInstance::iteration(). Construit sans +// SEQUA_BUILD_GODOT_RUNTIME, start() renvoie not_implemented. +// PROVISOIRE : render_frame() ne lit encore aucun tampon (étape 5). class GodotEngineAdapter final : public three_d::I3DEngineAdapter { public: + GodotEngineAdapter(); + ~GodotEngineAdapter() override; + [[nodiscard]] three_d::EngineCapabilities capabilities() const override { return godot_capabilities(); } [[nodiscard]] std::optional start(const three_d::EngineConfig& config) override; @@ -33,7 +41,10 @@ public: [[nodiscard]] three_d::FrameOutcome render_frame(const three_d::FrameRequest& request) override; private: + struct Runtime; // moteur hébergé et nœuds de la scène (vide sans SEQUA_BUILD_GODOT_RUNTIME) + three_d::SceneState scene_; + std::unique_ptr runtime_; bool running_ = false; }; diff --git a/engines/godot/adapter/project/.gitignore b/engines/godot/adapter/project/.gitignore new file mode 100644 index 0000000..89b6240 --- /dev/null +++ b/engines/godot/adapter/project/.gitignore @@ -0,0 +1,2 @@ +# Cache créé par Godot à l'exécution +.godot/ diff --git a/engines/godot/adapter/project/main.tscn b/engines/godot/adapter/project/main.tscn new file mode 100644 index 0000000..3c11a60 --- /dev/null +++ b/engines/godot/adapter/project/main.tscn @@ -0,0 +1,22 @@ +[gd_scene load_steps=4 format=3] + +[sub_resource type="ProceduralSkyMaterial" id="Sky_1"] + +[sub_resource type="Sky" id="Sky_2"] +sky_material = SubResource("Sky_1") + +[sub_resource type="Environment" id="Environment_1"] +background_mode = 2 +sky = SubResource("Sky_2") +ambient_light_source = 3 + +[node name="Main" type="Node3D"] + +[node name="Environment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_1") + +[node name="Sun" type="DirectionalLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.707, 0.707, 0, -0.707, 0.707, 0, 10, 0) +shadow_enabled = true + +[node name="SequaScene" type="Node3D" parent="."] diff --git a/engines/godot/adapter/project/project.godot b/engines/godot/adapter/project/project.godot new file mode 100644 index 0000000..453d921 --- /dev/null +++ b/engines/godot/adapter/project/project.godot @@ -0,0 +1,14 @@ +; Projet Godot chargé par l'adaptateur SEQUA (src/godot_host.cpp). Godot n'y sert que de +; moteur d'exécution : la scène vient de SEQUA, sous le nœud SequaScene de main.tscn. + +config_version=5 + +[application] + +config/name="SEQUA" +run/main_scene="res://main.tscn" + +[rendering] + +renderer/rendering_method="forward_plus" +rendering_device/driver.windows="vulkan" diff --git a/engines/godot/adapter/src/conversions.cpp b/engines/godot/adapter/src/conversions.cpp new file mode 100644 index 0000000..3ab77ff --- /dev/null +++ b/engines/godot/adapter/src/conversions.cpp @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/godot/conversions.hpp" + +#include +#include + +namespace sequa::godot::convert { +namespace { + +// Valeur absolue, non finie -> min_dimension, puis plancher à min_dimension. +[[nodiscard]] float safe_dimension(float value) noexcept +{ + if (!std::isfinite(value)) { + return min_dimension; + } + return std::max(std::abs(value), min_dimension); +} + +[[nodiscard]] three_d::Vec3 safe_size(const three_d::Vec3& size) noexcept +{ + return {safe_dimension(size.x), safe_dimension(size.y), safe_dimension(size.z)}; +} + +} // namespace + +three_d::Quat normalized_rotation(const three_d::Quat& q) noexcept +{ + if (!std::isfinite(q.x) || !std::isfinite(q.y) || !std::isfinite(q.z) || !std::isfinite(q.w)) { + return three_d::Quat{}; + } + const float norm_squared = q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w; + // Norme carrée infinie : possible pour des composantes finies mais énormes (float). + if (norm_squared < 1e-12f || !std::isfinite(norm_squared)) { + return three_d::Quat{}; + } + const float inverse_norm = 1.0f / std::sqrt(norm_squared); + return {q.x * inverse_norm, q.y * inverse_norm, q.z * inverse_norm, q.w * inverse_norm}; +} + +Basis basis_from(const three_d::Quat& rotation, const three_d::Vec3& scale) noexcept +{ + const three_d::Quat q = normalized_rotation(rotation); + const float xx = q.x * q.x; + const float yy = q.y * q.y; + const float zz = q.z * q.z; + const float xy = q.x * q.y; + const float xz = q.x * q.z; + const float yz = q.y * q.z; + const float wx = q.w * q.x; + const float wy = q.w * q.y; + const float wz = q.w * q.z; + + Basis basis; + basis.x.x = (1.0f - 2.0f * (yy + zz)) * scale.x; + basis.x.y = 2.0f * (xy + wz) * scale.x; + basis.x.z = 2.0f * (xz - wy) * scale.x; + basis.y.x = 2.0f * (xy - wz) * scale.y; + basis.y.y = (1.0f - 2.0f * (xx + zz)) * scale.y; + basis.y.z = 2.0f * (yz + wx) * scale.y; + basis.z.x = 2.0f * (xz + wy) * scale.z; + basis.z.y = 2.0f * (yz - wx) * scale.z; + basis.z.z = (1.0f - 2.0f * (xx + yy)) * scale.z; + return basis; +} + +float linear_to_srgb(float c) noexcept +{ + if (!std::isfinite(c)) { + return 0.0f; + } + const float clamped = std::clamp(c, 0.0f, 1.0f); + return clamped <= 0.0031308f ? 12.92f * clamped + : 1.055f * std::pow(clamped, 1.0f / 2.4f) - 0.055f; +} + +three_d::Color to_srgb(const three_d::Color& linear) noexcept +{ + three_d::Color result; + result.r = linear_to_srgb(linear.r); + result.g = linear_to_srgb(linear.g); + result.b = linear_to_srgb(linear.b); + // L'alpha ne subit pas le gamma : borné seulement, non fini -> 1 (opaque). + result.a = std::isfinite(linear.a) ? std::clamp(linear.a, 0.0f, 1.0f) : 1.0f; + return result; +} + +MeshParams mesh_params(const three_d::Visual& visual) noexcept +{ + MeshParams params; + params.primitive = visual.primitive; + const three_d::Vec3 size = safe_size(visual.size); + + switch (visual.primitive) { + case three_d::Primitive::box: + params.box_size = size; + break; + case three_d::Primitive::sphere: + params.radius = size.x * 0.5f; + params.height = size.y; + break; + case three_d::Primitive::capsule: + params.radius = size.x * 0.5f; + // Godot impose height >= 2*radius. + params.height = std::max(size.y, 2.0f * params.radius); + break; + case three_d::Primitive::plane: + params.plane_width = size.x; + params.plane_depth = size.z; + break; + } + return params; +} + +} // namespace sequa::godot::convert diff --git a/engines/godot/adapter/src/godot_adapter.cpp b/engines/godot/adapter/src/godot_adapter.cpp index 9f8f817..9175794 100644 --- a/engines/godot/adapter/src/godot_adapter.cpp +++ b/engines/godot/adapter/src/godot_adapter.cpp @@ -1,14 +1,30 @@ // SPDX-License-Identifier: MPL-2.0 #include "sequa/godot/godot_adapter.hpp" +#include #include +#if SEQUA_GODOT_RUNTIME +#include "godot_host.hpp" +#include "godot_scene.hpp" +#endif + namespace sequa::godot { using three_d::BufferSet; using three_d::Error; using three_d::FrameBuffer; +#if SEQUA_GODOT_RUNTIME +struct GodotEngineAdapter::Runtime { + // Ordre de destruction : les nœuds de la scène avant le moteur. + std::unique_ptr host; + std::unique_ptr scene; +}; +#else +struct GodotEngineAdapter::Runtime {}; +#endif + three_d::EngineCapabilities godot_capabilities() { three_d::EngineCapabilities caps; @@ -17,22 +33,49 @@ three_d::EngineCapabilities godot_capabilities() caps.buffers = BufferSet{FrameBuffer::color, FrameBuffer::depth, FrameBuffer::normals, FrameBuffer::motion_vectors}; caps.windowed = true; - caps.headless = false; // --headless de Godot coupe le rendu : hors écran à étudier + caps.headless = false; // --headless de Godot coupe le rendu : vrai headless à faire dans le fork (ADR 0007) return caps; } -std::optional GodotEngineAdapter::start(const three_d::EngineConfig& /*config*/) +bool runtime_available() noexcept +{ + return SEQUA_GODOT_RUNTIME != 0; +} + +GodotEngineAdapter::GodotEngineAdapter() = default; + +GodotEngineAdapter::~GodotEngineAdapter() +{ + stop(); +} + +std::optional GodotEngineAdapter::start(const three_d::EngineConfig& config) { if (running_) { return Error{Error::Code::already_started, "adaptateur Godot déjà démarré"}; } - return Error{Error::Code::not_implemented, - "hébergement de Godot par LibGodot pas encore écrit (ADR 0007)"}; +#if SEQUA_GODOT_RUNTIME + GodotHost::StartOutcome outcome = GodotHost::start(config); + if (auto* error = std::get_if(&outcome)) { + return std::move(*error); + } + auto runtime = std::make_unique(); + runtime->host = std::move(std::get>(outcome)); + runtime->scene = std::make_unique(runtime->host->scene_root()); + runtime_ = std::move(runtime); + scene_.clear(); + running_ = true; + return std::nullopt; +#else + (void)config; + return Error{Error::Code::not_implemented, "adaptateur construit sans SEQUA_BUILD_GODOT_RUNTIME"}; +#endif } void GodotEngineAdapter::stop() noexcept { running_ = false; + runtime_.reset(); scene_.clear(); } @@ -41,7 +84,14 @@ std::optional GodotEngineAdapter::apply(const three_d::SceneDelta& delta) if (!running_) { return Error{Error::Code::not_started, "adaptateur Godot arrêté"}; } - return scene_.apply(delta); + // Validation par le miroir d'abord (tout ou rien) : Godot ne reçoit que des deltas valides. + if (std::optional error = scene_.apply(delta)) { + return error; + } +#if SEQUA_GODOT_RUNTIME + runtime_->scene->apply(delta); +#endif + return std::nullopt; } three_d::FrameOutcome GodotEngineAdapter::render_frame(const three_d::FrameRequest& request) @@ -52,7 +102,27 @@ three_d::FrameOutcome GodotEngineAdapter::render_frame(const three_d::FrameReque if (std::optional error = three_d::validate_request(request, capabilities())) { return *std::move(error); } - return Error{Error::Code::not_implemented, "rendu Godot pas encore écrit"}; +#if SEQUA_GODOT_RUNTIME + const auto begin = std::chrono::steady_clock::now(); + const bool keep_running = runtime_->host->iterate(); + const double cpu_ms = std::chrono::duration(std::chrono::steady_clock::now() - begin).count(); + if (!keep_running) { + stop(); + return Error{Error::Code::engine_failure, "Godot s'est arrêté (fenêtre fermée)"}; + } + // PROVISOIRE : l'image n'est qu'affichée dans la fenêtre ; aucun tampon n'est encore lu + // (étape 5). cpu_ms inclut l'attente de la synchronisation verticale. + three_d::FrameResult result; + result.frame_index = request.frame_index; + result.world_version = scene_.world_version(); + if (request.buffers.contains(FrameBuffer::color)) { + result.produced = BufferSet{FrameBuffer::color}; + } + result.measurements.cpu_ms = cpu_ms; + return result; +#else + return Error{Error::Code::not_implemented, "adaptateur construit sans SEQUA_BUILD_GODOT_RUNTIME"}; +#endif } } // namespace sequa::godot diff --git a/engines/godot/adapter/src/godot_host.cpp b/engines/godot/adapter/src/godot_host.cpp new file mode 100644 index 0000000..cf5197b --- /dev/null +++ b/engines/godot/adapter/src/godot_host.cpp @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "godot_host.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX // déjà défini par godot-cpp +#define NOMINMAX +#endif +#include +#else +#include +#endif + +namespace sequa::godot { +namespace { + +using three_d::Error; + +using CreateInstanceFn = GDExtensionObjectPtr (*)(int, char*[], GDExtensionInitializationFunction); +using DestroyInstanceFn = void (*)(GDExtensionObjectPtr); + +// LibGodot : une instance par processus, jamais recréée. +bool g_started_once = false; + +void no_op_level(::godot::ModuleInitializationLevel /*level*/) {} + +// Fonction d'initialisation GDExtension passée à LibGodot : SEQUA est l'extension hôte, et +// godot-cpp reçoit par elle l'accès à l'API du moteur. +GDExtensionBool host_init(GDExtensionInterfaceGetProcAddress get_proc_address, GDExtensionClassLibraryPtr library, + GDExtensionInitialization* initialization) +{ + const ::godot::GDExtensionBinding::InitObject init(get_proc_address, library, initialization); + init.register_initializer(&no_op_level); + init.register_terminator(&no_op_level); + init.set_minimum_library_initialization_level(::godot::MODULE_INITIALIZATION_LEVEL_SCENE); + return init.init(); +} + +void* load_library(const char* path) +{ +#ifdef _WIN32 + return LoadLibraryA(path); +#else + return dlopen(path, RTLD_NOW | RTLD_LOCAL); +#endif +} + +void* load_symbol(void* library, const char* name) +{ +#ifdef _WIN32 + return reinterpret_cast(GetProcAddress(static_cast(library), name)); +#else + return dlsym(library, name); +#endif +} + +void unload_library(void* library) +{ +#ifdef _WIN32 + FreeLibrary(static_cast(library)); +#else + dlclose(library); +#endif +} + +// Décharge Godot après la destruction des objets globaux de godot-cpp, qui libèrent leur +// mémoire par l'allocateur de Godot : décharger avant fait planter la sortie du processus, +// et laisser Windows le faire aussi (constat de prototypes/libgodot_host). +struct LibraryKeeper { + void* library = nullptr; + LibraryKeeper() = default; + LibraryKeeper(const LibraryKeeper&) = delete; + LibraryKeeper& operator=(const LibraryKeeper&) = delete; + ~LibraryKeeper() + { + if (library != nullptr) { + unload_library(library); + } + } +}; + +#ifdef _MSC_VER +// Construit avant les objets globaux de godot-cpp (segment « user »), donc détruit après eux. +#pragma warning(disable : 4073) +#pragma init_seg(lib) +LibraryKeeper g_library_keeper; +#else +LibraryKeeper g_library_keeper __attribute__((init_priority(101))); +#endif + +::godot::Node3D* find_scene_root() +{ + auto* tree = ::godot::Object::cast_to<::godot::SceneTree>(::godot::Engine::get_singleton()->get_main_loop()); + if (tree == nullptr || tree->get_current_scene() == nullptr) { + return nullptr; + } + return ::godot::Object::cast_to<::godot::Node3D>( + tree->get_current_scene()->get_node_or_null(::godot::NodePath("SequaScene"))); +} + +} // namespace + +GodotHost::StartOutcome GodotHost::start(const three_d::EngineConfig& config) +{ + if (config.headless) { + // --headless de Godot coupe le rendu ; le vrai headless viendra du fork (ADR 0007). + return Error{Error::Code::unsupported, "Godot : pas de rendu sans fenêtre pour l'instant"}; + } + if (config.width == 0 || config.height == 0) { + return Error{Error::Code::invalid_argument, "Godot : taille de fenêtre nulle"}; + } + if (g_started_once) { + return Error{Error::Code::engine_failure, + "Godot ne redémarre pas dans le même processus (LibGodot) : relancer le processus de rendu"}; + } + + // PROVISOIRE : chemins fixés à la construction ; le processus de rendu les fournira (ADR 0008). + void* library = load_library(SEQUA_GODOT_DEFAULT_LIBRARY); + if (library == nullptr) { + return Error{Error::Code::engine_failure, + std::string("Godot : impossible de charger ") + SEQUA_GODOT_DEFAULT_LIBRARY}; + } + auto* create = reinterpret_cast(load_symbol(library, "libgodot_create_godot_instance")); + if (create == nullptr || load_symbol(library, "libgodot_destroy_godot_instance") == nullptr) { + unload_library(library); + return Error{Error::Code::engine_failure, "Godot : bibliothèque construite sans library_type=shared_library"}; + } + + std::vector args = {"sequa", + "--path", + SEQUA_GODOT_DEFAULT_PROJECT, + "--rendering-driver", + "vulkan", + "--resolution", + std::to_string(config.width) + "x" + std::to_string(config.height)}; + std::vector argv; + for (std::string& arg : args) { + argv.push_back(arg.data()); + } + argv.push_back(nullptr); + + g_started_once = true; + GDExtensionObjectPtr instance = create(static_cast(args.size()), argv.data(), &host_init); + if (instance == nullptr) { + unload_library(library); + return Error{Error::Code::engine_failure, "Godot : échec de libgodot_create_godot_instance"}; + } + + std::unique_ptr host(new GodotHost()); + host->library_ = library; + host->instance_ = instance; + host->godot_ = ::godot::Object::cast_to<::godot::GodotInstance>( + ::godot::internal::get_object_instance_binding(instance)); + if (host->godot_ == nullptr || !host->godot_->start()) { + return Error{Error::Code::engine_failure, "Godot : GodotInstance::start() a échoué"}; + } + + host->scene_root_ = find_scene_root(); + if (host->scene_root_ == nullptr) { + return Error{Error::Code::engine_failure, "Godot : nœud SequaScene introuvable dans project/main.tscn"}; + } + host->scene_root_->get_tree()->get_root()->set_title(::godot::String::utf8(config.window_title.c_str())); + return host; +} + +GodotHost::~GodotHost() +{ + if (instance_ != nullptr) { + auto* destroy = reinterpret_cast(load_symbol(library_, "libgodot_destroy_godot_instance")); + destroy(instance_); + } + g_library_keeper.library = library_; +} + +bool GodotHost::iterate() +{ + // iteration() renvoie true quand Godot veut s'arrêter. + return !godot_->iteration(); +} + +} // namespace sequa::godot diff --git a/engines/godot/adapter/src/godot_host.hpp b/engines/godot/adapter/src/godot_host.hpp new file mode 100644 index 0000000..e724dee --- /dev/null +++ b/engines/godot/adapter/src/godot_host.hpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include "sequa/3d/adapter.hpp" + +#include +#include + +namespace godot { +class GodotInstance; +class Node3D; +} // namespace godot + +namespace sequa::godot { + +// Godot hébergé par LibGodot (ADR 0007) : bibliothèque chargée à l'exécution, instance +// créée avec godot-cpp comme extension hôte, boucle pilotée image par image. +// Une seule instance par processus et pas de recréation après destruction (limite de +// LibGodot) : un second start() dans le même processus échoue. +class GodotHost { +public: + using StartOutcome = std::variant, three_d::Error>; + [[nodiscard]] static StartOutcome start(const three_d::EngineConfig& config); + + GodotHost(const GodotHost&) = delete; + GodotHost& operator=(const GodotHost&) = delete; + // Détruit l'instance puis décharge aussitôt la bibliothèque (voir prototypes/libgodot_host). + ~GodotHost(); + + // Une image de Godot. Renvoie false quand Godot demande l'arrêt (fenêtre fermée). + [[nodiscard]] bool iterate(); + + // Nœud sous lequel SEQUA crée sa scène (SequaScene dans project/main.tscn). + [[nodiscard]] ::godot::Node3D* scene_root() const noexcept { return scene_root_; } + +private: + GodotHost() = default; + + void* library_ = nullptr; + void* instance_ = nullptr; // GDExtensionObjectPtr + ::godot::GodotInstance* godot_ = nullptr; + ::godot::Node3D* scene_root_ = nullptr; +}; + +} // namespace sequa::godot diff --git a/engines/godot/adapter/src/godot_scene.cpp b/engines/godot/adapter/src/godot_scene.cpp new file mode 100644 index 0000000..b56ce4f --- /dev/null +++ b/engines/godot/adapter/src/godot_scene.cpp @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// GodotScene : crée et met à jour les nœuds Godot (godot-cpp) d'après les commandes +// de scène SEQUA. Le delta passe déjà par three_d::SceneState, qui garantit des ids +// valides et une application tout ou rien ; ici, un id inconnu est simplement ignoré. + +#include "godot_scene.hpp" + +#include "sequa/godot/conversions.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace sequa::godot { +namespace { + +// Repères identiques (mètres, main droite, Y vers le haut) : aucune conversion d'axes. + +[[nodiscard]] ::godot::Vector3 godot_vector3(const three_d::Vec3& v) noexcept +{ + return ::godot::Vector3(v.x, v.y, v.z); +} + +// Transform SEQUA -> Transform3D Godot. Le constructeur Basis(Vector3, Vector3, Vector3) +// prend les COLONNES (axes locaux exprimés dans le repère parent), ce que remplissent +// convert::Basis::x/y/z avec la rotation et l'échelle de la commande. +[[nodiscard]] ::godot::Transform3D godot_transform(const three_d::Transform& t) noexcept +{ + const convert::Basis axes = convert::basis_from(t.rotation, t.scale); + const ::godot::Basis basis(godot_vector3(axes.x), godot_vector3(axes.y), godot_vector3(axes.z)); + return ::godot::Transform3D(basis, godot_vector3(t.position)); +} + +// Maillage de la primitive, étalonné par convert::mesh_params : seuls les champs utiles +// à la primitive sont lus, les autres gardent le défaut des classes Godot. +[[nodiscard]] ::godot::Ref<::godot::Mesh> godot_mesh(const convert::MeshParams& params) +{ + switch (params.primitive) { + case three_d::Primitive::box: { + ::godot::Ref<::godot::BoxMesh> mesh; + mesh.instantiate(); + mesh->set_size(godot_vector3(params.box_size)); + return mesh; + } + case three_d::Primitive::sphere: { + ::godot::Ref<::godot::SphereMesh> mesh; + mesh.instantiate(); + mesh->set_radius(params.radius); + mesh->set_height(params.height); + return mesh; + } + case three_d::Primitive::capsule: { + ::godot::Ref<::godot::CapsuleMesh> mesh; + mesh.instantiate(); + mesh->set_radius(params.radius); + mesh->set_height(params.height); + return mesh; + } + case three_d::Primitive::plane: + break; // plan : grille horizontale (X largeur, Z profondeur), traitée ci-dessous + } + + ::godot::Ref<::godot::PlaneMesh> mesh; + mesh.instantiate(); + mesh->set_size(::godot::Vector2(params.plane_width, params.plane_depth)); + return mesh; +} + +// Matériau standard pour une couleur SEQUA : albedo converti en sRGB (StandardMaterial3D +// le lit en sRGB), transparence activée dès que l'alpha passe sous 1. +// semantic_class n'est pas traité ici, réservé au masque sémantique (§5.6). +[[nodiscard]] ::godot::Ref<::godot::StandardMaterial3D> godot_material( + const three_d::Color& color) +{ + ::godot::Ref<::godot::StandardMaterial3D> material; + material.instantiate(); + const three_d::Color srgb = convert::to_srgb(color); + material->set_albedo(::godot::Color(srgb.r, srgb.g, srgb.b, srgb.a)); + if (srgb.a < 1.0f) { + material->set_transparency(::godot::BaseMaterial3D::TRANSPARENCY_ALPHA); + } + return material; +} + +void create_object(const three_d::CreateObject& command, ::godot::Node3D* root, + std::map& objects) +{ + // Racine absente ou id déjà créé : delta non conforme, on ignore plutôt que de fuiter. + if (root == nullptr || objects.find(command.id) != objects.end()) { + return; + } + ::godot::MeshInstance3D* node = memnew(::godot::MeshInstance3D); + node->set_name(("sequa_" + std::to_string(command.id)).c_str()); + node->set_mesh(godot_mesh(convert::mesh_params(command.visual))); + node->set_material_override(godot_material(command.visual.color)); + node->set_transform(godot_transform(command.transform)); + root->add_child(node); + objects[command.id] = node; +} + +void destroy_object(const three_d::DestroyObject& command, ::godot::Node3D* root, + std::map& objects) +{ + const auto found = objects.find(command.id); + if (root == nullptr || found == objects.end()) { + return; // id inconnu : ignoré (delta déjà validé, ne devrait jamais arriver) + } + ::godot::MeshInstance3D* node = found->second; + objects.erase(found); + root->remove_child(node); + ::godot::memdelete(node); +} + +void change_transform(const three_d::SetTransform& command, + const std::map& objects) +{ + const auto found = objects.find(command.id); + if (found != objects.end()) { + found->second->set_transform(godot_transform(command.transform)); + } +} + +void change_visual(const three_d::SetVisual& command, + const std::map& objects) +{ + const auto found = objects.find(command.id); + if (found != objects.end()) { + found->second->set_mesh(godot_mesh(convert::mesh_params(command.visual))); + found->second->set_material_override(godot_material(command.visual.color)); + } +} + +// Caméra créée au premier SetCamera, retargetée ensuite ; set_current seulement après +// add_child, puisqu'un Node sortant ne peut pas être activé. +[[nodiscard]] ::godot::Camera3D* place_camera(const three_d::SetCamera& command, + ::godot::Node3D* root, + ::godot::Camera3D* camera) +{ + if (root == nullptr) { + return camera; // racine absente : ne change rien plutôt que de perdre la caméra + } + if (camera == nullptr) { + camera = memnew(::godot::Camera3D); + camera->set_name("sequa_camera"); + root->add_child(camera); + } + const three_d::Camera& settings = command.camera; + // Camera3D garde la hauteur par défaut (keep_aspect) : fov = valeur verticale. + camera->set_fov(settings.vertical_fov_deg); + camera->set_near(settings.near_plane); + camera->set_far(settings.far_plane); + // La caméra ne se déforme pas : échelle forcée à (1, 1, 1). + const three_d::Transform transform{settings.transform.position, settings.transform.rotation, + three_d::Vec3{1, 1, 1}}; + camera->set_transform(godot_transform(transform)); + camera->set_current(true); + return camera; +} + +} // namespace + +GodotScene::GodotScene(::godot::Node3D* root) noexcept : root_{root} {} + +GodotScene::~GodotScene() +{ + clear(); +} + +void GodotScene::apply(const three_d::SceneDelta& delta) +{ + for (const three_d::SceneCommand& command : delta.commands) { + std::visit( + [&](const auto& variant) { + using Command = std::decay_t; + if constexpr (std::is_same_v) { + create_object(variant, root_, objects_); + } + else if constexpr (std::is_same_v) { + destroy_object(variant, root_, objects_); + } + else if constexpr (std::is_same_v) { + change_transform(variant, objects_); + } + else if constexpr (std::is_same_v) { + change_visual(variant, objects_); + } + else { // SetCamera + camera_ = place_camera(variant, root_, camera_); + } + }, + command); + } +} + +void GodotScene::clear() noexcept +{ + for (const auto& object : objects_) { + root_->remove_child(object.second); + ::godot::memdelete(object.second); + } + objects_.clear(); + if (camera_ != nullptr) { + root_->remove_child(camera_); + ::godot::memdelete(camera_); + camera_ = nullptr; + } +} + +} // namespace sequa::godot diff --git a/engines/godot/adapter/src/godot_scene.hpp b/engines/godot/adapter/src/godot_scene.hpp new file mode 100644 index 0000000..29a3b04 --- /dev/null +++ b/engines/godot/adapter/src/godot_scene.hpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include "sequa/3d/scene.hpp" + +#include + +namespace godot { +class Camera3D; +class MeshInstance3D; +class Node3D; +} // namespace godot + +namespace sequa::godot { + +// Nœuds Godot qui reflètent la scène SEQUA, sous une racine fournie par l'hébergement. +// N'est utilisé que pendant que Godot tourne, depuis le fil qui appelle iteration(). +// Attention : dans ce namespace, « godot:: » désigne sequa::godot ; les classes du moteur +// s'écrivent « ::godot:: ». +class GodotScene { +public: + explicit GodotScene(::godot::Node3D* root) noexcept; + GodotScene(const GodotScene&) = delete; + GodotScene& operator=(const GodotScene&) = delete; + ~GodotScene(); + + // Le delta a déjà été validé par three_d::SceneState : il n'est pas revalidé ici. + void apply(const three_d::SceneDelta& delta); + + // Détache et détruit tous les nœuds créés (objets et caméra). + void clear() noexcept; + +private: + ::godot::Node3D* root_; + std::map objects_; + ::godot::Camera3D* camera_ = nullptr; +}; + +} // namespace sequa::godot diff --git a/engines/godot/adapter/tests/test_conversions.cpp b/engines/godot/adapter/tests/test_conversions.cpp new file mode 100644 index 0000000..70df81e --- /dev/null +++ b/engines/godot/adapter/tests/test_conversions.cpp @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/godot/conversions.hpp" + +#include + +#include + +namespace { + +using sequa::three_d::Color; +using sequa::three_d::Primitive; +using sequa::three_d::Quat; +using sequa::three_d::Vec3; +using sequa::three_d::Visual; +using namespace sequa::godot::convert; + +const float nan_value = std::numeric_limits::quiet_NaN(); +const float inf_value = std::numeric_limits::infinity(); +const float sin_45 = 0.7071067811865475f; + +} // namespace + +TEST_CASE("convert : normalized_rotation garde l'identite") +{ + CHECK(normalized_rotation(Quat{}) == Quat{}); + CHECK(normalized_rotation(Quat{0, 0, 0, 1}) == Quat{}); + CHECK(normalized_rotation(Quat{0, 0, 0, 2}) == Quat{}); +} + +TEST_CASE("convert : normalized_rotation cas invalides -> identite") +{ + CHECK(normalized_rotation(Quat{0, 0, 0, 0}) == Quat{}); + CHECK(normalized_rotation(Quat{nan_value, 0, 0, 1}) == Quat{}); + CHECK(normalized_rotation(Quat{0, inf_value, 0, 1}) == Quat{}); + CHECK(normalized_rotation(Quat{0, 0, 0, -inf_value}) == Quat{}); +} + +TEST_CASE("convert : normalized_rotation normalise les quaternions non unitaires") +{ + const Quat normalized = normalized_rotation(Quat{2, 0, 0, 0}); + CHECK(normalized.x == doctest::Approx(1.0f)); + CHECK(normalized.y == doctest::Approx(0.0f)); + CHECK(normalized.z == doctest::Approx(0.0f)); + CHECK(normalized.w == doctest::Approx(0.0f)); + + const Quat around_w = normalized_rotation(Quat{0, 0, 0, 3}); + CHECK(around_w == Quat{0, 0, 0, 1}); +} + +TEST_CASE("convert : basis_from identite et echelle pure") +{ + const Basis unit = basis_from(Quat{}, Vec3{1, 1, 1}); + CHECK(unit.x == Vec3{1, 0, 0}); + CHECK(unit.y == Vec3{0, 1, 0}); + CHECK(unit.z == Vec3{0, 0, 1}); + + const Basis scaled = basis_from(Quat{}, Vec3{2, 3, 4}); + CHECK(scaled.x == Vec3{2, 0, 0}); + CHECK(scaled.y == Vec3{0, 3, 0}); + CHECK(scaled.z == Vec3{0, 0, 4}); +} + +TEST_CASE("convert : basis_from rotation de 90 degres autour de Y") +{ + // q = (0, sin 45, 0, cos 45) ; colonnes attendues : X -> -Z, Z -> +X. + const Basis basis = basis_from(Quat{0, sin_45, 0, sin_45}, Vec3{1, 1, 1}); + CHECK(basis.x.x == doctest::Approx(0.0f)); + CHECK(basis.x.y == doctest::Approx(0.0f)); + CHECK(basis.x.z == doctest::Approx(-1.0f)); + CHECK(basis.y == Vec3{0, 1, 0}); + CHECK(basis.z.x == doctest::Approx(1.0f)); + CHECK(basis.z.y == doctest::Approx(0.0f)); + CHECK(basis.z.z == doctest::Approx(0.0f)); +} + +TEST_CASE("convert : basis_from echelle multiplie chaque colonne") +{ + const Basis basis = basis_from(Quat{0, sin_45, 0, sin_45}, Vec3{2, 3, 4}); + CHECK(basis.x.x == doctest::Approx(0.0f)); + CHECK(basis.x.y == doctest::Approx(0.0f)); + CHECK(basis.x.z == doctest::Approx(-2.0f)); + CHECK(basis.y == Vec3{0, 3, 0}); + CHECK(basis.z.x == doctest::Approx(4.0f)); + CHECK(basis.z.y == doctest::Approx(0.0f)); + CHECK(basis.z.z == doctest::Approx(0.0f)); +} + +TEST_CASE("convert : linear_to_srgb bornes et linearite basse") +{ + CHECK(linear_to_srgb(0.0f) == doctest::Approx(0.0f)); + CHECK(linear_to_srgb(1.0f) == doctest::Approx(1.0f)); + // Frontiere du segment lineaire : 12.92 * 0.0031308. + CHECK(linear_to_srgb(0.0031308f) == doctest::Approx(12.92f * 0.0031308f)); + CHECK(linear_to_srgb(0.002f) == doctest::Approx(0.002f * 12.92f)); + CHECK(linear_to_srgb(0.5f) == doctest::Approx(0.735357f)); +} + +TEST_CASE("convert : linear_to_srgb valeurs hors bornes et non finies") +{ + CHECK(linear_to_srgb(-0.5f) == doctest::Approx(0.0f)); + CHECK(linear_to_srgb(2.0f) == doctest::Approx(1.0f)); + CHECK(linear_to_srgb(nan_value) == doctest::Approx(0.0f)); + CHECK(linear_to_srgb(inf_value) == doctest::Approx(0.0f)); +} + +TEST_CASE("convert : to_srgb convertit rgb et conserve l'alpha") +{ + const Color result = to_srgb(Color{0.5f, 0.0f, 2.0f, 0.25f}); + CHECK(result.r == doctest::Approx(0.735357f)); + CHECK(result.g == doctest::Approx(0.0f)); + CHECK(result.b == doctest::Approx(1.0f)); + CHECK(result.a == doctest::Approx(0.25f)); +} + +TEST_CASE("convert : to_srgb alpha non fini -> opaque") +{ + CHECK(to_srgb(Color{1, 1, 1, nan_value}).a == doctest::Approx(1.0f)); + CHECK(to_srgb(Color{1, 1, 1, 3.0f}).a == doctest::Approx(1.0f)); + CHECK(to_srgb(Color{1, 1, 1, -1.0f}).a == doctest::Approx(0.0f)); + CHECK(to_srgb(Color{1, 1, 1, inf_value}).a == doctest::Approx(1.0f)); +} + +TEST_CASE("convert : mesh_params box") +{ + Visual visual; + visual.primitive = Primitive::box; + visual.size = Vec3{2, 3, 4}; + const MeshParams params = mesh_params(visual); + CHECK(params.primitive == Primitive::box); + CHECK(params.box_size == Vec3{2, 3, 4}); + // Les champs des autres primitives gardent leur valeur par defaut. + CHECK(params.radius == doctest::Approx(0.5f)); + CHECK(params.height == doctest::Approx(1.0f)); + CHECK(params.plane_width == doctest::Approx(1.0f)); + CHECK(params.plane_depth == doctest::Approx(1.0f)); + + // Visual par defaut (box 1x1x1). + const MeshParams default_params = mesh_params(Visual{}); + CHECK(default_params.box_size == Vec3{1, 1, 1}); +} + +TEST_CASE("convert : mesh_params sphere") +{ + Visual visual; + visual.primitive = Primitive::sphere; + visual.size = Vec3{2, 3, 4}; + const MeshParams params = mesh_params(visual); + CHECK(params.primitive == Primitive::sphere); + CHECK(params.radius == doctest::Approx(1.0f)); + CHECK(params.height == doctest::Approx(3.0f)); + CHECK(params.box_size == Vec3{1, 1, 1}); + CHECK(params.plane_width == doctest::Approx(1.0f)); + CHECK(params.plane_depth == doctest::Approx(1.0f)); +} + +TEST_CASE("convert : mesh_params plane") +{ + Visual visual; + visual.primitive = Primitive::plane; + visual.size = Vec3{5, 2, 7}; + const MeshParams params = mesh_params(visual); + CHECK(params.primitive == Primitive::plane); + CHECK(params.plane_width == doctest::Approx(5.0f)); + CHECK(params.plane_depth == doctest::Approx(7.0f)); + CHECK(params.box_size == Vec3{1, 1, 1}); + CHECK(params.radius == doctest::Approx(0.5f)); + CHECK(params.height == doctest::Approx(1.0f)); +} + +TEST_CASE("convert : mesh_params capsule trop courte -> height >= 2*radius") +{ + Visual visual; + visual.primitive = Primitive::capsule; + visual.size = Vec3{2, 1, 2}; + const MeshParams params = mesh_params(visual); + CHECK(params.primitive == Primitive::capsule); + CHECK(params.radius == doctest::Approx(1.0f)); + CHECK(params.height == doctest::Approx(2.0f)); +} + +TEST_CASE("convert : mesh_params capsule assez haute conserve height") +{ + Visual visual; + visual.primitive = Primitive::capsule; + visual.size = Vec3{2, 4, 2}; + const MeshParams params = mesh_params(visual); + CHECK(params.radius == doctest::Approx(1.0f)); + CHECK(params.height == doctest::Approx(4.0f)); +} + +TEST_CASE("convert : mesh_params securise les tailles negatives et nulles") +{ + Visual visual; + visual.primitive = Primitive::box; + visual.size = Vec3{-2, 0, inf_value}; + const MeshParams params = mesh_params(visual); + CHECK(params.box_size == Vec3{2, min_dimension, min_dimension}); + + Visual negative_sphere; + negative_sphere.primitive = Primitive::sphere; + negative_sphere.size = Vec3{-4, -6, 0}; + const MeshParams sphere = mesh_params(negative_sphere); + CHECK(sphere.radius == doctest::Approx(2.0f)); + CHECK(sphere.height == doctest::Approx(6.0f)); + + Visual tiny_capsule; + tiny_capsule.primitive = Primitive::capsule; + tiny_capsule.size = Vec3{0, 0, 0}; + const MeshParams capsule = mesh_params(tiny_capsule); + CHECK(capsule.radius == doctest::Approx(0.0005f)); + CHECK(capsule.height == doctest::Approx(min_dimension)); + + Visual negative_plane; + negative_plane.primitive = Primitive::plane; + negative_plane.size = Vec3{-5, 1, -7}; + const MeshParams plane = mesh_params(negative_plane); + CHECK(plane.plane_width == doctest::Approx(5.0f)); + CHECK(plane.plane_depth == doctest::Approx(7.0f)); +} + +TEST_CASE("convert : mesh_params securise les tailles non finies") +{ + Visual visual; + visual.primitive = Primitive::sphere; + visual.size = Vec3{nan_value, nan_value, nan_value}; + const MeshParams params = mesh_params(visual); + CHECK(params.radius == doctest::Approx(min_dimension * 0.5f)); + CHECK(params.height == doctest::Approx(min_dimension)); +} diff --git a/engines/godot/adapter/tests/test_godot_adapter.cpp b/engines/godot/adapter/tests/test_godot_adapter.cpp index b752722..428f6ab 100644 --- a/engines/godot/adapter/tests/test_godot_adapter.cpp +++ b/engines/godot/adapter/tests/test_godot_adapter.cpp @@ -18,8 +18,11 @@ TEST_CASE("GodotEngineAdapter : capacites declarees sans demarrer le moteur") CHECK_FALSE(caps.buffers.contains(three_d::FrameBuffer::object_ids)); } -TEST_CASE("GodotEngineAdapter : non demarre tant que l'agent n'existe pas") +TEST_CASE("GodotEngineAdapter : construit sans hebergement, start() non implemente") { + if (godot::runtime_available()) { + return; // démarrage réel : couvert par la démonstration (fenêtre Godot) + } godot::GodotEngineAdapter adapter; const auto error = adapter.start({}); REQUIRE(error.has_value()); @@ -27,3 +30,15 @@ TEST_CASE("GodotEngineAdapter : non demarre tant que l'agent n'existe pas") CHECK_FALSE(adapter.running()); CHECK(adapter.apply({1, {}})->code == three_d::Error::Code::not_started); } + +TEST_CASE("GodotEngineAdapter : rendu sans fenetre refuse") +{ + godot::GodotEngineAdapter adapter; + three_d::EngineConfig config; + config.headless = true; + const auto error = adapter.start(config); + REQUIRE(error.has_value()); + CHECK((error->code == three_d::Error::Code::unsupported || + error->code == three_d::Error::Code::not_implemented)); + CHECK_FALSE(adapter.running()); +}