OpenGL ES 2.0游戏开发(下卷):高级特效和典型案例

978-7-115-34469-4
作者: 吴亚峰
译者:
编辑: 张涛

图书目录:

详情

通过案例详细讲解了OpenGL的应用,让读者学会如何驾驭复杂的应用程序,这些特性是目前市面上的一些书籍所没有的。本书中的大型案例均来源于真实的产品,100%原创,读者可以学以致用的尽快进入实战角色。本书通俗易懂,很适合读者开发项目中应用。

图书摘要

OpenGL ES 2.0游戏开发(下卷) 高级特效和典型案例
吴亚峰 著

百纳科技 审校

人民邮电出版社

北京

前言

为什么要写这样的一套书

随着智能手机移动嵌入式平台硬件性能的不断提升,如水果忍者、极品飞车、会说话的Tom猫等一批优秀的3D 游戏娱乐应用在广大智能机用户间流行开来,与此同时,也带动了手机游戏产业逐渐从2D走向3D。但目前国内专门介绍这方面开发的书籍与资料都非常少,同时3D应用开发的门槛又比较高,使得很多初学者无从下手。根据这种情况,笔者结合多年从事 3D 游戏应用开发的经验编写了这样一套书,本套书包括《OpenGL ES 2.0 游戏开发(上卷):基础技术和典型案例》和《OpenGL ES 2.0 游戏开发(下卷):高级特效和典型案例》。

了解一些3D领域的技术人员都知道,移动嵌入式平台上的3D应用开发主要是基于OpenGL ES的3个版本,即OpenGL ES 1.0、1.1和2.0。OpenGL ES 1.0 与1.1 是较老的版本,渲染能力很有限,留给开发人员发挥的空间也很有限。而本套书主要介绍了OpenGL ES 2.0,其采用的是可编程渲染管线,留给了开发人员充分的发挥空间,可以说是“海阔凭鱼跃,天高任鸟飞”。

本套书中在给出实际的开发案例时涉及了Android SDK、Android NDK、iOS、WebGL等主流平台,充分考虑到了各个不同主流目标平台读者的需求。同时,本套书中关于OpenGL ES 2.0可编程渲染管线着色器的知识与技术还可以通用于其他的很多移动嵌入式平台,包括 Windows Phone、MeeGO、PSP、PlayStation等。因此,学习各种移动嵌入式平台下高级3D应用开发的朋友都可以参考此书。

经过一年多见缝插针式的奋战,本套书终于交稿了。回顾写书的这一年时间,不禁为自己能最终完成这个耗时费力的“大制作”而感到欣慰。同时也为自己能将从事游戏开发近10年来积累的宝贵经验以及编程感悟分享给正在开发阵线上埋头苦干的广大开发人员而感到高兴。

贾岛的《剑客》一诗有言:“十年磨一剑,霜刃未曾试,今日把示君,谁有不平事?”从1998年首次接触Java与OpenGL算起,到现在也是10年有余。笔者希望用10年的知识和经验磨出的利剑能够帮助广大读者在实际工作中披荆斩棘、奋勇向前。

本书特点

1.内容丰富,由浅入深

本套书组织上本着“起点低,终点高”的原则,内容覆盖了从学习OpenGL ES 2.0 必知必会的基础知识到基于OpenGL ES 2.0 实现各种高级特效,“每卷书的最后还给出了完整的大型3D游戏案例。其中第一卷最后的两个案例是笔者带领学员参加谷歌(Google)2011第二届Android应用开发中国大学生挑战赛”的获奖作品,其中第16章的夜鹰行动更是获全国总决赛前5名大奖的作品。

这样的内容组织使得懵懂的移动嵌入式 3D 应用开发菜鸟可以一步一步成长为 3D 开发的达人,符合绝大部分想学习3D应用开发的学生与程序开发人员以及相关技术人员的需求。

2.结构清晰,讲解到位

本套书中配合每个需要讲解的知识点都给出了丰富的插图与完整的案例,使得初学者易于上手,有一定基础的读者便于深入。书中所有的案例均是根据笔者多年的开发心得进行设计的,结构清晰明朗,便于读者进行学习。同时书中还给出了很多笔者多年来积累的编程技巧与心得,具有很高的参考价值。

3.非常实用的光盘

为了便于读者学习,本套书附带的光盘中包含了书中所有案例的完整源代码,最大限度地帮助读者快速地掌握开发技术。

内容导读

本书共分为12章,内容按照必知必会的基础知识、基于OpenGL ES 2.0实现基本特效以及真实大型游戏案例的顺序进行详细地讲解。

本套书内容丰富,从基本知识到高级特效;从简单的应用程序到完整的3D 游戏案例,适合不同需求、不同水平层次的各类读者阅读使用。

● 初学OpenGL ES 2.0 3D 应用开发的读者

本套书内容包括在各个主流平台下进行 3D 应用开发各方面的知识,内容由浅入深,配合详细的案例。非常适合3D游戏的初学者循序渐进地学习,最终成为3D游戏应用开发的达人。

● 有一定3D 开发基础希望进一步深入学习OpenGL ES 2.0 高级3D开发技术的读者

本套书不仅包括了OpenGL ES 2.0 开发的基础知识,同时也包括了基于OpenGL ES 2.0 实现高级特效以及完整的游戏案例,有利于有一定基础的开发人员进一步提高开发水平与能力。

本书作者

吴亚峰,毕业于北京邮电大学,后留学澳大利亚卧龙岗大学取得硕士学位。1998年开始从事Java应用的开发,有十多年的Java 开发与培训经验。主要的研究方向为OpenGL ES、手机游戏、Java EE 以及搜索引擎。同时为手机游戏、Java EE独立软件开发工程师,并兼任百纳科技Java 培训中心首席培训师。近十年来为数十家著名企业培养了上千名高级软件开发人员,曾编写过《菜鸟成长之路——Java 程序员职场全攻略》、《Android 3D 游戏案例开发大全》、《Android 平板电脑开发实战详解和典型案例》、《Android游戏开发大全》、《Android应用案例开发大全》、《Unity 3D游戏开发技术详解与典型案例》等多本畅销技术书籍。2008年年初开始关注Android平台下的应用开发,并开发出一系列优秀的Android 3D 应用程序与游戏。

本书在编写过程中得到了唐山百纳科技有限公司Java培训中心的大力支持,同时仇磊、李腾飞、夏学良、王旅波、李胜杰、代其祥、蒋科、任俊钢、章雅卓、付鹏、白冰以及家人为本书的编写提供了很多帮助,在此表示衷心的感谢!

由于笔者的水平和学识有限,且书中涉及的知识较多,难免有错误疏漏之处,敬请广大读者批评指正,并多提宝贵意见。本书责任编辑联系邮箱为:zhangtao@ptpress.com.cn。

编者

第1章 顶点着色器的妙用

《OpenGL ES 2.0 游戏开发(上卷):基础技术和典型案例》(以下简称第一卷)的章节中已经大量地用到了顶点着色器,但并没有能完全体现出顶点着色器的功效。本章将进一步介绍一些顶点着色器的使用技巧,通过应用这些技巧可以开发出很多酷炫的效果,而这些效果在OpenGL ES1.x时代是很难高效实现的。

提示

由于本书是系列书籍的第二卷,故当在本书正文中提到某些代码与本书前面的案例相同,不再重复给出时,既有可能指的是本书中的案例,也可能指的是第一卷中的案例。不过读者不用担心,虽然在正文中没有给出与第一卷重复的部分代码,但本书所附的光盘中包含了本书案例的完整源代码。因此,有需要时读者既可以去参考第一卷书,也可以参看本书所附光盘中的源代码。

1.1 飘扬的旗帜

飘扬的旗帜是本章要介绍的第一个案例,通过此技术可以实现类似旗帜迎风飘扬的效果,也可以实现类似水面起伏的效果。

1.1.1 基本原理

介绍本案例的具体开发步骤之前首先需要了解一下实现旗帜飘扬的基本原理,如图1-1所示。

▲图1-1 旗帜飘扬的线框图

提示

图1-1中左图为原始情况下旗帜的顶点位置情况,右图为顶点着色器根据参数计算后某一帧画面中旗帜的顶点位置情况。

从图1-1中可以看出,矩形的旗帜与以前案例中绘制的矩形不同,不再是仅有两个三角形组成的整体,而是有大量的小三角形组成的。这样只要在绘制一帧画面时由顶点着色器根据一定的规则变换各个顶点的位置,即可得到旗帜迎风飘动的效果。

为了使旗帜的飘动过程比较平滑,本案例采用的是基于正弦曲线的顶点位置变换规则,具体情况如图1-2所示。

说明

图1-2给出的是旗帜面向z轴正方向,即顶点沿z轴上下振动,形成的波浪沿x轴传播的情况。同时注意,观察的方向是沿y轴的方向。

▲图1-2 X方向波浪原理图

从图1-2中可以看出,传入顶点着色器的原始顶点的z坐标都是相同的(本案例中为0),经过顶点着色器变换后顶点的z坐标是根据正弦曲线分布的。具体的计算方法如下。

● 首先计算出当前处理顶点的x 坐标与最左侧顶点x 坐标的差值,即X 距离。

● 然后根据距离与角度的换算率将 X 距离换算为当前顶点与最左侧顶点的角度差(tempAngle)。

提示

所谓距离与角度的换算率指的是由开发人员人为设定的一个值,将距离乘以其后就可以换算成角度值。例如可以规定,x方向上距离4等于2π,则换算公式为:x距离×2π/4。

● 接着将 tempAngle 加上最左侧顶点的对应角度(startAngle)即可得到当前顶点的对应角度(currAngle)。

● 最后通过求currAngle 的正弦值即可得到当前顶点变换后的z坐标。

可以想象出,只要在绘制每帧画面时传入不同的startAngle值(例如在0~2π连续变化),即可得到平滑的基于正弦曲线的旗帜飘扬的动画了。

1.1.2 开发步骤

上一小节介绍了飘扬旗帜的基本原理,本小节将基于此原理开发一个旗帜迎风飘扬的案例Sample1_1,其运行效果如图1-3所示。

▲图1-3 案例Sample1_1的运行效果图

提示

图1-3中所示从左到右分别为x方向波浪、斜向下方向波浪和xy双向波浪的效果。由于插图是灰度印刷且是静态的,因此可能看得不是很清楚,建议读者用真机运行本案例进行体会。

了解了案例的运行效果后,接下来对本案例的具体开发过程进行简要介绍。由于本案例中的大部分类和前面章节很多案例中的非常类似,因此在这里只给出本案例中比较有代表性的部分,具体内容如下所列。

(1)首先需要简单说明的是,表示旗帜的纹理矩形类TextureRect,其大部分代码与本书前面的很多案例基本一致。主要的区别是需要增加将起始角度和角度总跨度等数据传入渲染管线的相关代码,有了这些数据后顶点着色器在绘制每帧画面前就可以顺利地对顶点位置进行变换了。

提示

增加的代码非常简单,需要的读者请自行查阅随书光盘。

(2)从案例效果图中可以看出,本案例中的波浪方向有3种选择,因此需要3套着色器来实现不同的波浪方向。首先给出最简单的实现x方向波浪的顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_1/assets目录下的vertex_tex_x.sh。

1 uniform mat4 uMVPMatrix;     //总变换矩阵

2 uniform float uStartAngle;    //本帧起始角度(即最左侧顶点的对应角度)

3 uniform float uWidthSpan;    //横向长度总跨度

4 attribute vec3 aPosition;    //顶点位置

5 attribute vec2 aTexCoor;     //顶点纹理坐标

6 varying vec2 vTextureCoord;    //用于传递给片元着色器的纹理坐标

7 void main(){

8  float angleSpanH=4.0*3.14159265;  //横向角度总跨度,用于进行x距离与角度的换算

9  float startX=-uWidthSpan/2.0;   //起始x坐标(即最左侧顶点的x坐标)

10  //根据横向角度总跨度、横向长度总跨度及当前点x坐标折算出当前顶点x坐标对应的角度

11  float currAngle=uStartAngle+((aPosition.x-startX)/uWidthSpan)*angleSpanH;

12  float tz=sin(currAngle)*0.1;   //通过正弦函数求出当前点的Z坐标

13  //根据总变换矩阵计算此次绘制此顶点位置

14  gl_Position=uMVPMatrix*vec4(aPosition.x,aPosition.y,tz,1);

15  vTextureCoord=aTexCoor;    //将接收的纹理坐标传递给片元着色器

16 }

说明

上述顶点着色器实现了上一小节中所介绍的基于正弦曲线的x方向波浪,其中第8行的变量angleSpanH可以用来控制波浪的密度,其值越大,波浪密度越大。

(3)接着给出的是实现斜向下方向波浪的顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_1/assets目录下的vertex_tex_xie.sh。

1 uniform mat4 uMVPMatrix;     //总变换矩阵

2 uniform float uStartAngle;    //本帧起始角度(即最左侧顶点的对应角度)

3 uniform float uWidthSpan;    //横向长度总跨度

4 attribute vec3 aPosition;    //顶点位置

5 attribute vec2 aTexCoor;     //顶点纹理坐标

6 varying vec2 vTextureCoord;    //用于传递给片元着色器的纹理坐标

7 void main(){

8  float angleSpanH=4.0*3.14159265;  //横向角度总跨度,用于进行X距离与角度的换算

9  float startX=-uWidthSpan/2.0;   //起始x坐标(即最左侧顶点的x坐标)

10  //根据横向角度总跨度、横向长度总跨度及当前点X坐标折算出当前顶点x坐标对应的角度

11  float currAngleH=uStartAngle+((aPosition.x-startX)/uWidthSpan)*angleSpanH;

13  float angleSpanZ=4.0*3.14159265;  //纵向角度总跨度,用于进行Y距离与角度的换算

14  float uHeightSpan=0.75*uWidthSpan; //纵向长度总跨度

15  float startY=-uHeightSpan/2.0;  //起始y坐标(即最上侧顶点的y坐标)

16  //根据纵向角度总跨度、纵向长度总跨度及当前点y坐标折算出当前顶点y坐标对应的角度

17  float currAngleZ=((aPosition.y-startY)/uHeightSpan)*angleSpanZ;

18  float tzH=sin(currAngleH-currAngleZ)*0.1; //通过正弦函数求出当前点的z坐标

19  //根据总变换矩阵计算此次绘制此顶点的位置

20  gl_Position=uMVPMatrix*vec4(aPosition.x,aPosition.y,tzH,1);

21  vTextureCoord=aTexCoor;     //将接收的纹理坐标传递给片元着色器

22 }

说明

本质上讲,上述斜向下方向波浪的顶点着色器与前面的x方向波浪的顶点着色器没有本质区别,仅仅是在计算当前顶点的对应角度时增加了y轴方向的计算,不再是仅考虑x轴的坐标。因此,形成的波浪方向就是斜向下的。

(4)最后给出的是沿x、y两个方向各自传播的波浪效果叠加的顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_1/assets目录下的vertex_tex_xy.sh。

1 uniform mat4 uMVPMatrix;     //总变换矩阵

2 uniform float uStartAngle;    //本帧起始角度(x、y两个方向都是其)

3 uniform float uWidthSpan;    //横向长度总跨度

4 attribute vec3 aPosition;    //顶点位置

5 attribute vec2 aTexCoor;     //顶点纹理坐标

6 varying vec2 vTextureCoord;    //用于传递给片元着色器的纹理坐标

7 void main(){

8  //首先计算当前顶点X方向波浪对应的Z坐标

9  float angleSpanH=4.0*3.14159265;  //横向角度总跨度,用于进行x距离与角度的换算

10  float startX=-uWidthSpan/2.0;   //起始x坐标(即最左侧顶点的x坐标)

11  //根据横向角度总跨度、横向长度总跨度及当前点X坐标折算出当前顶点x坐标对应的角度

12  float currAngleH=uStartAngle+((aPosition.x-startX)/uWidthSpan)*angleSpanH;

13  float tzH=sin(currAngleH)*0.1;  //x方向波浪对应的z坐标

14  //接着计算当前顶点Y方向波浪对应的Z坐标

15  float angleSpanZ=4.0*3.14159265;  //纵向角度总跨度,用于进行y距离与角度的换算

16  float uHeightSpan=0.75*uWidthSpan; //纵向长度总跨度

17  float startY=-uHeightSpan/2.0;  //起始y坐标(即最上侧顶点的y坐标)

18  //根据纵向角度总跨度、纵向长度总跨度及当前点y坐标折算出当前顶点y坐标对应的角度

19  float currAngleZ=uStartAngle+3.14159265/3.0+((aPosition.y-startY)/uHeightSpan)*angleSpanZ;

20  float tzZ=sin(currAngleZ)*0.1;  //y方向波浪对应的Z坐标

21  //根据总变换矩阵计算此次绘制此顶点的位置,

22  gl_Position=uMVPMatrix*vec4(aPosition.x,aPosition.y,tzH+tzZ,1);

23  vTextureCoord=aTexCoor;    //将接收的纹理坐标传递给片元着色器

24 }

提示

本质上讲上述 x、y 双向波浪的顶点着色器与前面的x 方向波浪的顶点着色器没有本质区别,仅仅是首先分别计算了x方向和y方向波浪在当前顶点位置的z坐标,最后将两个z坐标叠加实现了波的叠加。因此,运行案例时看到的波浪就是x、y两个方向的了。

本案例3套着色器中的片元着色器都是一样的,并且都是采用的普通的纹理采样片元着色器,前面很多案例中已经出现过,因此这里不再赘述。

1.2 扭动的软糖

上一节介绍的飘扬的旗帜是对一个纹理矩形中的顶点位置进行了变换,本节的案例将对一个长方体中的顶点位置进行变换以实现软糖扭动的效果。

1.2.1 基本原理

介绍本案例的具体开发之前首先需要了解一下实现的基本原理,具体情况如图1-4所示。

从图1-4中可以看出,软糖模型实际上是由很多层的小矩形叠加而成。在同一帧中,随着 y坐标的不断升高此层的顶点绕中心轴扭曲的角度越大。因此,实现扭动软糖的效果只要将代表软糖的长方体中各层顶点的x、z坐标按照一定的规则根据顶点的y坐标以及当前帧的控制参数进行变换即可,具体的计算思路如图1-5、图1-6与图1-7所示。

▲图1-4 软糖的线框图
▲图1-5 软糖扭曲原理图1
▲图1-6 软糖扭曲原理图2
▲图1-7 向量旋转图

具体的计算步骤如下。

● 首先如图1-5所示,需要计算出当前顶点y 坐标与最下层顶点y 坐标的差值。

● 接着根据 y 坐标的差值,角度换算比例以及本帧的总扭曲角度换算出当前顶点的扭曲角度,计算公式为:currAngle=(currY-startY)/ySpan×angleSpan。

● 最后根据当前顶点的x、z坐标,扭曲的角度计算出变换后顶点的x、z 坐标,此步的计算思路如图1-6与图1-7所示。

从图1-6与图1-7中可以看出,将顶点绕中心轴扭曲(旋转)实际上可以看作,将从中心点出发到变换前顶点的向量旋转指定的角度。旋转后得到的新向量的终点位置即所求的变换后顶点的位置,因此具体的计算公式如下。

x'=xcosα-zsinα  z'=xsinα+zcosα

说明

上述公式中的α为需要旋转(扭曲)的角度,实际计算时采用前面步骤计算出来的变量currAngle的值即可。

1.2.2 开发步骤

上一小节介绍了软糖扭动的基本原理,本小节将基于此原理开发一个软糖不断扭动的案例Sample1_2,其运行效果如图1-8所示。

▲图1-8 案例Sample1_2的运行效果图

说明

图1-8所示给出了4幅软糖扭动过程中的程序抓图,从左至右扭动角度不断增大。

了解了案例的运行效果后,接下来对本案例的具体开发过程进行简要介绍。由于本案例中的大部分类和前面章节很多案例中的非常类似,因此在这里只给出本案例中比较有代表性的部分,具体内容介绍如下。

(1)首先需要简单说明的是,表示软糖的长方体类Cuboid,其大部分代码与本书前面的很多案例基本一致。主要的区别是需要增加将当前帧最大扭动角度、y起始坐标、y坐标总跨度等数据传入渲染管线的相关代码,有了这些数据后顶点着色器在绘制每帧画面前就可以顺利地对顶点位置进行变换了。

提示

增加的代码非常简单,需要的读者请自行查阅随书光盘。

(2)接着需要给出的是实现软糖扭动的顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_2/assets目录下的vertex_tex.sh。

1 uniform mat4 uMVPMatrix;     //总变换矩阵

2 attribute vec3 aPosition;     //顶点位置

3 attribute vec2 aTexCoor;   //顶点纹理坐标

4 varying vec2 vTextureCoord;  //用于传递给片元着色器的纹理坐标

5 uniform float angleSpan;   //本帧扭曲总角度

6 uniform float yStart;    //y坐标起始点

7 uniform float ySpan;    //y坐标总跨度

8 void main(){

9  float tempAS=angleSpan*(aPosition.y-yStart)/ySpan;//计算当前顶点扭动(绕中心点选择)的角度

10  vec3 tPosition=aPosition;

11  if(aPosition.y>yStart){   //若不是最下面一层的顶点则计算扭动后的x、z坐标

12  tPosition.x=(cos(tempAS)*aPosition.x-sin(tempAS)*aPosition.z);

13  tPosition.z=(sin(tempAS)*aPosition.x+cos(tempAS)*aPosition.z);

14  }

15  gl_Position=uMVPMatrix*vec4(tPosition,1);//根据总变换矩阵计算此次绘制此顶点的位置

16  vTextureCoord=aTexCoor;  //将接收的纹理坐标传递给片元着色器

17 }

说明

上述顶点着色器根据上一小节介绍的计算过程实现了对顶点位置的变换,其中最核心的就是第12-13行根据扭动角度计算变换后顶点x、z坐标的代码。

1.3 风吹椰林场景的开发

前两节分别给出了两个单一的用顶点着色器实现软体的案例,本节将给出一个综合性的软体案例。此案例为风吹海滩上椰子林的场景,场景中海浪拍打沙滩,椰子树在风的吹动下摇摆,伴随着海浪的声音,非常吸引人。

提示

本案例中的海浪实际就是放平的、采用了海水纹理的飘扬的旗帜,天空采用的是天空穹,海岛采用的是灰度图地形,海浪的声音采用的是声音池。这些技术在前面的章节中都已经详细介绍过,因此本节就不再赘述。而椰子树随风摆动是本案例的重点,下面将详细进行介绍。

1.3.1 椰子树随风摇摆的基本原理

介绍椰子树的具体开发之前首先需要了解一下沙滩椰子树随风摆动的基本原理。本案例中椰子树的树干会随着风力的大小、方向产生对应的弯曲,下面的图1-9给出了如何计算某一帧中树干上指定顶点弯曲后位置的策略。

▲图1-9 椰子树树干弯曲原理图

从图1-9中可以看出,为了简化计算,本案例中采用的风向是与 xoz 平面平行的。设当前风向与 z 轴正方向的夹角为α,树干原始状态下与y轴重合。点A为树干模型中的任一顶点,在风的吹动下偏转到A'点。

则顶点着色器需要计算的问题为:已知A点坐标(x0,y0,z0)、当前风向与 z 轴正方向的夹角α以及弧 OA'所在圆的半径OO',求A点偏转到A'点后的坐标。

提示

本案例采用的计算模型中,半径OO'的大小与风力的大小是成反比例的,风力越大,半径OO'越小。这样就非常容易地实现了风越大,树干弯曲得越厉害。

下面给出了具体的计算步骤。

(1)由于OA'为半径为OO'的一段圆弧,那么可以得出OA'=OA,且O'O=O'A'。

(2)根据弧长公式,可得出树干弯曲后的弧对应的圆心角θ的弧度计算公式如下。

(3)从图1-9以及根据三角函数的知识可以得出如下结论。

A'D=O'A'×sin(θ)=O'O'×sin(OA/OO')

OD=OO'-O'A'×cos(θ)=OO'-O'O'×cos(OA/OO')

(4)接着可以得出如下结论。

OX'=OD×sin(α)=( OO'-O'O'×cos(OA/OO'))×sin(α)

OZ'=OD×cos(α)=(OO'-O'O'×cos(OA/OO'))×cos(α)

(5)设顶点A的坐标为(x0,y0,z0),偏移后A'的坐标为(x1,y1,z1)。则可以用y0替换上面的OA,那么有如下结论。

Ox'=(OO'-OO'×cos(y0/OO'))×sin(α)

OZ'=(OO'-OO'×cos(y0/OO'))×cos(α)

(6)最后可以得到A'点的坐标为。

x1=x0+Ox'=X0+(OO'-OO'×cos(Y0/OO'))×sin(α)

y1=A'D=OO'×sin(Y0/OO')

z1=z0+Oz'=z0+(OO'-OO'×cos(y0/OO'))×cos(α)

从上述得出的顶点位置变换公式中可以看出,只需要改变风向角度α,就可以使椰子树向不同的方向摆动。同时,只需要根据风力大小改变弯曲半径 OO'的大小,就可以改变椰子树树干的弯曲程度。

1.3.2 开发步骤

上一小节介绍了树干弯曲的基本原理,本小节将基于此原理开发一个呈现风吹椰林场景的案例Sample1_3,其运行效果如图1-10所示。

▲图1-10 案例Sample1_3的运行效果图

本案例运行时可以通过手指在屏幕上左右滑动使摄像机绕场景转动,上下滑动使摄像机推近或远离场景。通过单击手机上的菜单键,程序会弹出菜单。选择菜单中的风向选项可以设置风向,选择菜单中的风力选项可以设置风力,如图1-11所示。

了解了案例的运行效果后,接下来将对本案例的具体开发过程进行简要介绍。由于本案例中的大部分类和前面章节很多案例中的非常类似,因此,在这里只给出本案例中比较有代表性的与椰子树相关的部分,具体内容如下所列。

▲图1-11 设置风向和风力的界面

(1)首先给出的是用于生成椰子树树干原始位置顶点坐标的initVertexData方法,其来自于表示椰子树树干的TreeTrunk类,具体代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_3/com/bn/Sample1_3目录下的TreeTrunk.java。

1 public void initVertexData(floatbottom_radius,floatjoint_Height,intjointNum,intavailableNum){

2  List<Float> vertex_List=new ArrayList<Float>();  //顶点坐标列表

3  List<float[]> texture_List=new ArrayList<float[]>(); //顶点纹理坐标列表

4  for(int num=0;num<availableNum;num++){ //循环计算出每节树干中的各个顶点

5   float temp_bottom_radius=bottom_radius*(jointNum-num)/(float)jointNum;//此节树干底端半径

6   float temp_top_radius=bottom_radius*(jointNum-(num+1))/(float)jointNum;//此节树干顶端半径

7   float temp_bottom_height=num*joint_Height;   //此节树干底端的y坐标

8   float temp_top_height=(num+1)*joint_Height;  //此节树干顶端的y坐标

9   //循环一周,生成组成此节树干各个四边形的顶点坐标,并卷绕成三角形

10  for(float hAngle=0;hAngle<360;hAngle=hAngle+longitude_span) {

11    //当前四边形左上点x、y、z坐标

12    float x0=(float) (temp_top_radius*Math.cos(Math.toRadians(hAngle)));

13    float y0=temp_top_height;

14    float z0=(float) (temp_top_radius*Math.sin(Math.toRadians(hAngle)));

15    //当前四边形左下点x、y、z坐标

16    float x1=(float) (temp_bottom_radius*Math.cos(Math.toRadians(hAngle)));

17    float y1=temp_bottom_height;

18    float z1=(float) (temp_bottom_radius*Math.sin(Math.toRadians(hAngle)));

19    //当前四边形右上点x、y、z坐标

20    float x2=(float) (temp_top_radius*Math.cos(Math.toRadians(hAngle+longitude_span)));

21    float y2=temp_top_height;

22    float z2=(float) (temp_top_radius*Math.sin(Math.toRadians(hAngle+longitude_span)));

23    //当前四边形右下点x、y、z坐标

24    float x3=(float) (temp_bottom_radius*Math.cos(Math.toRadians(hAngle+longitude_span)));

25    float y3=temp_bottom_height;

26    float z3=(float) (temp_bottom_radius*Math.sin(Math.toRadians(hAngle+longitude_span)));

27    //将顶点坐标按照卷绕成两个三角形的顺序依次放入顶点坐标列表

28    vertex_List.add(x0);vertex_List.add(y0);vertex_List.add(z0);

29    vertex_List.add(x1);vertex_List.add(y1);vertex_List.add(z1);

30    vertex_List.add(x2);vertex_List.add(y2);vertex_List.add(z2);

31    vertex_List.add(x2);vertex_List.add(y2);vertex_List.add(z2);

32    vertex_List.add(x1);vertex_List.add(y1);vertex_List.add(z1);

33    vertex_List.add(x3);vertex_List.add(y3);vertex_List.add(z3);

34  }

35 ……//此处省略了计算纹理坐标以及将顶点坐标与纹理坐标送入缓冲的代码

36 }

提示

从上述代码中可以看出,椰子树的树干是由一节一节的圆形梯台组合而成的。每一节都是下面的半径大,上面的半径小,这也符合现实世界椰子树树干的情况。

(2)为了使树干能够根据风向与风力摆动,还需要在TreeTrunk类中增加将当前风向以及风力对应的树干曲率半径数据传入渲染管线的相关代码。由于将这两项数据传入渲染管线的代码与传递其他数据的代码没有本质区别,故这里不再赘述,需要的读者可以自行查看随书光盘中的源代码。

(3)接着给出的是根据风力、风向对树干顶点位置进行变换的顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_3/assets目录下的vertex_tree.sh。

1 uniform mat4 uMVPMatrix;     //总变换矩阵

2 uniform float bend_R;      //这里指的是树干弯曲的半径

3 uniform float direction_degree;   //用角度表示的风向,沿z轴正方向逆时针旋转

4 attribute vec3 aPosition;     //顶点位置

5 attribute vec2 aTexCoor;     //顶点纹理坐标

6 varying vec2 vTextureCoord;    //用于传递给片元着色器的纹理坐标

7 void main(){

8   float curr_radian=aPosition.y/bend_R;   //计算当前的弧度

9   float result_height=bend_R*sin(curr_radian); //计算当前点变换后的y坐标

10  float increase=bend_R-bend_R*cos(curr_radian);

11  float result_X=aPosition.x+increase*sin(radians(direction_degree));//计算当前点最后的x坐标

12  float result_Z=aPosition.z+increase*cos(radians(direction_degree));//计算当前点最后的z坐标

13  vec4 result_point=vec4(result_x,result_height,result_z,1.0);//最后结果顶点的坐标

14  gl_Position=uMVPMatrix*result_point;//根据总变换矩阵计算此次绘制此顶点的位置

15  vTextureCoord=aTexCoor;    //将接收的纹理坐标传递给片元着色器

16 }

说明

上述顶点着色器实现了上一小节介绍的顶点随风力、风向变换的算法。读者要想彻底掌握最好比对上一小节介绍的原理研读代码,直接看代码可能难于理解。

(4)介绍完树干部分后,下面介绍一下树叶随风摆动的相关代码。本案例中的树叶采用的是纹理矩形实现的,每棵椰子树有6片树叶(6个纹理矩形)。树叶会根据风向、风力改变位置姿态,本身不会发生形变。首先给出用于绘制树叶的纹理矩形的顶点及纹理坐标生成方法initVertexData,其来自于TreeLeaves类,具体代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_3/com/bn/Sample1_3目录下的TreeLeaves.java。

1 public void initVertexData(float width,float height,float absolute_height,intindex) {

2  vCount=6;

3  float vertices[]=null;         //顶点坐标数组

4  float texCoor[]=null;         //纹理坐标数组

5  switch(index) {  //根据情况编号生成对应角度树叶纹理矩形的顶点数据

6   case 0:   //第一种情况,树叶纹理矩形的边与x轴重合,对应旋转角度为0°

7   vertices=new float[]{ 0,height+absolute_height,0, 0,absolute_height,0,

8    width,height+absolute_height,0, width,height+absolute_height,0,

9     0,absolute_height,0, width,absolute_height,0,

10   };

11   texCoor=new float[]{ 1,0, 1,1, 0,0, 0,0, 1,1, 0,1 }; //纹理坐标

12   terX=width/2; enterZ=0;        //确定中心点坐标

13   break;

14   case 1:  //第一种情况,与x轴夹角60°的树叶纹理矩形

11  ……//此处省略了后面5种不同情况的代码,与第一种情况套路完全相同

15  }

提示

上述 initVertexData 方法的主要功能为根据情况编号生成对应角度树叶纹理矩形的顶点数据,情况编号从0~5,分别对应0°、60°、120°、180°、240°、300°6种情况。

(5)接下来给出的是根据当前帧对应的风向、风力计算出树叶纹理矩形位置与姿态数据的resultPoint方法,其来自TreeLeavesControl类,具体代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_3/com/bn/Sample1_3目录下的TreeLeavesControl.java。

1 public float[] resultPoint(float direction_degree,float currBend_R,floatpointX,float pointY,float pointZ){

2 float []position=new float[6];      //记录位置、姿态数据的数组

3 float curr_radian=pointY/currBend_R;    //计算当前的弧度

4 float result_Y=(float) (currBend_R*Math.sin(curr_radian)); //计算结果的y分量

1 //计算结果相对于中心点的偏移距离

5 float increase=(float) (currBend_R-currBend_R*Math.cos(curr_radian));

6 //计算结果的x、z分量

7 float result_X=(float) (pointX+increase*Math.sin(Math.toRadians(direction_degree)));

8 float result_Z=(float) (pointZ+increase*Math.cos(Math.toRadians(direction_degree)));

9 position[0]=result_x;       //将计算出的位置数据存入结果数组

10 position[1]=result_y;

11 position[2]=result_z;

12 position[3]=(float) Math.cos(Math.toRadians(direction_degree));//计算旋转轴的x分量

13 position[4]=(float) Math.sin(Math.toRadians(direction_degree));/计算旋转轴的z分量

14 position[5]=(float) Math.toDegrees(curr_radian);   //计算旋转的角度

15 return position;            //返回结果数组

16 }

● 第3行利用弧长公式计算出当前弯曲半径对应的弧度。

● 第4-8行根据计算出的弧度及风向计算出树叶位置偏移的x、y、z 分量。

● 第12-14行是根据当前的风力、风向计算出树叶的旋转轴x、z 分量以及旋转角度。

(6)最后给出的是绘制树叶的drawSelf方法,其来自TreeLeavesControl类,具体代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_3/com/bn/Sample1_3目录下的TreeLeaves Control.java。

1 public void drawSelf(int tex_leavesId,float bend_R,float wind_direction){//绘制树叶

2   MatrixState.pushMatrix();

3   MatrixState.translate(positionx, positiony, positionz);//移动到指定的位置

4   float curr_height=Constant.leaves_absolute_height; //当前叶子矩形的绝对高度

5   float result[]=resultPoint(wind_direction,bend_R,0,curr_height,0);//计算偏移量和旋转角

6   MatrixState.translate(result[0], result[1], result[2]);//进行偏移

7   MatrixState.rotate(result[5], result[3],0,-result[4]); //进行旋转

8   treeLeaves.drawSelf(tex_leavesId);      //绘制

9   MatrixState.popMatrix();

10 }

提示

此方法根据前面resultPoint方法计算出来的位置偏移数据,旋转轴、旋转角度数据,在绘制树叶前首先对坐标系进行对应的平移,然后再对坐标系进行对应的旋转,最后绘制树叶。

1.4 展翅飞翔的雄鹰

前面3节分别介绍了3个不同的软体的例子,虽然采用的数学模型各有不同,但都是通过编程直接实现特定的数学模型以实现软体动画的。这在一般的情况下足够用了,但如果想呈现非常复杂的软体动画就很困难了。

有些复杂软体的动画虽然也可以采用数学模型编程实现,但对应的数学模型非常复杂,编程成本很高。本节将给出一种非常简便的实现软体动画的策略——关键帧动画,通过其可以方便地实现游戏中雄鹰飞过蓝天、英雄举刀杀敌的动画。

1.4.1 基本原理

关键帧动画的基本思想非常简单,就是给顶点着色器提供动画中每个关键帧对应的各个顶点的位置数据以及融合比例。顶点着色器根据两套位置数据及当前融合的比例融合出一套结果顶点位置数据。只要在绘制每一帧时提供不同的混合比例即可产生想要的动画。

如本节将要给出的展翅飞翔的雄鹰动画中就用到了3个关键帧,包含4个动画阶段。

● 第一阶段是对1、2 号关键帧中的顶点数据进行融合,即从1号关键帧到2号关键帧。

● 第二阶段是对2、3 号关键帧中的顶点数据进行融合,即从2号关键帧到3号关键帧。

● 第三阶段是对3、2 号关键帧中的顶点数据进行融合,即从3号关键帧到2号关键帧。

● 第四阶段是对2、1 号关键帧中的顶点数据进行融合,即从2号关键帧到1号关键帧。

上述4个阶段不断重复就可以呈现出雄鹰展翅飞翔的动画,每个关键帧的具体顶点位置情况如图1-12所示。

说明

从图1-12中可以看出,最左侧的是雄鹰翅膀上扬到最高位置的情况,中间的是雄鹰翅膀放平的情况,右侧是雄鹰翅膀下垂到最低位置的情况。

▲图1-12 雄鹰动画3个关键帧的线框图

到这里读者可能会产生一个疑问:为什么一定要3个关键帧呢?仅保留1、3号关键帧不也能融合出动画吗?确实如此,只保留1、3两个关键帧是可以的,但动画的真实感就会大打折扣。因为仅通过1、3关键帧融合出来的翅膀展平的情况翅膀就会缩短,如图1-13所示。

从图1-13中可以看出使用关键帧动画的一个要领,那就是不重要的中间帧可以通过按比例融合两个关键帧得到,真实感基本不受影响。但关键帧不应该省略而通过其他关键帧融合得到,否则动画的真实感就会变差。

提示

使用基于顶点位置的融合的关键帧动画时有一点需要特别注意,那就是所有关键帧中顶点的数量要是一致的,并能够形成一一对应的关系。

1.4.2 开发步骤

上一小节中介绍了关键帧动画的基本原理以及注意事项,本小节将给出一个关键帧动画的案例Sample1_4,其运行效果如图1-14所示。

▲图1-13 仅保留1、3号关键帧的缺陷
▲图1-14 案例Sample1_4的运行效果图

说明

图1-14中给出了雄鹰展翅飞翔动画中的3帧画面,效果非常真实。由于本书插图是灰度印刷效果可能不是很好,请读者自行用真机运行本案例体会。案例运行时可以用手指在屏幕上滑动以旋转雄鹰从不同的角度观察。

了解了案例的运行效果后,接下来将对本案例的具体开发过程进行简要介绍。由于本案例中的大部分类和前面章节很多案例中的非常类似,因此在这里只给出本案例中比较有代表性的部分,具体内容如下所列。

(1)本案例中用到的雄鹰的3个关键帧是采用3ds Max 设计并导出成obj文件的,因此首先需要将3个关键帧的顶点数据加载进应用程序并存放到缓冲中,相关代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_4/com/bn/Sample1_4目录下的GledeForDraw.java。

1 public void initVertexData(MySurfaceView mv) {  //初始化顶点数据的方法

2   //加载雄鹰模型

3   glede_one=LoadUtil.loadFromFileVertexOnly("laoying01.obj",mv);

4   glede_two=LoadUtil.loadFromFileVertexOnly("laoying02.obj",mv)[0];

5   glede_three=LoadUtil.loadFromFileVertexOnly("laoying03.obj",mv)[0];

6   //创建第一个顶点坐标数据缓冲

7  vCount=glede_one.length/3;

8  ByteBuffer vbb=ByteBuffer.allocateDirect(glede_one[0].length*4);

9  vbb.order(ByteOrder.nativeOrder());  //设置字节顺序

10  mVertexBuffer1=vbb.asFloatBuffer();  //转换为Float型缓冲

11  mVertexBuffer1.put(glede_one[0]);   //向缓冲区中放入顶点坐标数据

12  mVertexBuffer1.position(0);    //设置缓冲区起始位置

13  //创建第二个顶点坐标数据缓冲

14  vCount=glede_two.length/3;

15  vbb=ByteBuffer.allocateDirect(glede_two.length*4);

16  vbb.order(ByteOrder.nativeOrder());  //设置字节顺序

17  mVertexBuffer2=vbb.asFloatBuffer();  //转换为Float型缓冲

18  mVertexBuffer2.put(glede_two);    //向缓冲区中放入顶点坐标数据

19  mVertexBuffer2.position(0);    //设置缓冲区起始位置

20  //创建第三个顶点坐标数据缓冲

21  vCount=glede_three.length/3;

22  vbb=ByteBuffer.allocateDirect(glede_three.length*4);

23  vbb.order(ByteOrder.nativeOrder());  //设置字节顺序

24  mVertexBuffer3=vbb.asFloatBuffer();  //转换为Float型缓冲

25  mVertexBuffer3.put(glede_three);   //向缓冲区中放入顶点坐标数据

26  mVertexBuffer3.position(0);    //设置缓冲区起始位置

27  //创建纹理坐标数据缓冲

28  ByteBuffer tbb=ByteBuffer.allocateDirect(glede_one[1].length*4);

29  tbb.order(ByteOrder.nativeOrder());  //设置字节顺序

30  mTexCoorBuffer=tbb.asFloatBuffer();  //转换为Float型缓冲

31  mTexCoorBuffer.put(glede_one[1]);   //向缓冲区中放入顶点纹理坐标数据

32  mTexCoorBuffer.position(0);    //设置缓冲区起始位置

33 }

说明

上述代码主要是将obj文件中的顶点位置与纹理坐标数据加载进来。由于3个关键帧中各个对应顶点的纹理坐标是相同的,因此纹理坐标仅保留了一套。但各个关键帧中对应顶点的位置是不同的,因此顶点数据有3套。

(2)为了在顶点着色器中能够根据比例融合关键帧中的顶点数据,因此需要将融合的比例传入渲染管线。由于有3个关键帧,因此融合比例的取值在0~2连续变化。由于将融合比例送入渲染管线的代码非常简单,这里就不再赘述,需要的读者请自行参考光盘中的源代码。

(3)接着需要介绍的是执行顶点融合以产生关键帧动画的顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_4/assets目录下的vertex.sh。

1 uniform mat4 uMVPMatrix;      //总变换矩阵

2 attribute vec3 aPosition;      //顶点位置(来自1号关键帧)

3 attribute vec3 bPosition;      //顶点位置(来自2号关键帧)

4 attribute vec3 cPosition;      //顶点位置(来自3号关键帧)

5 attribute vec2 aTexCoor;      //顶点纹理坐标

6 uniform float uBfb;        //融合比例

7 varying vec2 vTextureCoord;      //用于传递给片元着色器的纹理坐标

8 void main(){

9 vec3 tv;          //融合后的结果顶点

10  if(uBfb<=1.0) { //若融合比例小于等于1,则需要执行的是1、2号关键帧的融合

11  tv=mix(aPosition,bPosition,uBfb)

12  }else{   //若融合比例大于1,则需要执行的是2、3号关键帧的融合

13  tv=mix(bPosition,cPosition,uBfb-1.0);

14  }

15  gl_Position=uMVPMatrix*vec4(tv,1);//根据总变换矩阵计算此次绘制此顶点的位置

16  vTextureCoord=aTexCoor;    //将接收的纹理坐标传递给片元着色器

17 }

说明

上述顶点着色器是实现关键帧动画的核心,其根据传入的融合比例选择对应的两个关键帧进行融合。要注意的是,融合时是调用mix函数完成的,这是为了提高执行效率。实际开发中有些功能既可以采用函数完成也可以自己编程完成,笔者强烈建议直接调用函数完成。这是因为系统的函数在大部分情况下比自己开发的同功能代码片段性能优异。

1.5 二维扭曲

本章前面几节的案例都是在3D空间中对顶点位置进行的变换,但本节将给出一个在2D空间中基于顶点位置变换进行二维扭曲的案例。

提示

本质上可以采用本节介绍的技术扭曲任何2D形状,为了便于介绍,本节将基于等边三角形进行介绍。

1.5.1 基本原理

介绍本节案例的具体开发之前,首先需要了解一下二维扭曲的基本情况,如图1-15所示。

▲图1-15 扭动的三角形线框图
▲图1-16 扭动的三角形原理图

从图1-15中可以看出,左侧的原始三角形经过扭曲处理后产生了右侧奇异的形状,犹如一个风车。同时从图1-15中可以看出,要想能对原始三角形实现扭曲处理,必须将大三角形切分为很多的小三角形。下面就简单介绍一下扭曲的计算思路(如图1-16所示),具体步骤如下。

(1)设扭动的中心点为点O,其坐标为(X0,Y0);绕中心点被扭动的点为D,其坐标为(X1,Y1)。

(2)设D点在x方向上的偏移为XSpan,y方向上的偏移为YSpan,则有如下结论。

XSpan=X1-X0

YSpan=Y1-Y0

(3)接着就可以求出OD与x轴正方向的夹角θ,具体情况如下。

● 如果XSpan=0,并且YSpan 大于0,那么θ=π/2。

● 如果XSpan=0,并且YSpan 小于0,那么θ=3π/2。

● 如果XSpan不等于0,那么θ=atan(YSpan/XSpan)。

(4)然后计算旋转后的D点与x轴正方向的夹角θ'。

θ'=θ+ratio×OD

说明

其中 ratio 表示与当前总体旋转角度线性相关的一个系数,用于将距离转化为当前考察点的旋转角度。

(5)计算出旋转后的夹角后,就可以求出旋转后点的x、y坐标了。

X1'=X0+OD×cos(θ')

Y1'=Y0+OD×sin(θ')

只需要用顶点着色器实现上述算法即可得到非常漂亮的二维扭曲效果。

1.5.2 开发步骤

上一小节中介绍了二维扭曲的基本原理以及注意事项,本小节将给出一个三角形二维扭曲的案例Sample1_5,其运行效果如图1-17所示。

▲图1-17 案例Sample1_5的运行效果图

了解了案例的运行效果后,接下来将对本案例的具体开发过程进行简要介绍。由于本案例中的大部分类和前面章节很多案例中的非常类似,因此在这里只给出本案例中比较有代表性的部分,具体内容如下所列。

(1)从前面的介绍中已经知道,本案例中的大三角形实际上是由很多小三角形构成的。因此,下面首先给出的是自动生成各个小三角形顶点数据的initVertexData方法,其来自MultiTrangle类,具体代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_5/com/bn/Sample1_5目录下的MultiTrangle.java。

1 public void initVertexData(float edgeLength,int levelNum) {

2   ArrayList<Float> al_vertex=new ArrayList<Float>(); //顶点坐标数据列表

3   ArrayList<Float> al_texture=new ArrayList<Float>();//纹理坐标数据列表

4   float perLength=edgeLength/levelNum;    //小三角形的边长

5   for(int i=0;i<levelNum;i++){      //循环每一层生成小三角形

6    int currTopEdgeNum=i;       //当前层顶端边数

7    int currBottomEdgeNum=i+1;     //当前层底端边数

8    float currTrangleHeight=(float) (perLength*Math.sin(Math.PI/3));//每个三角形的高度

9    float topEdgeFirstPointX=-perLength*currTopEdgeNum/2;//当前层顶端最左边点的x坐标

10    float topEdgeFirstPointY=-i*currTrangleHeight;//当前层顶端最左边点的y坐标

11    float topEdgeFirstPointZ=0;    //当前层顶端最左边点的z坐标

12    float bottomEdgeFirstPointX=-perLength*currBottomEdgeNum/2;//当前层底端最

13    float bottomEdgeFirstPointY=-(i+1)*currTrangleHeight; //左边点的x、

14    float bottomEdgeFirstPointZ=0;    //y、z坐标

15    float horSpan=1/(float)levelNum;    //横向纹理的偏移量

16    float verSpan=1/(float)levelNum;    //纵向纹理的偏移量

17    float topFirstS=0.5f-currTopEdgeNum*horSpan/2;//当前层顶端最左边点的

18    float topFirstT=i*verSpan;     //纹理ST坐标

19    float bottomFirstS=0.5f-currBottomEdgeNum*horSpan/2;//当前层底端最左边点的

20    float bottomFirstT=(i+1)*verSpan;    //纹理ST坐标

21    for(int j=0;j<currBottomEdgeNum;j++){//循环产生当前层各个上三角形的顶点数据

22     float topX=topEdgeFirstPointX+j*perLength; //当前三角形顶端点的

23     float topY=topEdgeFirstPointY;    //x、y、z坐标

24     float topZ=topEdgeFirstPointZ;

25     float topS=topFirstS+j*horSpan;    //当前三角形顶端点的

26     float topT=topFirstT;      //S、T纹理坐标

27     float leftBottomX=bottomEdgeFirstPointX+j*perLength;//当前三角形左下侧

28     float leftBottomY=bottomEdgeFirstPointY;  //点的x、y、z坐标

29     float leftBottomZ=bottomEdgeFirstPointZ;

30     float leftBottomS=bottomFirstS+j*horSpan; //当前三角形左下侧

31     float leftBottomT=bottomFirstT;    //点的S、T纹理坐标

32     float rightBottomX=leftBottomX+perLength; //当前三角形右下侧

33     float rightBottomY=bottomEdgeFirstPointY; //点的x、y、z坐标

34     float rightBottomZ=bottomEdgeFirstPointZ;

35     float rightBottomS=leftBottomS+horSpan;  //当前三角形右下侧

36     float rightBottomT=leftBottomT;    //点的S、T纹理坐标

37     //将当前三角形顶点数据按照逆时针顺序送入顶点坐标、纹理坐标列表

38     al_vertex.add(topX);al_vertex.add(topY);al_vertex.add(topZ);

39     al_vertex.add(leftBottomX);al_vertex.add(leftBottomY);al_vertex.add(leftBottomZ);

40     al_vertex.add(rightBottomX);al_vertex.add(rightBottomY);al_vertex.add(rightBottomZ);

41     al_texture.add(topS);al_texture.add(topT);

42     al_texture.add(leftBottomS);al_texture.add(leftBottomT);

43     al_texture.add(rightBottomS);al_texture.add(rightBottomT);

44   }

45    for(int k=0;k<currTopEdgeNum;k++){//循环产生当前层各个下三角形的顶点数据

46     float leftTopX=topEdgeFirstPointX+k*perLength;//当前三角形左上侧

47     float leftTopY=topEdgeFirstPointY;   //点的x、y、z坐标

48     float leftTopZ=topEdgeFirstPointZ;

49     float leftTopS=topFirstS+k*horSpan;   //当前三角形左上侧

50     float leftTopT=topFirstT;     //点的S、T纹理坐标

51     float bottomX=bottomEdgeFirstPointX+(k+1)*perLength;//当前三角形底端点

52     float bottomY=bottomEdgeFirstPointY;   //的x、y、z坐标

53     float bottomZ=bottomEdgeFirstPointZ;

54     float bottomS=bottomFirstS+(k+1)*horSpan; //当前三角形右底端点

55     float bottomT=bottomFirstT;     //的S、T纹理坐标

56     float rightTopX=leftTopX+perLength;   //当前三角形右上侧

57     float rightTopY=leftTopY;     //点的x、y、z坐标

58     float rightTopZ=leftTopZ;

59     float rightTopS=leftTopS+horSpan;   //当前三角形右上侧

60     float rightTopT=topFirstT;     //点的S、T纹理坐标

61     al_vertex.add(leftTopX);al_vertex.add(leftTopY);al_vertex.add(leftTopZ);//逆时针卷绕

62     al_vertex.add(bottomX);al_vertex.add(bottomY);al_vertex.add(bottomZ);

63     al_vertex.add(rightTopX);al_vertex.add(rightTopY);al_vertex.add(rightTopZ);

64     al_texture.add(leftTopS);al_texture.add(leftTopT);

65     al_texture.add(bottomS);al_texture.add(bottomT);

66     al_texture.add(rightTopS);al_texture.add(rightTopT);

67    }}

68  ……//此处省略部分代码,需要的读者请参考光盘

69 }

说明

上述代码根据传入的大三角形边长及分层数量自动计算出了每一层中各个三角形的顶点坐标、纹理坐标。每一层中的三角形分为上三角形与下三角形,顶点计算方法不同,如图1-18所示。

▲图1-18 每层的上三角形与下三角形

(2)要能够呈现出扭动的三角形就需要在绘制每一帧时将不同的、连续变化的整体扭动角度因子(就是上一小节算法中的ratio)传入渲染管线。由于这部分代码非常简单,这里不再赘述,需要的读者请参考随书光盘。

(3)接着介绍的是实现二维扭曲算法的顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_5/assets目录下的vertex_tex.sh。

1 uniform mat4 uMVPMatrix;       //总变换矩阵

2 attribute vec3 aPosition;       //顶点位置

3 attribute vec2 aTexCoor;      //顶点纹理坐标

4 varying vec2 vTextureCoord;      //用于传递给片元着色器的纹理坐标

6 uniform float ratio;       //当前整体扭动角度因子

7 void main(){

8   float pi=3.1415926;      //圆周率

9   float centerX=0.0;      //中心点x坐标

10  float centerY=-5.0;      //中心点y坐标

11  float currX=aPosition.x;    //当前点x坐标

12  float currY=aPosition.y;    //当前点y坐标

13  float spanX=currX -centerX;   //当前X偏移量

14  float spanY=currY -centerY;   //当前Y偏移量

15  float currRadius=sqrt(spanX*spanX+spanY*spanY);//计算距离

16  float currRadians;     //当前点与x轴正方向的夹角,用弧度表示

17  if(spanX !=0.0) {     //计算当前点与x轴正方向的夹角

18    currRadians=atan(spanY , spanX);//一般情况

19  }else{

20    currRadians=spanY > 0.0 ? pi/2.0 : 3.0*pi/2.0;//0.5π和1.5π的特殊情况

21  }

22  float resultRadians=currRadians+ratio*currRadius; //计算出扭曲后的角度

23  float resultX=centerX+currRadius*cos(resultRadians);//计算结果点的x坐标

24  float resultY=centerY+currRadius*sin(resultRadians);//计算结果点的y坐标

25  //构造结果点,并根据总变换矩阵计算此次绘制此顶点的位置

26  gl_Position=uMVPMatrix*vec4(resultX,resultY,0.0,1);

27  vTextureCoord=aTexCoor;    //将接收的纹理坐标传递给片元着色器

28 }

说明

上述顶点着色器实现了上一小节介绍的二维扭曲算法,通过应用此顶点着色器可以对任意的二维物体进行扭曲,并不一定是三角形。

进行二维扭曲效果的开发时,有一个需要特别注意的地方。那就是提供给渲染管线的模型一定要分得比较细,如果分得比较粗糙,最终的结果就会比较差,如图1-19所示。

▲图1-19 模型切分得很粗导致扭曲效果很差

说明

图1-19 给出了将本案例中的大三角形切分得比较粗糙后的运行效果以及线框图。从图1-19中可以看出,切分得很粗糙后实际扭曲的效果与期望的效果之间就有较大的差距了。

1.6 吹气膨胀特效

通过前面几个小节的学习,读者应该对顶点着色器的使用有了一定的了解。本节将进一步给出一个通过使用顶点着色器实时改变3D模型中顶点的位置,以实现物体吹气膨胀效果的案例。

1.6.1 特效基本原理

介绍本节案例的具体开发之前,首先需要了解一下本节案例实现吹气膨胀特效的基本原理,如图1-20所示。

从图1-20中可以看出,实现吹气膨胀特效时,由顶点着色器根据收到的参数将当前处理的顶点位置沿当前顶点的法向量方向移动一定的距离。每次处理时移动距离的大小由传入的参数控制,这样就可以非常方便地实现吹气膨胀的效果了。

▲图1-20 吹气膨胀特效的基本原理

1.6.2 特效开发步骤

上一小节介绍了实现物体吹气膨胀特效的基本原理,本小节将给出一个基于此原理开发的实现人物头部3D模型不断吹气膨胀的案例Sample1_6,其运行效果如图1-21所示。

▲图1-21 案例Sample1_6的运行效果图

了解了本节案例的运行效果后,接下来将对本节案例的具体开发过程进行简单介绍。由于本案例中的大部分代码与本书前面的很多案例非常类似,因此,这里仅给出本案例中有代表性的部分,具体内容如下所列。

(1)首先介绍的是用于在程序运行过程中不断修改吹气膨胀程度系数(fatFacror 变量)的drawSelf 方法,此方法来自于 LoadedObjectVertexNormalTexture 类。LoadedObjectVertexNormalTexture类在本书第一卷的第9章中介绍过,其对象表示从Obj文件中加载的3D模型。本案例中用于加载包含了人物头部的3D模型,其中drawSelf方法的具体代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_6/com/bn/Sample1_6目录下的LoadedObjectVertexNormalTexture.java。

1  public void drawSelf(int texId){

2  fatFacror+=fatFacrorStep;      //计算新的膨胀系数

3  if(fatFacror>0.05f||fatFacror<0){    //若膨胀系数达到上限或下限

4   fatFacrorStep=-fatFacrorStep;    //将膨胀系数的符号置反

5  }

6  ……//此处省略了部分代码,与本书前面案例中的类似,有兴趣的读者可以自行查看随书光盘

7  GLES20.glUniform1f(muFatFactor, fatFacror); //将膨胀系数传入着色器

8  ……//此处省略了部分代码,与本书前面案例中的类似,有兴趣的读者可以自行查看随书光盘

9  }

提示

上述代码的主要功能为不断地修改物体的吹气膨胀系数,并把膨胀系数传入着色器程序。

(2)接着介绍的是接收吹气膨胀系数,并根据系数将顶点位置沿法向量方向移动一定距离的顶点着色器,其具体代码如下。

代码位置:见随书光盘中源代码/第1章/Sample1_6/assets目录下的vertex.sh。

1 uniform float uFatFactor;     //接收的吹气膨胀系数

2 uniform mat4 uMVPMatrix;      //总变换矩阵

3 uniform mat4 uMMatrix;      //变换矩阵

4 uniform vec3 uLightLocation;     //光源位置

5 uniform vec3 uCamera;      //摄像机位置

6 attribute vec3 aPosition;     //顶点位置

7 attribute vec3 aNormal;      //顶点法向量

8 attribute vec2 aTexCoor;      //顶点纹理坐标

9 varying vec4 ambient;      //用于传递给片元着色器的环境光最终强度

10 varying vec4 diffuse;      //用于传递给片元着色器的散射光最终强度

11 varying vec4 specular;      //用于传递给片元着色器的镜面光最终强度

12 varying vec2 vTextureCoord;     //用于传递给片元着色器的纹理坐标

13 void pointLight(        //定位光光照计算的方法

14 in vec3 normal,       //法向量

15 inout vec4 ambient,      //环境光最终强度

16 inout vec4 diffuse,      //散射光最终强度

17 Inout vec4 specular,      //镜面光最终强度

18 in vec3 lightLocation,      //光源位置

19 in vec4 lightAmbient,      //光源环境光强度

20 in vec4 lightDiffuse,      //光源散射光强度

21 in vec4 lightSpecular      //光源镜面光强度

22 ){

23 ambient=lightAmbient;      //直接得出环境光的最终强度

24 vec3 normalTarget=aPosition+normal;

25 vec3 newNormal=       //计算变换后的法向量 (uMMatrix*vec4(normalTarget,1)).xyz-(uMMatrix*vec4(aPosition,1)).xyz;

26 newNormal=normalize(newNormal);   //对法向量规格化

27 //计算从表面点到摄像机的向量

28 vec3 eye=normalize(uCamera-(uMMatrix*vec4(aPosition,1)).xyz);

29 //计算从表面点到光源位置的向量vp

30 vec3 vp=normalize(lightLocation-(uMMatrix*vec4(aPosition,1)).xyz);

31 vp=normalize(vp);       //格式化vp

32 vec3 halfVector=normalize(vp+eye);   //求视线与光线的半向量

33 float shininess=50.0;      //粗糙度,越小越光滑

34 float nDotViewPosition=max(0.0,dot(newNormal,vp));//求法向量与vp的点积与0的最大值

35 diffuse=lightDiffuse*nDotViewPosition;  //计算散射光的最终强度

36 float nDotViewHalfVector=dot(newNormal,halfVector);  //法线与半向量的点积

37 float powerFactor=max(0.0,pow(nDotViewHalfVector,shininess));//镜面反射光强度因子

38 specular=lightSpecular*powerFactor;      //计算镜面光的最终强度

39 }

40 void main()

41 {

42  //根据总变换矩阵计算绘制此顶点的位置,在计算时将顶点位置沿着法向量方向移动一定的距离

43  gl_Position=uMVPMatrix*vec4(aPosition+aNormal*uFatFactor,1);

44  vec4 ambientTemp, diffuseTemp, specularTemp;//环境光、散射光、镜面反射光的临时变量

45  pointLight(normalize(aNormal),ambientTemp,diffuseTemp,specularTemp,uLightLocation,vec4(0.15,0.15,0.15,1.0),vec4(0.9,0.9,0.9,1.0),vec4(0.4,0.4,0.4,1.0));

46  ambient=ambientTemp;    //将环境光最终强度传给片元着色器

47  diffuse=diffuseTemp;    //将散射光最终强度传给片元着色器

48  specular=specularTemp;   //将镜面光最终强度传给片元着色器

49  vTextureCoord=aTexCoor;  //将接收的纹理坐标传递给片元着色器

50 }

提示

从上述顶点着色器的程序中可以看出,大部分都是与前面案例相同的,如计算定位光光照等。最能体现本节案例特点的就是第43行的代码,其在计算顶点经过变换后的最终位置时不是直接针对顶点的坐标计算的。而是首先将顶点坐标沿着顶点的法向量方向移动一定的距离(移动距离的大小由接收的吹气膨胀系数uFatFactor来确定),然后再与变换矩阵相乘。

1.7 OpenGL ES 1.x与OpenGL ES 2.0实现方案的对比

本章最开始提到过,在OpenGL ES 1.x 平台上想高效地实现本章案例中的特效是非常困难的。这是因为OpenGL ES 1.x 采用的是固定渲染管线,顶点数据一旦送入渲染管线后就不可能对其进行自定义的处理了。因此,在OpenGL ES 1.x 上想实现本章案例中的特效只能采用如下两种策略之一。

1.初始化时预先计算数据

此种策略的基本思想非常简单,就是在初始化时将动画中每一帧画面的顶点数据都计算出来,绘制每一帧画面时将与此帧画面对应的顶点数据送入渲染管线即可。因此,此策略就有两方面明显的局限性。

● 动画中的帧数不能过多,否则会占用大量的内存,导致程序不能正常执行。由于帧数不是很多,因此对于动态范围大的动画就会不太平滑,效果一般。

● 动画中的每一帧都是预先计算好的,不能够根据用户交互情况的变化而变化,灵活性差。

2.绘制每帧画面前由CPU临时计算顶点数据

此种策略其实就是将本章案例中由顶点着色器完成的工作改为绘制每帧画面前由 CPU 来完成,其可以解决第一种策略的两个局限,但其本身也有很大的局限性。因为在此情况下,CPU一方面需要处理顶点数据,同时还承载了很多处理其他业务逻辑的任务,如人机交互、物理碰撞等,这会导致CPU不堪重负,整个程序运行很慢。

从上述 OpenGL ES 1.x 平台上实现本章案例特效可能采用的两种策略中可以看出,基于OpenGL ES 1.x很难完全发挥出GPU强大的处理能力,因此,整个3D开发产业现在都在向OpenGL ES 2.0 迈进。读者在以后的开发中也可以多思考、多总结,将能够由着色器完成的工作都让着色器去完成,尽量把CPU解放出来。

提示

笔者也是从OpenGL ES 1.x平台走过来的,那时开发出的一些高级的特效,一方面代码很长,开发成本高。另一方面,运行速度慢,不得不做出很多牺牲。现在有了OpenGL ES 2.0,原来的很多限制都不复存在了,相信随着硬件的进一步发展,可以开发出更多、更好的3D特效。

1.8 本章小结

本章主要介绍了顶点着色器的几种巧妙用法,并通过典型的案例实现了这些用法。通过本章的学习,读者可以进一步领会到顶点着色器的强大作用,为以后开发出更加酷炫的3D 应用打下坚实的基础。

第2章 片元着色器的妙用

上一章介绍了顶点着色器的妙用,给出了不少顶点着色器的使用技巧。其实片元着色器在开发中一样可以大显身手,本章将通过几个非常实用的案例来介绍这方面的知识。

2.1 程序纹理技术

《Open GL ES 2.0 游戏开发(上卷)》的案例中对物体进行纹理映射时采用的都是预先由美工人员设计好的纹理图,这种模式能满足大部分的需要。但需要满足一些特殊的效果时,也可以使用片元着色器程序来基于一定的规则计算每个片元的颜色,这就是程序纹理技术。

提示

其实《Open GL ES 2.0游戏开发(上卷)》第6章介绍光照时对圆球采用的棋盘着色器就是基于程序纹理技术,本节将再给出两个这方面的案例。

2.1.1 砖块着色器

本小节将介绍本节的第一个程序纹理着色器——砖块着色器,其基本原理如图2-1所示。

▲图2-1 砖块着色器原理图

从图2-1中可以看出,砖块着色器可以实现类似于砖墙的效果,具体的实现细节如下。

(1)先根据需着色片元的某种参数计算出片元位于哪一行(纵向分割),并记录下行号。

(2)再根据片元的参数计算出片元是否位于此行的区域1中(纵向分割),若位于区域1中,则采用砖块缝隙的水泥色着色。

(3)若片元不在区域1中,则根据行号的奇偶性及片元的参数计算出片元是否位于此行的区域3中(横向分割)。若位于区域3中则采用砖块色着色,否则也采用砖块缝隙的水泥色着色。之所以需要依据行号的奇偶性是因为奇数行与偶数行要偏移半个砖块。

提示

进行纵向分割时的依据有很多选择,如本小节案例是对球面进行的,则可以采用纬度。若表面是平面,则可以采用某个轴的坐标。进行横向分割时的依据也有很多选择,如本小节案例是对球面进行的,则可以采用经度。若表面是平面,则也可以采用某个轴的坐标。

了解了砖块着色器的基本原理后,下面请读者了解一下本小节案例Sample2_1的运行效果,如图2-2所示。

▲图2-2 案例Sample2_1的运行效果图

从图2-2中可以看出,本小节案例是针对球面应用的砖块着色器。由于构建球面的知识在本书第一卷中已经详细介绍过,故这里仅给出与砖块着色器相关的部分代码,具体内容如下。

(1)首先在负责绘制球面的Ball类中,需要增加将顶点经纬度数据传入渲染管线的相关代码,此部分代码与将顶点坐标传递进渲染管线的代码套路完全一致。因此这里不再赘述,需要的读者请参考随书光盘中的源代码。

(2)接下来给出的是顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_1/assets目录下的vertex.sh。

1 uniform mat4 uMVPMatrix;     //总变换矩阵

2 attribute vec3 aPosition;     //顶点位置

3 attribute vec2 aLongLat;     //顶点经纬度

4 varying vec2 mcLongLat;     //用于传递给片元着色器的顶点经纬度

5 void main() {

6  gl_Position=uMVPMatrix*vec4(aPosition,1);//根据总变换矩阵计算此次绘制此顶点的位置

7  mcLongLat=aLongLat;      //将顶点的经纬度传递给片元着色器

8 }

说明

与普通的顶点着色器相比,上述顶点着色器中主要是增加了接收从管线传入的顶点经纬度,并将其传递给片元着色器的相关代码。

(3)最后给出的是片元着色器,其代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_1/assets目录下的frag.sh。

1 precision mediump float;     //给出默认的浮点精度

2 varying vec2 mcLongLat;     //从顶点着色器传递过来的经纬度

3 void main() {

4  vec3 bColor=vec3(0.678,0.231,0.129);  //砖块的颜色

5  vec3 mColor=vec3(0.763,0.657,0.614);  //水泥的颜色

6  vec3 color;        //片元的最终颜色

7  int row=int(mod((mcLongLat.y+90.0)/12.0,2.0));//计算当前片元位于奇数行还是偶数行

8  float ny=mod(mcLongLat.y+90.0,12.0); //计算当前片元是否在此行区域1中的辅助变量

9  float oeoffset=0.0;     //每行的砖块偏移值,奇数行偏移半个砖块

10  float nx;        //当前片元是否在此行区域3中的辅助变量

11  if(ny>10.0) {       //位于此行的区域1中

12  color=mColor;      //采用水泥色着色

13  } else {        //不位于此行的区域1中

14  if(row==1) {       //若为奇数行则偏移半个砖块

15   oeoffset=11.0;

16  }

17  nx=mod(mcLongLat.x+oeoffset,22.0); //计算当前片元是否在此行区域3中的辅助变量

18  if(nx>20.0) {      //不位于此行的区域3中

19   color=mColor;      //采用水泥色着色

20  } else {        //位于此行的区域3中

21   color=bColor;      //采用砖块色着色

22  }}

23  gl_FragColor=vec4(color,0);   //将片元的最终颜色传递进管线

24 }

说明

上述片元着色器按照前面介绍的砖块着色器的原理实现了针对球面的砖块着色器,分割的依据是片元的经纬度。有兴趣的读者还可以针对其他表面基于不同的分割依据实现砖块着色器,以进一步加深理解。

2.1.2 沙滩球着色器

上一小节介绍了砖块着色器,本小节将介绍另一种基于程序纹理技术的着色器——沙滩球着色器。沙滩球着色器的实现相比于砖块着色器要简单不少,主要思路为,将靠近球面两级的片元用白色着色,其他部分按照经度切分,以不同的颜色着色。

介绍本小节案例Sample2_2所使用的着色器前,首先请读者了解一下本小节案例的运行效果,如图2-3所示。

▲图2-3 案例Sample2_2的运行效果图

了解了案例的运行效果后,下面就可以进行案例的开发了。由于本案例中的大部分代码与上一小节的案例非常相似,主要的区别在于片元着色器。因此这里仅给出本案例中的片元着色器,其代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_2/assets目录下的frag.sh。

1 precision mediump float;     //给出默认的浮点精度

2 varying vec4 vAmbient;      //从顶点着色器传递过来的环境光分量

3 varying vec4 vDiffuse;      //从顶点着色器传递过来的散射光分量

4 varying vec4 vSpecular;     //从顶点着色器传递过来的镜面光分量

5 varying vec2 mcLongLat;     //从顶点着色器传递过来的经纬度

6 void main() {

7  vec3 color;        //片元的最终颜色

8  if(abs(mcLongLat.y)>75.0){

9   color=vec3(1.0,1.0,1.0);   //两极附近是白色

10  }else{

11  int colorNum=int(mcLongLat.x/45.0); //根据经度计算出颜色编号

12  if(colorNum==0){

13   color=vec3(1.0,0.0,0.0);  //0号颜色

14  }else if(colorNum==1){

15   color=vec3(0.0,1.0,0.0);  //1号颜色

16  }else if(colorNum==2){

17   color=vec3(0.0,0.0,1.0);  //2号颜色

18  }else if(colorNum==3){

19   color=vec3(1.0,1.0,0.0);  //3号颜色

20  }else if(colorNum==4){

21   color=vec3(1.0,0.0,1.0);  //4号颜色

22  }else if(colorNum==5){

23   color=vec3(0.0,1.0,1.0);  //5号颜色

24  }else if(colorNum==6){

25   color=vec3(0.3,0.4,0.7);  //6号颜色

26  }else if(colorNum==7){

27   color=vec3(0.3,0.7,0.2);  //7号颜色

28  }}

29  vec4 finalColor=vec4(color,1.0);  //将颜色扩充为带Alpha通道的Vec4类型

30  //综合3个通道光的最终强度及片元的颜色计算出最终片元的颜色并传递给管线

31  gl_FragColor=finalColor*vAmbient+finalColor*vDiffuse+finalColor*vSpecular;

32 }

● 第8-9行中首先判断片元是否靠近两极,如果靠近两极,就采用白色对片元着色。

● 第11-27行首先将不靠近两极的片元根据经度确定其位于的颜色区域,然后采用对应区域的既定颜色对片元着色。

提示

相比于上一小节中砖块着色器的案例,本案例中还引入了光照。光照部分的相关代码与本书第一卷第6章介绍光照时案例中的完全一致,需要的读者请参考随书光盘中的源代码。

2.2 数字图像处理

随着数码相片逐渐替代传统胶卷相片成为主流,数字图像处理与我们生活的关系也越来越密切了。例如经常用来进行相片后期制作的PhotoShop中就带有很多特效滤镜,如:模糊、浮雕、锐化等,这些都是基于数字图像处理技术开发的。

传统的数字图像处理应用大部分是基于CPU开发的,由于CPU运算能力的限制,处理一幅图片需要较长的时间。本节将介绍的数字图像处理应用是基于GPU开发的,具有开发简便,速度快等优势。

2.2.1 卷积的基本知识

数字图像处理中用到的很多滤镜都是基于卷积计算开发的,因此本小节将对卷积计算的原理进行简要的介绍。卷积是一种很常见的数字图像处理操作,其可以用来过滤一幅图像。实现过滤的方法是计算源图像与卷积内核之间的积,所谓卷积内核是指一个n×n的矩阵,n一般为奇数。进行卷积计算时将卷积内核对待处理图像中的每个像素都应用一次,具体的计算思路如图2-4所示。

▲图2-4 卷积基本原理

从图2-4中可以看出,卷积计算将需要处理的图片中的每个像素计算一次,具体的计算方法如下。

● 首先将卷积内核中心的元素对准当前待处理的像素,此时卷积内核中的其他元素也各自对应到了一个像素。

● 然后将卷积内核中的各元素与其对应的像素的颜色值相乘。

● 最后将所有的乘积加权求和即可得到处理后图片中此位置像素的颜色值。

根据卷积内核中个各元素值的不同,可以轻松地实现模糊、边缘检测、锐化、浮雕等滤镜的特效,下面的小节将陆续给出这方面的案例。

提示

请读者注意的是,卷积内核的尺寸并不一定是 3×3,也可以是 5×5、7×7 等。但卷积内核越大,计算量成几何级数增长,因此采用3×3的情况较多。

2.2.2 平滑过滤

平滑过滤可以将过于锐利的照片变得平滑些,实现起来也非常简单,只需要将卷积内核的所有元素值都设为1即可,下面的表2-1给出了本节案例所采用的3×3平滑过滤的卷积内核。

了解了平滑过滤的卷积内核后,下面请读者进一步了解一下本小节案例Sample2_3的运行效果,如图2-5所示。

表2-1 实现平滑过滤的卷积内核
▲图2-5 案例Sample2_3的运行效果图

说明

图2-5中左侧为处理前的原图,右侧为经过平滑过滤的图像。细致比对左右两幅图可以看出,右侧的图片比左侧的要平滑了不少。由于插图灰度印刷的原因,读者也可能看得不是很清楚,此时请读者自行在真机上运行本案例观察。

了解了案例所采用的卷积内核与案例的运行效果后,就可以进行案例的开发了。由于本案例中的大部分代码在前面章节的许多案例中都出现过,故这里仅给出实现卷积的片元着色器,其代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_3/assets目录下的frag.sh。

1 precision mediump float;     //给出默认的浮点精度

2 varying vec2 vTextureCoord;    //从顶点着色器传递过来的纹理坐标

3 uniform sampler2D sTexture;    //纹理内容数据

4 void main() {

5   //给出卷积内核中各个元素对应像素相对于待处理像素的纹理坐标偏移量

6   vec2 offset0=vec2(-1.0,-1.0); vec2 offset1=vec2(0.0,-1.0); vec2 offset2=vec2(1.0,-1.0);

7   vec2 offset3=vec2(-1.0,0.0); vec2 offset4=vec2(0.0,0.0); vec2 offset5=vec2 (1.0,0.0);

8   vec2 offset6=vec2(-1.0,1.0); vec2 offset7=vec2(0.0,1.0); vec2 offset8=vec2 (1.0,1.0);

9   const float scaleFactor=1.0/9.0;//给出最终求和时的加权因子(为调整亮度)

10  //卷积内核中各个位置的值

11  float kernelValue0=1.0; float kernelValue1=1.0; float kernelValue2=1.0;

12  float kernelValue3=1.0; float kernelValue4=1.0; float kernelValue5=1.0;

13  float kernelValue6=1.0; float kernelValue7=1.0; float kernelValue8=1.0;

14  vec4 sum;//最终的颜色和

15  //获取卷积内核中各个元素对应像素的颜色值

16  vec4 cTemp0,cTemp1,cTemp2,cTemp3,cTemp4,cTemp5,cTemp6,cTemp7,cTemp8;

17  cTemp0=texture2D(sTexture, vTextureCoord.st+offset0.xy/512.0);

18  cTemp1=texture2D(sTexture, vTextureCoord.st+offset1.xy/512.0);

19  cTemp2=texture2D(sTexture, vTextureCoord.st+offset2.xy/512.0);

20  cTemp3=texture2D(sTexture, vTextureCoord.st+offset3.xy/512.0);

21  cTemp4=texture2D(sTexture, vTextureCoord.st+offset4.xy/512.0);

22  cTemp5=texture2D(sTexture, vTextureCoord.st+offset5.xy/512.0);

23  cTemp6=texture2D(sTexture, vTextureCoord.st+offset6.xy/512.0);

24  cTemp7=texture2D(sTexture, vTextureCoord.st+offset7.xy/512.0);

25  cTemp8=texture2D(sTexture, vTextureCoord.st+offset8.xy/512.0);

26  //颜色求和

27  sum=kernelValue0*cTemp0+kernelValue1*cTemp1+kernelValue2*cTemp2+

28    kernelValue3*cTemp3+kernelValue4*cTemp4+kernelValue5*cTemp5+

29    kernelValue6*cTemp6+kernelValue7*cTemp7+kernelValue8*cTemp8;

30  gl_FragColor=sum*scaleFactor;//进行亮度加权后将最终颜色传递给管线

31 }

提示

上述片元着色器的代码使用平滑过滤的卷积内核进行卷积计算,实现了平滑过滤效果的滤镜。同时上述代码中使用的加权因子是用来调节结果图像亮度的,读者可以根据具体情况进行修改,其值越大结果图像亮度越大。

2.2.3 边缘检测

通过卷积不但可以对图像进行平滑处理,还可以进行边缘检测。实现边缘检测的卷积内核各元素的值如表2-2所示。

表2-2 实现边缘检测的卷积内核

了解了边缘检测的卷积内核后,下面请读者进一步了解一下本小节案例Sample2_4的运行效果,如图2-6所示。

▲图2-6 案例Sample2_4的运行效果图

说明

图2-6中左图为处理前的原图,中间为经过边缘检测后的图像。对比两幅图可以看出,中间的图像中仅保留了左侧原图中物体边缘的位置内容。由于插图灰度印刷的原因,读者也可能看的不是很清楚,因此最右边还给出了将结果图像颜色置反后的参考图像,这样看起来会清楚很多。

了解了案例所采用的卷积内核与案例的运行效果后,就可以进行案例的开发了。由于本案例中的大部分代码与上一小节案例中的基本一致,有区别的部分主要就是片元着色器中卷积内核各个元素的值以及控制亮度的加权因子。故这里仅给出有区别部分的代码,具体内容如下。

代码位置:见随书光盘中源代码/第2章/Sample2_4/assets目录下的frag.sh。

1   const float scaleFactor=0.9;//给出最终求和时的加权因子(为调整亮度)

2   //卷积内核中各个位置的值

3   float kernelValue0=0.0; float kernelValue1=1.0; float kernelValue2=0.0;

4   float kernelValue3=1.0; float kernelValue4=-4.0; float kernelValue5=1.0;

5   float kernelValue6=0.0; float kernelValue7=1.0; float kernelValue8=0.0;

2.2.4 锐化处理

对于过于平滑已经显得模糊的图像也可以使用卷积进行锐化处理,使得图像看起来清晰些。实现锐化处理的卷积内核各元素的值如表2-3所列。

表2-3 实现锐化的卷积内核

了解了锐化处理的卷积内核后,下面请读者进一步了解一下本小节案例Sample2_5的运行效果,如图2-7所示。

▲图2-7 案例Sample2_5的运行效果图

说明

图2-7中左图为处理前的原图,右图为经过锐化处理后的图像。细致比对左右两幅图可以看出,右侧的图片比左侧的要清晰了一些。由于插图灰度印刷的原因,读者也可能看的不是很清楚,此时请读者自行在真机上运行本案例观察。

看完案例的运行效果后,接下来对本案例的具体开发过程进行介绍。由于本案例只是将案例Sample2_4的片元着色器做了简单的修改,因此这里仅给出修改部分的代码。

代码位置:见随书光盘中源代码/第2章/Sample2_5/assets目录下的frag.sh。

1   const float scaleFactor=1.0;//给出最终求和时的加权因子(为调整亮度)

2   //卷积内核中各个位置的值

3   float kernelValue0=0.0; float kernelValue1=-1.0; float kernelValue2=0.0;

4   float kernelValue3=-1.0; float kernelValue4=5.0; float kernelValue5=-1.0;

5   float kernelValue6=0.0; float kernelValue7=-1.0; float kernelValue8=0.0;

提示

从上述代码中可以看出,主要是修改了片元着色器中卷积内核的元素值以及亮度加权因子,其他部分基本没有变化。

2.2.5 浮雕效果

采用卷积计算还可以产生浮雕效果,实现浮雕效果的卷积内核各元素的值如表2-4所列。

表2-4 实现浮雕效果的卷积内核

了解了浮雕效果的卷积内核后,下面请读者进一步了解一下本小节案例 Sample2_5A 的运行效果,如图2-8所示。

▲图2-8 案例Sample2_5A的运行效果图

说明

图2-8中左图为处理前的原图,右侧为经过浮雕处理后的图像。对比左右两幅图可以明显地看出,左图的原图给人的感觉是平面的,而右图经过浮雕处理后的结果图像有明显的雕刻的凹凸感。

看完案例的运行效果后,接下来对本案例的具体开发过程进行介绍。由于本案例只是将案例Sample2_5的片元着色器做了简单的修改,因此这里仅给出修改后片元着色器的代码。

代码位置:见随书光盘中源代码/第2章/Sample2_5A/assets目录下的frag.sh。

1 precision mediump float;   //给出默认的浮点精度

2 varying vec2 vTextureCoord;  //从顶点着色器传递过来的纹理坐标

3 uniform sampler2D sTexture;  //纹理内容数据

4 void main() {

5   //给出卷积内核中各个元素对应像素相对于待处理像素的纹理坐标偏移量

6   vec2 offset0=vec2(-1.0,-1.0); vec2 offset1=vec2(0.0,-1.0); vec2 offset2=vec2(1.0,-1.0);

7   vec2 offset3=vec2(-1.0,0.0); vec2 offset4=vec2(0.0,0.0); vec2 offset5=vec2(1.0,0.0);

8   vec2 offset6=vec2(-1.0,1.0); vec2 offset7=vec2(0.0,1.0); vec2 offset8=vec2(1.0,1.0);

9   const float scaleFactor=1.0;//给出最终求和时的加权因子(为调整亮度)

10  //卷积内核中各个位置的值

11  float kernelValue0=2.0; float kernelValue1=0.0; float kernelValue2=2.0;

12  float kernelValue3=0.0; float kernelValue4=0.0; float kernelValue5=0.0;

13  float kernelValue6=3.0; float kernelValue7=0.0; float kernelValue8=-6.0;

14  vec4 sum;//最终的颜色和

15  //获取卷积内核中各个元素对应像素的颜色值

16  vec4 cTemp0,cTemp1,cTemp2,cTemp3,cTemp4,cTemp5,cTemp6,cTemp7,cTemp8;

17  cTemp0=texture2D(sTexture, vTextureCoord.st+offset0.xy/512.0);

18  cTemp1=texture2D(sTexture, vTextureCoord.st+offset1.xy/512.0);

19  cTemp2=texture2D(sTexture, vTextureCoord.st+offset2.xy/512.0);

20  cTemp3=texture2D(sTexture, vTextureCoord.st+offset3.xy/512.0);

21  cTemp4=texture2D(sTexture, vTextureCoord.st+offset4.xy/512.0);

22  cTemp5=texture2D(sTexture, vTextureCoord.st+offset5.xy/512.0);

23  cTemp6=texture2D(sTexture, vTextureCoord.st+offset6.xy/512.0);

24  cTemp7=texture2D(sTexture, vTextureCoord.st+offset7.xy/512.0);

25  cTemp8=texture2D(sTexture, vTextureCoord.st+offset8.xy/512.0);

26  //颜色求和

27  sum=kernelValue0*cTemp0+kernelValue1*cTemp1+kernelValue2*cTemp2+

28    kernelValue3*cTemp3+kernelValue4*cTemp4+kernelValue5*cTemp5+

29   kernelValue6*cTemp6+kernelValue7*cTemp7+kernelValue8*cTemp8;

30  //灰度化

31  float hd=(sum.r+sum.g+sum.b)/3.0;

32  gl_FragColor=vec4(hd)*scaleFactor;//进行亮度加权后将最终颜色传递给管线

33 }

● 第11-13行中卷积内核各个元素的值与修改前案例Sample2_5中的有很大的不同。

● 第31行对滤镜处理后的颜色进行了灰度化处理,将3个色彩通道值的平均值作为结果颜色各个色彩通道的值。

提示

到这里为止本书需要介绍的基于卷积计算的数字图像处理滤镜就介绍完了。但实际可用的滤镜千变万化,远不止这几个,有兴趣的读者可以查阅数字图像处理相关的技术资料,仿造上述几个案例自行实现更加酷炫的滤镜。

2.2.6 图像渐变

采用片元着色器不但可以轻松开发出各种滤镜,还可以开发出很多有趣的应用。如本小节将给出的图像渐变的例子就是如此,此应用运行时从一幅照片平滑地过渡为另一幅照片,非常有意思。在介绍案例的开发之前,请读者先了解一下案例的运行效果,如图2-9所示。

▲图2-9 案例Sample2_6的运行效果图

说明

图2-9中从左至右为从一幅照片向另一幅照片平滑过渡的过程。从最左边我们开发团队仇磊的照片平滑过渡到了我们团队夏学良的照片,实现的基本思路就是在不同的时间点采用不同的加权因子对两幅照片进行混合。

了解了案例的运行效果后,下面来介绍本小节案例的开发。由于本小节案例中的大部分代码与前面很多案例中的基本一致,因此这里仅给出本案例中有代表性和特色的部分,具体如下所列。

(1)首先需要在应用程序中定时将连续变化的混合比例因子以及两幅纹理图传入渲染管线,以备片元着色器使用。由于将混合比例因子传入渲染管线的代码与将其他数据传入渲染管线的代码基本相同,因此这里不再赘述,需要的读者请参考随书光盘中的源代码。

(2)本案例的顶点着色器与普通的纹理映射顶点着色器基本相同,但片元着色器有所不同。因此下面给出本案例中的片元着色器,其代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_6/assets目录下的frag.sh。

1 precision mediump float;      //给出默认的浮点精度

2 varying vec2 vTextureCoord;      //从顶点着色器传递过来的纹理坐标

3 uniform sampler2D sTexture1;     //纹理内容数据1(仇磊照片)

4 uniform sampler2D sTexture2;     //纹理内容数据2(夏学良照片)

5 uniform float uT;        //混合比例因子

6 void main() {

7  vec4 color1=texture2D(sTexture1, vTextureCoord); //从纹理1中采样出颜色值1

8  vec4 color2=texture2D(sTexture2, vTextureCoord); //从纹理2中采样出颜色值2

9  gl_FragColor=color1*(1.0-uT)+color2*uT;   //按比例混合两个颜色值

10  }

说明

上述片元着色器其实非常简单,根据传入的混合比例因子将从两幅纹理图中采样得到的颜色按比例进行混合。只要混合比例因子定时变化,就自然会产生平滑过渡的效果了。

2.3 分形着色器

最近一些年有一门非常热门的几何学分支——分形几何,基于分形几何可以渲染出很多绚丽多彩的图案。本节将基于曼德布罗集(Mandelbrot set)和茱莉亚集(Julia Set)向读者简单介绍如何基于分形几何开发出具有吸引力的程序纹理着色器。

2.3.1 曼德布罗集简介

曼德布罗集是基于复数在复平面上迭代产生的,因此下面将首先简要介绍一下复数及复平面的知识。每个复数由两部分组成:实部和虚部。实部的基本单位是实数1,虚部的基本单位是i。i是一个很特殊的虚数,其是−1的平方根,即i2=−1。

复数可以用a+bi的基本格式来表示,下面给出了两个复数相乘的规则。

x=a+bi y=c+di xy=ac+adi+cbi−bd=(ac−bc)+(ad+bc)i

因为复数包含两个部分,所以每个复数都可以看做是二维平面上的一个点,用实部作为一个轴的坐标,虚部作为另一个轴的坐标。这个平面就称之为复平面,具体情况如图2-10所示。

介绍完了复数及复平面的基本知识后,就可介绍曼德布罗集了,其通过一个涉及复数的递归函数迭代产生,此递归函数如下。

Z 0=0+0i (初始条件)

(迭代规则)

从上述递归函数中可以看出,不同的常数c会导致不同的迭代结果。有些c值经过迭代可能会产生无穷大,有些可能不会。那些不会导致无穷大的c值就构成了曼德布罗集。

▲图2-10 复数在复数平面上的表示
▲图2-11 曼德布罗物集合图形

可以通过OpenGL ES 2.0 的片元着色器进行上述的迭代计算,将迭代一定次数后达到无穷大(实际开发中指超过指定值)的片元采用一种颜色着色,迭代一定次数后小于指定值的片元采用另一种颜色着色。如果希望得到更绚丽的图案,则可以将迭代一定次数后超过指定值的片元,根据迭代次数的多少采用不同的颜色着色,图2-11就给出了一幅采用不同灰度进行着色的曼德布罗集图案。

2.3.2 曼德布罗集着色器的实现

上一小节介绍了曼德布罗集的基本原理,本小节将给出一个实现了曼德布罗集着色器的案例Sample2_7,其运行效果如图2-12所示。

▲图2-12 案例Sample2_7的运行效果图

说明

图2-12中的4幅图是采用不同的中心坐标和缩放位置绘制的,4幅图各自的绘制参数如表2-5所列。学习完本案例的代码后,读者可以自由改变这些参数以获得想要的渲染效果。另外,由于本书插图采用的是灰度印刷,因此可能看起来不是很漂亮,此时请读者自行用真机运行本案例观察就可以看到很漂亮的曼德布罗集了。

表2-5 缩放系数和中心坐标位置的值

了解了案例的运行效果后,下面对案例的开发进行简要的介绍。由于本案例中大部分代码与前面很多案例中的基本一致,因此这里仅介绍本案例中有代表性的着色器部分,具体内容如下。

(1)首先给出的是顶点着色器,其代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_7/assets目录下的vertex.sh。

1 uniform mat4 uMVPMatrix;      //总变换矩阵

2 attribute vec3 aPosition;      //顶点位置

3 attribute vec2 aTexCoor;      //顶点纹理坐标

4 varying vec2 vTexPosition;      //转换后传递给片元着色器的纹理坐标

5 void main() {

6  gl_Position=uMVPMatrix*vec4(aPosition,1);//根据总变换矩阵计算此次绘制此顶点的位置

7  vTexPosition=(aTexCoor-0.5)*5.0;   //将纹理坐标转换后传递给片元着色器

8 }

提示

从上述代码中可以看出,此顶点着色器与普通纹理映射的顶点着色器基本一致。唯一的区别就是其不是将管线传入的纹理坐标直接传出的,而是将纹理坐标从0.0~1.0的范围转换到在−2.5~2.5的范围后再传出的,这样做是为了后面片元着色器迭代计算的方便。

(2)接着给出的是片元着色器,其代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_7/assets目录下的frag.sh。

1 precision mediump float;      //给出默认的浮点精度

2 varying vec2 vTexPosition;      //从顶点着色器传递过来的纹理坐标

3 const float maxIterations=9999.0;    //最大迭代次数

4 const float zoom=1.0;       //缩放系数

5 const float xCenter=0.0;      //中心x坐标

6 const float yCenter=0.0;      //中心y坐标

7 const vec3 innerColor=vec3(0.0, 0.0, 1.0); //内部颜色

8 const vec3 outerColor1=vec3(0.1, 0.0, 0.0); //外部颜色1

9 const vec3 outerColor2=vec3(0.0, 1.0, 0.0); //外部颜色2

10 void main() {

11  float real=vTexPosition.x*zoom+xCenter; //变换当前位置

12  float imag=vTexPosition.y*zoom+yCenter;

13  float cReal=real;       //c的实部

14  float cImag=imag;       //c的虚部

15  float r2=0.0;        //半径的平方

16  float i;          //迭代次数

17  for(i=0.0; i<maxIterations && r2<4.0; i++){ //循环迭代

18    float tmpReal=real;      //保存当前实部值

19    real=(tmpReal*tmpReal) -(imag*imag)+cReal;//计算下一次迭代后实部的值

20    imag=2.0*tmpReal*imag+cImag;  //计算下一次迭代后虚部的值

21    r2=(real*real)+(imag*imag);  //计算半径的平方

22  }

23  vec3 color;    //最终颜色

24  if(r2 < 4.0){    //如果r2未达到4就退出了循环,表明迭代次数已达到最大值

25  color=innerColor;  //此时采用内部颜色对此片元着色

26  }else{      //如果因r2大于4.0而退出循环,表明此位置在外部

27  color=mix(outerColor1, outerColor2, fract(i*0.07));  //按迭代次数采用不同的颜色着色

28  }

29  gl_FragColor=vec4(color, 1.0); //将最终颜色传递给管线

30 }

● 第17-28行是本片元着色器的关键,其实现了上一小节中介绍的曼德布罗集迭代生成算法。首先通过一个循环进行迭代,当迭代超过最大次数或指定值时停止迭代。停止迭代后根据终止迭代的原因采用不同的颜色对片元进行着色。

● 通过调整缩放系数(zoom),中心点坐标(xCenter, yCenter)可以得到不同的局部图案,有兴趣的读者可以自行修改这些参数并运行观察。

提示

像曼德布罗集这样大剂量高并发的运算用片元着色器实现最好不过,性能将大大优于 CPU 的实现。笔者自己也开发过用 Java代码基于 CPU实现的版本(也是Android手机版的),需要数十秒才能跑出结果,而本小节的案例仅需不到1秒。从这里也可以看出,在3D游戏开发中恰当运用计算能力较强的GPU可以很好地改善性能问题。

2.3.3 将曼德布罗集纹理应用到实际物体上

上一小节案例中的曼德布罗集纹理是应用到一个简单的纹理矩形上的,其实可以方便地将其应用到任意的物体上。本小节就给出一个将曼德布罗集纹理应用到茶壶上的案例 Sample2_8,其运行效果如图2-13所示。

▲图2-13 案例Sample2_8的运行效果图

提示

由于本书插图采用的是灰度印刷,因此可能看起来不是很漂亮,此时请读者自行用真机运行本案例观察就可以看到很漂亮的曼德布罗集纹理茶壶了。

了解了案例的运行效果后,下面简要介绍一下案例的开发。由于本案例是复制并修改的上册第9章的案例Sample9_4,故没有变化的代码不再赘述,仅介绍着色器中有变化的部分,具体内容如下所列。

(1)首先需要将顶点着色器中直接将纹理坐标传入片元着色器的代码进行修改。修改为将纹理坐标在0.0~1.0的范围转换到在−2.5~2.5的范围后再传出的版本。这部分代码很简单,这里就不给出了,需要的读者请参考随书光盘里的源代码。

(2)接着将片元着色器进行修改,增加曼德布罗集迭代计算的相关代码。由于需要增加的代码与上一小节案例Sample2_7中的基本相同,因此这里不再重复给出,需要的读者也请参考随书光盘里的源代码。

提示

特别需要注意的是,本案例中迭代次数需要从上一小节的9999改为99,否则用手指在屏幕上滑动以旋转茶壶时就会很卡。

2.3.4 茱莉亚集着色器的实现

将曼德布罗集中与片元位置挂钩的常量 c 替换为固定常量后就可以产生茱莉亚集的分形图案,本小节的第一个案例Sample2_9就是实现了茱莉亚集分形图案的纹理矩形,其运行效果如图2-14所示。

▲图2-14 Sample2_9运行效果图

说明

图2-14中的4幅图是采用不同的固定常量c绘制的,4幅图各自的绘制参数如表2-6所列。学习完本案例的代码后,读者可以自由改变这些参数以获得想要的渲染效果。另外,由于本书插图采用的是灰度印刷,因此可能看起来不是很漂亮,此时请读者自行用真机运行本案例观察就可以看到很漂亮的茱莉亚集了。

表2-6 不同的参数c

了解了案例的运行效果后,下面对案例的开发进行简要的介绍。由于本案例是由前面的案例Sample2_7 修改而来,而且仅修改了片元着色器。故这里仅给出修改后片元着色器的代码,具体内容如下。

代码位置:见随书光盘中源代码/第2章/Sample2_9/assets目录下的frag.sh。

1 precision mediump float;      //给出默认的浮点精度

2 varying vec2 vTexPosition;      //从顶点着色器传递过来的纹理坐标

3 const float maxIterations=9999.0;    //最大迭代次数

4 const float zoom=0.6;       //缩放系数

5 const float xCenter=0.0;      //中心x坐标

6 const float yCenter=0.0;      //中心y坐标

7 const vec3 innerColor=vec3(0.0, 0.0, 1.0); //内部颜色

8 const vec3 outerColor1=vec3(0.1, 0.0, 0.0); //外部颜色1

9 const vec3 outerColor2=vec3(0.0, 1.0, 0.0); //外部颜色2

10  void main() {

11   float real=vTexPosition.x*zoom+xCenter; //变换当前位置

12   float imag=vTexPosition.y*zoom+yCenter;

13   float cReal=0.32;     //c的实部

14   float cImag=0.043;     //c的虚部

15   float r2=0.0;      //半径的平方

16   float i;        //迭代次数

17   for(i=0.0; i<maxIterations && r2<4.0; i++){//循环迭代

18    float tmpReal=real;     //保存当前实部值

19    real=(tmpReal*tmpReal) -(imag*imag)+cReal;//计算下一次迭代后实部的值

20    imag=2.0*tmpReal*imag+cImag; //计算下一次迭代后虚部的值

21    r2=(real*real)+(imag*imag); //计算半径的平方

22   }

23  vec3 color;    //最终颜色

24  if(r2 < 4.0){    //如果r2未达到4就退出了循环,表明迭代次数已达到最大值

25  color=innerColor;  //此时采用内部颜色对此片元着色

26  }else{      //如果因r2大于4.0而退出循环,表明此位置在外部

27  color=mix(outerColor1, outerColor2, fract(i*0.07));  //按迭代次数采用不同的颜色着色

28  }

29  gl_FragColor=vec4(color, 1.0);     //将最终颜色传递给管线

30 }

提示

从上述代码中可以看出,最大的变化就是第13行与第14行常数c的实部与虚部。修改前实部与虚部是与位置挂钩的,修改后变成固定的常量了。

同样也可以将茱莉亚集分形纹理应用到茶壶上,只要将前面的案例Sample2_8复制一份并对片元着色器进行相应的修改即可得到案例Sample2_10,其运行效果如图2-15所示。

▲图2-15 Sample2_10运行效果图

提示

由于本书插图采用的是灰度印刷,因此可能看起来不是很漂亮,此时请读者自行用真机运行本案例观察就可以看到很漂亮的茱莉亚集纹理茶壶了。

由于本案例中的代码主要是来自案例 Sample2_8,仅仅是将原来曼德布罗集的片元着色器修改成了茱莉亚集的片元着色器。而茱莉亚集片元着色器的代码前面也已经给出,故这里就不再给出本案例的代码了,需要的读者请参考随书光盘。

2.4 体积雾

第一卷第10章中介绍过简单的雾特效,通过其可以模拟很多现实世界中与雾、烟等相关的场景。但是简单的雾特效也有一定的局限性,如在实现山中烟雾缭绕的效果时就比较假。这是由于简单的雾特效没有考虑到变化的情况,只是采用简单的与距离相关的公式计算雾浓度因子。

而现实世界中的山中雾气往往是随风变化,并不是在所有的位置都遵循完全一致的雾浓度因子计算公式。本节将介绍一种能更好地模拟山岚烟云效果的雾特效技术——体积雾,通过其可以开发出非常真实的山中烟雾缭绕的效果。

2.4.1 基本原理

介绍具体的案例之前,首先需要了解一下本节案例实现体积雾的基本原理。体积雾实现的关键点在于计算出每个待绘制片元的雾浓度因子,然后根据雾浓度因子、雾的颜色及片元本身采样的纹理颜色计算出片元的最终颜色。

读者可能会有一个疑问:简单的雾特效采用的不也是这样的策略吗?确实如此,基本的大思路很类似,但体积雾雾浓度因子的计算模型不像简单的雾特效那样是一个简单呆板的公式,具体的计算策略如图2-16所示。

▲图2-16 体积雾计算模型原理

从图2-16中可以看出,体积雾具体的计算策略如下(此计算由片元着色器完成)。

● 首先通过当前待处理片元的位置与摄像机的位置确定一根射线,并求出射线与雾平面的交点位置。

● 若上述交点在雾平面以下,则求出交点到待处理片元位置的距离。

● 根据此距离的大小求出雾浓度因子,距离越大雾越浓。

提示

为了进一步增加真实感,实际案例中的雾平面并不是一个完全的平面,而是加入了正弦函数的高度扰动使得雾平面看起来有波动效果,如图2-16中右侧所示。

2.4.2 体积雾开发步骤

了解了实现体积雾的基本原理后,接着可以了解一下本节案例的运行效果,具体运行情况如图2-17所示。

▲图2-17 体积雾运行效果图

从图2-17中可以看出,山间飘荡着黄色的雾气,似有似无,效果非常真实。但由于本书正文中的插图采用灰度印刷,而且图是静态的,因此,强烈建议读者使用设备运行观察一下,那样才可以看到非常真实的效果。

了解了本节案例的运行效果后,下面就可以介绍案例的具体开发了。由于本案例中的大部分代码与第一卷中介绍过程纹理地形时给出的案例非常类似,因此,这里仅给出本案例中最有代表性的部分,具体内容如下所列。

(1)观察过本节案例的运行效果后就会发现,场景中的山间雾气并不是静止的,而是沿着起始角连续变化的正弦曲线飘动的。为了实现雾气飘动的效果,Mountion类中的drawSelf方法将连续变化的起始角在绘制每帧画面前传入渲染管线。此起始角与用于扰动雾平面高度的正弦曲线对应,相关的代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_11/src/com/bn/Sample2_11目录下的Mountion.java。

1   //将体积雾的雾平面高度传入渲染管线

2   GLES20.glUniform1f(slabYHandle, TJ_GOG_SLAB_Y);

3   //将体积雾扰动起始角传入渲染管线

4   GLES20.glUniform1f(startAngleHandle, (float) Math.toRadians(startAngle));

5   //修改扰动角的值,每次加3,取值范围永远在0~360的范围内

6   startAngle=(startAngle+3f)%360.0f;

提示

上述代码的功能非常简单,首先将体积雾所需雾平面的高度传入渲染管线,接着将用于扰动雾平面高度的正弦曲线所需起始角传入渲染管线。最后增加起始角的值,并通过取模的方式将起始角限制在0~360的范围内。

(2)上一小节通过图2-16给出了体积雾计算模型的基本原理,也提到过此计算是由片元着色器完成的,下面就给出实现此计算的片元着色器的详细代码,具体内容如下。

代码位置:见随书光盘中源代码/第2章/Sample2_11/src/com/bn/Sample2_11目录下的frag.sh。

1 precision mediump float;      //给出默认的浮点精度

2 varying vec2 vTextureCoord;     //接收从顶点着色器传过来的纹理坐标

3 varying float currY;       //接收从顶点着色器传过来的y坐标

4 varying vec4 pLocation;      //接收从顶点着色器传过来的顶点坐标

5 uniform float slabY;       //体积雾对应雾平面的高度

6 uniform float startAngle;      //扰动起始角

7 uniform vec3 uCamaraLocation;     //摄像机位置

8 uniform sampler2D sTextureGrass;    //纹理内容数据(草皮)

9 uniform sampler2D sTextureRock;    //纹理内容数据(岩石)

10 uniform float landStartY;     //过程纹理起始y坐标

11 uniform float landYSpan;      //过程纹理跨度

12 float tjFogCal(vec4 pLocation){    //计算体积雾浓度因子的方法

13 float xAngle=pLocation.x/16.0*3.1415926; //计算出顶点x坐标折算出的角度

14 float zAngle=pLocation.z/20.0*3.1415926; //计算出顶点z坐标折算出的角度

15 float slabYFactor=sin(xAngle+zAngle+startAngle);//联合起始角计算出角度和的正弦值

16 //求从摄像机到待处理片元的射线参数方程Pc+(Pp-Pc)t与雾平面交点的t值

17 float t=(slabY+slabYFactor-uCamaraLocation.y)/(pLocation.y-uCamaraLocation.y);

18 //有效的t的范围应该在0~1的范围内,若不在范围内表示待处理片元不在雾平面以下

19 if(t>0.0&&t<1.0){        //若在有效范围内则

20  //求出射线与雾平面的交点坐标

21  float xJD=uCamaraLocation.x+(pLocation.x-uCamaraLocation.x)*t;

22  float zJD=uCamaraLocation.z+(pLocation.z-uCamaraLocation.z)*t;

23  vec3 locationJD=vec3(xJD,slabY,zJD);

24  float L=distance(locationJD,pLocation.xyz);//求出交点到待处理片元位置的距离

25  float L0=10.0;

26  return L0/(L+L0);       //计算体积雾的雾浓度因子

27  }else{

28  return 1.0;    //若待处理片元不在雾平面以下,则此片元不受雾影响

29 }}

30 void main(){

31  vec4 gColor=texture2D(sTextureGrass, vTextureCoord); //从草皮纹理中采样出颜色

32  vec4 rColor=texture2D(sTextureRock, vTextureCoord); //从岩石纹理中采样出颜色

33  vec4 finalColor;          //片元最终颜色

34  if(currY<landStartY){

35  finalColor=gColor;  //当片元y坐标小于过程纹理起始y坐标时采用草皮纹理

36  }else if(currY>landStartY+landYSpan){

37  finalColor=rColor;  //当片元y坐标大于过程纹理起始y坐标加跨度时采用岩石纹理

38  }else{      //当片元y坐标在过程纹理范围内时将草皮和岩石混合

39  float currYRatio=(currY-landStartY)/landYSpan; //计算岩石纹理所占的百分比

40  //将岩石、草皮纹理颜色按比例混合

41  finalColor=currYRatio*rColor+(1.0-currYRatio)*gColor;

42  }

43  float fogFactor=tjFogCal(pLocation);   //计算雾浓度因子

44  //根据雾浓度因子、雾的颜色及片元本身采样的纹理颜色计算出片元的最终颜色

45  gl_FragColor=fogFactor*finalColor+

46  (1.0-fogFactor)*vec4(0.9765,0.7490,0.0549,0.0);

47 }

● 第12-29行为本案例中最有代表性的,根据传入着色器的参数计算体积雾浓度因子的tjFogCal 方法。此方法首先根据起始角和对应片元位置折算出的角度计算出一个正弦值,然后将此正弦值加上雾平面的高度作为扰动后的雾平面高度。然后计算出从摄像机到待处理片元的射线对应的参数方程(摄像机位置+(待处理片元位置−摄像机位置)×t)与扰动后雾平面交点处的参数值(t值)。若t值在0~1的范围内(表示待处理片元在雾平面以下),则根据待处理片元的位置到交点的距离计算出雾浓度因子的大小。

● 第30-47行为片元着色器的main 方法,其中首先执行了过程纹理计算,根据片元高度计算出了待处理片元的纹理采样颜色值。然后计算出体积雾浓度因子,最后根据雾浓度因子、雾的颜色及片元本身的纹理采样颜色计算出片元的最终颜色值。

到这里为止,体积雾技术就介绍完了,经过上面的介绍读者可能已经发现体积雾并不是实际存在的3D 模型,只是在应该被雾覆盖的片元上通过某种计算模型的计算混合了雾的颜色,最后造成了有雾覆盖的效果。同时体积雾的实际计算模型有很多,本节只是给出了比较简单的一种。读者可以根据具体的需要以及本节介绍的体积雾的思想,开发出效果更加真实和酷炫的体积雾计算模型。

2.5 粒子系统火焰的开发

很多的游戏场景中会采用火焰或烟雾等作为点缀,以增强场景的真实性与吸引力。而目前最流行的实现火焰、烟雾等效果的技术就是粒子系统技术,本节将向读者介绍如何利用粒子系统开发出非常真实酷炫的火焰与烟雾特效。

2.5.1 火焰的基本原理

用粒子系统实现火焰效果的基本思想非常简单,将火焰看作是由一系列运动的粒子叠加而成。系统定时在固定的区域内生成新粒子,粒子生成后不断按照一定的规律运动并改变自身的颜色。当粒子运动满足一定的条件后,粒子消亡。对单个粒子而言,其生命周期过程如图2-18所示。

▲图2-18 粒子对象的生命过程

读者可能会觉得,是不是过于简单了,这样就可以产生游戏场景中真实的火焰效果吗?当然,如果系统中同时存在的粒子数量很少,则模拟的火焰效果并不像。但如果有大量的粒子同时存在,而开发人员又给予了粒子合适的初始位置、运动速度、起始颜色、终止颜色、尺寸、最大生命期等特性,就可以模拟出非常真实的火焰效果。

说明

实际粒子系统的开发中,开发人员需要根据目标特效的需求给出合适的各项粒子特性,就可以真实地模拟出火焰、烟雾、爆炸等不同的效果。

了解了粒子系统的基本思想后,下面介绍一下本节案例中采用的具体策略,具体内容如下。

● 每个粒子本质上是一个较小的纹理矩形,采用的纹理图中不完全透明区域的形状确定了粒子的基本形状。随不完全透明部分所占区域形状的不同,实现的粒子可以为任何形状,如星形、六边形等。本节案例中实际采用的是圆形,如图2-19所示。

▲图2-19 粒子纹理矩形的纹理图(1)
▲图2-20 粒子纹理矩形的纹理图(2)

● 粒子一般不是在固定的位置生成,而是在指定的区域内随机选择位置生成。对于本节火焰效果的案例而言,随机生成粒子的区域是火焰下方的一个矩形区域,如图2-20所示。

● 由于需要模拟的火焰整体形状下方宽,上方窄,因此,生成粒子的速度方向应该是偏向中心轴线的,也就是在左侧生成的粒子速度方向偏右,在右侧生成的粒子速度方向偏左。

● 粒子运动过程中不但位置需要发生变化,颜色也需要根据一定的规则变化。本节案例采用的粒子颜色变化策略是,着色器接收渲染管线传入的起始颜色、终止颜色、总衰减因子。然后根据当前片元距离粒子纹理矩形中心点的距离、总衰减因子、片元纹理采样颜色的透明度通道值、起始/终止颜色计算出当前片元的颜色,如图2-21所示。

▲图2-21 粒子中片元颜色值的计算策略

说明

从图2-21中的计算策略可以看出,纹理图中每个片元的颜色仅仅是透明度(alpha)色彩通道起了作用。因此,纹理图中完全透明的位置对应到粒子中的相应位置而言也是完全透明的,这样纹理图就起到了充当粒子形状模板的作用。实际开发中根据目标特效的需要,可以选择不完全透明区域是任何所需形状的纹理图,而且不完全透明区域的透明度一般也是渐变的,这样可以产生更加平滑的效果。

● 总衰减因子由 Java 程序计算并传入渲染管线,本案例中采用的总衰减因子计算策略很简单,粒子存在的生命期越长,总衰减因子值越小,计算公式为“(最大允许生命期−当前粒子生命期)/最大允许生命期”。

● 从片元颜色变化规律来说,总衰减因子越小,片元颜色越接近终止颜色,反之,则越接近起始颜色。同时随片元位置离粒子中心点距离的增加,片元颜色也越接近终止颜色,反之则越接近起始颜色。

2.5.2 火焰的开发步骤

上一小节介绍了用粒子系统实现火焰效果的基本原理,本小节将基于上一小节介绍的原理给出一个实现的案例Sample2_12,其运行效果如图2-22所示。

▲图2-22 案例Sample2_12的运行效果图

从图2-22中可以看出,场景中有4个火盆,每个火盆中都有一个粒子系统实现的火焰。但由于4个火焰粒子系统所采用的参数值不同,实际呈现出的有火焰效果也有烟雾效果。由于插图采用灰度印刷,可能看起来效果不是很好,建议读者采用真机设备运行本节案例观察体会。

了解了本节案例的运行效果后,就可以进行案例的开发了。由于本案例中一些代码和前面章节案例中的非常相似,因此,这里仅给出本案例中最有代表性的部分,具体内容如下所列。

(1)首先介绍的是火焰粒子系统的总控制类ParticleSystem,其具体代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleSystem.java.

1 package com.bn.Sample2_12;

2 ……//此处省略了包的引入代码,读者可自行查阅随书光盘中的源代码

3 public class ParticleSystem implements Comparable<ParticleSystem>{

4 //用于存放所有粒子的列表

5 public ArrayList<ParticleSingle> alFsp=new ArrayList<ParticleSingle>();

6 //用于存放需要删除粒子的列表

7 ArrayList<ParticleSingle> alFspForDel=new ArrayList<ParticleSingle>();

8 //用于为绘制工作转存所有粒子的列表

9 public ArrayList<ParticleSingle> alFspForDraw=new ArrayList<ParticleSingle>();

10 //用于直接为绘制工作服务的粒子列表

11 public ArrayList<ParticleSingle> alFspForDrawTemp=new ArrayList<ParticleSingle>();

12 Object lock=new Object();      //资源访问锁

13 public float[] startColor;      //粒子起始颜色

14 public float[] endColor;      //粒子终止颜色

15 public int srcBlend;       //源混合因子

16 public int dstBlend;       //目标混合因子

17 public int blendFunc;       //混合方式

18 public float maxLifeSpan;      //粒子最大生命期

19 public float lifeSpanStep;      //粒子生命期步进

20 public int sleepSpan;       //线程休眠时间

21 public int groupCount;       //每批喷发的粒子数量

22 public float sx;        //基础发射点x坐标

23 public float sy;        //基础发射点y坐标

24 float positionX;        //绘制位置x坐标

25 float positionZ;        //绘制位置y坐标

26 public float xRange;       //发射点x方向的变化范围

27 public float yRange;       //发射点y方向的变化范围

28 public float vx;        //粒子发射的x方向速度

29 public float vy;        //粒子发射的y方向速度

30 float yAngle=0;         //此粒子系统的旋转角度

31 ParticleForDraw fpfd;       //单个粒子的绘制者

32 boolean flag=true;        //线程工作的标志位

33 public ParticleSystem(float positionx,float positionz,ParticleForDraw fpfd){

34 this.positionX=positionx;      //初始化此粒子系统的绘制位置x坐标

35 this.positionZ=positionz;      //初始化此粒子系统的绘制位置z坐标

36 this.startColor=START_COLOR[CURR_INDEX];  //初始化粒子起始颜色

37 this.endColor=END_COLOR[CURR_INDEX];   //初始化粒子终止颜色

38 this.srcBlend=SRC_BLEND[CURR_INDEX];   //初始化源混合因子

39 this.dstBlend=DST_BLEND[CURR_INDEX];   //初始化目标混合因子

40 this.blendFunc=BLEND_FUNC[CURR_INDEX];   //初始化混合方式

41 this.maxLifeSpan=MAX_LIFE_SPAN[CURR_INDEX]; //初始化每个粒子的最大生命周期

42 this.lifeSpanStep=LIFE_SPAN_STEP[CURR_INDEX]; //初始化每个粒子的生命步进

43 this.groupCount=GROUP_COUNT[CURR_INDEX];  //初始化每批喷发的粒子数

44 this.sleepSpan=THREAD_SLEEP[CURR_INDEX];  //初始化线程的休眠时间

45 this.sx=0;          //初始化此粒子系统的中心点x坐标

46 this.sy=0;          //初始化此粒子系统的中心点y坐标

47 this.xRange=X_RANGE[CURR_INDEX];    //初始粒子距离中心点x方向的最大距离

48 this.yRange=Y_RANGE[CURR_INDEX];    //初始粒子距离中心点y方向的最大距离

49 this.vx=0;          //初始化粒子的x方向运动速度

50 this.vy=VY[CURR_INDEX];      //初始化粒子的y方向运动速度

51 this.fpfd=fpfd;        //初始化单个粒子的绘制者

52 new Thread(){         //创建粒子的更新线程

53  public void run(){

54  while(flag){

55  update();         //调用update方法更新粒子状态

56  try{

57   Thread.sleep(sleepSpan);    //休眠一定的时间

58   }

59  catch (InterruptedException e){

60   e.printStackTrace();

61  }}}

62  }.start();

63 }

64 public void drawSelf(int texId){     //绘制方法

65 ……//此处省略了部分源代码,将在后面的步骤中给出

66 }

67 public void update() {       //更新粒子状态的方法

68 ……//此处省略了部分源代码,将在后面的步骤中给出

69 }

70 ……//此处省略了重写的比较两个火焰离摄像机距离的方法,读者可自行查阅随书光盘中的源代码

71 }

说明

从上述代码中可以看出,每个 ParticleSystem 类的对象代表一个粒子系统。其中有一系列的成员变量用于存储对应粒子系统的各项属性信息。同时,其中还开启了一个定时更新粒子系统状态的线程,其定时调用更新粒子状态的update方法,此方法将在下面详细介绍。

(2)接下来给出前面介绍 ParticleSystem类时省略的drawSelf 方法,该方法主要负责绘制整个粒子系统,其具体代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleSystem.java.

1 public void drawSelf(int texId){

2  GLES20.glDisable(GLES20.GL_DEPTH_TEST);  //关闭深度检测

3  GLES20.glEnable(GLES20.GL_BLEND);   //开启混合

4  GLES20.glBlendEquation(blendFunc);   //设置混合方式

5  GLES20.glBlendFunc(srcBlend,dstBlend);   //设置混合因子

6  //清空用于直接为绘制工作服务的粒子列表,为向此列表中添加当前存在的粒子做准备

7  alFspForDrawTemp.clear();

8  synchronized(lock){

9  //加锁的目的是为了防止alFspForDraw列表被两个线程同时访问而出问题

10  for(int i=0;i<alFspForDraw.size();i++){

11  alFspForDrawTemp.add(alFspForDraw.get(i));  //复制粒子

12  }}

13 MatrixState.translate(positionX, 1, positionZ); //执行平移变换

14 MatrixState.rotate(yAngle, 0, 1, 0);   //执行旋转变换

15 for(ParticleSingle fsp:alFspForDrawTemp){  //循环绘制每个粒子

16 fsp.drawSelf(texId,startColor,endColor,maxLifeSpan);

17 }

18 GLES20.glEnable(GLES20.GL_DEPTH_TEST);   //开启深度检测

19 GLES20.glDisable(GLES20.GL_BLEND);    //关闭混合

20 }

● 第2-5行完成了绘制粒子系统前的一些必要设置,首先关闭深度测试,然后开启混合,最后根据初始化得到的混合方式与混合因子进行混合相关参数的设置。

● 第6-12行将转存粒子列表中的粒子复制进直接服务于绘制工作的粒子列表,为下面的粒子绘制工作做准备。要特别注意的是,复制的任务是在获得资源锁的同步代码块中进行的。这是由于转存粒子列表不但被绘制线程访问,在粒子的更新线程中还会访问,为了防止两个不同的线程同时对一个列表执行读写带来的问题,这里应该采用同步互斥技术。

● 第13-17行首先进行了平移和旋转变换,然后遍历整个直接服务于绘制工作的粒子列表,绘制其中的每一个粒子。

提示

由于本案例中粒子系统产生的特效实际是2D的,所以,在绘制粒子系统之前需要执行相应的旋转变换,将粒子系统旋转到正对摄像机的角度。这实际上用到了本书第一卷所介绍的标志板技术,有需要的读者可以参考本书第一卷中的相关内容。

(3)接着给出前面介绍ParticleSystem类时省略的update方法,该方法主要负责更新整个粒子系统,其具体代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleSystem.java。

1 public void update() {      //更新粒子系统的方法

2 for(int i=0;i<groupCount;i++){   //循环发射一批新粒子

3 //在发射中心位置附近随机产生此次发射粒子的位置

4 float px=(float) (sx+xRange*(Math.random()*2-1.0f));

5 float py=(float) (sy+yRange*(Math.random()*2-1.0f));

6   float vx=(sx-px)/150;    //粒子x方向速度

7   ParticleSingle fsp=new ParticleSingle(px,py,vx,vy,fpfd);//创建粒子对象

8 alFsp.add(fsp);  //将生成的粒子加入用于存放所有粒子的列表中

9  }

10 //清空记录需要删除粒子的列表

11 alFspForDel.clear();

12 for(ParticleSingle fsp:alFsp){ //遍历更新当前的所有粒子

13 //调用每个粒子的go方法,实施粒子的变化

14 fsp.go(lifeSpanStep);

15 //如果粒子生存的时间已经达到了最大值,就将其添加到需要删除的粒子列表

16 if(fsp.lifeSpan>this.maxLifeSpan){

17  alFspForDel.add(fsp);

18 }}

19 for(ParticleSingle fsp:alFspForDel){//将需要删除粒子列表中的粒子从所有粒子列表中删除

20 alFsp.remove(fsp);

21 }

22 synchronized(lock){     //获取访问锁

23 alFspForDraw.clear();    //清空转存粒子列表

24 for(int i=0;i<alFsp.size();i++){ //循环将所有粒子列表中的粒子添加到转存粒子列表中

25  alFspForDraw.add(alFsp.get(i));

26 }}}

● 第2-9行的功能为产生一批新的粒子,粒子的初始位置在指定的中心点位置附近随机产生。同时由于期望的火焰是向上逐渐收窄的,因此,根据粒子初始位置偏离中心位置x坐标的差值确定粒子 x 方向的速度。总的来说,x 方向速度指向中心点,速度大小与偏离中心点的距离线性相关,偏离越远,速度越大。

● 第10-21行的功能为将超过生命期上限的粒子从所有粒子列表中删除。但直接在遍历所有粒子列表的循环中执行删除会带来问题,故这里首先遍历所有粒子列表中的粒子,将符合删除条件的粒子加入到删除列表中,最后遍历删除列表,执行最后删除。这是Java编程中批量删除列表中满足条件的元素时常用的技巧,读者以后也可以采用。

● 第22-26行的功能为将更新后的所有粒子列表中的粒子复制进转存粒子列表。这与前面绘制方法中将转存粒子列表中的粒子复制进直接服务于绘制工作的粒子列表是呼应的,正好形成了粒子数据从计算线程到绘制线程的流水线。

提示

有的读者可能会有一个疑问?为什么需要3个列表(所有粒子、转存、绘制),而不是直接遍历绘制所有粒子列表中的粒子即可。这是因为若是如此,同时就会有更新线程与绘制线程都要访问所有粒子列表,可能会产生由于无限制并发访问引发的画面撕裂问题。若通过直接加锁解决的话,两个线程实际就不是并行了,影响效率。因此,本案例中采用了3个列表,形成了一个流水线,既避免了多线程并发访问带来的问题,又保证了效率。

具体的流水线是:更新线程进行粒子的计算,计算完毕后加锁,将粒子复制进转存列表;绘制线程加锁,将转存列表复制进绘制列表。这样加锁区域涉及的任务很少,执行时间很短(也就是临界区小),使得两个线程几乎不受影响,不影响效率。这是一种常用的多线程开发技巧,读者也可以在自己的项目中采用。

(4)下面介绍的是代表单个粒子的ParticleSingle类,其负责存储单个特定粒子的信息,具体代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleSingle.java.

1 package com.bn.Sample2_12;     //包声明

2 public class ParticleSingle{

3 public float x;        //粒子的x坐标

4 public float y;        //粒子的y坐标

5 public float vx;        //粒子的x方向速度

6 public float vy;        //粒子的y方向速度

7 public float lifeSpan;      //粒子的生命期

8 ParticleForDraw fpfd;       //粒子对象的绘制者

9 public ParticleSingle(float x,float y,float vx,float vy,ParticleForDraw fpfd){

10 this.x=x;         //初始化粒子的x坐标

11 this.y=y;         //初始化粒子的y坐标

12 this.vx=vx;         //初始化粒子的x方向速度

13 this.vy=vy;         //初始化粒子的y方向速度

14 this.fpfd=fpfd;        //初始化粒子对象的绘制者

15 }

16 public void go(float lifeSpanStep){   //移动粒子,并增长粒子生命期的方法

17 x=x+vx;          //计算粒子新的x坐标

18 y=y+vy;          //计算粒子新的y坐标

19 lifeSpan+=lifeSpanStep;      //增加粒子的生命期

20 }

21 public void drawSelf(int texId,float[] startColor,float[] endColor,float maxLifeSpan){

22 MatrixState.pushMatrix();     //保护现场

23 MatrixState.translate(x, y, 0);    //执行平移变换

24 float sj=(maxLifeSpan-lifeSpan)/maxLifeSpan;//计算总衰减因子

25 fpfd.drawSelf(texId,sj,startColor,endColor);//绘制单个粒子

26 MatrixState.popMatrix();     //恢复现场

27 }}

● 第3-14行为单个粒子对象基本信息对应成员变量的声明与初始化,主要包括位置、速度、绘制者等。

● 第16-20行为定时被调用用以运动粒子及增大粒子生命期的go 方法。

● 第21-27行为绘制单个粒子的drawSelf 方法,其中最主要的工作是,首先根据粒子当前的生命期与最大允许生命期计算出总衰减因子,然后调用粒子绘制者的drawSelf方法完成粒子的绘制工作。

说明

从第24行计算总衰减因子的代码中可以看出,随着粒子生命期的增加,总衰减因子逐渐减小,直至为0。结合上一小节介绍的片元颜色变化规律可以看出,随着粒子生命期的增加,片元的颜色逐渐接近终止颜色。

(5)从前面的图2-22中可以看出,本节案例中火焰有4种不同的效果。同时,前面介绍基本原理时也提到过,实际粒子系统的开发中,开发人员需要根据目标特效的需求给出合适的各项粒子特性,就可以真实地模拟出火焰、烟雾、爆炸等不同的效果。因此,为了使用方便,本节案例中将 4 种不同效果需要的特性数据封装进了一个常量类 ParticleDataConstant,其具体代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_12/src/com/bn/Sample2_12目录下的ParticleDataConstant.java

1 package com.bn.Sample2_12;      //包声明

2 import android.opengl.GLES20;      //相关类的引用

3 public class ParticleDataConstant{

4 ……//此处省略部分变量的声明,读者可自行查阅光盘的源代码

5 public static final float[][] START_COLOR={  //粒子起始颜色

6 {0.7569f,0.2471f,0.1176f,1.0f},    //0-普通火焰

7 {0.7569f,0.2471f,0.1176f,1.0f},    //1-白亮火焰

8 {0.6f,0.6f,0.6f,1.0f},       //2-普通烟

9 {0.6f,0.6f,0.6f,1.0f},       //3-纯黑烟

10 };

11 public static final float[][] END_COLOR={  //粒子终止颜色

12 {0.0f,0.0f,0.0f,0.0f},       //0-普通火焰

13 {0.0f,0.0f,0.0f,0.0f},       //1-白亮火焰

14 {0.0f,0.0f,0.0f,0.0f},       //2-普通烟

15 {0.0f,0.0f,0.0f,0.0f},       //3-纯黑烟

16 };

17 public static final int[] SRC_BLEND={   //源混合因子

18 GLES20.GL_SRC_ALPHA,       //0-普通火焰

19 GLES20.GL_ONE,         //1-白亮火焰

20 GLES20.GL_SRC_ALPHA,       //2-普通烟

21 GLES20.GL_ONE,         //3-纯黑烟

22 };

23 public static final int[] DST_BLEND={   //目标混合因子

24 GLES20.GL_ONE,         //0-普通火焰

25 GLES20.GL_ONE,         //1-白亮火焰

26 GLES20.GL_ONE_MINUS_SRC_ALPHA,     //2-普通烟

27 GLES20.GL_ONE,         //3-纯黑烟

28 };

29 public static final int[] BLEND_FUNC={   //混合方式

30 GLES20.GL_FUNC_ADD,       //0-普通火焰

31 GLES20.GL_FUNC_ADD,       //1-白亮火焰

32 GLES20.GL_FUNC_ADD,       //2-普通烟

33 GLES20.GL_FUNC_REVERSE_SUBTRACT,    //3-纯黑烟

34 };

35 public static final float[] RADIS={    //单个粒子半径

36 0.5f,           //0-普通火焰

37 0.5f,           //1-白亮火焰

38 0.8f,           //2-普通烟

39 0.8f,           //3-纯黑烟

40 };

41 public static final float[] MAX_LIFE_SPAN={  //粒子最大生命期

42 6.0f,           //0-普通火焰

43 6.0f,            //1-白亮火焰

44 7.0f,            //2-普通烟

45 7.0f,            //3-纯黑烟

46 };

47 public static final float[] LIFE_SPAN_STEP={   //粒子生命期步进

48 0.07f,            //0-普通火焰

49 0.07f,            //1-白亮火焰

50 0.07f,            //2-普通烟

51 0.07f,            //3-纯黑烟

52 };

53 public static final float[] X_RANGE={    //粒子发射的x左右范围

54 0.5f,            //0-普通火焰

55 0.5f,            //1-白亮火焰

56 0.5f,            //2-普通烟

57 0.5f,            //3-纯黑烟

58 };

59 public static final float[] Y_RANGE={    //粒子发射的y上下范围

60 0.3f,            //0-普通火焰

61 0.3f,            //1-白亮火焰

62 0.15f,            //2-普通烟

63 0.15f,            //3-纯黑烟

64 };

65 public static final int[] GROUP_COUNT={    //每次发射的粒子数量

//0-普通火焰66 4,

67 4,             //1-白亮火焰

68 1,             //2-普通烟

69 1,             //3-纯黑烟

70 };

71 public static final float[] VY={      //粒子y方向升腾的速度

72 0.05f,            //0-普通火焰

73 0.05f,            //1-白亮火焰

74 0.04f,            //2-普通烟

75 0.04f,            //3-纯黑烟

76 };

77 public static final int[] THREAD_SLEEP={    //粒子更新物理线程休眠时间(ms)

78 60,            //0-普通火焰

79 60,            //1-白亮火焰

80 30,            //2-普通烟

81 30,            //3-纯黑烟

82 };}

● 本案例中一共有4 种粒子特效,每一种粒子特效系统中采用的各项参数是不同的,具体包括起始颜色、终止颜色、混合因子,混合方式、最大允许生命期等。

● 混合方式以及混合因子的基础知识,在本书第一卷中已经进行了详细介绍,需要了解的读者可以查阅本书第一卷中的相关内容,这里不再赘述。

(6)最后给出的是实现控制片源颜色变化的片源着色器,其代码如下。

代码位置:见随书光盘中源代码/第2章/Sample2_12/assets目录下的flag.sh。

1 precision mediump float;       //给出默认浮点精度

2 uniform vec4 startColor;       //起始颜色

3 uniform vec4 endColor;       //终止颜色

4 uniform float sjFactor;       //总衰减因子

5 uniform float bj;        //纹理矩形半径

6 uniform sampler2D sTexture;      //纹理内容数据

7 varying vec2 vTextureCoord;      //接收从顶点着色器传过来的纹理坐标

8 varying vec3 vPosition;      //接收从顶点着色器传过来的片元位置

9 void main() {

10 vec4 colorTL=texture2D(sTexture, vTextureCoord);//进行纹理采样

11 vec4 colorT;           //颜色变量

12 float disT=distance(vPosition,vec3(0.0,0.0,0.0));//计算当前片元与中心点的距离

13 float tampFactor=(1.0-disT/bj)*sjFactor;  //计算片元颜色插值因子

14 vec4 factor4=vec4(tampFactor,tampFactor,tampFactor,tampFactor);

15 colorT=clamp(factor4,endColor,startColor);  //进行颜色插值

16 colorT=colorT*colorTL.a;      //结合采样出的透明度计算最终颜色

17 gl_FragColor=colorT;       //将计算出来的片元颜色传给渲染管线

18 }

提示

上述片元着色器实现了上一小节图2-21中给出的粒子中片元颜色值的计算策略,主体思想就是在计算出片元颜色插值因子后,通过在起始颜色与终止颜色间进行线性插值,并结合纹理采样颜色的透明度得出最终的片元颜色。

2.6 本章小结

本章主要介绍了片元着色器的几种巧妙用法,包括程序纹理技术、数字图像处理、分形集合的可视化,以及体积雾与粒子系统火焰特效等。通过本章的学习,读者可以初窥片元着色器在开发中的巨大魅力,为以后开发出具有吸引力的应用程序打下坚实的基础。

图书在版编目(CIP)数据

OpenGL ES 2.0游戏开发.下卷,高级特效和典型案例/吴亚峰著.--北京:人民邮电出版社,2014.4

ISBN 978-7-115-34469-4

Ⅰ.①O… Ⅱ.①吴… Ⅲ.①图形软件—软件开发 Ⅳ.①TP391.41

中国版本图书馆CIP数据核字(2014)第027771号

内容提要

本书共分12章,内容按照必知必会的基础知识、基于OpenGL ES 2.0实现基本特效,以及真实大型游戏案例的顺序进行详细讲解。第1章介绍如何通过顶点着色器实现酷炫效果,包括飘扬的旗帜、扭动的软糖、展翅飞翔的雄鹰、吹气特效等。第2章介绍程序纹理、数字图像处理技术、分形着色器、粒子系统火焰特效等。第3章介绍模拟现实世界的一些光学效果,如反射、折射、凹凸映射、高真实感地形等。第4章介绍投影贴图、平面阴影、阴影映射、阴影贴图、聚光灯高级光源、高真实感水面倒影等光影效果。第5章游戏开发中的物理学,介绍包括AABB包围盒、穿透效应、粒子系统以及弹簧质点模型等。第6章介绍3D游戏开发中经常使用的3D物理引擎jBullet、Bullet,各种物体形状的碰撞以及NDK下Bullet物理引擎的使用。第7章介绍3D游戏开发中常用的骨骼动画技术。第8章介绍包括3D拾取、多点触控以及多键监听等技巧。第9章讨论3D游戏开发中的一些性能优化问题。第10章~第12章用3大案例,如火力篮球、虚拟停车场、WebGL模拟飞行把所学知识贯穿起来,达到了学以致用的目的。

本书内容丰富,从基本知识到高级特效,从简单的应用程序到完整的3D游戏案例,适合不同需求、不同水平层次的各类读者。特别是移动游戏开发者、程序员,以及大专院校相关专业师生的学习用书和培训学校的教材。

◆著 吴亚峰

审校 百纳科技

责任编辑 张涛

责任印制 程彦红 杨林杰

◆人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

大厂聚鑫印刷有限责任公司印刷

◆开本:787×1092 1/16

印张:25.25  彩插:4

字数:697千字  2014年4月第1版

印数:1-3500册  2014年4月河北第1次印刷

定价:79.00元(附光盘)

读者服务热线:(010)81055410 印装质量热线:(010)81055316

反盗版热线:(010)81055315

广告经营许可证:京崇工商广字第0021号

相关图书

Python面向对象编程:构建游戏和GUI
Python面向对象编程:构建游戏和GUI
精通游戏测试(第3版)
精通游戏测试(第3版)
罗布乐思开发官方指南 从入门到实践
罗布乐思开发官方指南 从入门到实践
游戏引擎原理与实践 卷2 高级技术
游戏引擎原理与实践 卷2 高级技术
游戏数值设计
游戏数值设计
游戏引擎原理与实践 卷1 基础框架
游戏引擎原理与实践 卷1 基础框架

相关文章

相关课程