普通视图

发现新文章,点击刷新页面。
昨天以前首页

2D 渲染管线的一点优化

作者 云风
2025年2月20日 10:46

考虑到我想做的独立游戏并不需要以画面效果吸引人,游戏是策略向的,所以 2D 表现就足够了。之前几年做的 3d 引擎对这个需求来说太复杂了,而且这次我也不打算主打移动平台,之前为移动平台做的考虑也没太大意义。所以,最近想花个把月重新搭一个 2d 游戏用的框架。当然,最重要的是:我太久没写代码了,而做这个非常有趣。

前天在 github 上开了一个新坑,具体想法写在项目的讨论区了

虽说 2d 游戏在如今的硬件上,性能那是相当富裕。但在具体写代码时,还是忍不住想想怎么设计,性能会比较好。不然总是重复大家都有的东西也是无趣。

在现代 GPU 上实现一个最简单的 2d 管线,就是把它当成 3d 网格,一堆顶点数据填进去,绑定贴图,提交渲染即可。所谓 2d 图片,就是两个三角形,看成是 3d 世界里的一个面片即可。

所以,每个顶点的数据就是 vec2 pos ,要画一个矩形需要四个顶点,用 vertex buffer 传进去。

但是和 3d 游戏不同,2d 图片形状大多不规整,不是边长为 2 的幂的正方形,尺寸也不大。如果每张小图片(2d 游戏中通常称为 sprite)都构造一张贴图的话,会非常低效。通常我们会把很多 sprite 打包在同一张大的正方形的贴图上。这样,顶点数据中还需要定义绘制矩形对应在贴图上的区域,通常称之为 uv 坐标。至此,常规的实现方法中,每个顶点就是 4 个数据量:vec2 pos 和 vec2 uv 。因为 sprite 都在同一张贴图上,一次图形指令提交只画一个矩形就太浪费了,我们会把多个矩形的顶点放在一起,一次把整个顶点数组提交到 vertex buffer 中。

虽然 2d 游戏的大多数 sprite 只需要指定屏幕(画布)坐标渲染即可,画布可以整体缩放。sprite 单独缩放旋转的机会比较少,但也并非没有。用上面的方法怎么处理旋转和缩放呢?过去常见的方法是在 CPU 中计算好四个顶点,把结果填在顶点数据流中。btw, 很早以前,我在实现 ejoy2d 的初版时就是这么做的。这样最为灵活,CPU 计算一个 2x3 的矩阵也不慢(ejoy2d 使用了定点数,在早期的手机上性能更好)。而且,大多数 sprite 并不需要旋转和缩放,只需要做一次 vec2 的加法即可。

计算该怎么做?我们需要找到 sprite 的基准点。大多数情况下,这个基准点并不是图片的左上角。然后以这个点为坐标原点,对 sprite 的四个顶点依次做旋转和缩放变换再加上 sprite 的绘制位置。这一系列运算相当于乘一个 2x3 的矩阵。如果我们想把这个运算放在 GPU 该怎么做?顶点数据流中就不能直接放顶点计算结果的坐标了,而应该放针对 sprite 的基准点的相对坐标,以及一个 2x3 的变换矩阵。这样,顶点数据就变成了:vec2 offset ; vec2 uv ; mat2 sr; vec2 t; 一共是 10 个数据。

很明显,后面这个 mat2 sr; vec2 t; 在数据流中重复了 4 次(一个 sprite 的 4 个顶点有相同的 2x3 矩阵)。另一方面,绝大多数的 sprite 不需要旋转和缩放变换,这种情况下,mat2 sr 都是单位矩阵;即使有旋转变换,旋转角度也是有限的。整个数据流中必然存在大量重复的 mat2 sr 。怎么优化掉这些重复数据呢?我们可以用一个 storage buffer 保存唯一的 mat2 sr ,在顶点流中保存一个索引 index 即可。这样,顶点数据就剩下 vec2 offset; vec2 uv; index; vec2 t; 7 个数据。最后这个 vec2 t 不放在索引中是因为大多数 sprite 会有不同的位移坐标,而 2x2 的 SR 矩阵更容易合并。

接下来的问题是,index 和 vec2 t 还是重复了 4 次。为了去掉这个重复,我们可以采用 instance draw 或 indirect draw 。理论上用 indirect draw 更合适,但它对图形 api 版本要求高一些(如果想运行在 web 上,还是需要考虑这点),所以我选择用 instance draw 实现。

使用 instance draw 的一个额外好处是可以省掉 index buffer ,使用三角条带描述矩形即可。

但 instance draw 有个问题:它最初是为了把一组顶点数据重复渲染设计的。而这里,我们有很多不同的矩形需要同一批次渲染。即,vb 中每组数据 vec2 offset; vec2 uv; 有很多组。所以,我选择不使用顶点数据流,把这组数据放在另一格 storage buffer 中,然后在顶点着色器(vs)中通过 gl_InstanceIndexgl_VertexIndex 索引它。

做到这里,我注意到:2d 游戏中的 sprite 矩形都是轴对齐的。所以,描述四个顶点并不需要 8 个量,而只需要 4 个,保存两个对角顶点即可。另外,offset 矩形和贴图上的 uv 矩形形状也是一致的,我们只是把贴图上的一个区域完整映射到画布上,这样还可以少两个重复信息。最终,我们只需要 3 对 vec2 就可以表达一个矩形以及 uv 。

而图片是以像素为单位的,贴图尺寸不会有几万像素大。这个坐标量使用 int16 足够了。所以在保存 sprite 元信息的这个 storage buffer 中,每个图元其实只需要 6 个 int16 ,也就是 12 字节足够了。最终,绘制每个 sprite 的数据为 6 short + 3 float ( x,y,index ) = 26 字节。

最终的 vs 是这样的

layout(binding=0) uniform vs_params {
    vec2 texsize;
    vec2 framesize;
};

struct sr_mat {
    mat2 m;
};

layout(binding=0) readonly buffer sr_lut {
    sr_mat sr[];
};

struct sprite {
    uint offset;
    uint u;
    uint v;
};

layout(binding=1) readonly buffer sprite_buffer {
    sprite spr[];
};

in vec3 position;

out vec2 uv;

void main() {
    sprite s = spr[gl_InstanceIndex]; 
    ivec2 u2 = ivec2(s.u >> 16 , s.u & 0xffff);
    ivec2 v2 = ivec2(s.v >> 16 , s.v & 0xffff);
    ivec2 off = ivec2(s.offset >> 16 , s.offset & 0xffff) - 0x8000;
    uv = vec2(u2[gl_VertexIndex % 2] , v2[gl_VertexIndex >> 1]);
    vec2 pos = uv - ( off + ivec2(u2[0], v2[0]));
    pos = (pos * sr[int(position.z)].m + position.xy) * framesize;
    gl_Position = vec4(pos.x - 1.0f, pos.y + 1.0f, 0, 1);
    uv = uv * texsize;
}

再来看 CPU 侧的设计:

我这次使用了 sokol 做底层图形 api 。sokol api 不支持多线程,所有图形指令必须在同一个线程提交。所以我做了一个简单的中间层:绘图时不直接调用图形 api ,而是填充一个内存结构。这个结构被称为 batch ,不同的线程可以持有多个不同的 batch 。所有 batch 汇总到渲染线程后,渲染线程再将 batch 中的数据转换为图形指令以及所需的数据结构。

因为 2d 游戏据大多数情况都在处理图片,使用默认的渲染方式。我对这种默认材质做了特别优化。batch 是由这样的结构数组构成:

struct draw_primitive {
    int32_t x;  // sign bit + 23 bits + 8 bits   fix number
    int32_t y;
    uint32_t sr;    // 20 bits scale + 12 bits rot
    int32_t sprite; // negative : material id
};

其中,用两个顶点 32bit 整数表示 sprite 的画布坐标;一个 32bit 整数表示旋转和缩放量;一个 sprite id 。

渲染层会查表把 sprite id 翻译成对应的元信息(上面提到的 offset 和 uv ),当 sprite id 为负数时,表示这是一个非默认材质,batch 中的下一组数据是该材质的参数。例如,文本渲染就会用到额外材质,文本的 unicode 和颜色信息就放在接下来的数据中。

大批量动画模型的优化

作者 云风
2024年5月3日 15:03

最近和公司一个开发团队探讨了一下他们正在开发的游戏中遇到的性能问题,看看应该如何优化。这个游戏的战斗场景想模仿亿万僵尸(They are billions)的场景。在亿万僵尸中,场景中描绘了上万的僵尸潮,但我们这个游戏,超过 500 个僵尸就遇到了性能问题。固然,手机的硬件性能比不上 PC ,但 500 这个数量级还是略低于预期。

对于游戏中大量类似的动画物体,肯定有方法可以优化。我们来看看渲染这些动画可行的优化方向:

常见的方式是把僵尸先预渲染成图片,而动画自然就是多个图片帧。对于亿万僵尸这个游戏来说,它本身就是基于 2D 渲染引擎的,这么做无可厚非。

如果引擎本身基于 3d 渲染管线,也可以以预渲染图片的方式去渲染它们,但图片是否比基于三角形的模型渲染有更好的性能,这个需要根据具体场景去分析。

当我们在运行时,将模型的三维顶点信息传递给 GPU ,让 GPU 做光栅化,通常可以获得两个方面的好处:

一,最终渲染出来的像素有更准确的光照信息,它是根据该像素在场景中的空间状态计算出来。而预渲染图片上的像素则是根据预渲染时的固定空间状态计算,所以不准确。

二,可以省掉顶点着色器的计算过程,对像素着色过程也简化为一个简单的复制(而不必计算光照)。

但是,渲染图片也未必一定有绝对的性能优势。这是因为,预渲染图片本质上是将渲染过程预处理,烘焙到贴图上。约等于用空间换时间的优化。图片本身相比顶点数据会消耗更大的带宽。当模型本身顶点不多,用到的贴图也不大时,这个区别会更加明显。通常模型顶点占用的带宽会比图片需要的带宽小一个数量级。当使用帧动画时,需要为每个动画帧渲染独立的图片,这个差别就更加明显。


再来看动画。通常,我们会用骨骼来描述动画。这其实也是一种数据压缩方式。一个模型的顶点可能有成千上万甚至更多。但我们只需要用几十个关键点来描述动画即可。同时,只需要建立唯一的蒙皮数据,把数千个顶点映射到几十个关键点(被称为骨骼)上,每个动画帧就不再需要重复几千顶点的空间状态。

更进一步,如果动画的时间很长,即使记录每一帧骨骼的状态也会多余。我们会记录一些关键帧(key frame)上的骨骼状态,再用插值的方式求得每个渲染帧的骨骼状态。数据就得到了进一步的压缩。

让我们回顾这个流程:

骨骼关键帧 (通过插值得到)每个渲染帧骨骼信息,(通过蒙皮按权重计算出)这帧模型每个顶点(经过光栅化)映射为屏幕上的画面。

这里的每个环节,前面的都有更少的数据,可以通过索引和计算,得到下一个环节更多的数据。因为 GPU 比较适合做简单但巨量的并行计算,所以,我们每个环节都可以考虑把提前预运算,或是实时计算;计算可以考虑放在 CPU 中,或是放在 GPU 中。每个选择必定有取舍,都可以根据实际场景考量。

当需要渲染的物件非常多时,为了提高性能,要么尽可能的把数据预预算好,而不要每帧都反复计算;要么把计算放入 GPU 中,让处理器可以尽可能的并行。

上面列出的动画流程中,中间一步“从蒙皮计算出顶点”是最有弹性的,根据不同的场合会有不同的处理方法。

早年 GPU 无法处理复杂的业务,大多数引擎会选择在 CPU 中处理蒙皮。如今,GPU 有了 compute shader ,把蒙皮放在 GPU 中处理越来越普遍。 但今天谈的这个场合,我任何并非最佳方案。

因为在 GPU 中计算蒙皮,也面临两个选择:其一,渲染每个物件,都从骨骼到顶点再到屏幕像素跑一遍完整的流程,这样带宽开销是最小的,都计算量最大;其二,把蒙皮过程分离出来,GPU 计算出蒙皮结果,然后把蒙皮后的顶点和其它静态模型顶点统一处理,这样做需要临时占用额外的显存来保存蒙皮计算结果。

如果选择方案一,我们会多出相当多的重复计算。首先,3d 管线中,每个模型都不只处理一次渲染。会有 preZ ,阴影等额外的渲染步骤,这个方案会重复计算蒙皮几次。其次,更重要的是,对于僵尸潮来说,僵尸的数量越多,在播放动画时,恰巧处于同一个动画帧的可能性就越大。当渲染数以千计的僵尸时,会做相当数量的重复蒙皮计算。

如果渲染方案二,我们会为每个僵尸对象的蒙皮结果在显存中保存一份临时顶点数据。PC 上或许有足够大的显存,但手机上非常容易显存不够。(我们过去尝试过这个方案,在手机上果真遇到了显存不足的问题)固然我们可以继续优化方案二,加一个间接层合并一些重复的数据,但渲染管线的复杂度会上升很多,暂时不往这个方向考虑。

实际上,把僵尸模型的所有动画的每一个动画帧的蒙皮结果预算计算好,在这个场景是最为简单有效的方案。

游戏中,单个僵尸只有不到 300 个顶点,以每秒 30 帧动画计,每秒动画不到 10k 顶点数据。预计算 100 秒动画数据,手机显存也能存放的下。我们只需要在 CPU 中烘培好这些数据,就可以把这些动画顶点变成静态模型一并处理。然后使用简单的 instancing 方法就能批量处理上千僵尸的渲染。

以上就是最近在 Ant Engine 中增加的特性,实测在 iPhone 12 上可以流畅渲染数千僵尸动画。不过,这个游戏本身不是 Ant Engine 制作的。虽然我和项目制作人讨论过用新引擎重新制作的可能性,因为开发计划的压力未能实施。


另外,我们在 Ant Engine 中实现这个新特性时,接口设计中用到了一个小技巧:

Ant Engine 是基于 Lua 的。Lua 的函数调用有不小的开销,所以我们在使用时应该避免每帧做过于频繁的调用。对于动画系统,底层接口需要设置每个模型每帧渲染使用的是预计算好的顶点组中的哪一段(以此为 instancing 的一个参数)。逻辑上讲,就是需要通知渲染层动画模型当前帧需要渲染的帧号。

当僵尸数以千计时,每一帧都需要修改每个僵尸的动画帧号,总共数千次。这每帧数千次的 Lua 函数调用其实是可用一个简单的技巧避免的。以走路动画为例,引擎内部其实只持有一个预渲染好的走路动画所有帧的对象。我们每帧都固定推进这个对象的 offset ,而使用这个动画的对象,它们实际保存的是一个起始相位值。在 C 层,每次渲染时都把这个相位数和动画对象中的 offset 相加再对总动画帧数取模,才得到真正的帧号。这样,我们只需要每帧去推进单个动画对象的 offset 就够了,而不需要上层通过 Lua 接口修改每个对象的动画帧。

Ant Engine 的一些优化

作者 云风
2024年4月12日 13:08

最近一段时间都在公司内寻找项目可以合作推进 Ant Engine 的使用。我觉得自研引擎的一个重要优势在于我们可以针对具体游戏更好的做性能优化。在目标设备硬件性能允许的范畴内,把画面质量和交互体验做到更好。而同样的优化手段,在通用商业引擎上面做会困难的多,甚至无法顺利完成。

我们用 Ant Engine 制作的第一款游戏 Red Frontier 在一年前是性能完全不达标的。它在 iPhone 8 上甚至都达不到 30fps ,无法流畅游戏。很多性能问题是已知问题,比如我们用 Lua 搭建了整个引擎,一开始只考虑了引擎结构和正确性,把性能搁置在一边待后面再处理。

优化方案是一开始就想好的:借助 lua ecs 框架,把数据结构放在 C 内存中,必要时可以绕过 Lua 代码,直接用 C 代码控制核心数据。我们花了大约 3 个多月的时间将核心渲染系统用 C 重写后,就把性能提高了 1 个数量级以上。这个过程可以说是一直掌握在手中,按计划推进。

但即使可以让游戏运行在 60fps 下,优化的目标也远远没有达到。这是因为对于手机设备来说,用户更容易产生电量焦虑。在固定座位上插着电玩主机或 PC 游戏,玩家不会去想游戏机耗了多少电;即使把 switch 外带玩游戏,也可以一直玩到没电;但用手机不光是用来玩游戏的,如果消耗电量太快,玩家会担心手机等一下会不会无法支付交通费用,不能扫码吃饭……

我甚至一度怀疑,手机并不适合长时间沉浸式的游戏类型。或许放置游戏这类玩一下放一下的游戏类型更合适一些?

游戏达到要求帧率后,继续做性能优化和传统游戏引擎非常不同。

过去我们做性能优化,追求的是每项任务的低延迟。最快的做完每件事就能提高帧率。但节能不是这样的考虑,在较长尺度内,尽量少做重复运算,才能减少总的能量开销。能缓存住耗能较高的计算结果,哪怕复用这些结果比重新计算更慢,恐怕也是值得的。

其实手机的硬件设备就是在向这个方向努力。例如 Tile based rendering 的 GPU 架构,它本质上不是为了渲染更快,而是用能耗更低的手段检测出潜在的重复计算,缓存下渲染结构,争取在下一帧复用。最重要的目的就是节能。

软件设计上更是如此。我在之前的 blog 中就探讨过可能会做的一些优化方向


另一方面,我们需要针对具体的游戏去考虑优化方案。

比如我们在现在的游戏中使用了这样一些优化手段:

虽然场景中有大量的对象时刻在运动,且这些运动和 gameplay 息息相关。例如在公路上跑的小货车、仓库和机器间来回运转的无人机。如果用常规方案,它们均需要每一帧都重新计算位置。如果数百个这样的对象都在 Lua 中计算位置,性能就会受到冲击。不光是位置计算,还涉及把状态同步到渲染底层。

我们实际在 gameplay 层用了一个非常低的帧率计算这些对象(小车/无人机)的位置。然后在引擎中实现了一个运动插值器,可以直接在 C 中补足运动轨迹,把 gameplay 中计算出的离散点变成连续轨迹。这样就大大减少了在 Lua 中的计算量。

另一个例子是动画。

游戏中大量的建筑都带有丰富的动画。一开始,我们在 blender 中制作建筑的细节动画,给建筑做上蒙皮、骨骼,再导出到游戏中。一般来说,受硬件限制,即使有大量相同建筑,但因为其动画轨迹上的相位不同,我们也很难把这些带有蒙皮的建筑事先都提交到显存中用单一绘图指令批量绘制,即所谓的合批操作。

但我们在这个游戏中还是做了一些尝试。例如,我们把建筑的动画分为四类,停工动画、正常运转动画、以及两个状态之间的衔接动画。然后,把这四组动画均做成相同时长三秒。gameplay 层在需要渲染时,都在 3 秒间隔上切换动画。这样,就让所有相同建筑播放同一个动画时,动画轨迹的相位是完全相同的。

在此基础上,我们就可以让引擎把相同建筑带上蒙皮合并渲染了。

前几个月,我发现,我们游戏中用到的动画全部都是一些机械运动。看起来使用蒙皮动画并不是必须的。只要我们可以把机器都拆解成一个个机械零件,每个零件其实都是刚体,骨骼只需要直接驱动这些刚体,而不必借助蒙皮去驱动网格上的点集(蒙皮)。如果把动画改成这种形式描述,整个游戏完全不需要蒙皮动画,而机器的那些相同的零部件天然就可以合并成同一绘图指令。

要做到这一点,只需要做好美术用的工具,让美术直接用定制的工具来制作机器动画就可以了。我们花了 3 个月的时间,项目组内唯一的一个美术就完成了将原有的蒙皮动画全部转换为自有形式的刚体动画的工作。游戏性能也就大大提高了。

目前,我们游戏在 iPhone 上已经不太耗电,正常游戏过程,相同时间甚至比玩微信刷抖音还要省电,所以我对游戏性能方面的表现也就没那么焦虑了。接下来可以有更多时间去考虑 gameplay 方面的调整。


前几天,我看了公司另外一个刚开始的新项目。是一个沙盒生存类的 3D 游戏。第一眼看上去场景有点像塞尔达,又或者说有点魔兽世界场景的感觉。场景特别的复杂。目前是用 Unity 制作的,虽然最终会在手机上运行,但现阶段在 PC 上也不到 30 fps 。简单看了一下,场景中 gameobject 就多达十万个之量级。CPU 有非常大的压力。

虽然该项目短期不太可能更换引擎。但我还是忍不住去想了一下,如果我们用 Ant Engine 参与制作的话,应该怎样优化如此之大的场景。

Ant Engine 目前已经很好的处理了:“内存中的对象数量对当前帧需要渲染的对象数量无太大影响”这个问题。即使内存中有 100 万个对象,但如果有方法确定只渲染其中 1000 个对象的话,100 万数量级不会对性能造成冲击。关键问题在于怎样从待渲染集中剔除掉大量不需要的对象。

针对魔兽世界这种视角的 3d 游戏场景,一个最大的特点是,摄像头跟随着玩家,它永远以相对场景规模来说较慢的速度运动。也就是说,一旦我们以人物为中心,剔除掉大量不需要渲染的对象,这个剔除过程并不需要非常频繁的操作,而只需要及其低频的修正剔除结果就够了。

游戏场景虽然复杂,但却是基本固定的。所以,我们可以做相当多的离线标注。例如,在房间中,如果没有窗户,就不会关心房间之外的世界;反之,如果在户外,即使房子近在眼前,也不用关心室内的家具。

用距离和物件的大小两者就能剔除大量的物件,而不需要特别复杂的空间分割方案。物件越小,可见距离就越短。

剔除过程其实可以放在独立线程中,根据离线标注做一个非常粗略的筛选。筛选结果比设计可见性要宽泛一点。这样,即使筛选结果有一点的误差,也不影响正确性。我们在渲染系统中,再对这个粗筛可见集再做一次视锥体剔除。这个剔除过程完全可以做成异步请求,即根据玩家的位置等参数,请求一个粗略的可见集。

这个计算过程可以比较复杂,利用来离线标注的信息。因为它非常独立,所以这些复杂性不会增加系统整体的复杂度。而结果允许晚几帧甚至晚几秒才到达渲染线程。这样做的正确性是由“玩家在场景中移动速度相较场景规模非常慢” 这个预设前提来保证的。如果 gameplay 要求玩家在场景中做瞬间大范围移动,那么也可以简单的把异步请求改为同步请求。

同样,如果我们在场景中需要摆放很多的点光源来增强表现力的话,也不应该完全把光源剔除的工作完全做在渲染层。使用一个异步光源剔除方案且结合游戏场景特点定制(使用更多的离线标准信息)会更为廉价。

避免帧间不变像素的重复渲染

作者 云风
2023年12月25日 13:30

上周五在公司内做了一个技术分享,介绍我们最近五年来自研的游戏引擎,以及最近一年用这个引擎开发的游戏。大约有一百多个同学参加了这次分享会,反响挺不错。因为这些年做的东西挺多,想分享的东西太多,很多细节都只是简单一提,没时间展开。

我谈到,我们的引擎主要专注于给移动设备使用,那么优化的重点并不在于提高单帧渲染的速度,而在于在固定帧率下,一个比较长的时间段内,怎样减少计算的总量,从而降低设备的能耗。当时我举了几个例子,其中有我们已经做了的工作,也有一些还没做但在计划中的工作。

我提了一个问题:如果上一帧某个像素被渲染过,而若有办法知道当前帧不需要重复渲染这个像素,那么减少重复渲染就能减少总的能耗。这个方法要成立,必须让检查某个像素是否需要重复渲染的成本比直接渲染它要低一个数量级。之所以现存的商业引擎都不在这个问题上发力,主要是因为它们并没有优先考虑怎么给移动设备省电,而要做到这样的先决条件(可以廉价的找到不需要重新渲染的像素),需要引擎本身的结构去配合。

我没有在分享会上谈细节,是因为我不大想谈还没有做出来而只是在构想中的东西。但我同时承诺会写一篇 blog 展开说一下,便有了现在这篇。

我们尚未去实现这个想法是因为目前引擎的性能已经被优化到比较满意的程度,而完善游戏本身要重要得多。对于只有 3,4 个人的小团队来说,必须推迟一些不重要的工作。

我们的引擎虽然是用 Lua 编写的,但性能瓶颈目前在 GPU 而不是 CPU 上 。比如,开启 PreZ 这个流程,先把几何信息提交到显卡,减少部分重复的像素着色器的运算,就能明显的看出性能的提高。

PreZ 是一个非常流行的优化方法:我们先把需要绘制的对象写入 Z-Buffer ,这样就可以得到当前帧和屏幕对应的每个像素的 Z 值。然后在后续的渲染中,只要没有半透明的像素,都可以先和这个计算好的 Z 相比较,如果它将被后续像素覆盖,那么就不必运行它对应的像素着色器。

PreZ 的算法很简单,就是把场景多遍历并渲染一次即可得到所需的 Z-Buffer 。那么,有没有什么廉价的方法可以得到一张蒙版图,让当前帧相对上一帧并没有改变的像素都在蒙版上标记出来呢?我们可以想象,如果光照情况在帧间没有发生改变(这很常见),摄像机也无变化(除了 fps ,这也很常见),其实每一帧不变的像素其实占有很大的比例。即使是 fps ,摄像机也不是全程逐帧运动的。从节能角度看,我们减少了一个时间段内的大量像素着色器的重复运算,就将是个非常成功的优化。

如何用廉价的方法得到这样一个蒙版:蒙版上的 1 表示这帧不需要绘制的像素,0 表示需要绘制的。这个蒙版不需要生成的非常精确,任何本该是 1 的地方变成 0 都是可以接受的,反之则会导致 bug 。每帧结束后,不要清除 backbuffer ,而在绘制阶段,每个绘图指令都带上这个蒙版(就好比带上 PreZ 生成的 Z-Buffer 一样)就可以渲染出正确的结果。

为了简化问题,我们可以先不考虑阴影(后面再谈有阴影的问题)。那么,每个绘图指令都是对屏幕空间指定像素的直接修改。最终,屏幕上每个像素都被若干绘图指令修改了多次。正如 PreZ 可以帮助快速剔除特定像素上的多个不必要的绘制过程而只保留正确的那一个那样,我们也可以用类似的方法来在每一帧的开头生成蒙版。

为了表述方便,我们把绘图指令分为两类,红色和黑色。红色表示,这是一条上帧没有出现过的绘图指令;黑色表示,这条绘图指令上一帧出现过。这个信息,只要引擎合理的设计,是很容易知道的:任何一条绘图指令,它都能知道其参数是否和上一帧相比发生了变化。如果我们把红黑两色绘制到屏幕上,那么,任何一个像素只要出现过至少一次红色,它最终就是需要绘制的,而如果全为黑色,它就很可能可以保留上次的绘制结果。

有什么简单的途径可以知道某个像素在什么时候没有红色却也需要重新绘制呢?答案是实际绘制上这个位置的黑色的次数。只要次数完全相同,就能保证这个像素一定和上一帧完全相同。

这个算法很容易实现。我们每帧将 buffer 清零。对于黑色绘图指令,在光栅化时把对应的像素加 1 ;而红色绘图指令则加一个极大数。最终,我们比较 buffer 和上一帧 buffer ,将极大值以及和上一帧不同的值设置为蒙版上的 1 ,就得到了变化蒙版。

20 多年前,我在风魂这个 2D Engine 中实现过类似算法。在西游系列的游戏中运用,性能比同时期的 2D 图形引擎好不少,就是因为它可以剔除很多当前帧不必要重复渲染的像素。当然,当时我是在 CPU 中实现的这个算法,而今天改到 GPU 中去做也不算麻烦。

同样,除了 GPU 层面,我们还可以在 CPU 也运用类似算法,减少一些多余的图形指令。只需要把 backbuffer 按比例缩小(比如每个轴缩小 64 倍),得到一个粗略的网格。然后把每个绘图指令涉及的 mesh 投影这个 buffer 上的 AABB 矩形计算出来,用同样的方法把绘图指令记录在网格的每个格子上。最终,我们可以剔除掉那些当前帧和上一帧完全相同的格子。这些格子可以用来得到一个更粗粒度的蒙版,同时剔除掉对应的绘图指令。


阴影怎么办?

我倾向于为每个可以接收阴影的物件单独生成一张独立的较小的阴影图,而不是像传统方式那样,将所有的场景物体渲染去一张非常大的单一阴影图上。

初看这个独立阴影图的想法会觉得性能无论上时间还是空间上都难以接受。因为如果有 n 个物体需要接收阴影,有 m 个物体会投射阴影,那么就需要绘制 n * m 次,并生成 n 张阴影图。

但实际上,单独的阴影图会比流行的 CSM 等算法更简单,也更容易提高单个物体阴影的精度。而大多数情况下,只要场景上的物件大小不是差距很大,且分布均匀的话(第三人称视角的游戏中非常常见),每个物体只会接收其周围很少几个物体的投影。而一个物体受哪些物体的投影影响这个信息,在帧间通常变化很少,所以筛选过程并不需要每帧全部重新计算。所以,生成 n 张阴影图的成本远远不到 O (n * m) ,应该可以优化到 O(n Log m) 左右。

如果再考虑做以上相同的帧间 cache ,对于每个物件单独的阴影图(也只是它材质的一部分),很可能下一帧并不需要重新生成,只需要投影它的有限几个物件没有改变即可。整体的(尤其是能耗)成本很有可能比传统方式更小。

❌
❌