现代渲染能有多现代?

去年5月,我开始了现代渲染学习之路。目前,作为一个一年半的现代渲染练习生,准备来讲讲这个议题:

现代渲染能有多现代?

作为一名古董级渲染工程师,我曾长期深耕于OpenGL ES领域。此次对现代渲染技术的学习,彻底刷新了我的认知。同时,这些现代渲染方案已在Vulkan平台上通过真机测试,并能高效应用于现代移动设备。

Vulkan作为一项诞生于现代的最新API,其设计理念极致贴近硬件,同时也是目前唯一能实现全平台跨越的渲染API。Mac平台目前采用MoltenVK,未来将由一个更底层的kosmetkripse替代,以提供更接近原生的Vulkan支持。

为避免枯燥的平铺直叙,我将通过自问自答的形式,阐述我对现代渲染的当前理解。

1. 一个现代的shader是什么样的?

下面是shader codebase的bindless module的一段代码

namespace Bindless
{
    public Sampler2D GetSampleTexture(int index )
    {
        return SampleTextureArray[NonUniformResourceIndex(index)].as<Sampler2D>();
    }

    public RWTexture2D<T> GetStorageTexture<T : ITexelElement>(int index )
    {
        return StorageTextureArray[NonUniformResourceIndex(index)].as<RWTexture2D<T>>();
    }

    public uint2 GetStorageTextureDimensions(int index)
    {
        uint2 dim;
        StorageTextureArray[NonUniformResourceIndex(index)].as<RWTexture2D<float4>>().GetDimensions(dim.x, dim.y);
        return dim;
    }
}

一年前的我,绝对不会相信,这是一段shader代码。而现在,每天写的就是这一类代码,非常现代

下面是硬件PT和软件ST渲染器的核心代码

import Common;
import Bindless;
#include "common/ShaderClock.slang"

[shader("compute")]
[numthreads(8, 8, 1)]
void main(uint3 DTid : SV_DispatchThreadID)
{
    START_SHADERCLOCK()

    // compose raycaster, raytracer, etc
    FVisibilityBufferRayCasterV2 rayCaster;
    FHardwareRayTracerV2 tracer;
    FHardwareDirectIlluminatorV2 dIlluminator;

    // build renderer
    FPathTracingRendererV2 renderer;
    renderer.ExitProbability = 0.5f;
    renderer.ExitAfterFirst = Bindless.GetGpuscene().Camera->FastGather;
    renderer.HitNormalOffset = 0.001f;
    renderer.SampleDownscale = 1;
    renderer.Init(DTid.xy);

    if( !renderer.PrimaryHit(rayCaster) )
    {
        END_SHADERCLOCK(DTid.xy)
        return;
    }

    int sampleMultiplier = Bindless.GetStorageTexture<uint>(Bindless.RT_MOTIONMOMENT)[DTid.xy]
                           > 0 ? 4 : 1;
    renderer.Render(tracer, dIlluminator, sampleMultiplier);
    END_SHADERCLOCK(DTid.xy)
}
import Common;
import Bindless;
#include "common/ShaderClock.slang"

[shader("compute")]
[numthreads(8, 8, 1)]
void main(uint3 DTid : SV_DispatchThreadID)
{
    START_SHADERCLOCK()

    // compose raycaster, raytracer, etc
    FVisibilityBufferRayCasterV2 rayCaster;
    FSoftwareRayTracerV2 tracer;
    FSoftwareDirectIlluminatorV2 dIlluminator;

    // build renderer
    FPathTracingRendererV2 renderer;
    renderer.ExitProbability = 0.5f;
    renderer.ExitAfterFirst = true;
    renderer.HitNormalOffset = 0.1f;
    renderer.SampleDownscale = 2;
    renderer.Init(DTid.xy);

    if( !renderer.PrimaryHit(rayCaster) )
    {
        END_SHADERCLOCK(DTid.xy)
        return;
    }

    renderer.Render(tracer, dIlluminator, 1);
    END_SHADERCLOCK(DTid.xy)
}

与其说是着色器,其实更像一个渲染器。slang的语法,让封装,重构成为了一件非常简单的事情,一定程度上,对shader代码质量的提升也会很有帮助。

Slangᴿ from nvidia

Slang,NVIDIA发起并集成于Vulkan SDK的项目,正是我曾构想的着色器语言,如今梦想成真。其高级语法、全平台编译(包括WebGPU)的特性,展现了极佳的普适性。附带的自动微分功能及工具链,也为渲染特征的预训练提供了新途径。

Slang支持泛型、成员函数、模块、命名空间等高级语言特性,让着色器代码库的编写体验,已然与C++开发无异。更令人称奇的是,我将一套GLSL着色器代码库转写为Slang后,性能竟有所提升。

拥有Slang,现代渲染开发无疑如虎添翼。

2. 现代的GPU资源管理是什么样的?

个人理解,现代渲染,gpu资源管理是非常关键的一点。而管理gpu资源最重要的一点就是Bindless。最好做到零bind。

做到了零bind,让pipeline可以访问所有的东西。不管是写代码,还是做管线,都可以做到极高的自由度。

在我将管线完全换为零bind之后。有一天晚上,有一个解决残影问题的思路,我只花了几分钟的时间,就完成了修改,验证的几个循环,迅速的解决了问题,无需关心我需要将绑定的纹理换成哪些,需要加入和清理多少参数。而在没有实现零bind的时候,这种级别的改动应该至少是一晚上的开发时间。

做到零bind,主要需要以下几个vulkan feature:

这时,cpu和gpu的数据交互,被浓缩到了这样两个小结构中

public struct ALIGN_16 GPUScene
{
    /* Scene Info */
    public UniformBufferObject* Camera;

    /* Scene Node Tree */
    public NodeProxy* Nodes;

    /* Global Vertice Buffer */
    public uint* Reorders;
    public half4* VerticesSimple;
    public GPUVertex* Vertices;
    public uint* Indices;

    /* Resources */
    public Material* Materials;
    public ModelData* Offsets;

    /* Others */
    public AmbientCube* Cubes;
    public VoxelData* Voxels;
    public PageIndex* Pages;
    public SphericalHarmonics* HDRSHs;
    public LightObject* Lights;

    /* IndirectDraw */
    public VkDrawIndexedIndirectCommand* IndirectDrawCommands;
    public GPUDrivenStat* GPUDrivenStats;
    
    /* TLAS */
    public uint64_t TLAS;

    public uint SwapChainIndex;
    public uint custom_data_0;
    public uint custom_data_1;
}

// bindless textures
[[vk::binding(0, 0)]] __DynamicResource SampleTextureArray[];
[[vk::binding(1, 0)]] __DynamicResource StorageTextureArray[];

gpuScene.IndirectCommand为起点,我们就可以在gpu上渲染出整个场景。

3. 现代渲染是如何组织Draw的?

一个疯狂的海洋球场景

现代渲染是通过gpu自驱动来完成drawcall的,而非传统的cpu驱动。零Bind的gpu资源管理,核心目的就是为了让gpu能够更完整的自驱运行,将cpu的干涉降到最低。

“传统”gpu driven

比较可惜的是,在mesh shader/task shader出现之前,虽然有indirect draw,gpu的自驱运行尚做不到十分完美。

传统gpu driven的基本流程

  1. 创建足量的indirectdraw
  2. gpu cull,在ComputeShader中剔除的indirectDraw修改为空绘制
  3. cpu发起足量的indirectdraw

这里会有一些问题,在桌面的gpu,空绘制的indirectdraw几乎没有开销,但在移动gpu上,这些空绘制也会造成较大的开销。

“现代”gpu driven

vulkan新增了mesh shader / task shader,这解决了空绘制的问题。indirectdraw不再只能由发起。而可以通过task shader发起,这样,可以让gpu只发起需要绘制的indirectdraw,而不用填充大量空绘制。

模拟”现代”gpu driven

再次可惜的是,大量的移动gpu,不支持mesh shader。但有一篇文章提出了模拟方式,并指出amd的部分显卡的mesh shader就是软件模拟的。

实现上的思路其实比较简单,将所有drawcall的instance数据拼接进一个巨大的数组,通过单个drawcall调用

gkNextEngine目前还是使用的传统gpu driven的方式,由于meshshader的兼容性问题,可能后续会改进为模拟方式实现现代的gpu driven

4. 现代光照很复杂吗?

这个问题,我无法给出正确的回答。这一次现代渲染研究的初衷,就是想要正确的理解硬件光线追踪。而最本质的PathTracing算法其实是非常朴素的,我把它形容为,回归本源,忘掉trick。从相机出发,让光线“找到”光源。

当然,对比最初的实现,现在的codebase,已经复杂了很多。但,都不是类似于之前做渲染时的各种“巧妙”的trick。而是为了最大限度的加速上面说的这个最朴素的过程。

从某种程度上来说,现代光照是很简单的,他不再像之前那样要把直接光照,间接光照,一项一项的通过各种各样的手法,以不同的组合方式达到接近真实的渲染结果。而是一开始就有了最真实的渲染结果,只是很慢。通过各种各样的手法,在结果不怎么偏离的情况下,尽可能的加速这个过程。

gkNextEngine中的加速方法

在gkNextEngine中,我设计了一个离散分布于场景中的AmbientCube探针系统。探针会通过多次射线追踪(GPU或CPU),获取其位置六个cube朝向的光照信息,并存储于一个类似体素的存储系统中。在PathTracing的过程中,光线按照概率提前退出,在退出的命中点采样附近的探针信息,插值出该位置的光照,作为其后续“光路”模拟,贡献于最终结果。通过控制提前退出的概率,我们便能控制PathTracing的平均追踪次数,从而平衡效率。这与NEE(Next Event Estimation)的思路有那么一点相似。

当然,仅凭此加速策略,在低于16个样本的情况下,我们依然无法得到一个可接受的结果。在离线渲染中,我们通常通过长时间、持续地累积样本并进行平均,来逐步消除噪声,最终获得一个平滑且高质量的图像输出。因此,在实时渲染的动态环境中,如果能尽可能地累积多帧样本,我们便有望逼近离线渲染的输出品质。举例来说,若一帧产生8个样本,成功累积16帧,我们便能获得相当于128个样本的渲染结果,这在视觉上通常已可接受。为此,我设计了一种特别的reprojection方案,它会根据objectid和normal,对diffuse、specular、albedo结果分别进行时间和空间上的样本累积,以实现高样本数的渲染。

最终,128个样本的渲染结果,即使在不接降噪器的情况下,也已是一个可接受的品质。

hardware ray tracing

Vulkan通过扩展(extension)的方式,提供了硬件光线追踪的支持。这主要通过构建硬件加速结构(BVH),来实现高速的射线求交。

早期,Vulkan引入了一种“raytracing pipeline”的模式,通过编写多个阶段的着色器(shader),硬件会在射线的不同阶段进行调用,从而实现光线追踪。

后期,Vulkan又提供了“rayquery”的方式。这种方式允许在着色器的任何阶段发起射线查询,这也是gkNextEngine所选择的硬件设施,并且是目前移动设备上唯一支持的方案。基于Bindless的硬件资源管理,rayquery能够极其便捷地实现各种光线追踪算法,这无疑为开发带来了极大的灵活性。

software ray tracing

在不支持光追的硬件上,比如基于moltenvk的mac笔记本,不能就此放弃现代渲染。回归到之前,因此我们就需要一种近似的软件实现的追踪算法。

当然,直接用computer shader实现一个bvh求交,也不是不可以,甚至tinybvh库已经给出了用于gpu使用的数据结构。也有很多人做了相关的实现。但感觉只是单纯炫技并没什么实际的用处,如果能快过硬件,那为什么显卡厂商不约而同的生产了那么多RT CORE?

因此,我基于实际工程出发,前面提到的用于提前退出的那个AmbientCube探针,是可以用来进行粗略的软件求交的。我的探针分布为0.25米一个,密度足以表达场景。

探针发出的射线命中mesh的背面,被视作距离为0,命中正面,则会记录距离。这样一个数据结构,便可以用来表示空间了。我们可以遍历一条射线上的所有探针,根据他的最大距离,来快速移动(比较类似SDF)。如果触碰到距离为0或1的探针,说明有了命中交点。

hybrid context

gkNextEngine为移动端及桌面高帧率环境,实现了一种高效的混合渲染模式。该高性能渲染需求,正是AmbientCube探针系统的设计初衷。初期,其工作方式略似DDGI:AmbientCube探针通过硬件光追实时生成,所得结果经插值后作为场景光照。

后期,引入了tinybvh,将0.25米探针视为体素,支持软件射线追踪。数据被划分为voxel与AmbientCube两层。Voxel层可由CPU实时生成;AmbientCube计算则灵活地选择硬件或软件执行。

最终,AmbientCube探针数据作为混合上下文(hybrid context),在多种渲染模式中均扮演着关键角色:

  1. PathTracing: 作为提前退出的后备缓存,提升渲染效率。
  2. SoftTracing: 兼作提前退出的后备缓存与软件追踪的数据结构。
  3. SoftModern: 提供漫反射光照来源及镜面反射遮挡数据结构。

通过此混合上下文,gkNextEngine的全局光照渲染展现出强大的伸缩性。

5. 时域渲染

这里想多讲几句,关于时域渲染。

从TAA出现的那一刻起,渲染的思路就完全改变了,通过重投影,将“样本”分摊到多帧,是一个绝佳的解决思路。并且配合现在的高刷显示器,样本的分摊,其实是可以做到“人眼补帧”的。

目前我的开发主要在120-240hz的显示器上进行,我尽量将渲染器的运行效率控制在120hz以上,我就会发现,截图下来的图像,和我肉眼看到的图像,质量已经有非常大的差距了。这就是“人眼补帧”。

在60hz的显示模式下,我就可以把样本数提升一倍,得到和120hz显示器下接近的肉眼图像质量,但

因此,gkNextEngine在后续的开发中,会继续坚持时域渲染这个特征,进一步发扬光大。包括渲染和逻辑的异步运行等,继续探索时域渲染的更多可行性。

瞄准未来

经过这半年的疯狂补课,我有一个清晰的感受:

实时渲染的未来,游戏引擎的未来,一定有所颠覆

不光是光追这种精确而朴素的方法,gpu和cpu的解耦,通过LLM产生的AI革命,将会很快的影响游戏工业。

而尽管是目前使用最多,最为主流,最为先进的Unreal Engine和Unity Engine。也因为一路走来,有了太多太多的包袱。

说实话,我苦于各种引擎的材质爆炸已久。各种情形使得变体数量指数级的增长。面对硬件光追,也一定是传统开销叠加上硬件光追的开销。很难说,他们在面对现代渲染,面对未来,已经做好了准备。

因此,我决定把实验性质的gkNextRenderer变为gkNextEngine,整好也和我的gkEngine相呼应。

这次的gkNextEngine,我没有任何保留,我决定完全轻装上阵。当然它的目标:

Just for fun.

这比我的gkEngine的目标更为纯粹。

我给gkNextEngine定下了几条原则:

  1. 永远使用最新技术,不考虑向前兼容
  2. 拥抱强大的第三方库,绝不主动造轮子
  3. 保持代码库的小体积,易读性

遵循这个规则,目前NextEngine除了图形层面的开发,还完成了诸多用于实际游戏的模块,并开发了一个类似MagicaVoxel的乐高搭建小游戏。拥抱强大的第三方库,也让我打开了新的世界。目前强依赖的第三方库:

目前已有模块:

最近用SDL替换glfw,看到Sam Lantinga自1998年以来对SDL的持续投入,确实让人深感敬佩。他二十多年如一日地更新代码,甚至直到昨天还在为SDL3贡献力量,这种坚持不懈的精神,在快速迭代的软件行业中显得尤为珍贵。从最初的起点,到后来辗转几家公司,最终在Valve继续发光发热,并且许多Valve的软件和游戏都基于SDL,这本身就是一段传奇。

这样的历程,也让我对自己的项目——gkNextEngine——有了更深的期许。我真心希望它也能成为一个能够持续更新的开源项目,像SDL一样,不断演进,始终保持先进性。我希望这份热情和投入能够一直延续下去,直到我真正写不动代码的那一天。这不仅仅是一个技术项目的愿景,更是一种对创造和贡献的个人承诺。