杂谈
之前读取MMD动作数据的文章,得到了参考博客大佬的指引,并推荐了光线追踪、实时渲染后续的书籍和网站,不过个人集中敲了Opengl的入门代码后有点怠惰,还有美赛建模的打扰心情(龙妈的龙到底需要多大的地盘来养???),就暂时放下了图形学的学习,现在开学才捡起来。
本人非英语英语不是很好,好在“一周末学习光线追踪”词汇还好,配合谷歌翻译,以及国内一些翻译、解读博客,还是啃下来了。我这基本快丢下的高中数学和基本没学过的光学也能啃下来真是万幸……看完的时间是周一到周五,平时有课。
国内的解读博客我感觉还是不错的,之所以要写笔记,是想记下来读书时推公式还原代码的心情。
引用
书上google直接搜可以找到PDF
知乎上的翻译版本【翻译】两天学会光线追踪(一)
是用C#和unity写的,代码能看懂,解释的相对较少,不过他部分段落引用了知乎的叶大一些理论,能解决一部分疑惑国内的解读博客# 【Ray Tracing in One Weekend 超详解】 光线追踪1-1
真的是超详解,之前各种搜理论,找到这篇文章后节省了很多搜资料时间反射和折射计算方法的向量推导:光的反射与折射的向量计算
上面那个博客也有,不过那个是拍在之上的照片,这个是文本,各个向量还用字体颜色标注出来了,没找到那个博客前看不懂代码,这个博客就是我的救命稻草叶大的文章用 C 语言画光(六):菲涅耳方程
上面知乎文章引用了很多他的文章,对反射和折射不清晰,看了后了解了很多
第一部分:输出图片

就是先介绍一下PPM文件,文件储存方式好像有好几个,不过文章里只用了P3,想了解的可以去google查。
文件格式就是开头一个P3,第二行输出行和列数,第三行输出最大的数字,之后就是以空白字符间隔的数字(建议三个一换行),每三个就是一个像素的RGB值,像素从左上角开始向右打印。
作者建议下一个PPM图片显示软件PPMViewer,不嫌慢的话,其实PS就可以打开

文章里几乎都是把所有文本输出到控制台,然后粘贴到后缀改为ppm的文本里,拿PPMViewer看效果,不过打印到控制台需要很长时间,这个过程很麻烦,只要稍加改动例如cout改成ofstream对象,直接写到文本里会快很多

不过作者也提到了github上一个图片读写库stb,之前opengl读取材质也是用的这个库,这个库的优点是,依赖少,只需要引入单个文件,不用配置就能用,缺点是整个工程只能有一个文件引入,引入第二个会报错,progma once或ifndef都不好使,不过只要封装一下就没问题了
【可能用opencv的图片处理更加专业,不过我opencv是用python学的,不想增加额外的学习开销】
在上述网站分别下载stb_image.h和stb_image_write.h,放到工程目录下,然后用面向对象进行简单封装:
//PNG_IMAGE.h
#pragma once
class PNG_IMAGE {
private:
unsigned char* data;
int data_w, data_h, data_n;
bool foreign;
public:
PNG_IMAGE(unsigned char* data, int w, int h, int n);
PNG_IMAGE(const char* filename);
PNG_IMAGE(PNG_IMAGE& other) = delete;
PNG_IMAGE& operator=(PNG_IMAGE& other) = delete;
unsigned char& getNum(int row, int col, int rgba);
int weight() { return data_w; }
int height() { return data_h; }
int channel() { return data_n; }
void write(const char* filename);
~PNG_IMAGE();
};
\\PNG_IMAGE.cpp
#include "PNG_IMAGE.h"
#include <exception>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
PNG_IMAGE::PNG_IMAGE(unsigned char* data, int w, int h, int n) {
this->data = data;
data_w = w;
data_h = h;
data_n = n;
foreign = false;
}
PNG_IMAGE::PNG_IMAGE(const char* filename) {
data = stbi_load("out.png", &data_w, &data_h, &data_n, 0);
foreign = true;
}
unsigned char& PNG_IMAGE::getNum(int row, int col, int rgba)
{
if (data_w == 0 || data_h == 0 || data_n == 0) {
throw std::exception("图片未读入");
}
return *(data + (row*data_w + col)*data_n + rgba);
}
void PNG_IMAGE::write(const char* filename) {
stbi_write_png(filename, data_w, data_h, data_n, data, data_w * 4);
}
PNG_IMAGE::~PNG_IMAGE() {
if (foreign) {
stbi_image_free(data);
}
else {
delete[] data;
}
}
分成.h和.cpp是必须的,因为include文件时,会直接把.h文件复制进去,相当于又把stb文件引用了几次
类由两种构造方式,传入字符串就读取文件内图片,否则传入数据,长、宽、通道数量构造新图片。
getNum传入行列通道(以0开始),返回unsigned char的左值,既可得到值,也可以更改(其实起名叫item更贴切,不过不影响功能)
write将图片以png格式写入到filename文件
析构函数检测,如果是读入的图片,则用stb自带的free释放,如果是传入构造的图片,则delete(所以传入的图片不用再delete了)
因为拷贝构造函数会拷贝指针,导致析构时析构两次,所以就禁止拷贝和赋值了(也可以再写一些,构造一个新图片)
因此代码可以更改一下:
#include "PNG_IMAGE.h"
int main(){
unsigned char* data = new unsigned char[200 * 100 * 4];
PNG_IMAGE png(data, 200, 100, 4);
for (int row = 0; row < png.height(); row++) {
for (int col = 0; col < png.weight(); col++) {
png.getNum(row, col, 0) = unsigned char(255.99 * float(col)/float(png.weight()));
png.getNum(row, col, 1) = unsigned char(255.99 * float(row)/float(png.height()));
png.getNum(row, col, 2) = 0.2;
png.getNum(row, col, 3) = 255;
}
}
png.write("test.png");
return 0;
}
就可以在项目文件夹下看到新写好的test.png图片了
第二部分:三维向量类
图形学经常用向量,例如opengl的glm库,库的写法确实精妙,好像用了宏编程,只是好像向量乘浮点数都需要拆开向量重新构造,挺烦的,文章的作者重新写了一个三维向量类,就是体力活,实现向量加减,点积、交叉积,单位化,和浮点数的乘除法等等,按照原文抄就行了,我个人喜好单位化向量设为成员函数,作者喜欢设为工具函数,因此后面我放出的代码单位化都会用.unit_vertior()的形式而不是作者unit_vector(vec3)的形式,这个看个人爱好了;后面作者又加了些向量的工具函数,例如已知入射向量和法向量求反射向量、或是求折射向量等等,跟着文章写就行。

这一部分最后上的代码,意思就是用三维向量表示图片。
第三部分:射线、简单的相机、背景

射线类,A是原点,B是方向,point_at_parameter函数就是当射线长度为t时,射线会射到哪里,如下图:


黄色坐标为世界坐标,坐标系是右手坐标系(和Opengl一样,示意方法为:右手握紧拳头,掌心冲自己,伸出拇指是X,伸出食指是Y,伸出中指是Z),上图想象为Z正半轴对着自己,原点(0, 0, 0)是我们的眼睛,蓝黑线组成的矩形是电脑屏幕。
屏幕本身也有坐标,电脑屏幕的处理一般是左上角为原点的二维坐标,但这里我们以左下角为原点,u为横坐标,v为纵坐标。

从main函数内看起,多了4个向量,第一个可以看做是屏幕的左下角,第二个是横向长度,第三个是纵向长度,第四个是相机位置。
展开循环,刚进入循环的趋势,u逐渐从0变为1,v逐渐从1变为0,射线r,起点为我们的眼睛(相机),方向和终点一致(因为起点是原点)。
u和v变量相当于是uv坐标的百分比,乘上长度和宽度是uv坐标,再加上平面原点(左下角),就是最终目标。因此相当于从原点向屏幕发射射线,从左上角开始向右扫描,逐渐向下,将射线交给color函数处理,得到目标点应该有的颜色,既是屏幕i行j列像素的颜色。
color函数的作用相当于着色器,根据当前射线得到目标位置应该显示的颜色并返回,是以后程序的核心。

就是个天空盒是吧?
第四部分:加个球
从这里开始就需要一些数学公式推导了,不过还不难,初高中数学的初学级别
球心在原点的球的公式是:
球心在点的球的公式:(x-cx)2+(y-cy)2+(z-cz)^2 = R^2
假设点P为球面上一点
向量的平方等于向量模长的平方:
也记做:
我们假设射线能打到球上,那么一定存在一个长度,使得从射线原点
出发,方向为
的射线,到达了
的点,将
代入到方程中:
将看做一组变量,通过完全平方展开:
看起来复杂,但、
、
、
都是已知变量,只有t是未知变量,也就是说,上面的式子只不过是很普通的一元二次方程组解
,我们常用的求根公式:
,其中

和他的图一样,

第五部分:面的法向量和抽象撞击
球的法向量得到方法及其简单,上面那个一元二次方程组,解出来就能得到的值,代入到
,就能得到撞击的点,而球撞击点的法向量就是
,
和
都知道,法向量也就能得到了,示意图就是下图:





if表示,假如撞击到了,那么先用求根公式算出小的根(其实就是离我们眼睛近的点),看看在不在采样范围内,如果在,那么把撞击记录的值填上,返回true,否则看离自己远的点。
我在算这个的时候有个疑问,就算和球有两个交点,远的那个点也不再视线范围内(被前面的点挡住了),完全可以不用计算,后来出图也是,算远的点可能导致图片有很多噪点,而只算近的点,图片会干净很多,知乎那个完全没有更改代码,后面那个博客有所更改,我感觉应该注释掉下面的那个情况。

当可撞击列表被撞击检测时,他会定义几个变量:temp_rec的作用是记录最后撞击到的信息,hit_anything是列表中是否有物体被检测到撞击,closest_so_far是记录最短的撞击路径(用于深度检测?)
然后对列表内每个可撞击物进行检测,采样射线一直是r,最小采样距离不变,最大采样距离是检测到的最小的t(这个地方感觉超级精妙),然后将临时记录交给它。
我们想象,假如一开始最大采样距离是float的最大值(后面确实是这么做的),此时一个碰撞物列表开始检测,closest_so_far=t_max=maxfloat,他碰撞第一个物体,没碰到;那么继续第二个物体,这时候碰到了,那么closest_so_far会被复制此时的碰撞长度(离我们眼睛有多远),假设是100;我们再碰第三个物体,第三个物体的t_max的最大采样距离就被设成这个100,假如第三个物体也在碰撞范围内,但碰撞点离我们用110的长度,那么在sphere类的hit函数中,就会被判断:不再采样距离内,就会返回false;如果离我们有80呢?那么就会返回true,并且将80赋值给closest_so_far,这样保证temp_rec记录的肯定是里我们最近的物体的点,不会将后面的物体不会因为后采样而盖在前面物体的上面被我们“看到”,在多个物体的时候特别有用,所以说设计思想真的很精妙!

main方法声明了个撞击物列表list,塞入两个球后,交给world构造hitable_list对象;一个球热别大,放在这里充当地面,另一个小球使我们用于实验的,p变量没有一点用可以不写。

结果不是很令人欣喜,不过这是为后面打下基础。这个时候我做了一个题外的事情:既然法向量有了,坐标也有了,那么光照也应该没问题,我用了Opengl教程里面的冯氏光照增写了下color作为着色器,试验了点光源,下面是效果图:

第六部分:抗锯齿
想象一下,锯齿为什么存在?
因为我们发射线也是有距离间隔的,现在每个间隔作为一个像素,只采样一个点,采样到的颜色就作为这个像素的全部颜色,但实际上可能这个点刚好没有表面,而像素范围内表面占比其实很多,我们看不到不符合常理。同理,假如物体有很多颜色,通体是蓝的,只有一点是黑的,我们恰好采样到了,就认为一大块像素都是黑的……



#include <random>
float rand_uniform() {
static std::uniform_real_distribution<double> u_rand(0, 1);
static std::default_random_engine rand_engine;
return u_rand(rand_engine);
}
相机类只是普通的封装,变化并不大,更方便射线的获取。main函数的循环中又嵌套一层用来多重取样,一个像素就要取100次,一口老血喷出来,原来瞬间出图变成等了一秒,CPU是I7-6700HQ,想想Blender,采样次数一般开在20到40间,都能做到实时渲染,不愧是GPU(人家也肯定做了优化)
效果喜人,起码明显的锯齿没有了。
第七部分:漫反射
生活中大多数物品的都不是特别亮,因为表面粗糙,会把光向各种方向反射,而像金属之类的材质表面光滑,会把大多数光像一个方向反射,因此看起来很亮。


vec3 random_in_unit_sphere() {
vec3 p;
p = (2.0 * vec3(rand_uniform(), rand_uniform(), rand_uniform()) - vec3(1, 1, 1)).unit_vector() * rand_uniform();
return p;
}
这种方法,就算一开始三个rand结果都是1,正规化结果后还是三个分量都是1的vec3,单位化后保证三个分量的平方和一定小于1(相当于半径小于1的球),此时再对半径小于1的球做随机。
color的撞击到球部分继续发生改变,target变量就是上述单位球随机法的体现,而返回的值不再是法向量,而是将折射给自己的光衰减0.5后输出。
这个地方当时奇怪了很久,不过想了半天后,突然想起我们的主题是光线追踪,所以光来的方向和我们射线的方向其实是相反的,因此物体会将光吸收一部分(就是我们所说的物体颜色)视为衰减再反射给我们,衰减之前的光则是需要继续向前追踪,此时碰撞点相当于另一个相机,发射射线采样继续向前追踪,所以用到了递归。

毕竟光源是天空盒,因此看起来有点暗,然后经过


部分最后,作者将采样最小距离
if (world->hit(r, 0.00000001, FLT_MAX, rec))
改为0.000001,t为0时,相机就在球面上,这样再看到球有点吓人(没试过,不过大概是单色?)
第八部分:材质抽象及金属材质
不同材质对反射、折射的处理是不同的,因此最好将材质抽象出来
//material.h
class material {
public:
virtual bool scatter(const ray& r_in, const hit_record& rec, vec3& attenuation, ray& scattered) const = 0;
};
在现在的含义就是反射,不过在后面的代码还会包含折射功能,这个函数的作用就是检测是否有下一步采样,没有返回false,有的话返回true,并把检测出的参数填到传入的引用参数中。
四个参数分别为:击中射线,撞击记录(因为会先检测撞击再根据材质看折射、反射方向),衰减率(暂时可理解为颜色),下一步的采样方向(折射或反射或其他)

class lambertian : public material {//可直接写在material.h中
public:
lambertian(const vec3& a) : albedo(a) {}
virtual bool scatter(const ray& r_in, const hit_record& rec, vec3& attenuation, ray& scattered) const override {
vec3 target = rec.p + rec.normal + random_in_unit_sphere();//新的采样目标
scattered = ray(rec.p, target - rec.p);// 新的采样射线,兰伯特会随机打线
attenuation = albedo; //衰减 = 折射率
return true;
}
vec3 albedo; //可看做颜色,假如颜色为0,0,0 那么外界直接衰减为无
};
兰伯特材质就是之前我们讨论的漫反射材质。
接下来是金属材质,不同于漫反射,金属材质得到反射方向更加有规律。
我个人推算反射向量的方法:



color再次惨遭改变,增加了一个参数,是追踪深度,防止进入死角(可能是后面折射会偏转角度,结果跑到了两个全反射还垂直射入的平行金属板之间?)。

可以看出我出的图有很多噪点,把sphere::hit的那个背面采样的那一段注释掉(前面说的)就干净很多了,后面有演示。

class metal : public material {
public:
metal(const vec3& a, float f) : albedo(a) {
fuzz = f < 1 ? f : 1;
}
virtual bool scatter(const ray& r_in, const hit_record& rec, vec3& attenuation, ray& scattered) const override {
vec3 reflected = reflect(r_in.direction().unit_vector(), rec.normal);
scattered = ray(rec.p, reflected + fuzz*random_in_unit_sphere());
attenuation = albedo;
return (dot(scattered.direction(), rec.normal) > 0);
}
vec3 albedo;
float fuzz;
};
多了个fuzz模糊变量,用于控制随机球的收缩,最大是单位球(不过……假如是个单位球,出射向量本身是个单位向量,如果和球的距离小于1,岂不是可能反射到球内部???这也是return时要dot一下看和法向量夹角是否处于90°的原因吧?)


第九部分:电介质
就是带折射的材质,水、玻璃之类的。

根据Snell定律

电介质代码,新增成员变量refract_index(ref_idx),就是该材质的折射率
散射函数内,定义了outward_normal变量,是为了矫正法向量,一会儿说。
图中的衰减率和后面示意图使用的不一样,原文说要实验杀死蓝色通道,不过后面又加上了蓝色通道,变回了没有衰减的(1, 1, 1)。
if判断dot结果就是入射光线和法向量的夹角,如果在90°以内,说明光线是从材质内部射出的,那么就翻转法向量,因为反射函数咱们都当是法向量和入射光线夹角大于90°的情况(既上面示意图的情况),

既然不能只折射,反射和折射各有衰减,这个比例和观测处对观测目标的角度有关。叶大的文章举例子用的是马克杯里的水,不过我感觉一个更方便的实验方法。
我们都坐在电脑屏幕正前方,此时打开手机屏幕,保持开启状态,手机屏幕向上,高度位于胸部或往下,我们将手机水平平移到我们胸前,此时观测点和观测目标平面趋于垂直,能清楚看到手机屏内的内容;将手机水平移动到人和电脑屏幕中间,手机屏内的内容逐渐暗淡不清,电脑里的内容也不是很清晰,继续向电脑屏移动,等到挨到电脑屏时,我们几乎能完全看到电脑屏的内容显示在手机屏上,而手机本身的内容几乎看不到了。
这也是原书中提到的:游泳潜水一瞬间犹如看到镜子,以及叶大文章内水中倒影的相同事例。
那么我们怎么判断反射和折射对原来光的分流各式多少?
知乎上的文章里引用了叶大的文章,里面推导了公式,需要用到的参数挺麻烦(好像有电磁波什么的),不过有个Schlick近似,我们需要角度和折射率,即可计算反射占比:


新加了个球,球心和左球一致,发现半径是负的,也就是说法向量向内,造成了镂空玻璃的效果,看起来效果不错(虽然有点糊,可能是分辨率200*100太低了)
第十部分:可变位置相机
这相机确实该改改了,位置不变,透视不变,不过没看之前也是懵逼的,Opengl那时直接乘个lookat矩阵和透视矩阵,世界就旋转了(虽然现在还没解决绕点旋转垂直±90°roll角跳变180°问题,问了说是万向锁用四元数解决,现在还没头绪)




相机放平,在roll角不发生变化的情况下,相机绕着相机平面任何轴旋转,

相机的构造方法也需要更新
camera cam(vec3(-2,2,1), vec3(0,0,-1),vec3(0,1,0),90,float(png.weight())/float(png.height()));
,现在我们的相机可以改变透视,和非roll角旋转了(分辨率太低好糊)。

第十一部分:景深

我对照相机原理了解也不多,上面图大致讲的就是,相机分成三段:胶片、镜片、成像面,左边决定聚焦,右边决定散焦,我们不需要全部模拟,只要模拟作为我们眼睛的镜片和成像面就好。
还是随机球(圆)法,我们的眼睛不再是一个点,而是一个长度给定的三维圆,射线在圆内随机打出。
和随机球一样,我们也需要定义一个3D随机圆函数,并且根据知乎文章那种生成方法,不进行反复随机取随机圆。
//camera.h
vec3 random_in_unit_disk() {
vec3 p = 2.0f * vec3(rand_uniform(), rand_uniform(), 0) - vec3(1, 1, 0);
p = p.unit_vector() * rand_uniform();
return p;
}
我们最后编辑相机类:
class camera {
public:
camera(vec3 lookfrom, vec3 lookat, vec3 vup, float vfov, float aspect, float aperture, float focus_dist) {
lens_radius = aperture / 2;
float theta = vfov * M_PI / 180;
float half_height = tan(theta / 2);
float half_width = aspect * half_height;
origin = lookfrom;//原点
w = (lookfrom - lookat).unit_vector();
u = cross(vup, w).unit_vector();
v = cross(w, u);
lower_left_corner = origin - half_width *focus_dist* u - half_height *focus_dist* v - focus_dist*w;
horizontal = 2*half_width*u*focus_dist;
vertical = vertical = 2*half_height*v*focus_dist;
}
ray get_ray(float s, float t) {
vec3 rd = lens_radius * random_in_unit_disk();
vec3 offset = u * rd.x() + v * rd.y();
return ray(origin+offset, lower_left_corner + s * horizontal + t * vertical - origin-offset);
}
vec3 lower_left_corner;
vec3 horizontal;
vec3 vertical;
vec3 origin;
vec3 u, v, w;
float lens_radius;
};
新来的参数aspect决定起始处随机圆的大小,aperture会将成像面扩大

第十二部分 下一步
最后作者给出了封面的生成碰撞物列表的函数,不过没给相机数据
hitable *random_scene() {
int n = 500;
hitable **list = new hitable*[n + 1];
list[0] = new sphere(vec3(0, -1000, 0), 1000, new lambertian(vec3(0.5, 0.5, 0.5)));
int i = 1;
for (int a = -11; a < 11; a++) {
for (int b = -11; b < 11; b++) {
float choose_mat = rand_uniform();
vec3 center(a + 0.9*rand_uniform(), 0.2, b + 0.9*rand_uniform());
if ((center - vec3(4, 0.2, 0)).length() > 0.9) {
if (choose_mat < 0.8) {
list[i++] = new sphere(center, 0.2, new lambertian(vec3(rand_uniform()*rand_uniform(), rand_uniform()*rand_uniform(), rand_uniform()*rand_uniform())));
}
else if (choose_mat < 0.95) {
list[i++] = new sphere(center, 0.2,
new sp_metal(vec3(0.5*(1 + rand_uniform()), 0.5*(1 + rand_uniform()), 0.5*(1 + rand_uniform())), 0.5*rand_uniform()));
}
else {
list[i++] = new sphere(center, 0.2, new dielectric(1.5));
}
}
}
}
list[i++] = new sphere(vec3(0, 1, 0), 1.0, new dielectric(1.5));
list[i++] = new sphere(vec3(-4, 1, 0), 1.0, new lambertian(vec3(0.4, 0.2, 0.1)));
list[i++] = new sphere(vec3(-4, 1, 0), 1.0, new sp_metal(vec3(0.7, 0.6, 0.5), 0.0));
return new hitable_list(list, i);
}
感想

camera cam(lookfrom, lookat, vec3(0, 1, 0), 90, png.weight() / png.height(), 1, 0);
的成图结果采样次数70,分辨率400*200,看起来不错,跑了我半个小时,不想跑第二次了。
想想总共400*200个像素,每个像素要采样100次,每次采样要射线进行最多50次追踪,每个追踪需要对500的球逐个进行碰撞检测……心疼我的电脑酱,假如是1080*720P的图片呢……总次数还要乘个10,I7-6700HQ瑟瑟发抖,或许下一步应该想办法交给我的GTX960mGPU了。
相比之前的opengl,我更加清楚了渲染的原理,不过更多的疑问出现了:
- 球的碰撞只要检测半径就行了,平面或其他图形呢?
- Opengl里怎么实现少量采样的光线追踪,每个碰撞到的位置都不是我们记录的,而是glsl里面的g1_Position变量,在不拿出来交给面向对象程序的情况下,用uniform传入数据干算吗?有点混乱,应该好好想一想。
- 看到有些实例会对生成图片进行图片处理,比如说边缘检测,还写进shader里了,放到glsl里面又是怎么做的?边缘检测需要旁边其他像素的值来卷积啊,shader这种感觉只入不出的东西是怎么获取旁边其他管道的计算结果的?
下一步可能会看这本书的下一本,或者深入学下Opengl,还有C++的多线程,这方面了解太少。
网友评论