基于hook的全屏弹幕实现

avatar
研发 @比心APP

1111111111111.png

每当我们看到如此炫酷的游戏直播,是否好奇弹幕功能是如何实现的呢?

最初,弹幕采用窗口层级的原理实现,结构较为简单,具体实现要点如下:

  1. 创建一个独立的弹幕窗口
  2. 窗口置顶,设置鼠标穿透

基于以上两点,当全屏游戏窗口化时,我们的弹幕窗口就会始终显示在最顶层,故可实现主播一边直播游戏一边观看弹幕的需求。 此方案存在缺陷:游戏进程需要以窗口形式运行,即不能使用独占显示屏模式,且游戏的FPS会有所降低,可能会影响主播的游戏体验,进而影响观众的观看体验。

意识到问题的严重性,我们借鉴了很多前沿直播技术方案,改进了我们弹幕的实现呢!!!

先来看看新版弹幕方案对资源的消耗及效率对比图:

永劫无间直播英雄联盟直播dota2直播
FPS(波动区间)无弹幕:56-65
有弹幕:58-61
无弹幕:165-179
有弹幕:158-171
无弹幕:107-118
有弹幕:102-118

可以看到有弹幕的情况下对游戏体验的影响微乎其微,那么小编带你一起解读新版全屏弹幕的实现方案吧~~~

新版弹幕实现

与旧版弹幕相比,新版采用全新的技术实现,基于hook的前提,将我们的绘制模块注入到游戏进程中,从而实现在游戏内部绘制出我们的弹幕,其中依赖的主要知识点如下(下文中加粗字体为弹幕功能实现的主要步骤):

  1. dear-imgui开源库的使用 (link)
  2. hook的使用
  3. DirectX的基本绘制原理

dear-imgui介绍

Dear ImGui是一个轻便的用户图形界面库,它可以在应用程序渲染过程中,将我们自定义的图形界面输出到应用程序的顶点缓冲区中(即下文 呈现 中介绍的纹理),再由应用程序最终渲染出来。 界面库的详细使用方法可查看链接:(link),此文暂且跳过

DirectX的基本绘制原理

游戏开发需要使用图形渲染相关api(DirectX,OpenGL等)。windows平台游戏也是如此,绝大多数游戏都是基于微软官方提供的DirectX接口,目前主流版本有DirectX 9,DirectX 11.0,DirectX 11.1,DirectX 12(较少)。要在游戏内部绘制弹幕,就需要了解DirectX的绘制原理,以下是DirectX的简单介绍,是后续我们寻找hook点,进行弹幕绘制的关键所在:

  1. Direct3D 是一种底层绘图 API(application programming interface,应用程序接口), 它可以让我们可以通过 3D 硬件加速绘制 3D 世界。从本质上讲,Direct3D 提供的是一组软件接口,我们可以通过这组接口来控制绘图硬件。例如,要命令绘图设备清空渲染目标(例如屏幕),就可以调用 Direct3D 的 ID3D11DeviceContext::ClearRenderTargetView 方法来完成这一工作。Direct3D 层位于应用程序和绘图硬件之间,这样软件开发者就不必担心3D硬件的实现细节,只要设备支持 Direct3D 11,就可以通过 Direct3D 11 API 来控制3D硬件了
  2. 2D 纹理(texture)是一种数据元素矩阵。2D 纹理的用途之一是存储 2D 图像数据,在 纹理的每个元素中存储一个像素颜色。但这不是纹理的唯一用途;例如, 有一种称为法线 贴图映射(normal mapping)的高级技术在纹理元素中存储的不是颜色,而是 3D 向量。因 此,从通常意义上讲,纹理用来存储图像数据,但是在实际应用中纹理可以有更广泛的用途。
  3. 呈现。通常为了避免在动画中出现闪烁,最好的做法是在一个离屏(off-screen)纹理中执行所有的动画帧绘制工作,这个离屏纹理称为后台缓冲区(back buffer)。当我们在后台缓冲区中完 成给定帧的绘制工作后,便可以将后台缓冲区作为一个完整的帧显示在屏幕上;使用这种方法,用户不会察觉到帧的绘制过程,只会看到完整的帧。从理论上讲,将一帧显示到屏幕上 所消耗的时间小于屏幕的垂直刷新时间。硬件会自动维护两个内置的纹理缓冲区来实现这一 功能,这两个缓冲区分别称为前台缓冲区(front buffer)和后台缓冲区。前台缓冲区存储 了当前显示在屏幕上的图像数据,而动画的下一帧会在后台缓冲区中执行绘制。当后台缓冲 区的绘图工作完成之后,前后两个缓冲区的作用会发生翻转:后台缓冲区会变为前台缓冲区, 而前台缓冲区会变为后台缓冲区,为下一帧的绘制工作提前做准备。这种前后缓冲区功能互换的行为称做呈现(presenting)。如下图所示 aaa.png
  4. 前后缓冲区形成了一个交换链( swap chain )。 在 Direct3D 中 , 交换链由 IDXGISwapChain 接口表示。该接口保存了前后缓冲区纹理,并提供了用于调整缓冲区尺寸的方法(IDXGISwapChain::ResizeBuffers)和呈现方法(IDXGISwapChain::Present)。至此我们找到了满足弹幕绘制需求的hook切入点,IDXGISwapChain::Present方法。可以通过hook改方法,得到游戏进程的2D texture,再对该纹理进行加工处理,添加我们的弹幕,从而实现弹幕的绘制。

hook的使用及弹幕实现过程

钩子(Hook),是Windows消息处理机制的一个平台,应用程序可以在上面设置子程以监视指定窗口的某种消息,而且所监视的窗口可以是其他进程所创建的。当消息到达后,在目标窗口处理函数之前处理它。钩子机制允许应用程序截获处理window消息或特定事件。它的应用模式主要有:观察模式,注入模式,替换模式,集权模式等,相关模式介绍如下:

  1. 观察模式(观察者只可以查看信息,根据自己关心的内容处理自己的业务,但是不可以更改,如歌词
  2. 注入模式(加入新的扩展代码和原来的代码会协调处理同类业务,如全屏弹幕
  3. 替换模式(原有的代码会被新的代码所替换)
  4. 集权模式(统一处理某类事情,如键盘锁) 这里我们需要使用注入模式,并对Direct3D 的绘图API进程hook,而全屏弹幕hook的API,以DirectX 11为例,我们hook的是IDXGISwapChain::Present方法,该方法的作用是将纹理呈现在显示设备上。

直播助手游戏的捕获采用的是注入模式,通过系统API HHOOK WINAPI SetWindowsHookEx( __in int idHook, \钩子类型 __in HOOKPROC lpfn, \回调函数地址 __in HINSTANCE hMod, \实例句柄 __in DWORD dwThreadId \线程ID),实现注入行为,关键代码如下:

int inject_library_safe_obf(DWORD thread_id, const wchar_t *dll,
			    const char *set_windows_hook_ex_obf, uint64_t obf1)
{
	HMODULE user32 = GetModuleHandleW(L"USER32");
	set_windows_hook_ex_t set_windows_hook_ex;
	HMODULE lib = LoadLibraryW(dll);
	LPVOID proc;
	HHOOK hook;
	size_t i;

	if (!lib || !user32) {
		return INJECT_ERROR_UNLIKELY_FAIL;
	}
	
#ifdef _WIN64
	proc = GetProcAddress(lib, "dummy_debug_proc");
#else
	proc = GetProcAddress(lib, "_dummy_debug_proc@12");
#endif

	if (!proc) {
		return INJECT_ERROR_UNLIKELY_FAIL;
	}
	
	set_windows_hook_ex =
		get_obfuscated_func(user32, set_windows_hook_ex_obf, obf1);
		
	hook = set_windows_hook_ex(WH_GETMESSAGE, proc, lib, thread_id);
	if (!hook) {
		return GetLastError();
	}
	for (i = 0; i < RETRY_COUNT; i++) {
		Sleep(RETRY_INTERVAL_MS);
		PostThreadMessage(thread_id, WM_USER + 432, 0, (LPARAM)hook);
	}
	return 0;
}

//显式调用SetWindowsHookEx方法会被安全软件拦截并误报,故对方法进行加解密处理,避开安全软件

static inline int inject_library_safe(DWORD thread_id, const wchar_t *dll)
{
	return inject_library_safe_obf(thread_id, dll, "[bs^fbkmwuKfmfOvI",
				       0xEAD293602FCF9778ULL);
}

通过以上代码,我们可以将自己的dll(graphics-hook.dll,根据32位和64位进程游戏的区别dll也需区分32和64位)注入到游戏进程中。 而graphics-hook.dll的工作主要有两步:

  1. hook IDXGISwapChain::Present方法
  2. 绘制弹幕界面 关键代码如下: 通过VirtualProtect 系统API,修改进程内存页的读写权限,并将内存页中记录dxd present方法的地址修改为自己的present的地址。

加载dxgi.dll

static inline bool dxgi_init(dxgi_info &info)
{
	HMODULE d3d10_module;
	d3d10create_t create;
	create_fac_t create_factory;
	IDXGIFactory1 *factory;
	IDXGIAdapter1 *adapter;
	IUnknown *device;
	HRESULT hr;

	info.hwnd = CreateWindowExA(0, DUMMY_WNDCLASS,
				    "d3d10 get-offset window", WS_POPUP, 0, 0,
				    2, 2, nullptr, nullptr,
				    GetModuleHandleA(nullptr), nullptr);
	if (!info.hwnd) {
		return false;
	}

	info.module = LoadLibraryA("dxgi.dll");
	if (!info.module) {
		return false;
	}

	create_factory =
		(create_fac_t)GetProcAddress(info.module, "CreateDXGIFactory1");

	d3d10_module = LoadLibraryA("d3d10.dll");
	if (!d3d10_module) {
		return false;
	}

	create = (d3d10create_t)GetProcAddress(d3d10_module,
					       "D3D10CreateDeviceAndSwapChain");
	...
	return true;
}

获取dxd接口及地址偏移量

void get_dxgi_offsets(struct dxgi_offsets *offsets)
{
	dxgi_info info = {};
	bool success = dxgi_init(info);
	HRESULT hr;

	if (success) {
		offsets->present = vtable_offset(info.module, info.swap, 8);
		offsets->resize = vtable_offset(info.module, info.swap, 13);

		IDXGISwapChain1 *swap1;
		hr = info.swap->QueryInterface(__uuidof(IDXGISwapChain1),
					       (void **)&swap1);
		if (SUCCEEDED(hr)) {
			offsets->present1 =
				vtable_offset(info.module, swap1, 22);
			swap1->Release();
		}
	}

	dxgi_free(info);
}

void hook_init(struct func_hook *hook, void *func_addr, void *hook_addr,
	       const char *name)
{
	memset(hook, 0, sizeof(*hook));

	hook->func_addr = (uintptr_t)func_addr;
	hook->hook_addr = (uintptr_t)hook_addr;
	hook->name = name;
	//修改dxd接口所在内存页读写权限
	fix_permissions((void *)(hook->func_addr - JMP_32_SIZE),
			JMP_64_SIZE + JMP_32_SIZE);

	memcpy(hook->unhook_data, func_addr, JMP_64_SIZE);
}

插入函数地址

static inline void hook_reverse_new(struct func_hook *hook, uint8_t *p)
{
	hook->call_addr = (void *)(hook->func_addr + 2);
	hook->type = HOOKTYPE_REVERSE_CHAIN;
	hook->hooked = true;
	//通过汇编指令实现在源代码中插入我们的界面绘制代码,E9表示跳转指令,后面的四个字节是跳转的偏移地址
	p[0] = 0xE9;
	*((uint32_t *)&p[1]) = (uint32_t)(hook->hook_addr - hook->func_addr);
	*((uint16_t *)&p[5]) = X86_JMP_NEG_5;
}

完成IDXGISwapChain::Present方法的hook后,接下来就可以在我们自己定义的方法中绘制弹幕界面了,这里需要用到dear-imgui。当然,还需要在我们的present中主动调用IDXGISwapChain::Present,否则游戏画面就无法正常绘制了。下面通过一段简单控件的绘制代码介绍imgui的使用

资源初始化

void init_imgui(){
	IMGUI_CHECKVERSION();
	ImGui::CreateContext();
	ImGuiIO &io = ImGui::GetIO();

	StyleColorsYuer(nullptr);

	// Setup Platform/Renderer backends
	ImGui_ImplWin32_Init(hwnd);
	ImGui_ImplDX11_Init(device, context);
}

界面绘制

void paint()
{
	ImGui_ImplDX11_NewFrame();
	ImGui_ImplWin32_NewFrame();
	ImGui::NewFrame();
	ImGui::End();

	{
		static float f = 0.0f;
		static int counter = 0;

		ImGui::Begin("Hello, world!");                          // Create a window called "Hello, world!" and append into it.

		ImGui::Text("This is some useful text.");               // Display some text (you can use a format strings too)
		ImGui::Checkbox("Demo Window", &show_demo_window);      // Edit bools storing our window open/close state
		ImGui::Checkbox("Another Window", &show_another_window);

		ImGui::SliderFloat("float", &f, 0.0f, 1.0f);            // Edit 1 float using a slider from 0.0f to 1.0f
		ImGui::ColorEdit3("clear color", (float*)&clear_color); // Edit 3 floats representing a color

		if (ImGui::Button("Button"))                            // Buttons return true when clicked (most widgets return true when edited/activated)
			counter++;
		ImGui::SameLine();
		ImGui::Text("counter = %d", counter);

		ImGui::Text("Application average %.3f ms/frame (%.1f FPS)", 1000.0f / ImGui::GetIO().Framerate, ImGui::GetIO().Framerate);
		ImGui::End();
	}

	//Rendering
	ImGui::EndFrame();
	ImGui::Render();
	ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
}

资源释放

void destroy(){
	ImGui_ImplDX11_Shutdown();
	ImGui_ImplWin32_Shutdown();
	ImGui::DestroyContext();
}

总结

至此,弹幕功能基本实现。当然,程序也还有许多可优化的空间,此文中仅介绍了大致的实现原理和过程,如有错误之处,还请大家指正!