一、前期出知识储备
在前面的文章里,我们已经对点、线和三角形进行了渲染,并用这些简单的图形和颜色完成了一些常规的工作。但是还缺点什么,如果我们想在这些图形上绘画并加入精致的细节呢?像艺术家一样,我们可以从基本的图形和颜色开始,再使用纹理(texture)在其表面上加入额外的细节。简单来说,纹理就是一个图像或照片,它们可以被加载进OpenGL中。
自然界中的纹理是二维的,但是OpenGL也支持其他类型的纹理格式:一维纹理、二维纹理、三维纹理、立方体映射纹理以及缓存纹理。
(1)纹理是什么
纹理是一种能够应用到场景中的三角形上的图像数据,它通过经过过滤的纹理单元(texel,相当于基于纹理的像素)填充到实心区域。OpenGL中的纹理可以用来表示图像、照片、甚至由一个数学算法生成的分形数据。每个二维的纹理都由许多小的纹理单元组成,它们是小块的数据,类似片段和像素。要使用纹理,最常用的方式是直接从一个图像文件加载数据。
(2)纹理坐标系
每个二维的纹理都有其自己的坐标系统,其范围是从一个拐角的(0,0)到另一个拐角的(1,1)。按照惯例,一个维度叫做S,另一个称为T。当我们想要把一个纹理应用于三角形或一组三角形的时候,我们要为每个顶点指定一组ST纹理坐标,以便OpenGL知道需要用哪个纹理的哪个部分画到每个三角形上。
我们将纹理视为一片区域,它的覆盖范围会沿着每个坐标轴从0.0扩展到1.0。纹理坐标也就是纹理中的坐标,用于对图像进行采样。它们通常是按照逐顶点的方式来设置的,然后对结果几何体区域进行插值来获得逐片元的坐标值。这个坐标值是在片元着色器中被使用的,以便读取纹理数据并返回纹理中的颜色作为结果片元。
(3)纹理API
OpenGL中使用纹理较使用颜色填充片元要更加复杂一点,这里先将不同场景下大多需要使用的绘制纹理相关的API列出:
任务 | 使用的函数 |
载入纹理图像 | glTexImage(加载纹理) / glTexSubImage (更新纹理) |
设置纹理参数 | glTexParameter |
管理多重纹理 | glGenTextures / glBindTextures / glDeleteTexture |
生成Mip贴图 | glGenerateMipmap |
使用各向异性过滤 | glGetFloatv / glTexParameter |
载入压缩纹理 | glCompressedTexImage / glCompressedTexSubImage |
(4)使用纹理映射步骤
- 创建一个纹理对象并且加载纹素;
- 为顶点数据增加纹理坐标;
- 如果要在着色器中使用纹理采样器,将它关联到纹理贴图;
- 在着色器中通过纹理采样器获取纹理单元。
二、上代码,具体实现
(1)我们第一个任务是把一个图像文件的数据加载到一个OpenGL的纹理中
①加载位图数据并与纹理绑定;
下一步是使用Android的API读入图像文件的数据。OpenGL不能直接读取PNG或者JPEG文件的数据,因为这些文件被编码成特定的压缩格式。OpenGL需要非压缩形式的原始数据,因此,我们需要Android内置的位图解码器把图像文件解压缩为OpenGL能理解的形式。
public static int loadTexture(Context context,int resourceId){
/*
*首先创建一个新的BitmapFactory.Options实例,并且设置inScaled为false
*这就告诉Android我们想要的是原始的图像数据,而不是这个图像的缩放版本
*/
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inScaled = false;
final Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(),resourceId,options);
/*
*通过传递1作为第一个参数调用glGenTextures(1,textureObjectIds,0),就创建了一个纹理对象
*OpenGL会把这个生成的ID存储在textureObjectIds中
*/
final int[] textureObjectIds = new int[1];
glGenTextures(1, textureObjectIds,0);
/*
*第一个参数GL_TEXTURE_2D告诉OpenGL这是一个二维的纹理,
*第二个参数告诉OpenGL要绑定到哪个纹理对象的ID
*/
glBindTexture(GL_TEXTURE_2D,textureObjectIds[0]);
... ...
}
Bitmap的四种解析方式均可,这里使用BitmapFactory.decodeResource()做实际的解码工作。
纹理绑定到OpenGL环境需要通过纹理单元(texture unit)来完成,它是一个不小于0,不大于设备所支持的最大单元数量的绑定点整数值。如果环境支持多个纹理单元,多个纹理可以同时绑定到同一个环境中。一旦纹理绑定到环境中,我们就可以在着色器中通过采样器变量的方式去访问它。Android中使用纹理贴图只关注二维纹理,对应的是二维纹理的采样器:sampler2D和二维纹理查询函数:vec4 texture2D()。GLSL语言中内建的每个纹理查询函数都需要一个采样器变量和一组纹理坐标输入。
②理解纹理过滤并设置过滤处理;
当我们在渲染表面绘制一个纹理时,那个纹理的纹理元素可能无法精确地映射到OpenGL的每个片段上。有两种情况:缩小和放大。当我们尽力把几个纹理单元放进一个片段时,缩小就发生了;当我们把一个纹理元素扩展到许多片段时,放大就发生了,针对每一种情况,我们都需要配置OpenGL的纹理过滤器(texture filtering)明确说明会发生什么。
public static int loadTexture(Context context,int resourceId){
... ...
/*
*调用glTexParameteri()设置每个过滤器,GL_TEXTURE_MIN_FILTER指缩小的情况
*缩小用的是三线性过滤,GL_TEXTURE_MAG_FILTER指放大的情况,用的是双线性过滤
*/
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
... ...
}
每种情况下允许使用的纹理过滤模式:
缩小:GL_NEAREST GL_NEAREST_MIPMAP_NEAREST
GL_NEAREST_MIAPMAP_LINEAR GL_LINEAR_MIPMAP_NEAREST
GL_LINEAR_MIPMAP_LINEAR GL_LINEAR
放大:GL_NEAREST GL_LINEAR
③加载纹理到OpenGL并返回其ID;
我们现在可以调用一个简单的OpenGL内置的GLUtils.texImage2D()函数加载位图数据到OpenGL里了。在几何图形中应用纹理贴图时,将纹理从存储器缓冲区载入内存中是一个必要的步骤。一旦被载入,这些纹理就会成为当前纹理状态的一部分。有三个派生自glTexImage的函数经常用来从存储器缓冲区(比如从一个磁盘文件中读取)或颜色缓冲区读取纹理数据。这里使用对应二维纹理的函数。
纹理状态:纹理状态包含了纹理图像本身和一组纹理参数,这些参数控制过滤和纹理坐标的行为。
public static int loadTexture(Context context,int resourceId){
... ...
//加载纹理到OpenGL
texImage2D(GL_TEXTURE_2D,bitmap,0);
//位图数据加载完成后释放这些数据
bitmap.recycle();
//对纹理进行Mip贴图,以提高纹理贴图性能或视觉质量
glGEnerateMiapmap(GL_TEXTURE_2D);
/*完成纹理加载后解除与这个纹理的绑定
*传递0给glBindTexture就与当前的纹理解除绑定了
*/
glBindTexture(GL_TEXTURE_2D,0);
//最后一步 返回纹理对象的ID
return textureObjectIds[0];
}
我们现在有一个loadTexture()方法,它可以从资源文件夹中读入图像文件,并且把图形数据加载到OpenGL中,我们也可以取回一个纹理ID,它可以被用作这个纹理的引用。
(2)创建新的着色器集合
在把纹理绘制到屏幕之前,我们需要创建一套新的着色器,它们可以接收新纹理并把它应用在要绘制的片段上。
①创建顶点着色器
uniform mat4 u_Matrix;
attribute vec4 a_Position;
attrtbute vec2 a_TextureCoordinates;
varying vec2 v_TextureCoordinates;
void main()
{
v_TextureCoordinates = a_TextureCoordinates;
gl_Position = u_Matrix*a_Position;
}
我们首先为位置定义了一个属性,然后为纹理坐标同样加了一个新的属性,它叫a_TextureCoordinates。因为纹理坐标有两个分量:S坐标和T坐标,所以被定义为一个vec2。我们把这些坐标传递给顶点着色器被插值的varying,称为v_TextureCoordinates。
②创建片元着色器
precision mediump float
uniform sampler2D u_TextureUnit;
varying vec2 v_TextureCoordinates;
void main()
{
gl_FragColor = texture2D(u_TextureUnit,v_TextureCoordinates);
}
为了把纹理绘制到一个物体上,OpenGL会为每个片段都调用片段着色器,并且每个调用都接收v_TextureCoordinates的纹理坐标。片段着色器也通过uniform——u_TextureUnit接收实际的纹理数据,u_TextureUnit被定义为一个sampler2D,这个采样器变量指的是一个二维纹理数据的数组。
被插值的纹理坐标和纹理数据被传递给着色器texture2D(),它会读入纹理中的那个特定坐标处的颜色值。接着通过把结果赋值给gl_FragColor设置片段的颜色。
(3)创建独立的类,为顶点数据创建新的类结构
为了复用,我们单独把从Java代码中获取顶点位置传递给本地环境的代码进行封装。新建VertexArray类,在该类中加入以下代码:
private final FloatBuffer floatBuffer;
public VertexArray(float[] vertexData){
floatBuffer = ByteBuffer
.allocateDirect(vertexData.length*4)
.order(ByteBuffer.nativeOrder)
.asFloatBuffer()
.put(vertexData);
}
public void setVertexAttribPointer(int dataOffset,int attributeLoacation,
int compontCount,int Stride){
floatBuffer.position(dataOffset);
glVertexAttribPointer(attributeLoacation,compontCount,GL_FLOAT,false,
stride,floatBuffer);
glEnableVertexAttribArray(attributeLoacation);
floatBuffer.position(0);
}
以上代码包含了一个FloatBuffer,如笔者之前的文章《在Android中使用OpenGL ES进行开发第(二)节:定义图形》,它是用来在本地代码中存储顶点矩阵数据的。这个构建器用一个Java的浮点数据数组,并把它写进这个缓冲区。
接着我们创建了一个通用的方法把着色器中的属性与这些数据关联起来。
(4)创建图形类-Table
接下来我们定义一个关键的类-图形类,用于存储图形的顶点数据,并加入纹理坐标,并把这个纹理用于这个图形。
①添加类常量和添加顶点数据
总体上讲,我们是通过为每个顶点指定纹理坐标而直接在几何图形上进行纹理贴图的。一个纹理坐标会在每个顶点上应用一个纹理分量,然后OpenGL根据需要对纹理进行放大或缩小,将纹理贴到几何图形上。
/*
*定义位置分量计数、纹理坐标分量计数以及跨距
*/
private static final int POSITION_COMPONENT_COUNT = 2;
private static final int TEXTURE_COORDINATES_COMPONENT_COUNT = 2;
private static final int STRIDE = (POSITION_COMPONENT_COUNT+TEXTURE_COORDINATES_COMPONENT_COUNT)*4
//定义图形的顶点
private static final float[] VERTEX_DATA = {
//坐标的顺序:X、Y、S、T
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.09f
};
//为Table类创建一个构造函数,内部使用VertexArray把数据复制到本地内存中的一个FloatBuffer
private final VertexArray vertexArray;
public Table(){
vertexArray = new VertexArray(VERTEX_DATA);
}
为帮助理解“为图形每个顶点指定一个纹理坐标”读者可参考以下图:
左图显示的是一个二维纹理被贴图到几何图形上一个正方形上;右图是一个正方形的纹理图像,但现在图形为三角形。叠加在这个纹理贴图上的是,扩展到这三角形各个顶点在正方形贴图上位置的纹理坐标。
②初始化和绘制数据
//添加一个方法把顶点数组绑定到一个着色器程序上
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
);
}
//最后图形类中定义画的方法
public void draw(){
glDrawArrays(GL_TRIANGLE_FAN,0,6);
}
(5)为着色器程式添加类
接着我们会为纹理着色器程式创建一个类,并用纹理着色器绘制桌子。创建一个名为TextureShaderProgram的新类。
//Uniform constants
private static final String U_MATRIX = "u_Matrix";
private static final String U_TEXTURE_UNIT = "u_TextureUnit";
//Attribute contents
private static final String A_POSITION = "a_Position";
private static final String A_TEXTURE_COORDINATES = "a_TextureCoordinates";
//shader program
private final int program;
//uniform locations
private final int uMatrixLocation;
private final int uTextureUnitLocation;
//Attribute location
private final int aPostionLocation;
private final int aTextureCoordinatesLocation;
//这里加入四个整型用于保存那些uniform和属性的位置
//创建用于初始化着色器程序的构造函数
public TextureShaderProgram(Context context){
getShaderProgram(context R.raw.texture_vertex_shader,R.raw.texture_fragment_shader);
uMatrixLocation = glGetUniformLocation(program,U_MATRIX);
uTextureUnitLocation = glGetUniformLocation(program,U_TEXTURE_UNIT);
aPostionLocation = glGetAttribLocation(program,A_POSITION);
aTextureCoordinatesLocation = glGetAttribLocation(program,A_TEXTURE_COORDINATES);
}
public void getShaderProgram(Context context,int vertexShaderResourceId,int fragmentShaderResourceId){
program = buildProgram(TextResourceReader.readTextFileFromResource(context,vertexShaderResourceId),
TextResourceReader.readTextFileFromResource(context,fragmentShaderResourceId)};
}
public int buildProgram(String vertexShaderResource,String fragmentShaderResource){
int vertexShader = compileVertexShader(vertexShaderResource);
int fragmentShader = compileVertexShader(fragmentShaderResource);
program = linkProgram(vertexShader,fragmentShader);
return program;
}
//传递矩阵和纹理给它们的uniform
public void setUniforms(float[] matrix,int textureID){
glUniformMatrix4fv(uMatrixLocation,1,false,matrix,0);
//调用 glActiveTexture()把活动的纹理单元设置为纹理单元0
glActiveTexture(GL_TETURE0);
//调用 glBindTexture()把纹理绑定到纹理单元
glBindTexture(GL_TEXTURE_2D,textureID);
//调用 glUniformli()把选定的纹理单元传递到片元着色器中的采样器上
glUniformli(uTextureUnitLocation,0);
}
//创建外部获取属性位置的方法
public int getPositionAttributeLocation{
return aPostionLocation;
}
public int getTextureCoordinatesAttributeLocation{
return aTextureCoordinatesLocation;
}
public void useProgram(){
glUseProgram(program);
}
注意:当我们在OpenGL中使用纹理进行绘制的时候,我们不需要直接给着色器传递纹理。相反,我们使用纹理单元保存那个纹理,之所以这样做,是因为一个GPU只能同时绘制数量有限的纹理。它使用这些纹理单元表示当前正在被绘制的活动的纹理。
(6)渲染器类Render类中绘制纹理
private final Context context;
private final float[] projectionMatrix = new float[16];
private final float[] modelMatrix = new float[16];
private Table table;
private TextureShaderProgram textureProgram;
private int texture;
public Render(Context context){
this.context = context;
}
/*
*把清屏颜色设为黑色,初始化顶点数组和着色器程式,并loadTexture()利用加载纹理
*/
@Override
public void onSurfaceCreated(GL10 glUnused,EGLConfig config){
glClearColor(0.0f,0.0f,0.0f,0.0f);
table = new Table();
textureProgram = new TextureShaderProgram(context);
texture = TextureHelper.loadTexture(context,R.drawable.XXX);
}
//绘制桌子
@Override
public void onDrawFrame(GL10 glUnused){
glClear(GL_COLOR_BUFFER_BIT);
textureProgram.useProgram();
textureProgram.setUniforms(projectionMatrix,texture);
table.bindData(textureProgram);
table.draw();
}
总结:填充片元的纹理和填充片元的颜色不同,纹理不会被直接绘制,它们要绑定到纹理单元,然后把这些纹理单元传递到着色器中。通过在纹理单元中把纹理切来切去,我们还可以在场景中绘制不同的纹理,但是过分的切换可能使性能下降。我们调整纹理来适应它们将要被绘制的形状,既可以通过调整纹理坐标,也可以通过拉伸或者压扁纹理本身来实现。
延伸知识:使用多重纹理
如上文所述,为何将纹理绑定至激活的纹理单元中而不是直接使用纹理,是因为GPU能够绘制的纹理数量有限。而实际开发中,OpenGL支持的纹理数一般是足够使用的——一个着色器阶段最少可以支持16个纹理,如果乘上OpenGL支持的着色器阶段的数量那就是80个纹理!实际上,OpenGL确实有80个纹理单元,他们对应标识符GL_TEXTURE0~GL_TEXTURE79。
如果要在着色器中使用多重纹理,我们还需要定义多个uniform类型的采样器变量。每个采样器变量都对应着一个不同的纹理单元。从应用程序的角度来说,采样器uniform和一般整数的uniform非常相似。它们可以使用通常的glGetActivieUniform()函数来进行枚举,也使用过了glUniformli()函数来设置数值。设置给采样器uniform的整数数值也就是它所关联的纹理单元的索引值。
在一个着色器(或者程式对象)中使用多重纹理的步骤如下:首先,我们需要使用glBindTextureUnit()函数将纹理绑定到纹理单元上。对于每一个将会在着色器中使用的纹理单元,我们都需要重复这个操作。然后在着色器中,将每个采样器uniform都关联到一个纹理单元上。
//绑定纹理到纹理单元0 glBindTextureUnit(0,tex1);
//重复步骤,绑定纹理到纹理单元1 glBindTextureUnit(1,tex1);