GAMES202-作业2: Precomputed Radiance Transfer
作业总览
- 预计算球谐系数
- 预计算环境光投影到球谐函数上的对应的系数
- 预计算diffuse unshadowed情况的漫反射传输项球谐系数
- 预计算diffuse shadowed情况的漫反射传输项球谐系数
- 预计算diffuse inter-reflection情况的漫反射传输项球谐系数(Bonus 1)
- 实时球谐光照计算
- 环境光球谐旋转(Bonus 2)
源码
关于对球谐函数与PRT的理解
球谐函数
可能很多人跟我一样第一次看到这张图时都觉得它抽象到离谱,不过问题不大,作为工程师,我们应该先着重解决工程部分问题,把关于基函数怎么来、为什么等问题先抛到一边,我们只需要知道球谐函数有哪些性质,如何使用球谐函数等即可。
如果你认为自己对球谐函数的了解还是非常欠缺,那么这篇通俗而又优秀的文章可以帮到你:
202课程本身也没讨论球谐函数的基函数是怎么来的,如果你想深究其数学本质,那么可以看以下这篇文章:
PRT
当对球谐函数有基本的了解后,就可以看如何把它应用到我们的PRT实现上了,这部分如果有不清楚的地方,建议重复观看课程第六集1:10:00-1:25:18
部分,作业要做的基础部分的实现,都在这了,实际上我们要做的事情并不复杂。
特别补充一下我在第一次观看课程时产生过的疑问,也是弹幕里很多人提到疑问,就是如果预计算是考虑上Visibility项的话,那岂不是不能移动摄像机?毕竟移动摄像机遮挡关系会发生变化
,出现这种疑问主要是因为误以为Visibility项的值是取决于Shading Point与摄像机方向的遮挡关系,其实不是,这里Visibility项的值是取决于Shading Point与环境光的遮挡关系,对于静态场景来说,某个Shading Point对某个方向的环境光是否被其他物体遮挡,完全取决于场景本身,跟摄像机在哪,朝向哪,甚至存不存在摄像机都没关系。
另外再补充一个非常不错的课外资料:
Spherical Harmonic Lighting - the gritty details.pdf
作业流程
本作业框架分为两部分,一部分是nori部分,需要用cmake构建出相应工程,另一部分是与作业1一样的webgl+js实时渲染框架。我们需要在nori工程下完成预计算部分实现,并把预计算生成的light.txt
(光照球谐系数数据)和transport.txt
(传输线球谐系数数据)两个文件拷贝到实时渲染框架对应目录下,然后在实时渲染框架下完成利用预计算数据完成球谐光照渲染,并实现环境光旋转。
nori框架在Windows下编译问题
在win10+vs2022环境下,cmake后,在vs编译出现了<lambda_9ed74708f63acbd4deb1a7dc36ea3ac3>::operator()
的报错。
这个问题我在课程BBS讨论区找到了解决方案:
MSVC 对于代码中的中文字符支持有问题(应该是会吞换行符),需要启用 utf-8 编译选项:
在 prt/CMakeLists.txt 112 行添加:
target_compile_options(nori PUBLIC /utf-8) # MSVC unicode support
关于作业2开头编译的困惑 - 来自用户YunHsiao的回答
假如你编译时遇到无法打开输入文件:nanogui.lib
的报错,可以尝试删除构建工程,重新执行camke构建后再试。
实现
环境光的球谐系数
1 | // prt.cpp |
PrecomputeCubemapSH
整个函数就是对以上公式的实现,外层有三层i、x、y的循环,其中i对应的cubemap六个面,x和y对应cubemap的像素,并以cubemap的像素对应的立体角作为微分单位,框架给出的Le变量就是公式中的,然后我们可以借助框架内的函数double EvalSH(int l, int m, const Eigen::Vector3d& dir)
求出某个基函数在某个方向上的值,函数要求传入第l阶,编号m来指定取哪个基函数,注意球谐函数阶数从0开始,在第l阶有2l+1个的基函数,对于l阶来说,基函数编号m范围为[-l , l],最后还需要一个方向参数,注意方向需要归一化。我们遍历所有基函数求出光照函数在基函数上的投影,并把结果累加起来即可。由于我们用于储存结果系数的结构是个一维数组,而前面我们是通过l、m两个维度来索引一个基函数,我们可以通过 int GetIndex(int l, int m)
来转换一下某个基函数在一维结构下的索引。
另外要注意计算要算上cubemap上这个像素对应的立体角的权重,也就是乘上函数CalcArea
返回的结果delta_w。关于CalcArea
的推导,可通过这个链接CUBEMAP TEXEL SOLID ANGLE了解。
Diffuse Unshadowed和Diffuse Shadowed传输项球谐系数
1 | // prt.cpp |
把顶点位置作为我们的Shading Point,计算每个顶点位置的传输项球谐系数。
理解ProjectFunction
函数是我们完成这段代码的关键,通过这个函数,只需要指定球谐阶数、需要投影在基函数上的函数、采样数,该函数内部会根据采样数来选取采样方向,把方向作为参数传递到我们需要实现的Lambda函数中,然后取Lambda函数返回的结果投影在基函数上得到系数,最后把各个样本系数累加并乘以权重,最后得出该顶点的最终系数。
那么我们要做的就是在Lambda函数中根据参数,计算出以下两个式子即可
Diffuse Unshadowed
是光照辐射度项,利用球谐函数的性质,我们把积分内的光照辐射度项和传输函数项分离了,并把光照辐射度项提出积分外,在这里,积分是在ProjectFunction
函数内完成,对于不考虑阴影的漫反射情况,我们只需要计算,并作为Lambda函数的返回值即可。
Diffuse Shadowed
对于考虑自阴影的漫反射传输,我们加上Visibility项即可,Visibility项在这里是一个非1即0的值,所以我们利用框架提供的bool rayIntersect(const Ray3f &ray)
函数,从顶点位置到采样方向反射一条射线,若击中物体,则认为被遮挡,有阴影,返回0,若射线未击中物体,则仍然返回即可。
最后会把ProjectFunction
函数的返回值,写入到m_TransportSHCoeffs
中,返回值就是积分后的值,注意我们公式中还有没乘上,我们可以在这里乘上,这里的其实就相当于,这里我们直接取1,也就是最终我们需要把积分结果除以再写入到m_TransportSHCoeffs
中。
导出数据
预计算实现完成后,就可以编译启动程序了,但要令程序生成我们需要的预计算数据文件,需要运行程序时传参,传入的参数要求是配置文件prt.xml
的路径,可以通过命令行传参,也可以把参数填入VS的命令参数配置项,这样在VS内启动可以自动传参。
1 | // prt.xml |
在配置文件prt.xml
中,我们只需要关注上面这些参数,其中type
的value
可以配置为unshadowed、shadowed、 interreflection,bounce
为interreflection类型下的光线弹射次数,目前我们还没实现interreflection,PRTSampleCount
为传输项每个顶点的采样数,cubemap
为环境光的所用的cubemap路径,一共有CornellBox、GraceCathedral、Indoor、Skybox可选,我们可以用shadowed模式,依次填上4个cubemap的路径并依次生成后把生成的light.txt
和transport.txt
拷贝到实时渲染框架的对应cubemap目录中。
实时球谐光照计算
先按作业提示把engine.js
文件中把88-114行取消注释,这部分代码为我们完成预计算数据解析工作,并储存到全局变量中。
如果已经做过作业1,相信你已经对这个框架有一定了解了,本文不过多讨论细节。
1 | //PRTMaterial.js |
在materials文件夹下新增PRTMaterial.js
文件,代码如上。
1 | //index.html |
在index.html
补上对新文件的引入。
1 | //loadOBJ.js |
在loadOBJ.js
中支持新的PRT材质加载。
1 | //engine.js |
给场景添加mary模型,并使用新增的PRTMaterial材质。
1 | //WebGLRenderer.js |
在渲染循环中给材质设置precomputeL实时的值。
1 | //prtVertex.glsl |
新增prtVertex.glsl
文件,vs核心就是对上面公式的实现,上面公式其实就相当于点积,由于我们环境光系数有rgb三个通道,所以这个点积写起来略显麻烦一点,最后把结果vColor传递到fragment shader即可。
注意vNormal的赋值无实际作用,如果不使用顶点属性aNormalPosition的话,WebGL会执行优化掉这个属性,导致网页端会一直刷一个报错:
1 | WebGL: INVALID_VALUE: vertexAttribPointer: index out of range |
vNormal的赋值代码实际上就是使用上aNormalPosition,避免因优化后产生的报错。
1 | //prtFragment.glsl |
新增prtFragment.glsl
文件,fs很简单,把插值后的颜色输出为该片元颜色即可。
下面我们来看看效果:
如果你没有把传输项球谐系数除以π的话,你应该会得到以上结果,很多同学、甚至包括作业pdf中的图2和图3都是上图效果,这应该是一个过亮的错误效果,特别是图3,skybox
这个cubemap环境下,显示出了与环境非常不协调的过曝效果(这里其实有个坑,skybox
在预计算中使用的cubemap资源和实时渲染中显示出来的cubemap资源是两套不同画面的资源,所以差异会更大)。
这是一个错误的例子,如果你是根据本文来实现,在传输项球谐系数计算时,最终有把结果除以π的话,是不会得到上图效果的,而是会得到以下效果:
过亮问题没有了,但似乎又太暗了点?作为参考,我们可以找到prt/scenes
目录下生成的prt.png
图片来对比,实际上prt.png
要比我们渲染出来的效果是要亮一点的,那问题出在哪呢?
1 | //bitmap.cpp |
其实在nori程序中,保存图片时调用的savePNG
这个API,内部有个把颜色toSRGB
的处理,我们再看看toSRGB
的实现。
1 | //common.cpp |
他似乎是做了Gamma校正+ToneMapping,有同学可能有疑问作业不是提到不需要做Gamma校正吗?作业意思应该是预计算部分的cubemap采样出来的结果就是在线性空间中的,我们不需要进行校正后再做光线计算,但当我们最后把颜色输出到sRGB颜色空间的显示器上时,我们仍然需要进行Gamma校正。
所以我们把以上toSRGB
的实现移植并引入到fragment shader即可。
1 | //prtFragment.glsl |
最后我们可以得到以下的画面效果:
可见与nori框架下生成的png图片效果一致,到此作业2的基础部分已完成。
实时渲染框架支持CornellBox的Cubemap
1 | //engine.js |
1 | //engine.js |
虽然实时渲染框架内放置了CornellBox的资源,但默认情况下并不能切换到CornellBox,需要做出以上修改,CornellBox很适合用来观察我们的实现结果,其他cubemap由于没有明显的颜色方向区分,可能得出错误的结果而又没能直接观察出来,特别是对于提高部分要做的环境光旋转。
修改上面两行代码后,我们便可以切换到CornellBox了:
Diffuse Inter-reflection传输项球谐系数
1 | //prt.cpp |
计算传输项光线多次弹射的情况,其实有点类似于做光线追踪的思路,把我们原本计算球谐系数的方法,套进光线追踪的流程即可,大概步骤如下:
- 编写一个递归函数,该函数指定一个Shading Point,计算这个Shading Point的所有间接光的球谐系数。
- 设定bounce次数,并以bounce次数作为递归的结束条件,超出设定bounce次数的那次弹射,认为系数是0。
- 仿照
ProjectFunction
函数来做方向采样,对每个Shading Point采样m_SampleCount个方向。 - 取处于Shading Point上半球方向的样本,从Shading Point向采样方向发出射线,若击中物体,则认为击中点对射线出发点有贡献,利用三角形重心坐标求出击中点的球谐系数,并以击中点作为出发点Shading Point,进行递归求击中点的间接光球谐系数,最后把原出发点的球谐系数加上击中点的间接光球谐系数,作为出发点的最终球谐系数。
- 把结果除以采样数。
以上,对于计算某个点的间接球谐系数的函数已经完成。
1 | //prt.cpp |
最后,遍历每个顶点作为计算间接光的出发点,调用我们上面实现的函数,取得间接球谐系数,并把系数加到m_TransportSHCoeffs
里即可。
编译后,再把各个cubemap数据导出一次,拷贝到实时渲染框架中观察下效果。
上面是Shadowed和interreflection bounce两次情况下的对比,可以看到整体上没有太大差异,但阴影处可以明显看出interreflection情况下原本阴影的地方会更明亮一些。
环境光球谐旋转
1 | //WebGLRenderer.js |
先把天空盒旋转那行代码注释取消掉,然后调用getRotationPrecomputeL
,把返回的环境光球谐函数传入Shader。然后我们再来看getRotationPrecomputeL
相关该如何实现。
1 | //tools.js |
由于本文不对球谐旋转性质进行证明或相关计算推导,我们只需要按照作业提供的计算公式用代码表达出来即可,以上代码基本上是对作业提供的计算方法直接翻译到代码中,computeSquareMatrix_3by3
是求第一阶的3x3旋转矩阵,而computeSquareMatrix_5by5
是求第二阶的5x5旋转矩阵,最后在getRotationPrecomputeL
调用这两个函数并计算出整体旋转后的系数。
如果你想了解相关性质的证明,或许这篇文章能帮到你,记得看明白后回头教一下小编。
但框架代码与作业描述的计算方式似乎都存在一点问题,我在GAMES202课程BBS里找到了相关讨论【作业2】环境光球谐旋转推导,题主提出三个问题:
- 球谐计算旋转所用的旋转矩阵不是天空盒的旋转矩阵,而是天空盒的旋转矩阵的逆矩阵。
- 由于框架使用到了
glMatrix
和math.js
两个数学库,前者矩阵是列优先,后者是行优先,而框架提供的mat4Matrix2mathMatrix
函数可以对两个库之间的矩阵类进行转换,但似乎并没有考虑这点,所以我们把这个函数的原返回矩阵做一次转置再返回,这样才能得到正确的转换。 - 作业中的球谐旋转的步骤3应该是
原SH系数 * M
,而不是M * 原SH系数
。
如果你同时忽略了问题1和问题2,那么可能会因为负负得正的作用抵消了这两个问题,因为旋转矩阵是正交矩阵,他的逆矩阵正是他自身的转置。为了数学意义上更准确,上面代码实现中纠正了这两点,把两次矩阵转置写了出来。
把上面3个问题做了调整后,我们将得到作业的最终效果: