NZL 21/1/13
这是我图形学这门课的期末作业,我觉得还挺有意思的就写一篇博客记录一下。本身这门课学的技术都是四五十年前的了,相当于图形学的入门课程,所以用到的公式,方法可能都过时了。我只是按照老师教的方法,结合一些论文的辅助,把一个基本的光线追踪算法实现了而已,如果有图形学大佬那么尽可以对本文嗤之以鼻。
由于实现光照渲染需要用到GLSL,而我又没有该方面基础,因此使用了一个模板:framework.h/cpp
,它来源于国外一门课程的仓库。按照模板的说明,你不应该动其中的任何一部分,通过实现framework.cpp
中的几个函数即可运行。本次光线追踪项目,我们主要通过定义一系列物品、光、场景等结构,最终实现onInitialization
和onDisplay
函数,从而完成渲染。
当然,这个模板需要依赖freegult
和glew
,若要运行,需要将glew32.dll
与freeglut.dll
两个文件放入系统的System32
文件夹中,又或者放入生成的.exe
文件所在文件夹中(一般在Debug里)。
enum MaterialType {
ROUGH, REFLECTIVE, REFRACTIVE };
struct Material
{
vec3 ka, kd, ks; // 环境光照(Ambient),漫反射(Diffuse),镜面反射(Specular)系数,用于phong模型计算
float shininess; // 表面平整程度,用于镜面反射计算
vec3 F0; // 垂直入射时,反射光的占比。计算公式需要折射率n和消逝率k:[(n-1)^2+k^2]/[(n+1)^2+k^2]
float ior; // 折射率(index of refraction)
MaterialType type;
Material(MaterialType t) {
type = t; }
};
Material是我们定义的一个基类(虽然用的结构体),表示物体的材质。根据枚举类型不难理解,我们将物体分为三种材质,粗糙(Rough)、反射型(Reflective)、折射型/透明物体(Refractive/Transparent)。
实际上,这三种材质划分了光线照射在物体表面的三种主要行为:
对于粗糙物体我们用Phong模型求解,ka,kd,ks
分别为环境光系数
、漫反射系数
与镜面反射系数
,特别的,在Phong模型中,镜面反射的公式为: I s = I i n c o m i n g ∗ k s ∗ ( R ⋅ V ) s h i n i n e s s I_s=I_{incoming}*k_s*(R·V)^{shininess} Is=Iincoming∗ks∗(R⋅V)shininess
其中R·V表示反射光与视线的角度( c o s θ cos\theta cosθ),而shininess表示物体表面的平整程度(或者你也可以理解为镜面反射强度),因此单独设立一个变量表示。不过在实际计算时,我们用Halfway vector
这个方法加速计算(因为R不好算),后面光线追踪时再说。
反射型和折射型的计算则用到了一个叫做Fresnel Function
的公式(准确的说是简化版),具体的内容可以看这个论文:Optic Fundamentals。这里面定义了一个F0
,表示“反射光的占比”,其计算公式为: ( n − 1 ) 2 + κ 2 ( n + 1 ) 2 + κ 2 \frac{(n-1)^2+\kappa^2}{(n+1)^2+\kappa^2} (n+1)2+κ2(n−1)2+κ2
这里面n是物体的折射率, κ \kappa κ是“消光系数”,表示光射入物体后的衰弱(diminish)的快慢,它的含义你可以看这里:Optical Properties of Electronic Materials: Fundamentals and Characterization
这个公式中, κ \kappa κ这一项实际上只对金属物体有作用,因为金属的 κ \kappa κ值很大,计算出来的F0趋近于1,即所有光几乎都进行了反射。
光线追踪的一个简单的场景模型如下:
场景(Scene)中有一个摄像机,若干个物体,若干个光源。我们追踪的“光线”,实际上是从摄像机出发,穿过视窗上某一像素点的一条射线。追踪这条射线,求其与场景中所有物体是否有交,如果与所有物体都没有交点,则返回环境光;否则,计算距离最近的那个交点位置的着色,如果该交点处的材质是反射型的,那么继续追踪其反射射线,直到达到递归深度上限,或不再有交。
所以你看到这个描述之后就知道,我们需要定义需要追踪的射线“Ray”,以及交点“Hit”:
struct Ray // 射线(从视角出发追踪的射线)
{
vec3 start, dir; // 起点,方向
Ray(vec3 _start, vec3 _dir) {
start = _start;
dir = normalize(_dir); // 方向要进行归一化,因为在光照计算时我们认为参与运算的都是归一化的向量。
}
};
struct Hit // 光线和物体表面交点
{
float t; // 交点距光线起点距离,当t大于0时表示相交,默认取-1表示无交点
vec3 position, normal; // 交点坐标,法线
Material* material; // 交点处表面的材质
Hit() {
t = -1; }
};
上面的场景中还有两个重要的东西,就是“物体”与“光”。目前我已经扩展了平面类、球类与圆柱类,点光源。
class Intersectable // 定义一个基类(接口),可交
{
public:
virtual Hit intersect(const Ray& ray) = 0; // 需要根据表面类型实现
protected:
Material* material;
};
struct Light {
// 定义光源
vec3 direction;
vec3 Le; // 光照强度
Light(vec3 _direction, vec3 _Le)
{
direction = _direction;
Le = _Le;
}
};
class Sphere : public Intersectable // 定义球体
{
vec3 center;
float radius;
public:
Sphere(const vec3& _center, float _radius, Material* _material)
{
center = _center;
radius = _radius;
material = _material;
}
~Sphere() {
}
Hit intersect(const Ray& ray){
}
};
由于这里我们用的全部都是向量表示,因此下面展示的是向量求解法,联立直线方程与球方程:
{ p = s + t ∗ d ∣ P 0 P ∣ = R \begin{cases} p=s+t*d\\ |P_0P|=R\\ \end{cases} { p=s+t∗d∣P0P∣=R
其中s表示光线起点的坐标(或者说是从世界坐标系原点到s点的向量),d表示光线的方向向量,P0是球心坐标,R为半径。
代入后的结果为:
d 2 ∗ t 2 + 2 ∗ d ∗ ( s − P 0 ) ∗ t + ( s − P 0 ) 2 − R 2 = 0 d^2*t^2+2*d*(s-P_0)*t+(s-P_0)^2-R^2=0 d2∗t2+2∗d∗(s−P0)∗t+(s−P0)2−R2=0
a = d 2 , b = 2 ∗ d ∗ ( s − P 0 ) , c = ( s − P 0 ) 2 − R 2 a=d^2,b=2*d*(s-P_0),c=(s-P_0)^2-R^2 a=d2,b=2∗d∗(s−P0),c=(s−P0)2−R2
这就很简单了(都变成标量了),求解 Δ \Delta Δ的正负就知道是否有交,而t值也很容易求出来。因此把上述过程写为代码就是:
Hit intersect(const Ray& ray)
{
Hit hit;
vec3 dist = ray.start - center; // 距离
float a = dot(ray.dir, ray.dir); // dot表示点乘,这里是联立光线与球面方程
float b = dot(dist, ray.dir) * 2.0f;
float c = dot(dist, dist) - radius * radius;
float discr = b * b - 4.0f * a * c; // b^2-4ac
if (discr < 0) // 无交点
return hit;
float sqrt_discr = sqrtf(discr);
float t1 = (-b + sqrt_discr) / 2.0f / a; // 求得两个交点,t1 >= t2
float t2 = (-b - sqrt_discr) / 2.0f / a;
if (t1 <= 0)
return hit;
hit.t = (t2 > 0) ? t2 : t1; // 取近的那个交点
hit.position = ray.start + ray.dir * hit.t;
hit.normal = (hit.position - center) / radius;
hit.material = material;
return hit;
}
class Plane :public Intersectable
{
// 点法式方程表示平面
vec3 normal; // 法线
vec3 p0; // 线上一点坐标,N(p-p0)=0
public:
Plane(vec3 _p0, vec3 _normal, Material* _material)
{
normal = normalize(_normal);
p0 = _p0;
material = _material;
}
Hit intersect(const Ray& ray);
};
这里平面依然是用向量的表示方式,一个点、一条法线即可确定一个平面,方程为: N ( P − P 0 ) = 0 N(P-P_0)=0 N(P−P0)=0
把直线方程代入之后,可以把t表示出来(推导可见):
N ⋅ P 0 − N ⋅ S N ⋅ D \frac{N·P_0-N·S}{N·D} N⋅DN⋅P0−N⋅S
注意,分母中的部分,用视线与平面法向量点乘,如果这个结果为0,那就表示视线是平行于平面观察的,那么自然不会有交点。而当解出的t小于0时,则表示交点在视线起点后方,自然是排出的。综上,代码如下:
Hit intersect(const Ray& ray)
{
Hit hit;
float nD = dot(ray.dir, normal); // 射线方向与法向量点乘,为0表示平行
if (nD == 0)
return hit;
float t1 = (dot(normal, p0) - dot(normal, ray.start)) / nD;
if (t1 < 0)
return hit;
hit.t = t1;
hit.position = ray.start + ray.dir * hit.t;
hit.normal = normal;
hit.material = material;
return hit;
}
圆柱体用向量表示的方法好像并不固定,这里我参考了一篇论文中给出的公式:
( q − p a − ( v a , q − p a ) v a ) 2 − r 2 = 0 (q-pa-(va,q-pa)va)^2-r^2=0 (q−pa−(va,q−pa)va)2−r2=0
在stackoverflow上面,我找到了一个合理的推导过程,因此我们就使用这个公式来表示圆柱。公式中括号表示点乘
上面这个公式中变量的含义如下:
不难发现,这样定义的实际上是一个无限长的圆柱面,而要给它加上底面也很简单,只需要在提供一个上底面圆心与下底面圆心即可,有限长圆柱体的表达式为:
( q − p a − ( v a , q − p a ) v a ) 2 − r 2 = 0 , ( v a , q − p 1 ) > 0 , ( v a , q − p 2 ) < 0 (q-pa-(va,q-pa)va)^2-r^2=0,(va,q-p1)>0,(va,q-p2)<0 (q−pa−(va,q−pa)va)2−r2=0,(va,q−p1)>0,(va,q−p2)<0
如下图所示:
因此圆柱的类型定义为:
class Cylinder :public Intersectable
{
// 无(有)限长圆柱面,(q-pa-(va,q-pa)va)^2-r^2=0,q是面上一点
// (va,q-pa),是点乘
vec3 va; // 转轴方向
vec3 pa; // 转轴中心点(或者理解成某一截面的中心)
float radius; // 旋转半径
vec3 p1; // 下底面圆心,有(va,q-p1)>0,这个参数不给就是无限长圆柱
vec3 p2; // 上底面圆心,有(va,q-p2)<0,这个参数不给就是无限长圆柱
public:
Cylinder(vec3 _pa, vec3 _va, float _radius, Material* _material, vec3 _p1 = vec3(0,0,0), vec3 _p2 = vec3(0,0,0))
{
pa = _pa;
va = normalize(_va);
radius = _radius;
material = _material;
p1 = _p1; // 如果为(0,0,0)则说明用户定义无限长圆柱面
p2 = _p2;
}
Hit intersect(const Ray& ray);
对于一个圆柱体求交点的过程如下(以下步骤针对有限长圆柱,无限长圆柱面只需要第一步):
第二步推导很简单,下面写一下第一步的推导:
直 线 : q = s + d t , 侧 面 : ( q − p a − ( v a , q − p a ) v a ) 2 − r 2 = 0 直线:q=s+dt,侧面:(q-pa-(va,q-pa)va)^2-r^2=0 直线:q=s+dt,侧面:(q−pa−(va,q−pa)va)2−r2=0
将 直 线 代 入 侧 面 方 程 : ( s − p a + d t − ( v a , s − p a + d t ) v a ) 2 − r 2 = 0 将直线代入侧面方程:(s-pa+dt-(va,s-pa+dt)va)^2-r^2=0 将直线代入侧面方程:(s−pa+dt−(va,s−pa+dt)va)2−r2=0
( d − ( d , v a ) v a ) 2 t + 2 ( d − ( d , v a ) v a , Δ p − ( Δ p , v a ) v a ) t + ( Δ p − ( Δ p , v a ) v a ) 2 − r 2 = 0 , 其 中 Δ p = s − p a (d-(d,va)va)^2t+2(d-(d,va)va,\Delta p-(\Delta p,va)va)t+(\Delta p-(\Delta p,va)va)^2-r^2=0,其中\Delta p=s-pa (d−(d,va)va)2t+2(d−(d,va)va,Δp−(Δp,va)va)t+(Δp−(Δp,va)va)2−r2=0,其中Δp=s−pa
上式为标准的 A t 2 + B t + C = 0 At^2+Bt+C=0 At2+Bt+C=0形式,因此可以通过判别式的正负确定有无交点,并易求t值。代码实现如下:
Hit intersect(const Ray &ray)
{
Hit hit;
// 把直线方程 q = s + dt 代入,则
// (s - pa + dt - (va, s - pa + dt)va)^2 - r^2 = 0
// t为未知量,形式为At^2+Bt+C=0
// A = (d - (d,va)va)^2
// B = 2(d - (d,va)va, Δp-(Δp,va)va)
// C = (Δp - (Δp,va)va)^2 - r^2
// Δp = s - pa
vec3 A_operand = ray.dir - dot(ray.dir, va) * va;
float A = dot(A_operand, A_operand);
vec3 delta_p = ray.start - pa;
vec3 B_operand = delta_p - dot(delta_p, va) * va;
float B = 2 * dot(A_operand, B_operand);
float C = dot(B_operand, B_operand) - radius * radius;
float discr = B * B - 4 * A * C;
if (discr < 0)
return hit;
float sqrt_discr = sqrtf(discr);
float t1 = (-B + sqrt_discr) / 2 * A; // t1>=t2
float t2 = (-B - sqrt_discr) / 2 * A;
if (p1 == vec3(0, 0, 0) || p2 == vec3(0, 0, 0))
{
if (t1 < 0)
return hit;
hit.t = (t2 > 0) ? t2 : t1; // 取近的那个交点
hit.position = ray.start + ray.dir * hit.t;
vec3 N = hit.position - pa - dot(va, hit.position - pa) * va;
hit.normal = normalize(N);
hit.material = material;
//cout << hit << endl;
return hit;
}
else
{
vector<float> t_candidate;
// 对于有限圆柱还要看交点是不是在上下底之间
// 另外要判断上下底是否有交点
// 第一步,判断侧面的两个交点是否有效(在上下底之间)
vec3 q1 = ray.start + ray.dir * t1;
vec3 q2 = ray.start + ray.dir * t2;
if (t1 >= 0 && dot(va, q1 - p1) > 0 && dot(va, q1 - p2) < 0)
t_candidate.push_back(t1);
if (t2 >= 0 && dot(va, q2 - p1) > 0 && dot(va, q2 - p2) < 0)
t_candidate.push_back(t2);
// 第二步,求与上下底面的交点
// 把直线公式代入下底面为:
// (va,d)t + (va,s) - (va,p1) = 0
float vad = dot(va, ray.dir);
float vas = dot(va, ray.start);
float vap1 = dot(va, p1);
float t_bottom = (vap1 - vas) / vad;
if (t_bottom >= 0)
{
vec3 q3 = ray.start + ray.dir * t_bottom;
if (dot(q3 - p1, q3 - p1) < radius * radius)
t_candidate.push_back(t_bottom);
}
// 把直线公式代入上底面,则
float vap2 = dot(va, p2);
float t_up = (vap2 - vas) / vad;
if (t_up >= 0)
{
vec3 q4 = ray.start + ray.dir * t_up;
if (dot(q4 - p2, q4 - p2) < radius * radius)
t_candidate.push_back(t_up);
}
if (t_candidate.size() == 0)
return hit;
// 遍历所有候选t,找到最小的
float mint = MAXINT;
for (auto it = t_candidate.begin(); it != t_candidate.end(); it++)
{
if (*it < mint)
mint = *it;
}
hit.t = mint; // 取近的那个交点
hit.position = ray.start + ray.dir * hit.t;
if (mint == t1 || mint == t2)
{
vec3 N = hit.position - pa - dot(va, hit.position - pa) * va;
hit.normal = normalize(N);
}
else if (mint == t_up)
hit.normal = va;
else if (mint == t_bottom)
hit.normal = -va;
hit.material = material;
}
return hit;
}
场景中有一台摄像机,表示用户的视角,我们主要是定义摄像机的位置,以及视窗(Image)的大小:
class Camera {
// 用相机表示用户视线
vec3 eye, lookat, right, up; // eye用来定义用户位置;lookat(视线中心),right和up共同定义了视窗大小
float fov;
public:
void set(vec3 _eye, vec3 _lookat, vec3 _up, float _fov) // fov视场角
{
eye = _eye;
lookat = _lookat;
fov = _fov;
vec3 w = eye - lookat;
float windowSize = length(w) * tanf(fov / 2);
right = normalize(cross(_up, w)) * windowSize; // 要确保up、right与eye到lookat的向量垂直(所以叉乘)
up = normalize(cross(w, right)) * windowSize;
}
Ray getRay(int X, int Y) // 返回穿过(x,y)位置像素的射线
{
vec3 dir = lookat + right * (2 * (X + 0.5f) / windowWidth - 1) + up * (2 * (Y + 0.5f) / windowHeight - 1) - eye;
return Ray(eye, dir);
}
void Animate(float dt) // 修改eye的位置(旋转)
{
vec3 d = eye - lookat;
eye = vec3(d.x * cos(dt) + d.z * sin(dt), d.y, -d.x * sin(dt) + d.z * cos(dt)) + lookat;
set(eye, lookat, up, fov);
}
};
现在我们已经万事俱备了,可以真正搭建起要进行光线追踪的“场景”了。
class Scene {
// 场景,物品和光源集合
vector<Intersectable*> objects;
vector<Light*> lights;
Camera camera;
vec3 La; // 环境光
public:
void build();
void render();
Hit firstIntersect(Ray);
bool shadowIntersect(Ray);
vec3 trace(Ray,int);
void Animate(float dt);
}
就像之前图中展示的,一个场景中有若干个物品、若干个光源,一个摄像机,这里多加了一个环境光(因为环境光不是点光源,所以不放在Light中),用于做基本的返回值。
这里面光线追踪以及其相关函数下面会讲,但其他函数简单说一下:
build()
,这是初始化函数,定义了用户(摄像机)的初始位置,环境光La,向光源集合、物品集合中添加物件。render()
,渲染视窗上每个点的着色(逐像素调用trace
函数)Animate(float)
,调用摄像机的Animate,让视角旋转起来光线追踪的基本思想我在第三部分说了,我们直接按部分划分讲求解过程:
之前说了,我们用Phong模型计算视线与Rough材质交点处的着色,其公式为: I s = I i n c o m i n g ∗ k s ∗ ( R ⋅ V ) s h i n i n e s s I_s=I_{incoming}*k_s*(R·V)^{shininess} Is=Iincoming∗ks∗(R⋅V)shininess
但是在实际计算时,反射光线R较难求解,而且我们最终并不需要它,我们只需要其与观察方向的夹角,因此这里有一个性能优化的trick:
我们看上面这张图中的向量h,定义为 h = v + s h=v+s h=v+s。直观的可以看到,h和n之间的夹角与v和r之间的夹角几乎相同,而h比r好求很多(因为s、v和n都是已知的),如果我们用h与n之间的夹角去代替v与r的夹角,将大幅提高Phong模型的计算速度,代码如下:
if (hit.material->type == ROUGH)
{
vec3 outRadiance = hit.material->ka * La; // 初始化返回光线(利用环境光)
for (Light* light : lights)
{
Ray shadowRay(hit.position + hit.normal * epsilon, light->direction);
float cosTheta = dot(hit.normal, light->direction);
if (cosTheta > 0) // 如果cos小于0(钝角),说明光找到的是物体背面,用户看不到
{
if (!shadowIntersect(shadowRay)) // 如果与其他物体有交,则处于阴影中;反之,按Phong模型计算
{
outRadiance = outRadiance + light->Le * hit.material->kd * cosTheta;
vec3 halfway = normalize(-ray.dir + light->direction);
float cosDelta = dot(hit.normal, halfway);
if (cosDelta > 0)
outRadiance = outRadiance + light->Le * hit.material->ks * powf(cosDelta, hit.material->shininess);
}
}
return outRadiance;
}
这里还有一个点,那就是ShadowRay。ShadowRay是学名,它表示直指光源的那一段射线,这里我们需要求该射线是否与其他物体有交,一旦有交则说明光源被遮挡了,那么这里在用户看来就是阴影,其着色用环境光赋予即可。
除了Rough材质的物体,Reflective和Refractive材质的物品都会发生镜面反射,镜面反射的光线我们需要继续追踪,直到达到迭代深度,或者截止于一个Rough材质物体。按照Fresnel理论,一束光到达物体表面后,会分为折射光线与反射光线两种,这里我们需要用到结构体中定义的F0,即反射光线的占比:
float cosa = -dot(ray.dir, hit.normal); // 镜面反射(继续追踪)
vec3 one(1, 1, 1);
vec3 F = hit.material->F0 + (one - hit.material->F0) * pow(1 - cosa, 5);
vec3 reflectedDir = ray.dir - hit.normal * dot(hit.normal, ray.dir) * 2.0f; // 反射光线R = v + 2Ncosa
vec3 outRadiance = trace(Ray(hit.position + hit.normal * epsilon, reflectedDir), depth + 1) * F;
既然我们已经用了Fresnel公式,那么折射的计算也是举手之劳:
if (hit.material->type == REFRACTIVE) // 对于透明物体,计算折射(继续追踪)
{
float disc = 1 - (1 - cosa * cosa) / hit.material->ior / hit.material->ior;
if (disc >= 0)
{
vec3 refractedDir = ray.dir / hit.material->ior + hit.normal * (cosa / hit.material->ior - sqrt(disc));
outRadiance = outRadiance + trace(Ray(hit.position - hit.normal * epsilon, refractedDir), depth + 1) * (one - F);
}
}
至此我们的光线追踪算法已经实现了,剩下的部分就是利用OpenGL的渲染器将它显示出来,这一部分用到了GLSL,我并不是很懂,但是大体的运行逻辑如下:
FullScreenTextureQuad
类是一个用来初始化顶点着色器与片段着色器的类,其构造函数中生成了VertexArray与Buffer缓冲。Scene
类中定义过一个Render函数,用来逐像素的求取着色,这里我们就利用该函数返回的image,作为要被渲染的材质使用,通过LoadTexture
函数让FragmentShader知道用什么颜色给像素点赋值。Draw
函数,利用glDrawArrays把刚刚的image绘制出来。onInitialization
是整个函数的初始化,我们设定好视窗、初始化场景、初始化着色器,并创建gpu进程onDisplay
,显示函数。调用render求取光线追踪的结果,存到image中,然后用LoadTexture
更新当前帧的图像,最后Draw
出来虽然学到的东西都很老,但是这种思维方式的培养我认为是很有帮助的。图形学这门课的老师说的最好的一句话就是,“永远按照计算机的思维设计算法”。
该项目的完整内容可见我的gitee仓库