Hello! 
I want to share something that might help others on developing a game/application with C++/GDExtension
First of all,
Godot is based on one of the programming principles, which I’m going to discuss here in a bit; Observer Patterns. With this principle, it ensures systems to be reactive instead of awaiting a flag or polling for an update. It also gives clarity to the programmer by introducing cause and effect mechanisms. This pattern is prevalent when creating and applying signals for user-made systems.
But with how many intertwined signals and connected callbacks in one class, the codebase may be cluttered (especially in UI) in the long run.
Let’s look at some examples, shall we?
Following code is a part of ItemHandling object handling item icon editing function.
// item_handling.h
class ItemHandling: public godot::Node{
GDCLASS(ItemHandling, godot::Node)
private:
bool _currently_edit_item_icon = false;
godot::String _item_path;
godot::Ref<godot::Texture> _item_icon;
void _edit_item_icon();
void _finish_edit_item_icon();
void _ask_item_file();
void _item_file_dialog_choosing(const godot::String& path, godot::EditorFileDialog* dialog);
void _item_file_dialog_closing(godot::EditorFileDialog* dialog);
void _ask_item_icon();
void _item_icon_dialog_choosing(const godot::String& path, godot::EditorFileDialog* dialog);
void _item_icon_dialog_closing(godot::EditorFileDialog* dialog);
protected:
static void _bind_methods();
public:
...
};
// item_handling.cpp
...
void ItemHandling::_edit_item_icon(){
if(_currently_edit_item_icon){
UtilityFunctions::push_warning("Already editing item icon.");
return;
}
_currently_edit_item_icon = true;
_ask_item_file();
}
void ItemHandling::_finish_edit_item_icon(){
_currently_edit_item_icon = false;
// Change the item data to use a new item icon
...
}
void ItemHandling::_ask_item_file(){
EditorFileDialog* _dialog = memnew(EditorFileDialog);
_dialog->set_title("Choose Item File");
_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILE);
_dialog->set_access(EditorFileDialog::ACCESS_RESOURCES);
add_child(_dialog);
_dialog->popup_centered();
_dialog->connect("canceled", Callable(this, "_item_file_dialog_closing").bind(_dialog));
_dialog->connect("dir_selected", Callable(this, "_item_file_dialog_choosing").bind(_dialog));
}
void ItemHandling::_item_file_dialog_choosing(const String& path, EditorFileDialog* dialog){
// Check if path is a valid item resource
...
dialog->hide();
dialog->queue_free();
_item_path = path;
_ask_item_icon();
}
void ItemHandling::_item_file_dialog_closing(EditorFileDialog* dialog){
dialog->hide();
dialog->queue_free();
_currently_edit_item_icon = false;
}
void ItemHandling::_ask_item_icon(){
EditorFileDialog* _dialog = memnew(EditorFileDialog);
_dialog->set_title("Choose Item Icon");
_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILE);
_dialog->set_access(EditorFileDialog::ACCESS_RESOURCES);
add_child(_dialog);
_dialog->popup_centered();
_dialog->connect("canceled", Callable(this, "_item_icon_dialog_closing").bind(_dialog));
_dialog->connect("dir_selected", Callable(this, "_item_icon_dialog_choosing").bind(_dialog));
}
void ItemHandling::_item_icon_dialog_choosing(const String& path, EditorFileDialog* dialog){
// Check if path is a valid texture resource
...
if(!_item_icon.is_valid())
return;
dialog->hide();
dialog->queue_free();
_finish_edit_item_icon();
}
void ItemHandling::_item_icon_dialog_closing(EditorFileDialog* dialog){
dialog->hide();
dialog->queue_free();
_currently_edit_item_icon = false;
}
...
As you can see here, a typical way of handling signals. Prompting file dialog for user to get a path which what the user wants. But I want you to focus on something else. As you can see, the flow jumps around back and forth in between functions. Not only that, when you try to chain a prompt to create a compiled data from user, the code base gets even messier, especially if you want to chain multiple FileDialog prompts, with each specific callbacks.
Signals are not to blame here, it is just the nature of handling Observer Pattern with restriction to Godot’s Object safety. This is the reason why I wrote this topic. Hence,
Let me introduce you to Cothreaded library
Repository Link
Plugin Link - Check in repository (plugin in-review at the time of writing this)
So, what is Cothread in this case? Cothread, or short for Cooperative Threading, is an alternative way to handle signals by using a threaded process and states that can be wait upon, so that the specific thread will wait until a state is sufficient. You can think of an example like, by using previous example, where a thread waits until a user give their input to a shown FileDialog, which then the waiting thread will “wake up” and continue its function.
How about we refine the code from previous example with that logic?
// item_handling.h
class ItemHandling: public godot::Node{
GDCLASS(ItemHandling, godot::Node)
private:
// The Cothread ID
uint64_t _edit_item_icon_cothread = 0;
CVCoWaitObject _wait_object;
godot::String _item_path;
godot::String _item_icon_path;
void _edit_item_icon();
godot::String _ask_item_file();
void _item_file_dialog_choosing(const godot::String& path, godot::EditorFileDialog* dialog);
void _item_file_dialog_closing(godot::EditorFileDialog* dialog);
godot::Ref<godot::Texture> _ask_item_icon();
void _item_icon_dialog_choosing(const godot::String& path, godot::EditorFileDialog* dialog);
void _item_icon_dialog_closing(godot::EditorFileDialog* dialog);
protected:
static void _bind_methods();
public:
...
};
// item_handling.cpp
...
void ItemHandling::_edit_item_icon(){
if(_edit_item_icon_cothread){
UtilityFunctions::push_warning("Edit item icon already invoked.");
return;
}
_edit_item_icon_cothread = cothread_create([this]{
String _file_path = _ask_item_file();
// User has canceled the prompt
if(_file_path.is_empty())
return;
Ref<Texture> _item_icon = _ask_item_icon();
// User has canceled the prompt
if(!_item_icon.is_valid())
return;
// Edit icon of target item
...
_edit_item_icon_cothread = 0;
});
}
String ItemHandling::_ask_item_file(){
if(!cothread_valid()){
UtilityFunctions::push_error("Not in cothreaded context.");
return "";
}
EditorFileDialog* _dialog = memnew(EditorFileDialog);
_dialog->set_title("Choose Item File");
_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILE);
_dialog->set_access(EditorFileDialog::ACCESS_RESOURCES);
__COTHREAD_ENTER_MAIN_THREAD(this, _dialog)
add_child(_dialog);
_dialog->popup_centered();
_dialog->connect("canceled", Callable(this, "_item_file_dialog_closing").bind(_dialog));
_dialog->connect("dir_selected", Callable(this, "_item_file_dialog_choosing").bind(_dialog));
__COTHREAD_EXIT_MAIN_THREAD()
// Check update
for(;;){
// Optional: add semaphore object
_item_path = "";
cothread_wait(&_wait_object);
// User has canceled the prompt
if(_item_path.is_empty())
break;
// Check if the path is a valid item resource
...
break;
}
__COTHREAD_ENTER_MAIN_THREAD(_dialog)
_dialog->queue_free();
_dialog->hide();
__COTHREAD_EXIT_MAIN_THREAD()
return _item_path;
}
void ItemHandling::_item_file_dialog_choosing(const String& path, EditorFileDialog* dialog){
_item_path = path;
_wait_object.notify();
}
void ItemHandling::_item_file_dialog_closing(EditorFileDialog* dialog){
_wait_object.notify();
}
Ref<Texture> ItemHandling::_ask_item_icon(){
if(!cothread_valid()){
UtilityFunctions::push_error("Not in cothreaded context.");
return nullptr;
}
EditorFileDialog* _dialog = memnew(EditorFileDialog);
_dialog->set_title("Choose Item Icon");
_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILE);
_dialog->set_access(EditorFileDialog::ACCESS_RESOURCES);
__COTHREAD_ENTER_MAIN_THREAD(this, _dialog)
add_child(_dialog);
_dialog->popup_centered();
_dialog->connect("canceled", Callable(this, "_item_icon_dialog_closing").bind(_dialog));
_dialog->connect("dir_selected", Callable(this, "_item_icon_dialog_choosing").bind(_dialog));
__COTHREAD_EXIT_MAIN_THREAD()
Ref<Texture> _item_icon;
// Check update
for(;;){
// Optional: use semaphore object
_item_icon_path = "";
cothread_wait(&_wait_object);
// User has canceled the prompt
if(_item_icon_path.is_empty())
break;
ResourceLoader* _loader = ResourceLoader::get_singleton();
_item_icon = _loader->load(_item_icon_path, Texture::get_class_static());
// Check if the target file is a valid texture
if(!_item_icon.is_valid()){
UtilityFunctions::push_error("Target file is not a valid texture.");
// loop to wait again
continue;
}
break;
}
__COTHREAD_ENTER_MAIN_THREAD(_dialog)
_dialog->hide();
_dialog->queue_free();
__COTHREAD_EXIT_MAIN_THREAD()
return _item_icon;
}
void ItemHandling::_item_icon_dialog_choosing(const String& path, EditorFileDialog* dialog){
_item_icon_path = path;
_wait_object.notify();
}
void ItemHandling::_item_icon_dialog_closing(EditorFileDialog* dialog){
_wait_object.notify();
}
...
Look at that! The flow looks much more clearer! But it lacks something, it still uses local variable of the class, instead of stack variable. Thus,
It becomes even more effective when using CallbackObject
Wait, wait, wait, what is CallbackObject? CallbackObject (actually _CallbackObject, but with Ref<> attached to it) creates a callback object based on std::function it uses. By then, you can convert the object to a valid Godot callback with C++ functions in it. To ensure the lifetime of the CallbackObject, it will bind itself to the Callable. The code is within the Essential Utils source code.
Here is an example:
Signal handling with Callable
void ButtonTest::_on_pressed(){
UtilityFunctions::print("Button is being pressed!");
}
void ButtonTest::_ready(){
connect("pressed", Callable(this, "_on_pressed"));
}
Signal handling with CallbackObject
void ButtonTest::_ready(){
connect("pressed", create_native_callback([]{
UtilityFunctions::print("Button is being pressed!");
}, this));
}
Isn’t this unsafe? (Memory leaks, potential of calling an already deleted object)
No, let me address it:
- Since the lifetime of the
CallbackObjectis based on theCallable(and how many callables that referencing the object), and this is achieved by binding itself to theCallable(which you can see that the receiving end ofCallbackObjecthas the first argument of itself) - Caller has to specify which Godot’s object it belongs, and the
CallbackObjectwill store the ID of the object which will be checked againstObjectDBwhen it is called (this mechanism is similar to howCallablechecks the validity of the target object)
Though, what you need to keep in mind, is that, the data/objects captured within the callback. Which might be invalid if you are not careful enough. Example: capturing a reference of a stack variables, for a callback that might be invoked at the time where the stack frame is already invalid.
What about a callback that has a return value and/or argument(s)?
You can also give it arguments and even return values!
How does it work? It will try to parse each type in the argument and/or return value into Variant type enums. Then, by using Godot’s variadic method implementation, it will try to parse each arguments and return values into the supposed type the callback uses for its arguments and results.
Let’s talk about the drawbacks
Anything convenient always have their own drawbacks, which this mechanism does not lack:
- It uses more memory to create
CallbackObjectrather than primitive variables likeCallable - Has way more overhead, which basically call the receiving end of
CallbackObjectmethod the same way a Callable would. And then call the lambda function embedded in storedstd::function - For signals, even with ObjectDB checks, the lifetime of
CallbackObjectwill be based on the connection lifetime, so it is a good practice to disconnect it when not needed (and you also need to store the Callable in order to disconnect it)
Let’s also talk about the drawbacks of the Cothread method
- Switching to main thread means waiting until a deferred time frame
- Like any implementation of threaded processes, inappropriately handling wait objects or semaphores will introduce deadlocks
Before we conclude, how about we give it a final touch?
// item_handling.h
class ItemHandling: public godot::Node{
GDCLASS(ItemHandling, godot::Node)
private:
// The Cothread ID
uint64_t _edit_item_icon_cothread = 0;
void _edit_item_icon();
godot::String _ask_item_file();
godot::Ref<godot::Texture> _ask_item_icon();
protected:
static void _bind_methods();
public:
...
};
// item_handling.cpp
...
void ItemHandling::_edit_item_icon(){
if(_edit_item_icon_cothread){
UtilityFunctions::push_warning("Edit item icon already invoked.");
return;
}
_edit_item_icon_cothread = cothread_create([this]{
String _file_path = _ask_item_file();
// User has canceled the prompt
if(_file_path.is_empty())
return;
Ref<Texture> _item_icon = _ask_item_icon();
// User has canceled the prompt
if(!_item_icon.is_valid())
return;
// Edit icon of target item
...
_edit_item_icon_cothread = 0;
});
}
String ItemHandling::_ask_item_file(){
if(!cothread_valid()){
UtilityFunctions::push_error("Not in cothreaded context.");
return "";
}
EditorFileDialog* _dialog = memnew(EditorFileDialog);
_dialog->set_title("Choose Item File");
_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILE);
_dialog->set_access(EditorFileDialog::ACCESS_RESOURCES);
CVCoWaitObject _wait_object;
String _item_path;
__COTHREAD_ENTER_MAIN_THREAD(&)
add_child(_dialog);
_dialog->popup_centered();
_dialog->connect("canceled", create_native_callback([&_wait_object]{
_wait_object.notify();
}, this));
{ // enclosure for scoping
// Seperate, just not to upset the compiler
std::function _func = [&_wait_object, &_item_path](const String& path){
_item_path = path;
_wait_object.notify();
};
_dialog->connect("dir_selected", create_native_callback(_func, this));
} // enclosure closing
__COTHREAD_EXIT_MAIN_THREAD()
// Check update
for(;;){
// Optional: add semaphore object
_item_path = "";
cothread_wait(&_wait_object);
// User has canceled the prompt
if(_item_path.is_empty())
break;
// Check if the path is a valid item resource
...
break;
}
__COTHREAD_ENTER_MAIN_THREAD(_dialog)
_dialog->queue_free();
_dialog->hide();
__COTHREAD_EXIT_MAIN_THREAD()
return _item_path;
}
Ref<Texture> ItemHandling::_ask_item_icon(){
if(!cothread_valid()){
UtilityFunctions::push_error("Not in cothreaded context.");
return nullptr;
}
EditorFileDialog* _dialog = memnew(EditorFileDialog);
_dialog->set_title("Choose Item Icon");
_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILE);
_dialog->set_access(EditorFileDialog::ACCESS_RESOURCES);
CVCoWaitObject _wait_object;
String _item_icon_path;
__COTHREAD_ENTER_MAIN_THREAD(&)
add_child(_dialog);
_dialog->popup_centered();
_dialog->connect("canceled", create_native_callback([&_wait_object]{
_wait_object.notify();
}, this));
{ // enclosure for scoping
std::function _func = [&_wait_object, &_item_icon_path](const String& path){
_item_icon_path = path;
_wait_object.notify();
};
_dialog->connect("dir_selected", create_native_callback(_func, this));
} // enclosure closing
__COTHREAD_EXIT_MAIN_THREAD()
Ref<Texture> _item_icon;
// Check update
for(;;){
// Optional: use semaphore object
_item_icon_path = "";
cothread_wait(&_wait_object);
// User has canceled the prompt
if(_item_icon_path.is_empty())
break;
ResourceLoader* _loader = ResourceLoader::get_singleton();
_item_icon = _loader->load(_item_icon_path, Texture::get_class_static());
// Check if the target file is a valid texture
if(!_item_icon.is_valid()){
UtilityFunctions::push_error("Target file is not a valid texture.");
// loop to wait again
continue;
}
break;
}
__COTHREAD_ENTER_MAIN_THREAD(_dialog)
_dialog->hide();
_dialog->queue_free();
__COTHREAD_EXIT_MAIN_THREAD()
return _item_icon;
}
...
Conclusion
This is a method I implemented to solve a problem I face when dealing with UI functionalities in Godot, where I confusedly trying to find which function does A or B. This way, the codebase will be clearer and easier to maintain (subjectively, for know). Keep in mind, I developed it without proper testing ground (like extended usage of the tool), so I wouldn’t be surprised if a problem might show itself, but I will keep on fixing it, as it might be one of the tool I will commonly used. And also, I developed it with experimentation in mind, so the stability of the mechanism is questionable at best.
I haven’t test it for time-critical game systems, and I prefer that the mechanism should not even touch that. Due to time-misses it can cause when switching to main thread (if needed).
There is also an example project which is mentioned in the repo that you can compile and test it for further and complete understanding of the mechanism.
I will look forward on what you think of this, I’m eager to know if this might actually help you in your projects. Also let me know if you found a drawback or holes in the mechanism, which I’ll gladly hear as a feedback.
Since I am a slow responder, I will try to dedicate my time to check on this forum for a week, at around Indonesia’s night time (14:00 - 15:00 UTC).
FAQ
Why do we need another coroutine library when there is standard C++ library (coroutine) that we can use?
As disappointing as this might sound, the setup (for the std library) is a bit tedious. And yes I know, but I will give some counterpoints to balance it out:
- Those who familiar with working with threads can jump straight into it with little to no learning
- It’s even more straightforward to use when using it recursively or in nested functions
Since it is multi-threaded, does it made some Godot’s functions invalid?
Yes, you’re right, and that is the reason for coroutine_switch_main or __COTHREAD_ENTER_MAIN_THREAD - __COTHREAD_EXIT_MAIN_THREAD exists. It will help to switch the code flow to main thread in order to work with Godot’s functions.