首先申明下,本文为笔者学习《OpenGL ES应用开发实践指南(Android卷)》的笔记,涉及的代码均出自原书,如有需要,请到原书指定源码地址下载。
《OpenGL ES学习笔记(二)——平滑着色、自适应宽高及三维图像生成》中阐述的平滑着色、自适应宽高是为了实现在移动端模拟真实场景采用的方法,并且通过w分量增加了三维视角,在具体实现上采用了正交投影、透视投影的理论。本文将在此基础上,构建更加精美的三维场景。三维效果本质上是点、直线和三角形的组合,纹理是将图像或者照片覆盖到物体表面,形成精美的细节。在实现上具体分为两步:1)将纹理图片加载进OpenGL;2)OpenGL将其显示到物体表面。(有点像把大象装进冰箱分几步~~~)不过,在实现过程中,涉及到着色器程序的管理,涉及到不同的纹理过滤模式,涉及到顶点数据新的类结构等问题,下面将一一对其阐述:
- 纹理加载
- 纹理着色器
- 更新顶点数据类结构
- 着色器程序类
- 纹理绘制
一、纹理加载
将纹理覆盖到物体表面,最终是通对齐坐标来实现的。而OpenGL中二维纹理的坐标与计算机图像的坐标并不一致,因此,首先对比下两者的不同。
可见,两者的差别在于绕横轴翻转180度。另外,OpenGL ES支持的纹理不必是正方形,但每个维度都必须是2的幂。
加载纹理图片的方法参数列表应该包括Android上下文(Context)和资源ID,返回值应该是OpenGL纹理的ID,因此,该方法申明如下:
public static int loadTexture(Context context, int resourceId) {}
首先,创建一个纹理对象,与普通OpenGL对象生成模式一样。生成成功之后,申明纹理调用应该应用于这个纹理对象。其次,加载位图数据,OpenGL读入位图数据并复制到前面绑定的纹理对象。
final int[] textureObjectIds = new int[1];
glGenTextures(1, textureObjectIds, 0); if (textureObjectIds[0] == 0) {
if (LoggerConfig.ON) {
Log.w(TAG, "Could not generate a new OpenGL texture object.");
}
return 0;
}
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inScaled = false; // Read in the resource
final Bitmap bitmap = BitmapFactory.decodeResource(
context.getResources(), resourceId, options); if (bitmap == null) {
if (LoggerConfig.ON) {
Log.w(TAG, "Resource ID " + resourceId + " could not be decoded.");
} glDeleteTextures(1, textureObjectIds, 0);
return 0;
}
// Bind to the texture in OpenGL
glBindTexture(GL_TEXTURE_2D, textureObjectIds[0]);
这两段代码需要说明的并不多,其中options.inScaled = false表明OpenGL读入图像的非压缩形式的原始数据。OpenGL读入位图数据需要注意一点:纹理过滤。OpenGL纹理过滤模式如下表:(--内容来自原书)
GL_NEAREST |
最近邻过滤 |
GL_NEAREST_MIPMAP_NEAREST |
使用MIP贴图的最近邻过滤 |
GL_NEAREST_MIPMAP_LINEAR |
使用MIP贴图级别之间插值的最近邻过滤 |
GL_LINEAR |
双线性过滤 |
GL_LINEAR_MIPMAP_NEAREST |
使用MIP贴图的双线性过滤 |
GL_LINEAR_MIPMAP_LINEAR |
三线性过滤(使用MIP贴图级别之间插值的双线性过滤) |
至于每种过滤具体的解释及实现,请自行Google吧。这里对于缩小情况,采用了GL_LINEAR_MIPMAP_LINEAR,对于放大情况,采用了GL_LINEAR。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
加载纹理的最后一步就是将bitmap复制到当前绑定的纹理对象:
texImage2D(GL_TEXTURE_2D, 0, bitmap, 0);
绑定之后,仍然需要做一些后续操作,比如回收bitmap对象(bitmap内存占用大户),生成MIP贴图,接触纹理绑定,最后返回纹理对象ID。
glGenerateMipmap(GL_TEXTURE_2D);
// Recycle the bitmap, since its data has been loaded into OpenGL.
bitmap.recycle(); // Unbind from the texture.
glBindTexture(GL_TEXTURE_2D, 0); return textureObjectIds[0];
二、纹理着色器
在继续采用GLSL编写着色器程序之前,先说明下之前遗漏的一个问题:
OpenGL着色语言(OpenGL Shading Language)是用来在OpenGL中着色编程的语言,也即开发人员写的短小的自定义程序,他们是在图形卡的GPU (Graphic Processor Unit图形处理单元)上执行的,代替了固定的渲染管线的一部分,使渲染管线中不同层次具有可编程型。比如:视图转换、投影转换等。
GLSL(GL Shading Language)的着色器代码分成2个部分:Vertex Shader(顶点着色器)和Fragment(片断着色器),有时还会有Geometry Shader(几何着色器)。负责运行顶点着色的是顶点着色器。它可以得到当前OpenGL 中的状态,GLSL内置变量进行传递。GLSL其使用C语言作为基础高阶着色语言,避免了使用汇编语言或硬件规格语言的复杂性。
这段内容来自百度百科,有一点需要重视:采用GLSL编写的程序是在GPU中执行的,意味着着色器程序并不占用CPU时间,这启发我们在某些耗时的渲染程序(摄像头实时滤镜)中可以采用GLSL实现,或许比NDK方式实现数据处理更为高效。后续笔者会在这方面实践,这里先说明纹理着色器程序。同样,为了支持纹理,需对顶点着色器和片段着色器进行更改。
uniform mat4 u_Matrix; attribute vec4 a_Position;
attribute vec2 a_TextureCoordinates; varying vec2 v_TextureCoordinates; void main()
{
v_TextureCoordinates = a_TextureCoordinates;
gl_Position = u_Matrix * a_Position;
}
precision mediump float; uniform sampler2D u_TextureUnit;
varying vec2 v_TextureCoordinates; void main()
{
gl_FragColor = texture2D(u_TextureUnit, v_TextureCoordinates);
}
上述顶点着色器中,变量a_TextureCoordinates的类型为vec2,因为纹理坐标的两个分量:S坐标和T坐标。片段着色器中,sampler2D类型的u_TextureUnit表示接收二维纹理数据的数组。
三、更新顶点数据类结构
首先将不同类型的顶点数据分配到不同的类中,每个类代表一个物理对象的类型。在类的构造器中初始化VertexArray对象,VertexArray的实现与前述文章中描述的一致,采用FloatBuffer在本地代码中存储顶点矩阵数据,并创建通用方法将着色器的属性与顶点数据关联。
private final FloatBuffer floatBuffer; public VertexArray(float[] vertexData) {
floatBuffer = ByteBuffer
.allocateDirect(vertexData.length * BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertexData);
} public void setVertexAttribPointer(int dataOffset, int attributeLocation,
int componentCount, int stride) {
floatBuffer.position(dataOffset);
glVertexAttribPointer(attributeLocation, componentCount, GL_FLOAT,
false, stride, floatBuffer);
glEnableVertexAttribArray(attributeLocation); floatBuffer.position(0);
}
public Table() {
vertexArray = new VertexArray(VERTEX_DATA);
}
构造器中传入的参数VERTEX_DATA就是顶点数据。
private static final float[] VERTEX_DATA = {
// Order of coordinates: X, Y, S, T // Triangle Fan
0f, 0f, 0.5f, 0.5f,
-0.5f, -0.8f, 0f, 0.9f,
0.5f, -0.8f, 1f, 0.9f,
0.5f, 0.8f, 1f, 0.1f,
-0.5f, 0.8f, 0f, 0.1f,
-0.5f, -0.8f, 0f, 0.9f };
在该组数据中,x=0,y=0对应纹理S=0.5,T=0.5,x=-0.5,y=-0.8对应纹理S=0,T=0.9,之所以有这种对应关系,看下前面讲到的OpenGL纹理坐标与计算机图像坐标的对比就清楚啦。至于纹理部分的数据使用了0.1和0.9作为T坐标,是为了避免把纹理压扁,而对纹理进行了裁剪,截取了0.1到0.9的部分。
初始化vertexArray之后,通过其setVertexAttribPointer()方法将顶点数据绑定到着色器程序上。
public void bindData(TextureShaderProgram textureProgram) {
vertexArray.setVertexAttribPointer(
0,
textureProgram.getPositionAttributeLocation(),
POSITION_COMPONENT_COUNT,
STRIDE); vertexArray.setVertexAttribPointer(
POSITION_COMPONENT_COUNT,
textureProgram.getTextureCoordinatesAttributeLocation(),
TEXTURE_COORDINATES_COMPONENT_COUNT,
STRIDE);
}
这个方法为每个顶点调用了setVertexAttribPointer(),并从着色器程序获取每个属性的位置。通过getPositionAttributeLocation()把位置数据绑定到被引用的着色器属性上,并通过getTextureCoordinatesAttributeLocation()把纹理坐标数据绑定到被引用的着色器属性。
完成上述绑定以后,绘制只需要调用glDrawArrays()实现。
public void draw() {
glDrawArrays(GL_TRIANGLE_FAN, 0, 6);
}
四、着色器程序类
随着纹理的使用,着色器程序变得更多,因此需要为着色器程序添加管理类。根据着色器分类,这里分别创建纹理着色器类和颜色着色器类,且抽象它们的共同点,形成基类ShaderProgram,TextureShaderProgram和ColorShaderProgram分别继承于此实现。ShaderProgram主要的功能就是根据Android上下文Context和着色器资源ID读入着色器程序,其构造器参数列表如下:
protected ShaderProgram(Context context, int vertexShaderResourceId,
int fragmentShaderResourceId) {
……
}
读入着色器程序的实现应该在ShaderHelper类中,其步骤与之前所述相似,包括编译、链接等步骤。
public static int buildProgram(String vertexShaderSource,
String fragmentShaderSource) {
int program; // Compile the shaders.
int vertexShader = compileVertexShader(vertexShaderSource);
int fragmentShader = compileFragmentShader(fragmentShaderSource); // Link them into a shader program.
program = linkProgram(vertexShader, fragmentShader); if (LoggerConfig.ON) {
validateProgram(program);
} return program;
}
compileVertexShader(编译)和linkProgram(链接)的实现在之前的笔记中已详细描述过。ShaderProgram的构造器调用上述buildProgram()方法即可。
program = ShaderHelper.buildProgram(
TextResourceReader.readTextFileFromResource(
context, vertexShaderResourceId),
TextResourceReader.readTextFileFromResource(
context, fragmentShaderResourceId));
得到着色器程序之后,定义OpenGL后续的渲染使用该程序。
public void useProgram() {
// Set the current OpenGL shader program to this program.
glUseProgram(program);
}
着色器程序类TextureShaderProgram和ColorShaderProgram在构造器中调用父类的构造函数,并读入纹理着色器中uniform和属性的位置。
public TextureShaderProgram(Context context) {
super(context, R.raw.texture_vertex_shader,
R.raw.texture_fragment_shader); // Retrieve uniform locations for the shader program.
uMatrixLocation = glGetUniformLocation(program, U_MATRIX);
uTextureUnitLocation = glGetUniformLocation(program, U_TEXTURE_UNIT); // Retrieve attribute locations for the shader program.
aPositionLocation = glGetAttribLocation(program, A_POSITION);
aTextureCoordinatesLocation =
glGetAttribLocation(program, A_TEXTURE_COORDINATES);
}
接下来,传递矩阵给uniform,这在之前的笔记中描述过了。
// Pass the matrix into the shader program.
glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0);
纹理的传递相对于矩阵的传递要复杂一些,因为纹理并不直接传递,而是采用纹理单元(Texture Unit)来保存,因为一个GPU只能同时绘制数量有限的纹理,使用这些纹理单元表示正在被绘制的活动的纹理。
// Set the active texture unit to texture unit 0.
glActiveTexture(GL_TEXTURE0); // Bind the texture to this unit.
glBindTexture(GL_TEXTURE_2D, textureId); // Tell the texture uniform sampler to use this texture in the shader by
// telling it to read from texture unit 0.
glUniform1i(uTextureUnitLocation, 0);
glActiveTexture(GL_TEXTURE0)表示把活动的纹理单元设置为纹理单元0,调用glBindTexture将textureId指向的纹理绑定到纹理单元0,最后,调用glUniform1i把选定的纹理单元传递给片段着色器中的u_TextureUnit(sampler2D)。
颜色着色器类与纹理着色器类的实现基本类似,同样在构造器中获取uniform和属性的位置,不过设置uniform值只需传递矩阵即可。
public void setUniforms(float[] matrix) {
// Pass the matrix into the shader program.
glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0);
}
五、纹理绘制
通过前面的准备,顶点数据,着色器程序已经放到了不同的类中,因此,在渲染类中可以通过前面的实现进行纹理绘制了。AirHockeyRenderer类更新后的成员变量和构造函数如下:
private final Context context; private final float[] projectionMatrix = new float[16];
private final float[] modelMatrix = new float[16]; private Table table;
private Mallet mallet; private TextureShaderProgram textureProgram;
private ColorShaderProgram colorProgram; private int texture; public AirHockeyRenderer(Context context) {
this.context = context;
}
初始化变量主要包括清理屏幕、初始化顶点数组和着色器程序,加载纹理等。
@Override
public void onSurfaceCreated(GL10 glUnused, EGLConfig config) {
glClearColor(0.0f, 0.0f, 0.0f, 0.0f); table = new Table();
mallet = new Mallet(); textureProgram = new TextureShaderProgram(context);
colorProgram = new ColorShaderProgram(context); texture = TextureHelper.loadTexture(context, R.drawable.air_hockey_surface);
}
最后,在onDrawFrame()中绘制物体,绘制的方法就是通过调用前面着色器类和物体类(顶点数据)的方法来实现的。
@Override
public void onDrawFrame(GL10 glUnused) {
// Clear the rendering surface.
glClear(GL_COLOR_BUFFER_BIT); // Draw the table.
textureProgram.useProgram();
textureProgram.setUniforms(projectionMatrix, texture);
table.bindData(textureProgram);
table.draw(); // Draw the mallets.
colorProgram.useProgram();
colorProgram.setUniforms(projectionMatrix);
mallet.bindData(colorProgram);
mallet.draw();
}
总结一下,这篇笔记涉及到一下内容:
1)加载纹理并显示到物体上;
2)重新组织程序,管理多个着色器和顶点数据之间的切换;
3)调整纹理以适应它们将要被绘制的形状,既可以调整纹理坐标,也可以通过拉伸或压扁纹理本身来实现;
4)纹理不能直接传递,需要被绑定到纹理单元,然后将纹理单元传递给着色器;