人眼与相机
计算机图形渲染的最本质原理,是对人眼成像的模拟。
若把人视作造物主之创造,那这个被称作人体的仪器,其精密度显然处于鬼斧神工之巅。其中主管视觉的眼睛,更是神秘之所在。
人类视神经的纤维数约为 770,000 到 1,700,000 ,也正是与每个视网膜连接的神经节细胞的轴突数。在视觉敏感的视网膜中央凹,每个视网膜神经节细胞联系少至五个感光细胞。在视网膜的其他区域,这个数目上可至几千。——维基百科
人类视觉形成的完整细节,在生物学上仍有许多尚未揭开的神秘面纱。但是我们都知道的是,这一切都是因为有光。有了光打在物体上,经过吸收、折射、反射进入我们的眼睛,我们才能看到东西。没有光,就没有视觉。
在此我们先抛开复杂的神经元,仅探讨光学成像方面的一些基础原理,因而先从最简单的小孔成像开始,之后是与人眼较为相近的透镜相机,最后再类比到人眼。
小孔相机
小孔成像的原理简单到可以用两个道理来概括:1. 光沿直线传播;2. 两个点确定一条直线。
可以看到,小孔限定了光线传播方向,使透过它的光线投在了对称位置的光屏上,因而得到一个倒置的蜡烛像。
❓多小算小,为什么小孔放大后,画面就模糊了?
大家可否产生过这样的迷思:所谓小孔成像,大和小是相对的,多小算是小孔,多大算是大?
仅从粒子性的角度考虑的话,恰好能通过一个光子的小孔似乎是最理想的。它为我们的第二个原理(两点确定一条直线)提供了最完美的条件。其缺陷在于,孔小,进光量也小,如果我们的光屏是一块感光元件(能够与光子发生化学反应),则需要经过很长时间,接收到足够多的光子才能产生影像。
然而爱因斯坦告诉我们,光具有波粒二象性。大家都知道大名鼎鼎的光的衍射现象(最早由意大利物理学家朗西斯科·格里马第(1618-1663)发现)。由于波动性的存在,当孔径小于光的波长时,会发生非常明显的衍射现象,破坏了小孔成像的第一个原理(光沿直线传播),光屏上出现一圈圈的衍射波纹。
因而,孔径过小也是不行的。经验告诉我们,对于一个鞋盒大小的小孔相机来说,2mm 的孔径可以使我们得到一个较为清晰的影像。
孔径再大些,画面怎么就模糊了?前面说过,小孔是两点确定一条直线中的第二个点。如果这个点变大,变成一个圆了,那么透过它的就不是一条直光线,而是一束光线圆锥了。我们看下面的对比图:
如上是一个理想的小孔,物体某一点处的光线经过小孔后,落在光屏某一确定的位置。
如上孔径明显增大,小孔变为圆洞,可见,物体某处的光线可经过圆洞的不同位置进入黑箱内,呈一定的发散状,光屏上的影像则变得模糊。
小孔相机的原理十分简单,它通过小孔限制了光线的传播路径,使物体与投影在对称的位置一一对应。而这种结构也限制了它的进光量,使其在感光元件上形成影像所需的时间很长,因此通过该结构来制作相机是不合适的。真正的相机使用了凸透镜结构,其在光线聚焦的同时,依然保证了足够的进光量。
透镜相机
相比于小孔成像,透镜相机的关键在于凸透镜的应用。而凸透镜成像的基本原理,可见于中小学课本,大家对于下图都不陌生:
图中 F 点是透镜的焦距点,P 点是透镜的两倍焦距(2F)点。
情景一:蜡烛放置于距离透镜距离大于 2F 处,此时得到小于原物的倒像。
情景二:物体靠近透镜,放置于 2F 处,得到与原物等大的倒像。
情景三:物体继续靠近透镜,处于 2F 与 F 之间时,得到大于原物的倒像。
情景四:继续靠近,处于 F 点时,光线平行无法聚焦,无法成像。
情境四:继续靠近,小于 F 时,光线发散。在透镜右侧,光线无法聚焦,而在其逆向的延长线上,我们可以找到聚焦点。此时,我们得到了一个放大的正立虚像。(在此之前,我们得到的一直都是实像。)
照相机的应用属于情景一:得到一个缩小的倒像。
人眼
人眼结构的光学成像原理与上面所述的凸透镜成像是完全相通的,晶状体对应于透镜,视网膜对对应于光屏。光线经过晶状体的折射聚拢,在视网膜上形成缩小的倒像。倒像经过视神经传递到大脑后,被转换成我们我们所感知到的正立影像。
人眼是可变焦的,晶状体周围的环形肌可对晶状体的曲率进行调节,使焦距在大约 18 - 22 mm 的范围内变动。
人眼是可变光圈的,人的瞳孔可根据光线亮度自动调节,其直径在约 2 - 8 mm 的范围内变动。
❓ 小小迷思:虚像是如何被人眼看到的?前面我们讲透镜时提到了实像和虚像,实像是可以在光屏上成像的,而虚像由于无法聚焦,不能成像。但是这个虚像是可以被我们的眼睛看见的,最典型的就是放大镜。如图,我们透过放大镜,看到的就是正立放大的虚像。那么,这是怎么回事,虚像的这束光不是发散而不聚焦的吗,它是怎么在我们的视网膜中成像的?这里其实有一个思维误区,在蜡烛通过透镜在光屏上得到实像的场景中,我们自然地将透镜代入为我们的晶状体,将光屏代入为我们的视网膜,这是相当形象的。而虚像场景中,我们出于惯性思维继续将透镜代入为晶状体,并仍想着将我们的视网膜放在透镜后面,期望得到影像。沿着这个思路,由于光线是发散的,因此无法在我们的视网膜上聚焦,自然也无法形成清晰的图像。然而,以上完全是惯性思维带来的错误。当我们用放大镜观察物体时,透镜并不是我们的晶状体,光屏也不是我们的视网膜。透镜在我们手中,而晶状体和视网膜依然健在于眼眶中。因而实际的光路是这样的:物体 → 透镜 → 晶状体 → 视网膜。从图中可以清晰地看出,发散后的光经过我们晶状体的再次聚拢,在视网膜上汇聚成像。实际上生活中大部分的反射光都是发散的(漫反射),而透镜在此处造成的发散,使得其逆向聚焦虚像变得更大,试想一下,我们把图中的蜜蜂和透镜都拿掉,假设在虚像处确实有一只如此大的蜜蜂,其最终进入到我们眼中的光线,和图中虚像进入我们眼中的光线是一样的。
3D 渲染技术
计算机科学大部分时候是对现实世界的模拟和抽象,3D 图形技术更是如此,它本质上是模拟了人眼的工作原理,更进一步来说是模拟了相机的工作原理,再进一步来说,模拟的是小孔成像(对此,我们后面会进一步说明)。
要记住的是,我们的世界是连续的(宏观层面来说是如此),而计算机的世界是离散的(无论是二进制的存储单元,还是屏幕上一个个的像素点)。
另外,我们的世界是三维的,而眼睛看到的其实是二维,对于显示器平面来说更是如此。具体来说,我们眼睛看到的是3D世界的光线在眼中形成的二维投影,双眼接收到的不同角度的投影经过大脑合成为具有景深感的画面。更准确来说,我们的视网膜是一个凹面,因而接收到的实际上也是一个凹面投影。就这一点来说,凹面的显示器是更适合人类观感的(想一想迪士尼等乐园中的球形巨幕),只不过出于成本和空间占用等原因,我们常用的显示器都是平面图矩形(市场上也有一些小众的曲面显示器)。对于计算机 3D 渲染来说,我们最终呈现的是由像素点构成的点阵画面,其本质也是计算机中的3D数据经过计算后投射在显示器平面上的投影。
两个关键问题
综合上面所述的内容,我们可以总结计算机 3D 渲染的工作内容为:将 3D 物体投影到 2D 平面上,哦对了,是有颜色的。那么就需要解决如下两个关键问题:
- 可见性(Visibility)
这个点能否被看见,能看见的话,这个点应该出现在 2D 画面的哪个位置。
- 着色(Shading)
这个点应该是什么颜色。
解决可见性问题的常见方法有两个:光线追踪(Ray-tracing)和光栅化(Rasterization)。此外,光线追踪的技术原理使得其非常天然地同时解决了着色问题。而光栅化技术并不能完美地实现着色,尤其是被非直接光源所照射的物体。在现代的光栅化渲染技术中,往往通过引入其他算法来计算非直接光源照射的着色。
光线追踪与光栅化
如何迅速地描述光线追踪与光栅化各自的特点呢?
有人会说:二者的计算方向是相反的,光栅化是将物体上的点投影到画布上,而光线追踪是顺着画布上的点去找场景中的物体。
有人会说:二者的遍历顺序不一样,光栅化是先遍历物体再遍历画布,而光线追踪是先遍历画布上的点再遍历物体。
以上说法都对,但光线追踪与光栅化的本质区别,在于解决问题的思维方式不同。可以说,光线追踪是最符合人类认知(或者说最符合物理学家认知?)的一种 3D 渲染技术。前面我们说,人之所以能看见东西,是因为有光。而光线追踪技术的本质,就是尽可能地去模拟光线在传播过程中的情况。
如上图所示,我们之所以能看见这个绿色的气球,是因为光线照射在球面上,发生了吸收、折射和反射。太阳光是白色的,当它照射在球面上的时候,由于球面材料的性质,其吸收掉了红色光,因而剩下的光构成了红色光的补色——绿色。剩下的绿光又分成两路,一部分透过气球发生了折射,另一部分发生反射进入了我们的眼睛。因而,之前我们说光线追踪在解决可见性问题的同时,也非常自然地解决了着色问题,因为其核心思路就是对上述的光线传播过程进行模拟。另外需要需要注意的是,照射在气球表面的不一定是来自太阳的白光,而有可能是其他物体所反射的带颜色的光。同样的,在球面上发生折射的光也可能在经过其他表面的反射后再次进入我们的眼睛。最后,对于非镜面反射的物体来说,光往往是朝各个方向反射的,其中只有一部分会进入我们的眼睛。
可以体会,光线传播的基本原理是很简单的,但是要完全模拟所有光线的传播情况,其计算量非常巨大。有两个显而易见的优化方法是:1. 限定反射光的计算量,当一束光经过多次反射后,忽略其作用。2. 由于只有进入眼睛的光能被看见,而光路是可逆的,其所有传播特性都可以实现逆运算,因此我们从眼睛的方向出发,逆向计算进入眼睛的光线经过了怎样的传播路径。
可见,顺着眼睛寻找场景中的物体,并不是光线追踪技术的根本属性,而只是一种计算优化方法。
由于其巨大的计算开销,在计算机性能尚未如今般强大时,光线追踪技术的应用一直收到限制,仅在某些非实时渲染的领域得到应用,如动画制作、电影后期、图片渲染等。而与之对应的,另一项渲染技术——光栅化渲染,由于其高效且易于管线化的计算方式,则得到了更为广泛的应用,大部分的 GPU 都采用了基于光栅化技术的管线化计算设计。
光栅渲染
相较于光线追踪的物理化思维,光栅化渲染更接近数学。
光栅化英文名为 Rasterization,其词根 Raster 的中文翻译即光栅,基本可以理解为显示器中由横竖排列的像素点所构成的栅格。因而,光栅化渲染是一种表征其目的的命名:将 3D 物体转化成栅格化的图像。
光栅化渲染的主要步骤包括:透视投影、栅格化、解决可见行问题。
透视投影
投影可以分为两种类型,正交投影和透视投影。在我们的生活经验中,同样的物体,离我们近就越大,离我们远就越小,这是因为我们的眼睛接受到的是物体的透视投影。假使我们的眼睛并非聚光成像,而是平行光成像,则我们的眼睛接收到的是平行光投影,届时我们的视野只有瞳孔那么大,且眼中的物体不会因为远近而发生大小变化。
我们在 3D 渲染当中,自然也有正交和透视两种渲染方式。下图是游戏模拟城市中的画面,其使用了正交投影的渲染方式,可以看到,其中的建筑并没有遵循近大远小的透视规律。
当然,3D 渲染的一个重要目标是创造出令人难辨真伪的画面,而正交投影的渲染的渲染方式显然是不符合人类的视觉经验的,我们着重讲解的是模拟人类视觉的透视投影。
如上图,eye 便是我们眼睛的位置,而 canvas,我们暂且就将其理解为显示器平面,假设在显示器平面之后,有一个立方体存在,要在显示器平面上画出怎样一副 Image,才会让我们的眼睛相信这个假设呢?
仔细观察,我们所看到的视觉光是从物体开始朝着我们眼睛的方向直线传播的,直线传播过程中与显示器平面相交的点,就是其在显示器上的投影。因而透视投影过程就被转化成了一个非常简单的几何问题。
我们从显示器的侧面来观察这个问题,A 点是我们的眼睛,B'C' 所在的是我们的显示器平面,为了方便计算,我们定义眼睛到显示器平面的距离为 1 个单位。同时,为了存储和计算,我们需要定义坐标系:我们定义眼睛为坐标原点,从屏幕向眼睛的方向定义为 Z 轴,垂直向上的为 Y 轴,X 轴与 YZ 构成的平面垂直,在当前视角中,我们暂时忽略对它的计算。
现在,我们要将 BC 两点投影到显示器平面上来。B 点与视线平齐,其 Y 坐标为 0,投影到显示平面上后, Y 坐标仍然为 0。C 点的 Y 坐标我们定义为 C.y,投影到显示平面后,点 C' 的 Y 坐标 C'.y 应该是多少呢?显然,这是一个近似三角形的问题。
三角形 ABC 与 AB'C' 是近似三角形,因此我们有
又,AB' = 1,我们有
而 AB 的值就是 B 点 Z 坐标的负数,C 点与 B 点 Z 坐标相同,因此也是 C 点 Z 坐标的负数。因此我们有
我们以同样的方法来计算其投影后的 x 坐标
就是这么简单,只需要经过简单的除法运算,我们就将三维场景中的点透视投影到了二维平面。另外,二维平面中的点是没有 Z 坐标的,那么我们如何处理原来三维场景中点的 Z 坐标值呢,直接将其舍弃吗?不,我们将其原封不动的保存下来,这个坐标值将在后续处理多个物体之间的遮挡和可见性的时候发挥作用。
栅格化
好,现在我们有一个三维空间中三角形的 3 个顶点都已经投影到显示器平面了。为什么拿三角形来说事呢,因为所有由离散点构成的几何体都可以分解为若干个平面,而三角形则是最小的平面单元。毕竟三角形由三个不在一条直线上的点构成,两个点可以确定一条直线,直线加上一个不在该线上的点则可以确定一个平面。
我们根据投影到平面上的三个点的位置,可以确定三角形的三条边。假设我们的三角形是绿色的,现在我们需要给它涂上色。(我们怎么得知这个三角形投影到显示器平面后仍然是绿色的呢?实际上,仅从光栅渲染的技术层面是无法得知的,毕竟我们做的投影是一个纯位置计算的过程。因此,如我们之前所说,真正的光栅化渲染的应用中,实际需要引入其他的算法来计算非直接光照等因素对物体表面颜色的影响,从而得到逼真的画面效果。)
我们先忽略光路对颜色的复杂影响,暂且将它涂成绿色。时刻记住,我们的显示器图像是由离散像素点构成的数字图像。我们的涂色过程,所要做的,是找出该三角形所包围的像素点,将其置为绿色。那么,我们如何确定一个像素点是否被某三角形包围呢?
幸好,有一个叫 Juan Pineda 的科学家 1988 年在其发表的论文 A Parallel Algorithm for Polygon Rasterization 中提出了一个边缘函数(edge function) 可以非常方便的帮我们解决这个问题。其原理倒也并不复杂。
图中有一个三角形 v0v1v2,现在我聚焦于其中一条边 v0v1(我们定义 v0 -> v1 为它的方向),可以看到,v0v1 所在的直线将平面分成了两半(灰色 - left side,白色 - right side)。现在我们有一个点 P(x,y),将边和点输入之后,边缘函数将返回一个正值,表示点在边的右侧,或一个负值,表示点在边的左侧。
那么,这跟三角形又有什么关系呢?
通过简单的观察我们可以发现,如果我们顺时针来定义边方向的话,会发现,如果一个点同时处在三条边的右侧,那么这个点必然处在三边所构成的三角形内的。(试想我们围着一个花园顺时针散步一圈,花园中的花自然始终是在我们右侧的。)这里的正负是相对的,如果我们逆时针定义边的方向的话,则三角形内点的边缘计算的值应当是负值。
这个边缘函数长什么样呢?也非常简单:
根据所得的值
- E(P) > 0
点在线的右侧
- E(P) < 0
点在线的左侧
- E(P) = 0
点在线上
这个边缘函数可以用矩阵来表达
我们把 P - V0 定义为向量 A,把 V1 - V0 定义为向量 B
则矩阵变为
可见,我们把整个运算转变成了 A B 两个向量的叉乘。
空间中的两个三维向量叉乘意味着什么呢?——这也让我们从另一个角度来理解边缘函数。
空间中的两个三维向量叉乘,会得到第三个三维向量,这个向量会与前两个向量构成的平面垂直。可以看到这个向量的方向和大小会根据前两个向量的夹角关系发生变化。(实际上它的大小就等于前两个向量所构成的平行四边形的面积。)
而 A 与 B 的夹角关系,也就表征了 B 是在 A 的左侧还是右侧。
以上,我们介绍了边缘函数如何使用,以及其背后原理的理解。截至目前为止,我们举例的场景中,都只出现一个三角形,那么如果是多个三角形场景下,互相之间存在遮挡时,我们如何确定谁是可见的呢?
可见性
问题的答案非常简单,离我最近的不会被遮挡。
还记得我们之前投影的时候,原封不动地保存了点的 Z 坐标吗?现在可以发挥作用了。
我们创建一个与画布等大的二维数组名为 z-buffer,用来存储各个位置当前可见投影点的 Z 坐标。刚开始的时候,我们还没有投影任何一个点,z-buffer 中的元素初始化为无穷大。之后,我们在投影的过程中,比较当前投影点的 Z 坐标,如果比 z-buffer 中的更小,则将当前点投影至平面,并更新对应 z-buffer 中 Z 值为当前点的值。(注意,由于 Z 坐标的方向是从物体到眼睛的方向,因此物体的 Z 坐标实际都是负值。而我们为叙述方便,此段中所述的 Z 坐标值是指其绝对值。)
如上所述的算法被称作 z-buffer 算法。用来解决可见性的算法当然还有,但大部分分为两类,一类在遍历物体的时候做文章(如 painter 算法),一类在点投影到画布上之后再做文章。我们的 z-buffer 算法就属于后者。关于更多的可见性算法,我们不在此赘述。
归一化
现在我们来考虑一个问题,现在大家用的显示器有不同的规格,有 1920*1080 的,有 2560x1440 的,那么全屏显示同一张图片的时候,前者横向有 1920 个像素,后者则有 2560 个像素。那么,我们 3D 渲染的时候应该怎么办呢,针对不同的显示器都要做一遍投影和渲染计算吗?没有必要,我们引入归一化的方法,对投影结果进行归一化后,我们就可以非常方便地将其计算并应用到不同的分辨率图像上了。
在我们进行投影的时候,我们使用了这样一个投影平面,平面的正中心与我们的水平视线对齐。设投影平面为宽度为 2 个单位的正方形,定义与视线正对的平面中心为坐标原点,则平面左上角的坐标为 (-1, 1) 右下角的坐标为 (1, -1)。
在将点投影到这个平面上之后,我们接下来做一个归一化的操作。
所谓归一化,就是将坐标体系由原来的范围映射到 [0, 1] 的范围,映射后的坐标系,我们称之为归一化坐标系,又称 NDC 坐标系(Normalized Device Coordinate)。
映射过程也是一个简单的数学运算,我们设映射前投影平面的宽度为 canvasWidth,高度为 canvasHeight,则 p 点在映射后,其在 NDC 坐标系中的坐标为:
pNDC.x = (pScreen.x + canvasWidth / 2) / canvasWidth;pNDC.y = (pScreen.y + canvasHeight / 2) / canvasHeight;
归一化之后,我们要将图像绘制为不同的分辨率就很简单了。有一点要注意,绘制平面的坐标系与 NDC 有所区别,NDC 坐标系的原点在左下角,Y 轴朝上,而绘制平面坐标系的原点在左上角,Y 轴朝下。
我们进行如下计算
注意,我们对计算出来的坐标结果进行了取模。这是因为我们之前所说的,显示器上的数字图像是由离散的像素点构成,因此我们必须通过取模来定位到具体的像素点。
这里还有一个小问题, 由于取模是向下的,会导致 x 坐标的计算整体偏左移,比如说最右边那个像素点很难被算到,因此我们将 x 坐标整体加 1 后再取模。而 y 坐标由于已经进行了取反和加 1 操作,因此并不存在这个问题。修改之后,我们的公式变成如下
重心坐标与顶点着色
待续。