使用 GPU 进行 Lightmap 烘焙 - 简单 demo
![](https://img2024.cnblogs.com/blog/619936/202406/619936-20240616151105880-1375705331.png)
作者:i_dovelemon
日期:2024-06-16
主题:Lightmap, PathTracer, Compute Shader
引言
一直以来,我都对离线 bake lightmap 操作很着迷。一方面,这个方案历久弥新,虽然很古老,但是一直在实际项目中都有使用;另一方面,它能够产生非常高质量的 GI 效果。
很久之前,我写过一个基于 Radiosity 算法的 baker 。那个算法很简单,但是却不够吸引人。当时我就想以后一定要写一个基于 PathTracer 的 baker,所以,就有了这个 demo 的产生。
做这个 demo,我按照一定的步骤,迭代式的进行开发:先实现一个 CPU 版本的 PathTracer,然后基于此实现一个 GPU 版本的 PathTracer。PathTracer 相关操作完成之后,实现一个 CPU 版本的 Lightmap Baker,然后基于 CPU 版本,移植到 GPU 上。
首先申明,我这里只是做概念上的验证,很多功能都没有支持,性能也未作任何优化,只能算是一个可工作的版本。主要目的,是为了掌握一般性的流程和一些核心的操作。
这里的 Lightmap 是最基础的,只支持 diffuse 材质,和两种解析光源:平行光和点光源。因为这几个属性是实时游戏最常使用到的,也是最容易实现的,所以就只支持了这些。
构造 BVH
以前我写 ray tracing 相关算法的时候,都是使用简单的 sphere 进行场景的构建就能够满足要求。但是这个 demo,我们最终是想实现一个 lightmap 的功能,所以需要支持任意的模型。
而 ray tracing 中最核心的一个操作,就是射线和场景的相交性检测。虽然对于任意的模型,我们可以通过遍历三角形的方式,依次进行相交性检测。但是我这里想尝试一些优化的方案来实现。
这里主要是通过对模型,创建 BVH 来加速射线与场景的相交性检测,主要参考 这系列[2]文章来实现。这个系列文章作者讲的十分详细,这里就不再赘述了。
路径追踪 - PathTracer
路径追踪(PathTracer)是光线追踪的一种算法,它能够渲染出非常逼真的图像,而且算法原理十分简单,所以我使用了它来作为 lightmap baker 的核心算法。
网上有很多关于 PathTracer 的算法教程,这里推荐 Global Illumination and Path Tracing - ScratchAPixel 这篇来做基本的了解。我最初的版本就是基于它这个来实现的。
Brute Force 版本
我首先实现了一个最简单粗暴的版本,流程上大概如下:
- 在一个像素 quad 中,随机选择一个采样点,作为 camera primary ray 的起始点 S,并且与 eye position 连接,构建 primary ray
- 使用构建出来的 ray 与场景进行相交性检测
- 获取到相交点的 position 和 normal 信息,计算当前像素的 direct lighting (使用 direct light sampling 的方式)
- 然后根据当前点的 normal 方向,随机产生一些新的射线 second ray(参考 [3] ),然后对所有这些新的射线,依次继续以上的 2-4 的操作,直到达到预定的 depth 停止
几何上,整个追踪流程类似如下图所示:
很多关于 PathTracer 的教程里面,都是使用一个有面积的几何体作为光源来照亮场景。在这种配置下,射线有可能碰撞到光源几何体,从而照亮整个场景。但是我这里因为只支持解析光源 (Point, Directional),射线永远无法碰撞到一个点或者平行线,依靠粗暴的碰撞光源几何体的方式来照亮场景,显然行不通。所以我这里使用了 direct light sampling 的方式来实现,即:对于每一个碰撞到的点,都主动的发射一条 shadow ray 到解析光源上(点光源就是向点光源中心点发射,平行光就是以平行光方向进行发射),从而判断是否被遮挡。如果被遮挡,这个点就没有 direct light 的贡献;反之就存在 direct light 的贡献。通过这种方式就能够支持解析光源。
如下是这部分的代码:
lmb_color li_brute_force(lmb_bake_data& data, lmb_ray ray, int32_t depth, int max_depth, bool ray_as_hit = false) { if (depth >= max_depth) { return lmb_color(0.0f); } lmb_intersection intersection; if (!intersect_scene(data, ray, intersection)) { return data.ambient; } lmb_vec3 hit_pos = ray.o + ray.d * intersection.t; lmb_vec3 hit_normal = intersection.n; // direct lighting lmb_color direct_light = li_direct(data, hit_pos, hit_normal); // indirect lighting lmb_color indirect_light(0.0f); { float pdf = 1.0f / (2.0f * 3.1415926f); for (uint32_t i = 0; i < data.sample_count; i++) { lmb_vec3 sample; sample.x = rand_float(); sample.y = rand_float(); lmb_vec3 sample_dir = map_sample_to_direction(hit_normal, sample); lmb_ray indirect_ray; indirect_ray.o = hit_pos + sample_dir * 0.001f; indirect_ray.d = sample_dir; float ndotl = sqrtf(sample.x); indirect_light = indirect_light + li_brute_force(data, indirect_ray, depth + 1, max_depth) * (ndotl / pdf); } indirect_light = indirect_light * (1.0f / data.sample_count); } lmb_color albedo = data.albedo; return (direct_light + indirect_light) * albedo * (1.0f / 3.1415926f); }
进行直接光照的部分代码,如下所示:
lmb_color li_direct(lmb_bake_data& data, lmb_vec3& hit_pos, lmb_vec3& hit_normal) { lmb_color result(0.0f); if (data.light.type == LMB_LIGHT_TYPE_SUN) { float ndotl = max(0.0f, lmb_vec3::dot(hit_normal, data.light.dir_or_pos)); float shadow_atten = 0.0f; if (ndotl > 0.0f) { lmb_ray shadow_ray; shadow_ray.o = hit_pos + hit_normal * 0.001f; shadow_ray.d = data.light.dir_or_pos; lmb_intersection shadow_intersection; shadow_atten = intersect_scene(data, shadow_ray, shadow_intersection) ? 0.0f : 1.0f; } result = data.light.color * (shadow_atten * ndotl); } else if (data.light.type == LMB_LIGHT_TYPE_POINT) { lmb_vec3 dir = data.light.dir_or_pos - hit_pos; float l = dir.length(); dir.normalize(); float dist_atten = 1.0f / (l * l); dist_atten = min(1.0f, dist_atten); float ndotl = max(0.0f, lmb_vec3::dot(hit_normal, dir)); float shadow_atten = 1.0f; if (ndotl > 0.0f) { lmb_ray shadow_ray; shadow_ray.o = hit_pos + hit_normal * 0.001f; shadow_ray.d = dir; lmb_intersection shadow_intersection; bool b = intersect_scene(data, shadow_ray, shadow_intersection); if (b && shadow_intersection.t < l) { shadow_atten = 0.0f; } } result = data.light.color * (dist_atten * shadow_atten * ndotl); } return result; }
这个版本的算法,简单粗暴,非常容易理解(除了 蒙特卡洛积分 部分,这部分需要比较复杂的数学知识,可以通过我之前的文章 [4], [5], [6], [7] 来了解,也可以参考图形学圣经 PBRT[8] 来了解,[3] 中也给出了详细的解释)下图,是此方案下,渲染一张 1024x1024,sample count = 4 的图的结果:
Single Sum 优化
上个章节中的 Brute Force 版本,有个很明显的问题,随着递归的深度越来越深,需要计算的光线呈指数级爆炸往上涨。虽然它的计算逻辑非常符合直觉,但是计算量也特别的大。而且,很多我们网上看到的教程,都是单条路径进行追踪,并不是这样的一个指数爆炸式光线追踪的版本。我当初一直很困惑,单条路径追踪的版本,怎么可能和 brute force 等价了?很多教程也没有从 brute force 版本到单条路径追踪版本的理论推导,只是告诉你这样就是正确的。我找了很多资料来解答我的疑惑,最终找到了这个视频 [9],它详细的推导了从 brute force 版本到单条路径追踪版本的理论公式,称之为 single sum。我这里简单的列举下推导的过程。
首先我们给出最基础的渲染方程公式:
我们将中间的 $L(x, \omega)$ 展开,得到如下:
我们只展开了一次,但是从中可以看到一些规律出来:
公式左右两边,随着中间 $L(x, \omega)$ 展开,相同的模式一个接一个的出现。我们使用蒙特卡洛积分重写上述公式,得到如下公式:
这个公式就是上面 Brute Force 版本所使用的公式。可以看到每个求和里面,都还嵌套着另外的求和公式,如此不断递归迭代,从而导致计算非常的慢。所以,通过对公式进行一定的变形,我们能够找到另外优化的方法。我们重写渲染方程为如下的形式:
这里就是简单的将连乘展开,变成连续的加法运算。仔细看下每个加法部分的内容,我们可以得到如下的结论:
从上面公式可以看到,自发光我们不用采样,直接计算得到;直接光照需要对整个场景进行 N 次采样;第一次反弹的间接光照需要对场景采样 NxN 次;第二次反弹的间接光照需要对场景采样 NxNxN 次;如此递归下去。但是很明显这样很不合理。因为对于最终的画面来说,随着反弹次数越来越高,间接光的贡献就越来越小。但是上面的公式反应出,随着反弹的次数变高,我们需要贡献越来越大的计算量去计算间接光。所以,这里就是我们该优化的地方。
我们可以看到,第一次间接光照的项是一个双重积分,而第二次间接光照的项是一个三重积分。也就是说,所有间接光照的积分都是一个多重积分。有没有什么办法可以简化这个多重积分的计算?答案是有的,这里就展现出了,为什么我们要选择蒙特卡罗尔积分求解算法。蒙特卡罗尔积分算法它是一个维度无关的算法,也就是说它不会随着维度的增加而需要增加更多的采样数(详细的讲解参考 [8] 中关于蒙特卡罗尔积分算法的说明)。使用蒙特卡罗尔,我们可以将多重积分的间接光项,转化为一个高维(就是有很多输入参数的函数)单重积分函数,从而得到如下新的公式:
然后带入蒙特卡罗尔积分求和算法,得到如下:
从上面的公式,我们可以看到,我们可以使用同样的采样次数 N 来计算所有的间接光和直接光照项,从而避免了指数爆炸式的计算间接光,却对最终画面贡献不大的情况。因为所有的计算都是使用同样次数的采样,我们将相同的操作提取出来,得到如下:
也就是这样一个计算公式:
这样,我们就将渲染方程,通过蒙特卡罗尔转变成了一个只需要单次求和公式计算的公式,也就是 single sum。这个算法几何上看起来如下图所示:
每一次只会对单条路径进行追踪,也就是路径追踪的由来。
数学上的解释上面已经完备了,这里概念性的说一下,如果 brute force 和 single sum 都是产生无穷多条射线,那么实际所有的光线路径这两个算法都能够覆盖掉,也就是说这两个算法在最终的结果上是完全等价的,就看收敛到最终结果的速度了。
以下是这个方案所对应的代码:
lmb_color
li_single_sum(lmb_bake_data& data, lmb_ray ray, int32_t depth, int max_depth, bool ray_as_hit = false) {
if (depth >= max_depth) {
return lmb_color(0.0f);
}
lmb_intersection intersection;
if (!intersect_scene(data, ray, intersection)) {
return data.ambient;
}
lmb_vec3 hit_pos = ray.o + ray.d * intersection.t;
lmb_vec3 hit_normal = intersection.n;
// direct lighting
lmb_color direct_light = li_direct(data, hit_pos, hit_normal);
// indirect lighting
lmb_color indirect_light(0.0f);
{
float pdf = 1.0f / (2.0f * 3.1415926f);
lmb_vec3 sample;
sample.x = rand_float();
sample.y = rand_float();
lmb_vec3 sample_dir = map_sample_to_direction(hit_normal, sample);
lmb_ray indirect_ray;
indirect_ray.o = hit_pos + sample_dir * 0.001f;
indirect_ray.d = sample_dir;
float ndotl = sqrtf(sample.x);
indirect_light = li_single_sum(data, indirect_ray, depth + 1, max_depth) * (ndotl / pdf);
}
lmb_color albedo = data.albedo;
return (direct_light + indirect_light) * albedo * (1.0f / 3.1415926f);
}
这个版本已经能够比较快速的渲染出一张图了,欣赏下 1024x1024,sample count = 128 的图吧:
Russian Roulette
上面两个算法,我们都通过限定一个最大追踪深度来控制递归不会无限进行下去。但是较小的深度值,会导致一些高贡献度的路径提前终止了;较大的深度值,又浪费了很多计算量。所以最好有个机制,能够自动根据当前射线所能产生的贡献度判断是否需要终止。这里就引入了 russian roulette 随机算法。
我们在追踪一条路径的时候,计算一个当前路径所能够产生的贡献度,然后根据这个贡献度来随机判断是否需要终止。随着路径不停的往前推进,反弹的光线所产生的贡献度也越来越小,就越有可能终止它。
因为我们提前终止了路径的计算,为了保证结果的正确性(即保证算法是无偏的 unbais),我们需要对结果进行补偿,补偿的具体操作原理可以看 [9] 中的描述,这里不再赘述。
如下是支持了 russian roulette 方案的代码:
lmb_color
li_russian_roulette(lmb_bake_data& data, lmb_ray ray, int32_t depth, lmb_color throughput, bool ray_as_hit) {
lmb_intersection intersection;
// avoid recursion too deep
if (depth > 64) {
return data.ambient;
}
if (!ray_as_hit && !intersect_scene(data, ray, intersection)) {
return data.ambient;
}
float rr_prob = throughput.max_coefficient();
rr_prob = max(rr_prob, 0.99f); // avoid bounce forever
if (depth < 2) {
rr_prob = 1.0f; // direct and first indirect bounce, always track
}
// russian roulette terminate
if (rand_float() >= rr_prob) {
return data.ambient;
}
lmb_color albedo = data.albedo;
lmb_color brdf = albedo * (1.0f / 3.1415926f);
lmb_vec3 hit_pos = ray.o + ray.d * intersection.t;
lmb_vec3 hit_normal = intersection.n;
if (ray_as_hit) {
hit_pos = ray.o;
hit_normal = ray.d;
}
// direct lighting
lmb_color direct_light = li_direct(data, hit_pos, hit_normal);
// indirect lighting
lmb_color indirect_light(0.0f);
{
float pdf = 1.0f / (2.0f * 3.1415926f);
lmb_vec3 sample;
sample.x = rand_float();
sample.y = rand_float();
lmb_vec3 sample_dir = map_sample_to_direction(hit_normal, sample);
lmb_ray indirect_ray;
indirect_ray.o = hit_pos + sample_dir * 0.001f;
indirect_ray.d = sample_dir;
float ndotl = sqrtf(sample.x);
throughput = throughput * brdf * (ndotl / (pdf * rr_prob));
indirect_light = li_russian_roulette(data, indirect_ray, depth + 1, throughput, false) * (ndotl / (pdf * rr_prob));
}
return (direct_light + indirect_light) * brdf;
}
这里解释下,因为上面的实现是基于递归实现的,而且是随机终止,理论上可能存在递归太深导致栈内存用完的情况,所以我加了一个保护避免崩溃。等到后面使用循环代替递归算法之后,就可以避免这个操作了。
这个算法在 single sum 的基础上又提升了一些速度,这次咱换个灯光效果,老样子 1024x1024, sample count = 128:
GPU Path Tracer
前面的章节我们已经实现了一个基本的 path tracer,现在我们需要基于这个实现,实现一个 GPU 版本。
从上面的实现可以看出,每个像素的计算都是独立的,所以我们能够基于这个特性,使用 GPU 大量线程并行运行的特性来加速整个 path tracer 的计算。在这里我是基于 vulkan,使用 compute shader 来实现整个 GPU 版本。基本来说,我们就是直接将 CPU 版本的代码直接用 GLSL 移植一遍。但是由于 GPU 本身的限制,有一些特殊的地方需要额外注意。
递归转循环
基于栈结构(stack)转循环
GPU 上是不支持类似 CPU 的递归调用的,所以我们需要将递归转化为非递归的形式。
一般来说,递归我们都可以通过额外添加一个栈结构,将它转变为一个循环。这是因为递归函数的调用,本质上就是将相关参数压栈,递归函数返回就是出栈,所以完全可以手动使用一个栈结构来模拟,如下是在 CPU 端实现的基于栈的非递归版本:
lmb_color
li_russian_roulette_stack_based(lmb_bake_data& data, lmb_ray ray, int32_t depth, lmb_color throughput, bool lightmap) {
static const uint32_t k_stack_size = 64;
lmb_color stack_0[k_stack_size];
lmb_color stack_1[k_stack_size];
uint32_t stack_pos = 0;
lmb_color result = data.ambient;
while (true) {
if (stack_pos >= k_stack_size) {
// too deep, just ignore
break;
}
lmb_intersection intersection;
if (!lightmap && !intersect_scene(data, ray, intersection)) {
break;
}
float rr_prob = throughput.max_coefficient();
rr_prob = max(rr_prob, 0.99f); // avoid bounce forever
if (depth < 2) {
rr_prob = 1.0f; // direct and first indirect bounce, always track
}
// russian roulette terminate
if (rand_float() >= rr_prob) {
break;
}
lmb_color albedo = data.albedo;
lmb_color brdf = albedo * (1.0f / 3.1415926f);
lmb_vec3 hit_pos = ray.o + ray.d * intersection.t;
lmb_vec3 hit_normal = intersection.n;
if (lightmap) {
hit_pos = ray.o;
hit_normal = ray.d;
}
// direct lighting
lmb_color direct_light = li_direct(data, hit_pos, hit_normal);
// indirect lighting
lmb_color indirect_light(0.0f);
{
float pdf = 1.0f / (2.0f * 3.1415926f);
lmb_vec3 sample;
sample.x = rand_float();
sample.y = rand_float();
lmb_vec3 sample_dir = map_sample_to_direction(hit_normal, sample);
lmb_ray indirect_ray;
indirect_ray.o = hit_pos + sample_dir * 0.001f;
indirect_ray.d = sample_dir;
float ndotl = sqrtf(sample.x);
indirect_light = (ndotl / (pdf * rr_prob));
// setup next iteration parameters
ray = indirect_ray;
throughput = throughput * brdf * (ndotl / (pdf * rr_prob));
}
stack_0[stack_pos] = direct_light * brdf;
stack_1[stack_pos] = indirect_light * brdf;
stack_pos++;
depth++;
}
for (int32_t i = stack_pos - 1; i >= 0; i--) {
result = stack_0[i] + stack_1[i] * result;
}
return result;
}
Iterative 方法
基于栈结构的方案的确能够解决问题,但只是从 c++ 的压栈实现,换成了我们自己的栈实现,理论上还是会存在栈过深的问题。最好能够将栈结构给完全去除掉。
这里就介绍一种被称之为 iterative 的方法,这个方法我是从 IQ 大神的 simple path tracer [10] 中学到的。
首先,我们仔细看下 li_russian_roulette 递归版本的代码,可以发现整个函数可以定义为如下的形式:
其中,$f$ 表示当前追踪到的点所对应材质的 brdf;$d$ 表示当前点进行 direct light sampling 计算得到的直接光照;$p\left(\omega\right)$ 表示当前材质的概率密度函数;$t$ 表示使用 russian roulette 下的补偿概率。而 $L_i$ 表示入射光线的 irrandiance,它同样也能够被这个公式所定义。
我们简单的展开这个公式得到如下形式:
继续将公式带入到 $L_i$ 中,不断带入,得到如下一系列的公式:
从这个公式里面,能看到一些规律,我们可以一次计算一个加法项,而后一个项中很多部分是前一个项中已经计算过的:
根据这个规律,我们可以使用一个变量来保存中间结果:
初始时,$a = 1.0$。同时需要注意虽然公式里面写的是 $r$,但是实际上表示的是上一个加法项中的 $r$,我们可以认为初始的 $r_0 = 1.0$。有了 $a$ 保存中间的累计操作,我们就可以得到最后的结果为:
$o$ 的初始值为 0.0,通过上面两个操作就能够不需要栈结构保存中间结果的方式来不断迭代式的计算路径追踪。如下是通过这个变换之后得到的代码:
lmb_color
li_russian_roulette_iterative(lmb_bake_data& data, lmb_ray ray, int32_t depth, lmb_color throughput, bool lightmap) {
lmb_color accum = lmb_color(1.0f);
lmb_color result = lmb_color(0.0f);
float r = 1.0f;
bool first = true;
while (true) {
lmb_intersection intersection;
if (!(lightmap && first)) {
if (!intersect_scene(data, ray, intersection)) {
break;
}
}
float rr_prob = throughput.max_coefficient();
rr_prob = max(rr_prob, 0.99f); // avoid bounce forever
if (depth < 2) {
rr_prob = 1.0f; // direct and first indirect bounce, always track
}
// russian roulette terminate
if (rand_float() >= rr_prob) {
break;
}
lmb_color albedo = data.albedo;
lmb_color brdf = albedo * (1.0f / 3.1415926f);
lmb_vec3 hit_pos = ray.o + ray.d * intersection.t;
lmb_vec3 hit_normal = intersection.n;
if (lightmap && first) {
hit_pos = ray.o;
hit_normal = ray.d;
}
// direct lighting
lmb_color direct_light = li_direct(data, hit_pos, hit_normal);
accum = accum * brdf * r;
result = result + accum * direct_light;
// indirect lighting for next iteration parameters
lmb_color indirect_light(0.0f);
{
float pdf = 1.0f / (2.0f * 3.1415926f);
lmb_vec3 sample;
sample.x = rand_float();
sample.y = rand_float();
lmb_vec3 sample_dir = map_sample_to_direction(hit_normal, sample);
lmb_ray indirect_ray;
indirect_ray.o = hit_pos + sample_dir * 0.001f;
indirect_ray.d = sample_dir;
float ndotl = sqrtf(sample.x);
indirect_light = (ndotl / (pdf * rr_prob));
ray = indirect_ray;
r = ndotl / (pdf * rr_prob);
throughput = throughput * brdf * r;
}
depth++;
first = false;
}
result = result + accum * r * data.ambient;
return result;
}
这里需要注意,不管是因为路径追踪射向了背景,还是因为 russian rolette 提前中止,最后一次迭代的直接光照,我们都是使用 ambient 环境光来表示的,所以代码后面有最后一次的补充计算,千万不能忘记。
渐进式渲染 - Progressive Rendering
即使我们将计算搬到了 GPU 上,需要的计算量依然十分庞大。特别是当 sample count 特别大的时候,如果指望一帧内渲染完毕,那势必需要等待很久才能够完成。所以为了能够更加快速的看到渲染的结果,特别开发了渐进式渲染的方案(Progressive Rendering)。
这里渐进式的方式,主要是将一个像素的多个 sample ,分摊到到多帧里面进行计算。在我这里就是 1 帧计算一个 sample。也就是说,对于 1024x1024,sample count = 4096 的配置来说,需要分摊到 4096 帧去计算完成。
这里就存在一个问题,之前我们的计算方法是计算每一个 sample 采样出来的路径,并且将结果保存到一个列表中去。等到全部结果计算完毕,我们得到了一个长度为 4096 的列表,我们将整个列表中的所有结果相加,再除以 sample 数量求平均值,从而得到最终的颜色值。很显然,这种计算方法在 GPU 上不太友好。
解决这个问题也不是很复杂,我们可以保存从开始到现在累积的颜色值到另外一张图上,然后添加一个额外的 pass 从这张图中获取值,再除以当前所累计的 sample 数量,得到当前 sample 数量下的平均值。这个方法能够解决问题,但是需要额外的数据存储和操作。这里提供过一个更加简便的方法来解决。
Welford 方法
上面的问题在其他领域也经常遇到。比如计算一组采样的方差。这不仅需要当前采样的平均值,而且需要保存每个采样数据才行。但是现实情况下,往往很难确定需要采样多少次,也就是说我们需要一种流式的处理方式,不管有多少样本,我都能够简单的不用存储额外数据的方式计算得到,而这个方法就是 Welford 方法。
因为我这里只是为了计算平均数,所以它的形式非常简单,我们可以直接手动推导出来,主要核心操作是使用前一次计算出来的结果。
上图是前后两次计算平均数的一般性公式,我们可以将 $a_n$ 用 $a_{n-1}$ 来进行表示:
这样,新的 $a_n$ 就只依赖与之前一次计算的平均数和当前 sample 计算的颜色 $A_n$ 以及当前累计采样总数 $n$ 了。通过这个公式,我们就可以不需要额外存储的情况下,得到当前帧累计以来的平均值了,就可以渐进式的计算所有的 sample。
随机函数
在 CPU 中,我是简单的通过 c/c++ 库自带的 rand 函数来实现随机数获取的。但是 GPU 上,没有自带的随机函数,需要自行定义一个随机函数。这里我使用了 [11] 中给出的 GPU 随机函数,代码如下:
uint rng_state;
uint pcg_seed(uvec2 uv) {
return 19u * uv.x + 47u * uv.y + 101u;
}
uint rand_pcg() {
uint state = rng_state;
rng_state = rng_state * 747796405u + 2891336453u;
uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
return (word >> 22u) ^ word;
}
float rand_pcg_f() {
return float(rand_pcg()) * (1.0 / 4294967296.0);
}
这里我将 rng_state 设计为一个全局变量,也就是说,只要在一个 compute shader thread 里面不停的调用随机函数,就能够产生一序列分布均匀的随机数。同时为了支持渐进式渲染,当当前帧的计算结束之后,我们将当前的 rng_state 保存到贴图中去,从而可以在下一帧里面继续随机过程,让帧与帧之间也能够产生均匀的随机数序列。
这里有个很重要的点,每一个随机数都需要一个起始的种子,因为我的设计是每一个线程处理一个像素,所以我就将当前像素所在的位置作为起始随机数种子设置给 rng_state,这样可以保证像素与像素之间也能够是均匀分布的随机数。随机数设置操作如下所示:
void main() {
uvec2 cur_pixel_loc = uvec2(pushc.tile_pos + gl_GlobalInvocationID.xy);
if (cur_pixel_loc.x >= pushc.width || cur_pixel_loc.y >= pushc.height) return;
uint seed = imageLoad(seedmap, ivec2(cur_pixel_loc.xy)).x;
if (seed == 0) {
rng_state = pcg_seed(cur_pixel_loc.xy);
} else {
rng_state = seed;
}
......
imageStore(seedmap, ivec2(cur_pixel_loc.xy), uvec4(rng_state, 0u, 0u, 0u));
}
结果
其他就没有什么特别的地方了,无非就是将 bvh 数据打包上传到 GPU,指定一些渲染的配置参数(fov, light setup, albedo texture setup),然后一比一翻译 CPU 端的 PathTracer。
虽然已经通过 sample 分摊的方式实现了渐进式渲染,但是对于大尺寸的渲染图来说,一帧的计算量还是太多了,所以我额外增加了按 tile 进行分帧计算的方式,确保可以保持 60 fps 情况下进行渲染。
通过这样一系列操作就能够得到如下几张图,都是 1024x1024, sample count > 1024:
Lightmap 烘焙
Lightmap 是渲染中经常使用到的一个技术,随着时代的演进,lightmap 也具有很多其他的含义。我们今天就只考虑最古老的那种情况,即:lightmap 中以像素的形式保存了物体表面最终的 GI 颜色值。我们使用 PathTracer 计算物体表面的 GI 颜色,并且将结果保存在一张贴图中便于后续访问。
老样子,先在 CPU 端进行基本流程的实现,然后搬到 GPU 上去。
Lightmap UV
第一步,我们需要为场景中的物体产生一个 lightmap uv,我们将使用这个 uv 来对 lightmap 进行采样,从而得到 GI 光照颜色。由于 GI 光照信息在物体的表面上都不一样,一般并不能够直接使用物体的第一套 uv。因为第一套 uv 可能为了充分利用贴图,存在 uv 重叠的部分,而这在 lightmap uv 中是不允许的。所以,我们需要将场景物体进行全展开,得到一个合适的 uv 布局。我这里是通过 blender 创建了第二套 uv 作为 lightmap uv。使用 blender 创建 lightmap uv 的方式,网上有很多教程,这里不再赘述。如下就是得到的 lightmap uv 布局:
实际上,大部分的 lightmap baker 系统都是自己自行产生 lightmap uv,并且也不是像我这样只处理单个物体,需要考虑很多其他的情况(lightmap 拆分,lightmap uv 合并,缝隙修正等等)。我这里出于验证核心烘焙流程的目的,简化了这些操作,而且想要得到高质量的 lightmap,需要做很多额外的工作,比如文章 [12] 中介绍的各种产生瑕疵的情况就需要额外的操作进行修正。
Lightmap 像素世界位置计算
有了 uv 之后,接下来我们就需要计算 lightmap 中每一个像素的颜色。这里我们就是遍历每一个像素,判断像素在物体表面的位置,然后从这个位置开始进行 PathTracer,计算当前点的 irrandiance 信息,并将它保存在当前贴图像素上。
判断当前像素在物体表面哪个位置,我们需要先判断像素在物体表面哪个三角形中。由于 lightmap uv 是全展开的,像素在哪个三角形中是唯一的。因此,我们只要简单的依次遍历模型的所有三角形,判断当前像素是否在某个三角形中。而判断一个点是否在某个三角形中,我们可以通过 edge function 来判断实现,这个原理可以参考 [13] 中关于 edge function 的描述,如下是一个简单代码:
float edge_func(vec2 p, vec2 v0, vec2 v1) {
return (p.x - v0.x) * (v1.y - v0.y) - (p.y - v0.y) * (v1.x - v0.x);
}
bool is_inside_tri(vec2 uv, tri_ex_t tri) {
float e0 = edge_func(uv, vec2(tri.uv1_0.x, tri.uv1_0.y), vec2(tri.uv1_1.x, tri.uv1_1.y));
float e1 = edge_func(uv, vec2(tri.uv1_1.x, tri.uv1_1.y), vec2(tri.uv1_2.x, tri.uv1_2.y));
float e2 = edge_func(uv, vec2(tri.uv1_2.x, tri.uv1_2.y), vec2(tri.uv1_0.x, tri.uv1_0.y));
return ((e0 >= 0.0f) && (e1 >= 0.0f) && (e2 >= 0.0f)) || ((e0 <= 0.0f) && (e1 <= 0.0f) && (e2 <= 0.0f));
}
int find_tri(float x, float y) {
float cur_ux = x / pushc.width;
float cur_uy = y / pushc.height;
for (uint i = 0; i < pushc.tri_count; i++) {
tri_ex_t tri = tri_ex_pool[tri_index_pool[i]];
if (is_inside_tri(vec2(cur_ux, cur_uy), tri)) {
return int(tri_index_pool[i]);
}
}
return -1;
}
在知道了当前 lightmap 像素采样点所坐落的三角形之后,我们需要计算这个采样点对应的 position,normal 和 albedo uv 等等信息。在一个 2D UV 平面上,三角形三个点的 position,uv 和normal 信息都是已知的情况下,求其中某个点的信息,我们可以通过三角形重心坐标(barycentric coordinate)来计算,相关原理在我最早的 lightmap baker 文章中有讲述,参考文章 [1] 中的代码进行实现,最终我们能得到如下代码:
ray_t build_ray(uint tri_index, float x, float y, inout vec2 uv) {
tri_t tri = tri_pool[tri_index];
tri_ex_t tri_ex = tri_ex_pool[tri_index];
float cx = x / pushc.width;
float cy = y / pushc.height;
float x1 = tri_ex.uv1_0.x;
float y1 = tri_ex.uv1_0.y;
float x2 = tri_ex.uv1_1.x;
float y2 = tri_ex.uv1_1.y;
float x3 = tri_ex.uv1_2.x;
float y3 = tri_ex.uv1_2.y;
float lambda0 = ((y2 - y3) * (cx - x3) + (x3 - x2) * (cy - y3)) / ((y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3));
float lambda1 = ((y3 - y1) * (cx - x3) + (x1 - x3) * (cy - y3)) / ((y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3));
float lambda2 = 1.0f - lambda0 - lambda1;
// interpolote patch attributes
ray_t r;
r.o = tri.v0 * lambda0 + tri.v1 * lambda1 + tri.v2 * lambda2;
r.d = tri_ex.n0 * lambda0 + tri_ex.n1 * lambda1 + tri_ex.n2 * lambda2;
uv = tri_ex.uv0_0 * lambda0 + tri_ex.uv0_1 * lambda1 + tri_ex.uv0_2 * lambda2;
return r;
}
通过上面的操作,我们最终就得到了当前需要追踪像素的 position,normal 和 albedo uv 信息,有了这几个信息,我们就可以直接使用前面开发的 PathTracer 进行 GI 颜色的计算,结果计算完毕之后,就将颜色保存在对应的像素即可。
搬到 GPU
我这里也是简单粗暴的将实现直接搬到 compute shader 中,然后每一个 thread 计算 lightmap 中一个像素的一个采样,简单直接的就能够得到一个 GPU lightmap baker 了。文章首页的图就是使用当前系统烘焙出来的一张 lightmap。这里再给一张 2048x2048 samplecount = 1024,灯光为 平行光的 lightmap 效果:
总结
至此一个简单的 lightmap baker 功能就出来了。虽然它性能不好,功能不强,但是对于学习流程来说已经足够。后面有机会,我会继续完善这个系统,让它变的更加实用,高效。
如果发现文中有错误之处,欢迎指出,感谢阅读!
参考
[3] Global Illumination and Path Tracing
[6] 图形学数据基础之1D采样分布计算方法 - Inverse Method
[9] Rendering Lecture 04 - Path Tracing Basics
[11] Hash Functions for GPU Rendering
[12] Baking artifact-free lightmaps on the GPU
[13] Rasterization Practical Implementation - The Rasterization Stage