Google ANGLEの描画領域をC#のForm Application に埋め込む

開発中エンジンの描画に用いているのはOpenGL ES 2.0であり、これをWindows上で使うためにGoogle ANGLEというGLES2/3ライブラリを使っている。

これは元々GoogleWebブラウザChrome上で安定した品質のWebGL対応を行うにあたり、WindowsにおけるOpenGL実装の優劣に左右されないよう作り上げた、DirectXを用いてOpenGL ES 2.0/3.xAPIの動作を模倣するライブラリであるわけなのだけども、同時にiOSAndroidでサポートされているのと同じGLES2のAPIを使うことができるため、クロスプラットフォームエンジンの開発にも使えるわけだ。


描画APIはこのANGLEで何とかなるし、エンジン自体はiOS/Android/Windowsいずれでも使えるC++で記述されるから良いのだけども、Windows上で開発用のGUIツール周りを実装する際にはわざわざC++で書くのもめんどくさいので、サクッとC#などで作ってしまいたい。しかし、ツール上でGLES2を用いたプレビュー表示を出したいこともあるわけで、このC++で記述されたANGLEによる描画域をC#で組まれた.NET Formアプリ中に埋め込みたい、という需要があった。そんなわけでその方法。

  1. ANGLEではWin32APIのHWND値をEGLに渡すことで、そのウィンドウ範囲を描画域として初期化する。
  2. .NET の UserControl は、.Handle プロパティ値としてコントロール自身のHWND値を保持している。

という単純な事実から、

GLES2描画域をUserControlとしてForm中に作り、そのUserControl.Handle値をC++で書かれたUnmanaged codeに渡す。そこでその HWND 値をEGLで初期化することで、GLES2描画域をForm中に作ることができる。

ということになる。Unmanaged codeの呼び出しには System.Runtime.InteropServices を用いる。

こんな感じだろうか。まずはC#側。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Reflection;
using System.Runtime.InteropServices;

namespace AppNameSpace
{
    public partial class GLES2View : UserControl
    {
        [DllImport("GLES2DLL.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern IntPtr gles2CreateWindow(IntPtr parent_hWnd);

        [DllImport("GLES2DLL.dll", CallingConvention = CallingConvention.Cdecl)]
        private static extern void gles2DestroyWindow(IntPtr gles2);

        private IntPtr envGLES2 = IntPtr.Zero;

        public GLES2View()
        {
            InitializeComponent();
        }

        public void Create()
        {
            envGLES2 = gles2CreateWindow(this.Handle);
        }

        public void Destroy()
        {
            if (envGLES2 != IntPtr.Zero)
            {
                gles2DestroyWindow(envGLES2);
                envGLES2 = IntPtr.Zero;
                this.Invalidate();
            }
        }
    }
}

上記では省略したけど、Dispose処理中にDestroy()を呼んでやるのを忘れずに。
そしてC++側。C#から呼び出すためにDLLとしてビルドする必要がある。
上記例のC#コードに合わせるのであれば GLES2DLL.dll としてビルドする。

#include "CWin32GLES2.h"

// C#からシンボルを指定するため extern "C" で括る
extern "C" {
	// DLL export関数として定義。
	__declspec(dllexport) void * gles2CreateWindow(HWND hWnd)
	{
		CWin32GLES2 * gles2 = new CWin32GLES2(hWnd);
		return (void *)gles2;
	}

	// DLL export関数として定義。
	__declspec(dllexport) void gles2DestroyWindow(void * ptr)
	{
		CWin32GLES2 * gles2 = (CWin32GLES2 *)ptr;
		delete gles2;
	}
}

上記 DLL export 関数では CWin32GLES2 クラスのインスタンスを生成し、そのクラスがHWNDの初期化を行っている。このCWin32GLES2は以下のような感じ。traditionalな書き方になっているのはご容赦願いたい。

まずはヘッダ。

#ifndef CWin32GLES2_h
#define CWin32GLES2_h

#include <tchar.h>
#include <Windows.h>

#include <GLES2/gl2.h>
#include <EGL/egl.h>


class CWin32GLES2
{
protected:
	HWND			m_hWnd;
	HDC			m_hDC;
	EGLDisplay		m_eglDisplay;
	EGLSurface		m_eglSurface;
	EGLContext		m_eglContext;

public:
	CWin32GLES2(HWND hWnd);
	virtual ~CWin32GLES2();

private:
	bool createWindow(HWND hWnd);
	void destroyWindow();
	void destroyEGL();
	bool createEGL();
};

#endif // CWin32GLES2_h

お次は実装。

#include "CWin32GLES2.h"
#include <memory>

CWin32GLES2::CWin32GLES2(HWND hWnd)
	: m_eglDisplay(EGL_NO_DISPLAY)
	, m_eglSurface(EGL_NO_SURFACE)
	, m_eglContext(EGL_NO_CONTEXT)
	, m_hWnd(0)
	, m_hDC(0)
{
	if (!createWindow(hWnd)) {
		throw std::bad_alloc();
	}

	if (!createEGL()) {
		destroyWindow();
		throw std::bad_alloc();
	}
}

CWin32GLES2::~CWin32GLES2()
{
	destroyEGL();
	destroyWindow();
}

void
CWin32GLES2::destroyWindow()
{
	if (m_hDC) {
		ReleaseDC(m_hWnd, m_hDC);
		m_hDC = 0;
	}
	if (m_hWnd) {
		m_hWnd = 0;
	}
}

bool
CWin32GLES2::createWindow(HWND hWnd)
{
	m_hWnd = hWnd;

	m_hDC = GetDC(m_hWnd);
	if (!m_hDC) {
		destroyWindow();
		return false;
	}

	return true;
}


void
CWin32GLES2::destroyEGL()
{
	if (m_eglContext != EGL_NO_CONTEXT) {
		eglMakeCurrent(m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
		eglDestroyContext(m_eglDisplay, m_eglContext);
		m_eglContext = EGL_NO_CONTEXT;
	}

	if (m_eglSurface != EGL_NO_SURFACE) {
		eglDestroySurface(m_eglDisplay, m_eglSurface);
		m_eglSurface = EGL_NO_SURFACE;
	}

	if (m_eglDisplay != EGL_NO_DISPLAY) {
		eglTerminate(m_eglDisplay);
		m_eglDisplay = EGL_NO_DISPLAY;
	}
 }

bool
CWin32GLES2::createEGL()
{
	m_eglDisplay = eglGetDisplay(m_hDC);
	if (m_eglDisplay == EGL_NO_DISPLAY) {
		m_eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
		if (m_eglDisplay == EGL_NO_DISPLAY) return false;
	}

	if (!eglInitialize(m_eglDisplay, 0, 0)) {
		m_eglDisplay = EGL_NO_DISPLAY;
		return false;
	}

	const EGLint configs[] = {
		EGL_RED_SIZE, 5,
		EGL_GREEN_SIZE, 6,
		EGL_BLUE_SIZE, 5,
		EGL_ALPHA_SIZE, 8,
		EGL_DEPTH_SIZE, 8,
		EGL_STENCIL_SIZE, EGL_DONT_CARE,
		EGL_SAMPLE_BUFFERS, 0,
		EGL_NONE
	};

	EGLConfig configuration;
	EGLint numFoundConfigurations = 0;
	if (!eglChooseConfig(m_eglDisplay, configs, &configuration, 1, &numFoundConfigurations)) {
		destroyEGL();
		return false;
	}

	if (numFoundConfigurations < 1) {
		destroyEGL();
		return false;
	}

	m_eglSurface = eglCreateWindowSurface(m_eglDisplay, configuration, static_cast<EGLNativeWindowType>(m_hWnd), 0);
	if (!m_eglSurface) {
		destroyEGL();
		return false;
	}

	eglBindAPI(EGL_OPENGL_ES_API);

	EGLint attribList[] = {
		EGL_CONTEXT_CLIENT_VERSION, 2,
		EGL_NONE
	};

	m_eglContext = eglCreateContext(m_eglDisplay, configuration, EGL_NO_CONTEXT, attribList);
	if (m_eglContext == EGL_NO_CONTEXT) {
		destroyEGL();
		return false;
	}

	if (!eglMakeCurrent(m_eglDisplay, m_eglSurface, m_eglSurface, m_eglContext)) {
		destroyEGL();
		return false;
	}

	return true;
}

…と、こんな感じでHWND渡してEGLでいつものように初期化するだけで、UserControl として ANGLE による GLES2 描画を埋め込むことができるようになる。ひとたび描画環境ができてしまえば、あとは既存のコードを同じようにDLLとしてC#環境から呼び出せるようにするだけで、ツール中でエンジンによる描画プレビューを作れてしまうわけだ。まあ、EGLによるHWND初期化部分のコードは概ね人様のパクリなのだけども、だいたい誰が書いても同じようなコードになるわな。


大抵のサンプルでは、描画先のWindowをWin32APIのCreateWindow(Ex)で作った上で同様のEGLによる初期化をやっているのだけども、この「Windowを作ってHWNDを取得する」ところまではC#でFormを作った時点で終わっているので、そこから先だけやってやればいい、というお話。