顶点和着色器
顶点
一个顶点就是代表几何对象的一个拐角点,这个点有很多附加属性,最重要的属性就是位置,代表这个顶点在空间中的定位
在代码中定义二维顶点
用 float[] 存储顶点,这个数组通常称为顶点属性 (attribute) 数组。因为它们定义在二维坐标系里,所以每个顶点要用两个浮点数进行标记,分别表示 x 轴和 y 轴的位置
由于之后会给顶点加三维数据、甚至颜色数据,到时候一个顶点占据的就不是固定的两个数据了,因此,可以用常量表示每个顶点的位置信息占 float[] 的几个数据,颜色信息占 float[] 的几个数据
在OpenGL里,只能绘制点、直线以及三角形
点和直线可以用于某些特殊效果;只有三角形才能用来构建拥有复杂的对象和纹理的场景。把组成三角形的点依次放在 float[] 中,再告诉OpenGL如何连接这些点形成三角形,再由足够多的三角形组成复杂的对象
当定义三角形的时候,以逆时针的顺序排列顶点称为卷曲顺序(winding order)。都使用这种一致的卷曲顺序,可以优化性能。使用卷曲顺序可以指出一个三角形属于任何给定物体的前面或者后面,OpenGL可以忽略那些无论如何都无法被看到的后面的三角形
// 使用 float[] 存储顶点属性
float[] vertex = new float[]{
// 第一个三角形的顶点位置信息
-0.8f, -0.8f,
0.8f, 0.8f,
-0.8f, 0.8f,
// 第二个三角形的顶点位置信息
-0.8f, -0.8f,
0.8f, -0.8f,
0.8f, 0.8f,
// 中间的线条的顶点位置信息
-0.8f, 0f,
0.8f, 0f,
// 上下两个点的顶点位置信息
0f, -0.5f,
0f, 0.5f
};
关于坐标写法,无论是 x 还是 y 坐标,OpenGL 都会把屏幕映射到 [-1,1] 的范围内,这就意味着屏幕的左边对应x轴的-1,而屏幕的右边对应+1,屏幕的底边会对应y轴的-1,而屏幕的顶边就对应+1,因此,顶点坐标范围要在 [-1,1] 的范围内
使顶点数据可以被OpenGL存取
完成了顶点的定义,但是,OpenGL 还不能存取它们,因为这些代码的运行环境与OpenGL运行的环境使用了不同的语言
在模拟器或者手机设备上编译和运行Java代码的时候,并不是直接运行在硬件上的,而是运行在虚拟机上,OpenGL是作为 native 系统库直接运行在硬件上,没有虚拟机,而运行在虚拟机上的代码不能直接访问 native 环境,除非通过特定的API
有两种方法可以与OpenGL通信:
- 使用Java本地接口 (JNI)
- 把内存从Java堆复制到本地堆
使用Java本地接口 (JNI)
使用Java本地接口 (JNI),这个技术已经由Android软件开发包提供了,当调用android.opengl.GLES20包里的方法时,实际上就是在后台使用 JNI 调用本地系统库
把内存从Java堆复制到本地堆
就是改变内存分配的方式,Java 有一个特殊的类集合,它们可以分配本地内存块,并且把 Java 的数据复制到本地内存。本地内存可以被本地环境存取,不受垃圾回收器的管控,如下图所示
把内存从Java堆复制到本地堆的代码:
// 表示每个浮点数都占用4个字节,把存储顶点的内存从Java堆复制到本地堆的时候使用
private static final int BYTES_PER_FLOAT = 4;
......
mVertexBuffer = ByteBuffer.allocateDirect(vertex.length * BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertex);
mVertexBuffer.position(0);
使用 ByteBuffer.allocateDirect() 分配了一块本地内存,这块内存不会被垃圾回收器管理。这个方法需要知道要分配多少字节的内存块,因为顶点都存储在一个浮点数组里,并且每个浮点数有4个字节,所以这块内存的大小应该是 vertex.length * BYTES_PER_FLOAT
order(ByteOrder.nativeOrder()) 方法告诉字节缓冲区 ( byte buffer)按照本地字节序( native byte order)组织它的内容,本地字节序是指,当一个值占用多个字节时,比如32位整型数,字节按照从最重要位到最不重要位或者相反顺序排列,可以认为这与从左到右或者从右到左写一个数类似。知道这个排序并不重要,重要的是作为一个平台要使用同样的排序,调用order(ByteOrder.nativeOrder())可以保证这一点
调用asFloatBuffer()得到一个可以反映底层字节的FloatBuffer类实例,就可以直接使用浮点数操作
调用vertexData.put(vertex) 把数据从Java的内存复制到本地内存了。当进程结束的时候,这块内存会被释放掉,一般情况下不用关心它
着色器
OpenGL管道
要想把顶点转变为图像显示到屏幕上,需要在OpenGL的管道( pipeline)中传递,这就需要使用着色器( shader),这些着色器会告诉图形处理单元(GPU)如何绘制数据
顶点着色器
顶点着色器的主要作用是:为每个顶点生成最终位置
由于着色器使用的语言是GLSL,因此最好使用 Android Studio 安装 GLSL Support 插件,这样关键字才会高亮
顶点着色器代码
新建 raw/draw1_vertex.vert 文件,写入如下代码
attribute vec4 a_Position;
void main(){
gl_Position=a_Position;
gl_PointSize=10.0;
}
attribute
一个顶点会有几个属性,比如颜色和位置,关键字 "attribute" 就是把这些属性放进着色器的手段,例如上面的 "attribute vec4 a_Position" 就会把前面定义过的顶点位置赋值给 a_Position
vec4
对于我们定义过的每一个顶点,顶点着色器都会被调用一次,当它被调用的时候,它会在a_Position 属性里接收当前顶点的位置,这个属性被定义成vec4类型
一个vec4是包含4个分量的向量,如果这个vec4代表位置,可以认为这4个分量是x、y、z和w坐标,x、y和z对应一个三维位置,而w是一个特殊的坐标,默认情况下,OpenGL都是把向量的前三个坐标设为0,并把最后一个坐标设为1
main
是着色器的入口
gl_Position
顶点着色器一定要给输出变量 gl_Position 赋值,因为 OpenGL 会把 gl_Position 中存储的值作为当前顶点的最终位置,并把这些顶点组装成点、直线和三角形
gl_PointSize
指定点的大小
片段着色器
片段着色器的主要作用是:为每个片段生成最终颜色
对于基本图元(点、直线和三角形)的每个片段,片段着色器都会被调用一次,因此,如果一个三角形被映射到10000个片段,片段着色器就会被调用10000次
光栅化(Rasterization)技术
将顶点几何信息转换成栅格组成的图像的过程
光栅化的目的是找出一个几何单元所覆盖的像素,根据三角形顶点的位置,确定需要多少个像素点才能构成这个三角形,以及每个像素点应该得到哪些信息
片段着色器代码
新建 raw/draw1_fragment.frag 文件,写入如下代码
precision mediump float;
uniform vec4 u_Color;
void main(){
gl_FragColor=u_Color;
}
精度限定符
在这个片段着色器中,"precision mediump float;" 定义了所有浮点数据类型的默认精度,就像在Java 代码中选择浮点数还是双精度浮点数一样,可以选择lowp、mediump和 highp
顶点着色器同样可以改变其默认的精度,但是,对于一个顶点的位置而言,精确度是最重要的,因此默认顶点着色器的精度设置成最高级highp
高精度数据类型更加精确,但是这是以降低性能为代价的,对于片段着色器,选择 mediump,是基于速度和质量的权衡
uniform
这次我们传递一个 uniform 类型,叫做 u_Color,它不像 attribute 类型,每个顶点都要设置一个,一个 uniform 会让每个顶点都使用同一个值,除非我们再次改变它
u_Color也是一个 vec4,四个分量分别对应红色、绿色、蓝色和阿尔法
gl_FragColor
片段着色器一定要给 gl_GragColor赋值,OpenGL会使用这个颜色作为当前片段的最终颜色,例如上面的,把 u_Color 赋值给那个特殊的输出变量 gl_FragColor
加载着色器
就是普通的读取着色器代码的文本内容
public class FileUtil {
public static String getRawText(Context context, int rawId) {
InputStream inputStream = context.getResources().openRawResource(rawId);
InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
StringBuilder builder = new StringBuilder();
String line;
try {
while ((line = bufferedReader.readLine()) != null) {
builder.append(line);
}
} catch (Exception e) {
e.printStackTrace();
}
return builder.toString();
}
}
编译着色器
public class ShaderHelper {
private static final String TAG = "ShaderHelper";
/**
* 编译顶点着色器
*
* @param context
* @param rawId 顶点着色器代码资源id
* @return
*/
public static int compileVertexShader(Context context, int rawId) {
return compileShader(context, GLES20.GL_VERTEX_SHADER, rawId);
}
/**
* 编译片段着色器
*
* @param context
* @param rawId 片段着色器代码资源id
* @return
*/
public static int compileFragmentShader(Context context, int rawId) {
return compileShader(context, GLES20.GL_FRAGMENT_SHADER, rawId);
}
/**
* 编译着色器
*
* @param context
* @param type 着色器类型,分为顶点着色器和片段着色器
* @param rawId 着色器代码资源id
* @return
*/
private static int compileShader(Context context, int type, int rawId) {
// 更加type创建一个新的着色器对象,返回一个整型值,这个整型值就是OpenGL对象的引用
// 无论后面什么时候想要引用这个对象,就要把这个整型值传回OpenGL
int shaderId = GLES20.glCreateShader(type);
// 检查着色器对象是否成功创建,返回值为 0 则创建失败
if (shaderId == 0) {
Log.d(TAG, "compileShader: glCreateShader filed!");
}
// 得到着色器代码
String shaderSource = FileUtil.getRawText(context, rawId);
// 告诉OpenGL读入字符串shaderSource引用的源代码,并把它与shaderId所引用的着色器对象关联起来
GLES20.glShaderSource(shaderId, shaderSource);
// 告诉OpenGL编译先前上传到shaderId的源代码
GLES20.glCompileShader(shaderId);
// 检查OpenGL是否能成功地编译这个着色器
int[] status = new int[1];
// 告诉OpenGL读取与shaderId关联的编译状态,并把编译结果写入status的第0个元素
GLES20.glGetShaderiv(shaderId, GL_COMPILE_STATUS, status, 0);
if (status[0] != GL_TRUE) {
// 通过glGetShaderiv方法获取编译状态的时候,OpenGL只给出一个简单的是或否的回答
// 如果失败了,可以通过调用glGetShaderInfoLog(shaderId)获得关于着色器信息日志
String shaderInfoLog = GLES20.glGetShaderInfoLog(shaderId);
Log.d(TAG, "compileShader: glCompileShader failed! log=" + shaderInfoLog);
// 编译着色器失败,就删除这个着色器,并返回0
GLES20.glDeleteShader(shaderId);
return 0;
}
return shaderId;
}
}
生成OpenGL程序
我们已经加载并编译了一个顶点着色器和一个片段着色器,下一步就是把它们绑定在一起放入一个OpenGL程序
public class ProgramHelper {
private static final String TAG = "ProgramHelper";
/**
* 通过顶点和片段着色器生成OpenGL程序
*
* @param vertexId
* @param fragmentId
* @return
*/
public static int getProgram(int vertexId, int fragmentId) {
// 新建程序对象,返回的整形值表示程序对象的引用
int program = GLES20.glCreateProgram();
// 检查程序是否创建成功
if (program == 0) {
Log.d(TAG, "getProgram: create program failed!");
return 0;
}
// 将顶点着色器和片段着色器都附加到程序对象上
GLES20.glAttachShader(program, vertexId);
GLES20.glAttachShader(program, fragmentId);
// 链接程序,把着色器联合起来
GLES20.glLinkProgram(program);
// 检查链接是否成功,链接成功并不能保证执行成功
int[] status = new int[1];
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, status, 0);
if (status[0] != GLES20.GL_TRUE) {
// 如果链接失败,打印这个程序的信息日志
String programInfoLog = GLES20.glGetProgramInfoLog(program);
Log.d(TAG, "getProgram: glLinkProgram failed! log=" + programInfoLog);
// 链接失败,意味着这个程序无法使用,删除它并返回0
GLES20.glDeleteProgram(program);
return 0;
}
// 正确创建程序后,代码中就不再使用着色器引用了,因此删除它
GLES20.glDeleteShader(vertexId);
GLES20.glDeleteShader(fragmentId);
return program;
}
/**
* 验证程序应用程序有效性,在glUseProgram方法后调用
*
* @param program
*/
public static void validate(int program) {
// 是一个比较耗时的操作,一般只是用作调试
if (LogUtil.isDebug) {
int[] status = new int[1];
GLES20.glGetProgramiv(program, GLES20.GL_VALIDATE_STATUS, status, 0);
if (status[0] != GLES20.GL_TRUE) {
String programInfoLog = GLES20.glGetProgramInfoLog(program);
Log.d(TAG, "getProgram: program is invalidate log=" + programInfoLog);
// 程序无效,意味着这个程序无法使用,删除它并返回0
GLES20.glDeleteProgram(program);
}
}
}
}
在渲染类中调用OpenGL程序
public class MyRenderer implements GLSurfaceView.Renderer {
// 代表位置信息占据顶点数组几个数据
private static final int POSITION_COMPONENT_COUNT = 2;
// 表示每个浮点数都占用4个字节,把存储顶点的内存从Java堆复制到本地堆的时候使用
private static final int BYTES_PER_FLOAT = 4;
private final FloatBuffer mVertexBuffer;
private Context mContext;
private int u_color;
private int a_position;
private int program;
public MyRenderer(Context context) {
mContext = context;
// 使用 float[] 存储顶点属性
float[] vertex = new float[]{
// 第一个三角形的顶点位置信息
-0.8f, -0.8f,
0.8f, 0.8f,
-0.8f, 0.8f,
// 第二个三角形的顶点位置信息
-0.8f, -0.8f,
0.8f, -0.8f,
0.8f, 0.8f,
// 中间的线条的顶点位置信息
-0.8f, 0f,
0.8f, 0f,
// 上下两个点的顶点位置信息
0f, -0.5f,
0f, 0.5f
};
// 将Java堆内存的顶点数据复制到本地堆
mVertexBuffer = ByteBuffer.allocateDirect(vertex.length * BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertex);
}
/**
* 当Surface被创建的时候,GLSurfaceView会调用这个方法
* 程序第一次运行、设备被唤醒或者从其他activity切换回来时,可能会被调用,因此可能会被调用多次
*
* @param gl10
* @param eglConfig
*/
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
// 设置清空屏幕用的颜色
glClearColor(1f, 1f, 1f, 1f);
// 顶点着色器和片段着色器的引用
int vertexShader = ShaderHelper.compileVertexShader(mContext, R.raw.draw1_vertex);
int fragmentShader = ShaderHelper.compileFragmentShader(mContext, R.raw.draw1_fragment);
// 利用顶点着色器和片段着色器生成OpenGL程序
program = ProgramHelper.getProgram(vertexShader, fragmentShader);
//将应用程序设置为活动程序,告诉OpenGL在绘制任何东西到屏幕上的时候要使用program程序
glUseProgram(program);
// 获取在着色器中定义的uniform的位置,uniform的位置不能指定,当OpenGL把着色器链接成一个程序的时候
// 它用一个位置编号把着色器中定义的uniform关联起来了,这些位置编号用来给着色器发送数据
u_color = glGetUniformLocation(program, "u_Color");
// 获取在着色器中定义的attribute的位置,如果不调用glBindAttribLocation()方法
// 给attribute分配位置编号,OpenGL就会自动分配
a_position = glGetAttribLocation(program, "a_Position");
// 告诉OpenGL从这个缓冲区中读取数据之前,确保它会从开头处开始读取数据
mVertexBuffer.position(0);
// 关联attribute与顶点数据的数组,也就是告诉OpenGL到哪里找到属性a_Position对应的数据
glVertexAttribPointer(a_position, 2, GL_FLOAT, false, 0, mVertexBuffer);
// 使能顶点数组,每个attribute都要调用一次,相当于一个开关,调用后,OpenGL才知道到哪里找到属性a_Position对应的数据
glEnableVertexAttribArray(a_position);
}
/**
* Surface被创建以后,每次Surface尺寸变化时,这个方法都会被GLSurfaceView调用
* 例如在横屏、竖屏来回切换的时候,Surface尺寸会发生变化
*
* @param gl10
* @param width
* @param height
*/
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
// 设置视口(viewport)尺寸,告诉OpenGL可以用来渲染的surface的大小
glViewport(0, 0, width, height);
}
/**
* 当绘制一帧时,这个方法会被GLSurfaceView调用,在这个方法中,一定要绘制一些东西,即使只是清空屏幕
* 因为,在这个方法返回后,渲染缓冲区会被交换并显示在屏幕上,如果什么都没画,可能会看到闪烁效果
*
* @param gl10
*/
@Override
public void onDrawFrame(GL10 gl10) {
// 清空屏幕,会擦除屏幕上的所有颜色,并用之前glClearColor()调用定义的颜色填充整个屏幕
glClear(GL_COLOR_BUFFER_BIT);
// 调用glUniform4f()更新着色器代码中的u_Color的值,与属性不同,uniform 的分量没有默认值
// 因此,如果一个uniform在着色器中被定义为vec4类型,需要提供所有四个分量的值
glUniform4f(u_color, 0f, 1f, 0f, 1f);
// 一个参数告诉OpenGL想要画的形状
// 第二个参数告诉OpenGL从顶点数组的哪里开始读顶点
// 第三个参数是告诉OpenGL读入几个顶点
glDrawArrays(GL_TRIANGLES, 0, 6);
glUniform4f(u_color, 0.0f, 0.0f, 0.0f, 1.0f);
glDrawArrays(GL_LINES, 6, 2);
glUniform4f(u_color, 0f, 0f, 1f, 1f);
glDrawArrays(GL_POINTS, 8, 1);
glDrawArrays(GL_POINTS, 9, 1);
}
}
glVertexAttribPointer参数说明
参数 | 说明 |
---|---|
int index | 属性位置,通过 glGetAttribLocation() 方法获取的值 |
int size | 属性占几个Float,我们为每个顶点只传递了两个分量,代表 x 和 y 坐标,但是在着色器中,a_Position被定义为vec4,它有4个分量。如果分量没有被指定值,默认情况下,OpenGL 会把前3个分量设为0,最后一个分量设为1 |
int type | 数据的类型,一般都是 GL_FLOAT |
boolean normalized | 只有使用整型数据的时候,这个参数才有意义,因此可以暂时把它安全地忽略掉 |
int stride | 当一个数组存储多于一个属性时,例如同时存储颜色和位置信息,应该填入 (颜色+位置) 占的 Float个数 * Float 所占字节数 |
Buffer ptr | 填入 从Java堆内存的数据复制到本地堆的 Buffer |
效果
最好在真机上调试,我运行在 Genymotion 模拟器上的时候,没有任何报错信息,但中间的横线画不出来