【安卓音视频开发OpenGLES】 开发入门(三):绘制一个3D立方体

1,345 阅读10分钟

本文正在参加「金石计划」

前篇回顾*

前面一篇文章我们介绍了关于如何在OpenGL中使用纹理,以及纹理坐标,纹理映射等内容,相信你们已经都学会了。那么今天我们来做个稍微难点的东西:使用OpenGL画一个立方体贴图。就是下面这种效果。

ezgif-4-018edcd24a

废话不多说,我们直接进入正题。

纹理坐标设置

同样的,我们需要先定义好立方体的顶点坐标:一个立方体有6个面,一个面需要2个三角形图元组成,也就是需要6个顶点(当然你可以使用EBO索引缓冲对象来让两个三角形共用4个顶点),这里为了方便大家理解,我就直接使用一个面6个顶点来处理了,6个面就是36个顶点坐标,分别定义如下:

//0.初始化顶点数据
    float vertices[] = {
            //顶点位置坐标------------纹理坐标
            -0.5f, -0.5f, -0.5f,    0.0f, 0.0f,
            0.5f, -0.5f, -0.5f,     1.0f, 0.0f,
            0.5f,  0.5f, -0.5f,     1.0f, 1.0f,
            0.5f,  0.5f, -0.5f,     1.0f, 1.0f,
            -0.5f,  0.5f, -0.5f,    0.0f, 1.0f,
            -0.5f, -0.5f, -0.5f,    0.0f, 0.0f,
​
            -0.5f, -0.5f,  0.5f,    0.0f, 0.0f,
            0.5f, -0.5f,  0.5f,     1.0f, 0.0f,
            0.5f,  0.5f,  0.5f,     1.0f, 1.0f,
            0.5f,  0.5f,  0.5f,     1.0f, 1.0f,
            -0.5f,  0.5f,  0.5f,     0.0f, 1.0f,
            -0.5f, -0.5f,  0.5f,     0.0f, 0.0f,
​
            -0.5f,  0.5f,  0.5f,    1.0f, 0.0f,
            -0.5f,  0.5f, -0.5f,    1.0f, 1.0f,
            -0.5f, -0.5f, -0.5f,    0.0f, 1.0f,
            -0.5f, -0.5f, -0.5f,    0.0f, 1.0f,
            -0.5f, -0.5f,  0.5f,    0.0f, 0.0f,
            -0.5f,  0.5f,  0.5f,    1.0f, 0.0f,
​
            0.5f,  0.5f,  0.5f,     1.0f, 0.0f,
            0.5f,  0.5f, -0.5f,     1.0f, 1.0f,
            0.5f, -0.5f, -0.5f,     0.0f, 1.0f,
            0.5f, -0.5f, -0.5f,     0.0f, 1.0f,
            0.5f, -0.5f,  0.5f,     0.0f, 0.0f,
            0.5f,  0.5f,  0.5f,     1.0f, 0.0f,
​
            -0.5f, -0.5f, -0.5f,    0.0f, 1.0f,
            0.5f, -0.5f, -0.5f,     1.0f, 1.0f,
            0.5f, -0.5f,  0.5f,     1.0f, 0.0f,
            0.5f, -0.5f,  0.5f,     1.0f, 0.0f,
            -0.5f, -0.5f,  0.5f,    0.0f, 0.0f,
            -0.5f, -0.5f, -0.5f,    0.0f, 1.0f,
​
            -0.5f,  0.5f, -0.5f,    0.0f, 1.0f,
            0.5f,  0.5f, -0.5f,     1.0f, 1.0f,
            0.5f,  0.5f,  0.5f,     1.0f, 0.0f,
            0.5f,  0.5f,  0.5f,     1.0f, 0.0f,
            -0.5f,  0.5f,  0.5f,    0.0f, 0.0f,
            -0.5f,  0.5f, -0.5f,    0.0f, 1.0f
    };

注意:每一行的前三位为一个顶点坐标位置,后两位为纹理坐标位置,因为我们还需要为每个面都设置一个纹理贴图。

设置好纹理坐标,下一步就是对于的顶点着色器以及片段着色器的编写了。

顶点着色器

#version 300 es
layout(location = 0) in vec4 vPosition;
layout(location = 1) in vec2 texCords;
out vec2 TexCoord;
void main()
{
   gl_Position = vPosition;
   TexCoord = texCords;
}

片段着色器:

#version 300 es
precision mediump float;
in vec2 TexCoord;
out vec4 fragColor;
uniform sampler2D textureColor;
void main()
{
   vec3 picColor = vec3 (texture(textureColor,TexCoord));
   fragColor = vec4 (picColor, 1.0 );
}

可以看到着色器代码部分很简单,和前面一篇文章的设置正方形的顶点着色器基本一样。所以不要觉得GLSL是什么很玄乎的东西,你只要把他看做是运行在GPU上的一段处理输入并输出的代码就可以

in和out关键字的使用,一个是输入,一个是输出。

编译着色器

programObj = GLUtils::CreateProgram(vShaderStr,fShaderStr);

编译着色器一般步骤:

  • 1.创建顶点着色器对象,给顶点着色器对象绑定着色器代码,编译顶点着色器。
  • 2.用同样的方法,创建并编译片段着色器、
  • 3.创建着色器程序对象,并将1,2中创建的着色器对象依附到着色器程序对象中,最后链接着色器。

这里我将编译着色器封装在了一个工具库中,详见github。

设置顶点属性:

//2.生成VAO,VBO对象,并绑定顶点属性
GLuint VBO;
glGenVertexArrays(1,&VAO);
glGenBuffers(1,&VBO);
​
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER,VBO);
glBufferData(GL_ARRAY_BUFFER,sizeof(vertices),vertices,GL_STATIC_DRAW);
​
//顶点坐标属性
glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,5*sizeof(GLfloat),(GLvoid*)0);
glEnableVertexAttribArray(0);
//顶点颜色属性
glVertexAttribPointer(1,2,GL_FLOAT,GL_FALSE,5*sizeof(GLfloat),(GLvoid*)(3*sizeof(GLfloat)));
glEnableVertexAttribArray(1);
glBindVertexArray(GL_NONE);

由于这里我们没有使用EBO索引缓冲对象,所以不需要EBO来设置顶点索引。

顶点属性的标准流程:

  • 1.创建顶点数组对象VAO以及顶点缓冲对象VBO
  • 2.绑定顶点数组对象VAO
  • 3.绑定VBO,并为VAO绑定对应的顶点数据
  • 4.设置顶点坐标属性/纹理坐标属性等
  • 5.解绑VAO对象。

记住,这是标准流程,不同形状需求,无非就是改动顶点数据结构,其实都可以套用这个步骤。

加载纹理贴图:

//生成纹理
glGenTextures(1, &textureID);
ImageUtil::_stbi_set_flip_vertically_on_load(true);
int width, height, nrComponents;
unsigned char *data = ImageUtil::_stb_image_load("/sdcard/mmpic.png", &width, &height, &nrComponents, 0);
if (data)
{
    GLenum format;
    if (nrComponents == 1)
        format = GL_RED;
    else if (nrComponents == 3)
        format = GL_RGB;
    else if (nrComponents == 4)
        format = GL_RGBA;
​
    glBindTexture(GL_TEXTURE_2D, textureID);
    glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
    glGenerateMipmap(GL_TEXTURE_2D);
​
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
​
    ImageUtil::_stb_image_free(data);
}
else
{
    LOGCATE("Texture failed to load at path: %s",data);
    ImageUtil::_stb_image_free(data);
}

纹理加载过程也是标准化过程:

  • 1.调用glBindTexture绑定纹理,由于这里使用的是2D纹理,所以使用的是GL_TEXTURE_2D
  • 2.调用glTexImage2D为绑定的2D纹理绑定添加纹理数据。
  • 3.调用glGenerateMipmap,自动生成纹理mipmap。

这里加载纹理贴图使用到了stb_image.h这个类库,强烈推荐大家可以去github上看看,这个库中还有很多其他好用的东西。

stb_image.h:github.com/nothings/st…

设置好这些,我们使用下面方式来渲染:

glDrawArrays(GL_TRIANGLES, 0, 36);

如果一切顺利你可能看到如下效果:

image-20230403153907419

额,不是说好的立方体么,这不只是个长方形么。。

先别急,还没完工,为了可以看到立方体的效果,我们还需要来了解下OpenGL中的坐标系统。

坐标系统

首先要知道OpenGL的所有坐标转换都是为了将local坐标转换为屏幕上的坐标

这个转换过程需要经过下面几个坐标:

  • 局部空间(Local Space,或者称为物体空间(Object Space))
  • 世界空间(World Space)
  • 观察空间(View Space,或者称为视觉空间(Eye Space))
  • 裁剪空间(Clip Space)
  • 屏幕空间( Screen Space)

而这些坐标在转换过程中,又需要使用到几个转换矩阵,其中最重要的几个分别是模型(Model)视图(View)投影(Projection) 三个矩阵。

首先,顶点坐标开始于局部空间(Local Space) ,称为局部坐标(Local Coordinate) ,然后经过世界坐标(World Coordinate)观察坐标(View Coordinate)裁剪坐标(Clip Coordinate) ,并最后以屏幕坐标(Screen Coordinate) 结束。下面的图示显示了整个流程及各个转换过程做了什么。

coordinate_systems

  1. 局部坐标是对象相对于局部原点的坐标;也是对象开始的坐标。
  2. 将局部坐标转换为世界坐标,世界坐标是作为一个更大空间范围的坐标系统。这些坐标是相对于世界的原点的。
  3. 接下来我们将世界坐标转换为观察坐标,观察坐标是指以摄像机或观察者的角度观察的坐标。
  4. 在将坐标处理到观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标是处理-1.0到1.0范围内并判断哪些顶点将会出现在屏幕上。
  5. 最后,我们需要将裁剪坐标转换为屏幕坐标,我们将这一过程成为视口变换(Viewport Transform) 。视口变换将位于-1.0到1.0范围的坐标转换到由glViewport函数所定义的坐标范围内。最后转换的坐标将会送到光栅器,由光栅器将其转化为片段。

上面这段是引用的官网话语。要去看的话需要花很多时间而且并不一定能看懂,下面小余就用大白话讲下:

  • 1.渲染的物体进行不是有可能进行平移,旋转,缩放等动作么,那么就用模型(Model)矩阵来处理。
  • 2.物体是渲染好了,但是我们从哪个角度观察呢?就是我们眼睛看物体哪个地方,那个方向呢,那就用视图(View)矩阵来处理。
  • 3.在我们真实世界中,看到的物体是不是越远就越小,越近就越大呢?实现这种效果就用到投影(Projection)矩阵来处理。

这样是不是很好理解了呢?

根据上面的讲解,我们先来设置这几个矩阵,让我们看到立方体效果。

首先来改造下顶点着色器代码:

#version 300 es
layout(location = 0) in vec4 vPosition;
layout(location = 1) in vec2 texCords;
out vec2 TexCoord;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
   gl_Position = projection*view*model*vPosition;
   TexCoord = texCords;
}

我们使用uniform关键字,传递三个全局的mat4矩阵变量,分别表示model矩阵,view矩阵,以及projection矩阵。

在C++代码中对顶点着色器中的全局变量进行设置:

glm::mat4 model,view,projection;
 glUniformMatrix4fv(glGetUniformLocation(programObj,"model"),1,GL_FALSE,glm::value_ptr(model));
view = glm::lookAt(glm::vec3(0.0f,0.0f,2.5f),glm::vec3(0.0f,0.0f,0.0f),glm::vec3(0.0f,1.0f,0.0f));      glUniformMatrix4fv(glGetUniformLocation(programObj,"view"),1,GL_FALSE,glm::value_ptr(view));
projection = glm::perspective(45.0f,(GLfloat)mScreenWidth/(GLfloat)mScreenHeight,0.1f,100.0f);
        glUniformMatrix4fv(glGetUniformLocation(programObj,"projection"),1,GL_FALSE,glm::value_ptr(projection));
 

对Model矩阵,我们没有做任何处理,只是设置了一个初始值。

对View矩阵,我们使用了glm::lookAt方法进行设置,下面来看lookAt方法参数:

  • 参数1:代表从哪看,就是观察者眼睛所在的位置,比如我们要在渲染的3D物体的左边看,那么就可以设置为vec3(-1.0f,0.0f,0.0f),就好像我们在手机的侧边-1.0f距离处看这个物体,这个距离可以调节,越大,眼睛位置离物体就越远,物体看起来就越小。
  • 参数2:代表眼睛看的位置,这里我们指定为vec3(0.0f,0.0f,0.0f),表示眼睛注视物体的中心位置,可调节。
  • 参数3:这是一个垂直我们眼睛的一个分量,这个向量是用来干嘛的呢,就是标识我们是头是斜着看呢还是正对着,还是倒立看呢?能理解不。

通过上面的三个参数,我们就建立了一个观察者,观察者的不同,我们看到的图像也不同,就好像:

一个女人漂亮不漂亮,光看背影是不够的。。

对projection矩阵,有两种,一种是正射投影,一种是透视投影,正射投影用的不多,我们来说下透视投影。

透视投影

透视投影是为了解决真实世界中的,离我们越远的物体,看起来就会越小。用火车路来看吧:

perspective

透视投影很好的解决了这个问题,物体远近体现在我们的屏幕中就是物体的深度,Z轴

透视投影使用glm::perspective来设置。

  • 参数1:定义了fov的值,它表示的是视野(Field of View) ,并且设置了观察空间的大小。对于一个真实的观察效果,它的值经常设置为45.0,当然大家也可以自定义大小来看效果。
  • 参数2:设置了宽高比,由视口的高除以宽.
  • 参数3和4:设置了平截头体的近和远平面。我们经常设置近距离为0.1远距离设为100.0所有在近平面和远平面的顶点且处于平截头体内的顶点都会被渲染

glm::perspective所做的其实就是再次创建了一个定义了可视空间的大的平截头体,任何在这个平截头体的对象最后都不会出现在裁剪空间体积内,并且将会受到裁剪。一个透视平截头体可以被可视化为一个不均匀形状的盒子,在这个盒子内部的每个坐标都会被映射到裁剪空间的点。一张透视平截头体的照片如下所示:

 perspective_frustum

好了,设置好上面三个矩阵之后,我们来看下效果:

image-20230403164311448

嗯,图像变为了正方形了,但是还是没看到立方体呀?

如果你观察仔细,前面我们把View矩阵的第一个参数也就是眼睛的位置设置为了vec3(0.0f,0.0f,2.5f),x和y为0,z值为一个正数,z值代表深度,说明我们眼睛位置是正对屏幕的。所以你看到的还是一个正方形,我们把眼睛位置改为vec3(2.5f,1.5f,2.5f),看下效果:

image-20230403165043919

效果已经出来了,下面我们让这个正方体随着屏幕滑动起来。

float lastX = 0,lastY = 0;
@Override
public boolean onTouchEvent(MotionEvent event) {
    int action = event.getAction();
    float dx = 0,dy = 0;
    float curX = event.getX();
    float curY = event.getY();
    switch (action){
        case MotionEvent.ACTION_DOWN:
            lastX = curX;
            lastY = curY;
             break;
​
        case MotionEvent.ACTION_MOVE:
            dx = curX-lastX;
            dy = curY-lastY;
            mRenderer.move(dx,dy);
            lastX = curX;
            lastY = curY;
             break;
        case MotionEvent.ACTION_UP:
            mRenderer.move(0.0f,0.0f);
             break;
    }
    Log.d("MyGLSurfaceView","curX:"+curX+" curY:"+curY);
    Log.d("MyGLSurfaceView","dx:"+dx+" dy:"+dy);
    return true;
​
}

这里我们定义一个native接口,将每次屏幕滑动触发的x和y偏移量传递给native层。

在native层我们使用model矩阵来处理滑动效果:

 /**
 * 计算旋转轴
 * 使用两个向量的叉乘可以获取一个垂直这两个向量的新向量
 * 向量V为移动的方向向量vec3(moveX,moveY,0.0f);
 * 向量K为垂直屏幕的向量vec3(0.0f,0.0f,1.0f); 可以使用单位向量代替
 * */
if(moveX!=0||moveY!=0){
    float radius = 360.0f;
    float moveL = sqrt(moveX*moveX+moveY*moveY);
    LOGCATD("move moveL:%f",moveL);
    glm::vec3 _v = glm::vec3(moveX,moveY,0.0f);
    glm::vec3 _k = glm::vec3(0.0f,0.0f,1.0f);
    glm::vec3 _u = glm::cross(_k,_v);
    float angle = sin(moveL)*radius;
    LOGCATD("move angle:%f %f %f",_u.x,_u.y,_u.z);
    model = glm::rotate(model,angle,_u);
}else {
    model = glm::rotate(model,0.0f,glm::vec3(0.0f,1.0f,0.0f));
}

来看最终效果:

ezgif-4-018edcd24a

好了,本文就讲解到这里了,本文主要通过一个立方体的渲染过程,来讲解了OpenGL中的坐标系统的使用。

下篇文章,将会讲解OpenGL中的光照使用。我是小余,关注我我们下期见。

参考:

OpenGL中文教程