[Nori]作业4

Assignment 4

在本作业中,我们需要实现一个直接光照的积分器。在作业的第二部分,我们还要实现电介质的 BSDF 模型。

Part 1

首先需要实现,让任何模型都能成为光源

nori建议我们对模型所有面均匀采样

步骤:

  1. 在模型上均匀采样一个坐标点
  2. 对每个顶点上的法线插值算出坐标点的法线。当网格没提供每个顶点的法线时,改为计算法线方向
  3. 计算概率密度,因为均匀采样,所以是表面积分之1

DiscretePDF类可以高效储存和查询离散pdf(看了下代码,它其实就是个前缀和…记录了每个三角形的总概率(就类似连续型随机变量的分布函数),最后归一化保证总概率是1)

使用重心坐标插值法线,可以使用公式:

(αβ)=(11ξ1ξ21ξ1)\begin{pmatrix} \alpha\\ \beta \end{pmatrix} = \begin{pmatrix} 1 - \sqrt{1 - \xi_1}\\ \xi_2\, \sqrt{1 - \xi_1} \end{pmatrix}

其中,ξ1\xi_1ξ2\xi_2是均匀随机数,最后的重心坐标是(α,β,1αβ)(\alpha, \beta, 1-\alpha-\beta)

既然是均匀采样网格,那就把我们的实现放到mesh类里好了

因为采样需要返回坐标点,法线和pdf,我们定义一个struct来存这些数据

1
2
3
4
5
struct SampleMeshResult {
Point3f p;
Normal3f n;
float pdf;
};

Mesh类里定义两个成员变量和用来查询的成员方法

1
2
3
4
5
6
7
8
9
class Mesh {
public:
const DiscretePDF& getPdf() const { return m_disPdf; }
SampleMeshResult sampleSurfaceUniform(Sampler* sampler) const;
protected:
//其他代码不展示了...
float m_area; //表面积
DiscretePDF m_disPdf; //每个三角形的pdf
};

首先去activate函数里把每个三角形的pdf算出来。nori在解析场景xml的时候会调用一次这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Mesh::activate() {
if (!m_bsdf) {
/* If no material was assigned, instantiate a diffuse BRDF */
m_bsdf = static_cast<BSDF*>(
NoriObjectFactory::createInstance("diffuse", PropertyList()));
}
m_area = 0.0f;//总面积
m_disPdf.reserve(getTriangleCount());
for (uint32_t i = 0; i < getTriangleCount(); i++) {
auto area = surfaceArea(i);
m_area += area;
m_disPdf.append(area);
}
m_disPdf.normalize();//别忘了归一化,不然总概率就不是1了
}

mesh.cpp里实现均匀采样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
SampleMeshResult Mesh::sampleSurfaceUniform(Sampler* sampler) const {
SampleMeshResult result;
uint32_t idx = m_disPdf.sample(sampler->next1D());//均匀随机一个三角形
//重心坐标
Point2f rng = sampler->next2D();//算重心坐标
float alpha = 1 - sqrt(1 - rng.x());
float beta = rng.y() * sqrt(1 - rng.x());
Point3f v0 = m_V.col(m_F(0, idx));
Point3f v1 = m_V.col(m_F(1, idx));
Point3f v2 = m_V.col(m_F(2, idx));
Point3f p = alpha * v0 + beta * v1 + (1 - alpha - beta) * v2;//用重心坐标插值出坐标
result.p = p;
if (m_N.size() != 0) {//如果有存法线
Point3f n0 = m_N.col(m_F(0, idx));
Point3f n1 = m_N.col(m_F(1, idx));
Point3f n2 = m_N.col(m_F(2, idx));
result.n = (alpha * n0 + beta * n1 + (1 - alpha - beta) * n2).normalized();//用重心坐标插值出法线
} else {
Vector3f e1 = v1 - v0;
Vector3f e2 = v2 - v0;
result.n = e1.cross(e2).normalized();//叉乘算法线
}
result.pdf = m_disPdf.getNormalization();
return result;
}

接下来,我们需要去Emitter类里面实现采样、计算pdf、返回radiance的函数,这些完全由我们来定义。教程建议参考BSDF类定义了的

BSDF有个用于用于传递各种参数的结构体BSDFQueryRecord,我们模仿着写一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct EmitterQueryRecord {
Point3f ref;//表示着色点
Point3f p;//光源上的点
Normal3f n;//法线
Vector3f wi;//从着色点ref到光源p的方向
float pdf;//采样点p的pdf
Ray3f shadowRay;//检测遮挡的射线

EmitterQueryRecord(const Point3f& ref) : ref(ref) {}

EmitterQueryRecord(const Point3f& ref, const Point3f& p, const Normal3f& n) : ref(ref), p(p), n(n) {
wi = (p - ref).normalized();
}
};

Emitter(其实是个接口?)里照抄BSDF定义纯虚函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Emitter : public NoriObject {
public:
virtual ~Emitter() {}
virtual Color3f eval(const EmitterQueryRecord& record) const = 0;
virtual Color3f getRadiance() const = 0;
virtual float pdf(const Mesh* mesh, const EmitterQueryRecord& lRec) const = 0;
virtual Color3f sample(const Mesh* mesh, EmitterQueryRecord& lRec, Sampler*) const = 0;

/**
* \brief Return the type of object (i.e. Mesh/Emitter/etc.)
* provided by this instance
* */
EClassType getClassType() const { return EEmitter; }
};

然后来实现这个接口。在源码里创建一个area.cpp,CMakeLists.txt里添加一下。

光源对着色点的贡献和入射角有关,可以直接在这里算渲染方程积分里面Li项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <nori/emitter.h>
#include <nori/sampler.h>
NORI_NAMESPACE_BEGIN
class AreaLight : public Emitter {
private:
Color3f m_radiance;
public:
AreaLight(const PropertyList& propList) {
m_radiance = propList.getColor("radiance");
}
Color3f eval(const EmitterQueryRecord& record) const override {
const EmitterQueryRecord& lRec = record;
return (lRec.n.dot(lRec.wi) < 0.0f) ? m_radiance : 0.0f;//法线和wi要面朝不同方向,这样光源正面才朝着着色点
}
Color3f getRadiance() const override {
return m_radiance;
}
Color3f sample(const Mesh* mesh, EmitterQueryRecord& lRec, Sampler* sample) const override {
auto sRec = mesh->sampleSurfaceUniform(sample);//均匀采样一个光源
lRec.p = sRec.p;
lRec.n = sRec.n;
lRec.wi = (lRec.p - lRec.ref).normalized();
lRec.shadowRay = Ray3f(lRec.ref, lRec.wi, Epsilon, (lRec.p - lRec.ref).norm() - Epsilon);
lRec.pdf = pdf(mesh, lRec);
if (lRec.pdf > 0.0f && !std::isnan(lRec.pdf) && !std::isinf(lRec.pdf)) {
return eval(lRec) / lRec.pdf;//直接返回除以了pdf的值
}
return Color3f(0.0f);//返回0表示采样失败了
}
float pdf(const Mesh* mesh, const EmitterQueryRecord& lRec) const override {
float cosTheta = lRec.n.dot(-lRec.wi);
if (cosTheta > 0.0f) {
//原本的pdf是定义在光源面积上的,但是渲染方程里是定义在立体角上
//这里直接转换成立体角上的pdf了,之后有推导
return mesh->getPdf().getNormalization() * (lRec.p - lRec.ref).squaredNorm() / cosTheta;
}
return 0.0f;
}
std::string toString() const override {
return "Emitter[]";
}
};
NORI_REGISTER_CLASS(AreaLight, "area")
NORI_NAMESPACE_END

Part 2

接下来,我们就要把所有东西组装起来了!

在此之前,先回顾一下渲染方程:

Lr(x,ωr)=H2fr(x,ωi,ωr)Li(x,ωi)cosθidωi.\newcommand{\vx}{\mathbf{x}} \newcommand{\vc}{\mathbf{c}} \newcommand{\vy}{\mathbf{y}} \newcommand{\vn}{\mathbf{n}} L_r(\vx,\omega_r) = \int_{\mathcal{H}^2} f_r (\vx,\omega_i,\omega_r)\,L_i (\vx,\omega_i) \cos\theta_i\, \mathrm{d}\omega_i.

我们有BSDF类可以计算frf_r,有Emitter类可以计算LiL_i,似乎没有什么问题了。但是,这个任务中我们只计算直接光照,这就意味着,除了射线碰巧击中光源,其他地方返回都是0,这样做的效率极低,只有一部分射线会碰到光源,其他射线会产生大量噪点。所以我们需要让射线更容易击中光源。

更好的办法是,直接在光源上采样并检查可见性,而不是在物体表面采样。这意味着我们需要将渲染方程从半球上改写到光源上

\newcommand{\vr}{\mathbf{r}} L_r(\vx,\omega_r) = \int_{\mathcal{L}} f_r (\vx,\vx\to\vy,\omega_r)\,L_e (\vy,\vy\to\vx) \, \mathrm{d} \vy

其中xy\mathbf{x}\to\mathbf{y}表示从x到y的归一化方向,Le(x,ω)L_e(x,\omega)表示在xx点向ω\omega方向发射的radiance量

但是这个积分算式不正确,因为我们将积分变量从立体角改为了位置,应该再加一项几何项

G(\vx\leftrightarrow\vy) :=V(\vx\leftrightarrow\vy)\frac{ |\vn_\vx \cdot(\vx\to\vy)|\,\cdot\, |\vn_\vy \cdot(\vy\to\vx)|}{\|\vx-\vy\|^2}

第一项V(xy)V(x\leftrightarrow y)是可见性函数,0表示不可见,1表示可见。分子包含两个点乘的绝对值,相当于把光源面积投影到着色点单位圆上的那块面积,分母是我们在使用点光源渲染时已经观察到的平方反比衰减。最终的渲染方程是:

\newcommand{\vr}{\mathbf{r}} L_r(\vx,\omega_r) = \int_{\mathcal{L}} f_r (\vx,\vx\to\vy,\omega_r)\,G(\vx\leftrightarrow\vy)\,L_e (\vy,\vy\to\vx)\, \mathrm{d} \vy

注意,原渲染方程中的余弦项合并到了几何项里面,并没有消失

现在我们可以真的开始写分布光追了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <nori/integrator.h>
#include <nori/scene.h>
#include <nori/sampler.h>
#include <nori/emitter.h>
#include <nori/bsdf.h>
NORI_NAMESPACE_BEGIN
class WhittedIntegrator : public Integrator {
public:
WhittedIntegrator(const PropertyList& props) {}
Color3f Li(const Scene* scene, Sampler* sampler, const Ray3f& ray) const {
Intersection its;
Color3f color(0.0f);
if (!scene->rayIntersect(ray, its)) {
return color;//没碰到任何场景物体直接返回
}
Color3f Le(0.0f);
if (its.mesh->isEmitter()) {//光源
EmitterQueryRecord lRecE(ray.o, its.p, its.shFrame.n);
Le = its.mesh->getEmitter()->eval(lRecE);
}
BSDFQueryRecord bRec(its.shFrame.toLocal(-ray.d));//入射方向是世界坐标,bsdf计算时在切线空间下
Color3f f = its.mesh->getBSDF()->sample(bRec, sampler->next2D());//就是公式中的fr
Color3f Li = 0;
Ray3f rayR = Ray3f(its.p, its.shFrame.toWorld(bRec.wo), 0.0001f);
Intersection itsR;
if (scene->rayIntersect(rayR, itsR)) {//出射方向有没有碰到物体
if (itsR.mesh->isEmitter()) {
EmitterQueryRecord lRec = EmitterQueryRecord(its.p, itsR.p, itsR.shFrame.n);
Li = itsR.mesh->getEmitter()->eval(lRec);//出射方向正好是光源,也就是公式的Li
}
}
return Le + Li * f;
}
std::string toString() const {
return "WhittedIntegrator[]";
}
};
NORI_REGISTER_CLASS(WhittedIntegrator, "whitted");
NORI_NAMESPACE_END

编译运行!

真不错,这张的ssp是32,噪点还可以

这张的ssp甚至是4,对光源采样太棒了!

再跑一跑测试


通过!

Part 3

这部分我们需要实现一个电介质材质(就是绝缘体)

nori已经帮我们定义好了基于Dirac delta function(这是啥?)的完美镜面反射的mirror材质和菲涅尔项的计算函数

需要实现的电介质材质代码在src/dielectric.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Color3f sample(BSDFQueryRecord& bRec, const Point2f& sample) const {
bRec.measure = EDiscrete;
float cosThetaI = Frame::cosTheta(bRec.wi);
float kr = fresnel(cosThetaI, m_extIOR, m_intIOR);
if (sample.x() < kr) {
bRec.wo = Vector3f(-bRec.wi.x(), -bRec.wi.y(), bRec.wi.z());
bRec.eta = 1.f;
return Color3f(1.0f);
} else {
bRec.eta = cosThetaI >= 0 ? m_extIOR / m_intIOR : m_intIOR / m_extIOR;
Normal3f n = cosThetaI < 0 ? Normal3f(0.f, 0.f, -1.f) : Normal3f(0.f, 0.f, 1.f);
cosThetaI = abs(cosThetaI);
float cosThetaO = sqrt(1 - bRec.eta * bRec.eta * fmax(0.f, 1.f - cosThetaI * cosThetaI));
bRec.wo = bRec.eta * -bRec.wi + (bRec.eta * cosThetaI - cosThetaO) * n;
bRec.wo.normalize();
return Color3f(bRec.eta * bRec.eta);
}
}

说实话完全不知道要如何写…这段代码是CV来的…不过不影响我们使用(233

Part 4

现在我们要实现whitted-style光追

漫反射材质我们从光源上采样,但是镜面材质和电介质不行,它们的pdf永远是0,所以只能根据bsdf去采样出射方向。

因为反射和折射可以无限递归下去,直到打中漫反射材质,所以可以用以下方程来估计最终的radiance

L_i(\vc, \omega_c) = \begin{cases} \frac{1}{0.95}c L_i(\vx, \omega_r),&\text{if $\xi < 0.95$}\\ 0,&\text{otherwise} \end{cases}

所以这是一个递归算法

现在可以修改WhittedIntegrator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Color3f Li(const Scene* scene, Sampler* sampler, const Ray3f& ray) const {
Intersection its;
Color3f color(0.0f);
if (!scene->rayIntersect(ray, its)) {
return color;//没碰到任何场景物体直接返回
}
Color3f Le(0.0f);
if (its.mesh->isEmitter()) {//光源
EmitterQueryRecord lRecE(ray.o, its.p, its.shFrame.n);
Le = its.mesh->getEmitter()->eval(lRecE);
}
if (its.mesh->getBSDF()->isDiffuse()) {//这里判断如果是漫反射材质就直接采样
BSDFQueryRecord bRec(its.shFrame.toLocal(-ray.d));//入射方向是世界坐标,bsdf计算时在切线空间下
Color3f f = its.mesh->getBSDF()->sample(bRec, sampler->next2D());//就是公式中的fr
Color3f Li = 0;
Ray3f rayR = Ray3f(its.p, its.shFrame.toWorld(bRec.wo));
Intersection itsR;
if (scene->rayIntersect(rayR, itsR)) {//出射方向有没有碰到物体
if (itsR.mesh->isEmitter()) {
EmitterQueryRecord lRec = EmitterQueryRecord(its.p, itsR.p, itsR.shFrame.n);
Li = itsR.mesh->getEmitter()->eval(lRec);//出射方向正好是光源,也就是公式的Li
}
}
return Le + Li * f;
} else {//不是的话,递归
BSDFQueryRecord bRec(its.toLocal(-ray.d));
Color3f refColor = its.mesh->getBSDF()->sample(bRec, sampler->next2D());//采样一个出射方向
if (sampler->next1D() < 0.95 && refColor.x() > 0.f) {//递归终止条件
return Li(scene, sampler, Ray3f(its.p, its.toWorld(bRec.wo))) / 0.95 * refColor;
} else {
return Color3f(0.0f);
}
}
}



玻璃球和镜子真漂亮


(话说玻璃球上有部分白点是什么…)


[Nori]作业4
https://ksgfk.github.io/2021/06/28/Nori-作业4/
作者
ksgfk
发布于
2021年6月28日
许可协议