Godot 游戏引擎源码梳理:图形渲染(二)
上一篇 Godot 游戏引擎源码梳理:图形渲染(一)本文为笔者阅读学习 godot 3.5 分支代码时的个人理解,难免疏漏,欢迎批评指正场景渲染列表
3D 场景各个元素以树形结构关联起来,当渲染整个3D场景时,需要遍历这棵树,绘制各个物体。Godot 没有在深度优先搜索中逐一绘制各个元素,而是把可绘制元素组织成了列表,绘制场景时先清理和填充列表,再对列表进行排序以确定渲染顺序,之后顺序遍历列表逐一绘制。场景每帧渲染分为多个 pass,每个 pass 渲染列表都要进行清理、填充、排序、绘制等处理。
渲染列表通过场景裁剪得到的,由裁剪结果构造渲染列表。场景裁剪的基本方式是BVH树的裁剪,根据相机transform等参数从树的各节点找出可视部分。此外还有针对传送门视角的裁剪(对用户提供的功能参考教程文档中 Rooms and Portals 的相关说明,在整个 VisualServer 模块代码结构中,portals 是一个且是唯一一个独立出来的子目录,也许《传送门3》就指望它了),当相机视线穿过传送门时,门后的物体极有可能因不在相机原本视锥内被裁掉,在传送门开启时,需要引入传送门空间位置参数做特殊裁剪。
BVH 和 Portals 如何进行裁剪和输出裁剪结果又是个极大的话题,本篇暂且关注场景输出渲染列表之后的处理过程。
场景渲染步骤
场景渲染分为 3 大pass
[*]pre-z pass,或者叫做 z-prepass
[*]opaque pass,渲染场景中的不透明物体
[*]transparent pass,渲染场景中的半透明物体
开启 use_depth_prepass 配置并满足相关使用前提条件时,pre z pass 执行,即实际渲染物体前先渲染一次深度,当 fragment shader 处理过程复杂时,利用提前渲染好的深度缓冲区避免 overdraw 从而降低 fragment shader 性能开销。
先渲染不透明物体,再从远到近渲染半透明物体,是常见的场景渲染方式。两者都需要排列好各自的渲染顺序。
不透明物体使用 sort_by_key 排序,以 Element 的 sort_key 字段作排序比较。sort_key 是物体各种属性按位货运算得到的 uint64_t 类型的整数,从偏移位的定义可以看出所涉及到的属性及其排序权重:
SORT_KEY_PRIORITY_SHIFT = 56,
SORT_KEY_PRIORITY_MASK = 0xFF,
//depth layer for opaque (56-52)
SORT_KEY_OPAQUE_DEPTH_LAYER_SHIFT = 52,
SORT_KEY_OPAQUE_DEPTH_LAYER_MASK = 0xF,
//64 bits unsupported in MSVC
#define SORT_KEY_UNSHADED_FLAG (uint64_t(1) << 50)
#define SORT_KEY_NO_DIRECTIONAL_FLAG (uint64_t(1) << 49)
#define SORT_KEY_LIGHTMAP_CAPTURE_FLAG (uint64_t(1) << 48)
#define SORT_KEY_LIGHTMAP_LAYERED_FLAG (uint64_t(1) << 47)
#define SORT_KEY_LIGHTMAP_FLAG (uint64_t(1) << 46)
#define SORT_KEY_GI_PROBES_FLAG (uint64_t(1) << 45)
#define SORT_KEY_VERTEX_LIT_FLAG (uint64_t(1) << 44)
SORT_KEY_SHADING_SHIFT = 44,
SORT_KEY_SHADING_MASK = 127,
//44-28 material index
SORT_KEY_MATERIAL_INDEX_SHIFT = 28,
//28-8 geometry index
SORT_KEY_GEOMETRY_INDEX_SHIFT = 8,
//bits 5-7 geometry type
SORT_KEY_GEOMETRY_TYPE_SHIFT = 5,
//bits 0-5 for flags
SORT_KEY_OPAQUE_PRE_PASS = 8,
SORT_KEY_CULL_DISABLED_FLAG = 4,
SORT_KEY_SKELETON_FLAG = 2,
SORT_KEY_MIRROR_FLAG = 1
在使用 pre-z pass 的情况下,sort_by_key 似乎没有必要,因为整个render_scene 过程深度测试都使用 GL_LEQUAL ,排序并不会再进一步降低 overdraw,但 Godot 无论是否用到 pre-z pass 都进行了sort_by_key 排序。
透明物体使用 sort_by_reverse_depth_and_priority 排序,按照渲染层级、与相机距离从远到近的顺序排序。层级使用 sort_key 移位计算来取得,即 sort_key >> SORT_KEY_PRIORITY_SHIFT,基本等同于 (uint64_t(p_material->render_priority) + 128)
在各个 pass 排序之前,会使用 RenderList::Clear()、RasterizerSceneGLES3::_fill_render_list 分别清空和填充列表,在排序之后,会使用 RasterizerSceneGLES3::_render_list 执行渲染。
排序使用 Intro Sort,这是一种结合了快速排序、堆排序、插入排序的综合排序方法,时间复杂度最坏情况下为 O(nlogn) ,最好情况下为 O(n) 。快速排序的最坏情况通过限制递归深度、换用堆排序来避免。插入排序被安排在较小分割长度下使用来发挥它的优势。由此可见 Intro sort 是一种比较无脑的排序方法,除了场景元素排序,编程语言容器类库的排序它一样可以胜任,所需要做的事情是确定好快排的递归限制深度和插排的限制长度。Godot 数组 Intro Sort 实现细节后续再作为单独的章节来展开描述。
渲染列表
无论是哪个pass,都统一通过 _render_list 函数来渲染一系列物体。这一过程主要包含
[*]选用着色器:选取某个变体版本的着色器
[*]设置材质:着色器绑定,向着色器传入材质相关 uniform
[*]设置光照:向着色器传入光照相关 uniform
[*]设置几何体:绑定 VAO,对需要 GPU Instancing 的物体更新实例缓冲区
[*]设置裁剪:设置是否裁剪、裁剪的正反面
[*]绘制几何体:glDraw call
[*]状态清理
看起来这个处理过程确实和自己写过的、很多 OpenGL 教程上描述的做法基本相同,只不过是引擎的做法相较于教程示例程序有亿点复杂。
选用着色器
相较于 OpenGL API 层面的着色器概念,Godot 和一些引擎层面的着色器变得不那么单纯。现代游戏引擎普遍实现 uber shader,通过编译开关来生成大量的 shader 变体版本。_render_list 中用到的 uber shader 只是一系列 uber shader 中的其中一个,被称为 scene_shader,类型为 SceneShaderGLES3。各个uber shader 类型、继承结构、封装关系为:
包含 SceneShaderGLES3 在内的各个派生的 ShaderGLES3 uber shader 子类是由 glsl 代码自动生成的,生成脚本是根目录下的 gles_builder.py,在 SConstruct 文件中由 scons 构建时被执行:
...
import gles_builders
...
if not env[&#34;platform&#34;] == &#34;server&#34;:# FIXME: detect GLES3
env.Append(
BUILDERS={
&#34;GLES3_GLSL&#34;: env.Builder(
action=run_in_subprocess(gles_builders.build_gles3_headers), suffix=&#34;glsl.gen.h&#34;, src_suffix=&#34;.glsl&#34;
)
}
)
env.Append(
BUILDERS={
&#34;GLES2_GLSL&#34;: env.Builder(
action=run_in_subprocess(gles_builders.build_gles2_headers), suffix=&#34;glsl.gen.h&#34;, src_suffix=&#34;.glsl&#34;
)
}
)
...gles_builder.py 是读 .glsl,输出 .h 的纯字符串处理,这个过程中会尽可能压缩glsl代码长度,去掉换行、注释、不必要的关键字、标识符等各种精简操作,最后输出的 glsl 字符串基本上就不具有可读性了,于是干脆就不让人类读:
...
for x in header_data.vertex_lines:
for c in x:
fd.write(str(ord(c)) + &#34;,&#34;)
fd.write(str(ord(&#34;\n&#34;)) + &#34;,&#34;)
fd.write(&#34;\t\t0};\n\n&#34;)
fd.write(&#34;\t\tstatic const int _vertex_code_start=&#34; + str(header_data.vertex_offset) + &#34;;\n&#34;)
fd.write(&#34;\t\tstatic const char _fragment_code[]={\n&#34;)
for x in header_data.fragment_lines:
for c in x:
fd.write(str(ord(c)) + &#34;,&#34;)
fd.write(str(ord(&#34;\n&#34;)) + &#34;,&#34;)
fd.write(&#34;\t\t0};\n\n&#34;)
fd.write(&#34;\t\tstatic const int _fragment_code_start=&#34; + str(header_data.fragment_offset) + &#34;;\n&#34;)
...经过这么一番 str(ord(c)),定义为常量字符数组的 vertex shader、fragment shader 文本成了两坨数字,而且不换行:
嵌入到头文件的 vertex shader 代码,fragment shader 代码由前面的 python 可知形式相同
变体着色器的生成是在引擎运行时进行,位于基类的 ShaderGLES3::setup,通过一系列字符串处理生成变体的 glsl。shader 的绑定使用接口 ShaderGLES3::bind() 的执行过程中会进行异步的编译链接。
以上这些着色器操作并不常见于 _render_list 过程中,通常在渲染场景时着色器已经编译完成,只需要给引擎提供一个uber shader 变体版本号,着色器系统就能绑定好正确的 shader program。除了 uber shader,项目中的自定义 shader 也一并被纳入版本管理,通过版本信息可以很方便地区分前后两个渲染批次是否使用同一个 shader,避免掉不必要的 shader 和 uniform 重复设置操作。版本信息结构体的定义为:
union VersionKey {
static const uint32_t UBERSHADER_FLAG = ((uint32_t)1) << 31;
struct {
uint32_t version;
uint32_t code_version;
};
uint64_t key;
...
};
struct Version {
VersionKey version_key;
...
};
VersionKey::version 字段信息由与 glsl 编译开关对应的二进制 flag bit 按位或构成,最高位flag UBERSHADER_FLAG 用来表明该版本的 shader 是否为一个 uber shader 变体,如果不是,则应该使用 code_version 来区分这是哪个自定义 shader。
在 _render_list 实际遍历和渲染每个列表元素之前,会先根据传入的 p_shadow、p_directional_add、p_sky 三个参数设置 USE_RADIANCE_MAP、USE_RADIANCE_MAP_ARRAY 两个 flag。随后 SHADELESS、USE_SKELETON、USE_INSTANCING、ENABLE_OCTAHEDRAL_COMPRESSION、USE_OPAQUE_PREPASS 被设为各自的默认值,然后开始遍历渲染列表,列表元素的各项属性会影响到其它 flag 的开启关闭状态。
设置材质
渲染列表每个元素都带有材质指针,材质所使用的shader参数、以及材质本身的参数材质会改变 OpenGL 状态,如深度测试、深度读写、绑定UBO、激活和绑定纹理、设置纹理过滤参数等。
材质会给scene_shader 设置好自定义shader代码id,然后执行绑定操作:
state.scene_shader.set_custom_shader(p_material->shader->custom_code_id);
bool rebind = state.scene_shader.bind();
之后,材质的 ubo id 被绑定到1号 uniform buffer base
if (p_material->ubo_id) {
glBindBufferBase(GL_UNIFORM_BUFFER, 1, p_material->ubo_id);
}
材质的各项参数是保存在 uniform buffer 里的,在 RasterizerStorageGLES3::_update_material 函数中,可以找到这样一段将材质的 params 填充进 ubo 的处理过程:
for (Map<StringName, ShaderLanguage::ShaderNode::Uniform>::Element *E = material->shader->uniforms.front(); E; E = E->next()) {
if (E->get().order < 0) {
continue; // texture, does not go here
}
//regular uniform
uint8_t *data = &local_ubo];
Map<StringName, Variant>::Element *V = material->params.find(E->key());
if (V) {
//user provided
_fill_std140_variant_ubo_value(E->get().type, V->get(), data, material->shader->mode == VS::SHADER_SPATIAL);
} else if (E->get().default_value.size()) {
//default value
_fill_std140_ubo_value(E->get().type, E->get().default_value, data);
//value=E->get().default_value;
} else {
//zero because it was not provided
if (E->get().type == ShaderLanguage::TYPE_VEC4 && E->get().hint == ShaderLanguage::ShaderNode::Uniform::HINT_COLOR) {
//colors must be set as black, with alpha as 1.0
_fill_std140_variant_ubo_value(E->get().type, Color(0, 0, 0, 1), data, material->shader->mode == VS::SHADER_SPATIAL);
} else {
//else just zero it out
_fill_std140_ubo_empty(E->get().type, data);
}
}
}
glBindBuffer(GL_UNIFORM_BUFFER, material->ubo_id);
glBufferData(GL_UNIFORM_BUFFER, material->ubo_size, local_ubo, GL_STATIC_DRAW);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
RasterizerStorageGLES3::update_dirty_resources 资源更新负责调用 _update_material 方法, 资源更新操作会在多个地方进行,其中一个分别位于 RasterizerGLES3::begin_frame:
void RasterizerGLES3::begin_frame(double frame_step) {
...
storage->update_dirty_resources();
...
scene->iteration();
}
另有一个位于 VisualServerScene::update_dirty_instances
void VisualServerScene::update_dirty_instances() {
VSG::storage->update_dirty_resources();
...
}
而在上一篇提及的 VisualServerRaster::draw 处理过程中,上面这两个函数会被先后调用。
设置光照
元素携带的光照信息会更改 scene_shader 相关的 uniform,光照类型及其影响到的 uniform 对应关系如下:
uniform location 由以上这些枚举值作为 key 来查找,映射关系存储在 ShaderGLES3::version 结构中的 uniform_location 数组中。在 bind shader 时,会检查 uniform 是否就绪,确保 location 信息已建立。
bind shader 会调用 glUseProgram,随后进行 _setup_uniforms:
if (ready) {
glUseProgram(version->ids.main);
if (!version->uniforms_ready) {
_setup_uniforms(custom_code_map.getptr(conditional_version.code_version));
version->uniforms_ready = true;
}
...
}
_setup_uniforms 的过程中会建立 uniform_names 与 location 的映射关系:
void ShaderGLES3::_setup_uniforms(CustomCode *p_cc) const {
//print_line(&#34;uniforms:&#34;);
for (int j = 0; j < uniform_count; j++) {
version->uniform_location = glGetUniformLocation(version->ids.main, uniform_names);
//print_line(&#34;uniform &#34;+String(uniform_names)+&#34; location &#34;+itos(version->uniform_location));
}
...
}
由于每个uber shader C++ 类中 uniform_names 字符串数组、Uniforms 枚举都是由同一个 glsl 文件自动生成的,本身就是一一对应的关系,因此能够通过各个 uber shader 的 Uniforms 枚举值来实际进行 glUniform 相关操作。
设置几何体
_setup_geometry 处理三类渲染列表 Element:INSTANCE_MESH、INSTANCE_MULTIMESH 和 INSTANCE_PARTICLES。
INSTANCE_MESH 类型分为三种子类型,最基础的一种是通常意义上的mesh,可以参考其它引擎里的静态网格对象。mesh vao 的绑定、顶点 attribute 设置位于后续 render_geometry,而在 _setup_geometry 无需处理,因此 INSTANCE_MESH 这一类型只是简单做了个 glBindVertexArray(s->array_id),而另外两种需要 GPU Instancing 的类型,由于需要填充 instance buffer 过程相对复杂很多。
INSTANCE_MULTIMESH 利用了GPU Instancing 绘制多重网格,除了 mesh 数据,还需要给 OpenGL 提供各个实例的参数。实例参数由三部分构成:transform、color、custom data,其中 transform 有着 2D、3D 两种类型,分别为4阶矩阵的前2行8个float、前三行12个float,custom data 形式与 color 相同,其用途是自定义的。在执行 _setup_geometry 之前,各项参数数值已在其它处理过程中写入到了 multi_mesh->buffer 中,本步骤主要对该 buffer 设置 vertex attribute。
INSTANCE_PARTICLES 与 multi mesh 类似,同样是 GPU Instancing 的绘制方式,实例参数的构成也基本相同。粒子的渲染顺序可指定为按 view depth 排序,此时需要对实例缓冲区排序,再次使用到了 SortArray 的 intro sort 排序算法。在 _particles_process 处理过程中,通过 particle shader、transform feedback 等操作,particle_buffers 数据内容还会连续更新。
综上,_setup_geometry 主要工作为设置实例缓冲区及其相关的 attribute。
设置裁剪
_set_cull 只处理它的三个参数
是否是裁剪正面,否则为反面:e->sort_key & RenderList::SORT_KEY_MIRROR_FLAG
是否启用正反面裁剪:e->sort_key & RenderList::SORT_KEY_CULL_DISABLED_FLAG
是否颠倒正反面裁剪:p_reverse_cull
在设置 OpenGL 状态的同时,裁剪状态也会写入到 RasterizerSceneGLES3::state 对饮的字段。
绘制几何体
OpenGL 的一个特色是状态参数巨多,这些状态设置好之后,只需要调用以 glDraw 前缀的 draw call 来执行渲染。在 Godot 引擎中,INSTANCE_MESH 使用 glDrawElements、glDrawArrays,依赖 GPU Instancing 的 INSTANCE_MULTIMESH、INSTANCE_PARTICLES 使用 glDrawElementsInstanced、 glDrawArraysInstanced。几何体类型还有一类是 INSTANCE_IMMEDIATE,它支持动态添加删除顶点数据,需要动态地更新顶点缓冲区以及顶点属性信息,然后再使用 glDrawArrays 进行渲染。
状态清理
返回到上一层的 render_scene 函数中来,在执行完以上渲染操作后,vao 绑定为 0,scene_shader 各项 Conditionals flag 置为 0。render_scene 到此结束。
小结:
尽管本篇篇幅比起上一篇多出不少,但也只能算是个场景渲染的简陋的大纲,只是顺着 render_scene 执行过程走了下来。uber shader GLSL代码逻辑、材质、GI probe 的创建和使用过程等都没有展开,目前也还没有深入研读。
从写上一篇到现在经历了很多琐事,面试找工作、等待offer、欲阴先阳、搬家、入职、适应新工作。以前,我一直以为在自己岗位坚持干下去,哪怕不如别人成长得快,也定能有所收获。但,寒来寒去,黑夜等不到白雪,离职那天正好是冬至,只希望之后春天真的会来。
新找来的工作仍然是 Unity3D 手游客户端开发。在商业游戏开发方面 Godot 目前还不足以取代 Unity3D,但它开源、完备、优秀,代码量暂时也处在阅读能力范围内,所以才会有阅读学习的想法并坚持。
页:
[1]