OpenGL 入门(一)—— OpenGL 基础
1、OpenGL 基础知识
1.1 OpenGL 简介
OpenGL(Open Graphics Library)是图形领域的工业标准,是一套跨编程语言、跨平台、专业的图形编程(软件)接口。它用于二维、三维图像,是一个功能强大,调用方便的底层图形库。它与硬件无关,可以在不同的平台如 Windows、Linux、Mac、Android、IOS 之间进行移植。因此,支持 OpenGL 的软件具有很好的移植性,可以获得非常广泛的应用(比如 PS 在部分功能和操作中使用 OpenGL 加速,以提高图像处理和渲染的性能)。
对于 Android 系统而言,它支持的是 OpenGL ES。OpenGL ES 是针对手机、PDA 和游戏主机等嵌入式设备设计的 OpenGL API 的子集。下面是 Android 系统版本与 OpenGL ES 的支持版本的一般对照表(具体的设备和制造商可能会在特定型号上进行适配和调整):
- Android 4.0(Ice Cream Sandwich):OpenGL ES 2.0
- Android 4.1 - 4.3(Jelly Bean):OpenGL ES 2.0/3.0
- Android 4.4(KitKat):OpenGL ES 2.0/3.0/3.1
- Android 5.0 - 5.1(Lollipop):OpenGL ES 3.1/3.2
- Android 6.0(Marshmallow):OpenGL ES 3.1/3.2
- Android 7.0 - 12.0:OpenGL ES 3.2
OpenGL 官方上提供了很多资料,作为 Android 开发者需要关注 OpenSL ES,可以在OpenGL 官网页面找到两个 OpenSL ES 的资料:
第一个是 OpenGL ES 的官方文档:
可以在右侧选择版本:
第二个是 OpenGL ES 的 API 快速查找手册:
1.2 OpenGL 绘制原理
OpenGL 是可以绘制 3D 图像的,比如绘制一头牛的过程如下:
- Vertex Processing:首先将牛抽象为 3D 网格,在网格中牛由无数个像素点组成,将这些像素点视为顶点,每 3 个顶点可以构成一个三角形,这个三角形可以视为一个
纹理
,多个纹理可以组成一个面,最终形成一头牛。其中,将 3D 网格转化为顶点的过程是由顶点着色器
完成的 - Rasterization:
光栅化
,将几何图形转换为在屏幕上显示的像素网格得到片元 - Raster Operations:进行纹理过滤,使用
片元着色器
对片元上色 - Fragment Processing:经过片元处理得到最终要输出的图像
以上过程涉及到一些 OpenGL 的专业名词,解释如下:
- 纹理(Texture):是将图像或图案应用到几何图形上的一种技术。
- 纹理可以理解为在三维空间中的一个图像或图案,它可以覆盖在三维对象的表面上,给出了表面的颜色和细节。实际上是通过将二维的纹理映射到三维的几何图形的表面上,从而实现对几何图形进行贴图,使其呈现出真实的外观和细节。
- 纹理通常是由像素组成的二维图像,可以是从图像文件加载的位图,或者是通过代码生成的程序纹理。在 OpenGL 中,纹理坐标(Texture Coordinates)被用来映射纹理到三维几何图形上的对应位置。
- 纹理可以看作是一个二维数组,由一系列像素组成。每个像素包含了图像的颜色信息或其他数据。纹理可以包含不同的图像格式,例如 RGB、RGBA、灰度图等。
- 顶点着色器(Vertex Shader):顶点着色器是一种在图形渲染管线中执行的程序,用于处理输入的顶点数据。它以顶点为基本单位,对每个输入顶点进行处理和变换,例如对顶点位置、颜色、法线等进行变换、计算和插值。顶点着色器通常用于执行顶点变换、几何操作和顶点属性的计算。
- 光栅化(Rasterization):是计算机图形学中的一个重要概念,用于将几何图形转换为在屏幕上显示的像素图像。光栅化的过程涉及将连续的几何图形转换为离散的像素表示。在三维图形渲染中,几何图形通常是由三角形组成的三角网格(Triangle Mesh)。光栅化将每个三角形映射到屏幕上的像素,并确定每个像素的颜色和深度值。其主要步骤包括:
- 顶点处理(Vertex Processing):在光栅化之前,对每个顶点进行变换、投影和其他处理,以将顶点从三维空间转换到屏幕空间。
- 三角形设置(Triangle Setup):确定每个三角形的边界框(bounding box)或边界区域,以限定光栅化的范围。
- 逐像素处理(Pixel Processing):对于每个位于三角形边界框内的像素,确定其是否位于三角形内部,并计算其颜色、纹理坐标、深度值等属性。
- 插值计算(Interpolation):根据顶点属性的值,通过插值计算来获取每个像素的准确属性值,如颜色插值、纹理坐标插值等。
- 像素输出(Pixel Output):根据计算得到的像素属性值,将其写入帧缓冲区(Frame Buffer),最终形成屏幕上的图像
- 片元着色器(Fragment Shader):片元着色器是一种在图形渲染管线中执行的程序,用于处理光栅化后的片元(像素)数据。它以片元为基本单位,对每个片元进行处理和计算,例如计算片元的颜色、法线、光照等。片元着色器通常用于执行光照模型、纹理采样、混合和其他片元级别的计算。
顶点着色器和片元着色器需要程序员自行实现。
1.3 Android 绘制原理
Android 在绘制时实际上就用到了 OpenGL。比如绘制一个 Button:
大致步骤如下:
- LayoutInflater 解析布局文件,将 Button 从 xml 的标签解析成包含边界和宽高等信息的 Button 对象
- CPU 对 Button 对象内的边界、宽高、颜色等进行计算,处理成纹理(多维的向量图形)交给 GPU
- GPU 负责填充,将向量图形栅格化(转换成像素位图图像)再画到屏幕上
实际上 OpenGL 就是工作在 GPU 当中的,下图从 CPU、GPU 和屏幕的维度描绘了绘制过程:
既然提到 CPU 与 GPU,就要简单看一下二者的区别与联系:
1.4 OpenGL 坐标系
OpenGL 编程会常用到如下三种坐标系:
|
|
|
- OpenGL 世界坐标系的原点 (0,0) 在中心,边界最大坐标为 1
- Android 屏幕坐标系的原点 (0,0) 在左上角,边界也是 1
- OpenGL 纹理坐标系的原点 (0,0) 在左下角,边界还是 1
OpenGL 世界坐标系与 Android 屏幕坐标系的顶点是有对应关系的,如图所示:
2、OpenGL SL 语法基础
OpenGL 编程使用的着色器语言。
着色器语言(Shader Language)是一种特定于图形编程的编程语言,用于编写顶点着色器、片元着色器和其他类型的着色器程序。在 OpenGL 中,常用的着色器语言是 GLSL(OpenGL Shading Language),它是一种高级着色器语言,用于描述和执行图形渲染管线中的着色器程序。GLSL 提供了丰富的语法和内置函数,使得开发者可以编写灵活和高效的着色器代码。
以下是后续 Demo 中常用的语法内容:
数据类型 | 描述 |
---|---|
float | 浮点型 |
vec2 | 含两个浮点型数据的向量 |
vec4 | 含四个浮点型数据的向量(xyzw、rgba、stpq) |
sampler2D | 2D纹理采样器(代表一层纹理,用uniform来修饰) |
修饰符 | 描述 |
---|---|
attribute | 属性变量。只能用于顶点着色器中。一般用该变量来表示一些顶点数据,如:顶点坐标、纹理坐标、颜色等。 |
uniform | 一致变量。在着色器执行期间一致变量的值是不变的。与 const 常量不同的是,这个值在编译时期是未知的是由着色器外部初始化的。 |
varying | 易变变量。是从顶点着色器传递到片元着色器的数据变量。 |
内置函数 | 描述 |
---|---|
texture2D | (采样器, 坐标) 采样指定位置的纹理 |
OpenGL 内置函数更多内容可以参考:https://shaderific.com/glsl/common_functions.html
这里顺便了解一下 OpenGL 函数的命名格式:
<函数库前缀> <根命令> <可选的参数数量> <可选的参数类型>
使用参数数量和参数类型是因为面向过程的 C 语言中没有函数重载。
内建变量 | 描述 |
---|---|
gl_Position | vec4类型,表示顶点着色器中顶点位置 |
gl_FragColor | vec4类型,表示片元着色器中颜色 |
精度名称 | 描述 |
---|---|
precision lowp | 低精度 |
precision mediump | 中精度 |
precision highp | 高精度 |
举个示例,比如要声明一个 4 * 4 的矩阵:
mat4 m1 = mat4(
1.0, 2.0, 3.0, 4.0,
1.0, 2.0, 3.0, 4.0,
1.0, 2.0, 3.0, 4.0,
1.0, 2.0, 3.0, 4.0
)
mat4 m2 = mat4(2.0)
mat4 m3 = mat4(
2.0, 0.0, 0.0, 0.0,
0.0, 2.0, 0.0, 0.0,
0.0, 0.0, 2.0, 0.0,
0.0, 0.0, 0.0, 2.0
)
其中 m2 初始化的结果与 m3 相同,即初始化左上角到右下角对角线上位置上的数据,其余位置均为 0。
3、OpenGL SL 简单使用
这一节我们使用 OpenGL SL 在 Android 屏幕上画一个等腰直角三角形。
3.1 OpenGL SL 简介
首先我们要清楚,OpenGL SL 本质上是一个 NDK 的动态库,在 NDK 的 toolchains 中可以找到它们:
但是 Google 将 NDK 接口封装为 Java API 方便上层调用:
图形渲染管线
OpenGL 图形渲染管线(计算机图形学中用于将三维模型转换为最终渲染图像的流程和算法)的简化流程图,它展示了顶点着色器和片元着色器之间的数据流:
OpenGL 绘制时需要自行实现顶点着色器和片元着色器:
- 顶点着色器的输入数据包括顶点属性(如位置、法线等)以及可选的采样器。输出数据是变换后的顶点位置(gl_Position)、正面朝向标志(gl_FrontFacing)以及其他临时变量
- 首先,顶点着色器接收来自应用程序的顶点数据,这些数据通常包含顶点的位置、法线、纹理坐标等信息。顶点着色器的主要任务是对这些顶点进行变换,例如应用模型矩阵、视图矩阵和投影矩阵,以将顶点从对象空间转换到屏幕空间
- 然后,顶点着色器会生成一个 gl_Position 变量,它是经过变换后的顶点位置,表示该顶点在屏幕上的位置。此外,顶点着色器还可以生成其他临时变量,用于后续的计算或传递给片元着色器
- 接下来,顶点着色器会将变换后的顶点数据传递给片元着色器。在这个过程中,顶点着色器还会生成一些 Varying 变量,这些变量可以用来存储顶点之间的一些信息,比如纹理坐标或者光照参数
- 片元着色器输入数据包括从顶点着色器传递过来的 Varying 变量(如纹理坐标),以及一些内置变量(如点大小 gl_PointSize 和片段坐标 gl_FragCoord)。输出数据是一个颜色值(gl_FragColor),它将被用于最终的图像渲染
- 片元着色器接收到顶点着色器传递来的 Varying 变量,以及一些内置变量,如 gl_FragCoord 和 gl_PointSize 等。片元着色器的主要任务是对每个像素的颜色进行计算,这可能涉及到纹理采样、光照计算、混合操作等
- 最后,片元着色器会生成一个 gl_FragColor 变量,这是最终的颜色值,它会被用于绘制像素。如果需要,片元着色器也可以生成其他临时变量,用于后续的计算或传递给其他阶段
可编程管线
再来看 OpenGL 2.0 增加的可编程管线,它显示了图形渲染管线的基本工作流程:
各部分职能:
- 顶点着色器:是图形渲染管线的第一步,它主要负责处理顶点数据,包括顶点位置、法线、纹理坐标等。顶点着色器可以根据需要修改顶点数据,例如应用变换矩阵、添加顶点动画效果等
- 图元装配:将顶点数据组装成图元的过程,常见的图元有三角形、矩形等。图元装配的结果是一组有序的顶点列表,这些顶点将被发送到光栅化阶段
- 光栅化:将图元转换为像素的过程,它根据图元的几何形状和屏幕分辨率,确定哪些像素应该被填充。光栅化阶段还负责执行裁剪、深度测试、模板测试等操作
- 片元着色器:是图形渲染管线中的一个重要步骤,它负责处理每个像素的颜色信息。片元着色器可以从顶点着色器获取数据,例如顶点位置、法线、纹理坐标等,然后根据这些数据计算出每个像素的颜色
- 逐片元处理:在片元着色器之后的一个阶段,它负责执行各种后期处理效果,例如模糊、抗锯齿、景深等。这些效果可以在片元着色器之后应用,从而实现更复杂的视觉效果
- 帧缓冲区:是图形渲染管线的最后一个阶段,它负责将渲染结果保存到屏幕上。帧缓冲区可以是硬件设备的一部分,也可以是软件模拟的内存区域
顶点着色器只是确定了图形的外部边界轮廓,而片元着色器才负责绘制内部的纹理(纹路)与颜色。
主要组件
最后来了解 Android OpenGL ES 包含的主要组件:
- GLSurfaceView:继承自 SurfaceView,其内嵌的 Surface 专门负责 OpenGL 渲染。它支持:
- 管理 Surface 与 EGL
- 允许自定义渲染器 Render
- 让渲染器在独立的线程里运作,和 UI 线程分离
- 按需渲染(on-demand)和连续渲染(continuous)
- EGL:OpenGL 是一个跨平台操作 GPU 的 API,但 OpenGL 需要与本地视窗系统进行交互,这就需要一个中间控制层,EGL 就是链接 OpenGL ES 和本地窗口系统的接口,引入 EGL 就是为了屏蔽不同平台上的区别(类似于上层通过 JNI 实现与本地代码的交互)
知道大致流程后可以进入代码阶段了。
3.2 初始化
首先在 AndroidManifest 中声明使用 OpenGL:
<uses-feature android:glEsVersion="0x00020000" android:required="true"/>
然后声明一个自定义 View 继承 GLSurfaceView,并设置 EGL 版本、渲染器和渲染模式:
// GLSurfaceView 继承了 SurfaceView,它自带一个线程 GLThread 进行渲染工作
class GLView(context: Context?, attrs: AttributeSet?) : GLSurfaceView(context, attrs) {
constructor(context: Context?) : this(context, null)
init {
// 设置 EGL 版本
setEGLContextClientVersion(2)
// 设置渲染器
setRenderer(GLRender())
// 设置渲染模式:
// RENDERMODE_WHEN_DIRTY 会在 Surface 被创建,或调用 requestRender() 时渲染
// RENDERMODE_CONTINUOUSLY 会每隔一段时间自动渲染
renderMode = RENDERMODE_WHEN_DIRTY
}
}
再声明自定义渲染器 GLRender 继承 GLSurfaceView.Render:
class GLRender : GLSurfaceView.Renderer {
// GLSurfaceView.Renderer start
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
// 将画布清空为黑色
GLES20.glClearColor(0f, 0f, 0f, 0f)
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
}
override fun onDrawFrame(gl: GL10?) {
}
// GLSurfaceView.Renderer end
}
3.3 初始化三角形
我们新建一个 Triangle 来处理三角形的绘制,并且在 GLRender 绘画回调时,将回调同步给 Triangle:
class GLRender : GLSurfaceView.Renderer {
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
// 将画布清空为黑色
GLES20.glClearColor(0f, 0f, 0f, 0f)
triangle = Triangle()
}
// 当外界调用 requestRender 时会触发本方法回调,
// 类似于 invalidate 与 onDraw 的关系
override fun onDrawFrame(gl: GL10?) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
// 具体的渲染工作交给 Triangle 对象
triangle.onDrawFrame(gl)
}
}
接下来要确定被绘制的三角形是什么样的。比如我想画一个在 z = 0 这个平面的等腰直角三角形,如下图所示:
那么该三角形的顶点数组声明如下:
companion object {
val triangleCoordinates = floatArrayOf(
0.5f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f
)
}
接下来需要创建顶点着色器和片元着色器了。首先我们在 AS 中安装 GLSL Support 插件,可以对 GLSL 进行高亮提示。然后在 /res/raw/ 目录下新建顶点着色器文件 vertex_shader.glsl:
// attribute 声明了包含四个浮点数的向量 vPosition
attribute vec4 vPosition;
// main 是着色器程序的入口点
void main() {
// gl_Position 是语言内置变量,代表了顶点的位置
gl_Position=vPosition;
}
然后是片元着色器 fragment_shader.glsl:
precision mediump float;
uniform vec4 vColor;
void main() {
// 将颜色向量赋值给内置变量,OpenGL 就会根据该颜色绘制
gl_FragColor=vColor;
}
接下来你可以将两个着色器的代码声明为字符串变量,或者通过 IO 流读取文件代码到字符串。这里为了方便我们采用第一种方式(后续 Demo 会采用第二种方式):
private val vertexShaderCode = "attribute vec4 vPosition;\n" +
"void main() {\n" +
" gl_Position=vPosition;\n" +
"}"
private val fragmentShaderCode = "precision mediump float;\n" +
"uniform vec4 vColor;\n" +
"void main() {\n" +
" gl_FragColor=vColor;\n" +
"}"
最后在 Triangle 的构造方法中做如下的初始化工作:
private var vertexBuffer: FloatBuffer
private var program = 0
init {
// 在 GPU 中申请内存,三角形有 3 个顶点,每个顶点有 3 个维度,都是 4 个字节的 float 类型
val byteBuffer = ByteBuffer.allocateDirect(triangleCoordinates.size * 4)
// ByteBuffer 内元素排列顺序采用 Native 顺序
byteBuffer.order(ByteOrder.nativeOrder())
// GL 语言代码需要通过 vertexBuffer 传入 GPU
vertexBuffer = byteBuffer.asFloatBuffer()
vertexBuffer.put(triangleCoordinates)
vertexBuffer.position(0)
// 创建顶点着色器并在 GPU 中进行编译
val vertexShader: Int = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER)
GLES20.glShaderSource(vertexShader, vertexShaderCode)
GLES20.glCompileShader(vertexShader)
// 创建片元着色器并在 GPU 中进行编译
val fragmentShader: Int = GLES20.glCreateShader(GLES20.GL_FRAGMENT_SHADER)
GLES20.glShaderSource(fragmentShader, fragmentShaderCode)
GLES20.glCompileShader(fragmentShader)
// 创建 Program 并绑定两个着色器
program = GLES20.glCreateProgram()
GLES20.glAttachShader(program, vertexShader)
GLES20.glAttachShader(program, fragmentShader)
// 连接到着色器程序
GLES20.glLinkProgram(program)
}
3.4 绘制三角形
最后在 GLRender 传入的绘制回调方法 onDrawFrame() 中绘制三角形:
private val colors = floatArrayOf(1.0f, 1.0f, 1.0f, 1.0f)
fun onDrawFrame(gl: GL10?) {
// 渲染
GLES20.glUseProgram(program)
// 获取 GPU 中 vPosition 变量的地址(实际上是一个 Native 指针)
val positionHandle = GLES20.glGetAttribLocation(program, "vPosition")
// 开启允许对变量读写,与关闭方法成对出现
GLES20.glEnableVertexAttribArray(positionHandle)
// 变量地址,数据尺寸,数据类型,是否正常,行跨度,偏移
GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 3 * 4, vertexBuffer)
val colorHandle = GLES20.glGetUniformLocation(program, "vColor")
GLES20.glUniform4fv(colorHandle, 1, colors, 0)
// 让 OpenGL 绘制三角形
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
// 关闭对变量读写
GLES20.glDisableVertexAttribArray(positionHandle)
}
实际上就是把实际的顶点坐标 vertexBuffer 赋值给 GPU 中的 vPosition 变量,将颜色数组写入到 GPU 的 vColor 变量。
运行程序确实画出了一个直角三角形,但是却不是我们想要的等腰直角三角形:
原因是 OpenGL 绘制的是三维图形,将三维图形绘制到 Android 屏幕这个二维平面上,是需要进行投影的。投影的目的是将三维物体的空间位置和形状转换为屏幕上的二维图像,使观察者可以从适当的视角观察和理解物体。在图形渲染过程中,投影通常是在三维物体的模型空间或世界空间与屏幕空间之间进行的转换。
投影方法主要有两种:
- 透视投影(Perspective Projection):透视投影模拟了人眼或相机在观察场景时的视角效果。它产生了近大远小的效果,使离观察者较远的物体显得较小。透视投影常用于创建逼真的三维效果,在虚拟现实、游戏和模拟等领域广泛应用。
- 正交投影(Orthographic Projection):正交投影是一种平行投影,保持了物体在不同距离上的大小不变。它独立于观察者的位置和角度,适用于需要保持物体大小和形状一致的场景,如工程制图、CAD 应用和二维游戏等。
投影过程涉及到三个要素:相机(观察物体的位置)、被观察的物体与投影面。
为了让最终看到的是等腰直角三角形,我们需要对顶点着色器中的顶点进行变换,变换方式就是对其左乘矩阵:
attribute vec4 vPosition;
uniform mat4 vMatrix;
void main() {
gl_Position=vMatrix*vPosition;
}
现在问题转移到 vMatrix 是如何计算的。首先,我们让 GLRender 将 Surface 的变化同步给 Triangle:
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
triangle.onSurfaceChanged(gl, width, height)
}
在 Triangle 的 onSurfaceChanged() 中设置投影矩阵、相机矩阵,并计算出变换矩阵:
private val viewMatrix = FloatArray(16)
private val projectMatrix = FloatArray(16)
private val mvpMatrix = FloatArray(16)
fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
// 计算宽高比
val ratio: Float = width.toFloat() / height
// 设置投影矩阵
Matrix.frustumM(projectMatrix, 0, -ratio, ratio, -1f, 1f, 3f, 120f)
// 计算相机矩阵
Matrix.setLookAtM(
viewMatrix, 0,
0f, 0f, 7f, // 摄像机的坐标
0f, 0f, 0f, // 目标物的中心坐标
0f, 1f, 0f // 相机看目标物的方向
)
// 计算变换矩阵,将 projectMatrix 与 viewMatrix 相乘
Matrix.multiplyMM(mvpMatrix, 0, projectMatrix, 0, viewMatrix, 0)
}
更新顶点着色器字符串并向 GPU 传入刚刚计算出的变换矩阵:
private val vertexShaderCode = "attribute vec4 vPosition;\n" +
"uniform mat4 vMatrix;\n" +
"void main() {\n" +
" gl_Position=vMatrix*vPosition;\n" +
"}"
fun onDrawFrame(gl: GL10?) {
// 渲染
GLES20.glUseProgram(program)
// 获取 GPU 中 vMatrix 变量的地址,并将 mvpMatrix 赋值给 vMatrix
val matrixHandle = GLES20.glGetUniformLocation(program, "vMatrix")
GLES20.glUniformMatrix4fv(matrixHandle, 1, false, mvpMatrix, 0)
// 获取 GPU 中 vPosition 变量的地址(实际上是一个 Native 指针)
val positionHandle = GLES20.glGetAttribLocation(program, "vPosition")
// 开启允许对变量读写,与关闭方法成对出现
GLES20.glEnableVertexAttribArray(positionHandle)
// 变量地址,数据尺寸,数据类型,是否正常?,行跨度,偏移
GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 3 * 4, vertexBuffer)
val colorHandle = GLES20.glGetUniformLocation(program, "vColor")
GLES20.glUniform4fv(colorHandle, 1, colors, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
// 关闭对变量读写
GLES20.glDisableVertexAttribArray(positionHandle)
}
这样就能画出一个等腰直角三角形:
最后总结一下绘制步骤:
- 创建顶点数组
- 创建顶点着色器和片元着色器
- 将上层定义的顶点数组和颜色数组通过 OpenGL ES 提供的 API 接口传递给 GL 语言的变量
更多推荐
所有评论(0)