【前言】
把Cocos2dx渲染到另一個應用程序框架中的方法,在2.x時代有很多大神已經實現了,而3.x的做法網上幾乎找不着。這兩天抽空強行折騰了一下,不敢獨享,貼出來供大家參考。
【已知存在的問題】
程序退出時會發生非常嚴重的內存泄漏,博主檢查了很久,但技術不夠暫時無法解決。如果有大神能搞定,求告知一下做法,謝謝!
在程序從開始運行到關閉期間,有且僅有一個cocos2dx窗體存在時可以選擇性無視內存泄漏。如果非常在意這一點,建議使用cocos2d-x 2.2.6這個版本,放在MFC中的內存泄漏很小。
*使用VLD檢查泄漏會報錯
【為什么要這么做】
在進行游戲開發途中,多多少少會用到一些輔助工具,比如CocosStudio。但是在更多的時候,CocosStudio並不能以不變應萬變(比如在博文《我用Cocos2d-x制作〈Love Live!學院偶像祭〉的Live場景》中提到的譜面編輯器的功能,CocoStudio無法做到)。在這種情況下,開發人員就需要一款針對當前項目而設計的工具。
如果輔助工具需要提供豐富的界面和控件,純用Cocos2d-x來制作就會十分雞肋。比如這個打開文件的控件:

當然,一定要做的話用cocos2dx也是可以做的,但是相當麻煩。如果有興趣可以自己嘗試寫一下,提高自己的姿勢水平。
所以這個時候應當把cocos2dx層放在一個提供了各種控件的應用程序框架里面,cocos2dx僅用於做顯示,其余的數據操作交由框架完成。
目前博主比較熟悉的框架是MFC和C# Winform。說實話C# Winform做窗體比MFC方便快捷太多。但是如果使用C# Winform就得去做C#調用C++,同時對於某些特定參數(比如string到const char*的轉換)必須做特殊處理,比較麻煩,否則DLL堆棧會出錯。而MFC不存在這個問題。
【核心思想】
Cocos2dx在Windows上運行起來是一個窗口,那么在其內部一定調用了CreateWindowEx這個API。那么只要我們找到這個API,把參數設為子窗口,並把父窗口的句柄傳進去,就可以達到要求。創建出來的窗體就是父窗體中的子窗體了。
還要注意一點是cocos2dx原生程序有一個自己的消息循環,如果直接調用Application::run會導致MFC層卡死,我們需要把消息循環交給框架的主線程來操作。
流程圖如下:

【需要的工具】
1、 安裝了MFC組件的Visual Studio 2013
2、 Cocos2d-x 3.6
3、 GLFW (下載地址:點我)
4、 CMake(下載地址:點我)
【操作步驟】
1、 創建項目
創建一個MFC項目(我使用的對話框型)。注意在向導中“MFC的使用”這一項要選擇“在共享DLL中使用MFC”:

2、 拷貝必要文件
把cocos2dx的源碼和模板項目中的Classes和Resources文件夾拷貝到項目目錄下(項目模板位於引擎目錄\templates\cpp-template-default下),一定要使用這個結構:

3、 修改項目屬性
打開MFC項目解決方案,在屬性管理器(視圖——屬性管理器)中為項目添加cocos2dx的兩個屬性表。屬性表位於解決方案目錄\cocos2d\cocos\2d:

然后將libcocos2d,libbox2d,libspine加入解決方案中,並把libcocos2d設為MFC項目的依賴項:

再在MFC項目的附加包含目錄中加入:
$(EngineRoot)cocos\audio\include
$(EngineRoot)external
$(EngineRoot)external\chipmunk\include\chipmunk
$(EngineRoot)extensions
..\Classes
..
%(AdditionalIncludeDirectories)
$(_COCOS_HEADER_WIN32_BEGIN)
$(_COCOS_HEADER_WIN32_END)
預處理器定義中加入:
COCOS2D_DEBUG=1
附加庫目錄中加入:
$(_COCOS_LIB_PATH_WIN32_BEGIN)
$(_COCOS_LIB_PATH_WIN32_END)
附加依賴項加入:
$(_COCOS_LIB_WIN32_BEGIN)
$(_COCOS_LIB_WIN32_END)
libcocos2d.lib
再修改項目屬性——工作目錄,以及生成目錄:


再將Classes下的所有文件加入MFC項目:

最后設置不使用預編譯頭,不然每加入一個類都得加上#include “stdafx.h”,麻煩:

4、 修改GLFW
Cocos2dx 2.x中創建窗口在CCEGLView類中完成,直接修改它就行。到3.x后使用glfw管理窗口,CreateWindowEx被封裝進去了。而cocos2dx並沒有附帶glfw的源碼,只有頭文件和lib文件。所以我們需要下載glfw的源碼進行修改。
用CMakeGUI打開GLFW,source code處選擇下下來的glfw解壓的文件夾,build the binaries選擇生成解決方案的文件夾,然后生成對應VS版本的解決方案(glfw解壓的文件夾不要刪除):

然后打開生成的sln,查找CreateWindowEx,修改它所在的函數(win32_window.c,633行):
static int createWindow(_GLFWwindow* window,
const _GLFWwndconfig* wndconfig,
const _GLFWctxconfig* ctxconfig,
const _GLFWfbconfig* fbconfig,
HWND parent) // 父窗體句柄
{
int xpos, ypos, fullWidth, fullHeight;
WCHAR* wideTitle;
window->win32.dwStyle = WS_CHILDWINDOW | WS_VISIBLE; // 子窗體樣式
window->win32.dwExStyle = WS_EX_APPWINDOW | WS_EX_WINDOWEDGE;
xpos = 0;
ypos = 0;
fullWidth = wndconfig->width;
fullHeight = wndconfig->height;
wideTitle = _glfwCreateWideStringFromUTF8(wndconfig->title);
if (!wideTitle)
{
_glfwInputError(GLFW_PLATFORM_ERROR,
"Win32: Failed to convert window title to UTF-16");
return GL_FALSE;
}
window->win32.handle = CreateWindowExW(window->win32.dwExStyle,
_GLFW_WNDCLASSNAME,
wideTitle,
window->win32.dwStyle,
xpos, ypos,
fullWidth, fullHeight,
parent, // 傳入父窗體句柄
NULL, // No window menu
GetModuleHandleW(NULL),
window); // Pass object to WM_CREATE
//
// ...
}
然后從內向外依次修改調用它的地方:
win32_window.c,769行
int _glfwPlatformCreateWindow(_GLFWwindow* window,
const _GLFWwndconfig* wndconfig,
const _GLFWctxconfig* ctxconfig,
const _GLFWfbconfig* fbconfig,
HWND parent)
{
// ...
//
if (!createWindow(window, wndconfig, ctxconfig, fbconfig, parent))
return GL_FALSE;
// ...
//
if (!createWindow(window, wndconfig, ctxconfig, fbconfig, parent))
return GL_FALSE;
//
// ...
}
internal.h,524行
int _glfwPlatformCreateWindow(_GLFWwindow* window,
const _GLFWwndconfig* wndconfig,
const _GLFWctxconfig* ctxconfig,
const _GLFWfbconfig* fbconfig,
HWND parent);
window.c,116行
GLFWAPI GLFWwindow* glfwCreateWindow(int width, int height,
const char* title,
GLFWmonitor* monitor,
GLFWwindow* share,
int parent)
{
// ...
//
if (!_glfwPlatformCreateWindow(window, &wndconfig, &ctxconfig, &fbconfig, (HWND)parent))
//
// ...
}
glfw3.h,1645行:
GLFWAPI GLFWwindow* glfwCreateWindow(int width, int height, const char* title, GLFWmonitor* monitor, GLFWwindow* share, int parent);
改好后使用MinSizeRel選項進行編譯,編譯好后在GLFW解決方案目錄\src\MinSizeRel下找到glfw3.lib文件,連同glfw3.h(在glfw解壓目錄\include\GLFW)一起,分別放入MFC項目解決方案目錄\cocos2d\external\glfw3\prebuilt\win32 和 MFC項目解決方案目錄\cocos2d\external\glfw3\include\win32下覆蓋原文件。
5、 修改Cocos層
在GLViewImpl類(3.2中是GLView類)的頭文件中加入一個方法和成員:
public:
static void SetParent(HWND parent){ m_sParent = parent; }
private:
static HWND m_sParent;
別忘了在cpp中加入
HWND GLViewImpl::m_sParent = NULL;
然后修改GLViewImpl::initWithRect方法,修改調用glfwCreateWindow的地方:
bool GLViewImpl::initWithRect(const std::string& viewName, Rect rect, float frameZoomFactor)
{
// ...
//
_mainWindow = glfwCreateWindow(rect.size.width * _frameZoomFactor,
rect.size.height * _frameZoomFactor,
_viewName.c_str(),
_monitor,
nullptr,
(int)m_sParent); // 傳入父窗口句柄
//
// ...
}
修改Application類的run方法,去掉里面的消息循環:
int Application::run()
{
PVRFrameEnableControlWindow(false);
initGLContextAttrs();
// Initialize instance and cocos2d.
if (!applicationDidFinishLaunching())
{
return 1;
}
// Retain glview to avoid glview being released in the while loop
Director::getInstance()->getOpenGLView()->retain();
return 0;
}
6、 編輯MFC窗體
接下來在MFC窗體中添加一個Picture Control控件,控件ID設為IDC_RENDERWND,然后選中控件(非常蛋疼的是只能在控件邊框處點擊才能選中)點右鍵——“添加變量”:

7、添加渲染類
在解決方案資源管理器中的MFC項目上點右鍵——“添加”——“類…”,添加一個MFC類:


然后修改類:
#pragma once
// CRenderWnd
class CRenderWnd : public CWnd
{
DECLARE_DYNAMIC(CRenderWnd)
public:
CRenderWnd();
virtual ~CRenderWnd();
protected:
DECLARE_MESSAGE_MAP()
public:
afx_msg void OnTimer(UINT_PTR nIDEvent);
afx_msg void OnDestroy();
public:
void Initialize();
private:
BOOL m_bInited;
};
實現:
// RenderWnd.cpp : 實現文件
//
#include "stdafx.h"
#include "Cocos2dxMFC.h"
#include "RenderWnd.h"
#include "cocos2d.h"
#include "AppDelegate.h"
// CRenderWnd
IMPLEMENT_DYNAMIC(CRenderWnd, CWnd)
CRenderWnd::CRenderWnd()
: m_bInited(FALSE)
{
}
CRenderWnd::~CRenderWnd()
{
}
BEGIN_MESSAGE_MAP(CRenderWnd, CWnd)
ON_WM_TIMER()
ON_WM_DESTROY()
END_MESSAGE_MAP()
// CRenderWnd 消息處理程序
AppDelegate app;
void CRenderWnd::Initialize()
{
cocos2d::GLViewImpl::SetParent(this->GetSafeHwnd());
cocos2d::Application::getInstance()->run();
this->m_bInited = TRUE;
SetTimer(1, 1, NULL);
}
void CRenderWnd::OnTimer(UINT_PTR nIDEvent)
{
if (this->m_bInited)
{
auto director = cocos2d::Director::getInstance();
director->mainLoop();
director->getOpenGLView()->pollEvents();
CWnd::OnTimer(nIDEvent);
}
}
void CRenderWnd::OnDestroy()
{
CWnd::OnDestroy();
if (this->m_bInited)
{
auto director = cocos2d::Director::getInstance();
director->getOpenGLView()->release();
director->end();
director->mainLoop();
this->m_bInited = FALSE;
}
}
然后將剛才綁定的控件m_RenderWnd的類型由CStatic改為CRenderWnd,並在主窗體的OnInitDialog方法中加入一行:
BOOL CCocos2dxMFCDlg::OnInitDialog()
{
// ...
//
// TODO: 在此添加額外的初始化代碼
this->m_RenderWnd.Initialize();
return TRUE; // 除非將焦點設置到控件,否則返回 TRUE
}
8、運行起來
理論上要做的操作已經做完了,現在只需要編譯就能運行起來。然而觸控會這么好心地做好事不留坑嘛?
當然不會了~傳說cocos系列的坑連起來可以繞地球多少圈來着,這里噗通一下就入坑了,不信你F5一下:

這什么鬼?!其實是ApplicationProtocol中Platform枚舉中的一個值和MFC的某個宏同名了。解決方法是在stdafx.h中加入這樣一句:
#undef OS_WINDOWS
然后繼續編譯。當然是坑不單行,又報錯:

不過這個簡單,根據報錯內容,在項目的預處理器定義中加入_CRT_SECURE_NO_WARNINGS。

按理說最后是不是應該出現一個BOSS級深坑來着?BOSS來了:此時編譯可以通過了,但是一運行必然報錯。看看輸出窗口:

嗷,原來是找不到文件。但是我們之前已經設置了工作目錄,Resources下面也有文件啊(這個坑在2.2.6中並沒有)。
從Label::createWithTTF一路追蹤下去,最后發現cocos2dx搜索文件的目錄是在這里設置的(CCFileUtils-win32.cpp 59行):
static void _checkPath()
{
if (0 == s_resourcePath.length())
{
WCHAR *pUtf16ExePath = nullptr;
_get_wpgmptr(&pUtf16ExePath);
// We need only directory part without exe
WCHAR *pUtf16DirEnd = wcsrchr(pUtf16ExePath, L'\\');
char utf8ExeDir[CC_MAX_PATH] = { 0 };
int nNum = WideCharToMultiByte(CP_UTF8, 0, pUtf16ExePath, pUtf16DirEnd-pUtf16ExePath+1, utf8ExeDir, sizeof(utf8ExeDir), nullptr, nullptr);
s_resourcePath = convertPathFormatToUnixStyle(utf8ExeDir);
}
}
_get_wpgmptr是個嘛玩意?查一下可以知道,這個函數用於取得進程exe所在的目錄。
我們再看看cocos2dx 2.2.6中對應的部分(CCFileUtilsWin32.cpp 34行):
static void _checkPath()
{
if (! s_pszResourcePath[0])
{
WCHAR wszPath[MAX_PATH] = {0};
int nNum = WideCharToMultiByte(CP_ACP, 0, wszPath,
GetCurrentDirectoryW(sizeof(wszPath), wszPath),
s_pszResourcePath, MAX_PATH, NULL, NULL);
s_pszResourcePath[nNum] = '\\';
}
}
很明顯,2.2.6中使用GetCurrentDirectoryW獲取當前目錄的,使用這個函數就能獲取正確的工作目錄了。為什么用cocos new出來的3.6項目沒這個問題?因為new出來的項目的預鏈接事件中最后有這么一句:

這個命令會把Resources下的所有文件拷貝到輸出目錄(也就是進程exe所在的目錄)下,自然不會出現找不到文件的問題了。
不知道這么做的意義和目的是什么?但是此時我想說:

我還想說:

修改的方法很簡單,參考2.2.6把_checkPath中_get_wpgmptr函數改為GetCurrentDirectoryW:
static void _checkPath()
{
if (0 == s_resourcePath.length())
{
char pathBuffer[MAX_PATH] = { 0 };
WCHAR wszPath[MAX_PATH] = { 0 };
int nNum = WideCharToMultiByte(CP_ACP, 0, wszPath,
GetCurrentDirectory(sizeof(wszPath), wszPath),
pathBuffer, MAX_PATH, NULL, NULL);
pathBuffer[nNum] = '\\';
s_resourcePath = pathBuffer;
}
}
⑨、最后的小修改
如果你用的MFC窗體是一個Dialog類型的,運行后會發現按回車或Esc后窗體直接關閉了。所以還需要屏蔽掉回車和Esc鍵的響應。在MFC對話框類中添加一個方法重寫PreTranslateMessage:
private:
virtual BOOL PreTranslateMessage(MSG* pMsg);
實現:
BOOL CCocos2dxMFCDlg::PreTranslateMessage(MSG* pMsg)
{
if (pMsg->message == WM_KEYDOWN)
{
if (pMsg->wParam == VK_ESCAPE || pMsg->wParam == VK_RETURN)
{
return TRUE;
}
}
return CDialogEx::PreTranslateMessage(pMsg);
}
【運行起來】
如果編譯沒有出錯的話,運行起來會看到這個樣子:

只要將接口留出來,就可以很方便地通過MFC層的控件來控制cocos層了。至於要做成一個什么樣的工具,全靠大家發揮咯~
【后記】
采用這套思路理論上可以把cocos渲染到任何一個支持調用C++層代碼的框架中。
需要渲染在C# Winform中的童鞋請看這篇博客,里面有講處理方法及string到const char*的轉換。
