光栅化与光线追踪
- 感谢原作者让我复制作为技术学习的记录
- 原文章的链接:蒙特卡洛路径追踪1;蒙特卡洛路径追踪2
- 光线追踪与光栅化的区别在之前的文章中总结了技术方面的大概内容,还有很多的细节没有写明,比如光栅化只会显示出视野范围内的内容而裁减掉其他的东西,导致水面的倒影也会出现裁切等等。
蒙特卡洛方法
-
蒙特卡洛积分的目的: 当一个积分很难通过解析的方式得到答案的时候可以通过蒙特卡洛的方式近似得到积分结果:
- 欲求区间上一函数的积分,我们往 x 区间上面丢豆子,并且计算豆子命中的位置的 y 的值,最后把他们加起来作为积分的估计:
事实上在实际问题中,豆子的位置不会总是服从均匀分布。那么每一个豆子的贡献,除了豆子命中位置的 y 值,还取决于豆子 命中该位置的概率。蒙特卡洛方法允许我们使用 x 的任意的概率密度函数,来对积分进行估计。 - 假设采样 x 的概率密度函数为 P D F ( x ),被积函数为 f ( x ) ,那么 x 点采样的贡献就是:
- 首先按照产生一堆符合概率密度函数 PDF 分布的随机变量 x,然后对每一个 x 都进行计算 ,然后求出均值。PDF(x)= 1/b-a
伪代码
- 首先渲染方程如下:
舍弃自发光后:
其物理含义为着色点p到摄像机或人眼的Radiance值。 - 对于一个困难积分只要选定一个被积分变量的采样分布即可通过蒙特卡洛的方法得到积分结果的近似值,而此时的被积分值为wi选定了 ω i∼p(ω i),得出的积分近似结果如下:
- 单独考虑直接光照,因此只有当采样的方向ω i 击中光源的时候,光源才会对该着色点有贡献,计算伪代码如下:
- 单独仅仅考虑直接光照自然是不够的,还需要间接光照,当采样的wi方向撞到了别的物体。
此时采样的光线碰撞到了另一个物体的Q点,那么该条路径对着色点P的贡献是多少呢?是在点Q的直接光照再乘上反射到该方向上的百分比。显然这是一个类似光线追踪的递归过程,不同在于该方法通过对光线方向的采样从而找出一条条可行的路径,这也正是为什么叫路径追踪的原因,伪代码如下:
- 出现的问题:
通过每次对光线方向的采样从而解出方程,假设每次采样100条,那么从人眼出发的第一次采样就是100条,在进行第二次反射之后就是10000条,依次类推,反射越多次光线数量便会指数型爆炸增长,唯有每次只采样一个方向!N=1才能防止这种增长。 - 每次如果只采样一个方向那么所带来的问题也是显而易见的,积分计算的结果会非常的noisy,虽然蒙特卡洛积分是无偏估计,但样本越少显然偏差越大。
解决问题的方法那么重复多次寻找到多条路径,将多条路径的结果求得平均即可。
// 追踪一条光线
pathTracing(p)
{
L = 0.0
wi = random() // 随机选择一个方向
if(wi hit q) // 射线 wi 击中 q 点
{
cosine = dot(nq, wi) // q 点法向量 nq 和 wi 的夹角余弦值
L += cosine * pathTracing(q) / PDF(wi)
}
return L
}
// 对一个像素投射 SPP 条光线
L = 0.0
for(i in SPP)
{
wi = random() // 随机选择一个方向
if(wi hit q) // 射线 wi 击中 q 点
{
L += pathTracing(q) / PDF(wi)
}
}
L /= SPP
————————————————
版权声明:本文为CSDN博主「AkagiSenpai」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_44176696/article/details/113418991
图像结果输出
- 使用非常轻量级的 svpng。svpng 不是一个 c++ 的第三方库,它仅是一个 inc文件:
把它放在你的工程目录下,然后再 #include “svpng.inc” 即可调用它。svpng 就一个非常简单的功能,就可以帮我们保存 png 图像,调用 svpng 函数即可。
FILE 是文件指针,w 和 h 是图片的宽度和高度,img 是图像的像素值数组,alpha 是透明度,我们填 0 即可。通过如下的代码就可以将一个范围在 [0, 1] 之间的 double 浮点数 buffer 输出到图片上:
void svpng(FILE* fp, unsigned w, unsigned h, const unsigned char* img, int alpha)
// 输出 SRC 数组中的数据到图像
void imshow(double* SRC)
{
unsigned char* image = new unsigned char[WIDTH * HEIGHT * 3];// 图像buffer
unsigned char* p = image;
double* S = SRC; // 源数据
FILE* fp;
fopen_s(&fp, "image.png", "wb");
for (int i = 0; i < HEIGHT; i++)
{
for (int j = 0; j < WIDTH; j++)
{
*p++ = (unsigned char)clamp((*S++) * 255, 0.0, 255.0); // R 通道
*p++ = (unsigned char)clamp((*S++) * 255, 0.0, 255.0); // G 通道
*p++ = (unsigned char)clamp((*S++) * 255, 0.0, 255.0); // B 通道
}
}
svpng(fp, WIDTH, HEIGHT, image, 0);
}
投射光线
- 我们模拟相机投影与成像的规则,指定一个 [-1, 1] 范围内的投影平面和一个视点,然后根据输出图片的像素位置,计算其对应投影平面上的坐标,最后用坐标减去视点坐标,得到 视线的方向向量。
图片的 xy 轴原点是在图片左上方,而实际投影我们需要一个在左下方的原点(即平面几何坐标系),所以 y 要做一次 flip。此外,在世界坐标系下,我们确定相机的位置和投影平面的位置,让相机看向 z 轴负方向:
相机配置就绪,我们尝试 输出相机的射线投射方向,其中 imshow 是上面编写的显示图片的函数:
double* image = new double[WIDTH * HEIGHT * 3];
memset(image, 0.0, sizeof(double) * WIDTH * HEIGHT * 3);
double* p = image;//p作为图像指针
for (int i = 0; i < HEIGHT; i++)
{
for (int j = 0; j < WIDTH; j++)
{
// 像素坐标转投影平面坐标
double x = 2.0 * double(j) / double(WIDTH) - 1.0;
double y = 2.0 * double(HEIGHT - i) / double(HEIGHT) - 1.0;
vec3 coord = vec3(x, y, SCREEN_Z); // 计算投影平面坐标
vec3 direction = normalize(coord - EYE); // 计算光线投射方向
vec3 color = direction;
*p = color.x; p++; // R 通道
*p = color.y; p++; // G 通道
*p = color.z; p++; // B 通道
}
}
imshow(image);
三角形与光线求交方式
-
结构体定义:假设我们用起点(start)和方向(direction)来描述一个射线:
// 物体表面材质定义 typedef struct Material { bool isEmissive = false; // 是否发光 vec3 normal = vec3(0, 0, 0); // 法向量 vec3 color = vec3(0, 0, 0); // 颜色 }Material;
-
三角形类的定义之前应该有的信息:是否相交,交点位置,相交位置的属性。
-
表面属性的定义:是否发光,法向量,颜色。
// 物体表面材质定义 typedef struct Material { bool isEmissive = false; // 是否发光 vec3 normal = vec3(0, 0, 0); // 法向量 vec3 color = vec3(0, 0, 0); // 颜色 }Material;
-
光线求交结果定义:是否命中,与交点的距离,光线的名重点,命中点的表面材质。
// 光线求交结果 typedef struct HitResult { bool isHit = false; // 是否命中 double distance = 0.0f; // 与交点的距离 vec3 hitPoint = vec3(0, 0, 0); // 光线命中点 Material material; // 命中点的表面材质 }HitResult;
-
然后是三角形的 class 的定义:
class Shape { public: Shape(){} virtual HitResult intersect(Ray ray) { return HitResult(); } }; // 三角形 class Triangle : public Shape { public: Triangle(){} Triangle(vec3 P1, vec3 P2, vec3 P3, vec3 C) { p1 = P1, p2 = P2, p3 = P3; material.normal = normalize(cross(p2 - p1, p3 - p1)); material.color = C; } vec3 p1, p2, p3; // 三顶点 Material material; // 材质 // 与光线求交 HitResult intersect(Ray ray) { HitResult res; // ... return res; }; };
-
使用虚函数+指针+继承 的编程习惯,因为我们光线和任意图形求交,都有一致的返回结果,即 HitResult 结构体。我们使 c++ 的指针特性,可以通过一套代码,完成多种复杂图元的求交。此外,在添加一种新图元的时候,主代码不需要任何的改动。
求交计算
-
求交步骤:
1.判断光线和三角形所在平面是否有交点
2.然后判断交点是否在三角形内部
其中 t 表示了射线起点到交点的距离,如果 t 小于 0 那么表示三角形在摄像机之后! ,然后开始判断点是否在三角形中。我们连接顶点与 P 点,然后判断连线与边的叉乘方向是否与 法向量 一致。如果三个顶点的判断都通过,说明 P 在三角形中,否则不在:注意上图的N是由三角形边的叉乘得出的,上图的N垂直屏幕向外于是又Triangle类求交:
// 与光线求交 //考虑的因素:视线与三角形平行,法向量与视线相同则三角形是背向我们的等等。 HitResult intersect(Ray ray) { HitResult res; vec3 S = ray.startPoint; // 射线起点 vec3 d = ray.direction; // 射线方向 vec3 N = material.normal; // 法向量 if (dot(N, d) > 0.0f) N = -N; // 获取正确的法向量 // 如果视线和三角形平行 if (fabs(dot(N, d)) < 0.00001f) return res;//防止误差 // 距离 float t = (dot(N, p1) - dot(S, N)) / dot(d, N); if (t < 0.0005f) return res; // 如果三角形在相机背面 // 交点计算 vec3 P = S + d * t; // 判断交点是否在三角形中 vec3 c1 = cross(p2 - p1, P - p1); vec3 c2 = cross(p3 - p2, P - p2); vec3 c3 = cross(p1 - p3, P - p3); vec3 n = material.normal; // 需要 "原生法向量" 来判断 if (dot(c1, n) < 0 || dot(c2, n) < 0 || dot(c3, n) < 0) return res; // 装填返回结果 res.isHit = true; res.distance = t; res.hitPoint = P; res.material = material; res.material.normal = N; // 要返回正确的法向 return res; };
判断点是否在三角形中,我们要用 改写之前 的法向量来计算。因为原生法向量取决于顶点定义的顺序(p1, p2, p3),我们也是按照 p1, p2, p3 的顺序来进行叉乘的。
上面代码的那个 if (t < 0.0005f) 是 为啥是 0.0005 呢?是为了防止在三角形 T 上弹射的光线再次命中三角形 T (我打我自己 因为浮点精度不足,交点可能出现在原三角形的里侧,那么弹射时就会自己打到自己
光线追踪程序:
-
在场景下添加三角形
const vec3 RED(1, 0.5, 0.5); ... vector<Shape*> shapes; // 几何物体的集合 shapes.push_back(new Triangle(vec3(-0.5, -0.5, 0), vec3(0.0, 0.5, 0), vec3(0.5, -0.5, 0), RED));
-
之后遍历场景,逐个要求并且返回hit结果,编写函数返回最近距离交点以及其属性。
// 返回距离最近 hit 的结果 HitResult shoot(vector<Shape*>& shapes, Ray ray) { HitResult res, r; res.distance = 1145141919.810f; // inf // 遍历所有图形,求最近交点 for (auto& shape : shapes) { r = shape->intersect(ray); if (r.isHit && r.distance < res.distance) res = r; // 记录距离最近的求交结果 } return res; }
-
然后逐像素得投射光线并且输出光线第一个碰到交点的颜色:
double* image = new double[WIDTH * HEIGHT * 3]; memset(image, 0.0, sizeof(double) * WIDTH * HEIGHT * 3); double* p = image; for (int i = 0; i < HEIGHT; i++) { for (int j = 0; j < WIDTH; j++) { // 像素坐标转投影平面坐标 double x = 2.0 * double(j) / double(WIDTH) - 1.0; double y = 2.0 * double(HEIGHT - i) / double(HEIGHT) - 1.0; vec3 coord = vec3(x, y, SCREEN_Z); // 计算投影平面坐标 vec3 direction = normalize(coord - EYE); // 计算光线投射方向 // 生成光线 Ray ray; ray.startPoint = coord; ray.direction = direction; // 找交点并输出交点的颜色 HitResult res = shoot(shapes, ray); vec3 color = res.material.color; *p = color.x; p++; // R 通道 *p = color.y; p++; // G 通道 *p = color.z; p++; // B 通道 } } imshow(image);
球面随机向量
-
在渲染方程的求解中,我们在法向半球上随机选取一个方向作为光线的弹射方向。首先获取一个 [0-1] 范围的随机浮点数,所以我们需要做一个随机的浮点数:
// 0-1 随机数生成 std::uniform_real_distribution<> dis(0.0, 1.0); random_device rd; mt19937 gen(rd()); double randf() { return dis(gen); }
-
然后我们随机生成 3 个坐标 xyz,如果坐标 不在 单位球内,我们拒绝,并且重新选取 xyz,从而产生 均匀分布 的球面随机向量:
// 单位球内的随机向量
vec3 randomVec3()
{
vec3 d;
do
{
d = 2.0f * vec3(randf(), randf(), randf()) - vec3(1, 1, 1);
} while (dot(d, d) > 1.0);
return normalize(d);
}
- 还要根据碰撞点的表面,生成分布在法向半球的随机向量。一种可行的策略是使用仍然拒绝法,一旦随机向量不在法向半球内,我们就拒绝它,同时再产生一个新的随机向量,代码如下:
// 法向半球随机向量
vec3 randomDirection(vec3 n)
{
// 法向半球
vec3 d;
do
{
d = randomVec3();
} while (dot(d, n) < 0.0f);
return d;
}
-
《Ray Tracing in One Weekend Book Series》系列中,有一种更加简洁的求法向半球随机向量的方法,就是以法向量的终点为球心,产生单位球面上的随机向量,然后连接法向量起点和随机向量的终点就是最终的随机方向 d。
// 法向半球随机向量
vec3 randomDirection(vec3 n)
{
return normalize(randomVec3() + n);
}
开始路径追踪
-
我们之前直接输出了碰到的物体的颜色。接下来我们改变策略,对碰到的物体,我们要求其渲染方程下的颜色。我们定义一个函数,它接收整个场景的信息,和一条光线,然后根据路径追踪,返回该光线最终积累的颜色:
// 路径追踪 vec3 pathTracing(vector<Shape*>& shapes, Ray ray) { ... return xxx; }
-
直接光照:
首先看考虑直接光照。如果射线碰到光源,我们返回光源的颜色,否则我们返回纯黑:
// 路径追踪 vec3 pathTracing(vector<Shape*>& shapes, Ray ray) { HitResult res = shoot(shapes, ray); if (!res.isHit) return vec3(0); // 未命中 // 如果发光则返回颜色 if (res.material.isEmissive) return res.material.color; // 否则直接返回 return vec3(0); }
修改主函数。我们添加一些三角形:
// 采样次数 const int SAMPLE = 128; // 每次采样的亮度 const double BRIGHTNESS = (2.0f * 3.1415926f) * (1.0f / double(SAMPLE)); ... vector<Shape*> shapes; // 几何物体的集合 // 三角形 shapes.push_back(new Triangle(vec3(-0.5, -0.5, -0.5), vec3(0.5, -0.5, -0.5), vec3(0, -0.5, 0.5), CYAN)); // 底部平面 shapes.push_back(new Triangle(vec3(10, -1, 10), vec3(-10, -1, -10), vec3(-10, -1, 10), WHITE)); shapes.push_back(new Triangle(vec3(10, -1, 10), vec3(10, -1, -10), vec3(-10, -1, -10), WHITE)); // 光源 Triangle l1 = Triangle(vec3(0.6, 0.99, 0.4), vec3(-0.2, 0.99, -0.4), vec3(-0.2, 0.99, 0.4), WHITE); Triangle l2 = Triangle(vec3(0.6, 0.99, 0.4), vec3(0.6, 0.99, -0.4), vec3(-0.2, 0.99, -0.4), WHITE); l1.material.isEmissive = true; l2.material.isEmissive = true; shapes.push_back(&l1); shapes.push_back(&l2);
场景如下:上方的是光源三角形组成的四边形,中间的淡蓝色三角形是不发光的实体,而底部则是一个很大的平面。
double* image = new double[WIDTH * HEIGHT * 3]; memset(image, 0.0, sizeof(double) * WIDTH * HEIGHT * 3); omp_set_num_threads(50); // 线程个数 #pragma omp parallel for for (int k = 0; k < SAMPLE; k++) { double* p = image; for (int i = 0; i < HEIGHT; i++) { for (int j = 0; j < WIDTH; j++) { // 像素坐标转投影平面坐标 double x = 2.0 * double(j) / double(WIDTH) - 1.0; double y = 2.0 * double(HEIGHT - i) / double(HEIGHT) - 1.0; vec3 coord = vec3(x, y, SCREEN_Z); // 计算投影平面坐标 vec3 direction = normalize(coord - EYE); // 计算光线投射方向 // 生成光线 Ray ray; ray.startPoint = coord; ray.direction = direction; // 与场景的交点 HitResult res = shoot(shapes, ray); vec3 color = vec3(0, 0, 0); if (res.isHit) { // 命中光源直接返回光源颜色 if (res.material.isEmissive) { color = res.material.color; } // 命中实体则选择一个随机方向重新发射光线并且进行路径追踪 else { // 根据交点处法向量生成交点处反射的随机半球向量 Ray randomRay; randomRay.startPoint = res.hitPoint; randomRay.direction = randomDirection(res.material.normal); // 颜色积累 vec3 srcColor = res.material.color; vec3 ptColor = pathTracing(shapes, reflectRay); color = ptColor * srcColor; // 和原颜色混合 color *= BRIGHTNESS; } } *p += color.x; p++; // R 通道 *p += color.y; p++; // G 通道 *p += color.z; p++; // B 通道 } } } imshow(image);
可以看到在仅有直接光照的情况下,就能够实现非常多的特效,比如光照,软阴影,并且是基于物理的!而这些特效在传统光栅管线中都是代价及其昂贵的特效。此外,增大每个像素的采样次数(SPP,代码中的 SAMPLE 参数)能提升品质:
-
间接光照
直接光照仅考虑了渲染方程的自发光项。事实上除了来自光源的直接光照,还有来自其他物体反射的光:因为渲染方程已经给了间接光照的计算公式,我们直接递归计算。但是值得注意的是递归的出口。我们可以简单的使用一个递归深度 depth 来控制,更加巧妙的方法是每次摇一个随机数 P,如果 P 小于某个阈值就结束。这个方法有一个很霸气的名字,叫做俄罗斯轮盘机制。
但是我们仍然要通过深度保证不会出现死递归,此外每次返回时应该将颜色除以 P 以保证颜色的 期望值 始终不变。于是有:
// 路径追踪 vec3 pathTracing(vector<Shape*>& shapes, Ray ray, int depth) { if (depth > 8) return vec3(0); HitResult res = shoot(shapes, ray); if (!res.isHit) return vec3(0); // 未命中 // 如果发光则返回颜色 if (res.material.isEmissive) return res.material.color; // 有 P 的概率终止 double r = randf(); float P = 0.8; if (r > P) return vec3(0); // 否则继续 Ray randomRay; randomRay.startPoint = res.hitPoint; randomRay.direction = randomDirection(res.material.normal); float cosine = dot(-ray.direction, res.material.normal); vec3 srcColor = res.material.color; vec3 ptColor = pathTracing(shapes, randomRay, depth+1) * cosine; vec3 color = ptColor * srcColor; // 和原颜色混合 return color / P; }
注意和直接光照最大的区别就是背光面能够被间接地照亮,注意上图 左右两个三角形的背面分别反射了红蓝两种颜色。而仅有直接光照的渲染是无法照亮背光面的。
绘制球体
-
Sphere 类代码:
// 球 class Sphere : public Shape { public: Sphere(){} Sphere(vec3 o, double r, vec3 c) { O = o; R = r; material.color = c; } vec3 O; // 圆心 double R; // 半径 Material material; // 材质 // 与光线求交 HitResult intersect(Ray ray) { HitResult res; vec3 S = ray.startPoint; // 射线起点 vec3 d = ray.direction; // 射线方向 float OS = length(O - S); float SH = dot(O - S, d); float OH = sqrt(pow(OS, 2) - pow(SH, 2)); if (OH > R) return res; // OH大于半径则不相交 float PH = sqrt(pow(R, 2) - pow(OH, 2)); float t1 = length(SH) - PH; float t2 = length(SH) + PH; float t = (t1 < 0) ? (t2) : (t1); // 最近距离 vec3 P = S + t * d; // 交点 // 防止自己交自己 if (fabs(t1) < 0.0005f || fabs(t2) < 0.0005f) return res; // 装填返回结果 res.isHit = true; res.distance = t; res.hitPoint = P; res.material = material; res.material.normal = normalize(P - O); // 要返回正确的法向 return res; } };
这时候你就会发现为啥一开始我要不厌其烦地定义一个 Shape 类并且使用基类指针与虚函数。因为这允许你 添加任何的图形而不用改动主代码,比如我们加三个球:
镜面反射
-
光打到材质上,有一部分发生漫反射,有一部分发生镜面反射,这取决于材质的属性。我们在材质中新定义一个属性叫做反射率 specularRate 。
// 物体表面材质定义 typedef struct Material { ... double specularRate = 0.0f; // 反射光占比 }Material;
-
入射光有 s 的概率被反射,否则继续漫反射。那么我们的光追要用如下的流程:先摇一个随机数,如果其小于反射率,那么我们光线被反射,于是通过入射光和法向量的夹角计算反射光线,并且继续递归。否则我们正常地随机取一个方向投射光线。
于是修改路径追踪的函数,注意主代码中 ray casting 投射光线的时候也要做同样的修改:// 路径追踪 vec3 pathTracing(vector<Shape*>& shapes, Ray ray, int depth) { 前半部分和之前一样 ... Ray randomRay; randomRay.startPoint = res.hitPoint; randomRay.direction = randomDirection(res.material.normal); vec3 color = vec3(0); float cosine = fabs(dot(-ray.direction, res.material.normal)); // 根据反射率决定光线最终的方向 r = randf(); if (r < res.material.specularRate) // 镜面反射 { randomRay.direction = normalize(reflect(ray.direction, res.material.normal)); color = pathTracing(shapes, randomRay, depth + 1) * cosine; } else // 漫反射 { vec3 srcColor = res.material.color; vec3 ptColor = pathTracing(shapes, randomRay, depth+1) * cosine; color = ptColor * srcColor; // 和原颜色混合 } return color / P; }
修改反射率,下图从左到右分别是 0.3,0.6,0.9 的反射率:
-
如果想模拟粗糙的反射,那么在生成反射光线方向的时候,加入随机向量的扰动即可。而扰动的程度取决于材质的粗糙度,这也是一个材质属性,我们加入它:
// 物体表面材质定义 typedef struct Material { ... double roughness = 1.0f; // 粗糙程度 }Material;
然后我们反射的时候不再按照反射光线的方向,而是根据粗糙度,在随机向量和反射光线的方向做一个 线性插值 以决定最终反射的方向:
if (r < res.material.specularRate) // 镜面反射 { vec3 ref = normalize(reflect(ray.direction, res.material.normal)); randomRay.direction = mix(ref, randomRay.direction, res.material.roughness); color = pathTracing(shapes, randomRay, depth + 1) * cosine; }
折射
-
光线通过介质发生折射,折射角取决于入射方向和物体表面法线。和反射类似,我们直接计算折射角即可。折射也有发生的概率,我们在材质结构体中添加一些字段:
// 物体表面材质定义 typedef struct Material { ... double refractRate = 0.0f; // 折射光占比 double refractAngle = 1.0f; // 折射率 double refractRoughness = 0.0f; // 折射粗糙度 }Material;
值得注意的是我们的概率计算:当随机数小于 reflectRate 的时候发生反射,随机数在 reflectRate 和 refractRate 之间发生折射,随机数大于 refractRate 的时候才是漫反射:
if (r < res.material.specularRate) // 镜面反射 { ... } else if (res.material.specularRate <= r && r <= res.material.refractRate) // 折射 { vec3 ref = normalize(refract(ray.direction, res.material.normal, float(res.material.refractAngle))); randomRay.direction = mix(ref, -randomRay.direction, res.material.refractRoughness); color = pathTracing(shapes, randomRay, depth + 1) * cosine; } else // 漫反射 { ... }
注:这段代码严格意义上是错的, 因为没有考虑射入球和射出球的两种不同的情况上图使用 0.1 的折射角(refract 函数的 eta 参数) 因为我没有学过光学相关的课程, 我也不知道这个参数该怎么取,事实上我是随便取的
抗锯齿
-
在光线追踪渲染器中使用抗锯齿非常简单,我们可以在发射光线的时候,在光线的方向上加一个小的偏移量,以实现一个像素多个方向的采样,就好比光栅管线里面的 MSAA 一样:
// MSAA x += (randf() - 0.5f) / double(WIDTH); y += (randf() - 0.5f) / double(HEIGHT);