每当我们看到如此炫酷的游戏直播,是否好奇弹幕功能是如何实现的呢?
最初,弹幕采用窗口层级的原理实现,结构较为简单,具体实现要点如下:
- 创建一个独立的弹幕窗口
- 窗口置顶,设置鼠标穿透
基于以上两点,当全屏游戏窗口化时,我们的弹幕窗口就会始终显示在最顶层,故可实现主播一边直播游戏一边观看弹幕的需求。 此方案存在缺陷:游戏进程需要以窗口形式运行,即不能使用独占显示屏模式,且游戏的FPS会有所降低,可能会影响主播的游戏体验,进而影响观众的观看体验。
意识到问题的严重性,我们借鉴了很多前沿直播技术方案,改进了我们弹幕的实现呢!!!
先来看看新版弹幕方案对资源的消耗及效率对比图:
永劫无间直播 | 英雄联盟直播 | dota2直播 | |
---|---|---|---|
FPS(波动区间) | 无弹幕:56-65 有弹幕:58-61 | 无弹幕:165-179 有弹幕:158-171 | 无弹幕:107-118 有弹幕:102-118 |
可以看到有弹幕的情况下对游戏体验的影响微乎其微,那么小编带你一起解读新版全屏弹幕的实现方案吧~~~
新版弹幕实现
与旧版弹幕相比,新版采用全新的技术实现,基于hook的前提,将我们的绘制模块注入到游戏进程中,从而实现在游戏内部绘制出我们的弹幕,其中依赖的主要知识点如下(下文中加粗字体为弹幕功能实现的主要步骤):
- dear-imgui开源库的使用 (link)
- hook的使用
- 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点,进行弹幕绘制的关键所在:
- Direct3D 是一种底层绘图 API(application programming interface,应用程序接口), 它可以让我们可以通过 3D 硬件加速绘制 3D 世界。从本质上讲,Direct3D 提供的是一组软件接口,我们可以通过这组接口来控制绘图硬件。例如,要命令绘图设备清空渲染目标(例如屏幕),就可以调用 Direct3D 的 ID3D11DeviceContext::ClearRenderTargetView 方法来完成这一工作。Direct3D 层位于应用程序和绘图硬件之间,这样软件开发者就不必担心3D硬件的实现细节,只要设备支持 Direct3D 11,就可以通过 Direct3D 11 API 来控制3D硬件了
- 2D 纹理(texture)是一种数据元素矩阵。2D 纹理的用途之一是存储 2D 图像数据,在 纹理的每个元素中存储一个像素颜色。但这不是纹理的唯一用途;例如, 有一种称为法线 贴图映射(normal mapping)的高级技术在纹理元素中存储的不是颜色,而是 3D 向量。因 此,从通常意义上讲,纹理用来存储图像数据,但是在实际应用中纹理可以有更广泛的用途。
- 呈现。通常为了避免在动画中出现闪烁,最好的做法是在一个离屏(off-screen)纹理中执行所有的动画帧绘制工作,这个离屏纹理称为后台缓冲区(back buffer)。当我们在后台缓冲区中完 成给定帧的绘制工作后,便可以将后台缓冲区作为一个完整的帧显示在屏幕上;使用这种方法,用户不会察觉到帧的绘制过程,只会看到完整的帧。从理论上讲,将一帧显示到屏幕上 所消耗的时间小于屏幕的垂直刷新时间。硬件会自动维护两个内置的纹理缓冲区来实现这一 功能,这两个缓冲区分别称为前台缓冲区(front buffer)和后台缓冲区。前台缓冲区存储 了当前显示在屏幕上的图像数据,而动画的下一帧会在后台缓冲区中执行绘制。当后台缓冲 区的绘图工作完成之后,前后两个缓冲区的作用会发生翻转:后台缓冲区会变为前台缓冲区, 而前台缓冲区会变为后台缓冲区,为下一帧的绘制工作提前做准备。这种前后缓冲区功能互换的行为称做呈现(presenting)。如下图所示
- 前后缓冲区形成了一个交换链( swap chain )。 在 Direct3D 中 , 交换链由 IDXGISwapChain 接口表示。该接口保存了前后缓冲区纹理,并提供了用于调整缓冲区尺寸的方法(IDXGISwapChain::ResizeBuffers)和呈现方法(IDXGISwapChain::Present)。至此我们找到了满足弹幕绘制需求的hook切入点,IDXGISwapChain::Present方法。可以通过hook改方法,得到游戏进程的2D texture,再对该纹理进行加工处理,添加我们的弹幕,从而实现弹幕的绘制。
hook的使用及弹幕实现过程
钩子(Hook),是Windows消息处理机制的一个平台,应用程序可以在上面设置子程以监视指定窗口的某种消息,而且所监视的窗口可以是其他进程所创建的。当消息到达后,在目标窗口处理函数之前处理它。钩子机制允许应用程序截获处理window消息或特定事件。它的应用模式主要有:观察模式,注入模式,替换模式,集权模式等,相关模式介绍如下:
- 观察模式(观察者只可以查看信息,根据自己关心的内容处理自己的业务,但是不可以更改,如歌词)
- 注入模式(加入新的扩展代码和原来的代码会协调处理同类业务,如全屏弹幕)
- 替换模式(原有的代码会被新的代码所替换)
- 集权模式(统一处理某类事情,如键盘锁) 这里我们需要使用注入模式,并对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的工作主要有两步:
- hook IDXGISwapChain::Present方法
- 绘制弹幕界面 关键代码如下: 通过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();
}
总结
至此,弹幕功能基本实现。当然,程序也还有许多可优化的空间,此文中仅介绍了大致的实现原理和过程,如有错误之处,还请大家指正!