从零开始实现3D软光栅渲染器 (5-1) 3D渲染流水线(上)

什么是渲染流水线

把大象放冰箱,需要几步?

  1. 打开冰箱
  2. 放入大象
  3. 关上冰箱

再放一个呢?

  1. 打开冰箱
  2. 放入大象
  3. 关上冰箱

这就是流水线。

渲染流水线(装逼的就叫渲染管线),其实解决了2个问题:

把3D物体显示到2D屏幕,

  1. 3D空间的物体显示在屏幕上什么地方?
  2. 物体该显示成什么样子?玻璃应该显示成透明的吧?放在玻璃后面的物体应该能看到吧?水泥地面,草地,墙面看起来应该不一样吧?总之,你显示的东西要符合我们对世界的认知!

既然是流水线,那它的流程就相对固定。早期的OpenGL使用的是固定渲染管线,也就是说,它完全规定了物体渲染的若干个阶段,这样程序员就省事了,程序员只要调用相应的API给特定的渲染阶段设置参数即可。但这样的缺点也很明显,就是不灵活呀。就像以前的直板机(现在的老人机),用起来很方便,反应速度快,但是你很难自己写App啊,一般出厂的时候,手机中的程序都固化在电路板上了,用户没办法自己扩展啊。但是,如今的Android系统,IOS系统,都提供了可编程的开发接口给用户开发App,以增强手机的功能。OpenGL也是这样,不知道在哪个版本之后,就开始提供可编程渲染管线,让用户可以自定义渲染的若干个阶段,这样做的好处就是,很灵活,用户可以定制自己的需求,来实现固定渲染管线难以实现的效果,但是缺点就是对程序员的要求较高,因为你要明白它是怎么玩的你才能定制呀。在OpenGL3.0之后,就完全摒弃了固定渲染管线,全面支持可编程渲染管线。既然前者已经成为历史的遗物,而作为新时代的社会主义接班人就没必要再趟这趟混水了。就比如你历经千辛万古学会了屠龙之术,才发现这世上本没有龙,这是何等的悲哀。我们直接学习可编程流水线吧,因为旧的我也不会。

可编程渲染管线

下面是可编程渲染管线包含的几个阶段。

其中蓝色框表示的阶段,就是程序员可以定制的阶段。

着色器(Shading Language),这玩意儿怎么说呢? OpenGL中用的叫 GLSL,就是一小段程序代码,在GPU上执行(因为GPU强大的并行计算能力,可同时运行多个着色器程序代码,进而提供图形绘制的效率)。在可编程渲染管线中程序员可定制的阶段就是在这些着色器中实现的。这里我们简单对这些概念有些了解即可,我们现在需要学习的渲染管线的各个阶段到底干了个啥,至于如何写这些着色器程序,在学习OpenGL的时候会接触到,其实也不是很难。现在大家知道有这些个阶段就可以了。

顶点着色器中完成了从3D空间到二维屏幕的变换,也就是解决了我们前面说的第一个问题(三维空间的物体如何变换显示到屏幕上)。这也是我们本节介绍的,第二个问题,我们后面再说。这个远比第一个问题复杂得多。

顺便说下,看到图中光栅化的灰色框框没有,这里的光栅化就类似于前面几节我们自己写的光栅化点、直线,但是这里的框框是灰色的,也就是你们程序员不能自己定制,这些算法由于过于成熟,都不配由程序员们来自己实现,都由显卡厂商集成到显卡中了。是的,你不配实现这些算法。啊???那前面不是白学了?对!从某种意义上,是的。但是,仁者见仁智者见智,做人不要太功利嘛,兄dei.

好了,开始进入正题。

将3D空间的点变换到屏幕空间,需要经历以下阶段:物体空间,世界空间,相机空间(观察空间),裁剪空间,屏幕空间。不同的书上叫法不一样,请知晓。

是不是有些晕?为什么搞这么多“空间“,为什么需要经过这么多步转化,一步到位不行吗?当然可以。但是,方便的东西一般灵活性都差,就像前面说的固定管线一样,虽然很方便,但是它不灵活呀,你无法方便地定制自己的需求去实现一些特殊的效果。比如,后面说到的顶点着色,有在世界坐标系中计算顶点颜色的,有在观察坐标系中计算顶点颜色的,两者的效果是不一样的。有人会说,当然选择效果好的呀。但是效果好的需要好的显卡呀,不是每个应用情景都能够使用好的渲染效果的,比如手机的性能肯定不如PC。从某方面说,图形学就是在效果和性能上的博弈。讲这么多废话,就是想说大家学习知识的时候要多想为什么,死读书读死书,有的时候行,有的时候它不灵呀。

下面我们挨个介绍每个坐标空间、涉及的变换,及其推导过程。

对象空间

又叫本地空间、物体空间、局部空间,还有我可能也没听过的什么空间,反正都是指一个东西。

在图形学中,大家把坐标空间理解成对应的坐标系即可,不同的坐标空间,就是指不同的坐标系。对象空间对应的坐标系就是本地坐标系(局部坐标系?对象坐标系?不管叫啥,你知道就行)。

一般游戏场景中都有很多不同的模型(场景模型、人物模型、怪物模型、特效等),这些一般都不是一个人做的,也不一定是一个团队做的。很多独立游戏工作室,像这些美术资源都是外包出去的。

大家都知道,3D建模的时候,你总得需要设置你建立的模型的原点以及模型的尺寸吧。不同人建模的习惯是不一样的,除非你明确给人家提建模的规范,否则人家都是按照自己的建模习惯来。像下面blender中,默认的原点在物体的中心,有的人建模的时候,习惯将人物放在人的腰部,有的人习惯放在两脚之间。此外,不同的人使用的建模尺寸(单位)也是不同的,有人使用米,有人使用厘米。这些数据都是在本地坐标系中刻画的。举个例子,两个不同的工作室给你2个人物模型A和B,同样是(100,800)这个点,A模型可能落在模型的嘴巴上,B模型可能落在模型的额头上。我想,大家都懂我的意思了吧。

那么针对不同的模型我们怎么统一操作呢?比如上面的王者地图,我们怎么把不同的模型放到合适的位置,且大小合适呢?这就需要将物体从本地坐标系转换到世界坐标系。

世界空间

世界坐标系就是描述你要构建的场景中的所有东西的。有的书上说它是唯一的、固定不动的坐标系。但是我觉得吧,这是相对的,比如你做一个发生在教室里的游戏,整个游戏的场景就是在教室里,那你的世界坐标系可能就是描述整个教室的场景,如果你做一个宇宙大冒险的游戏,那你的世界坐标系可能就是描述整个宇宙空间。我想大家应该懂这个意思了吧。

那么如何将物体从局部坐标系变换到世界坐标系呢?我想,这个就太简单了吧。这利用我们上一节说的基本变换矩阵不就可以了吗?平移、缩放、旋转。总之就是要使用世界坐标系描述所有的物体,这样你才能统一管理所有的物体,也为了后面方便地对这些物体进行操作。至于平移到哪?旋转多少?那当然根据你自己的需求。送分题!!!

观察空间

这个空间很有意思。顾名思义,观察空间当然是提供给人查看场景的。因为世界那么大,显示器也就那么大,你不可能一下子渲染所有的场景给观察者。所以,观察空间就是决定给用户呈现哪些场景。

大家把你的应用程序窗口想象成一个相框,《清明上河图》大家都听说过吧,就是那个很长的一幅画。

那么如何在显示器这个有限大小的相框中查看这么长的一幅图呢?

两种方法:

  1. 把相框怼到图上,扯下面的图,那么你在相框中就能看到不同的内容。
  2. 把图固定住,在图上滑动相框,那么你也能在相框中看到不同的内容。

大家说,哪种方法方便,有人说,肯定第一种方法方便啊,扯下面的图,我人就不用拿着相框动了呀;有人说,肯定第二种方法啊,相框就相当于一个相机,我对准哪里就显示哪里呀,多方便呀。嗯,都有道理。懒,是科技创新的源泉。

我们先来看第二种方法,把图固定住,移动相框(这里也就是你的应用程序窗口)。大家想一个问题啊,我们玩游戏的时候是不是都是在窗口中操作的,假设我们王者的地图是在世界坐标系中定位的,而世界坐标系的默认原点在屏幕的中心(实际上也确实这么干的,因为后续计算比较方便),如果我们通过移动窗口来观察王者地图的各个部分,那么你的应用程序窗口中心位置还是世界坐标系的原点吗?是不是就不是了?有人会说,不是又怎样?emmm…怎么说呢,一般我们就是默认把世界坐标系的原点放在窗口的正中心(后面介绍视口变换的时候会提到),大家都是这么干的。而且,世界坐标系之所以叫“世界”,它的原点如果在窗口中的位置是变化的,叫它“世界”坐标系是不是也不太贴切?

好了。那只能选第一种咯。是的。不过选第一种的别高兴太早。相框不动,扯底下的图意味着我们需要对场景中的每个物体挨个进行变换,那么你怎么描述这个变换呢?你对着场景中的每个物体说,唉,左一点?不对,过了,回来一点。艾,上一点?有人说,笨啊,用矩阵啊,是的,基于上一节介绍的基本变换工具,我们可以构建任意复杂的变换。但是难道每次变换都需要我们自己手动构建平移矩阵、旋转矩阵、缩放矩阵吗?是不是有点麻烦了。

我们再想想,如果有个相机,我们只操作相机就能控制我们看到的画面,是不是挺方便的。换句话说,我们通过变换相机进而达到变换场景中物体的目的。第二种方法中滑动相框是不是就类似这个方法?但是我们刚才不是否定了第二种方法吗? 是呀,但是我们可以构建个虚拟的相机,我们假装移动窗口,实际上变换的是场景中的物体,这叫啥来着?隔山打牛?移花接木?反正就是那意思。虚拟相机的作用就是让我们去操作它,进而可以根据需求以不同的视角来观察场景。总之,世上本没有相机, 只是我们引入了“相机”的概念,创建了一个虚拟的相机来间接变换场景中的物体,使其变换到窗口中合适的位置以供观察。一句话总结,视图变换是变换场景中的物体。

一般我们可以构建两种类型的虚拟相机:

  • 欧拉相机
  • UVN相机

注意,这里的相机和Unity中给的第一人称相机,第三人称相机,轨道相机等属于不同层次上的相机。我们这里说的相机是实现层次上的,是对虚拟相机的定义;而像第三人称相机,是在应用层面构建的相机,是为了方便观察场景用的,它是基于欧拉相机或者UVN相机来构建的。

欧拉相机

欧拉相机,顾名思义就是以欧拉角定义的相机,或者说欧拉这个人首先提出的,不管怎么样,我们不要想得太复杂,一个名字而已。

欧拉相机的思想很简单,就是构建一个变换矩阵,来变换场景中的物体到合适的位置。看下面这个图,一般我们的世界坐标系(注意,相机是在世界坐标系中定义的)的原点默认放在屏幕(你的应用程序窗口)的中心(这个和后面的视口变换有关系,这都是潜规则,专家告诉你,放在窗口中心操作最方便,你最好这么干,这个后面再说)。

拍过照的同学都知道,你相机对着啥,你就能看到啥。图中,相机正对着照片,发挥你的现象力,我们应该看到的一幅正立的在窗口中心的照片。但是现在你的窗口中什么也看不到,因为照片在世界坐标系的第一象限且是旋转过的呀。那么我们如何才能看到相机中拍的效果呢?很简单啊,前面说了,我们通过变换相机来间接变换场景中的物体。

那我们需要构建这样一种变换:先转正相机(就是使相机的局部坐标系三个坐标轴和世界坐标系三个坐标轴平行),再把相机移到世界坐标系的原点。将这个变换施加到场景中的所有物体上,那么我们就能看到我们构建的相机变换的效果了。

但是明眼人一眼就看出来了,这个相机就是个幌子呀,这不就是对场景中所有的物体,都做了一个统一的变换嘛。对呀,前面不是说了嘛,这个就是虚拟相机呀,它就是为了方便对象场景中的物体进行变换,进而方便观察场景的呀。这个大家自己想想是不是这么回事,这个还是有点绕的。不要光记着我说的先这样,再那样,你们多想想为什么这么干,这么干的好处是什么,不这么干的话不方便的地方在哪里?多琢磨几次,你就知道了。

下面是构建这个欧拉相机的伪代码:

	// 这个Camera就是我们定义的欧拉相机,很简单,它有自己的位置,有自己的局部坐标系,它的旋转角就是相对自己的本地坐标系来定义的。
	public class Camera {
        private Vector3f position = new Vector3f(0, 0, 100);	// 在世界坐标系中的位置
        private float pitch = 20f; 	// 绕x轴转的角度 --> 俯仰角
        private float yaw;			// 绕y轴转的角度 --> 偏航角
        private float roll;			// 绕z轴转的角度 --> 翻滚角

        public Camera() {
        }
	}
	
	// 构建相机变换的矩阵
	public static Matrix4f createViewMatrix(Camera camera) {
		Matrix4f viewMatrix = new Matrix4f();
		viewMatrix.setIdentity();
		
		// ========== 1. 先旋转 ===========
		// 绕x轴变换
		Matrix4f.rotate((float) Math.toRadians(camera.getPitch()),
				new Vector3f(1, 0, 0), viewMatrix, viewMatrix);
		// 绕y轴变换
		Matrix4f.rotate((float) Math.toRadians(camera.getYaw()), new Vector3f(
				0, 1, 0), viewMatrix, viewMatrix);
		// 绕z轴变换
		Matrix4f.rotate((float) Math.toRadians(camera.getRoll()), new Vector3f(
				0, 0, 1), viewMatrix, viewMatrix);
		
		// ========== 2. 再平移 ===========
		// 平移相机到世界坐标系原点
		Vector3f cameraPos = camera.getPosition();
		Vector3f negativeCameraPos = new Vector3f(-cameraPos.x, -cameraPos.y,
				-cameraPos.z);
		Matrix4f.translate(negativeCameraPos, viewMatrix, viewMatrix);

		return viewMatrix;
	}

其实实现起来一点也不复杂,关键是初学的时候不太好理解。欧拉相机其实非常符合人的思考习惯,也最容易理解。

UVN相机

UVN相机和欧拉相机干的事情是一样的,只不过两者定义相机的方式不同。前面是利用欧拉角定义的相机,UVN相机是利用相机的朝向、向上的方向以及右方向定义的。

大多数3D编程接口(OpenGL辅助库,Three.js etc.)几乎都提供一个类似 glLookAt 的函数,其实这个定义的就是UVN相机的变换矩阵。

UVN相机的思想是给一个目标点,只要相机对着这个目标点,那就能看到这个物体了。接着再调整相机的姿态进而调整看到物体的姿态。有人会问什么是姿态?其实和上面欧拉相机做的变换差不多,一般介绍这个概念的书都会给一个飞机的模型,你把UVN相机看成飞机,飞机绕其自身坐标系的三个轴旋转就得到三种不同的姿态,绕x轴–俯仰,绕y轴–偏航,绕z轴–翻滚。

那么UVN相机构建的变换矩阵是什么样的呢?

说这个问题之前,我们先来回顾一下上一节介绍的空间变换的相关知识。

这是我们上一节介绍的旋转变换。

我们换个角度思考这个问题啊。假设A点坐标(10,15),绕原点逆时针旋转alpha之后,A点旋转到A’点。

问题来了。

A点你说是 (10,15),你是相对哪个坐标系来说的?很明显,是原始的坐标系(黑色的)。那么(10,15)这个坐标你是如何度量的?

看图可知,一个矩阵可以表示一个坐标空间,原始的坐标系就是一个单位矩阵。我们旋转物体,由于物体本身也有坐标系,其局部坐标系也会跟着旋转。所以,我们旋转物体有2种等价的描述:

  1. 原始坐标系不变,旋转物体到新的位置,新位置也是在原始坐标系中描述
  2. 与之等价的是,物体不动,对坐标系进行逆变换(反着方向旋转),在变换后的坐标系中描述物体。举个例子:你和你女友站在马路的两端,她不动你奔过去,和你不动,她朝着相反的方向朝你奔过去,其效果是一样的。

第二种也就是常说的坐标系变换。记住:运动是相对的。

那么如何描述新的坐标系呢? 前面也说了,矩阵可以构建一个坐标空间,那么单位矩阵(原始坐标系)顺时针旋转(和之前物体变换方法相反,逆变换)alpha角度后(新的坐标系)得到什么样的矩阵呢?

看看,得到的矩阵是不是和上一节推导的是一致的?再一次证明了,运动是相对的。我想我说明白了吧。

那么这个有什么用呢?那这个可就太有用了。大家想想,我们虚构的虚拟相机是干嘛的?是不是将世界坐标系转换到观察坐标系?既然是坐标系,那它就能用一个矩阵表示啊,因为前面说了矩阵能表示一个坐标系空间啊。那我们这里的UVN相机只要求出相机的局部坐标系不就能构建这样一个矩阵了吗?

首先,要构建UVN相机你需要给一个目标点 target,一个相机的向上的方向 up,还有相机的位置 position (再唠叨一下,相机是在世界坐标系中定义的,即它的位置是世界坐标系中的点)。

下面我们来求UVN相机的各个坐标轴对应的向量:

  • 相机的前向向量:v = (position-target)

  • v 叉乘 up 我们可以得到垂直于v和up向量的右向量,我们叫R向量,然后再次 v 叉乘 R 向量,重新计算UVN相机的向上方向向量,因为这个一开始我们默认给的向上向量一般是(0,1,0),即默认和世界坐标系Y方向一致,这可能不准确,所以需要重新计算一次。

  • 将 v,up以及R向量组合成变换矩阵

  • 最后别忘了和之前欧拉相机一样将UVN相机平移到世界坐标系的原点,即乘以一个平移矩阵。

我想,我说明白了吧。好了,由于这节太长了,后面的我们下节续上。

由于最近比较忙,这节内容断断续续写了很长时间,写的过程中删减了很多东西,尽可能写的简洁、通俗一点,让大家看得明白一点。大家有任何问题,欢迎给我留言。

欢迎大家关注我的公众号【OpenGL编程】,定期分享OpenGL相关的3D编程教程、算法、小项目。欢迎大家一起交流。

在这里插入图片描述

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值