В этой статье я хочу рассказать, как просто можно обновлять текстуры OpenGLES через DMABUF. Поискал по Хабру и к своему удивлению не обнаружил ни одной статьи на эту тему. В Хабр Q&A тоже ничего такого не нашел. И это для меня немного странно. Технология появилась довольно давно, хотя информации о ней действительно в сети не много, вся она расплывчатая и противоречивая.

Я всю эту информацию собирал по крупицам из разных источников, прежде чем смог написать вот такой видео плеер, как на демке выше. Здесь, на демке, мой самописный видео плеер, основанный на библиотеке gstreamer, загружает видео кадры в текстуру OpenGLESv2 каждый раз перед рендерингом. Работает на Raspberry Pi4. Кадры просто копируются в специальным образом выделенную память — а уж DMA переносит их в память GPU, в текстуру. Далее расскажу, как я это делал.

Обычно программист использующий OpenGLESv2 создает текстуру только однажды и потом просто рендерит ее на объекты сцены. Так и бывает, ведь костюмы у персонажей меняются редко и иногда перезагрузить текстуру с помощью glTexSubImage2D() не трудно. Однако, настоящие проблемы начинаются, когда текстура динамичная, когда нужно обновлять ее чуть ли не каждый кадр во время рендеринга. Функция glTexSubImage2D() работает очень медленно. Ну как медленно — конечно, все зависит от компьютера и от графической карты. Я хотел найти такое решение, чтобы работало даже на слабых одноплатниках вроде Raspberry.

Архитектура многих современных компьютеров, в том числе одноплатников на SoC, такова, что память процессора отделена от памяти GPU. Обычно у пользовательских программ нет прямого доступа к памяти GPU и нужно пользоваться различными функциями API вроде той же glTexSubImage2D(). Причем, где-то читал, что внутреннее представление текстуры может отличаться от традиционного представления картинок в виде последовательности пикселей. Уж не знаю насколько это правда. Возможно.

Итак, что дает мне технология DMABUF? Специальным образом выделяется память и процесс из любого потока может туда просто писать пиксели когда ему захочется. DMA само будет переносить все изменения в текстуру находящуюся в памяти GPU. Ну разве это не прелестно?

Сразу скажу, что я знаю про PBO — Pixel Buffer Object, обычно с помощью PBO делается динамическое обновление текстур, там так же вроде бы используется DMA, но PBO появилось только в OpenGLESv3 и далеко не во всех реализациях. Так что нет — увы, это не мой путь.

Статья может быть интересна как программистам для Raspberry, так и разработчикам игр и наверное даже программистам Android, так как и там используется OpenGLES и я уверен, что эта технология DMABUF там так же присутствует (по крайней мере я уверен, что можно ею пользоваться из Android NDK).

Я буду писать программу использующую DMABUF на Raspberry Pi4. Программа так же должна (и будет) работать на обычных Intel компьютерах x86/x86_64 скажем под убунтой.

В этой статье я предполагаю, что вы уже знаете, как программировать графику с АПИ OpenGLESv2. Хотя, тут этих вызовов будет не очень много. В основном у нас будет магия ioctl.

Итак, первое, что нужно сделать — нужно убедиться, что имеющееся на платформе АПИ должно поддерживать DMABUF. Для этого нужно проверить список расширений EGL:

char* EglExtString = (char*)eglQueryString( esContext->eglDisplay, EGL_EXTENSIONS );
if( strstr( EglExtString, "EGL_EXT_image_dma_buf_import") )
{
	cout << "DMA_BUF feature must be supported!!!\n";
}

Так мы сразу поймем, есть ли надежда использовать DMABUF или надежды нет. Вот к примеру на Raspberry Pi3 и всех предыдущих платах надежды нет. Там вообще даже OpenGLESv2 какой-то урезанный, через специальные библиотеки броадком BRCM. А вот уже на Raspberry Pi4 — настоящий OpenGLES, расширение EGL_EXT_image_dma_buf_import есть, ура.

Сразу отмечу, какая у меня ОС стоит на одноплатнике Pi4, а то с этим так же могут быть проблемы:

pi@raspberrypi:~ $ lsb_release -a
No LSB modules are available.
Distributor ID: Raspbian
Description:    Raspbian GNU/Linux 10 (buster)
Release:        10
Codename:       buster
pi@raspberrypi:~ $ uname -a
Linux raspberrypi 4.19.75-v7l+ #1270 SMP Tue Sep 24 18:51:41 BST 2019 armv7l GNU/Linux

Так же отмечу, что расширение EGL_EXT_image_dma_buf_import есть на Orange Pi PC (Mali-400)/PC2 (Mali-450), если конечно вы сможете запустить на этих платах Mali GPU (в официальных сборках его нет, я ставил на Armbian, плюс сам делал сборку драйвера ядра). То есть DMABUF — есть почти везде. Нужно только брать и пользоваться.

Дальше нужно открыть файл /dev/dri/card0 или /dev/dri/card1 — какой-то из них, тут зависит от платформы, бывает по разному, нужно искать тот файл, который поддерживает DRM_CAP_DUMB_BUFFER:

int OpenDrm()
{
	int fd = open("/dev/dri/card0", O_RDWR | O_CLOEXEC);
	if( fd < 0 )
	{
		cout << "cannot open /dev/dri/card0\n";
		return -1;
	}

	uint64_t hasDumb = 0;
	if( drmGetCap(fd, DRM_CAP_DUMB_BUFFER, &hasDumb) < 0 )
	{
		close( fd );
		cout << "/dev/dri/card0 has no support for DUMB_BUFFER\n";

		//maybe Raspberry Pi4 or other platform
		fd = open("/dev/dri/card1", O_RDWR | O_CLOEXEC);
		if( fd < 0 )
		{
			cout << "cannot open /dev/dri/card1\n";
			return -1;
		}

		hasDumb = 0;
		if( drmGetCap(fd, DRM_CAP_DUMB_BUFFER, &hasDumb) < 0 )
		{
			close( fd );
			cout << "/dev/dri/card1 has no support for DUMB_BUFFER\n";
			return -1;
		}
	}

	if( !hasDumb )
	{
		close( fd );
		cout << "no support for DUMB_BUFFER\n";
		return -1;
	}

	//Get DRM authorization
	drm_magic_t magic;
	if( drmGetMagic(fd, &magic) )
	{
		cout << "no DRM magic\n";
		close( fd );
		return -1;
	}

	Window root = DefaultRootWindow( x_display );
	if( !DRI2Authenticate( x_display, root, magic ) )
	{
		close( fd );
		cout << "Failed DRI2Authenticate\n";
		return -1;
	}
	cout << "DRM fd "<< fd <<"\n";
	return fd;
}

Тут кстати есть необъяснимая для меня тонкая тонкость. В некоторых платформах нет библиотек, которые давали бы функцию DRI2Authenticate(). Ее к примеру нет на распберри и в 32х битной версии для Orange Pi PC. Странно все это. Но я нашел такой репозиторий на GITHUB: github.com/robclark/libdri2 его можно взять, собрать и поставить, тогда все ок. Странно, что в моей Ubuntu 18 (64х битная) на ноутбуке с этим проблем нет.

Если смогли найти и открыть /dev/dri/cardX можно двигаться дальше. Нужно получить доступ к трем очень нужным функциям KHR (Khronos):

PFNEGLCREATEIMAGEKHRPROC  funcEglCreateImageKHR = nullptr;
PFNEGLDESTROYIMAGEKHRPROC funcEglDestroyImageKHR = nullptr;
PFNGLEGLIMAGETARGETTEXTURE2DOESPROC funcGlEGLImageTargetTexture2DOES = nullptr;

...
funcEglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC) eglGetProcAddress("eglCreateImageKHR");
funcEglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC) eglGetProcAddress("eglDestroyImageKHR");
funcGlEGLImageTargetTexture2DOES = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES");
if( funcEglCreateImageKHR && funcEglDestroyImageKHR && funcGlEGLImageTargetTexture2DOES )
{
	cout << "DMA_BUF feature supported!!!\n";
}
else
{
	CloseDrm();
}

Теперь нужна функция, которая создает область памяти для DMABUF. Функция принимает параметрами ширину битмапа, высоту, а так же указатели по которым будет возвращен хандлер файлового дескриптора DmaFd и указатель на память битмапа Plane.

nt CreateDmaBuf( int Width, int Height, int* DmaFd, void** Plane )
{
	int dmaFd = *DmaFd = 0;
	void* pplane = *Plane = nullptr;

	// Create dumb buffer
	drm_mode_create_dumb buffer = { 0 };
	buffer.width = Width;
	buffer.height = Height;
	buffer.handle = 0;
	buffer.bpp = 32; //Bits per pixel
	buffer.flags = 0;

	int ret = drmIoctl( DriCardFd, DRM_IOCTL_MODE_CREATE_DUMB, &buffer);
	cout << "DRM_IOCTL_MODE_CREATE_DUMB " << buffer.handle << " " << ret << "\n";
	if (ret < 0)
	{
		cout << "Error cannot DRM_IOCTL_MODE_CREATE_DUMB\n";
		return -1;
	}

	// Get the dmabuf for the buffer
	drm_prime_handle prime;
	memset(&prime, 0, sizeof prime);
	prime.handle = buffer.handle;
	prime.flags = /*DRM_CLOEXEC |*/ DRM_RDWR;

	ret = drmIoctl( DriCardFd, DRM_IOCTL_PRIME_HANDLE_TO_FD, &prime);
	if (ret < 0)
	{
		cout << "Error cannot DRM_IOCTL_PRIME_HANDLE_TO_FD " << errno << " " << ret <<"\n";
		return -1;
	}
	dmaFd = prime.fd;
	// Map the buffer to userspace
	int Bpp = 32;
	pplane = mmap(NULL, Width*Height*Bpp/8, PROT_READ | PROT_WRITE, MAP_SHARED, dmaFd, 0);
	if( pplane == MAP_FAILED )
	{
		cout << "Error cannot mmap\n";
		return -1;
	}

	//return valid values
	*DmaFd = dmaFd;
	*Plane = pplane;
	cout << "DMABUF created "<< dmaFd << " " << (void*)Plane <<"\n";
	return 0;
}

Теперь нужно создать EGL image связанный с хандлером DmaFd:

int CreateDmaBufferImage( ESContext* esContext, int Width, int Height, int* DmaFd, void** Plane, EGLImageKHR* Image )
{
	int dmaFd = 0;
	void* planePtr = nullptr;

	int Bpp = 32;
	int ret0 = CreateDmaBuf( Width, Height, &dmaFd, &planePtr );
	if( ret0<0 )
		return -1;

	EGLint img_attrs[] = {
		EGL_WIDTH, Width,
		EGL_HEIGHT, Height,
		EGL_LINUX_DRM_FOURCC_EXT, DRM_FORMAT_ABGR8888,
		EGL_DMA_BUF_PLANE0_FD_EXT, dmaFd,
		EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0,
		EGL_DMA_BUF_PLANE0_PITCH_EXT, Width * Bpp / 8,
		EGL_NONE
	};

	EGLImageKHR image = funcEglCreateImageKHR( esContext->eglDisplay, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, 0, &img_attrs[0] );

	*Plane = planePtr;
	*DmaFd  = dmaFd;
	*Image = image;
	cout << "DMA_BUF pointer " << (void*)planePtr << "\n";
	cout << "DMA_BUF fd " << (int)dmaFd << "\n";
	cout << "EGLImageKHR " << image << "\n";
	return 0;
}

Ну и, наконец, наши мытарства почти окончены, и мы должны связать EGL image и OpenGLESv2 image. Функция возвращает указатель на память в адресном пространстве процесса. Туда можно просто писать из любого потока процессора и все изменения со временем автоматически оказываются в текстуре GPU через DMABUF.

void* CreateVideoTexture( ESContext* esContext, int Width, int Height )
{
	CreateDmaBufferImage( esContext, Width, Height, &esContext->DmaFd, &esContext->Plane, &esContext->ImageKHR );
	GLuint texId;
	glGenTextures ( 1, &texId );
	glBindTexture ( GL_TEXTURE_2D, texId );
	glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
	glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
	glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE );
	glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE );
	funcGlEGLImageTargetTexture2DOES(GL_TEXTURE_2D, esContext->ImageKHR );
	checkGlError( __LINE__ );
	UserData *userData = (UserData*)esContext->userData;
	userData->textureV = texId;
	userData->textureV_ready = true;
	return esContext->Plane;
}

Функция GlEGLImageTargetTexture2DOES(..) как раз и делает это связывание. Она использует обычное создание id текстуры glGenTextures(..) и связывает ее с ранее созданной esContext->ImageKHR EGL image. После этого текстуру userData->textureV можно использовать в обычных шейдерах. А указатель esContext->Plane и есть указатель на область в памяти куда нужно писать для обновления текстуры.

Приведу фрагмент кода, который производит копирование видео кадра:

GstFlowReturn on_new_sample( GstAppSink *pAppsink, gpointer pParam )
{
	GstFlowReturn ret = GST_FLOW_OK;
	GstSample *Sample = gst_app_sink_pull_sample(pAppsink);
	if( Sample )
	{
		if( VideoWidth==0 || VideoHeight==0 )
		{
			GstCaps* caps = gst_sample_get_caps( Sample );
			GstStructure* structure = gst_caps_get_structure (caps, 0);
			gst_structure_get_int (structure, "width", &VideoWidth);
			gst_structure_get_int (structure, "height", &VideoHeight);
			cout << "Stream Resolution " << VideoWidth << " " << VideoHeight << "\n";
		}

		GstBuffer *Buffer = gst_sample_get_buffer( Sample );
		if( Buffer )
		{
			GstMapInfo MapInfo;
			memset(&MapInfo, 0, sizeof(MapInfo));
			gboolean Mapped = gst_buffer_map( Buffer, &MapInfo, GST_MAP_READ );
			if( Mapped )
			{
				if( dmabuf_ptr )
					memcpy( dmabuf_ptr, MapInfo.data, MapInfo.size );
				gst_buffer_unmap( Buffer, &MapInfo);
				frame_ready = true;
				update_cv.notify_one();
			}
		}
		gst_sample_unref( Sample );
	}
	return ret;
}

Эта функция вызывается самим gstreamer каждый раз, когда появляется новый видео кадр. Мы его извлекаем с помощью gst_app_sink_pull_sample(). В этой функции есть memcpy(), которая копирует кадр в память DMABUF. Затем устанавливается флаг frame_ready и через std::condition_variable update_cv.notify_one() пробуждается поток, который занимается рендерингом.

Вот пожалуй и все…

Хотя нет, вру. Еще остаются вопросы синхронизации.

Первое — процессор пишет в память, но эти записи могут оказаться в кэше процессора и подзадержаться там, нужно после записи сделать флуш кэша. Второе — было бы не плохо точно знать, когда DMA уже отработало и можно начинать рендерить. Честно говоря, если первое я еще представляю, как сделать, то второе — нет. Если у вас есть идеи — напишите в комментариях.

И еще один момент. Я использую gstreamer, который воспроизводит видеофайл. Я добавил в pipeline самописный appsink, который получает видео кадры. Я беру пикселы из видео кадров и просто копирую их memcpy() в область памяти DMABUF. Рендеринг идет в отдельном потоке, main(). Но вот хотелось бы избавиться и от этого копирования. Каждое копирование — это зло. Есть даже такой термин zero-copy. Причем судя по документации вроде бы сам gstreamer может отдавать кадры сразу в DMABUF. К сожалению ни одного реального примера я не нашел. Смотрел исходники gstreamer — что-то про это есть, но как этим точно пользоваться не понятно. Если знаете, как сделать настоящий zero-copy кадров с gstreamer в текстуру OpenGLESv2 — напишите.

Пожалуй последнее замечание: в моем проекте я использую 32х битные битмапы, что конкретно в моем случае не есть хорошо. Гораздо разумнее было бы из gstreamer забирать YUV, тогда объем видеокадра получается существенно меньше, но усложняется логика — мне пришлось бы делать 3 DMABUF для трех текстур по отдельности Y, U, V. Ну и шейдер так же усложняется, нужно будет YUV конвертировать в ARGB прямо в шейдере.

Посмотреть весь проект можно на github. Однако, заранее прошу прощения у любителей чистого и правильного кода /стиля. Признаю, что писалось небрежно с помощью гугло-копи-пасты.

Комментарии (13)


  1. gbg
    24.12.2019 10:35
    +1

    С карточками NVIDIA на десктопе и родными драйверами, увы, трюк не прокатывает.

    eglCreateImage
    выдает 0x3008 (BadDisplay)

    На эту тему даже у самой NVIDIA есть топик


    1. nckma Автор
      24.12.2019 10:40

      Хм… печально.
      Там люди советуют использовать eglCreateImageKHR() вместо eglCreateImage()… Может поможет?


      1. gbg
        24.12.2019 10:50
        +1

        Не помогает, проверял. В теории, должны сработать eglStreams, однако, wayland основанный на них, на проприетарных драйверах, у меня, опять же, не стартует. Возможно, руки кривые.

        Рискну предположить, что причина того, что NVIDIA не дружит с DMABUF в этом


        1. nckma Автор
          24.12.2019 10:57
          +1

          Спасибо за познавательную ссылку.
          Вероятно вы правы. А у меня честно говоря все компьютеры с Intel графикой — я и не знал, что такие проблемы есть.


  1. al_sh
    24.12.2019 10:52
    +1

    У 4-ой малины уже, вроде, есть GLES3 на борту.


    1. nckma Автор
      24.12.2019 10:58

      Так и есть GLES3, но наличие DMABUF считаю полезной фичей.


      1. al_sh
        24.12.2019 11:03
        +1

        Дли синхронизации пользую glFenceSync/glWaitSync. На малине не пробовал, но вроде, не gles3 должно работать


  1. Rambden
    24.12.2019 16:41
    +1

    Чтобы достичь zero-copy вам в первую очередь надо отказаться от плагинов videoconvert и decodebin.
    На Rpi4 должен быть доступен omxh264dec, а он уже умеет сразу отдавать dmabuf, если выставить соответствующие параметры. Эту память уже можно сразу маппить на EGLImage.
    Делать новый eglImage на каждый плейн не надо — как правило платформа поддерживает NV12, который отдается декодером.


    1. nckma Автор
      24.12.2019 16:51

      Ну так-то я в конце приписал, что гораздо разумнее забирать YUV. То есть подразумевал, что videoconvert действительно не нужен. Тут согласен.
      omxh264dec в Rpi4 есть, но как-то не очень работает. Вместо него теперь v4l драйвер и он дает аппаратное декодирование видео. decodebin его и подхватывает.
      Вот как «выставить соответствующие параметры, чтоб получить dmabuf» для EGLImage нигде не нашел ни одного примера.
      По поводу того, что не нужны несколько отдельных eglImage на каждую plane не соглашусь. Ведь тогда невозможно будет написать шейдер, особенно с NV12. Как?


      1. Rambden
        24.12.2019 17:26
        +1

        В v4l2h264dec есть опция «capture-io-mode», по-умолчанию стоит в «0 (auto)». Если поставить «4» то это будет DMABUF. Далее вам надо проверить что память в буффере и правда dma при помощи gst_is_dmabuf_memory() ну и далее извлечь файловые дескрипторы и страйд/оффсет для каждого плейна (см. документацию на GstVideoMeta).

        По поводу того, что не нужны несколько отдельных eglImage на каждую plane не соглашусь. Ведь тогда невозможно будет написать шейдер, особенно с NV12. Как?


        При создании eglImage в аттрибуты можно добавить до трех плейнов, читайте спецификацию расширения EXT_image_dma_buf_import
        Используйте другой target для текстуры — GL_TEXTURE_EXTERNAL_OES вместо GL_TEXTURE_2D и аппаратный семплер samplerExternalOES в пиксельном шейдере.


        1. nckma Автор
          24.12.2019 19:47

          Я был бы премного благодарен, если бы вы смогли кинуть ссылку на пример использования dmabuf с таким использованием, как вы рассказываете.
          А то получается, что я к примеру сто раз читал спецификацию EXT_image_dma_buf_import и видел, что там может быть несколько plane, но из этой доки абсолютно не понятно, как потом в шейдере извлекать пикселы из такого image.
          И наоборот, перечитав кучу примеров по шейдерам никогда не встретишь пример самплера из multi-plane текстуры.


          1. gbg
            25.12.2019 12:43
            +1

            Там у шейдера специальный сэмплер (GL_OES_EGL_image_external), который сам конвертирует YUV в RGB.

            #extension GL_OES_EGL_image_external : require
            uniform samplerExternalOES sTex;

            У товарища w23 я нашел прекрасную доработку для проекта obs — стратегия использования DMABUF там аналогичная.


            1. nckma Автор
              25.12.2019 12:53
              +1

              Спасибо, посмотрю…