Creating an OpenGL app without display server on Linux (tutorial)

Are you in the process of crafting an OpenGL application but wish to remain independent of display servers like Wayland or X11? Perhaps you're developing a graphical application for a resource-constrained embedded device, or you've ventured into the creation of your own display server, only to encounter challenges when using the low-level DRM/KMS API. If any of these scenarios resonate with you, you've come to the right place.

In this tutorial, we will guide you through the process of creating an OpenGL application using the SRM library, which stands for Simple Rendering Manager. This library streamlines the configuration of all available GPUs and displays, allowing you to concentrate on developing your OpenGL ES 2.0 application. Moreover, it offers valuable features such as listeners for display hotplugging events, such as HDMI display connections and disconnections. Additionally, it facilitates rendering with multiple GPUs and enables texture sharing between them, all through a single allocation.

Without further ado, let's dive right in and embark on this journey.

Dependencies

First we need to have the SRM library installed on our machine. To build SRM, we need to install the following dependencies:

  • hwinfo
  • libdisplay-info
  • libudev
  • libdrm
  • libgbm
  • libEGL
  • libGLESv2
  • libseat
  • libinput

Additionally, we'll need Meson, which will also serve as the build system for our application.

Debian (Ubuntu, Linux Mint, etc)

If you are using a Debian based distribution please install the following packages:

$ sudo apt install build-essential meson hwinfo libdisplay-info-dev libinput-dev libudev-dev libdrm-dev libgbm-dev libegl1-mesa-dev libgles2-mesa-dev

If the hwinfo or libdisplay-info packages are not available in your distribution, please download and install them manually from the following links and in the following order:

RedHat (Fedora, CentOS, openSUSE, etc)

If you are using a RedHat based distribution please install the following packages:

$ sudo dnf install @development-tools
$ sudo dnf install meson hwinfo libseat-devel mesa-libEGL-devel libglvnd-devel libudev-devel libdrm-devel libgbm-devel libdisplay-info-devel libinput-devel

Building SRM

Once you've satisfied the dependencies, you can proceed by executing the following command:

$ git clone https://github.com/CuarzoSoftware/SRM.git
$ cd SRM/src
$ meson setup build -Dbuildtype=custom
$ cd build
$ sudo meson install

This should install the SRM library, examples and development headers on your system.
To ensure that everything is functioning correctly, you can test one of the available examples.

Creating the App

Let's begin by creating an empty project directory, with a main.c and meson.build file inside. 

main.c

int main()
{
    return 0;
}

meson.build

project('srm-example',
        'c',
        version : '0.1.0')
 
c = meson.get_compiler('c')
 
library_paths = ['/usr/lib']
 
glesv2_dep = c.find_library('GLESv2', dirs: library_paths, required: true)
srm_dep = c.find_library('SRM', dirs: library_paths, required: true)
 
sources = ['main.c']
 
executable(
    'srm-example',
    sources,
    dependencies: [glesv2_dep, srm_dep])

Now, let's configure the project by running the following commands in your project directory:

$ cd your_project_dir
$ meson setup builddir

You should observe output confirming that the GLESv2 and SRM libraries have been found, and a new builddir directory should be created.

...
 
Host machine cpu family: x86_64
Host machine cpu: x86_64
Library GLESv2 found: YES
Library SRM found: YES
Build targets in project: 1
 
Found ninja-1.10.1 at /usr/bin/ninja

If this is not the case, and the libraries are not found, please double-check that you have installed the GLESv2 and SRM libraries correctly, or investigate if any environment configuration adjustments are necessary.

Now, in main.c let's set up an interface that allows SRM to open and close DRM devices.

#include <SRM/SRMCore.h>
#include <SRM/SRMDevice.h>
#include <SRM/SRMConnector.h>
#include <SRM/SRMConnectorMode.h>
#include <SRM/SRMList.h>
#include <SRM/SRMLog.h>
#include <GLES2/gl2.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
 
/* Opens a DRM device */
static int openRestricted(const char *path, int flags, void *userData)
{
    SRM_UNUSED(userData);
 
    // Here something like libseat could be used instead
    return open(path, flags);
}
 
/* Closes a DRM device */
static void closeRestricted(int fd, void *userData)
{
    SRM_UNUSED(userData);
    close(fd);
}
 
static SRMInterface srmInterface =
{
    .openRestricted = &openRestricted,
    .closeRestricted = &closeRestricted
};

This interface handles the management of DRM file descriptors during SRMCore's device scanning process and when you call srmCoreDestroy().

Instead of relying solely on the open() and close() functions, you might consider incorporating a library like libseat to enhance your program's compatibility with multi-seat setups, enabling seamless TTY switching (like in the srm-multi-seat example).

Let's proceed by creating an SRMCore instance using this interface. If any errors arise during the SRMCore creation process, we will ensure a graceful program exit.

// ...
 
int main()
{
    SRMCore *core = srmCoreCreate(&srmInterface, NULL);
 
    if (!core)
    {
        SRMFatal("[srm-example] Failed to create SRMCore.");
        return 1;
    }
 
    srmCoreDestroy(core);
 
    return 0;
}

Devices and Connectors

Now lets enumerate all avaliable devices (GPUs) and their respective connectors (displays).

// ...
 
int main()
{
    SRMCore *core = srmCoreCreate(&srmInterface, NULL);
 
    if (!core)
    {
        SRMFatal("[srm-example] Failed to create SRMCore.");
        return 0;
    }
 
    // Loop each GPU (device)
    SRMListForeach (deviceIt, srmCoreGetDevices(core))
    {
        SRMDevice *device = srmListItemGetData(deviceIt);
 
        SRMLog("[srm-example] Device %s connectors:", srmDeviceGetName(device));
 
        // Loop each GPU connector (screen)
        SRMListForeach (connectorIt, srmDeviceGetConnectors(device))
        {
            SRMConnector *connector = srmListItemGetData(connectorIt);
            SRMLog("[srm-example] - Connector %d %s %s %s.",
                   srmConnectorGetID(connector),
                   srmConnectorGetName(connector),
                   srmConnectorGetModel(connector),
                   srmConnectorGetManufacturer(connector));
        }
    }
 
    srmCoreDestroy(core);
 
    return 0;
}

Here, we are simply iterating over each SRMDevice (GPU/DRM device) and its associated SRMConnectors (screens/displays), printing the DRM id, name, model, and manufacturer of each. Afterward, we conclude the program.

Lets compile the program by running:

$ cd builddir
$ meson compile

If there are no errors during the build process, you should find a new executable file in the builddir directory named srm-example. You can run it with the following command:

$ ./srm-example

The output should display one or more devices along with their respective connectors information. For example, on my machine, which has a single GPU, the output appears as follows:

[srm-example] Device /dev/dri/card0 connectors:
[srm-example] - Connector 77 eDP-0 Color LCD Apple Computer Inc.
[srm-example] - Connector 84 DisplayPort-1 Unknown Unknown.
[srm-example] - Connector 92 HDMI-A-1 Unknown Unknown.
[srm-example] - Connector 98 DisplayPort-0 Unknown Unknown.
[srm-example] - Connector 104 HDMI-A-2 Unknown Unknown.
[srm-example] - Connector 108 HDMI-A-0 Unknown Unknown.

Please note that in the output, connectors may appear as "Unknown" for model and manufacturer if no display is attached to those connectors. This is the expected behavior.

In my case, you can observe that there is only one connected connector, which corresponds to my laptop screen (eDP-0). You can check the connectivity status of any connector with the srmConnectorIsConnected() function, which we will demonstrate in the upcoming sections.

Rendering

Now, let's delve into the process of rendering to the available connectors. Our approach involves setting up a unified interface for managing OpenGL rendering events, which will be shared across all connectors. While it's possible to employ distinct interfaces for each connector, for the sake of simplicity, we'll use a single interface here.

It's of utmost importance to underscore that these events are initiated by SRM itself and should not be manually triggered by you. Additionally, it's essential to recognize that all these events are executed within the rendering thread of each connector, operating independently from the main thread.

// ...
 
static void initializeGL(SRMConnector *connector, void *userData)
{
    SRM_UNUSED(userData);
 
    /* You must not do any drawing here as it won't make it to
     * the screen. */
 
    SRMConnectorMode *mode = srmConnectorGetCurrentMode(connector);
 
    glViewport(0, 
               0, 
               srmConnectorModeGetWidth(mode), 
               srmConnectorModeGetHeight(mode));
 
    // Schedule a repaint (this eventually calls paintGL() later, not directly)
    srmConnectorRepaint(connector);
}
 
static void paintGL(SRMConnector *connector, void *userData)
{
    SRM_UNUSED(userData);
 
    glClearColor((sinf(color) + 1.f) / 2.f,
                 (sinf(color * 0.5f) + 1.f) / 2.f,
                 (sinf(color * 0.25f) + 1.f) / 2.f,
                 1.f);
 
    color += 0.01f;
 
    if (color > M_PI*4.f)
        color = 0.f;
 
    glClear(GL_COLOR_BUFFER_BIT);
    srmConnectorRepaint(connector);
}
 
static void resizeGL(SRMConnector *connector, void *userData)
{
    /* You must not do any drawing here as it won't make it to
     * the screen.
     * This is called when the connector changes its current mode,
     * set with srmConnectorSetMode() */
 
    // Reuse initializeGL() as it only sets the viewport
    initializeGL(connector, userData);
}
 
static void pageFlipped(SRMConnector *connector, void *userData)
{
    SRM_UNUSED(connector);
    SRM_UNUSED(userData);
 
    /* You must not do any drawing here as it won't make it to
     * the screen.
     * This is called when the last rendered frame is now being
     * displayed on screen.
     * Google v-sync for more info. */
}
 
static void uninitializeGL(SRMConnector *connector, void *userData)
{
    SRM_UNUSED(connector);
    SRM_UNUSED(userData);
 
    /* You must not do any drawing here as it won't make it to
     * the screen.
     * Here you should free any resource created on initializeGL()
     * like shaders, programs, textures, etc. */
}
 
static SRMConnectorInterface connectorInterface =
{
    .initializeGL = &initializeGL,
    .paintGL = &paintGL,
    .resizeGL = &resizeGL,
    .pageFlipped = &pageFlipped,
    .uninitializeGL = &uninitializeGL
};
 
// ...

Lets see what each event does:

  • initializeGL: This event is called once after a connector is initialized with srmConnectorInitialize(). Here you should set up all your necessary OpenGL resources, such as shaders, texture loading, etc. In this specific case, it configures the viewport using the dimensions of the current connector mode (SRMConnectorMode). A connector can have multiple modes, each defining resolution and refresh rate. Additionally, it calls srmConnectorRepaint(), which schedules a new rendering frame (paintGL() call) asynchronously.
  • resizeGL: This event is triggered when the current connector mode changes (set with srmConnectorSetMode()). Here, the main task is to update the viewport to match the new dimensions.
  • paintGL: Inside this event handler, you should perform all the OpenGL rendering operations required for the current frame. In the provided example, the screen is simply cleared with a random color and a new frame is scheduled with srmConnectorRepaint().
  • pageFlipped: This event is triggered when the last rendered frame (in paintGL()) is now being displayed on the screen (check Multiple Buffering).
  • uninitializeGL: This event is triggered just before the connector is uninitialized. Here you should free the resources created in initializeGL().

Important :  It is imperative that you avoid initializing, uninitializing, or changing a connector's mode within its rendering thread, that is, from any of the event handlers. Doing so could lead to a deadlock or even cause your program to crash. Please be aware that this behavior is slated for correction in the upcoming SRM release.

Now lets use this interface to initialize all connected connectors.

// ...
 
int main()
{
    SRMCore *core = srmCoreCreate(&srmInterface, NULL);
 
    if (!core)
    {
        SRMFatal("[srm-example] Failed to create SRMCore.");
        return 1;
    }
 
    // Loop each GPU (device)
    SRMListForeach (deviceIt, srmCoreGetDevices(core))
    {
        SRMDevice *device = srmListItemGetData(deviceIt);
 
        SRMLog("[srm-example] Device %s connectors:", srmDeviceGetName(device));
 
        // Loop each GPU connector (screen)
        SRMListForeach (connectorIt, srmDeviceGetConnectors(device))
        {
            SRMConnector *connector = srmListItemGetData(connectorIt);
            SRMLog("[srm-example] - Connector %d %s %s %s.",
                   srmConnectorGetID(connector),
                   srmConnectorGetName(connector),
                   srmConnectorGetModel(connector),
                   srmConnectorGetManufacturer(connector));
 
            // Check if there is a display attached
            if (srmConnectorIsConnected(connector))
            {
                // Initialize the connector
                if (!srmConnectorInitialize(connector, &connectorInterface, NULL))
                {
                    SRMError("[srm-example] Failed to initialize connector %s",
                             srmConnectorGetName(connector));
                }
            }
        }
    }
 
    // Sleep 10 secs
    usleep(10000000);
 
    srmCoreDestroy(core);
 
    return 0;
}

Now, we're checking each connector's display attachment status using srmConnectorIsConnected() and initializing them with srmConnectorInitialize().

Additionally, note that we've included a usleep() call at the end to wait for 10 seconds. This delay is necessary because, as said before, each connector performs its rendering in its own thread. Blocking the main thread ensures that the program doesn't exit immediately.

Re-compile with meson compile and before running the program, switch to a free virtual terminal (TTY) by pressing CTRL + ALT + F[1, 2, 3 ..., 10] or with the chvt N command and launch it from there. You should observe your displays changing colors rapidly for 10 seconds.

If you encounter issues, please attempt to run the program with superuser privileges or by adding your user to the video group. This may resolve any potential permission-related problems.

Additionally, you have the option to set the SRM_DEBUG environment variable to 3 in order to enable fatal, error and warning logging messages.

Hotplugging Events

Thus far, we've discussed the process of identifying available connectors and initializing them at program startup. However, a critical consideration is what happens if one of these connectors becomes disconnected while the program is running, such as unplugging an HDMI display.

In such scenarios, the connectors are programmed to undergo automatic uninitialization when they become disconnected, triggering their corresponding uninitializeGL() event. However, you do have the flexibility to include listeners to detect and respond to connectors plugging and unplugging events, as exemplified below:

// ...
 
static void connectorPluggedEventHandler(SRMListener *listener, SRMConnector *connector)
{
    SRM_UNUSED(listener);
 
    /* This is called when a new connector is avaliable (E.g. Plugging an HDMI display). */
 
    /* Got a new connector, let's render on it */
    if (!srmConnectorInitialize(connector, &connectorInterface, NULL))
        SRMError("[srm-example] Failed to initialize connector %s",
                             srmConnectorGetName(connector));
}
 
static void connectorUnpluggedEventHandler(SRMListener *listener, SRMConnector *connector)
{
    SRM_UNUSED(listener);
    SRM_UNUSED(connector);
 
    /* This is called when a connector is no longer avaliable (E.g. Unplugging an HDMI display). */
 
    /* The connnector is automatically uninitialized after this event (if initialized)
     * so calling srmConnectorUninitialize() is not necessary. */
}
 
int main()
{
    SRMCore *core = srmCoreCreate(&srmInterface, NULL);
 
    if (!core)
    {
        SRMFatal("[srm-example] Failed to create SRMCore.");
        return 1;
    }
 
    // Subscribe to Udev events
    srmCoreAddConnectorPluggedEventListener(core, &connectorPluggedEventHandler, NULL);
    srmCoreAddConnectorUnpluggedEventListener(core, &connectorUnpluggedEventHandler, NULL);
 
    // Loop each GPU (device)
    SRMListForeach (deviceIt, srmCoreGetDevices(core))
    {
        SRMDevice *device = srmListItemGetData(deviceIt);
 
        SRMLog("[srm-example] Device %s connectors:", srmDeviceGetName(device));
 
        // Loop each GPU connector (screen)
        SRMListForeach (connectorIt, srmDeviceGetConnectors(device))
        {
            SRMConnector *connector = srmListItemGetData(connectorIt);
            SRMLog("[srm-example] - Connector %d %s %s %s.",
                   srmConnectorGetID(connector),
                   srmConnectorGetName(connector),
                   srmConnectorGetModel(connector),
                   srmConnectorGetManufacturer(connector));
 
            if (srmConnectorIsConnected(connector))
            {
                if (!srmConnectorInitialize(connector, &connectorInterface, NULL))
                {
                    SRMError("[srm-example] Failed to initialize connector %s",
                             srmConnectorGetName(connector));
                }
            }
        }
    }
 
    while (1)
    {
        /* Udev monitor poll DRM devices/connectors hotplugging events (-1 disables timeout).
         * To get a pollable FD use srmCoreGetMonitorFD() */
 
        if (srmCoreProccessMonitor(core, -1) < 0)
            break;
    }
 
    srmCoreDestroy(core);
 
    return 0;
}

Now, each time a new connector becomes available, connectorPluggedEventHandler() will be invoked, allowing us to initialize the new connector. Similarly, we can detect when a connector is disconnected using connectorUnpluggedEventHandler(). If an initialized connector gets disconnected, it is automatically uninitialized, triggering the uninitializeGL() function.

One notable change is the replacement of the usleep() function with an infinite while loop. Within this loop, we poll a udev monitor file descriptor using the srmCoreProcessMonitor() function. This change is necessary to allow SRM to check and invoke the hotplugging events.

To test these changes, recompile the program and try connecting and disconnecting an external display on the fly. You should observe that it is automatically initialized and uninitialized each time, reflecting the hotplugging events.

Buffers

SRM lets you create buffers (OpenGL textures) from various sources, including DMA planes, GBM buffer objects, Wayland DRM buffers, and main memory buffers. These buffers can be used for rendering on all connectors, even if they belong to different devices.

Let's see how to create and use a buffer from main memory:

#include <SRM/SRMBuffer.h>
 
// ...
 
// A 128 x 256 ARGB8 image in main memory
UInt8 pixelsSource[128 * 256 * 4];
 
// Pass NULL as the allocator device to share the buffer across all devices
SRMBuffer *buffer = srmBufferCreateFromCPU(
    core, // SRM core
    NULL, // allocator device
    128, // src width
    256, // src height
    128 * 4, // src stride
    pixelsSource, 
    DRM_FORMAT_ARGB8888);
 
if (!buffer)
{
    SRMError("Failed to create a buffer from main memory.");
    exit(1);
}
 
// ...
 
// Use the buffer in a connector rendering thread (paintGL())
 
// First, get the device this connector belongs to
SRMDevice *connectorDevice = srmConnectorGetDevice(connector);
 
// Then, get the device responsible for rendering for the connector device (usually the same device)
SRMDevice *connectorRendererDevice = srmDeviceGetRendererDevice(connectorDevice);
 
// Retrieve the OpenGL texture ID
GLuint textureId = srmBufferGetTextureID(connectorRendererDevice, buffer);
 
if (textureId == 0)
    SRMError("Failed to get the GL texture ID from SRMBuffer.");
 
// ...

It's essential to acknowledge that all buffers are shared across all devices, with the exception of those created from GBM buffers or Wayland DRM buffers, which may not always be supported by all devices.

Furthermore, you have the option to read from and write to these buffers, and they are automatically synchronized across all devices. For more in-depth information, please refer to the SRMBuffer documentation.

As we reach the end of this journey, I hope you've found it to be a valuable and enlightening experience. If you're eager to explore further, consider checking out the following links:

Comentarios

Entradas populares de este blog

SRM v0.2.0-1 Release