Building a GDNative Plugin with third-party libraries

Introduction

Godot Engine provides many ways to extend its features with custom code. One of the most used ways is making a GDNative plugin. These kinds of plugins are usually meant to do in C/C++ what you would otherwise do in GDScript or C#. However, GDNative plugins are not limited to that use case. Although you can only use the parts of the engine code that are exposed through the scripting API with GDNative, this in combination with third-party libraries is often enough to do a lot of things.

In this blog post, we will prepare a simple project to build a GDNative plugin in C++, so we can in the future use it with custom libraries. You can think of this project as a template for other plugins as well.

The final purpose of this tutorial is to create a plugin that uses the “ice_cpu” library to get some information from the CPU.

Prerequisites

Note that we are going to be using Godot 3.5.2 (the latest LTS at the moment).

To be able to follow this tutorial you need to:

  • Have some basic knowledge of C++.
  • Have basic knowledge of scons build system (if you know how to build Godot, you are good to go!).

First steps

If you haven’t already, please give this official tutorial a read, as it will help us to set everything up here. We are going to describe the complete steps, but not in the level of detail of the official documentation, to avoid duplicating the information that is already there.

If you already completed that tutorial, that’s even better!

Start by creating a folder for your project. Call it gdnative_cpu and proceed to populate it with all the needed code. These commands should suffice:

mkdir gdnative_cpu
cd gdnative_cpu
git init
git submodule add -b 3.x https://github.com/godotengine/godot-cpp
cd godot-cpp
git submodule update --init

Then, inside the godot-cpp directory and build the C++ bindings:

scons platform=<platform> generate_bindings=yes -j4

Making the plugin

We now need to add some code. At first, we will just make a simple plugin that does nothing by itself, because we are going to add some functionality with external libraries later.

Start by creating a directory, called src/ inside the gdnative_cpu folder. Then create a file called cpu.h inside it, and add this code:

#ifndef CPU_H
#define CPU_H

#include <Godot.hpp>
#include <Node.hpp>

namespace godot {

class CPU : public Node {
    GODOT_CLASS(CPU, Node)

public:
    static void _register_methods();

    CPU();
    ~CPU();
};
}

#endif

This file is doing the basic definition for our CPU class, which inherits from the Node class. Note you can inherit from every kind of node available in Godot. As it is explained in the official tutorial, we use the GODOT_CLASS macro here to set up some internal things automatically. We need the _register_methods function for Godot to be able to find out what will be exposed to the scripts later.

Let’s continue by creating the source file cpu.cpp with this content:

#include "cpu.h"

using namespace godot;

void CPU::_register_methods() {
}

CPU::CPU() {
}

CPU::~CPU() {
    // add your cleanup here
}

These files are not doing anything nor adding any feature, but they will serve us as a start to add features later.

We still need a gdlibrary.cpp file with the following content to be able to build everything:

#include "cpu.h"

extern "C" void GDN_EXPORT godot_gdnative_init(godot_gdnative_init_options *o) {
    godot::Godot::gdnative_init(o);
}

extern "C" void GDN_EXPORT godot_gdnative_terminate(godot_gdnative_terminate_options *o) {
    godot::Godot::gdnative_terminate(o);
}

extern "C" void GDN_EXPORT godot_nativescript_init(void *handle) {
    godot::Godot::nativescript_init(handle);

    godot::register_class<godot::CPU>();
}

For simplicity, let’s use this SConstruct file provided by the official documentation to build the plugin. Place that file inside the gdnative_cpu. Search and replace demo/bin/ in the SConstruct file with godot_project/bin, as we will be using that directory instead of the one suggested in the official tutorial. Also, search and replace libgdexample with libcpu, as that’s the name we want to assign to our library.

Before building the plugin, create a project with Godot 3.5.2 in a directory called godot_project inside the gdnative_cpu folder. Make sure to also create the bin folder inside godot-project.

Now, you should be able to build the plugin by running the following command in the gdnative_cpu directory:

scons platform=<your_platform>

The library should be built and placed inside godot_project/bin/<your_platform>/ if everything was right!

Creating the library in the editor

Let’s create a .gdnlib file in the editor, using the library we just compiled.

In the inspector, click on the “Create a new resource” button, and select “GDNativeLibrary” in the window that pops up.

In the bottom panel, you will see a UI that lets you set the libraries for each platform. Look for your platform and add the library we built in the previous section shown below:

Now, again in the inspector, press the “Save” button, and save the resource in the bin/ folder as cpu.gdnlib.

Adding features with a third-party library

We have already done a GDNative plugin, but it does nothing! So the next step is to add some functionality to it. For that purpose, we are going to use a third-party library, that you can get here.

We are going to the single-header ice_cpu library. But for demonstration purposes, instead of the single-header version, we provide a header plus a pre-built library. We know it doesn’t make sense to use a single-header library like that, but this is just to show how to use pre-built libraries.

Uncompress the library in src/cpu. Note that you have include and lib directories inside.

Let’s go back to our cpu.h file, and add some functionality. Modify the file so it looks like this:

#ifndef CPU_H
#define CPU_H

#include <Godot.hpp>
#include <Node.hpp>

namespace godot {

class CPU : public Node {
    GODOT_CLASS(CPU, Node)

private:
  unsigned cores;

public:
    static void _register_methods();

    CPU();
    ~CPU();

    void _init();
    unsigned get_cores();
    void set_cores(unsigned);
};
}

#endif

We just added the definitions of _init() and get_cores() functions, and added a private variable called cores.

Let’s complete the cpu.cpp file to match the changes in the header:

#include "cpu.h":
#include <ice_cpu.h>

using namespace godot;

void CPU::_register_methods() {
    register_property<CPU, unsigned>("cores", &CPU::set_cores, &CPU::get_cores, 0);
}

CPU::CPU() {
}

CPU::~CPU() {
    // add your cleanup here
}

void CPU::init(){
  ice_cpu_info cpu;
  ice_cpu_bool res = ice_cpu_get_info(&cpu);
  cores = cpu.cores;
}

int CPU::get_cores(){
  return cores;
}

void CPU::set_cores(unsigned cores){
    return;
}

We include the header of our third-party library and use it to retrieve the number of cores of the CPU in the _init() function. We also wrote the implementation of the get_cores() getter to be able to retrieve this value from scripts.

Adding the third-party library to the SConstruct file.

Before building, we need to do some modifications to the SConstruct file, to make scons know that it must use the third-party library.

Start by adding these lines below the definition of the cpp_library variable:

cpu_library_path = "src/cpu/"
cpu_library = "libice_cpu"

These variables hold the path and the name of the library. Now we need to tell the compiler to use them. Near the bottom of the SConstruct file, change these three lines:

# make sure our binding library is properly includes
env.Append(CPPPATH=['.', godot_headers_path, cpp_bindings_path + 'include/', cpp_bindings_path + 'include/core/', cpp_bindings_path + 'include/gen/', cpu_library_path + "include/"])
env.Append(LIBPATH=[cpp_bindings_path + 'bin/', cpu_library_path + "lib/"])
env.Append(LIBS=[cpp_library, cpu_library])

In the first line, we are telling the compiler where to look for the library header. In the second line, we specify where the linker should look for the library binary. In the third line, we define which library is being used.

Lastly, let’s copy the third-party library alongside the GDNative library, so it can find it in runtime. Simply add this below the env.SharedLibrary command:

env.Install(env['target_path'], Glob(cpu_library_path + 'lib/*.so'))
Default(env['target_path'])

We also changed the Default to be the target path, so scons knows it must always copy the third-party library.

Caveats for Linux

If you are on a Linux system, you also need to specify where will the compiled project look for the third-party library. Trying to run the project will just tell you it cannot find the third-party library. To fix this, we need to add env.Append(RPATH=["'$$$$ORIGIN'"]) to the section specifically for Linux builds:

elif env['platform'] in ('x11', 'linux'):
    env['target_path'] += 'x11/'
    cpp_library += '.linux'
    env.Append(CCFLAGS=['-fPIC'])
    env.Append(CXXFLAGS=['-std=c++17'])
    if env['target'] in ('debug', 'd'):
        env.Append(CCFLAGS=['-g3', '-Og'])
    else:
        env.Append(CCFLAGS=['-g', '-O3'])
    env.Append(RPATH=["$$$$ORIGIN"])   

That last line is telling the linker that it should look for the libraries in the same path of the resulting binary (this is, the same directory as libcpu.so). Note you need the dollar sign four times to correctly escape $ORIGIN in this situation.

Your final SConstruct file should look like this:

#!python
import os

opts = Variables([], ARGUMENTS)

# Gets the standard flags CC, CCX, etc.
env = DefaultEnvironment()

# Define our options
opts.Add(EnumVariable('target', "Compilation target", 'debug', ['d', 'debug', 'r', 'release']))
opts.Add(EnumVariable('platform', "Compilation platform", '', ['', 'windows', 'x11', 'linux', 'osx']))
opts.Add(EnumVariable('p', "Compilation target, alias for 'platform'", '', ['', 'windows', 'x11', 'linux', 'osx']))
opts.Add(BoolVariable('use_llvm', "Use the LLVM / Clang compiler", 'no'))
opts.Add(PathVariable('target_path', 'The path where the lib is installed.', 'godot_project/bin/'))
opts.Add(PathVariable('target_name', 'The library name.', 'libcpu', PathVariable.PathAccept))

# Local dependency paths, adapt them to your setup
godot_headers_path = "godot-cpp/godot-headers/"
cpp_bindings_path = "godot-cpp/"
cpp_library = "libgodot-cpp"
cpu_library_path = "src/cpu/"
cpu_library = "libice_cpu"
# only support 64 at this time..
bits = 64

# Updates the environment with the option variables.
opts.Update(env)

# Process some arguments
if env['use_llvm']:
    env['CC'] = 'clang'
    env['CXX'] = 'clang++'

if env['p'] != '':
    env['platform'] = env['p']

if env['platform'] == '':
    print("No valid target platform selected.")
    quit();

# For the reference:
# - CCFLAGS are compilation flags shared between C and C++
# - CFLAGS are for C-specific compilation flags
# - CXXFLAGS are for C++-specific compilation flags
# - CPPFLAGS are for pre-processor flags
# - CPPDEFINES are for pre-processor defines
# - LINKFLAGS are for linking flags

# Check our platform specifics
if env['platform'] == "osx":
    env['target_path'] += 'osx/'
    cpp_library += '.osx'
    env.Append(CCFLAGS=['-arch', 'x86_64'])
    env.Append(CXXFLAGS=['-std=c++17'])
    env.Append(LINKFLAGS=['-arch', 'x86_64'])
    if env['target'] in ('debug', 'd'):
        env.Append(CCFLAGS=['-g', '-O2'])
    else:
        env.Append(CCFLAGS=['-g', '-O3'])

elif env['platform'] in ('x11', 'linux'):
    env['target_path'] += 'x11/'
    cpp_library += '.linux'
    env.Append(CCFLAGS=['-fPIC'])
    env.Append(CXXFLAGS=['-std=c++17'])
    if env['target'] in ('debug', 'd'):
        env.Append(CCFLAGS=['-g3', '-Og'])
    else:
        env.Append(CCFLAGS=['-g', '-O3'])
    env.Append(RPATH="'$$$$ORIGIN'")

elif env['platform'] == "windows":
    env['target_path'] += 'win64/'
    cpp_library += '.windows'
    # This makes sure to keep the session environment variables on windows,
    # that way you can run scons in a vs 2017 prompt and it will find all the required tools
    env.Append(ENV=os.environ)

    env.Append(CPPDEFINES=['WIN32', '_WIN32', '_WINDOWS', '_CRT_SECURE_NO_WARNINGS'])
    env.Append(CCFLAGS=['-W3', '-GR'])
    env.Append(CXXFLAGS='/std:c++17')
    if env['target'] in ('debug', 'd'):
        env.Append(CPPDEFINES=['_DEBUG'])
        env.Append(CCFLAGS=['-EHsc', '-MDd', '-ZI'])
        env.Append(LINKFLAGS=['-DEBUG'])
    else:
        env.Append(CPPDEFINES=['NDEBUG'])
        env.Append(CCFLAGS=['-O2', '-EHsc', '-MD'])

if env['target'] in ('debug', 'd'):
    cpp_library += '.debug'
else:
    cpp_library += '.release'

cpp_library += '.' + str(bits)

# make sure our binding library is properly includes
env.Append(CPPPATH=['.', godot_headers_path, cpp_bindings_path + 'include/', cpp_bindings_path + 'include/core/', cpp_bindings_path + 'include/gen/', cpu_library_path + "include/"])
env.Append(LIBPATH=[cpp_bindings_path + 'bin/', cpu_library_path + "lib/"])
env.Append(LIBS=[cpp_library, cpu_library])

# tweak this if you want to use different folders, or more folders, to store your source code in.
env.Append(CPPPATH=['src/'])
sources = Glob('src/*.cpp')

library = env.SharedLibrary(target=env['target_path'] + env['target_name'] , source=sources)

env.Install(env['target_path'], Glob(cpu_library_path + 'lib/*.so'))

Default(env['target_path'])

# Generates help for the -h scons option.
Help(opts.GenerateHelpText(env))

Go ahead and rebuild the library with scons platform=<your_platform>.

Adding dependencies to the GDNative library in Godot

We still need to do one more thing before we are done. Find the cpu.gdnlib in your project, and double-click it. In the bottom panel, you will see a table with different entries for each platform. Here you need to define the dependencies for your library. In this case, the GDNative library depends on the third-party library, so add the dependencies below:

This will make Godot also copy the dependency libraries with the binaries when exporting the game.

Creating a NativeScript

Now that we have all set and the plugin is built, we need to use a NativeScript to make use of it.

In the inspector, press the button to create a new resource as you did for the cpu.gdnlib. But this time search for “NativeScript” in the popup window.

Drag the cpu.gdnlib file to the “Library” attribute in the inspector of the native script you are creating. Also, write “CPU” in the “Class Name” attribute. Save this resource as cpu.gdns.

That’s all! Now you can assign this native script to any Node.

Final tests

For testing purposes, let us create a new autoload out of this native script. Go to the Project Settings, and select the “AutoLoad” tab. Search for the cpu.gdns file in the “Path” input box, and then press the “Add” button. You should end up with something similar to this:

Now create a new scene with any kind of root node, and attach a script with this function:

func _ready():
	print(Cpu.cores)

This will make the script print the cores attribute of the Cpu autoload. Remember that this autoload is using our native script.

If everything went ok, you should see the number of cores your PC has in the Output panel!

Conclusions

This tutorial was a brief example of how to use third-party libraries with a GDNative plugin for Godot 3.x. For 4.x, you should use GDExtension instead, but that API is still receiving updates and changes. If you are reading this because you are interested in providing plugins for your frameworks or libraries, you will still probably want to do an editor’s plugin that adds new nodes and register some singletons with the desired features. That will be covered in upcoming tutorials, so stay tuned!

Scroll to Top