Godot使用GDExtension扩展PDF渲染

朋友使用Godot开发的独立游戏想要实现在游戏中读取渲染PDF文件的功能,便借此机会研究了一下Godot引擎自定义扩展的方方面面,本文着重分享技术探索思路,记录一下实操时的种种细节(坑),对想要通过GDExtension扩展引擎功能的同学应该很有帮助。

0x01. 技术选型

首先想到的就是查一下官方手册,翻一下Godot社区,看看有没有好心人做过类似的插件,不过结果还是预料之中的,这种需求还是过于冷门了,官方没有提供这样的功能,社区中找到的大部分和PDF相关的插件都是用于生成PDF的(如:GD-PDF),而我们需要Input而不是Output,这便只好自己造轮子了,不过好在自己一直对Godot扩展很感兴趣,正好借这个机会琢磨一下。

Godot官方文档中提到的扩展方案大致可以分为三种:插件GDExtension自定义C++模块,经过我潦草的探索后发现,插件通常指的是可复用的GDScript脚本封装,而想要为Godot添加引擎本身没有支持的功能还是很难的,而自定义C++模块则是一种深度定制引擎功能的方式,需要对引擎代码进行修改和全量编译,这也就意味着添加新的功能支持后,还需要对发布时不同平台的导出模板进行同步编译,这显然是一个工作量很大的方案。所以折中选择了官方较为推荐的GDExtension方案,GDExtension的实现机制与之前写的使用Lua作为脚本的自研引擎框架EtherEngine很像,都是借助遵循接口规范的动态链接库进行额外功能扩展,既可以确保使用C++在引擎底层大刀阔斧地增添新功能,又能够避免修改引擎编辑器和发布模板本身,而是在运行时动态加载。

既然决定了在C层进行扩展,那么下一步便是需要考虑使用什么三方库对渲染PDF的功能本身进行支持了,其实在大学创业公司时的项目便接触过借助WPS-COM进行文档的一些处理技术,但是对于这种小体量的独立游戏来说这套技术未免太过笨重了,以及在跨平台时可能受限较多,一番探索后,决定使用MuPDF这个库,理由其实很简单,MuPDF不仅支持细粒度地读写操作,更是提供了很简单的PDF渲染接口,可以很方便地将制定内容的文档页渲染为像素数据,对于不想探索格式标准繁琐细节的人来说,黑箱总是好的。

MuPDF

0x02. 技术验证

那么,接下来就有两个技术关键节点了,首先是在C++环境下借助MuPDF实现渲染逻辑,然后便是将C++逻辑封装为GDExtension与Godot接入。所以本阶段的首要任务便是验证MuPDF渲染PDF文档的可行性,虽然官方直接提供了包含VisualStudio工程的源码包,但碍于版本选择,我最终还是选择借助vcpkg进行库的安装,希望至少可以得知哪个版本更为稳定(尽管后续的尝试告诉我使用vcpkg安装本库是有问题的,最后依然没能避免手动从源码进行编译,所以关于vcpkg的编译方案此处不进行讨论)。

然后,使用下述代码对MuPDF接口进行了简单封装:

pdf_renderer.hpp >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#pragma once

#include <string>
#include <vector>
#include <map>
#include "mupdf/fitz.h"

struct PDFPageImage {
int width;
int height;
int channels;
unsigned char* data;
};

class PDFRenderer {
public:
PDFRenderer();
~PDFRenderer();

bool load(const std::string& path);
void close();

PDFPageImage render_page(int page_index, float scale = 1.0f);
int page_count() const { return fz_count_pages(ctx, doc); }

private:
fz_context* ctx;
fz_document* doc;
};
pdf_renderer.cpp >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include "pdf_renderer.hpp"

PDFRenderer::PDFRenderer() {
ctx = fz_new_context(nullptr, nullptr, FZ_STORE_DEFAULT);
fz_register_document_handlers(ctx);
doc = nullptr;
}

PDFRenderer::~PDFRenderer() {
close();
fz_drop_context(ctx);
}

bool PDFRenderer::load(const std::string& path) {
close();
fz_try(ctx) {
doc = fz_open_document(ctx, path.c_str());
return true;
}
fz_catch(ctx) {
return false;
}
}

void PDFRenderer::close() {
if (doc) {
fz_drop_document(ctx, doc);
doc = nullptr;
}
}

PDFPageImage PDFRenderer::render_page(int page_index, float scale) {
PDFPageImage img{};

if (!doc || page_index < 0 || page_index >= page_count())
return img;

fz_try(ctx) {
// 准备渲染设置
fz_page* page = fz_load_page(ctx, doc, page_index);
fz_rect bounds = fz_bound_page(ctx, page);
fz_matrix matrix = fz_scale(scale, scale);

// 渲染整页到RGBA图像
fz_pixmap* pix = fz_new_pixmap_from_page(ctx, page, matrix,
fz_device_rgb(ctx), 0);

// 封装结果
img.width = pix->w;
img.height = pix->h;
img.channels = pix->n; // 通常为3(RGB)或4(RGBA)
img.data = pix->samples;
pix->samples = nullptr; // 转移所有权

fz_drop_pixmap(ctx, pix);
fz_drop_page(ctx, page);
}
fz_catch(ctx) {
// 处理错误
}

return img;
}

然后借助EasyX进行了简单的渲染测试:

main.cpp >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#include "pdf_renderer.hpp"

#include <iostream>
#include <graphics.h>

IMAGE* generate_easyx_image(const PDFPageImage& pdf_page_img)
{
IMAGE* img = new IMAGE(pdf_page_img.width, pdf_page_img.height);
DWORD* pBuffer = GetImageBuffer(img);
int imgSize = pdf_page_img.width * pdf_page_img.height;
const unsigned char* src = pdf_page_img.data;

if (pdf_page_img.channels == 3)
{
// RGB -> BGR (EasyX默认格式)
for (int i = 0; i < imgSize; ++i) {
BYTE b = *src++;
BYTE g = *src++;
BYTE r = *src++;
// 组合为 EasyX 格式 (0xBBGGRR)
pBuffer[i] = RGB(r, g, b);
}
}
else
{
// RGBA -> BGRA
for (int i = 0; i < imgSize; ++i) {
// RGBA -> BGRA
BYTE b = *src++;
BYTE g = *src++;
BYTE r = *src++;
BYTE a = *src++;
// 组合为 EasyX 格式 (0xAARRGGBB)
pBuffer[i] = (a << 24) | (r << 16) | (g << 8) | b;
}
}

return img;
}

int main(int argc, char** argv)
{
PDFRenderer pdf_renderer;

bool result = pdf_renderer.load("DesignPattern.pdf");
std::cout << "result: " << result << std::endl;

int page_count = pdf_renderer.page_count();
std::cout << "page count: " << page_count << std::endl;

PDFPageImage pdf_page_img = pdf_renderer.render_page(0);
std::cout << "channels: " << pdf_page_img.channels << std::endl;
std::cout << "width: " << pdf_page_img.width << ", height: " << pdf_page_img.height << std::endl;

initgraph(pdf_page_img.width, pdf_page_img.height, EW_SHOWCONSOLE);

setbkmode(TRANSPARENT);
BeginBatchDraw();

int page_num = 0;
bool need_update = true;
IMAGE* img_current_page = nullptr;

while (true)
{
ExMessage msg;
while (peekmessage(&msg))
{
if (msg.message == WM_KEYDOWN)
{
switch (msg.vkcode)
{
case VK_LEFT:
page_num--;
if (page_num < 0) page_num = 0;
need_update = true;
break;
case VK_RIGHT:
page_num++;
if (page_num >= page_count) page_num = page_count - 1;
need_update = true;
break;
}
}
}

if (need_update)
{
delete img_current_page;
PDFPageImage pdf_page_img = pdf_renderer.render_page(page_num);
img_current_page = generate_easyx_image(pdf_page_img);
free(pdf_page_img.data);
need_update = false;
}

putimage(0, 0, img_current_page);

std::wstring text = L"当前页码:" + std::to_wstring(page_num);
settextcolor(RGB(100, 1, 37));
outtextxy(10, 10, text.c_str());

FlushBatchDraw();
Sleep(10);
}

return 0;
}

需要注意的是,如果想要手动添加MuPDF的静态链接依赖项,除去libmupdf.lib库外,还有诸多库需要链接,下面列出在libmupdf-1.25.4版本下需要链接的静态库:libextract.lib / libharfbuzz.lib / libleptonica.lib / libmupdf.lib / libpkcs7.lib / libresources.lib / libtesseract.lib / libthirdparty.lib

下一步,便是需要跑通GDExtension的接入流程了,接下来的步骤基本上遵循Godot官方手册流程:GDExtension C++ 示例,但由于部分操作超出了官方文档讲述的范畴,也因此遇到了不少困难,仅希望学习GDExtension插件制作的同学可以以此为分界线,忽略前文内容,参考后文的步骤和细节。

0x03. GDExtension

本部分内容在Godot4.2和Godot4.4版本下验证完成,跟随实践的同学在开始本部分内容前请确保自己电脑上已经准备完善了如下内容:

之所以要求大家有Python3而不是官方文档中要求的SCons,是因为考虑到很多同学不知道如何安装SCons,但是有了环境配好的Python后,这个过程就很简单了,所以第一步便是使用Python安装SCons,在终端输入如下指令等待安装结束即可:

1
pip install scons

安装完成后,在终端输入scons指令,应该可以看到类似如下的内容输出,这就意味着我们的SCons构建工具已经安装完成啦:

1
2
3
> scons
> scons: *** No SConstruct file found.
File "c:\users\xxx\appdata\local\programs\python\python38\lib\site-packages\SCons\Script\Main.py", line 1023, in _main

下一步,便是借助我们的Godot可执行文件导出接口的元数据,在类似Godot_v4.4.1-stable_win64.exe的可执行文件目录下,执行下面的指令:

1
Godot_v4.4.1-stable_win64.exe --dump-extension-api

应该会有一个Godot的窗口启动后一段时间后自然消失,目录下多出一个叫做extension_api.json的文件,这一步便可以完成啦。

关于接口元数据导出的操作,官方给出的描述是:
The repository contains a copy of the metadata for the current Godot release, but if you need to build these bindings for a newer version of Godot, call the Godot executable…
存储库包含当前Godot版本的元数据副本,但如果需要为较新版本的Godot构建这些绑定,请调用Godot可执行文件…
也就是说你可以直接使用后面提到的godot-cpp仓库代码中的默认数据而非需要单独刻意执行这一步骤,但是在实际操作时发现,尽管官方给出的最新Release为4.4.x版本,但仓库主分支中的代码已经在为4.5版本准备,虽然理论上高版本会兼容低版本,但是在后续生成的步骤中还是出现过一点小问题,所以建议稳妥起见,还是不要省略导出制定版本的接口元数据这一步。

下一步,便是构建godot-cpp这个仓库啦,这一步最终会生成类似诸如libgodot-cpp.windows.template_release.x86_64.lib这样的静态库文件,来方便我们编译生成最终的GDExtension动态链接库。

如果你下载的是godot-cpp仓库的zip压缩包,我们首先把他解压到一个固定稳妥的地方方便后续使用,然后复制前面生成的extension_api.json文件到解压好的目录下,随后在该目录下打开终端,输入如下指令:

1
scons platform=windows custom_api_file=extension_api.json target=template_release

截止到官网文档中给出的指令中没有添加最后的target参数,这样就会遵循默认生成配置产生debug模式下的静态库,如果需要使用release版本记得追加target=template_release参数,如果前面的环境配置一切正常,那么接下来就会开始叽里咕噜的编译过程,因不同设备性能差异,这个时间可长可短,直到编译指令运行完全退出后,我们便可以在godot-cpp目录下的bin文件夹中,找到生成的libgodot-cpp.windows.template_debug.x86_64.lib或者libgodot-cpp.windows.template_release.x86_64.lib库文件啦。

使用SCons构建godot-cpp

下一步便是需要编写GDExtension实际的业务代码了,官方给出的示例教程是继续在SCons下构建,并且提供了构建模板用来编译这部分内容,但我对于SCons并不熟悉,与其在新的构建系统下现学现卖,不如直接迁移到自己熟悉的开发环境下,所以我选择了在Windows下开发几乎不可能完全绕开的VisualStudio作为后续的开发环境。当然,除去开发习惯外,前面提到的MuPDF等三方库也是使用vcpkg进行编译完成的,如果继续使用SCons我则需要将这部分内容也添加到SCons下从源码进行编译,会更加繁琐。所幸的是,默认状态下,SCons会优先选择MSVC执行生成工作,所以前面步骤生成的libgodot-cpp静态库的ABI是兼容的。

尽管如此,在执行多平台的生成任务时,继续在SCons下执行生成任务可能是最简单的方案,但我想我应该还是会在VS下开发和调试完成GDExtension代码后,让SCons只负责最终的多平台生成步骤。

我们从最简单的官方示例代码开始探讨,在官方示例下下,一个最简单的GDExtension插件项目包含下面四个代码文件:

gdexample.h >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#ifndef GDEXAMPLE_H
#define GDEXAMPLE_H

#include <godot_cpp/classes/sprite2d.hpp>

namespace godot {

class GDExample : public Sprite2D {
GDCLASS(GDExample, Sprite2D)

private:
double time_passed;
double amplitude;

protected:
static void _bind_methods();

public:
GDExample();
~GDExample();

void _process(double delta) override;

void set_amplitude(const double p_amplitude);
double get_amplitude() const;
};

}

#endif
gdexample.cpp >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "gdexample.h"
#include <godot_cpp/core/class_db.hpp>

using namespace godot;

void GDExample::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_amplitude"), &GDExample::get_amplitude);
ClassDB::bind_method(D_METHOD("set_amplitude", "p_amplitude"), &GDExample::set_amplitude);

ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "amplitude"), "set_amplitude", "get_amplitude");
}

GDExample::GDExample() {
// Initialize any variables here.
time_passed = 0.0;
amplitude = 10.0;
}

GDExample::~GDExample() {
// Add your cleanup here.
}

void GDExample::_process(double delta) {
time_passed += delta;

Vector2 new_position = Vector2(
amplitude + (amplitude * sin(time_passed * 2.0)),
amplitude + (amplitude * cos(time_passed * 1.5))
);

set_position(new_position);
}

void GDExample::set_amplitude(const double p_amplitude) {
amplitude = p_amplitude;
}

double GDExample::get_amplitude() const {
return amplitude;
}
register_types.h >folded
1
2
3
4
5
6
7
8
9
10
11
#ifndef GDEXAMPLE_REGISTER_TYPES_H
#define GDEXAMPLE_REGISTER_TYPES_H

#include <godot_cpp/core/class_db.hpp>

using namespace godot;

void initialize_example_module(ModuleInitializationLevel p_level);
void uninitialize_example_module(ModuleInitializationLevel p_level);

#endif // GDEXAMPLE_REGISTER_TYPES_H
register_types.cpp >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "register_types.h"

#include "gdexample.h"

#include <gdextension_interface.h>
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/godot.hpp>

using namespace godot;

void initialize_example_module(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}

GDREGISTER_RUNTIME_CLASS(GDExample);
}

void uninitialize_example_module(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}
}

extern "C" {
// Initialization.
GDExtensionBool GDE_EXPORT example_library_init(GDExtensionInterfaceGetProcAddress p_get_proc_address, const GDExtensionClassLibraryPtr p_library, GDExtensionInitialization* r_initialization) {
godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);

init_obj.register_initializer(initialize_example_module);
init_obj.register_terminator(uninitialize_example_module);
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);

return init_obj.init();
}
}

在VS中新建空项目工程并且新建对应文件复制代码后,整个项目大概是下图的样子(项目名无所谓):

项目结构

然后,在VS最上方,将当前使用的配置更改为Release-x64(默认应该为Debug-x64):

Release-x64

现在,我们需要对这个工程进行配置,添加头文件以及之前准备好的静态库依赖,右键前面解决方案预览图中的“HelloGDExtension”项目(注意不是最顶层的解决方案),点击菜单最下方的“属性”选项,打开如下图所示的属性页面板,确保窗口最顶部的配置是我们目前使用的Release-x64,然后依次执行如下配置:

  • 常规->目标文件名:修改为libgdexample.windows.template_release.x86_64,当然这里的命名只是规范和方便起见,实际上使用默认的工程名作为最终的dll文件名也没有太大问题,只需要后续.gdextension描述中对应好即可;
  • 常规->配置类型:修改为动态库(.dll)
  • 常规->C++语言标准:修改为ISO C++ 17标准(/std:c++17)
  • C/C++->常规->附加库目录:分别添加前面使用过的godot-cpp本身的include文件夹、生成的include文件夹,以及目录下的gdextension文件夹,如果使用绝对路径,大致是类似如下的三条记录:
    1
    2
    3
    D:\gdextension_cpp_example\godot-cpp\include
    D:\gdextension_cpp_example\godot-cpp\gen\include
    D:\gdextension_cpp_example\godot-cpp\gdextension
  • C/C++->代码生成->运行库:修改为多线程(/MT);
  • 链接器->常规->附加库目录:添加前面我们生成的静态库目录,如果使用绝对路径,那么就只有类似如下的一条记录:
    1
    D:\gdextension_cpp_example\godot-cpp\bin
  • 链接器->输入->附加依赖项:只需要添加我们前面生成好的静态库文件,注意复制完整的文件名不要遗漏了扩展名:
    1
    libgodot-cpp.windows.template_release.x86_64.lib

项目属性页

到此为止,我们的项目基本上就配置完成啦,注意不要忘记点击“应用”或者“确定”保存配置,但是随后,右键项目选择“生成”时,却会出现如下报错内容:

编译时报错

呕吼,完蛋,是最不愿意看到的模板报错,虽然我可以凭直觉很快定位到是ClassDB::bind_method这个方法绑定时出现了问题,但是如何解决这个问题确实卡了我好久,我翻找了Godot主仓库和godot-cpp仓库的issue,都发现了有人汇报了这个问题,大家给出的解决方案是回到SCons下生成便一切安好,出现问题的是类似VS的QtCreator等其他开发环境,虽然我没有验证过回到SCons下是否真的可以顺利通过编译,但是同在MSVC下生成如果出现问题那就只能是项目配置的事情了,但是官方的文档等诸多资料都没有提及除去SCons外的其他环境配置,那么便只好硬着头皮阅读godot-cpp源代码了,最终,在朋友的帮助下,我们最终还是在几乎没有任何资料可以参考的情况下解决了这个问题:

已知的报告此问题的issue:
Cannot build on Windows 11, pointer reinterpret cast fail
Cannot reinterpret_cast from member pointer type to member pointer type of different size [Clang on Windows] #43354

解决方案其实很简单,那便是 继续回到项目属性中,在C/C++ -> 预处理器 -> 预处理器定义中追加TYPED_METHOD_BIND这个宏。 下面我将简单阐述一下造成这种问题的原因,不关心此部分内容的同学可以跳过此部分继续跟随实践:

方法绑定时 reinterpret_cast 强制转换报错原因

相关的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef TYPED_METHOD_BIND
class _gde_UnexistingClass;
#define MB_T _gde_UnexistingClass
#else
#define MB_T T
#endif

template <typename T, typename R, typename... P>
MethodBind *create_method_bind(R (T::*p_method)(P...) const) {
#ifdef TYPED_METHOD_BIND
MethodBind *a = memnew((MethodBindTRC<T, R, P...>)(p_method));
#else
MethodBind *a = memnew((MethodBindTRC<R, P...>)(reinterpret_cast<R (MB_T::*)(P...) const>(p_method)));
#endif // TYPED_METHOD_BIND
a->set_instance_class(T::get_class_static());
return a;
}

在Windows 下,MSVC对不同类、不同签名的成员函数指针(PMF)会使用不同的内部表示和调用约定。这就导致,标准的 C++ 并不允许你随意把R (__cdecl godot::GDExample::*)() constdouble (__cdecl godot::_gde_UnexistingClass::*)() const互相reinterpret_cast,这就会引发C2440错误。Godot开发者的策略是,在GCC/Clang等平台上,由于PMF的内部表示恰好是“平坦”的,同一个签名跨类其实完全等价,Godot就可以用reinterpret_cast或者memcpy把指针从一个类绑定到MethodBind存储里面,而在MSVC下,由于它的PMF内部按类“分箱”存储,所以就改用一个全不同的通用包装/类型擦除方案,不再直接跨类cast。

所以,由于MSVC在不同类/签名PMF上使用非标准优化,所以我们需要定义TYPED_METHOD_BIND宏来启用独立的包装和擦除方案。


这样,我们在生成的目录下,就可以找到最终生成的dll文件,如下图所示:

注意生成的目录

下面,便是编写.gdextension插件描述文件,官方文档中提供的示例其实已经非常全面了,但考虑到我们暂时不需要其他平台上的配置,所以只需要下面简单的内容即可:

gdexample.gdextension
1
2
3
4
5
6
7
8
9
10
[configuration]

entry_symbol = "example_library_init"
compatibility_minimum = "4.1"
reloadable = true

[libraries]

windows.debug.x86_64 = "res://bin/libgdexample.windows.template_release.x86_64.dll"
windows.release.x86_64 = "res://bin/libgdexample.windows.template_release.x86_64.dll"

各个字段的含义其实已经很明确了,关于entry_symbol等约定细节我们在文末的GDExtension流程剖析时再进行解读,此外,我们也可以为debug和release编译不同的模块,此处为了简单起见就都使用同一个release模式下的dll文件了。

然后,我们便可以新建Godot工程,gdexample.gdextension复制到目录下,然后新建bin目录,并将生成的libgdexample.windows.template_release.x86_64.dll文件复制到该目录下,然后我们可以创建场景和脚本文件,创建release目录用以存放发布的文件,目录结构如下图所示:

Godot测试工程目录结构

如果插件被正确加载,那么我们新建节点时就可以在Sprite2D下找到GDExample自定义节点,然后搭建如下图所示的场景,并将代码挂载到根节点上,运行后便可以看到控制台输出amplitude的默认值。

Godot编辑器内容

当然,由于我们在脚本中重写了_process的逻辑,所以并不能看到C++代码中实现的运动逻辑,我们注释掉5~6行代码,回到场景中为GDExample节点设置可见的纹理,再次运行场景就可以看到动态效果了(GIF录制原因帧率较低):

运行效果

当我们使用官方提供的发布模板进行导出时,也可以看到GDExtension插件文件也被成功复制导出了,在编辑器外部独立运行也是没有问题的:

使用官方模板发布时Godot会自动复制插件

0x04. PDFRenderer

在完成上述流程探索后,下面便是回归主题,将MuPDF的功能封装到GDExtension中了,其中,考虑到插件工具属性并不一定要作为节点挂载到场景中,所以部分自定义的扩展类只需要继承自RefCounted基类即可,有下面三个类设计:

  • PDFDocument:用于管理PDF文档的生命周期,加载和卸载;
  • PDFPageTexture:作为纹理资源用于存储文档页的渲染结果;
  • PDFRenderer:文档页渲染器,持有文档对象的引用,生成对应页面的纹理。

关于MuPDF渲染文档页本身的内容在前面已经讨论过了,接下来只需要关注的就是如何将这部分像素数据传递给Godot,PDFPageTexture是需要继承自ImageTexture的,这个是关键所在,所以我们需要在render_page方法内部,将MuPDF渲染出的数据,先传递给Image对象,再由Image对象生成纹理,在这里我首先使用了create_from_image接口而非set_image接口来生成纹理,但实际测试时发现纹理为空并没有成功,具体原因等日后再进行探索吧。此外,一开始还希望借助Godot的工作线程和信号机制实现异步渲染,但无奈MuPDF的性能实在是太好了同步渲染完全没压力 :),考虑到不必要的开发负担和调试难度,便直接在主线程进行文档页的渲染任务了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Ref<PDFPageTexture> PDFRenderer::render_page(int page_index, float scale) {
// 检查参数有效性
if (current_document.is_null()) {
UtilityFunctions::push_error("PDFRenderer: No document assigned");
return Ref<PDFPageTexture>();
}

if (page_index < 0 || page_index >= current_document->get_page_count()) {
UtilityFunctions::push_error("PDFRenderer: Invalid page index: " + itos(page_index));
return Ref<PDFPageTexture>();
}

// 直接在主线程渲染
::PDFPageImage pdf_image = current_document->pdf_renderer.render_page(page_index, scale);

if (pdf_image.data == nullptr) {
UtilityFunctions::push_error("PDFRenderer: Failed to render page " + itos(page_index));
return Ref<PDFPageTexture>();
}

// 创建Image
Ref<Image> img;
img.instantiate();

// 设置图像格式
Image::Format format = (pdf_image.channels == 4) ?
Image::FORMAT_RGBA8 : Image::FORMAT_RGB8;

// 复制像素数据
PackedByteArray data;
data.resize(pdf_image.width * pdf_image.height * pdf_image.channels);
memcpy(data.ptrw(), pdf_image.data, data.size());

// 释放原始数据
::free(pdf_image.data);

// 设置图像数据
img->set_data(pdf_image.width, pdf_image.height, false, format, data);

// 创建纹理
Ref<PDFPageTexture> texture;
texture.instantiate();
texture->set_image(img);
texture->set_metadata(page_index, scale);

return texture;
}

下面是PDFRenderer插件的完整代码,编译流程与前面章节的示例插件操作完全一致,关于MuPDF的编译细节我在稍后进行说明:

mupdf_renderer.h >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#pragma once

#include <string>
#include <vector>
#include <map>
#include "mupdf/fitz.h"

struct PDFPageImage {
int width;
int height;
int channels;
unsigned char* data;
};

class MuPDFRenderer {
public:
MuPDFRenderer();
~MuPDFRenderer();

bool load(const std::string& path);
void close();

PDFPageImage render_page(int page_index, float scale = 1.0f);
int page_count() const { return fz_count_pages(ctx, doc); }

private:
fz_context* ctx;
fz_document* doc;
};
pdf_gdextension.h >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#pragma once

#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/ref_counted.hpp>
#include "mupdf_renderer.h"

namespace godot {

class PDFDocument : public RefCounted {
GDCLASS(PDFDocument, RefCounted)

public:
PDFDocument();
~PDFDocument();

bool load(godot::String path);
void close();
int get_page_count() const;

public:
::MuPDFRenderer pdf_renderer;

protected:
static void _bind_methods();
};

class PDFPageTexture : public ImageTexture {
GDCLASS(PDFPageTexture, ImageTexture)

public:
int get_page_index() const { return page_index; }
float get_scale() const { return scale; }

private:
int page_index = -1;
float scale = 1.0f;

void set_metadata(int page, float render_scale) {
page_index = page;
scale = render_scale;
}

friend class PDFRenderer;

protected:
static void _bind_methods();
};

class PDFRenderer : public RefCounted {
GDCLASS(PDFRenderer, RefCounted)

public:
PDFRenderer();
~PDFRenderer();

void set_document(Ref<PDFDocument> document);
Ref<PDFDocument> get_document() const;

Ref<PDFPageTexture> render_page(int page_index, float scale = 1.0f);

private:
Ref<PDFDocument> current_document;

protected:
static void _bind_methods();
};

}
mupdf_renderer.cpp >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include "mupdf_renderer.h"

MuPDFRenderer::MuPDFRenderer() {
ctx = fz_new_context(nullptr, nullptr, FZ_STORE_DEFAULT);
fz_register_document_handlers(ctx);
doc = nullptr;
}

MuPDFRenderer::~MuPDFRenderer() {
close();
fz_drop_context(ctx);
}

bool MuPDFRenderer::load(const std::string& path) {
close();
fz_try(ctx) {
doc = fz_open_document(ctx, path.c_str());
return true;
}
fz_catch(ctx) {
return false;
}
}

void MuPDFRenderer::close() {
if (doc) {
fz_drop_document(ctx, doc);
doc = nullptr;
}
}

PDFPageImage MuPDFRenderer::render_page(int page_index, float scale) {
PDFPageImage img{};

if (!doc || page_index < 0 || page_index >= page_count())
return img;

fz_try(ctx) {
// 准备渲染设置
fz_page* page = fz_load_page(ctx, doc, page_index);
fz_rect bounds = fz_bound_page(ctx, page);
fz_matrix matrix = fz_scale(scale, scale);

// 渲染整页到RGBA图像
fz_pixmap* pix = fz_new_pixmap_from_page(ctx, page, matrix,
fz_device_rgb(ctx), 0);

// 封装结果
img.width = pix->w;
img.height = pix->h;
img.channels = pix->n; // 通常为3(RGB)或4(RGBA)
img.data = pix->samples;
pix->samples = nullptr; // 转移所有权

fz_drop_pixmap(ctx, pix);
fz_drop_page(ctx, page);
}
fz_catch(ctx) {
// 处理错误
}

return img;
}
pdf_extension_entry.cpp >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include "pdf_gdextension.h"
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/godot.hpp>

using namespace godot;

void initialize_pdf_extension(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}

ClassDB::register_class<PDFDocument>();
ClassDB::register_class<PDFPageTexture>();
ClassDB::register_class<PDFRenderer>();
}

void uninitialize_pdf_extension(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}
}

extern "C" {
// 初始化函数
GDExtensionBool GDE_EXPORT pdf_extension_init(
GDExtensionInterfaceGetProcAddress p_get_proc_address,
GDExtensionClassLibraryPtr p_library,
GDExtensionInitialization* r_initialization
) {
godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);

init_obj.register_initializer(initialize_pdf_extension);
init_obj.register_terminator(uninitialize_pdf_extension);
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);

return init_obj.init();
}
}
pdf_gdextension.cpp >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#include "pdf_gdextension.h"
#include <godot_cpp/variant/utility_functions.hpp>

namespace godot {

// ======================== PDFDocument ========================
void PDFDocument::_bind_methods() {
ClassDB::bind_method(D_METHOD("load", "path"), &PDFDocument::load);
ClassDB::bind_method(D_METHOD("close"), &PDFDocument::close);
ClassDB::bind_method(D_METHOD("get_page_count"), &PDFDocument::get_page_count);
}

PDFDocument::PDFDocument() {}
PDFDocument::~PDFDocument() {
close();
}

bool PDFDocument::load(godot::String path) {
close();
CharString c_path = path.utf8();
return pdf_renderer.load(c_path.get_data());
}

void PDFDocument::close() {
pdf_renderer.close();
}

int PDFDocument::get_page_count() const {
return pdf_renderer.page_count();
}

// ======================== PDFPageTexture ========================
void PDFPageTexture::_bind_methods() {
ClassDB::bind_method(D_METHOD("get_page_index"), &PDFPageTexture::get_page_index);
ClassDB::bind_method(D_METHOD("get_scale"), &PDFPageTexture::get_scale);

ADD_PROPERTY(PropertyInfo(Variant::INT, "page_index", PROPERTY_HINT_NONE, "", PROPERTY_USAGE_EDITOR), "", "get_page_index");
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "scale", PROPERTY_HINT_NONE, "", PROPERTY_USAGE_EDITOR), "", "get_scale");
}

// ======================== PDFRenderer ========================
void PDFRenderer::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_document", "document"), &PDFRenderer::set_document);
ClassDB::bind_method(D_METHOD("get_document"), &PDFRenderer::get_document);
ClassDB::bind_method(D_METHOD("render_page", "page_index", "scale"), &PDFRenderer::render_page, DEFVAL(1.0f));

ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "document", PROPERTY_HINT_RESOURCE_TYPE, "PDFDocument"), "set_document", "get_document");
}

PDFRenderer::PDFRenderer() {}
PDFRenderer::~PDFRenderer() {}

void PDFRenderer::set_document(Ref<PDFDocument> document) {
current_document = document;
}

Ref<PDFDocument> PDFRenderer::get_document() const {
return current_document;
}

Ref<PDFPageTexture> PDFRenderer::render_page(int page_index, float scale) {
// 检查参数有效性
if (current_document.is_null()) {
UtilityFunctions::push_error("PDFRenderer: No document assigned");
return Ref<PDFPageTexture>();
}

if (page_index < 0 || page_index >= current_document->get_page_count()) {
UtilityFunctions::push_error("PDFRenderer: Invalid page index: " + itos(page_index));
return Ref<PDFPageTexture>();
}

// 直接在主线程渲染
::PDFPageImage pdf_image = current_document->pdf_renderer.render_page(page_index, scale);

if (pdf_image.data == nullptr) {
UtilityFunctions::push_error("PDFRenderer: Failed to render page " + itos(page_index));
return Ref<PDFPageTexture>();
}

// 创建Image
Ref<Image> img;
img.instantiate();

// 设置图像格式
Image::Format format = (pdf_image.channels == 4) ?
Image::FORMAT_RGBA8 : Image::FORMAT_RGB8;

// 复制像素数据
PackedByteArray data;
data.resize(pdf_image.width * pdf_image.height * pdf_image.channels);
memcpy(data.ptrw(), pdf_image.data, data.size());

// 释放原始数据
::free(pdf_image.data);

// 设置图像数据
img->set_data(pdf_image.width, pdf_image.height, false, format, data);

// 创建纹理
Ref<PDFPageTexture> texture;
texture.instantiate();
texture->set_image(img);
texture->set_metadata(page_index, scale);

return texture;
}

}
pdfrenderer.gdextension
1
2
3
4
5
6
7
8
9
10
[configuration]

entry_symbol = "pdf_extension_init"
compatibility_minimum = "4.1"
reloadable = true

[libraries]

windows.debug.x86_64 = "res://bin/libpdfrenderer.windows.template_release.x86_64.dll"
windows.release.x86_64 = "res://bin/libpdfrenderer.windows.template_release.x86_64.dll"

项目结构

关于MuPDF的编译问题,还是不得不再多提一嘴,vcpkg在使用x64-windows-static这个triplet对libmupdf进行编译时,使用的却是MD运行库,而godot-cpp等库显然是需要MT运行库的,所以最简单的方案就是手动下载官方的代码手动进行配置和编译。但是很可惜,截止到目前的最新版1.26.3版本在中文Win11环境下似乎会因为编码问题无法通过编译,于是便回退到vcpkg上使用的1.25.4版本。

下载1.25.4版本的MuPDF源码包到本地,全部解压后来到platform/win32目录下就可以找到VS的解决方案,双击打开后保持默认的设置升级即可:

MuPDF解决方案所在位置

确保当前使用的配置是Releasex64,随后修改侧边栏中的每一个子项目,将属性中的C/C++->代码生成->运行库全部修改为多线程(/MT),然后右键最顶层的解决方案,选择生成解决方案,等待一段时间后,便可以在解决方案同级的x64/Release目录下就可以找到熟悉的库文件:

生成的库文件所在位置

这样,在添加MuPDF的头文件和库文件依赖,完成插件的编译后,就可以在Godot中使用如下的代码进行测试和使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
extends Node2D

@onready var sprite_2d: Sprite2D = $Sprite2D

var document: PDFDocument
var renderer: PDFRenderer

func _ready() -> void:
document = PDFDocument.new()
renderer = PDFRenderer.new()

# 加载PDF文档
if document.load("DesignPattern.pdf"):
print("PDF loaded with ", document.get_page_count(), " pages")
# 设置渲染器文档
renderer.document = document
# 渲染第一页
var texture = renderer.render_page(0)
if texture:
print("Rendered page 0: ", texture.get_size())
sprite_2d.texture = texture
else:
print("Failed to render page")
else:
print("Failed to load PDF")

func _exit_tree() -> void:
if document:
document.close()

0x05. GDExtension流程浅析

最后,从宏观上简述一下GDExtension的工作流程:

  • Godot启动后会首先解析.gdextension描述文件,在compatibility_minimum字段描述的版本符合要求的情况下,根据当前目标平台找到libraries域中对应路径的动态库文件,随后尝试加载并执行entry_symbol字段描述的函数接口。
  • 在C++代码中,我们需要对外暴露对应名称的接口,其中需要很关键的两行便是中间的register_initializerregister_terminator函数调用,我们这时注册了模块的初始化和函数和退出函数,关于为什么不直接调用这两个函数而是需要注册保存起来的原因,我的猜想是.gdextension文件中可以通过reloadable字段允许模块可以被自动重新加载,而无需重启编辑器进程,这也就是说,在某些特殊情况下,注册的初始化函数和退出函数可能会被执行多次。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    extern "C" {
    GDExtensionBool GDE_EXPORT pdf_extension_init(
    GDExtensionInterfaceGetProcAddress p_get_proc_address,
    GDExtensionClassLibraryPtr p_library,
    GDExtensionInitialization* r_initialization
    ) {
    godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);

    init_obj.register_initializer(initialize_pdf_extension);
    init_obj.register_terminator(uninitialize_pdf_extension);
    init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);

    return init_obj.init();
    }
    }
  • 引擎会在合适的时候调用C++中注册的初始化函数,所以我们注册自定义扩展节点类的代码需要在这个函数中编写:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    void initialize_pdf_extension(ModuleInitializationLevel p_level) {
    if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
    return;
    }

    ClassDB::register_class<PDFDocument>();
    ClassDB::register_class<PDFPageTexture>();
    ClassDB::register_class<PDFRenderer>();
    }
作者

Voidmatrix

发布于

2025-07-11

更新于

2025-07-11

许可协议

评论