【计算机图形学】深入浅出讲解光线追踪(Ray Tracing)

CG基础与光学基础

问自己一句,3D场景为何可以被绘制到2D的画布/屏幕上?

emmmmm…没有那么复杂,这几乎是一个纯几何的过程:

  1. 透视投影。将三维物体的特征点与眼睛连接成一条线,这条线会穿过画布(Canvas)留下一个交点,无数个这样的“连线”与画布的交点,组成了三维物体在二维平面的投影(如下图>_<)
  2. 绘制颜色。好了,我们已经获得了三维物体在二维平面上投影的位置信息;接下来,这些“连线”还会将三维物体上的颜色/亮度信息(亮度归根结底还是要转化为颜色),带回到二维平面上来,形成最终的图像!
    在这里插入图片描述

补充一些物理中的光学常识吧!

  • 我们如何看到物体?光由光子组成,一束光子照射到物体上,发生碰撞,其中的一部分光子被碰撞弹开后,恰好击中了我们的眼睛——眼睛感受到这些光信号,紧接着转换为电信号,交给大脑进行复杂的处理后最终成像。
  • 物体为何具有颜色?光具有波粒二象性,颜色的本质是波长。再从粒子性的角度来看,以白光为例——白光由红、绿、蓝三种光子组成,当白光照射到红色的物体上,蓝色绿色的光子被物体吸收掉,只剩下红色光子反射到我们的眼睛中。
  • 光子撞击到一个物体时,可能且只能发生3种情况:被吸收反射透射。发生三种情况的光子百分因物体材料而异,但有一点我们可以万分确定——入射光子的总数,必然等于被吸收、反射、透射的光子数之和。

再说一些有趣的光学知识…

  • 材料的透明与不透明不是绝对的。比如,你的身体对于X光就是透明的。
  • 没有光线,我们就看不见物体(废话
  • 没有物体,我们也感受不到光线。想一想,如果宇宙中真的只有深邃的黑暗,为何我们不用手电筒照着就能看见本身不发光的天体呢?

1000多年前的阿拉伯人Ibn al-Haytham,最先提出了这个在当时近乎疯狂的假说——“We see objects because the sun’s rays of light; streams of tiny particles traveling in straight lines were reflected from objects into our eyes.”(我们之所以能看到物体,是因为太阳的光线;沿直线传播的微小颗粒从物体反射到我们的眼睛中,从而形成图像。)
 
为什么要反复的讲这些光学知识?因为经典的光线追踪算法,正是对自然界近乎完全一致的模拟。

 
 
 

前向追踪与后向追踪

先放一组对比图,看着图读文字会很好理解 >_<

在这里插入图片描述

前向追踪(Forward Tracing)

前向追踪是对自然界物理现象的完全复刻。

我们完全依照「光子碰撞后反射,并击中眼睛」的理论——设置一个光源并让它不断随机射出光子;并且注意,光子与物体碰撞后反射的方向也是随机的;根据3D的2D呈现原理,我们在眼睛前放置一块画板;假设反射后的光子只能击中画板的一个像素,经过不断击中,某个像素点的亮度会增加到大于0的值… … 重复这个过程,直到所有像素点都经过了调整,一整幅图像也就生成了。

在这里插入图片描述
很美妙的模拟。但是不可行

  • 第一,我们要明白,从物体表面反射的光子是随机方向的,真正能击中画板的光子,千万里挑一

  • 第二,就算一个光子幸运的击中了画板,它仅仅是带来了一个像素的一小部分信息,而我们需要足够多的信息才能组成一张完整的图像

  • 第三,如何界定“此时的信息已经够多了,程序可以停下来了”?这又是实践中的一个难题

总而言之,它过于昂贵——不必要的昂贵。
 

后向追踪(Backward Tracing)

后向追踪是我们对真实自然模拟的妥协。

我们进行一个与上面完全反向的模拟——从眼睛(当然眼睛前还是摆一个画布)向物体射出射线;如果命中,则从命中位置向光源投掷另一条射线。

  • 从眼睛射入场景的这个第一条射线称为主射线(primary ray), 可见射线(visibility ray), 相机射线(camera ray),如果该线没被遮挡,则命中;若该线被遮挡了,说明物体的该点“不可见”
  • 命中后射出的第二条射线称为阴影线(shadow ray),如果该线没被遮挡,则用来确定接收光的多少;如果被遮挡了,则说明命中点处于阴影中

总而言之,通过这种“逆向思维”,避免了大量的算力浪费。

上面我偷偷做了几个假设,可能你理所当然的接受了。倒也没关系,因为这都是物理领域的范畴,可以跳过。

  1. Q:光子与物体表面碰撞后,为什么是“随机向各个方向”反射?
    A:想一想,真的存在绝对光滑的物体平面吗?没有,那是理想状态的全反射模型。自然界中绝大多数的物体都是漫反射。
  2. Q:我们的眼睛好像也不算小,真的几百万个光子才能接收一个吗?
    A:真实的眼睛不是我们所假设的“点接收器”,而是“面接收器”,能接收的光子是比假设中要多的。但是当透镜半径很小时,接收的光线的确只能来自一个方向。

 
 
 

算法实现

基本原理正是上面讲过的自然光子模拟后向追踪过程(⭐️⭐️⭐️):

  1. 连接一个像素中心和眼睛,做这个连线的反向延长线——从而得到主射线(primary ray),即“第一条射线”,并将其射到场景中。若未命中,则说明该物体的该点压根“不可见”;若命中,从命中位置向光源投掷出阴影线(shadow rays),即“第二条射线”
    在这里插入图片描述
  2. 若阴影线未被遮挡,则这个命中点被照亮,则返回颜色×光强;若被其他物体遮挡,则命中点处于阴影中
    在这里插入图片描述
  3. 遍历画板的每一个像素,三维场景就被绘制为了二维图像 >_<
    在这里插入图片描述

>_< 算法的伪代码如下:

for (int i = 0; i < imageHeight; i++) {
    for (int j = 0; j < imageWidth; j++) {
        // 计算主射线
        Ray primaryRay = computePrimRay(i, j, eyePosition);
        // 主射线射向场景,并计算交点
        // nHit是与画板的交点
        // pHit是与物体的交点
        Normal nHit;
        Normal pHit;
        float minDistance = INFINITY;
        Object object = null;
        for (int k = 0; k < objects.size(); k++) {
            if (Intersect(objects[k], primaryRay, &pHit)) {
                float distance = Distance(eyePosition, pHit);
                if (distance < minDistance) {
                    // 发生主射线与多个物体相交的情况时,我们选择交点离眼睛最近的物体
                    object = objects[k];
                    minDistance = distance;
                }
            }
        }
        // 若命中,则从交点向光源投掷阴影线
        if (object != null) {
            Ray shadowRay;
            shadowRay.direction = lightPosition - pHit;
            boolean isInShadow = false;
            for (int k = 0; k < objects.size(); k++) {
                if (Intersect(objects[k], shadowRay)) {
                    isInShadow = true;
                    break;
                }
            }
        }
        // 如果交点处于阴影中,则接着计算颜色*亮度;否则像素点置0
        if (!isInShadow) {
            pixels[i][j] = object.color * light.brightness;
        } else {
            pixels[i][j] = 0;
        }
    }
}

 
 
 
 

加入反射与折射

还是简单补充一下物理光学知识,这其实是初中物理的内容

反射(Reflect):光在传播到不同介质时,在分界处改变传播方向又返回原来介质的现象

折射(Refract):光从一种介质斜射入另一种介质时,传播方向发生偏移

说明一下,本文中同时提到了透射(transmit)和折射(refract),是一个东西——前者是后者的不规范说法。

计算反射

根据“入射角=反射角”这个定律,我们只需要❷个已知量:击中点处的法线(normal)和主射线方向(primary ray)

获得反射方向后,朝这个方向射出一条新射线,假设又击中了一个红球,然后在新的击中点处向光源引出阴影线;计算出反射出的新射线在红球观察到的颜色(颜色 * 光强),返回到玻璃球表面

计算折射

折射方向还与具体材料的折射率有关,因此我们需要知道❸个已知量:击中点处的法线(normal)、主射线方向(primary ray) 、材料的折射率(refractive index)

获得折射光线后,朝这个方向射出一条新射线,假设又击中了一个绿球,然后在新的击中点处向光源引出阴影线;计算出折射出的新射线在绿球观察到的颜色(颜色 * 光强),返回到玻璃球表面

上面「反射+折射」的图解
在这里插入图片描述
菲涅耳方程

等等!我们需要意识到一个问题,上面的玻璃球同时具有反射性和折射性,我们如何将它们混合在一起

各占50%?不幸的是,还要更复杂一些;幸运的是,我们有一个可以准确计算出反射/折射混合光的方程——菲涅耳方程(fresnel

我们仅仅需要知道❸个已知量:主射线方向(primary ray) 、击中点处的法线(normal)、材料的折射率(refractive index)
 
伪代码和上面的知识点完全对应 >_<

Color ReflectionColor = computeReflectionColor();		// 计算反射颜色
Color RefractionColor = computeRefractionColor();		// 计算折射颜色
float K_reflect;			// 反射混合权重
float K_refract;			// 折射混合权重
fresnel(primaryRayDirection, normalHit, refractiveIndex, &K_reflect, &K_refract);	// 菲涅耳方程
glassBallColorAtHit = K_reflect * ReflectionColor + K_refract * RefractionColor		// 混合光

 
 
 

美丽的递归陷阱

—— 递归是美丽的。 光线追踪算法的美妙之处就在于,它是递归的。主光线击中玻璃球,由于反射和折射又射出两条新射线,如果它们又击中了另外的两个玻璃球,在每个玻璃球上又会发生新一轮的反射与和折射,如此递归…一个优雅的过程。

——递归是危险的。 光线追踪算法也是一个危险的陷阱。想象一下,我们的相机在一个只有四面八方都是镜子的封闭盒子中,视线就被「捕获」了,它将不断在盒子的墙壁上反弹,无限进行下去…多么可怕的陷阱。

正因如此,我们需要设置一个「递归深度」。

在这里插入图片描述

 
 
 
 
 
 

参考文献

[1] 《Introduction to Ray Tracing: a Simple Method for Creating 3D Images》:https://www.scratchapixel.com/lessons/3d-basic-rendering/introduction-to-ray-tracing/how-does-it-work
[2] 《JS玩转计算机图形学》Milo Yip:https://www.cnblogs.com/miloyip/archive/2010/03/29/1698953.html

  • 21
    点赞
  • 43
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
射线追踪法是一种计算机图形学中常用的算法,用于生成逼真的图像。直达波ray tracing 试射法是射线追踪算法的一种应用,可以模拟光线直接从光源射到物体表面上的效果。以下是一个简单的C源代码示例: ```c #include <stdio.h> typedef struct { float x; float y; float z; } Vector3; typedef struct { Vector3 origin; Vector3 direction; } Ray; typedef struct { Vector3 position; Vector3 normal; } Plane; int intersectRayPlane(Ray ray, Plane plane, float* t) { float denominator = plane.normal.x * ray.direction.x + plane.normal.y * ray.direction.y + plane.normal.z * ray.direction.z; if (denominator == 0) { return 0; } float numerator = plane.normal.x * (plane.position.x - ray.origin.x) + plane.normal.y * (plane.position.y - ray.origin.y) + plane.normal.z * (plane.position.z - ray.origin.z); *t = numerator / denominator; if (*t >= 0) { return 1; } return 0; } int main() { Ray ray; ray.origin.x = 0; ray.origin.y = 0; ray.origin.z = 0; ray.direction.x = 1; ray.direction.y = 0; ray.direction.z = 0; Plane plane; plane.position.x = 5; plane.position.y = 0; plane.position.z = 0; plane.normal.x = -1; plane.normal.y = 0; plane.normal.z = 0; float t; if (intersectRayPlane(ray, plane, &t)) { printf("The ray intersects the plane at t = %f\n", t); } else { printf("The ray does not intersect the plane\n"); } return 0; } ``` 以上代码示例定义了一个包含原点和方向的射线结构体`Ray`以及一个包含位置和法向量的平面结构体`Plane`。通过调用`intersectRayPlane`函数,可以判断射线和平面是否相交并计算相交点的参数`t`值。在示例中,射线的原点在坐标原点,方向为X轴正方向,平面的位置在X轴上5个单位处,法向量为X轴负方向。输出结果将显示射线是否与平面相交以及相交点的参数`t`值。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值