一、引言
在Android OpenGL基础(一、绘制三角形四边形)中,我们简单实现了绘制三角形的功能。大家可能会发现,我们声明的是一个标准设备坐标系下的等腰三角形:
class Triangle {
// 三角形三个点的坐标值
private var triangleCoords = floatArrayOf(
0.0f, 0.5f, 0.0f, // top
-0.5f, -0.5f, 0.0f, // bottom left
0.5f, -0.5f, 0.0f // bottom right
)
}
但是实际绘制出来的结果宽高与预期不符:

这是因为OpenGL假设屏幕采用均匀的方形坐标系,所以在把标准坐标系下的坐标绘制到非方形的屏幕上时,就会出现拉伸:
那怎么保证顶点的实际绘制效果与预期一致呢。要解决这个问题,可以通过应用 OpenGL 相机视图和投影模式来转换坐标,这样,我们的图形对象在任何屏幕上都会按正确的比例绘制。
二、坐标系统
在程序中设置了物体的顶点坐标后,OpenGL还需要经过一系列的坐标变换,最终变换到屏幕坐标,才决定了顶点的实际绘制位置。这个过程需要用到几个变换矩阵,最重要的几个分别是模型(Model)、观察(View)、投影(Projection) 三个矩阵。我们的顶点坐标起始于局部坐标(Local Coordinate),它在之后会变为世界坐标(World Coordinate),观察坐标(View Coordinate),裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Coordinate)的形式结束。
下面通过一个例子展示下各个变换过程的主要工作,假设我们要绘制一个正方体和一个三棱锥,在定义了正方体和三棱锥后,到他们实际绘制到屏幕上,经历了以下几个坐标变换:
2.1 局部坐标(Local Coordinate)
首先,我们需要先设置好正方体和三棱锥的顶点位置,这个时候分别在二者自身的局部坐标中设置自身顶点的坐标即可。二者的局部坐标都是一个(-1,1)的标准坐标系,此时二者之间还没有关系。

2.2 世界坐标(World Coordinate)
在各物体的局部坐标设置后,实际世界中的物体是分散放在不同地方的,如下图所示,正方体和三棱锥分别放到了不同不同的位置,这个时候二者之间才有了相互间的位置关系。

正方体和三棱锥的坐标将会从局部变换到世界空间。该变换是由模型矩阵(Model Matrix)实现的。模型矩阵是一种变换矩阵,它能通过对物体进行位移、缩放、旋转来将它置于它本应该在的位置或朝向。
目前Android OpenGL基础(一、绘制三角形四边形)中绘制三角形的例子比较简单,几何体都是放在世界坐标的正中心,所以暂时不需要特殊指定模型矩阵。暂时先只做简单了解。
2.3 观察坐标(View Coordinate)
在世界坐标中摆放完正方体和三棱锥的位置后,接下来需要设置我们想观察的位置,从不同的位置观察世界坐标中的正方体和三棱锥,看到的是不同的样子。例如从2.2小节中camera的位置去观察,看到的结果如下:

观察空间经常被人们称之OpenGL的摄像机(Camera,是个抽象的概念,不是Android手机的相机,不要混淆)。从世界空间转换到观察空间通常是由一系列的位移和旋转的组合来完成,这些组合在一起的变换通常存储在一个观察矩阵(View Matrix)里。
2.4 裁剪坐标(Clip Coordinate)
在设置了观察矩阵后,OpenGL世界的所有物体已经呈现在我们视野前方,但是实际展示的时候并不需要全部展示,因此需要把不展示的部分裁剪掉,绘制的时候忽略裁剪掉的部分,减少绘制时的运算量。决定哪部分可以展示是由投影矩阵决定的(因为使用投影矩阵能将3D坐标投影(Project)到很容易映射到2D的标准化设备坐标系中)。

为了将顶点坐标从观察变换到裁剪空间,我们需要定义一个投影矩阵(Projection Matrix),它指定了一个范围的坐标,比如在每个维度上的-1000到1000。投影矩阵接着会将在这个指定的范围内的坐标变换为标准化设备坐标的范围(-1.0, 1.0),而在矩阵投影范围之外的顶点坐标就会被裁剪掉。
将观察坐标变换为裁剪坐标的投影矩阵有两种形式,每种形式都定义了不同的平截头体。我们可以选择创建一个正射投影矩阵(Orthographic Projection Matrix)或一个透视投影矩阵(Perspective Projection Matrix)。
投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。
2.4.1 正射投影
正射投影矩阵定义了一个类似立方体的平截头箱,如下图所示,它定义了一个裁剪空间,在这空间之外的顶点都会被裁剪掉。创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度。在使用正射投影矩阵变换至裁剪空间之后处于这个平截头体内的所有坐标将不会被裁剪掉。下图的绿色正方体就不会被裁剪掉:
2.4.2 透视投影
透视是指实际生活中,具体我们越远的物体看起来越小。
想要达到透视的效果,需要使用透视投影矩阵。这个矩阵除了给出平截头体范围外,还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。
一个透视平截头体可以被看作一个不均匀形状的箱子,在这个箱子内部的每个坐标都会被映射到裁剪空间上的一个点:
2.4.3 区别
透视投影如左图所示,正射投影如右图所示。透视投影用于实际生活场景。正射投影主要用于二维渲染以及一些建筑或工程的程序,在这些场景中更希望顶点不会被透视所干扰。
2.5 屏幕坐标(Screen Coordinate)
最后的顶点应该被赋值到顶点着色器中的gl_Position,然后OpenGL会使用glViewPort内部的参数来将标准化设备坐标映射到屏幕坐标,每个坐标都关联了手机屏幕上的一个点。这个过程称为视口变换。

在Android OpenGL基础(一、绘制三角形四边形)的例子中,在2.1.2小节中onSurfaceChanged中的设置就是告诉OpenGL映射到屏幕坐标的视口变换。
class MyGLRenderer : GLSurfaceView.Renderer {
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
// 设置背景色为黑色
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
// 上面提到OpenGL使用的是标准化设备坐标;
GLES20.glViewport(0, 0, width, height)
}
override fun onDrawFrame(gl: GL10?) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
}
}
2.6 总结
OpenGL为上述的每一个步骤都创建了一个变换矩阵:模型矩阵、观察矩阵和投影矩阵
。一个顶点坐标将会根据以下过程被变换到裁剪坐标:
V
c
l
i
p
=
M
p
r
o
j
e
c
t
i
o
n
⋅
M
v
i
e
w
⋅
M
m
o
d
e
l
⋅
V
l
o
c
a
l
V_{clip} = M_{projection} ⋅ M_{view} ⋅ M_{model} ⋅ V_{local}
Vclip=Mprojection⋅Mview⋅Mmodel⋅Vlocal
注意矩阵运算的顺序是相反的(记住我们需要从右往左阅读矩阵的乘法)。最后的顶点会被赋值到顶点着色器中的gl_Position,OpenGL将会自动进行透视除法和裁剪。
三、基础用法
为了达到绘制预期宽高三角形的目的,我们需要应用 OpenGL 相机视图和投影模式来转换坐标,这样,我们的图形对象在任何屏幕上都会按正确的比例绘制。
3.1 设置观察矩阵投影矩阵
首先,在GLSurfaceView大小确定或发生改变后设置投影矩阵,在执行绘制方法时设置观察矩阵,并把模型矩阵、观察矩阵、投影矩阵的计算结果传递给其他需要绘制的物体(本例子中的物体都在屏幕中心,暂时不涉及模型矩阵):
class MyGLRenderer : GLSurfaceView.Renderer {
private lateinit var triangle: Triangle
// mvPMatrix是"Model View Projection Matrix"的缩写,代表模型矩阵、观察矩阵、投影矩阵
private val mvPMatrix = FloatArray(16)
// 投影矩阵
private val projectionMatrix = FloatArray(16)
// 观察矩阵
private val viewMatrix = FloatArray(16)
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
triangle = Triangle()
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)
val ratio: Float = width.toFloat() / height.toFloat()
// 通过设置平截头体的范围表示投影矩阵
Matrix.frustumM(projectionMatrix, 0, -ratio, ratio, -1f, 1f, 3f, 7f)
}
override fun onDrawFrame(gl: GL10?) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
// 设置观察矩阵
Matrix.setLookAtM(viewMatrix, 0, 0f, 0f, 3f, 0f, 0f, 0f, 0f, 1.0f, 0.0f)
// 计算投影矩阵、观察矩阵变换结果,保存到vPMatrix
Matrix.multiplyMM(mvPMatrix, 0, projectionMatrix, 0, viewMatrix, 0)
// 模型矩阵、观察矩阵、投影矩阵的计算结果vPMatrix传递给其他物体,其他物体依据矩阵进行变换
triangle.draw(mvPMatrix)
}
}
3.2 GLSL代码
修改Triangle中顶点着色器的代码如下:
class Triangle {
/**
* 顶点着色器代码;
*/
private val vertexShaderCode =
// uMVPMatrix变量是需要用于顶点坐标变换的矩阵
// 作为hook入口,用于绘制时传入模型矩阵、观察矩阵、投影矩阵
"uniform mat4 uMVPMatrix;" +
"attribute vec4 vPosition;" +
"void main() {" +
// 把vPosition顶点经过矩阵变换后传给gl_Position
" gl_Position = uMVPMatrix * vPosition;" +
"}"
}
3.3 绘制过程
接下来是三角形的绘制过程,大体流程与Android OpenGL基础(一、绘制三角形四边形)一致,不同之处在于在绘制之前,需要获取着色器程序中的uMVPMatrix变量,并将矩阵值传递给uMVPMatrix,这样在执行顶点绘制时就会执行矩阵换算的过程:
class Triangle {
fun draw(mvpMatrix: FloatArray) {
// 激活着色器程序
GLES20.glUseProgram(mProgram)
// 获取顶点着色器中的vPosition变量(因为之前已经编译过着色器代码,所以可以从着色器程序中获取);用唯一ID表示
val positionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition")
// 获取顶点着色器代码中的uMVPMatrix变量;用唯一ID表示
vPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uMVPMatrix")
// 把模型矩阵、观察矩阵、投影矩阵的计算结果传递给顶点着色器代码中的vPMatrixHandle
GLES20.glUniformMatrix4fv(vPMatrixHandle, 1, false, mvpMatrix, 0)
// 绘制三角形
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, vertexCount)
// 允许操作顶点对象position
GLES20.glEnableVertexAttribArray(positionHandle)
// 将顶点数据传递给position指向的vPosition变量
GLES20.glVertexAttribPointer(
positionHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT,
false, vertexStride, vertexBuffer)
// 获取片段着色器中的vColor变量
val colorHandle = GLES20.glGetUniformLocation(mProgram, "vColor")
// 通过colorHandle设置绘制的颜色值
GLES20.glUniform4fv(colorHandle, 1, color, 0)
// 绘制顶点数组;
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, vertexCount)
// 操作完后,取消允许操作顶点对象position
GLES20.glDisableVertexAttribArray(positionHandle)
}
}
绘制结果如下:

如果把透视矩阵改成Matrix.frustumM(projectionMatrix, 0, -ratio, ratio, -1f, 1f, 4f, 7f),再运行程序就会发现看不到三角形了,这是因为相机观察点的位置是Matrix.setLookAtM(viewMatrix, 0, 0f, 0f, 3f, 0f, 0f, 0f, 0f, 1.0f, 0.0f),我们的三角形z轴是0,距离相机观察点的距离是3,而透视矩阵的near面是4,我们的三角形已经不在平截头体范围内了。
3.4 函数说明
3.4.1 投影矩阵
在第2.4小节中提到,投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。一般通过设置平截头体范围描述投影矩阵。有以下几种创建方式:
- Matrix.orthoM():正射投影
- Matrix.perspectiveM():透视投影
- Matrix.frustumM():透视投影
/**
* 生成投影矩阵,把结果输出到第一个数据中
* @param m : 输出结果
* @param offset: 输出结果偏移量
* @param left : 平截头体near面的left
* @param right : 平截头体near面的right
* @param bottom : 平截头体near面的bottom
* @param top : 平截头体near面的top
* @param near : 平截头体near面距离观察点的值;即屏幕上可以看到的最近的一面
* @param far : 平截头体far面距离观察点的值;即屏幕上可以看到的最远的一面
**/
public static void frustumM(float[] m, int offset,
float left, float right, float bottom, float top,
float near, float far) { }
3.4.1 观察矩阵
/**
* 生成观察矩阵,把结果输出到第一个参数中
* @param rm : 输出结果
* @param rmOffset: 输出结果偏移量
* @param eyeX : 观察点的x值
* @param eyeY : 观察点的y值
* @param eyeZ : 观察点的z值
* @param centerX : 观察点看向的目标点x值
* @param centerY : 观察点看向的目标点x值
* @param centerZ : 观察点看向的目标点x值
* @param upX : 观察点朝上的方向x分量
* (即OpenGL相机怎么放置,即使观察点和目标点不动,相机摆放方式不同,看到的内容也不同,所以需要设置up向量)
* @param upY : 观察点的方向y分量
* @param upZ : 观察点的方向z分量
**/
public static void setLookAtM(float[] rm, int rmOffset,
float eyeX, float eyeY, float eyeZ,
float centerX, float centerY, float centerZ, float upX, float upY,
float upZ) { }
The End
欢迎关注我,一起解锁更多技能:BC的掘金主页~💐 BC的CSDN主页~💐💐
Android OpenGL开发者文档:https://developer.android.com/guide/topics/graphics/opengl
opengl学习资料:https://learnopengl-cn.github.io/
Android OpenGL基础(一、绘制三角形四边形):https://juejin.cn/post/7076751737461145630
Android OpenGL基础(二、坐标系统):https://juejin.cn/post/7077132016759603208/
Android OpenGL基础(三、绘制Bitmap纹理):https://juejin.cn/post/7079678062849163277/
Android OpenGL基础专栏:https://juejin.cn/column/7076751675595653150