feat(prototypes): libgodot_host, Godot hébergé par LibGodot et script de construction

scripts/build-godot.ps1 construit Godot en bibliothèque partagée depuis le fork (SCons dans un venv, Vulkan seul, --path autorisé). Le prototype charge la DLL, démarre Godot par l'interface C de GDExtension et pilote iteration() : démarrage 1,5 s, arrêt propre à condition de décharger la DLL juste après la destruction de l'instance.
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-07 04:37:38 +01:00
1 parent 4ffcd11734
commit e7ddd690ce
9 files changed
+453

No files matched your search

+1
View File
@@ -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()
+25
View File
@@ -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()
+59
View File
@@ -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.
+224
View File
@@ -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 <gdextension_interface.gen.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#else
#include <dlfcn.h>
#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 <class Fn>
Fn proc(const char* name)
{
auto* fn = reinterpret_cast<Fn>(reinterpret_cast<void*>(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<void*>(GetProcAddress(static_cast<HMODULE>(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 <godot.dll>] [--project <dossier>] [--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<CreateInstanceFn>(load_symbol(library, "libgodot_create_godot_instance"));
auto* destroy = reinterpret_cast<DestroyInstanceFn>(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<std::string> args = {"sequa-libgodot-host", "--path", options.project, "--rendering-driver", "vulkan"};
if (options.headless) {
args.emplace_back("--headless");
}
std::vector<char*> 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<int>(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<GDExtensionInterfaceGetGodotVersion2>("get_godot_version2")(&version);
std::printf("[2] instance créée : %s (%s)\n", version.string, version.hash);
auto string_name = proc<GDExtensionInterfaceStringNameNewWithLatin1Chars>("string_name_new_with_latin1_chars");
auto method_bind = proc<GDExtensionInterfaceClassdbGetMethodBind>("classdb_get_method_bind");
auto ptrcall = proc<GDExtensionInterfaceObjectMethodBindPtrcall>("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<double, std::milli>(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<double> frame_ms;
frame_ms.reserve(options.frames > 0 ? static_cast<std::size_t>(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<double, std::milli>(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<double>(std::chrono::steady_clock::now() - t_loop).count();
if (!frame_ms.empty()) {
std::vector<double> 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<std::size_t>(p * static_cast<double>(sorted.size())))];
};
std::printf("[4] %zu itérations en %.2f s (%.1f images/s)\n", frame_ms.size(), loop_s,
static_cast<double>(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<double>(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<HMODULE>(library));
#else
dlclose(library);
#endif
std::printf("[5] instance détruite, bibliothèque déchargée\n");
return 0;
}
@@ -0,0 +1,2 @@
# Cache créé par Godot à l'exécution
.godot/
@@ -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")
@@ -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"
+6
View File
@@ -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)
+97
View File
@@ -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.<target>.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