本节书摘来自异步社区《OpenGL ES 2.0游戏开发(上卷):基础技术和典型案例》一书中的第6章,第6.1节曲面物体的构建,作者 吴亚峰,更多章节内容可以访问云栖社区“异步社区”公众号查看
6.1 曲面物体的构建
OpenGL ES 2.0游戏开发(上卷):基础技术和典型案例
前面的章节中已经介绍了如何构建3D物体,但案例中的3D物体基本都是平面性质的(直接的平面形状或长方体等),还没有曲面性质的物体。对于演示光照效果而言,曲面物体更能凸显出光照效果的强大。因此,在正式介绍光照之前,本节首先基于球体的构建向读者简单介绍一下曲面物体的构建策略。
6.1.1 球体构建的基本原理
通过上一章的学习读者已经知道,OpenGL ES中任何形状的3D物体都是用三角形拼凑而成的,因此,构建曲面物体最重要的就是找到将曲面恰当拆分成三角形的策略。最基本的策略是首先按照一定的规则将物体按行和列两个方向进行拆分,这时就可以得到很多的小四边形。然后再将每个小四边形拆分成两个三角形即可,图6-1给出了基于这种策略的球面拆分思路。
从图6-1中可以看出,球面首先被按照纬度(行)和经度(列)的方向拆分成了很多的小四边形,每个小四边形又被拆分成两个小三角形。这种拆分方式下,三角形中每个顶点的坐标都可以用解析几何的公式方便地计算出来,具体情况如下。
x=R×cosα×cosβ y=R×cosα×sinβ z=R×sinα
上述给出的是当球的半径为R,在纬度为α,经度为β处球面上顶点坐标的计算公式。
提示
对一个曲面物体进行拆分时,以何为行,以何为列是不一定的,读者应该根据具体情况做出选择。
对曲面物体进行拆分时,拆分得越细,最终的绘制结果就越接近真实情况,图6-2很好地说明了这个问题。
提示
图6-2中从左至右依次为按照90°/份、45°/份、22.5°/份、11.25°/份对球面进行切分的情况,可以明显地看出,切分得越细,就越接近于真实的曲面。但也不是越细越好,切分得太细就会造成顶点数量过多,渲染速度大大降低。因此在开发中读者要掌握好两者之间的平衡,兼顾速度与效果。
6.1.2 案例效果概览
上一小节已经介绍了如何将球面拆分成一组小三角形,下面就可以基于上一小节介绍的原理开发出球体绘制的案例Sample6_1了。正式开发代码之前,有必要首先了解一下本案例的运行效果,如图6-3所示。
从图6-3中可以看出,本案例中的球体不是用单一颜色进行着色的,采用的是棋盘纹理着色器。棋盘纹理着色器是一种非常简单的着色器,其原理如图6-4所示。
说明
图6-4中的立方体为球的外接立方体,球面上的每个位置都在此外接立方体之内,此外接立方体沿x、y、z轴方向被切分成了很多同样尺寸的小方块。
具体的着色策略为,若片元位于黑色小方块中,就将该片元的颜色设置为红色;若片元位于浅灰色小方块中则将片元的颜色设置为白色,具体计算方法如下所列。
首先计算出当前片元x、y、z坐标对应的行数(x轴)、层数(y轴)及列数(z轴)。
如果行数、层数、列数之和为奇数,则片元为红色;若和为偶数,则片元为白色。
61.3 开发步骤
了解了案例的运行效果与基本原理后,就可以进行代码的开发了,具体步骤如下。
提示
由于本案例中的很多类与前面章节案例中的很类似,因此在这里只给出本案例中具有特殊性及代表性的代码。若读者对省略的代码感兴趣,可以参考随书光盘。
(1)首先需要介绍的是负责按照切分规则生成球面上顶点的坐标,并渲染球体的Ball类,其代码框架如下。
第X问1 代码位置:见随书光盘中源代码/第6章/Sample6_1/com/bn/Sample6_1目录下的Ball.java。
1 package com.bn.Sample6_1; //声明包
2 import java.nio.ByteBuffer; //引入相关类
3 ……//此处省略了部分类的引入代码,读者可自行查看随书光盘的源代码
4 import android.opengl.GLES20; //引入相关类
5 public class Ball { //球
6 int mProgram; //自定义渲染管线着色器程序id
7 int muMVPMatrixHandle; //总变换矩阵引用
8 int maPositionHandle; //顶点位置属性引用
9 int muRHandle; //球的半径参数引用
10 String mVertexShader; //顶点着色器代码脚本
11 String mFragmentShader; //片元着色器代码脚本
12 FloatBuffer mVertexBuffer; //顶点坐标数据缓冲
13 int vCount = 0; //顶点数量
14 float yAngle = 0;float xAngle = 0;float zAngle = 0; //绕_x_、_y_、_Z_轴旋转的角度
15 float r = 0.8f; //球的半径
16 public Ball(MySurfaceView mv) {
17 initVertexData(); //初始化顶点坐标
18 initShader(mv); //初始化着色器
19 }
20 public void initVertexData() {
21 ……//此处省略了初始化顶点坐标方法的代码,将在后面步骤中介绍
22 }
23 public void initShader(MySurfaceView mv) { //初始化着色器的方法
24 ……//此处省略了部分代码,读者可自行查看随书光盘的源代码
25 //获取着色器程序中球半径参数的引用
26 muRHandle = GLES20.glGetUniformLocation(mProgram, "uR");
27 }
28 public void drawSelf() {
29 ……//此处省略了部分代码,读者可自行查看随书光盘的源代码
30 GLES20.glUniform1f(muRHandle, r * UNIT_SIZE); //将半径属性传入渲染管线
31 GLES20.glVertexAttribPointer( //将顶点属性传入渲染管线
32 maPositionHandle, 3, GLES20.GL_FLOAT, false, 3 * 4, mVertexBuffer);
33 GLES20.glEnableVertexAttribArray(maPositionHandle);//允许渲染管线顶点位置数据
34 GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, vCount);//绘制球
35 }}
第6-15行声明了一些成员变量,其中大部分都与前面的很多案例类似。最大的区别是增添了着色器中球半径参数的引用muRHandle以及球的半径r。
第23-27行为初始化着色器的initShader方法,其中大部分代码与前面很多的案例类似,主要区别为增加了获取着色器程序中球半径参数引用的代码。
第28-35行为绘制球体的drawSelf方法,与前面案例中此方法的主要区别是增加了将当前球的半径传入渲染管线的代码。
(2)接下来介绍上一步骤中省略的负责初始化顶点的initVertexData方法,其具体代码如下。
第X问1 代码位置:见随书光盘中源代码/第6章/Sample6_1/com/bn/Sample6_1目录下的Ball.java。
1 public void initVertexData() {
2 ArrayList<Float> alVertix = new ArrayList<Float>();//存放顶点坐标值的ArrayList
3 final int angleSpan = 10; //将球进行单位切分的角度
4 for (int vAngle = -90; vAngle < 90; vAngle = vAngle + angleSpan) { //纬度方向angleSpan度一份
5 for (int hAngle = 0; hAngle <= 360; hAngle = hAngle + angleSpan){ //经度方向angleSpan度一份
6 //计算出以当前经度、纬度位置的顶点为左上侧点的四边形4个顶点的坐标
7 float x0 = (float) (r * UNIT_SIZE //第1个顶点的坐标
8 * Math.cos(Math.toRadians(vAngle)) * Math.cos(Math
9 .toRadians(hAngle)));
10 float y0 = (float) (r * UNIT_SIZE
11 * Math.cos(Math.toRadians(vAngle)) * Math.sin(Math .
toRadians(hAngle)));
12 float z0 = (float) (r * UNIT_SIZE * Math.sin(Math .toRadians(vAngle)));
13 float x1 = (float) (r * UNIT_SIZE //第2个顶点的坐标
14 * Math.cos(Math.toRadians(vAngle)) * Math.cos(Math
15 .toRadians(hAngle + angleSpan)));
16 float y1 = (float) (r * UNIT_SIZE
17 * Math.cos(Math.toRadians(vAngle)) * Math.sin(Math
18 .toRadians(hAngle + angleSpan)));
19 float z1 = (float) (r * UNIT_SIZE * Math.sin(Math .toRadians(vAngle)));
20 float x2 = (float) (r * UNIT_SIZE //第3个顶点的坐标
21 * Math.cos(Math.toRadians(vAngle + angleSpan)) * Math
22 .cos(Math.toRadians(hAngle + angleSpan)));
23 float y2 = (float) (r * UNIT_SIZE
24 * Math.cos(Math.toRadians(vAngle + angleSpan)) * Math
25 .sin(Math.toRadians(hAngle + angleSpan)));
26 float z2 = (float) (r * UNIT_SIZE * Math.sin(Math .toRadians(vAngle + angleSpan)));
27 float x3 = (float) (r * UNIT_SIZE //第4个顶点的坐标
28 * Math.cos(Math.toRadians(vAngle + angleSpan)) * Math
29 .cos(Math.toRadians(hAngle)));
30 float y3 = (float) (r * UNIT_SIZE
31 * Math.cos(Math.toRadians(vAngle + angleSpan)) * Math
32 .sin(Math.toRadians(hAngle)));
33 float z3 = (float) (r * UNIT_SIZE * Math.sin(Math .toRadians(vAngle + angleSpan)));
34 //将4个顶点的坐标按照卷绕成两个三角形的需要依次存入列表
35 alVertix.add(x1); alVertix.add(y1); alVertix.add(z1);
36 alVertix.add(x3); alVertix.add(y3); alVertix.add(z3);
37 alVertix.add(x0); alVertix.add(y0); alVertix.add(z0);
38 alVertix.add(x1); alVertix.add(y1); alVertix.add(z1);
39 alVertix.add(x2); alVertix.add(y2); alVertix.add(z2);
40 alVertix.add(x3); alVertix.add(y3); alVertix.add(z3);
41 }}
42 vCount = alVertix.size() / 3;//顶点的数量为坐标值数量的1/3,因为一个顶点有3个坐标
43 float vertices[] = new float[vCount * 3];
44 for (int i = 0; i < alVertix.size(); i++) { //将alVertix列表中的坐标值转存到一个float型数组中
45 vertices[i] = alVertix.get(i);
46 }
47 ByteBuffer vbb = ByteBuffer.allocateDirect(vertices.length * 4); //创建顶点坐标数据缓冲
48 vbb.order(ByteOrder.nativeOrder()); //设置字节顺序
49 mVertexBuffer = vbb.asFloatBuffer(); //转换为int型缓冲
50 mVertexBuffer.put(vertices); //向缓冲区中放入顶点坐标数据
51 mVertexBuffer.position(0); //设置缓冲区起始位置
52 }
第3行中的angleSpan为将球进行经纬度方向单位切分的角度,角度越小,切分得就越细,绘制出来的形状也越接近于球。
第4-41行用双层for循环将球按照一定的角度跨度(angleSpan)沿纬度、经度方向进行切分。每次循环到一组纬度、经度时都将对应顶点看作小四边形的左上侧点,然后按照规律计算出小四边形中其他3个顶点的坐标,最后按照需要将用于卷绕成两个三角形的6个顶点的坐标依次存入列表。
第42-51行首先将顶点坐标数据转存进数组中,然后再存入顶点数据缓冲中。
(3)完成了Java代码的开发后,就可以进行着色器的开发了。首先是顶点着色器,其具体代码如下。
第X问1 代码位置:见随书光盘中源代码/第6章/Sample6_1/assets目录下的vertex.sh。
1 uniform mat4 uMVPMatrix; //总变换矩阵
2 attribute vec3 aPosition; //从管线接收的顶点位置
3 varying vec3 vPosition; //用于传递给片元着色器的顶点位置
4 void main() {
5 gl_Position = uMVPMatrix * vec4(aPosition,1);//根据总变换矩阵计算此次绘制此顶点的位置
6 vPosition = aPosition; //将原始顶点位置传递给片元着色器
7 }
提示
上述顶点着色器的代码与前面章节的案例基本一致,主要是增加了将顶点位置通过易变变量vPosition传递给片元着色器的相关代码。
(4)完成了顶点着色器的开发后,就可以开发本案例中实现棋盘着色的片元着色器了,其代码如下。
第X问1 代码位置:见随书光盘中源代码/第6章/Sample6_1/assets目录下的frag.sh。
1 precision mediump float; //指定浮点相关变量的精度
2 uniform float uR; //从宿主程序中传入的球半径
3 varying vec3 vPosition; //接收从顶点着色器传递过来的顶点位置
4 void main() {
5 vec3 color;
6 float n = 8.0; //外接立方体每个坐标轴方向切分的份数
7 float span = 2.0*uR/n; //每一份的尺寸(小方块的边长)
8 int i = int((vPosition.x + uR)/span); //当前片元位置小方块的行数
9 int j = int((vPosition.y + uR)/span); //当前片元位置小方块的层数
10 int k = int((vPosition.z + uR)/span); //当前片元位置小方块的列数
11 int whichColor = int(mod(float(i+j+k),2.0)); //计算当前片元行数、层数、列数的和并对2取模
12 if(whichColor == 1) { //奇数时为红色
13 color = vec3(0.678,0.231,0.129); //红色
14 }else { //偶数时为白色
15 color = vec3(1.0,1.0,1.0); //白色
16 }
17 gl_FragColor=vec4(color,0); //将计算出的片元颜色传递给管线
18 }
说明>
上述片元着色器实现了如前一小节图6-4所示的棋盘着色器,其根据片元的位置计算出片元所在小方块的行数、层数、列数,再根据3个数之和的奇偶性确定片元所采用的颜色。