第0节:
为何学习
为了更好地理解计算机图形学相关基础知识,更好地理解3D引擎的底层工作原理,从而更好地进行移动领域的3D应用开发。
需要说明的是:不需学习OpenGL-ES,也可以基于3D引擎进行3D应用开发。
什么是OpenGL-ES
OpenGL-ES是平台无关的广泛应用于移动设备的2D/3D图形API(除了微软系的智能手机一般都支持该API),抽象层次较低,一般作为3D引擎的渲染底层,较少直接用于应用开发。OpenGL-ES是OpenGL的一个分支版本,针对嵌入式系统做了精简,一方面便于支持相对简单的嵌入式设备,另一方面可以较少地承担OpenGL历史包袱之累。
版本
目前有三个版本:
a, 1.0 & 1.1
固定渲染管线,基于OpenGL 1.5精简,在可编程管线大行其道的今天,已显过时,不建议花费过多时间进行学习。GPU广泛支持。1.1完全兼容1.0. Android SDK支持。
b, 2.0
可编程渲染管线,基于OpenGL2.0精简,非统一架构,包含顶点shader和片元shader,不兼容1.X,目前几乎所有新出货的智能手机GPU都支持,属于目前的主流版本。Android SDK支持。
c, 3.0
最新版本api,2012年秋发布,可编程渲染管线,已有少量GPU支持。
如何学习
对完全没有基础的同学,建议按如下顺序进行:
1,了解3D图形处理的大体流程,即输入的各种数据(顶点、纹理等)是如何转变为屏幕上的图形的。这其中经过了哪些阶段,各个阶段大体上做了什么,有个总体的概念。
2,学习3D数学,至少需要理解这些基础之后再学习API:点和向量在坐标系(标架)中的表示、向量运算及其几何表示、点和向量的关系、矩阵运算、矩阵变换的原理和推导、齐次坐标。
3,学习OpenGL-ES API,学习某个开源的3D引擎,学习高抽象层次上的3D世界表述。尝试自己实现一些模块,特别是数学方面。
4,重复2和3,它们是相互促进的。
5,开发完整的应用,应该包含OpenGL ES的所有基本特性。
建议:数学基础非常重要,这是支撑的基础;总体观感(隐喻)非常重要,这是系统的全局拼图,让你更容易理解特定概念。
A,官网
http://www.khronos.org/opengles/
其文档主要分为几类:
specification: 标准(规格)说明书,描述了所有细节。
Online Reference Pages: 在线API文档,说明了所有API的用法。
Quick Reference Card: 快速参考卡,以图片的形式展现了总体框架。
对2.0和3.0还有GLESSL(GLES着色语言)的参考文档。
B, 数学
《3D数学基础:图形与游戏开发》:对初学者来说很棒的一本书,相对易于理解,不足之处在于从数学角度来说不够严谨和彻底,依然会有知其然而不知其所以然的地方。
《交互式计算机图形学 - 基于OpenGL的自顶向下方法》(第六版):计算机图形学入门导论,对前者是很好的数学和图形学基础方面的补充,对学习OpenGL也有足够的价值,第六版较新,算是国内少有的中文版的现代的图形学教程。不足之处在于其假设读者有了一定的线性代数、解析几何、微积分方面的知识,但总体来说也比较适合初学者。
C,API
我所了解的ES相关的书只有两本:《OpenGL ES 2.0 Programming Guide》和国人的《Android 3D 游戏开发技术宝典 - OpenGL ES 2.0》。
由于OpenGL和OpenGL ES的关系,也可以参考OpenGL的系列教材,包括红宝书、橙宝书、蓝宝书。
《3D游戏编程大师技巧》自己实现了一个软渲染引擎,虽然有些老,也值得一看,对数学和基础概念会有帮助。
建议结合官网文档进行阅读。
D,引擎
OGRE: C++写的开源3D渲染引擎,质量、资料、社区都比较完备。
JMONKEY 3: Java写的开源3D游戏引擎,支持ANDROID,资料、社区比较完备。
Unity3D: 商业引擎,非开源,上手容易,平台支持广泛,基于组件的现代设计思想值得参考。
E,社区
http://www.altdevblogaday.com/: 高质量的游戏开发博客集中地。
http://www.gamedev.net: 游戏开发的老牌名站。
http://www.opengpu.org/forum.php: 知名国内计算机图形学站点。
F, 更多书籍推荐
http://www.realtimerendering.com/books.html
a,模拟器:
http://www.klayge.org/2012/01/28/opengl-es-emulator%E5%86%8D%E6%AC%A1%E6%A8%AA%E5%90%91%E6%AF%94%E8%BE%83/
b,shader编辑器
PowerVR SDK。
ARM的作为Eclipse 插件提供的Mali GUP Shader Development Studio(http://malideveloper.arm.com/develop-for-mali/tools/mali-gpu-shader-development-studio/)
都提供了离线编译、语法高亮、错误提示、实时效果展示功能,后者很适合用于Android开发。
第一节:
思考一下画家绘画的过程,首先要有一名懂得各种绘画技艺的画家,然后他需要一张画布,一些笔,一些颜料,一些辅助工具(尺、圆规、模板、橡皮、调色板等等),然后他在画布上绘制第一幅画,完成之后展示给人们看;在人们观赏第一幅画的时候,他可以在第二张画布上绘制第二幅画,绘制完成后收回第一幅画,将第二幅画展现给人们看;接着使用工具擦除第一幅画,在同一张画布上绘制第三幅画;周而复始,人们便看到了一幅接一幅的画。
对比OpenGL ES,各要素的对应关系大体如下:
画家:我们
笔、颜料、辅助工具:OpenGL ES API
画布:???
画布是画面的载体,而计算机图形总是展现在屏幕的某个矩形范围内,这个矩形就是其载体。我们用像素作为基本单位来描述这个矩形的大小,像素是屏幕显示画面的最小单位,可以近似的认为是一个有特定颜色特定大小(面积)的点。256 * 128表明这个矩形的长为256像素,高为128像素,这也是屏幕分辨率的定义(屏幕可视为一个最大的显示矩形)。所谓绘制就是用不同颜色的像素填充矩形,矩形的本质是一块内存(一般位于GPU上,称为显存),填充像素的本质就是向这块内存写入数据,这些数据描述了每个像素应该是什么颜色。(大部分显示器上)计算机画面最终都是作为像素点阵呈现的。
屏幕上一个10*10的矩形,对应着一块10*10的显存,总共有10*10 = 100个像素,那么这块显存有多大呢?这要看每个像素有多大,假设一个像素占用2个字节,那么显示10*10矩形所需用到的显存就是10*10*2=200字节。 如果一个像素占用4个字节,那么显存大小就是10*10*4 = 400字节。
那么一个像素是如何描述某种特定的颜色的?有很多种方式,这里我们只谈最通用的RGB颜色模型。
人眼所看到的颜色本质上是光子撞击人眼产生,所有颜色都可以用RGB三原色(红、绿、蓝)按一定强度比例混合而成,显示器屏幕所展现的各种颜色,是所有像素的集合,而每个像素都会发出RGB三种颜色混合的光,只要通过某种特定方式控制这三种颜色的比例和光的强度,就达到了控制颜色显示的目的。
假设一个像素为两个字节,我们可以给这个像素任意一个值,比如0x3826,最终硬件驱动将会解析这个数值,将其转化为RGB的每个颜色分量的强度,按此激发屏幕像素,我们就看到了特定的一种颜色。因此这里需要有个约定,类似:0x1000代表100%的R,0%的G和B。这种约定称之为像素(颜色)格式(Pixel/Color Format)。
Android中的像素格式在 PixelFormat类中描述,包括RGBA_8888,RGB_565等。这里的RGB_565表示每个像素有5个二进制位(bit)来表示R, 6 bits表示G,5 bits表示B,每个像素占用5+6+5 = 16 bits = 2字节。数值越大,表明对应的颜色(光)分量越强。比如:0x0000(RGB_565)代表没有任何颜色,即黑色。
所以计算机绘画的本质就是选择一种像素格式,申请一块显存(画布),填充像素(颜色),绘制完成之后,通知计算机显示到屏幕上(按比例发射RGB光),最终就看到了所绘制的画面。之所以要先选择像素格式,是因为无论是所申请显存的大小,还是硬件驱动解析显存的方式,都是由像素格式决定的。
因此,在使用OpenGL ES绘制之前,所需要做的初始化工作就比较清晰了:
a, 选择像素格式。
b, 申请显存。
除此之外,还有一些初始化工作要做:
c, 选择显示设备(有些设备可能有不只一个显示器);
d, 选择某些特性,比如如果你打算画中国水墨画,你需要额外指定宣纸和毛笔。
e, 创建上下文(Context),上下文本质上是一组状态的集合,描述了在某个特定时刻系统的状态, 用于处理暂停、恢复、销毁、重建等情况;
f, 指定当前的环境为绘制环境;比如你可能有多个环境,每个环境有不同参数,你需要在这些环境中切换(想象一下同时在两个显示器上绘制不同的画面),所以必须指定当前的绘制环境。
完成上述所有工作之后,就可以开始使用OpenGL ES API进行绘制了(所有器具准备完毕,画家开始作画)。
由于像素格式、显示设备这些涉及到硬件,不同系统之间可能差异很大(比如有的系统不支持RGB_565),维护OpenGL ES 的khronos组织使用一个专门的抽象层(想起了一句名言 - 某种意义上的冷笑话:所有的计算机问题都可以通过添加一个抽象层来解决)来处理不同系统间的适配,从而保证了OpenGL ES本身的平台无关性,这个抽象层就是EGL.
权威资料见这里:http://www.khronos.org/egl
EGL用来进行环境的初始化,包含了上面所描述的种种。
关于EGL的详细说明将在下一篇文章中讲述,以Android为例。
第二节:
在上一节中提到EGL是本地平台和OpenGL ES之间的抽象层,其完成了本地相关的环境初始化和上下文控制工作,以保证OpenGL ES的平台无关性。主要包含如下工作:
a,选择显示设备
b, 选择像素格式。
c, 选择某些特性,比如如果你打算画中国水墨画,你需要额外指定宣纸和毛笔。
d, 申请显存。
e, 创建上下文(Context),上下文本质上是一组状态的集合,描述了在某个特定时刻系统的状态, 用于处理暂停、恢复、销毁、重建等情况;
f, 指定当前的环境为绘制环境 。
总体流程上,EGL按顺序分为若干步骤:
1, 选择显示设备display,即上述的a.
2,指定特性,包括上述的像素格式(b)和特定特性(c),根据指定的特性来获取多个满足这些特性的config(比如你指定RGB中的R为5bits,那么可能会有RGB_565和RGB_555两种像素格式均满足此特性),用户从这些可用的configs中选择一个,根据display和config获取绘制用的buffer(一般为显存),即上述的d。
3,使用display、config、buffer来创建context,及即上述的e.
4, 使用display、buffer、context 设置当前的渲染环境,即上述的f.
本文将以Android下EGL的使用为例逐一进行讲解。
EGL有1.0、1.1、1.2、1.3、1.4这几个版本,Android中使用的是1.4,EGL提供了查询版本的API,以下为Android中例子:
EGL10 egl = (EGL10) EGLContext.getEGL(); EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); //获取显示设备 // Init int[] version = new int[2]; egl.eglInitialize(display, version); //version中存放EGL 版本号,int[0]为主版本号,int[1]为子版本号 String vendor = egl.eglQueryString(display, EGL10.EGL_VENDOR); WLog.d("egl vendor: " + vendor); // 打印此版本EGL的实现厂商 String version = egl.eglQueryString(display, EGL10.EGL_VERSION); WLog.d("egl version: " + version);// 打印EGL版本号 String extension = egl.eglQueryString(display, EGL10.EGL_EXTENSIONS); WLog.d("egl extension: " + extension); //打印支持的EGL扩展
说明:
1,虽然Android使用(实现)的是EGL 1.4(从打印的版本号中可见), 但在Android 4.2(API 17)以前的版本没有EGL14,只有EGL10和EGL11,而这两个版本是不支持OpengGL ES 2.x的,因此在老版本中某些ES 2.x相关的常量参数只能用手写的硬编码代替,典型的如设定EGL渲染类型API的参数EGL10.EGL_RENDERABLE_TYPE,这个属性用不同的赋值指定的不同的渲染API,包括OpenGL,OpenGL ES 1.x, OpenGL ES 2.x,OpenVG等,如果采用ES 2.0,应该设置此值为: EGL14.EGL_OPENGL_ES2_BIT,但是在Android 4.2之前,没有EGL14接口,只能采取手写的硬编码来指定,类似: EGL_RENDERABLE_TYPE = 4;
2,egl.eglQueryString()用来查询EGL的相关信息,详见这里:http://www.khronos.org/registry/egl/sdk/docs/man/xhtml/
3,EGL10.EGL_DEFAULT_DISPLAY 默认对应手机主屏幕。
1,构造需要的特性列表
int[] attributes = new int[] { EGL10.EGL_RED_SIZE, 8, //指定RGB中的R大小(bits) EGL10.EGL_GREEN_SIZE, 8, //指定G大小 EGL10.EGL_BLUE_SIZE, 8, //指定B大小 EGL10.EGL_ALPHA_SIZE, 8, //指定Alpha大小,以上四项实际上指定了像素格式 EGL10.EGL_DEPTH_SIZE, 16, //指定深度缓存(Z Buffer)大小 EGL10.EGL_RENDERABLE_TYPE, 4, //指定渲染api类别, 如上一小节描述,这里或者是硬编码的4,或者是EGL14.EGL_OPENGL_ES2_BIT EGL10.EGL_NONE }; //总是以EGL10.EGL_NONE结尾
2, 获取所有可用的configs,每个config都是EGL系统根据特定规则选择出来的最符合特性列表要求的一组特性。
EGLConfig config = null;
int[] configNum = new int[1]; //获取满足attributes的config个数。 egl.eglChooseConfig(display, attributes, null, 0, configNum); int num = configNum[0]; if(num != 0){ EGLConfig[] configs = new EGLConfig[num]; //获取所有满足attributes的configs egl.eglChooseConfig(display, attributes, configs, num, configNum); config = configs[0]; //以某种规则选择一个config,这里使用了最简单的规则。 }
说明:
1,display和attributes都来自之前的步骤。
2,eglChooseConfig(display, attributes, configs, num, configNum); 用于获取满足attributes的所有config,参数1、2其意明显,参数3用于存放输出的configs,参数4指定最多输出多少个config,参数5由EGL系统写入,表明满足attributes的config一共有多少个。如果使用eglChooseConfig(display, attributes, null, 0, configNum)这种形式调用,则会在configNum中输出所有满足条件的config个数。
3,一般习惯是获取所有满足attributes的config个数,再据此分配存放config的数组,获取所有config,根据某种特定规则,从中选择其一。
4,API详细说明和所有可指定的attributes见这里:http://www.khronos.org/registry/egl/sdk/docs/man/xhtml/
5,打印config中的常用attributes:
/** * 打印EGLConfig信息 * * @param egl * @param display * @param config * : 指定的EGLConfig */ public static void printEGLConfigAttribs(EGL10 egl, EGLDisplay display, EGLConfig config) { int value = findConfigAttrib(egl, display, config, EGL10.EGL_RED_SIZE, -1); WLog.d("eglconfig: EGL_RED_SIZE: " + value); value = findConfigAttrib(egl, display, config, EGL10.EGL_GREEN_SIZE, -1); WLog.d("eglconfig: EGL_GREEN_SIZE: " + value); value = findConfigAttrib(egl, display, config, EGL10.EGL_BLUE_SIZE, -1); WLog.d("eglconfig: EGL_BLUE_SIZE: " + value); value = findConfigAttrib(egl, display, config, EGL10.EGL_ALPHA_SIZE, -1); WLog.d("eglconfig: EGL_ALPHA_SIZE: " + value); value = findConfigAttrib(egl, display, config, EGL10.EGL_DEPTH_SIZE, -1); WLog.d("eglconfig: EGL_DEPTH_SIZE: " + value); value = findConfigAttrib(egl, display, config, EGL10.EGL_RENDERABLE_TYPE, -1); WLog.d("eglconfig: EGL_RENDERABL_TYPE: " + value); value = findConfigAttrib(egl, display, config, EGL10.EGL_SAMPLE_BUFFERS, -1); WLog.d("eglconfig: EGL_SAMPLE_BUFFERS: " + value); value = findConfigAttrib(egl, display, config, EGL10.EGL_SAMPLES, -1); WLog.d("eglconfig: EGL_SAMPLES: " + value); value = findConfigAttrib(egl, display, config, EGL10.EGL_STENCIL_SIZE, -1); WLog.d("eglconfig: EGL_STENCIL_SIZE: " + value); } /** * 在指定EGLConfig中查找指定attrib的值,如果没有此属性,返回指定的默认值 * * @param egl * @param display * @param config * : 指定的EGLConfig * @param attribute * : 指定的attrib * @param defaultValue * : 查找失败时返回的默认值 * @return: 查找成功,返回查找值;查找失败,返回参数中指定的默认值 */ static public int findConfigAttrib(EGL10 egl, EGLDisplay display, EGLConfig config, int attribute, int defaultValue) { int[] val = new int[1]; if (egl.eglGetConfigAttrib(display, config, attribute, val)) { return val[0]; } return defaultValue; }
4, 获取显存
EGLSurface surface = egl.eglCreateWindowSurface(display, config, surfaceHolder, null);
说明:
1,详细的参数说明见这里:http://www.khronos.org/registry/egl/sdk/docs/man/xhtml/
2,参数surfaceHolder是android.view.SurfaceHolder类型,负责对Android Surface的管理,后续将对此进行较详细说明,参看第8小节。
3,参数4用于描述WindowSurface类型,初始化方式如同前面小节的egl attributes, 其中一个attribute是EGL_RENDER_BUFFER, 用于描述渲染buffer(所有的绘制在此buffer中进行)类别,取值为EGL_SINGLE_BUFFER以及默认的EGL_BACK_BUFFER,前者属于单缓冲,绘制的同时用户即可见;后者属于双缓冲,前端缓冲用于显示,OpenGL ES 在后端缓冲中进行绘制,绘制完毕后使用eglSwapBuffers()交换前后缓冲,用户即看到在后缓冲中的内容,如此反复。其他attributes见官方文档。
5, 创建context
int attrs[] = { EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL10.EGL_NONE, }; EGLContext context = egl.eglCreateContext(display, config, EGL10.EGL_NO_CONTEXT, attrs);
说明:
函数原型 EGLContext eglCreateContext(EGLDisplay display, EGLConfig config, EGLContext share_context, int[] attrib_list);
share_context: 是否有context共享,共享的contxt之间亦共享所有数据。EGL_NO_CONTEXT代表不共享。
attrib_list: 目前可用属性只有EGL_CONTEXT_CLIENT_VERSION, 1代表OpenGL ES 1.x, 2代表2.0。同样在Android4.2之前,没有EGL_CONTEXT_CLIENT_VERSION这个属性,只能使用硬编码0x3098代替。
函数详细描述:http://www.khronos.org/registry/egl/sdk/docs/man/xhtml/
egl.eglMakeCurrent(display, surface, surface, contxt);
比较简单,不做赘述,详细描述:http://www.khronos.org/registry/egl/sdk/docs/man/xhtml/
// 开始使用OpenGL ES 2.0 API 进行绘制。 GLES20.glClearColor(0, 0, 0, 1); GLES20.clear(GL_COLOR_BUFFER_BIT);
一般在Android中使用OpenGL ES,总是会从GLSurfaceView和Renderer开始,但是由上面描述的过程可知,只需要提供一个合适的SurfaceHolder,就可以完成整个环境初始化,并进行绘制。GLSurfaceView和Renderer事实上只是在本文描述的基础上封装了一些便利的功能,便于开发者开发,比如渲染同步、状态控制、主(渲染)循环等。那么,如何提供一个SurfaceHolder,具体的Surface分配过程又是怎样的呢,这涉及到Android窗口机制,属于比较大的话题,将在下一节进行描述。
第三节:
计算机图形的生成,总是可以抽象成三个过程:输入数据 -> 处理数据 -> 输出人眼识别的图像。
其中最后一个过程,在之前的文章:学习OpenGL-ES: 1 - 像素、颜色、显存、初始化 中已经有过比较详细的描述,最终输出的是像素(颜色)点阵,由硬件激发显示屏,发射出相应颜色的光线,被人眼接受,形成图像视觉。所以OpenGL ES 的绘图过程可描述为:输入数据 -> 处理数据 -> 输出像素点阵。
那么,输入数据和处理数据又是怎样的呢?
很自然的可以想到通过模拟人眼的输入和处理过程来进行计算机处理。对人眼而言,观察物体实际上是物体在视网膜成像的过程,首先存在物体,然后存在光源,物体反射(自发射)光线,这些光线进入人眼,在视网膜成像,刺激视觉神经细胞,令人产生视觉。
相应的,对于计算机,我们需要描述物体,描述光源,模拟光源和物体的作用过程(吸收、反射、折射光线),模拟视网膜成像;但人眼本身是一个非常复杂的器官,很难完全对其进行模拟,一个简化方法是使用相机模型来代替人眼模型。和人眼模型相比,我们同样需要描述物体,描述光源,模拟光源和物体的作用过程,但是只需要用比较简单的相机成像来替代复杂的视网膜成像即可。
所以,OpenGL ES的绘图过程可描述为:输入物体数据、光源数据 -> 模拟光照,模拟投影(成像) -> 输出像素点阵。
如何描述一个物体?让我们看看一个物体有哪些属性(对于视觉而言):
a, 位置:
首先,我们需要知道物体的位置,如果物体在我们的视野以外,我们就无法看到它。
b,朝向:
其次,我们需要知道物体的朝向,当物体侧面对着我们时,我们看到的内容是不同于物体背面对着我们时的。
c,形状:
这点很明显。
d,颜色:
颜色其实是物体和光线相互作用的结果。
e, 材质:
金属表面有光泽,而木头表面比较粗糙,这也是物体和光线相互作用的结果,我们用材质来描述决定这种结果的属性。
接下来我们看看如何在计算机中去描述这些属性,注意我们是在一个三维空间中:
a, 位置:
人们日常生活中所用的位置总是一个相对值,比如:我在你前面100米。另一个例子是地图,人们查看地图时,总是先确定一个参考点,比如我当前位于XX,然后再确定另一个点:火车站在我右手边2000米。同样,在计算机中,我们也也使用相对位置,我们需要一个参考点,通常这个点是某个坐标系的原点,所有对位置的描述都是相对于这个原点进行的。
那么,我们需要在计算机中描述这些东西(注:以下描述为了简单起见,可能不够严谨):
坐标系 - 通常是笛卡尔坐标系,通过描述原点(位置)和三个正交基(坐标轴xyz,向量)的数学表示来进行。
物体位置 - 物体(中心点)在给定坐标系中的坐标。
b, 朝向:
同位置一样,这也是个相对值。通常我们先给定一个正方向,然后描述相对于这个正方向的旋转来描述物体的朝向,比如:先让物体处于正方向,然后围绕X轴旋转30度,再围绕Y轴旋转50度,就是现在的朝向。
那么,我们需要在计算机中描述这些东西:
正方向:事实上,我们不需要描述这个东西,我们假定这个东西为1,那么 朝向 = 1 * 旋转,因此我们只需要描述旋转就可以了。
旋转:有多种描述方式,矩阵、欧拉角、四元数等,后续再进行详细的讲解。
c,形状:
三维空间中的物体,可以看做由无数个紧密点组成,也可以看做由无数个紧密曲面组成,这两种表述都需要极大的计算量(有例外),不适合计算机。
计算机图形学中最常见的方法是使用大量细小的平面去模拟曲面,这个概念比较难以描述,任何看过3D建模工具中模型的线框图的人应该都能很容易理解,如果你不理解,建议去看一看,这里不再赘述。
平面有很多种,出于效率(算法限制)以及硬件实现复杂度的考量,我们一般要求平面为凸多边形,更进一步,OpenGL ES 只支持三角形。所以,我们使用大量的三角形来描述物体形状;那么,如何描述三角形本身呢,一个直观的想法是描述所有顶点,以及顶点组成三角形的方式,OpenGL ES 确实是采用这样的方式,后续再进行详细的说明。
d, 颜色:
当物体受到光照时,对于RGB中的每一种,它都会进行吸收、反射,所以颜色的定义来源于吸收了RGB中的多少,反射了多少。比如假设0代表完全吸收,1代表完全反射,那么(r,g,b) = (1,0,0)表示完全反射红光,吸收蓝光和绿光,那么我们所看到的就是红色的物体(假设使用白光照射)。
e,材质:
如果物体表面光滑,则大部分入射光会以相同的角度反射出去,我们称之为镜面反射;反之如果物体表面粗糙,大部分入射光会进行近似无序的散射;真实的物理过程比较复杂,一般我们会采用简化的模型,我们需要描述有多少比例的光进行镜面反射,多少比例的光进行散射。
3,输入光源数据
a, 位置:
光源可视为物体,位置的描述方式和物体一样。
b, 颜色:
发射的光线中RGB分量的强度。
c, 强度:
光源的亮度。
d, 方向:
是球形发射的点光源,还是集中在一个方向的聚光灯,还是无穷远处点光源发射过来的(近似)平行光。
e, 衰减度:
随距离增大的衰减程度。
4,模拟光照
总体来说分为两大类,局部光照和全局光照:
局部光照:
对指定物体,局部光照只考虑光源和这个物体的作用,不考虑其它物体。主要为 Phone光照模型。
全局光照:
对指定物体,全局光照不仅仅考虑光源和这个物体的作用,还考虑场景中的其它物体影响,比如光源照在A物体上,A物体反射的光照射在B物体上,则B物体同时受到光源的光和A反射光的影响。
全局光照主要使用光线跟踪(Ray Tracing)和辐射度(Radiosity)两种方法,前者顾名思义,追踪所有进入人眼的光线(光源直射、反射、反射的反射、反射的反射的反射。。。);后者基于一个场景内光线总能量守恒的原理,进行相应计算。
全局光照有更好的效果,更复杂的实现,更多的计算量。光照是个复杂的话题,这里只做简单描述。
5,模拟投影
物体摆好,光源设置好之后,从不同的位置不同的角度观察,所看到的图像是不同的,观察的过程本质上是投影的过程,将三维空间中的点投影到一个2D平面,形成图像。对人眼而言,投影到视网膜,对相机而言,投影到底片。为了模拟投影过程,我们需要设置观察者,以相机为例:
1,将相机放置在某个位置,这和放置物体是完全一样的。
2,调整好相机的朝向,这和调整物体朝向是完全一样的。
3,设定相机的观察范围,所有在这个观察范围内的物体都是可见的,在此之外的物体都是不可见的。观察范围通常是一个多面体,位于多面体内的物体可见,体外的物体不可见,称之为视见体。对照相机来说,这一步对应着调整焦距(视角)。
4,设定投影平面,这一步和第三步是密切相关的,容易想象,只有投影平面之前的物体才能投影到该平面,而位于投影平面之后的物体都是不可见的。
5,拍摄(投影)。
6,冲洗照片,我们可以以任意的尺寸冲洗照片。
6,输出像素点阵
将前面计算得出的像素(颜色)数据写入显存,通知硬件刷新(显示画面)。
OpenGL ES 的粗略绘制过程如下:
用大量三角形构造3D物体,指定物体位置、朝向,设置物体颜色、材质,设定光源,计算光照结果,投影,刷新显示。
以上是我们所推导的绘制过程,OpenGL ES的实际绘制过程包含比这些要多的内容,比如裁剪、纹理等,但其本质不变。
第四节:
接下来将是数学系列,将主要讲解一些初学者比较难以理解的内容,并假设读者已经熟悉了基本的向量和矩阵运算以及其几何意义。为了简单化,某些数学概念不甚严谨,但不妨碍学习和理解。
在上一节中,我们知道绘制3D图形,首先需要描述物体:
a, 我们需要描述物体位置,也经常需要平移物体,平移是指物体的形状和朝向都不变,只是位置改变。我们通过对物体的所有顶点进行相同的平移来完成这个过程,我们称之为对物体(顶点)进行平移变换,这个变换在计算机图形学中是通过用顶点坐标乘以一个4X4矩阵来完成的,顶点乘以矩阵,得到一个新的顶点,这个新的顶点就是原顶点平移后的结果,我们称这个矩阵为平移变换矩阵。
b, 与此类似,我们通过用顶点坐标旋转变换矩阵来旋转物体,进行旋转变换。
c,通过缩放变换矩阵来对物体进行缩放变换。
平移、旋转、缩放是最常见的变换,我们将在随后的几篇文章中分别讲解,先来看平移变换:
坐标系由原点和三个正交向量定义,三个正交向量称为一般称为坐标轴,相交于原点。由于位置总是相对的,所以坐标系A总是在某个坐标系B中进行描述:我们把最顶层的坐标系称为世界坐标系,我们总是从这个坐标系出发去描述其它的坐标系。定义世界坐标系原点 [0,0,0], x轴[1,0,0],y轴[0,1,0],z轴[0,0,1]
向量有大小和方向,但没有位置,指定坐标系,向量总是可以由三个坐标轴的线性表示来构造,比如向量V [x, y, z] = x*[1,0,0] + y*[0,1,0] + z[0,0,1]; 对于向量来说,只需要三个坐标轴(基)就可以定义(向量空间),和原点无关。
点描述了3维空间中的位置,为了在向量空间中描述点(位置),引入了原点的概念,所有点都相对于原点进行描述(仿射空间)。
点和向量相加得到另一个点(想象一下点P沿着向量的方向移动向量的长度,其目的地就是新的点),两点相减得到一个向量。在给定坐标系中的任意点P,都可以通过原点P0和从P0指向P的向量V定义:P = P0 + V;同样 V = P - P0; 而每个向量又能由三个坐标轴的线性表示构造,所以每个点总能由原点 + 三个坐标轴的线性表示构造:
P = P0 + V = P0 + (ax + by + cz) = P0 + (a*[1,0,0] + b*[0,1,0] + c*[0,0,1]); 其中a\b\c为标量,x\y\z分别代表三个坐标轴向量,V代表从原点出发,指向P的向量。
另外,由于原点的坐标总是[0,0,0],所以P = P0 + V = [0,0,0] + V[x,y,z] = [x,y,z],注意:点和向量并不等同,此处只是在数值上相等,务必要分清。
虽然点和向量的抽象定义和坐标系无关,无论有没有坐标系,点和向量总是存在于空间中,但当我们描述(表示)以及计算它们的时候,必须先给定一个坐标系。同一个点在不同的坐标系中有不同的表示,同一个向量在不同的坐标系中也有不同的表示。而坐标系由一个点(原点P0)和三个向量(Vx,Vy,Vz)定义,将其视为四元组[P, V, V, V], 所以同一个坐标系,在不同的坐标系中也有不同的表示。
描述平移,需要描述平移的方向和距离,而向量具有方向和大小,所以我们可以用向量来描述一个平移,沿着向量V的方向移动向量长度的距离,我们称之为沿着V平移。
a, 移动点:
这是最自然的想法,有一个点P,移动V,则移动后的新点为P + V(回想一下,点 + 向量 = 点)。
b,移动坐标系:
移动坐标系和移动点是可互换的对偶过程。把一个点P移动一段距离,等同于保持点不动,把坐标系反方向移动相同的距离,然后在这个新坐标系中描述P,也就是计算P在新坐标系中的坐标。
c,其它变换:
事实上,所有变换都可以表述为变换点和变换坐标系这两种等价的方式。在某些时刻,用一种表述(思考)方式会比另一种更合适,无论用那种方式,我们总可以通过一个逆变换转变到另外一种方式,后面将看到这点。
世界坐标系,原点 [0,0,0], x轴[1,0,0],y轴[0,1,0],z轴[0,0,1],我们把世界坐标系沿x的正方向移动1单位,也就是沿着向量[1,0,0]平移,可以得到一个新的坐标系,那么这个新坐标系在世界坐标系中的表示是什么呢?回想一下,一个坐标系由原点和三个坐标轴决定,因此移动坐标系,也就是移动原点和坐标轴,让我们依次进行计算:
先计算原点: 原点沿向量[1,0,0]平移,所以新的原点为:[0,0,0] + [1,0,0] = [1,0,0];
再计算坐标轴:先考虑x轴,x轴为向量,向量没有位置,只有方向和大小,而在平移中,x轴的方向和大小都没有发生变化,所以x不变,同理y和z也不变。
所以,新坐标系在世界坐标系中的表示:原点 [1,0,0], x轴[1,0,0],y轴[0,1,0],z轴[0,0,1]
可以得出这样一个结论:所有对坐标系的平移只影响原点,不影响坐标轴。
点P,在世界坐标系中坐标为[1,2,3],在第5小节生成的新坐标系中,这个点的坐标是多少?
移动坐标系,等于逆向移动点,因此坐标系平移V[1,0,0],等同于点平移-V = [-1,0,0],所以,平移后的点为:P + (-V) = [1,2,3] + [1,0,0] = [0,2,3].
那么,假设在新坐标系中的一个点[1,2,3],在世界坐标系中的坐标P2是多少呢?首先将新坐标系移回原位置(和世界坐标系重合),需要沿-V移动,所以对坐标系来说,平移-V,对点来说则平移-(-V) = V,则 P2 = [1,2,3] + V = [1,2,3] + [1,0,0] = [2,2,3].
如同之前所述,我们可以用向量来描述平移变换,对一个物体进行平移V,等价于对这个物体所有的点P进行这样的运算: P + V
但是,我们往往需要对物体进行各种变换组合,比如:先平移V,再绕X轴旋转100度,再均匀缩放10倍,再平移V。。。所以如果有一种统一的描述和计算方式来表示所有的变换,对于理解、计算、硬件设计都有很大的好处,计算机图形学中使用4X4矩阵来表示所有的变换,因此矩阵是一种用来进行各种变换的数学工具。那么矩阵是怎样进行变换的呢?
所谓变换,本质上可看做一个函数(映射),假设这个函数为T(),则对于一个顶点P,T(P)将输出一个新的顶点P1,这个顶点就是经过T变换的结果,而T的实现,实际上是拿顶点乘以一个矩阵M,也就是P1 = P X M. 对每一种变换都有一个对应的函数T,也都有一个对应的矩阵M,所以一系列的变换可以描述为:P1 = P X M平移 X M旋转 X M缩放 X M平移 = P平移 X M旋转 X M缩放 X M平移 = P平移+旋转 X M缩放 X M平移 = 。。。
而矩阵乘法满足结合率,所以P X M平移 X M旋转 X M缩放 X M平移 = P X (M平移 X M旋转 X M缩放 X M平移) = P X M连乘矩阵
那么,用矩阵进行平移变换,关键就是构造一个矩阵M,令P X M = P + V。注:在OpenGL ES中,使用4X4齐次坐标矩阵,右手坐标系,列主序,向量右乘。关于左/右手坐标系,行/列主序,向量左/右乘,向量和矩阵元素在内存(数组)中的顺序等,都是初学者容易迷惑的地方,后续将有一篇文章专门对此进行讲解。本文为了书写方便,使用行主序和向量左乘.
假设将点[1,2,3]沿向量[4,5,6]进行平移,我们可以得出平移结果为[1,2,3] + [4,5,6] = [5,7,9],以下为对应的平移变换矩阵:
1,0,0,0
[1,2,3,1] X 0,1,0,0 = [5,7,9,1]
0,0,1,0
4,5,6,1
[1,2,3,1]称之为点[1,2,3]的齐次坐标表示,其中第四个分量一般称为w分量,对3D来说,齐次坐标只是一种数学技巧,用来将各种变换统一为一种矩阵表示(3X3矩阵无法表示仿射变换,平移变换属于仿射变换),齐次坐标可参考之前文章中推荐的书籍(学习OpenGL-ES: 0 - 方法和资料),此处不再赘述。
观察上述矩阵,发现第四行为[4,5,6,1],恰为平移向量V的齐次坐标表示,因此沿向量V[X,Y,Z]平移的平移变换矩阵可构造如下;
1,0,0,0
0,1,0,0
0,0,1,0
X,Y,Z,1
对3D世界中的物体进行平移,就是对其所有顶点P进行相同的平移,这个平移可以通过一个平移向量V[X,Y,Z]描述,也可以通过一个平移变换矩阵M描述,平移变换的结果P1 = P +V = P X M。M是如下形式的4X4矩阵:
1,0,0,0
0,1,0,0
0,0,1,0
X,Y,Z,1