CG基础与光学基础
▍问自己一句,3D场景为何可以被绘制到2D的画布/屏幕上?
emmmmm…没有那么复杂,这几乎是一个纯几何的过程:
透视投影。将三维物体的特征点与眼睛连接成一条线,这条线会穿过画布(Canvas)留下一个交点,无数个这样的“连线”与画布的交点,组成了三维物体在二维平面的投影(如下图>_<)绘制颜色。好了,我们已经获得了三维物体在二维平面上投影的位置信息;接下来,这些“连线”还会将三维物体上的颜色/亮度信息(亮度归根结底还是要转化为颜色),带回到二维平面上来,形成最终的图像!
▍补充一些物理中的光学常识吧!
我们如何看到物体?光由光子组成,一束光子照射到物体上,发生碰撞,其中的一部分光子被碰撞弹开后,恰好击中了我们的眼睛——眼睛感受到这些光信号,紧接着转换为电信号,交给大脑进行复杂的处理后最终成像。物体为何具有颜色?光具有波粒二象性,颜色的本质是波长。再从粒子性的角度来看,以白光为例——白光由红、绿、蓝三种光子组成,当白光照射到红色的物体上,蓝色绿色的光子被物体吸收掉,只剩下红色光子反射到我们的眼睛中。光子撞击到一个物体时,可能且只能发生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),如果该线没被遮挡,则用来确定接收光的多少;如果被遮挡了,则说明命中点处于阴影中
总而言之,通过这种“逆向思维”,避免了大量的算力浪费。
上面我偷偷做了几个假设,可能你理所当然的接受了。倒也没关系,因为这都是物理领域的范畴,可以跳过。
Q:光子与物体表面碰撞后,为什么是“随机向各个方向”反射?
A:想一想,真的存在绝对光滑的物体平面吗?没有,那是理想状态的全反射模型。自然界中绝大多数的物体都是漫反射。Q:我们的眼睛好像也不算小,真的几百万个光子才能接收一个吗?
A:真实的眼睛不是我们所假设的“点接收器”,而是“面接收器”,能接收的光子是比假设中要多的。但是当透镜半径很小时,接收的光线的确只能来自一个方向。
算法实现
基本原理正是上面讲过的自然光子模拟和后向追踪过程(⭐️⭐️⭐️):
连接一个像素中心和眼睛,做这个连线的反向延长线——从而得到主射线(primary ray),即“第一条射线”,并将其射到场景中。若未命中,则说明该物体的该点压根“不可见”;若命中,从命中位置向光源投掷出阴影线(shadow rays),即“第二条射线”
若阴影线未被遮挡,则这个命中点被照亮,则返回颜色×光强;若被其他物体遮挡,则命中点处于阴影中
遍历画板的每一个像素,三维场景就被绘制为了二维图像 >_<
>_< 算法的伪代码如下:
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;}}}// 如果交点处于阴影中,则接着计算颜色*亮度;否则像素点置0if (!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》:/lessons/3d-basic-rendering/introduction-to-ray-tracing/how-does-it-work
[2] 《JS玩转计算机图形学》Milo Yip:/miloyip/archive//03/29/1698953.html