因为我也是菜鸡,所以不能确保所有都是正确的,希望大佬指正。
知乎的公式有点丑陋,可以去:GitHub
项目源代码:
https://github.com/Remyuu/GAMES202-Homeworkgithub.com/Remyuu/GAMES202-Homework
预计算球谐系数
利用框架nori预先计算球谐函数系数。
环境光照:计算cubemap每个像素的球谐系数
ProjEnv::PrecomputeCubemapSH(images, width, height, channel); 使用黎曼积分的方法计算环境光球谐函数的系数。
完整代码
// TODO: here you need to compute light sh of each face of cubemap of each pixel
// TODO: 此处你需要计算每个像素下cubemap某个面的球谐系数
Eigen::Vector3f dir = cubemapDirs[i * width * height + y * width + x];
int index = (y * width + x) * channel;
Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
images[i][index + 2]);
// 描述在球面坐标上当前的角度
double theta = acos(dir.z());
double phi = atan2(dir.y(), dir.x());
// 遍历球谐函数的各个基函数
for (int l = 0; l <= SHOrder; l++){
for (int m = -l; m <= l; m++){
float sh = sh::EvalSH(l, m, phi, theta);
float delta = CalcArea((float)x, (float)y, width, height);
SHCoeffiecents[l*(l+1)+m] += Le * sh * delta;
}
}
C#分析
球谐系数是球谐函数在一个球面上的投影,可以用于表示函数在球面上的分布。由于我们有RGB值有三个通道,因此我们会的球谐系数会存储为一个三维的向量。需要完善的部分:
/// prt.cpp - PrecomputeCubemapSH()
// TODO: here you need to compute light sh of each face of cubemap of each pixel
// TODO: 此处你需要计算每个像素下cubemap某个面的球谐系数
Eigen::Vector3f dir = cubemapDirs[i * width * height + y * width + x];
int index = (y * width + x) * channel;
Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
images[i][index + 2]);
C#首先从六个 cubemap ( images 数组)的每个像素采样方向(一个三维向量,表示从中心到像素的方向),将方向转化为球面坐标( theta 和 phi )。
接着将各个球面坐标传入 sh::EvalSH() 分别计算每个球谐函数(基函数)的实值 sh 。同时计算每个 cubemap 中每个像素所占据球面区域的比重 delta 。
最后累加球谐系数,代码中我们可以对 cubemap 伤的所有像素进行累加,近似在原始计算球谐函数积分的操作。
$$
Ylm=∫ϕ=02π∫θ=0πf(θ,ϕ)Ylm(θ,ϕ)sin(θ)dθdϕ
$$
其中:
- θ 是天顶角,范围从 0 到 π; ϕ 是方位角,范围从 0 到 2pi 。
- f(θ,ϕ) 是球面上某点的函数值。
- Ylm 是球谐函数,它由相应的勒让德多项式 Plm 和一些三角函数组成。
- l 是球谐函数的阶数; m 是球谐函数的序数,范围从 −l 到 l 。
为了更加具体的让读者理解,这里写出代码中球谐函数离散形式的估计,即黎曼积分的方法来计算。
$$
Ylm=∑i=1Nf(θi,ϕi)Ylm(θi,ϕi)Δωi
$$
其中:
- f(θi,ϕi) 是球面上某点的函数值。
- Ylm(θi,ϕi) 是球谐函数在该点的值。
- Δωi 是该点在球面上的微小区域或权重。
- N 是所有离散点的总数。
代码细节
- 从 cubemap 获取RGB光照信息
Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
images[i][index + 2]);
C#channel 的值是3,对应于RGB三个通道。因此,index 就指向了某一像素的红色通道的位置,index + 1 指向绿色通道的位置,index + 2 指向蓝色通道的位置。
- 将方向向量转换为球面坐标
double theta = acos(dir.z());
double phi = atan2(dir.y(), dir.x());
C#theta 是正z轴到 dir 方向的夹角,而 phi 表示从正x轴到 dir 在xz平面上的投影的夹角。
- 遍历球谐函数的各个基函数
for (int l = 0; l <= SHOrder; l++){
for (int m = -l; m <= l; m++){
float sh = sh::EvalSH(l, m, phi, theta);
float delta = CalcArea((float)x, (float)y, width, height);
SHCoeffiecents[l*(l+1)+m] += Le * sh * delta;
}
}
C#无阴影的漫反射项
scene->getIntegrator()->preprocess(scene); 计算 Diffuse Unshadowed 的情况。化简渲染方程,将上节的球谐函数代入进一步计算为BRDF的球谐投影的系数。其中关键的函数是ProjectFunction。我们要为这个函数编写一个lambda表达式,用于计算传输函数项 。
分析
对于漫反射传输项,可以分为三种情况考虑:有阴影的、无阴影的和相互反射的。
首先考虑最简单的没有阴影的情况。我们有渲染方程
其中,
- 是入射辐射度。
- 是几何函数,表面的微观特性和入射光的方向有关。
- 是入射光方向。
对于表面处处相等的漫反射表面,我们可以简化得到 Unshadowed 光照方程
其中:
- 是点 的漫反射出射辐射度。
- 是表面法线。
入射辐射度 和传输函数项 相互独立,因为前者代表场景中光源的贡献,后者表示表面如何响应入射的光线。因此将这两个部分独立处理。
具体到运用球谐函数近似是,我们分别对这两项展开。前者的输入是光的入射方向,后者输入的是反射(或者出射方向),并且展开是两个系列的数组,因此我们使用名为查找表(Look-Up Table,简称LUT)的数据结构。
auto shCoeff = sh::ProjectFunction(SHOrder, shFunc, m_SampleCount);
C#其中,最重要的是上面这个函数 ProjectFunction 。我们要为这个函数编写一个Lambda表达式(shFunc)作为传参,表达式用于计算传输函数项 。
ProjectFunction 函数传参:
- 球谐阶数
- 需要投影在基函数上的函数(我们需要编写的)
- 采样数
该函数会取Lambda函数返回的结果投影在基函数上得到系数,最后把各个样本系数累加并乘以权重,最后得出该顶点的最终系数。
完整代码
计算几何项,即传输函数项 。
// prt.cpp
...
double H = wi.normalized().dot(n.normalized()) / M_PI;
if (m_Type == Type::Unshadowed){
// TODO: here you need to calculate unshadowed transport term of a given direction
// TODO: 此处你需要计算给定方向下的unshadowed传输项球谐函数值
return (H > 0.0) ? H : 0.0;
}
C#总之最后的积分结果要记得除以 ,再传给 m_TransportSHCoeffs 。
有阴影的漫反射项
scene->getIntegrator()->preprocess(scene); 计算 Diffuse Shadowed 的情况。这一项多了一个可见项 。
分析
Visibility项()是一个非1即0的值,利用 bool rayIntersect(const Ray3f &ray) 函数,从顶点位置到采样方向反射一条射线,若击中物体,则认为被遮挡,有阴影,返回0;若射线未击中物体,则仍然返回 即可。
完整代码
// prt.cpp
...
double H = wi.normalized().dot(n.normalized()) / M_PI;
...
else{
// TODO: here you need to calculate shadowed transport term of a given direction
// TODO: 此处你需要计算给定方向下的shadowed传输项球谐函数值
if (H > 0.0 && !scene->rayIntersect(Ray3f(v, wi.normalized())))
return H;
return 0.0;
}
C#总之最后的积分结果要记得除以 ,再传给 m_TransportSHCoeffs 。
导出计算结果
nori框架会生成的两个预计算结果的文件。
添加运行参数:
./scenes/prt.xml
在 prt.xml 中,需要做以下修改,就可以选择渲染的环境光cubemap。另外,模型、相机参数等也可自行修改。
// prt.xml
<!-- Render the visible surface normals -->
<integrator type="prt">
<string name="type" value="unshadowed" />
<integer name="bounce" value="1" />
<integer name="PRTSampleCount" value="100" />
<!-- <string name="cubemap" value="cubemap/GraceCathedral" />-->
<!-- <string name="cubemap" value="cubemap/Indoor" />-->
<!-- <string name="cubemap" value="cubemap/Skybox" />-->
<string name="cubemap" value="cubemap/CornellBox" />
</integrator>
C#其中,标签可选值:
- type:unshadowed、shadowed、 interreflection
- bounce:interreflection类型下的光线弹射次数(目前尚未实现)
- PRTSampleCount:传输项每个顶点的采样数
- cubemap:cubemap/GraceCathedral、cubemap/Indoor、cubemap/Skybox、cubemap/CornellBox
上图分别是GraceCathedral、Indoor、Skybox和CornellBox的 unshadowed 渲染结果,采样数是1。
使用球谐系数着色
将nori生成的文件手动拖到实时渲染框架中,并且对实时框架做一些改动。
上一章计算完成后,将对应cubemap路径中的 light.txt 和 transport.txt 拷贝到实时渲染框架的cubemap文件夹中。
预计算数据解析
取消 engine.js 中88-114行的注释,这一段代码用于解析刚才添加进来的txt文件。
// engine.js
// file parsing
... // 把这块代码取消注释
C#导入模型/创建并使用PRT材质Shader
在materials文件夹下建立文件 PRTMaterial.js 。
//PRTMaterial.js
class PRTMaterial extends Material {
constructor(vertexShader, fragmentShader) {
super({
'uPrecomputeL[0]': { type: 'precomputeL', value: null},
'uPrecomputeL[1]': { type: 'precomputeL', value: null},
'uPrecomputeL[2]': { type: 'precomputeL', value: null},
},
['aPrecomputeLT'],
vertexShader, fragmentShader, null);
}
}
async function buildPRTMaterial(vertexPath, fragmentPath) {
let vertexShader = await getShaderString(vertexPath);
let fragmentShader = await getShaderString(fragmentPath);
return new PRTMaterial(vertexShader, fragmentShader);
}
C#然后在 index.html 里引入。