OpenGL.ES在Android上的简单实践:
13-全景(画个球)
1、画个球
继续上一节的操作,我们已经通过两次for循环遍历了一个球的所有网格矩形的顶点,并用List存储起来。并且用另外的一个List存储所要画的三角形顶点的索引值。接下来,我们就要开始使用VBO-IBO画出网格矩形,最终画出整个球体出来。
private void initVertexData() {
final int angleSpan = 5;
final float radius = 1.0f;
short offset = 0;
ArrayList<Float> vertexList = new ArrayList<>(); // 使用list存放顶点数据
ArrayList<Short> indexList = new ArrayList<>(); // 顶点索引数组
for (int vAngle = 0; vAngle < 180; vAngle = vAngle + angleSpan)
{
for (int hAngle = 0; hAngle <= 360; hAngle = hAngle + angleSpan)
{
... ...
}
}
numElements = indexList.size();// 记录有多少个索引点
// 创建 顶点数据缓存对象
float[] data_vertex = new float[vertexList.size()];
for (int i = 0; i < vertexList.size(); i++) {
data_vertex[i] = vertexList.get(i);
}
vertexBuffer = new VertexBuffer(data_vertex);
// 创建 索引缓存对象
short[] data_index = new short[indexList.size()];
for (int i = 0; i < indexList.size(); i++) {
data_index[i] = indexList.get(i);
}
indexBuffer = new IndexBuffer(data_index);
}
数据准备好了,下一步我们准备着色器程序,这个球的着色器比较简单,我们只需要把顶点数据画出来,至于颜色值,我们这次没有附加到顶点数据里面,那就直接指定就好了。下面直接贴上着色器的vertex_shader和fragment_shader的代码:
uniform mat4 u_Matrix; //最终的变换矩阵
attribute vec4 a_Position; //顶点位置
void main()
{
gl_Position = u_Matrix * a_Position;
}
precision mediump float;
void main()
{
vec4 color = vec4(1.0, 0.2, 0.8, 0);
// 我们这次直接在片段着色器内部指定颜色。
gl_FragColor = color;
}
这组着色器比正方体的更为简单,不难理解。(以后会安排关于shader的进阶学习,(^-^)V)
然后我们直接构建着色器程序,并获取其中的属性变量,贴上模板代码:
public class BallShaderProgram extends ShaderProgram {
protected static final String U_MATRIX = "u_Matrix";
public final int uMatrixLocation;
protected static final String A_POSITION = "a_Position";
public final int aPositionLocation;
public BallShaderProgram(Context context ) {
super(context, R.raw.ball_vs, R.raw.ball_fs);
uMatrixLocation = GLES20.glGetUniformLocation(programId, U_MATRIX);
aPositionLocation = GLES20.glGetAttribLocation(programId, A_POSITION);
}
public void setUniforms(float[] matrix){
GLES20.glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0);
}
}
最后,我们再准备一个方法,用于把顶点数据VBO设置到着色器程序属性的使能接口。超简单,如下:
private void setAttributeStatus() {
vertexBuffer.setVertexAttributePointer(
ballShaderProgram.aPositionLocation,
POSITION_COORDIANTE_COMPONENT_COUNT,
0, 0 );
}
好了,我们看看现在球体类Ball.java的整体代码结构:
public class Ball {
private static final int POSITION_COORDIANTE_COMPONENT_COUNT = 3; // 每个顶点的坐标数 x y z
private Context context;
IndexBuffer indexBuffer; // 顶点数据缓存对象
VertexBuffer vertexBuffer; // 索引缓存对象
BallShaderProgram ballShaderProgram; // 着色器程序
float[] modelMatrix = new float[16]; // 模型矩阵
public Ball(Context context){
this.context = context;
Matrix.setIdentityM(modelMatrix,0);
initVertexData();
buildProgram();
// setAttributeStatus();
}
private void initVertexData() {
... ...
// 节省篇幅,具体内容参照以上和前一节文章。
}
private void buildProgram() {
ballShaderProgram = new BallShaderProgram(context);
ballShaderProgram.userProgram();
}
private void setAttributeStatus() {
vertexBuffer.setVertexAttributePointer(
ballShaderProgram.aPositionLocation,
POSITION_COORDIANTE_COMPONENT_COUNT,
0, 0 );
}
}
2、还是画个球。
球体类ball已经准备差不多了,我们回到PanoramaRenderer,创建一个ball,并添加一系列的模板代码:
public class PanoramaRenderer implements GLSurfaceView.Renderer{
private final Context context;
private final float[] modelViewProjectionMatrix = new float[16];
private final float[] viewProjectionMatrix = new float[16];
private final float[] projectionMatrix = new float[16];
private final float[] viewMatrix = new float[16];
public PanoramaRenderer(Context context) {
this.context = context;
Matrix.setIdentityM(projectionMatrix,0);
Matrix.setIdentityM(viewMatrix,0);
Matrix.setIdentityM(viewProjectionMatrix,0);
Matrix.setIdentityM(modelViewProjectionMatrix,0);
}
Ball ball;
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
ball = new Ball(context);
}
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
GLES20.glViewport(0,0,width,height);
GLES20.glEnable(GLES20.GL_DEPTH_TEST);
// 打开深度测试
MatrixHelper.perspectiveM(projectionMatrix, 45, (float)width/(float)height, 1f, 100f);
Matrix.setLookAtM(viewMatrix, 0,
0f, 0f, 3f,
0f, 0f, 0f,
0f, 1f, 0f);
Matrix.multiplyMM(viewProjectionMatrix,0, projectionMatrix,0, viewMatrix,0);
}
@Override
public void onDrawFrame(GL10 gl10) {
GLES20.glClear( GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
// ball->draw
}
}
到这里,我们就差最后一步draw了。那究竟要怎么draw呢?我们想想之前的正方体的代码。每次draw之前都实时更新mvp矩阵,我们现在ball的着色器程序封装在里面了,不单独暴露在外,所以我们draw的接口要稍微改写一下,需要接受mvp矩阵并更新到着色器里面。代码如下:
public void draw(float[] modelViewProjectionMatrix) {
ballShaderProgram.userProgram();
setAttributeStatus();
// 将最终变换矩阵写入
ballShaderProgram.setUniforms(modelViewProjectionMatrix);
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.getIndexBufferId());
GLES20.glDrawElements(GLES20.GL_TRIANGLES, numElements, GLES20.GL_UNSIGNED_SHORT, 0);
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0);
}
更新着色器程序的矩阵后,我们就要激活索引缓存对象IBO,并用一个int值numElements记录索引元素的数目,然后调用GLES20.glDrawElements接口绘制索引值;注意第三个参数GL_UNSIGNED_SHORT,因为我们的索引值类型是short;最后绘制成功后解绑缓存对象。
现在,让我们启动程序,打开PanoramaActivity,看看ball是什么效果?
3、增加纹理
以上程序运行后,画面呈现一个圆形,我们根本看不出这是一个完整的球。我们可以在这个圆形上贴上一张全景图,最典型的全景图就是世界地图或者从其实视觉网站复制一张下来,如下:
那么,我们就在以上的着色器程序的基础上添加纹理吧。纹理相关的知识我们说得不多,基础的知识参考这里。我们这此机会好好复习一下纹理的知识。
首先,添加纹理到OpenGL并显示出来,着色器程序需要两个关键,一个是纹理单元,另外一个就是纹理坐标了。我们更新顶点着色器和片段着色器。
uniform mat4 u_Matrix; //最终的变换矩阵
attribute vec4 a_Position; //顶点位置
attribute vec2 a_TextureCoordinates; //纹理坐标
varying vec2 v_TextureCoordinates; //传递給片段着色器
void main()
{
gl_Position = u_Matrix * a_Position;
v_TextureCoordinates = a_TextureCoordinates;
}
precision mediump float;
uniform sampler2D u_TextureUnit; // 纹理采样器
varying vec2 v_TextureCoordinates; // 纹理坐标
void main()
{
// vec4 color = vec4(1.0, 0.2, 0.8, 0);
// gl_FragColor = color;
gl_FragColor = texture2D(u_TextureUnit, v_TextureCoordinates); // 纹理采样
}
对应的,我们需要更新着色器程序BallShaderProgram
... ...
protected static final String A_TEXTURE_COORDINATES = "a_TextureCoordinates";
public final int aTextureCoordinatesLocation;
protected static final String U_TEXTURE_UNIT = "u_TextureUnit";
public final int uTextureUnitLocation;
public BallShaderProgram(Context context ) {
super(context, R.raw.ball_vs, R.raw.ball_fs);
... ...
aTextureCoordinatesLocation=GLES20.glGetAttribLocation(programId, A_TEXTURE_COORDINATES);
uTextureUnitLocation = GLES20.glGetUniformLocation(programId, U_TEXTURE_UNIT);
}
接下来,我们在Ball类中添加纹理的初始化模板代码,就一行代码,so easy:
private int textureId;
public Ball(Context context){
... ...
initVertexData();
initTexture();
buildProgram();
}
private void initTexture() {
textureId = TextureHelper.loadTexture(context, R.mipmap.world);
}
同样更新着色器程序BallShaderProgram赋值接口setUniforms,增加纹理单元的赋值
public void setUniforms(float[] matrix,int textureId){
GLES20.glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0);
// 激活纹理单元0
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
// 绑定纹理对象ID
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
// 告诉shaderProgram sampler2D纹理采集器 使用纹理单元0的纹理对象。
GLES20.glUniform1i(uTextureUnitLocation, 0);
}
好了,我们打通了着色器程序纹理单元这一道了,剩下纹理坐标。那么问题来了,纹理坐标怎么计算?我们参考球体坐标,其实一个三维圆降到二维,就是一个长方矩形,我们很好的就能对应到全景图的纹理坐标。我们还是沿用以前的方法,把坐标数据和纹理数据放在一起,当作一组完整的顶点数据,保存统一缓存对象中。我们修改ball的initVertexData代码:
public class Ball {
private static final int POSITION_COORDIANTE_COMPONENT_COUNT = 3; // 每个顶点的坐标数 x y z
private static final int TEXTURE_COORDIANTE_COMPONENT_COUNT = 2; // 每个顶点的坐标数 s t
private static final int STRIDE = (POSITION_COORDIANTE_COMPONENT_COUNT
+ TEXTURE_COORDIANTE_COMPONENT_COUNT)
* Constants.BYTES_PER_FLOAT;
private void initVertexData() {
final int angleSpan = 5;// 将球进行单位切分的角度,此数值越小划分矩形越多,球面越趋近平滑
final float radius = 1.0f;// 球体半径
short offset = 0;
ArrayList<Float> vertexList = new ArrayList<>(); // 使用list存放顶点数据
ArrayList<Short> indexList = new ArrayList<>();// 顶点索引数组
for (int vAngle = 0; vAngle < 180; vAngle = vAngle + angleSpan)
{
for (int hAngle = 0; hAngle <= 360; hAngle = hAngle + angleSpan)
{
// st纹理坐标
float s0 = hAngle / 360.0f; //左上角 s
float t0 = vAngle / 180.0f; //左上角 t
float s1 = (hAngle + angleSpan)/360.0f; //右下角s
float t1 = (vAngle + angleSpan)/180.0f; //右下角t
// 左上角 0
float x0 = (float) (radius * Math.sin(Math.toRadians(vAngle)) * Math.cos(Math
.toRadians(hAngle)));
float y0 = (float) (radius * Math.sin(Math.toRadians(vAngle)) * Math.sin(Math
.toRadians(hAngle)));
float z0 = (float) (radius * Math.cos(Math.toRadians(vAngle)));
vertexList.add(x0);
vertexList.add(y0);
vertexList.add(z0);
vertexList.add(s0);
vertexList.add(t0);
// 右上角 1
float x1 = (float) (radius * Math.sin(Math.toRadians(vAngle)) * Math.cos(Math
.toRadians(hAngle + angleSpan)));
float y1 = (float) (radius * Math.sin(Math.toRadians(vAngle)) * Math.sin(Math
.toRadians(hAngle + angleSpan)));
float z1 = (float) (radius * Math.cos(Math.toRadians(vAngle)));
vertexList.add(x1);
vertexList.add(y1);
vertexList.add(z1);
vertexList.add(s1);
vertexList.add(t0);
// 右下角 2
float x2 = (float) (radius * Math.sin(Math.toRadians(vAngle + angleSpan)) * Math
.cos(Math.toRadians(hAngle + angleSpan)));
float y2 = (float) (radius * Math.sin(Math.toRadians(vAngle + angleSpan)) * Math
.sin(Math.toRadians(hAngle + angleSpan)));
float z2 = (float) (radius * Math.cos(Math.toRadians(vAngle + angleSpan)));
vertexList.add(x2);
vertexList.add(y2);
vertexList.add(z2);
vertexList.add(s1);
vertexList.add(t1);
// 左下角 3
float x3 = (float) (radius * Math.sin(Math.toRadians(vAngle + angleSpan)) * Math
.cos(Math.toRadians(hAngle)));
float y3 = (float) (radius * Math.sin(Math.toRadians(vAngle + angleSpan)) * Math
.sin(Math.toRadians(hAngle)));
float z3 = (float) (radius * Math.cos(Math.toRadians(vAngle + angleSpan)));
vertexList.add(x3);
vertexList.add(y3);
vertexList.add(z3);
vertexList.add(s0);
vertexList.add(t1);
indexList.add((short)(offset + 0));
indexList.add((short)(offset + 3));
indexList.add((short)(offset + 2));
indexList.add((short)(offset + 0));
indexList.add((short)(offset + 2));
indexList.add((short)(offset + 1));
offset += 4; // 4个顶点的偏移
}
}
numElements = indexList.size();// 记录有多少个索引点
float[] data_vertex = new float[vertexList.size()];
for (int i = 0; i < vertexList.size(); i++) {
data_vertex[i] = vertexList.get(i);
}
vertexBuffer = new VertexBuffer(data_vertex);
short[] data_index = new short[indexList.size()];
for (int i = 0; i < indexList.size(); i++) {
data_index[i] = indexList.get(i);
}
indexBuffer = new IndexBuffer(data_index);
}
}
我们利用球坐标的两个弧角度,在双for循环中求出同一位置下的纹理和位置坐标,并添加到list中。随后我们就要更新setAttributeStatus方法,使得设置着色器程序正确,更新的代码如下:
private void setAttributeStatus() {
// 每一组完整的顶点数据间隔STRIDE个字节,请注意
vertexBuffer.setVertexAttributePointer(
ballShaderProgram.aPositionLocation,
POSITION_COORDIANTE_COMPONENT_COUNT,
STRIDE, 0 );
// 每一组完整的顶点数据由 x y z s t 排列组成,所以纹理数据要先偏移正确的字节数,才能载入。
vertexBuffer.setVertexAttributePointer(
ballShaderProgram.aTextureCoordinatesLocation,
TEXTURE_COORDIANTE_COMPONENT_COUNT,
STRIDE,
POSITION_COORDIANTE_COMPONENT_COUNT * Constants.BYTES_PER_FLOAT);
}
此时运行项目,看看效果是否如下:
没毛病是吧?额,还真有点毛病,还记得我们设置的观察矩阵(相机)的位置参数是多少吗?不记得我贴上来:
Matrix.setLookAtM(viewMatrix, 0,
0f, 0f, 4f,// x,y,z
0f, 0f, 0f,
0f, 1f, 0f);
明明是在z轴上,根据OpenGL在Android上的世界坐标,z轴就是垂直屏幕xy的平面,平视球体的中心,也就是我们看到的应该是地球赤道啊,为啥我们现在看到的是北极圈?哈,毛病是在我们上篇文章百度回来的球体坐标,看清楚它的xyz坐标方向和我们的OpenGL在Android上的世界坐标方向,原来是不一样的!我们要把它调整回正确的方向。球体坐标的xyz对应AndroidOpenGL的zxy。请大家注意。
还有一点:不知道有没同学疑问,正常的OpenGL纹理坐标是st是至下而上,至左向右的延伸。那么对应我们球体的双for循环, 左上角0 不应该对应的是 s0t1?右上角1 不应该对应的是 s1t1?这个和Android的图片原点坐标有关,详细请参考之前的文章。