feat(engines/godot): Godot hébergé par l'adaptateur, scène SEQUA poussée dans Godot

Option SEQUA_BUILD_GODOT_RUNTIME (preset windows-msvc-prototypes, Windows seulement) : godot-cpp compilé avec l'API du fork si elle a été extraite, même bibliothèque d'exécution MSVC que SEQUA. GodotHost charge la DLL LibGodot, initialise godot-cpp en extension hôte et pilote iteration() ; GodotScene crée, déplace et détruit les nœuds sous SequaScene à partir des deltas validés par SceneState ; conversions pures (rotation, couleur sRGB, formes) testées sans moteur. Démo sequa_godot_demo (scène SIMULÉE) : 300 images, arrêt propre. La DLL de Godot n'est déchargée qu'après les globaux de godot-cpp (init_seg), sinon la sortie du processus plante. Frontière binaire de l'ADR 0008 reportée à l'étape 4 (README de l'adaptateur).
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-07 09:19:53 +01:00
1 parent 9358bd404e
commit c6aa05680e
18 files changed
+1266 -22

No files matched your search

+2 -1
View File
@@ -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()
+1 -1
View File
@@ -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",
+60 -5
View File
@@ -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$<$<CONFIG:Debug>: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 <source>/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)
+30 -1
View File
@@ -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
+124
View File
@@ -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 <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <variant>
#include <vector>
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<float>(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<double> cpu_ms;
std::uint64_t version = 1;
for (std::uint64_t frame = 0; frames == 0 || frame < static_cast<std::uint64_t>(frames); ++frame) {
if (auto error = adapter.apply(spin(++version, frame))) {
std::fprintf(stderr, "delta %llu : %s\n", static_cast<unsigned long long>(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<three_d::Error>(&outcome)) {
std::printf("arrêt après %llu images : %s\n", static_cast<unsigned long long>(frame),
error->message.c_str());
break;
}
const auto& result = std::get<three_d::FrameResult>(outcome);
if (result.world_version != version) {
std::fprintf(stderr, "version de scène %llu au lieu de %llu\n",
static_cast<unsigned long long>(result.world_version),
static_cast<unsigned long long>(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;
}
@@ -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
@@ -3,26 +3,34 @@
#include "sequa/3d/adapter.hpp"
#include <memory>
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<three_d::Error> 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> runtime_;
bool running_ = false;
};
+2
View File
@@ -0,0 +1,2 @@
# Cache créé par Godot à l'exécution
.godot/
+22
View File
@@ -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="."]
@@ -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"
+114
View File
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/godot/conversions.hpp"
#include <algorithm>
#include <cmath>
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
+76 -6
View File
@@ -1,14 +1,30 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/godot/godot_adapter.hpp"
#include <chrono>
#include <utility>
#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<GodotHost> host;
std::unique_ptr<GodotScene> 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<Error> GodotEngineAdapter::start(const three_d::EngineConfig& /*config*/)
bool runtime_available() noexcept
{
return SEQUA_GODOT_RUNTIME != 0;
}
GodotEngineAdapter::GodotEngineAdapter() = default;
GodotEngineAdapter::~GodotEngineAdapter()
{
stop();
}
std::optional<Error> 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<Error>(&outcome)) {
return std::move(*error);
}
auto runtime = std::make_unique<Runtime>();
runtime->host = std::move(std::get<std::unique_ptr<GodotHost>>(outcome));
runtime->scene = std::make_unique<GodotScene>(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<Error> 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> 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> 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<double, std::milli>(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
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MPL-2.0
#include "godot_host.hpp"
#include <godot_cpp/classes/engine.hpp>
#include <godot_cpp/classes/godot_instance.hpp>
#include <godot_cpp/classes/main_loop.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/scene_tree.hpp>
#include <godot_cpp/classes/window.hpp>
#include <godot_cpp/core/object.hpp>
#include <godot_cpp/godot.hpp>
#include <string>
#include <vector>
#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 <windows.h>
#else
#include <dlfcn.h>
#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<void*>(GetProcAddress(static_cast<HMODULE>(library), name));
#else
return dlsym(library, name);
#endif
}
void unload_library(void* library)
{
#ifdef _WIN32
FreeLibrary(static_cast<HMODULE>(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<CreateInstanceFn>(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<std::string> args = {"sequa",
"--path",
SEQUA_GODOT_DEFAULT_PROJECT,
"--rendering-driver",
"vulkan",
"--resolution",
std::to_string(config.width) + "x" + std::to_string(config.height)};
std::vector<char*> argv;
for (std::string& arg : args) {
argv.push_back(arg.data());
}
argv.push_back(nullptr);
g_started_once = true;
GDExtensionObjectPtr instance = create(static_cast<int>(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<GodotHost> 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<DestroyInstanceFn>(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
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include "sequa/3d/adapter.hpp"
#include <memory>
#include <variant>
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<std::unique_ptr<GodotHost>, 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
+229
View File
@@ -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 <godot_cpp/classes/box_mesh.hpp>
#include <godot_cpp/classes/camera3d.hpp>
#include <godot_cpp/classes/capsule_mesh.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/plane_mesh.hpp>
#include <godot_cpp/classes/ref.hpp>
#include <godot_cpp/classes/sphere_mesh.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/core/memory.hpp>
#include <godot_cpp/variant/basis.hpp>
#include <godot_cpp/variant/color.hpp>
#include <godot_cpp/variant/string_name.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <godot_cpp/variant/vector2.hpp>
#include <godot_cpp/variant/vector3.hpp>
#include <map>
#include <string>
#include <type_traits>
#include <utility>
#include <variant>
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<three_d::ObjectId, ::godot::MeshInstance3D*>& 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<three_d::ObjectId, ::godot::MeshInstance3D*>& 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<three_d::ObjectId, ::godot::MeshInstance3D*>& 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<three_d::ObjectId, ::godot::MeshInstance3D*>& 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<decltype(variant)>;
if constexpr (std::is_same_v<Command, three_d::CreateObject>) {
create_object(variant, root_, objects_);
}
else if constexpr (std::is_same_v<Command, three_d::DestroyObject>) {
destroy_object(variant, root_, objects_);
}
else if constexpr (std::is_same_v<Command, three_d::SetTransform>) {
change_transform(variant, objects_);
}
else if constexpr (std::is_same_v<Command, three_d::SetVisual>) {
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
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include "sequa/3d/scene.hpp"
#include <map>
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<three_d::ObjectId, ::godot::MeshInstance3D*> objects_;
::godot::Camera3D* camera_ = nullptr;
};
} // namespace sequa::godot
@@ -0,0 +1,229 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/godot/conversions.hpp"
#include <doctest/doctest.h>
#include <limits>
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<float>::quiet_NaN();
const float inf_value = std::numeric_limits<float>::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));
}
@@ -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());
}