diff --git a/prototypes/CMakeLists.txt b/prototypes/CMakeLists.txt index 1f63a0f..43ee303 100644 --- a/prototypes/CMakeLists.txt +++ b/prototypes/CMakeLists.txt @@ -5,6 +5,7 @@ if(WIN32) add_subdirectory(gpu_interop) + add_subdirectory(libgodot_host) else() message(STATUS "prototypes : gpu_interop ignore (Windows uniquement pour l'instant)") endif() diff --git a/prototypes/libgodot_host/CMakeLists.txt b/prototypes/libgodot_host/CMakeLists.txt new file mode 100644 index 0000000..16320eb --- /dev/null +++ b/prototypes/libgodot_host/CMakeLists.txt @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# Prototype jetable : héberger Godot par LibGodot (ADR 0007, ADR 0008). +# Exige Godot construit par scripts/build-godot.ps1 (en-tête GDExtension généré + DLL). + +set(_godot_src "${PROJECT_SOURCE_DIR}/engines/godot/vendor/godot-src") +set(_godot_header "${_godot_src}/core/extension/gdextension_interface.gen.h") +set(_godot_library "${_godot_src}/bin/godot.windows.template_debug.x86_64.dll") + +if(NOT EXISTS "${_godot_header}") + message(STATUS "prototypes : libgodot_host ignore (construire Godot : scripts/build-godot.ps1)") + return() +endif() + +add_executable(libgodot_host main.cpp) +target_include_directories(libgodot_host SYSTEM PRIVATE "${_godot_src}/core/extension") +target_compile_definitions(libgodot_host PRIVATE + SEQUA_LIBGODOT_DEFAULT_LIBRARY="${_godot_library}" + SEQUA_LIBGODOT_DEFAULT_PROJECT="${CMAKE_CURRENT_SOURCE_DIR}/project") +sequa_target_defaults(libgodot_host) + +if(SEQUA_BUILD_TESTS AND EXISTS "${_godot_library}") + add_test(NAME libgodot_host COMMAND libgodot_host --frames 300) + set_tests_properties(libgodot_host PROPERTIES LABELS "gpu;prototype;godot" TIMEOUT 120) +endif() diff --git a/prototypes/libgodot_host/README.md b/prototypes/libgodot_host/README.md new file mode 100644 index 0000000..5dcaebd --- /dev/null +++ b/prototypes/libgodot_host/README.md @@ -0,0 +1,59 @@ +> **PROTOTYPE JETABLE — pas du code produit.** +> Ce dossier sert uniquement à valider une hypothèse technique. Il sera supprimé ou réécrit. + +# libgodot_host — héberger Godot par LibGodot + +## Rôle + +Vérifier ce que supposent l'[ADR 0007](../../docs/adr/0007-adaptateur-godot-dans-le-depot.md) et l'[ADR 0008](../../docs/adr/0008-processus-de-rendu-et-bibliotheques-chargees.md) : un programme SEQUA peut charger Godot comme bibliothèque, le démarrer et piloter sa boucle image par image avec `GodotInstance::iteration()`. + +Le programme n'utilise que l'interface C de GDExtension (`gdextension_interface.gen.h`, généré par la construction de Godot), sans godot-cpp : + +1. charge `godot.windows.template_debug.x86_64.dll` (`LoadLibrary`, `dlopen` ailleurs) ; +2. appelle `libgodot_create_godot_instance` avec sa fonction d'initialisation GDExtension, par laquelle il reçoit l'accès à l'API de Godot ; +3. retrouve `GodotInstance::start` et `iteration` (`classdb_get_method_bind`, empreinte tirée de `extension_api-4-7.json`) et les appelle (`object_method_bind_ptrcall`) ; +4. boucle sur `iteration()` en mesurant chaque appel, jusqu'à `--frames N` ou la fermeture de la fenêtre ; +5. détruit l'instance, puis décharge la bibliothèque. + +Le projet Godot chargé (`project/`) contient un cube qui tourne, une caméra et une lumière. + +## Construire et lancer + +```powershell +.\scripts\build-godot.ps1 # Godot en bibliothèque (~6 min la première fois) +.\scripts\build.ps1 -Preset windows-msvc-prototypes -BuildDir build\windows-msvc-prototypes -Target libgodot_host -TestRegex libgodot_host +.\build\windows-msvc-prototypes\prototypes\libgodot_host\libgodot_host.exe [--frames N] [--headless] +``` + +Sans Godot construit, la cible est ignorée (message `STATUS` à la configuration). + +## Résultats (2026-11-07) + +Windows 11, RTX 4090 Laptop, Vulkan 1.4.357, Godot 4.7.2-stable (fork `sequa/4.7.2`), `template_debug`. + +| Mesure | Fenêtre | `--headless` | +|---|---|---| +| Démarrage (création + `start()`) | 1,5 – 1,8 s | 0,14 s | +| Débit | ≈179 images/s | ≈150 images/s | +| `iteration()` médiane / p95 | 5,6 / 6,1 ms | 6,9 / 6,9 ms | +| Pilote de rendu | Vulkan, Forward+ | factice (aucune image) | +| Arrêt | propre (code 0) | propre (code 0) | + +Lecture : ≈5,6 ms correspond à la synchronisation verticale de l'écran (≈180 Hz), pas au coût du rendu de cette scène minuscule. Le mode `--headless` confirme qu'aucun rendu n'a lieu. + +## Constats + +- **`--path` est interdit par défaut** dans les modèles d'export de Godot 4.7 : il faut compiler avec `disable_path_overrides=no` (fait dans `scripts/build-godot.ps1`). +- **Déchargement explicite obligatoire.** Laisser Windows décharger la DLL en fin de processus provoque un accès mémoire invalide (`0xC0000005`) : elle passe alors après d'autres DLL dont Godot dépend encore. Détruire l'instance puis appeler `FreeLibrary` tout de suite donne un arrêt propre. Ne pas détruire l'instance « marche » aussi, mais Godot signale alors de nombreuses fuites. +- Une seule instance de Godot par processus, et pas de recréation après destruction (commentaire de `platform/windows/libgodot_windows.cpp`) : relancer Godot revient à relancer le processus de rendu. +- `ERROR: Could not load global script cache` : le projet n'a jamais été importé par l'éditeur. Sans effet ici. + +## Limites + +- Windows seulement pour l'instant ; le code prévoit `dlopen`, mais rien n'a été essayé sous Linux. +- La scène vient d'un fichier `.tscn` : rien n'est encore créé depuis SEQUA (étape suivante, avec godot-cpp). +- Le temps d'`iteration()` inclut l'attente de la synchronisation verticale ; mesurer le coût réel demandera de la couper ou d'utiliser les compteurs GPU. + +## Licences + +MPL 2.0 (en-têtes SPDX). Godot : MIT. diff --git a/prototypes/libgodot_host/main.cpp b/prototypes/libgodot_host/main.cpp new file mode 100644 index 0000000..d206705 --- /dev/null +++ b/prototypes/libgodot_host/main.cpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// PROTOTYPE JETABLE : héberger Godot par LibGodot (ADR 0007, ADR 0008). +// Charge la bibliothèque Godot à l'exécution, crée l'instance, puis pilote la boucle +// image par image avec GodotInstance::iteration(). Mesure le temps de chaque itération. +// +// Aucune dépendance à godot-cpp : uniquement l'interface C de GDExtension +// (gdextension_interface.gen.h, généré par la construction de Godot). + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#else +#include +#endif + +namespace { + +using CreateInstanceFn = GDExtensionObjectPtr (*)(int, char*[], GDExtensionInitializationFunction); +using DestroyInstanceFn = void (*)(GDExtensionObjectPtr); + +GDExtensionInterfaceGetProcAddress g_get_proc_address = nullptr; +int g_init_calls = 0; + +// Fonction d'initialisation GDExtension exigée par LibGodot : c'est par elle que l'hôte +// reçoit l'accès à l'API de Godot. +GDExtensionBool host_init(GDExtensionInterfaceGetProcAddress get_proc_address, + GDExtensionClassLibraryPtr /*library*/, GDExtensionInitialization* init) +{ + g_get_proc_address = get_proc_address; + init->minimum_initialization_level = GDEXTENSION_INITIALIZATION_SCENE; + init->userdata = nullptr; + init->initialize = [](void*, GDExtensionInitializationLevel) { ++g_init_calls; }; + init->deinitialize = [](void*, GDExtensionInitializationLevel) {}; + return 1; +} + +template +Fn proc(const char* name) +{ + auto* fn = reinterpret_cast(reinterpret_cast(g_get_proc_address(name))); + if (fn == nullptr) { + std::fprintf(stderr, "fonction GDExtension introuvable : %s\n", name); + std::exit(1); + } + return fn; +} + +void* load_library(const std::string& path) +{ +#ifdef _WIN32 + return LoadLibraryA(path.c_str()); +#else + return dlopen(path.c_str(), 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 +} + +struct Options { + std::string library = SEQUA_LIBGODOT_DEFAULT_LIBRARY; + std::string project = SEQUA_LIBGODOT_DEFAULT_PROJECT; + long frames = 0; // 0 : jusqu'à la fermeture de la fenêtre + bool headless = false; +}; + +Options parse(int argc, char** argv) +{ + Options o; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + const bool has_value = i + 1 < argc; + if (a == "--library" && has_value) { + o.library = argv[++i]; + } else if (a == "--project" && has_value) { + o.project = argv[++i]; + } else if (a == "--frames" && has_value) { + o.frames = std::strtol(argv[++i], nullptr, 10); + } else if (a == "--headless") { + o.headless = true; + } else { + std::printf("usage : libgodot_host [--library ] [--project ] [--frames N] [--headless]\n"); + std::exit(a == "--help" ? 0 : 2); + } + } + return o; +} + +} // namespace + +int main(int argc, char** argv) +{ + std::setvbuf(stdout, nullptr, _IONBF, 0); // messages visibles même en cas de plantage + const Options options = parse(argc, argv); + + void* library = load_library(options.library); + if (library == nullptr) { + std::fprintf(stderr, "[1] impossible de charger %s\n", options.library.c_str()); + return 1; + } + auto* create = reinterpret_cast(load_symbol(library, "libgodot_create_godot_instance")); + auto* destroy = reinterpret_cast(load_symbol(library, "libgodot_destroy_godot_instance")); + if (create == nullptr || destroy == nullptr) { + std::fprintf(stderr, "[1] symboles LibGodot absents : bibliothèque construite sans library_type=shared_library ?\n"); + return 1; + } + std::printf("[1] bibliothèque chargée : %s\n", options.library.c_str()); + + std::vector args = {"sequa-libgodot-host", "--path", options.project, "--rendering-driver", "vulkan"}; + if (options.headless) { + args.emplace_back("--headless"); + } + std::vector godot_argv; + for (std::string& s : args) { + godot_argv.push_back(s.data()); + } + godot_argv.push_back(nullptr); + + const auto t_create = std::chrono::steady_clock::now(); + GDExtensionObjectPtr instance = create(static_cast(args.size()), godot_argv.data(), &host_init); + if (instance == nullptr || g_get_proc_address == nullptr) { + std::fprintf(stderr, "[2] échec de libgodot_create_godot_instance\n"); + return 1; + } + + GDExtensionGodotVersion2 version{}; + proc("get_godot_version2")(&version); + std::printf("[2] instance créée : %s (%s)\n", version.string, version.hash); + + auto string_name = proc("string_name_new_with_latin1_chars"); + auto method_bind = proc("classdb_get_method_bind"); + auto ptrcall = proc("object_method_bind_ptrcall"); + + // StringName : type opaque de la taille d'un pointeur (configuration 64 bits). + alignas(8) std::uint8_t class_name[8]{}; + alignas(8) std::uint8_t start_name[8]{}; + alignas(8) std::uint8_t iteration_name[8]{}; + string_name(class_name, "GodotInstance", 1); + string_name(start_name, "start", 1); + string_name(iteration_name, "iteration", 1); + + // Empreintes des méthodes (extension_api-4-7.json de godot-cpp) : bool f() const-less. + constexpr GDExtensionInt hash_bool_noargs = 2240911060; + GDExtensionMethodBindPtr start = method_bind(class_name, start_name, hash_bool_noargs); + GDExtensionMethodBindPtr iteration = method_bind(class_name, iteration_name, hash_bool_noargs); + if (start == nullptr || iteration == nullptr) { + std::fprintf(stderr, "[3] méthodes GodotInstance introuvables (empreinte différente ?)\n"); + return 1; + } + + GDExtensionBool ok = 0; + ptrcall(start, instance, nullptr, &ok); + const double startup_ms = + std::chrono::duration(std::chrono::steady_clock::now() - t_create).count(); + if (ok == 0) { + std::fprintf(stderr, "[3] GodotInstance::start() a échoué\n"); + destroy(instance); + return 1; + } + std::printf("[3] démarré en %.0f ms (création + start), %d niveaux d'initialisation reçus\n", startup_ms, + g_init_calls); + + std::vector frame_ms; + frame_ms.reserve(options.frames > 0 ? static_cast(options.frames) : 4096); + const auto t_loop = std::chrono::steady_clock::now(); + for (long frame = 0; options.frames == 0 || frame < options.frames; ++frame) { + const auto t0 = std::chrono::steady_clock::now(); + GDExtensionBool quit = 0; + ptrcall(iteration, instance, nullptr, &quit); + frame_ms.push_back(std::chrono::duration(std::chrono::steady_clock::now() - t0).count()); + if (quit != 0) { + std::printf("[4] Godot demande l'arrêt (fenêtre fermée) après %ld images\n", frame + 1); + break; + } + } + const double loop_s = std::chrono::duration(std::chrono::steady_clock::now() - t_loop).count(); + + if (!frame_ms.empty()) { + std::vector sorted = frame_ms; + std::sort(sorted.begin(), sorted.end()); + double sum = 0; + for (const double v : frame_ms) { + sum += v; + } + const auto pct = [&](double p) { + return sorted[std::min(sorted.size() - 1, static_cast(p * static_cast(sorted.size())))]; + }; + std::printf("[4] %zu itérations en %.2f s (%.1f images/s)\n", frame_ms.size(), loop_s, + static_cast(frame_ms.size()) / loop_s); + std::printf(" iteration() : moyenne %.2f ms, médiane %.2f ms, p95 %.2f ms, max %.2f ms\n", + sum / static_cast(frame_ms.size()), pct(0.5), pct(0.95), sorted.back()); + } + + destroy(instance); + // Déchargement explicite, juste après la destruction : laissé à la fin du processus, + // il intervient après d'autres DLL dont Godot dépend encore et provoque un accès + // mémoire invalide (constaté sous Windows, Godot 4.7.2). +#ifdef _WIN32 + FreeLibrary(static_cast(library)); +#else + dlclose(library); +#endif + std::printf("[5] instance détruite, bibliothèque déchargée\n"); + return 0; +} diff --git a/prototypes/libgodot_host/project/.gitignore b/prototypes/libgodot_host/project/.gitignore new file mode 100644 index 0000000..89b6240 --- /dev/null +++ b/prototypes/libgodot_host/project/.gitignore @@ -0,0 +1,2 @@ +# Cache créé par Godot à l'exécution +.godot/ diff --git a/prototypes/libgodot_host/project/main.tscn b/prototypes/libgodot_host/project/main.tscn new file mode 100644 index 0000000..6446627 --- /dev/null +++ b/prototypes/libgodot_host/project/main.tscn @@ -0,0 +1,21 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://spin.gd" id="1"] + +[sub_resource type="BoxMesh" id="BoxMesh_1"] + +[sub_resource type="StandardMaterial3D" id="Material_1"] +albedo_color = Color(0.9, 0.4, 0.15, 1) + +[node name="Main" type="Node3D"] + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 3) + +[node name="Sun" type="DirectionalLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.707, 0.707, 0, -0.707, 0.707, 0, 2, 0) + +[node name="Box" type="MeshInstance3D" parent="."] +mesh = SubResource("BoxMesh_1") +surface_material_override/0 = SubResource("Material_1") +script = ExtResource("1") diff --git a/prototypes/libgodot_host/project/project.godot b/prototypes/libgodot_host/project/project.godot new file mode 100644 index 0000000..8e9020e --- /dev/null +++ b/prototypes/libgodot_host/project/project.godot @@ -0,0 +1,18 @@ +; Projet Godot minimal du prototype libgodot_host (PROTOTYPE JETABLE). + +config_version=5 + +[application] + +config/name="SEQUA - essai LibGodot" +run/main_scene="res://main.tscn" + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 + +[rendering] + +renderer/rendering_method="forward_plus" +rendering_device/driver.windows="vulkan" diff --git a/prototypes/libgodot_host/project/spin.gd b/prototypes/libgodot_host/project/spin.gd new file mode 100644 index 0000000..43fb13a --- /dev/null +++ b/prototypes/libgodot_host/project/spin.gd @@ -0,0 +1,6 @@ +extends MeshInstance3D + +# Rotation continue : preuve visuelle que les images avancent. +func _process(delta: float) -> void: + rotate_y(delta) + rotate_x(delta * 0.5) diff --git a/scripts/build-godot.ps1 b/scripts/build-godot.ps1 new file mode 100644 index 0000000..7bb0cfa --- /dev/null +++ b/scripts/build-godot.ps1 @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: MPL-2.0 +# Messages en ASCII : compatible Windows PowerShell 5.1 sans BOM. + +<# +.SYNOPSIS + Construit Godot en bibliotheque partagee (LibGodot) depuis le fork engines/godot/vendor/godot-src. + +.DESCRIPTION + Cree au besoin un environnement Python isole (engines/godot/.venv) avec SCons, puis lance + SCons avec library_type=shared_library (ADR 0007). Pilote graphique Vulkan uniquement : + D3D12, AccessKit et ANGLE sont desactives (ADR 0007 : Vulkan sur Windows et Linux). + --path reste autorise : c'est l'hote qui indique a Godot le projet a charger. + SCons detecte MSVC lui-meme. Duree indicative : 10 a 20 min pour une premiere construction. + + Sortie : engines/godot/vendor/godot-src/bin/godot.windows..x86_64.dll + +.PARAMETER Target + template_debug (defaut) ou template_release. + +.PARAMETER Jobs + Nombre de compilations en parallele. Defaut : nombre de processeurs logiques. + +.PARAMETER Log + Fichier ou copier la sortie de SCons (en plus de la console). + +.PARAMETER Help + Affiche cette aide. + +.EXAMPLE + .\scripts\build-godot.ps1 +.EXAMPLE + Start-Process pwsh -ArgumentList '-NoExit','-File','scripts\build-godot.ps1','-Log','build\godot-build.log' +#> + +[CmdletBinding()] +param( + [ValidateSet("template_debug", "template_release")] + [string]$Target = "template_debug", + [int]$Jobs = [Environment]::ProcessorCount, + [string]$Log, + [Alias("h", "?")] + [switch]$Help +) + +if ($Help) { + Get-Help $PSCommandPath -Detailed + exit 0 +} + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$godot = Join-Path $root "engines\godot\vendor\godot-src" +$venv = Join-Path $root "engines\godot\.venv" +$python = Join-Path $venv "Scripts\python.exe" +$sconsVersion = "4.9.1" + +if (-not (Test-Path (Join-Path $godot "SConstruct"))) { + throw "Sources de Godot absentes : git submodule update --init engines/godot/vendor/godot-src" +} + +if (-not (Test-Path $python)) { + Write-Host "==> venv $venv" -ForegroundColor Cyan + & python -m venv $venv + if ($LASTEXITCODE -ne 0) { throw "Echec : creation du venv (Python 3 requis dans le PATH)" } +} + +& $python -c "import SCons, sys; sys.exit(0 if SCons.__version__ == '$sconsVersion' else 1)" 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "==> pip install scons==$sconsVersion" -ForegroundColor Cyan + & $python -m pip install --disable-pip-version-check --quiet "scons==$sconsVersion" + if ($LASTEXITCODE -ne 0) { throw "Echec : installation de SCons" } +} + +$sconsArgs = @( + "-m", "SCons", "-C", $godot, "-j", $Jobs, + "platform=windows", "arch=x86_64", "target=$Target", + "library_type=shared_library", + "d3d12=no", "accesskit=no", "angle=no", + # L'hote indique le projet a charger (--path), interdit par defaut dans les modeles 4.7. + "disable_path_overrides=no" +) + +Write-Host "==> scons $($sconsArgs[4..($sconsArgs.Length - 1)] -join ' ')" -ForegroundColor Cyan +$start = Get-Date +if ($Log) { + New-Item -ItemType Directory -Force (Split-Path -Parent $Log) | Out-Null + & $python @sconsArgs 2>&1 | Tee-Object -FilePath $Log +} +else { + & $python @sconsArgs +} +$code = $LASTEXITCODE +$elapsed = (Get-Date) - $start +if ($code -ne 0) { throw "Echec : scons (code $code) apres $([int]$elapsed.TotalMinutes) min" } + +Write-Host ("==> termine en {0:N1} min" -f $elapsed.TotalMinutes) -ForegroundColor Green +Get-ChildItem (Join-Path $godot "bin") -Filter "godot.windows.$Target.x86_64*" | Select-Object Name, Length