0%

[原创] Android OpenGL ES 2.0 使用简介

说明

本文讲解的是 Android 本身的 OpenGL ES 的用法,而不是采用 Native C 方式。

若想了解采用 Native C 方式使用 OpenGL 的同学,请自觉绕行

本文使用的是 OpenGL ES 2.0

PS:OpenGL ES,即 OpenGL 的一个子集,裁剪了一些功能,专门使用在嵌入式设备中。

源码下载

本文涉及到的所有代码,可以从我的 Github上下载。该项目中包含了更多的 OpenGL ES 示例。

前提

  • 需要读者了解最基本的 OpenGL 相关术语及基本概念。

样板代码

我们从一个最简单的示例入手,在页面上画一个点。

以下是使用 OpenGL ES 的所有样板代码,代码里已经包含了详细的注释。你会发现,除去注释的话,实际的代码量可能只有 200 行左右。

代码清单:

  • L1_1_BasicSkeletonRenderer.kt
  • AbsBaseOpenGLES.kt
  • OpenGLESExt.kt
  • GLConstants.kt
  • BufferExt.kt

L1_1_BasicSkeletonRenderer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
package com.leovp.leoandroidbaseutil.basic_components.examples.opengl.renderers

import android.content.Context
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import com.leovp.opengl_sdk.AbsBaseOpenGLES
import com.leovp.opengl_sdk.util.GLConstants.TWO_DIMENSIONS_POSITION_COMPONENT_COUNT
import com.leovp.opengl_sdk.util.compileShader
import com.leovp.opengl_sdk.util.createFloatBuffer
import com.leovp.opengl_sdk.util.linkProgram
import com.leovp.opengl_sdk.util.validateProgram
import java.nio.FloatBuffer
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10

/**
* 基础框架
*/
class L1_1_BasicSkeletonRenderer(@Suppress("unused") private val ctx: Context) : AbsBaseOpenGLES(), GLSurfaceView.Renderer {
override fun getTagName(): String = L1_1_BasicSkeletonRenderer::class.java.simpleName

private companion object {
// 关键字 概念:
// 1. uniform 由外部程序传递给 shader,就像是 C 语言里面的常量,shader 只能用,不能改;
// 2. attribute 是只能在 vertex shader 中使用的变量;
// 3. varying 变量是 vertex shader 和 fragment shader 之间做数据传递用的。
// 更多说明:http://blog.csdn.net/jackers679/article/details/6848085
/** 顶点着色器:之后定义的每个都会传 1 次给顶点着色器 */
private const val VERTEX_SHADER = """
// vec4:4 个分量的向量:x、y、z、w。从外部传递进来的每个顶点的颜色值
attribute vec4 a_Position;
void main()
{
// gl_Position:GL中默认定义的输出变量,决定了当前顶点的最终位置
gl_Position = a_Position;
// gl_PointSize:GL中默认定义的输出变量,决定了当前顶点的大小
gl_PointSize = 40.0;
}
"""

/**
* 片段着色器
* uniform:可用于顶点和片段着色器,一般用于对于物体中所有顶点或者所有的片段都相同的量。比如光源位置、统一变换矩阵、颜色等。
*/
private const val FRAGMENT_SHADER = """
// 定义所有浮点数据类型的默认精度;有 lowp、mediump、highp 三种,但只有部分硬件支持片段着色器使用 highp。(顶点着色器默认 highp)
precision mediump float;
// vec4:4 个分量的向量:x、y、z、w
uniform vec4 u_Color;
void main()
{
// gl_FragColor:GL 中默认定义的输出变量,决定了当前片段的最终颜色
gl_FragColor = u_Color;
}
"""

/**
* 顶点数据数组
* 点的 x,y 坐标(x,y 各占 1 个分量,也就是说每个点占用 2 个分量)。
* 该数组表示 1 个顶点数据,也就是 1 个点的坐标。
*/
private val POINT_DATA = floatArrayOf(0f, 0f)
}

/**
* 顶点坐标数据缓冲区
*
* 分配一个块 Native 内存,用于与 GL 通讯传递。(我们通常用的数据存在于 Dalvik 的内存中,1.无法访问硬件;2.会被垃圾回收)
*/
private val vertexData: FloatBuffer = createFloatBuffer(POINT_DATA)

/**
* 颜色 uniform 在 OpenGL 程序中的索引
*/
private var uColorLocation: Int = 0

override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {
// 设置刷新屏幕时候使用的颜色值,顺序是 RGBA,值的范围从 0~1。GLES20.glClear 调用时使用该颜色值。
GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f)
// 步骤1:编译顶点着色器
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, VERTEX_SHADER)
// 步骤2:编译片段着色器
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER)

// 步骤3:将顶点着色器、片段着色器进行链接,组装成一个 OpenGL 程序
programObjId = linkProgram(vertexShader, fragmentShader)

validateProgram(programObjId)

// 步骤4:通知 OpenGL 开始使用该程序
GLES20.glUseProgram(programObjId)

// 步骤5:获取颜色 Uniform 在 OpenGL 程序中的索引
uColorLocation = getUniform("u_Color")

// 步骤6:获取顶点坐标属性在 OpenGL 程序中的索引
// 顶点坐标在 OpenGL 程序中的索引
val aPositionLocation = getAttrib("a_Position")

// 将缓冲区的指针移动到头部,保证数据是从最开始处读取
// In [createFloatBuffers] method, it makes sure to achieve this.
// vertexData.position(0)

// 步骤7:关联顶点坐标属性和缓存数据
// 1. 位置索引;
// 2. 每个顶点属性需要关联的分量个数(必须为1、2、3或者4。初始值为4。);
// 3. 数据类型;
// 4. 指定当被访问时,固定点数据值是否应该被归一化(GL_TRUE)或者直接转换为固定点值(GL_FALSE)(只有使用整数数据时)
// 5. 指定连续顶点属性之间的偏移量。如果为0,那么顶点属性会被理解为:它们是紧密排列在一起的。初始值为0。
// 6. 数据缓冲区
GLES20.glVertexAttribPointer(aPositionLocation, TWO_DIMENSIONS_POSITION_COMPONENT_COUNT, GLES20.GL_FLOAT, false, 0, vertexData)

// 步骤8:通知 GL 程序使用指定的顶点属性索引
GLES20.glEnableVertexAttribArray(aPositionLocation)
}

override fun onSurfaceChanged(gl: GL10, width: Int, height: Int) {
// Set the OpenGL viewport to fill the entire surface.
GLES20.glViewport(0, 0, width, height)
}

override fun onDrawFrame(unused: GL10) {
// 步骤1:使用 glClearColor 设置的颜色,刷新 Surface
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

// 步骤2:更新 u_Color 的值,即更新画笔颜色
GLES20.glUniform4f(uColorLocation, 1.0f, 0.0f, 1.0f, 1.0f)

// 步骤3:使用数组绘制图形:1.绘制的图形类型;2.从顶点数组读取的起点;3.从顶点数组读取的顶点个数
GLES20.glDrawArrays(GLES20.GL_POINTS, 0, POINT_DATA.size / TWO_DIMENSIONS_POSITION_COMPONENT_COUNT)
}
}

AbsBaseOpenGLES.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package com.leovp.opengl_sdk

import android.opengl.GLES20
import com.leovp.log_sdk.LogContext
import com.leovp.log_sdk.base.ILog
import com.leovp.opengl_sdk.util.checkGlError
import com.leovp.opengl_sdk.util.compileShader
import com.leovp.opengl_sdk.util.linkProgram
import com.leovp.opengl_sdk.util.validateProgram

/**
* Author: Michael Leo
* Date: 2022/4/12 13:33
*/
abstract class AbsBaseOpenGLES {
abstract fun getTagName(): String
val tag: String by lazy { getTagName() }

@Suppress("WeakerAccess")
protected var programObjId: Int = 0

@Suppress("WeakerAccess")
protected var outputWidth: Int = 0

@Suppress("WeakerAccess")
protected var outputHeight: Int = 0

/**
* The step of make program.
*
* 步骤1: 编译顶点着色器
* 步骤2: 编译片段着色器
* 步骤3: 将顶点着色器、片段着色器进行链接,组装成一个 OpenGL ES 程序
* 步骤4: 通知 OpenGL ES 开始使用该程序
*
* @return OpenGL ES Program ID
*/
@Suppress("unused")
fun makeProgram(vertexShaderCode: String, fragmentShaderCode: String) {
val vertexShaderId = compileShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode)
val fragmentShaderId = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)
makeProgram(vertexShaderId, fragmentShaderId)
}

/**
* The step of make program.
*
* 步骤1: 编译顶点着色器
* 步骤2: 编译片段着色器
* 步骤3: 将顶点着色器、片段着色器进行链接,组装成一个 OpenGL ES 程序
* 步骤4: 通知 OpenGL ES 开始使用该程序
*
* @return OpenGL ES Program ID
*/
fun makeProgram(vertexShaderId: Int, fragmentShaderId: Int) {
programObjId = linkProgram(vertexShaderId, fragmentShaderId)
LogContext.log.i(tag, "makeProgram() programObjId=$programObjId", outputType = ILog.OUTPUT_TYPE_SYSTEM)
if (!validateProgram(programObjId)) throw RuntimeException("OpenGL ES: Make program exception.")

GLES20.glUseProgram(programObjId)
checkGlError("glUseProgram")
}

protected fun getUniform(name: String): Int {
if (programObjId < 1) throw IllegalArgumentException("Program ID=$programObjId is not valid. Make sure to call makeProgram() first.")
return GLES20.glGetUniformLocation(programObjId, name)
}

protected fun getAttrib(name: String): Int {
if (programObjId < 1) throw IllegalArgumentException("Program ID=$programObjId is not valid. Make sure to call makeProgram() first.")
return GLES20.glGetAttribLocation(programObjId, name)
}
}

GLConstants.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.leovp.opengl_sdk.util

/**
* Author: Michael Leo
* Date: 2022/4/7 16:27
*/
object GLConstants {
// I420, YV12
const val THREE_PLANAR = 3

// NV12, NV21
const val TWO_PLANAR = 2

/**
* 坐标占用的向量个数
* 每个顶点属性需要关联的分量个数(必须为1、2、3或者4。初始值为4。)
* 例如,若只有 x、y,则该值为 2
*/
const val TWO_DIMENSIONS_POSITION_COMPONENT_COUNT = 2

/**
* 纹理坐标中每个点所占的向量个数
*/
const val TWO_DIMENSIONS_TEX_VERTEX_COMPONENT_COUNT = 2

/**
* RGB 颜色占用的向量个数
*/
const val RGB_COLOR_COMPONENT_COUNT = 3

/**
* 数据数组中每个顶点起始数据的间距:数组中每个顶点相关属性占的 Byte 值
*/
const val TWO_DIMENSIONS_STRIDE_IN_FLOAT = TWO_DIMENSIONS_POSITION_COMPONENT_COUNT * Float.SIZE_BYTES
}

OpenGLESExt.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
package com.leovp.opengl_sdk.util

import android.opengl.GLES20
import com.leovp.log_sdk.LogContext
import com.leovp.log_sdk.base.ILog
import java.nio.ByteBuffer
import java.nio.IntBuffer

// https://download.csdn.net/download/lkl22/11065372?spm=1001.2101.3001.6650.3&utm_medium=distribute.pc_relevant.none-task-download-2%7Edefault%7EBlogCommendFromBaidu%7ERate-3.pc_relevant_paycolumn_v3&depth_1-utm_source=distribute.pc_relevant.none-task-download-2%7Edefault%7EBlogCommendFromBaidu%7ERate-3.pc_relevant_paycolumn_v3&utm_relevant_index=6
// https://blog.csdn.net/sinat_23092639/article/details/103046553
// https://blog.csdn.net/mengks1987/article/details/104186060

private const val TAG = "OpenGL"

/**
* 编译着色器程序
* @param type GLES20.GL_VERTEX_SHADER(0X8B31=35633) -> vertex shader
* GLES20.GL_FRAGMENT_SHADER(0X8B30=35632) -> fragment shader
* @param shaderCode 着色器程序代码
*
* https://www.jianshu.com/p/a772bfc2276b
*/
fun compileShader(type: Int, shaderCode: String): Int {
// Create a vertex shader type (GLES20.GL_VERTEX_SHADER)
// or a fragment shader type (GLES20.GL_FRAGMENT_SHADER)

// 1. 创建一个新的着色器对象
val shaderId = GLES20.glCreateShader(type)

// 2. 获取创建状态
// 在 OpenGL 中,都是通过整型值去作为 OpenGL 对象的引用。之后进行操作的时候都是将这个整型值传回给 OpenGL 进行操作。
// 返回值 0 代表着创建对象失败。
if (shaderId == GLES20.GL_FALSE) { // Failed
LogContext.log.e(TAG, "Could not create new shader[$type].", outputType = ILog.OUTPUT_TYPE_SYSTEM)
return GLES20.GL_FALSE
}

// Add the source code to the shader and compile it
// 3. 将着色器代码上传到着色器对象中
GLES20.glShaderSource(shaderId, shaderCode)

// 4. 编译着色器对象
GLES20.glCompileShader(shaderId)

// LogContext.log.i(TAG, "Results of compiling.\n${GLES20.glGetShaderInfoLog(shaderId)}", outputType = ILog.OUTPUT_TYPE_SYSTEM)

// 5. 获取编译状态:OpenGL 将想要获取的值放入长度为1的数组的首位
val compileStatus = IntArray(1)
GLES20.glGetShaderiv(shaderId, GLES20.GL_COMPILE_STATUS, compileStatus, 0)

// 6.验证编译状态
if (compileStatus[0] != GLES20.GL_TRUE) { // Failed
LogContext.log.e(TAG, "Compilation of shader[$type] failed.", outputType = ILog.OUTPUT_TYPE_SYSTEM)
// 如果编译失败,则删除创建的着色器对象
GLES20.glDeleteShader(shaderId)

// 7.返回着色器对象:失败,为0
return GLES20.GL_FALSE
}

// 7. 返回着色器对象:成功,非0
return shaderId
}

/**
* @return OpenGL ES Program ID
*/
fun linkProgram(vertexShaderId: Int, fragmentShaderId: Int): Int {
// 1. 创建一个 OpenGL ES 程序对象
// Create empty OpenGL ES Program
val programObjId = GLES20.glCreateProgram()
LogContext.log.i(TAG, "linkProgram() programObjId=$programObjId", outputType = ILog.OUTPUT_TYPE_SYSTEM)

// 2. 检查创建状态
checkGlError("glCreateProgram")
// 返回值 0 代表着创建对象失败。
if (programObjId == GLES20.GL_FALSE) { // Failed
LogContext.log.e(TAG, "Could not create new program.", outputType = ILog.OUTPUT_TYPE_SYSTEM)
return GLES20.GL_FALSE
}

// 3. 将顶点着色器依附到 OpenGL ES Program 对象
// Add the vertex shader to program
GLES20.glAttachShader(programObjId, vertexShaderId)
// 3. 将片段着色器依附到 OpenGL ES Program 对象
// Add the fragment shader to program
GLES20.glAttachShader(programObjId, fragmentShaderId)

// Creates OpenGL ES program executables
// 4. 将两个着色器链接到 OpenGL ES Program 对象
GLES20.glLinkProgram(programObjId)

// 5. 获取链接状态:OpenGL ES 将想要获取的值放入长度为1的数组的首位
val linkStatus = IntArray(1)
GLES20.glGetProgramiv(programObjId, GLES20.GL_LINK_STATUS, linkStatus, 0)

// 6. 验证链接状态
if (linkStatus[0] != GLES20.GL_TRUE) {
LogContext.log.e(TAG, "Could not link program: ${GLES20.glGetProgramInfoLog(programObjId)} linkStatus=${linkStatus[0]}", outputType = ILog.OUTPUT_TYPE_SYSTEM)
// 链接失败则删除程序对象
GLES20.glDeleteProgram(programObjId)

// 7. 返回程序对象:失败,为0
return GLES20.GL_FALSE
}

// 7. 返回程序对象:成功,非0
return programObjId
}

/**
* 检查 GL 操作是否有 error
* @param op 当前检查前所做的操作
*/
fun checkGlError(op: String): Int {
var error: Int = GLES20.glGetError()
while (error != GLES20.GL_NO_ERROR) {
LogContext.log.e(TAG, "checkGlError. $op: glError $error", outputType = ILog.OUTPUT_TYPE_SYSTEM)
error = GLES20.glGetError()
}
return error
}

BufferExt.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.leovp.opengl_sdk.util

import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import java.nio.ShortBuffer

/**
* Author: Michael Leo
* Date: 2022/4/2 18:08
*/

/**
* 创建一个 FloatBuffer 缓冲区,用于保存顶点/屏幕顶点和纹理顶点
*
* OpenGL的世界坐标系是 [-1, -1, 1, 1],纹理的坐标系为 [0, 0, 1, 1]
*/
fun createFloatBuffer(array: FloatArray): FloatBuffer {
return ByteBuffer.allocateDirect(array.size * Float.SIZE_BYTES)
// Use the device hardware's native byte order
.order(ByteOrder.nativeOrder())

// Create a floating point buffer from the ByteBuffer
.asFloatBuffer().apply {
// Add the coordinates to the FloatBuffer
put(array)
// Set the buffer to read the first coordinate
// position(0)
rewind()
}
}

fun createShortBuffer(array: ShortArray): ShortBuffer {
return ByteBuffer.allocateDirect(array.size * Short.SIZE_BYTES)
// Use the device hardware's native byte order
.order(ByteOrder.nativeOrder())

// Create a short point buffer from the ByteBuffer
.asShortBuffer().apply {
// Add the coordinates to the FloatBuffer
put(array)
// Set the buffer to read the first coordinate
// position(0)
rewind()
}
}

❗️这里有一个值得思考的问题。大家查阅网上的资料时,关于顶点坐标纹理坐标,经常会看到类似下面的代码(网上的代码 Java 居多,因此这里就以 Java 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 顶点坐标数组
static final float VERTICES_COORD[] = {
-1.0f, -1.0f,
1.0f, -1.0f,
-1.0f, 1.0f,
1.0f, 1.0f,
};

// 纹理坐标数组
static final float TEX_COORD[] = {
0.0f, 1.0f,
1.0f, 1.0f,
0.0f, 0.0f,
1.0f, 0.0f,
};

可能还看到过下面的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 顶点坐标数组
static final float VERTICES_COORD[] = {
-1.0f, -1.0f,
-1.0f, 1.0f,
1.0f, 1.0f,
1.0f, -1.0f
};

// 纹理坐标数组
static final float TEX_COORD[] = {
0.0f, 1.0f,
0.0f, 0.0f,
1.0f, 0.0f,
1.0f, 1.0f
};

当然还有其它各种各样的写法。但是,基本上没有人解释为什么要这么写。关于这个问题,我也是调查了很久,因此我觉得有必要详细说明下顶点坐标纹理坐标

OpenGL 坐标系

按照惯例,OpenGL 是一个右手坐标系(Right-handed System)。简单来说,就是正 x 轴在你的右手边,正 y 轴朝上,而正 z 轴是朝向后方的。

想象你的屏幕处于三个轴的中心,则正 z 轴穿过你的屏幕朝向你。坐标系画起来如下:

Left/Right-handed System OpenGL Coordinate Systems Right Handed

在标准化设备坐标系中,不管什么屏幕尺寸,三个轴的取值范围始终为 [-1, 1]。

ℹ️ 扩展说明

在标准化设备坐标(Normalized device coordinates,简称 NDC)中,OpenGL 实际上使用的是左手坐标系(投影矩阵交换了左右手)。

PS:关于 OpenGL 到底是左手坐标系还是右手坐标系这个问题,在 Stack Overflow 上也有人提出过这个问题,并且也有对应的回答。此外,这篇(中文)这篇(英文)这篇,文章也进行讲解。感兴趣的可以了解下。

重要的坐标系统

对我们来说,比较重要的坐标系统有 5 个:

  • 局部空间(Local Space),也可称为物体空间(Object Space)

  • 世界空间(World Space)

  • 观察空间(View Space,也可称为视觉空间(Eye Space))

  • 裁剪空间(Clip Space)

  • 屏幕空间(Screen Space),也可称为窗口空间(Window space)

这就是一个顶点在最终被转化为片段之前需要经历的所有不同状态。

坐标系变换

为了将坐标从一个坐标系变换到另一个坐标系,我们需要用到几个变换矩阵,最重要的几个分别是模型 (Model)、观察 (View)、投影 (Projection)三个矩阵。我们的顶点坐标起始于局部空间 (Local Space),在这里它称为局部坐标 (Local Coordinate),它在之后会变为世界坐标(World Coordinate),观察坐标 (View Coordinate),裁剪坐标 (Clip Coordinate),并最后以**屏幕坐标 ** (Screen Coordinate) 的形式结束。下面的这张图展示了整个流程以及各个变换过程做了什么:

Coordinate Systems

另外再总结下之前那些文章里的内容:

  • 对于局部空间(Local Space)也可称为物体空间(Object Space),OpenGL 采用的是右手坐标系
  • 对于屏幕空间(Screen Space)也可称为窗口空间(Window space),OpenGL 采用的是左手坐标系

原理什么的,咱也不知道,也不是十分关心。所以上述内容大家了解一下就好。感兴趣的可以仔细看看之前我提到的文章。

我们实际使用中,按右手坐标系来理解就可以了。

顶点坐标系

在使用 OpenGL 时,顶点坐标我们一定会使用到,在不考虑 z 轴的情况下,其坐标系范围如下图:

Vertices Coordinates

原点位于中央 (0, 0) 位置。

注意:可见的所有顶点都必须为标准化设备坐标(Normalized device coordinates,简称 NDC)。也就是说,每个顶点的 xyz 坐标都应该在 -1.01.0 之间,超出这个坐标范围的顶点都将不可见。

注意:无论屏幕尺寸是什么,每个顶点坐标的范围终于是 [-1,1] 。这就会导致相同的图形,在不同的设备上看起来可能会变形,如下图:

coordinates

当然这个问题也很好解决,通过正交投影的方式就可以很简单的解决这个问题。具体的可以查看我的 Github 源码中的示例。

纹理坐标系

关于纹理坐标系的定义,我并没有找到比较权威说法,网上说的纹理坐标的顶点位置就有多种不同的说法。不过感觉这篇(中文)英文原版文章讲的非常好。大家可以仔细阅读下。

首先,纹理坐标的取值范围没有任何争议,其范围在 0.01.0 之间。

比较有争议的是原点的位置。网上很多资料都说,原点位置位于左下角,像下图这样:

Texture Coordinates Bottom Left

但是经过实际验证发现,原点位置应该位于左上角,像下图这样:

Texture Coordinates Top Left

通过下面的示例代码可以看出,纹理坐标的原点是在左上角。我也不知道为什么是在左上角,而不是左下角。要是有大神明白的,欢迎留言区留言。

Texture Coordinates Top Left-Code

顶点坐标与纹理坐标的关系

刚才我们已经对顶点坐标纹理坐标做了一些说明,现在就要开始解答之前说的,顶点坐标数组与纹理坐标数组中各数据关系是怎么对应的?

顶点坐标数组

以上面的代码为例,先来说一下顶点坐标数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 顶点坐标与纹理坐标对应即可。
* 通过修改顶点坐标,即可控制图像的大小。
*
* 顺序: ABCD
* ```
* D(-1,1) A(1,1)
* ┌────────┐
* │ ↑ │
* │ ───┼──→│ center (0,0)
* │ │ │
* └────────┘
* C(-1,-1) B(1,-1)
* ```
*/
private val POINT_DATA = floatArrayOf(
0.5f, 0.5f, // Point A: top right
0.5f, -0.5f, // Point B: bottom right
-0.5f, -0.5f, // Point C: bottom left
-0.5f, 0.5f // Point D: top left
)

由于我们要在屏幕上显示一个矩形图像,因此就需要 4 个矩形的顶点坐标,分别代表图像的四个顶点。这四个顶点就表明了图像所要画的具体位置。而且,四个顶点的位置也同样表明了所要绘制的图像的大小。例如,上面的示例表明了在屏幕中央位置,以 50% 的尺寸大小显示图像。

顶点数组中的数据,每 2 个是一组,代表一个顶点的 x 值和 y 值。一共 8 个数据,正好代表了 4 个顶点。例如,示例中 4 个顶点的顺序分别是:右上(A)右下(B)左下(C)左上(D)。当然,顶点顺序的定义其实是没有固定要求的,只要其顺序和纹理坐标的顺序一致即可。

纹理坐标数组

接下来我们再来看看纹理坐标数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 纹理坐标
* 顶点坐标与纹理坐标对应即可。
* 顺序: ABCD
*
* ```
* D(0,0)────s──→A(1,0)
* │ ┌───────┐
* t │texture│
* │ │ │
* ↓ └───────┘
* C(0,1) B(1,1)
* ```
*/
private val TEX_VERTEX = floatArrayOf(
1f, 0f,
1f, 1f,
0f, 1f,
0f, 0f
)

纹理坐标数组也是每 2 个一组,代表纹理一个顶点的 x 值和 y 值。一共 8 个数据,正好代表了纹理的 4 个顶点。之前也说过了,实测结果表明纹理坐标系的顶点在左上角,那么上述示例中 4 个纹理顶点的顺序分别是:**右上(A)右下(B)左下(C)左上(D)**。

再次提醒,纹理坐标的顺序要和顶点坐标的顺序一致。

至此,我们终于搞明白了顶点坐标数组纹理坐标数组中各个数值的含义及对应关系了。

参考文献

坚持原创及高品质技术分享,您的支持将鼓励我继续创作!