翻译文
原文标题:Android Lesson Four: Introducing Basic Texturing 原文链接:
介绍纹理基础
这是我们Android系列的第四个课程。在本课中,我们将添加我们在中学到的内容,并学习如何添加纹理。我们来看看如何从应用资源中获取一张图片加载到OpenGLES中,并展示到屏幕上。跟着我一起来,你将马上明白纹理的基本使用方式。 | |
前提条件
本系列每个课程构建都是以前一个课程为基础,这节课是的扩展,因此请务必在继续之前复习该课程。
已下是本系列课程的前几课:
纹理基础
纹理映射的艺术(以及照明)是构建逼真的3D世界最重要的部分。没有纹理映射,一切都是平滑的阴影,看起来很人工,就像是90年代的老式控制台游戏。
第一个开始大量使用纹理的游戏,如Doom和Duke Nukem 3D,通过增加视觉冲击力,大大提升了游戏的真实感——如果在晚上玩可能会真的吓唬到我们。
这里我们来看有纹理和没有纹理的场景
| | 看左边的图片,这个场景通过每像素照明和着色点亮。这个场景看起来非常平滑,现实生活中我们走进一个房间有充满了光滑阴影的东西就像是这个立方体。在看右边的图片,同样的场景现在纹理化了。环境光也增加了,因为纹理的使用使整个场景变暗,也可以看到纹理对侧面立方体的影响。立方体具有和以前相同数量的多边形,但它们有新纹理看起来更加详细。满足于那些好奇的人,这个纹理的资源来自于 |
纹理坐标
在OpengGL中,纹理坐标时常使用坐标(s,t)代替(x,y)。(s,t)表示纹理上的一个纹理元素,然后映射到多边形。另外需要注意这些纹理坐标和其他OpengGL坐标相似:t(或y)轴指向上方,所以值越高您走的越远。
大多数计算机图形,y轴指向下方。这意味着左上角是图片的原点(0,0),并且y值向下递增。换句话说,OpenGL的坐标系和大多数计算机图形相反,这是您需要考虑到的。
OpenGL的纹理坐标系 |
---|
|
纹理映射基础
在本课中,我们将来看看常规2D纹理(GL_TEXTURE_2D
)和红,绿,蓝颜色信息(GL_RGB
)。OpenGL ES 也提供其他纹理模式让你做更多不同的特殊效果。我们将使用GL_NEAREST
查看点采样,GL_LINEAR
和MIP-映射将在后面的课程中讲解。
让我们一起来到代码部分,看看怎样开始在Android中使用基本的纹理。
顶点着色器
我们将采用上节课中的每像素照明着色器,并添加纹理支持。
这儿是新的变化:
attribute vec2 a_TexCoordinate;// 我们将要传入的每个顶点的纹理坐标信息...varying vec2 v_TexCoordinate; // 这将会传入到片段着色器void main(){ // 传入纹理坐标 v_TexCoordinate = a_TexCoordinate; ...}复制代码
在顶点着色器中,我们添加一个新的属性类型vec2
(一个包含两个元素的数组),将用来放入纹理坐标信息。这将是每个顶点都有,同位置,颜色,法线数据一样。我们也添加了一个新的变量,它将通过三角形表面上的线性插值将数据传入片段着色器。
片段着色器
uniform sampler2D u_Texture;" + // 传入纹理...varying vec2 v_TexCoordinate;" + // 插入的纹理坐标void main(){ ... // 计算光线矢量和顶点法线的点积,如果法线和光线矢量指向相同的方向,那么它将获得最大的照明 float diffuse = max(dot(v_Normal, lightVector), 0.1);" + // 根据距离哀减光线 diffuse = diffuse * (1.0 / (1.0 + (0.10 * distance * distance)));" + // 添加环境照明 diffuse = diffuse + 0.3;" + // 颜色乘以亮度哀减和纹理值得到最终的颜色 gl_FragColor = v_Color * diffuse * texture2D(u_Texture, v_TexCoordinate);" +}复制代码
我们添加了一个新的常量类型sampler2D
来表示实际纹理数据(与纹理坐标对应), 由定点着色器插值传入纹理坐标,我们再调用texture2D(texture, textureCoordinate)
得到纹理在当前坐标的值,我们得到这个值后再乘以其他项得到最终输出的颜色。
这种方式添加纹理会使整个场景变暗,因此我们还会稍微增强环境光照并减少光照哀减。
将一个图片加载到纹理
public static int loadTexture(final Context context, final int resourceId) { final int[] textureHandle = new int[1]; GLES20.glGenTextures(1, textureHandle, 0); if (textureHandle[0] != 0) { final BitmapFactory.Options options = new BitmapFactory.Options(); options.inScaled = false; // 没有预先缩放 // 得到图片资源 final Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), resourceId, options); // 在OpenGL中绑定纹理 GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]); // 设置过滤 GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST); GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST); // 将位图加载到已绑定的纹理中 GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0); // 回收位图,因为它的数据已加载到OpenGL中 bitmap.recycle(); } if (textureHandle[0] == 0) { throw new RuntimeException("Error loading texture."); } return textureHandle[0];}复制代码
这段代码将Androidres
文件夹中的图形文件读取并加载到OpenGL中,我会解释每一部分的作用。
我们首先需要告诉OpenGL去为我们创建一个新的handle
,这个handle
作为一个唯一标识,我们想在OpenGL中引用纹理时就会使用它。
final int[] textureHandle = new int[1];GLES20.glGenTextures(1, textureHandle, 0);复制代码
这个OpenGL方法可以用来同时生成多个handle
,这里我们仅生成一个。
因为我们这里只需要一个handle去加载纹理。首先,我们需要得到OpenGL能理解的纹理格式。 我们不能只从PNG或JPG提供原始数据,因为它不会理解。我们需要做的第一步是将图像文件解码为Android Bitmap对象:
final BitmapFactory.Options options = new BitmapFactory.Options();options.inScaled = false; // 没有预先缩放// 得到图片资源final Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), resourceId, options);复制代码
默认情况下,Android会根据设备的分辨率和你放置图片的资源文件目录而预先缩放位图。我们不希望Android根据我们的情况对位图进行缩放,因此我们将inScaled
设置为false
// 在OpenGL中绑定纹理GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]);// 设置过滤GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);复制代码
然后我们绑定纹理,并设置几个参数,绑定一个纹理,并告诉OpenGL后续OpenGL调用需要这样过滤这个纹理。我们将默认过滤器设置为GL_NEAREST
,这是最快,也是最粗糙的过滤形式。它所做的就是在屏幕的每个点选择最近的纹素,这可能导致图像伪像和锯齿。
GL_TEXTURE_MIN_FILTER
这是告诉OpenGL在绘制小于原始大小(以像素为单位)的纹理时要应用哪种类型的过滤。GL_TEXTURE_MAG_FILTER
这是告诉OpenGL在放大纹理到原始大小时要应用哪种类型的过滤。
// 将位图加载到已绑定的纹理中GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);// 回收位图,因为它的数据已加载到OpenGL中bitmap.recycle();复制代码
安卓有一个非常实用的功能可以直接将位图加载到OpenGL中。一旦您将资源读入Bitmap对象GLUtils.texImage2D()
将负责其他事情,这个方法的签名:
public static void texImage2D (int target, int level, Bitmap bitmap, int border)复制代码
我们想要一个常规的2D位图,因此我们传入GL_TEXTURE_2D
作为第一个参数。第二个参数用于MIP-映射,并允许您指定要在哪个级别使用的图像。我们这里没有使用MIP-映射,因此我们将传入0设置为默认级别。我们传入位图,由于我们没有使用边框,所以我们传入0。
然后原始位图对象调用recycle()
,这提醒Android可以回收这部分内存。由于纹理已被加载到OpenGL,我们不需要继续保留这个副本。 是的,Android应用程序在执行垃圾收集的Dalvik VM下运行,但Bitmap对象包含驻留在native内存中的数据,如果你不明确的回收它们,它们需要几个周期来进行垃圾收集。 这意味着如果您忘记执行此操作,实际上可能会因内存不足错误而崩溃,即使您不再持有对位图的任何引用。
将纹理应用到我们的场景
首先,我们需要添加各种成员变量来持有我们纹理所需要的东西:
// 存放我们的模型数据在浮点缓冲区private final FloatBuffer mCubeTextureCoordinates;// 用来传入纹理private int mTextureUniformHandle;// 用来传入模型纹理坐标private int mTextureCoordinateHandle;// 每个数据元素的纹理坐标大小private final int mTextureCoordinateDataSize = 2;// 纹理数据private int mTextureDataHandle;复制代码
我们基本上是需要添加新成员变量来跟踪我们添加到着色器的内容,以及保持对纹理的引用。
定义纹理坐标
我们在构造方法中定义我们的纹理坐标
// S, T (或 X, Y)// 纹理坐标数据// 因为图像Y轴指向下方(向下移动图片时值会增加),OpenGL的Y轴指向上方// 我们通过翻转Y轴来调整它// 每个面的纹理坐标都是相同的final float[] cubeTextureCoordinateData = { // 正面 0.0F, 0.0F, 0.0F, 1.0F, 1.0F, 0.0F, 0.0F, 1.0F, 1.0F, 1.1F, 1.0F, 0.0F, };...复制代码
这坐标数据看起来可能有点混乱。如果您返回去看第三课中点的位置是如何定义的,您将会发现我们为正方体每个面都定义了两个三角形。点的定义方式像下面这样:
(三角形1)左上,左下,右上(三角形2)左下,右下,右上复制代码
纹理坐标和正面的位置坐标对应,但是由于Y轴翻转,Y轴指向和OpenGL的Y轴相反的方向。
看下图,实线坐标表示在OpenGL中正方体正面X,Y坐标。虚线表示翻转后的坐标,可以看出和上面定义的纹理坐标是一一对应的
设置纹理
我们在onSurfaceCreated()
方法中加载纹理
@Overridepublic void onSurfaceCreated(GL10 gl, EGLConfig config) { ... mProgramHandle = ShaderHelper.createAndLinkProgram(vertexShaderHandle, fragmentShaderHandle, "a_Position", "a_Color", "a_Normal", "a_TexCoordinate"); ... // 加载纹理 mTextureDataHandle = TextureHelper.loadTexture(mActivityContext, R.drawable.bumpy_bricks_public_domain);复制代码
我们传入一个新的属性a_TexCoordinate
绑定到我们的着色器中,并且我们通过之前创建的loadTexture()
方法加载着色器。
使用纹理
我们也需要在onDrawFrame(GL10 gl)
方法中添加一些代码。
@Overridepublic void onDrawFrame(GL10 gl) { ... mTextureUniformHandle = GLES20.glGetUniformLocation(mProgramHandle, "u_Texture"); mTextureCoordinateHandle = GLES20.glGetAttribLocation(mProgramHandle, "a_TexCoordinate"); // 将纹理单元设置为纹理单元0 GLES20.glActiveTexture(GLES20.GL_TEXTURE0); // 将纹理绑定到这个单元 GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureDataHandle); // 通过绑定到纹理单元0,告诉纹理标准采样器在着色器中使用此纹理 GLES20.glUniform1i(mTextureUniformHandle, 0);复制代码
我们得到着色器中的纹理数据和纹理坐标句柄。在OpenGL中,纹理能在着色之前,需要绑定到纹理单元。纹理单元是读取纹理并实际将它传入着色器的中,因此可以再屏幕上显示。不同的图形芯片有不同数量的纹理单元,因此在使用它们之前,您需要检查是否存在其他纹理单元。
首先,我们告诉OpenGL我们想设置使用的纹理单元到第一个单元,纹理单元0。然后自动绑定纹理到第一个单元,通过调用glBindTexture()
。最后,我们告诉OpenGL,我们想将mTextureUniformHandle
绑定到第一个纹理单元,它引用了片段着色器中u_Texture
属性。
简而言之:
- 设置纹理单元
- 绑定纹理到这个单元
- 将此单元指定给片段着色器中的纹理标准
根据需要重复多个纹理。
进一步练习
一旦您做到这儿,您就完成的差不多了!当然这这并没有您预期的那么糟糕...或者确实糟糕??作为下一个练习,尝试通过加载另一个纹理,将其绑定到另一个单元,并在着色器中使用它。
回顾
现在我们回顾一下所有的着色器代码,以及我们添加了一个新的帮助功能用来从资源目录读取着色器代码,而不是存储在java字符串中:
顶点着色器 all
uniform mat4 u_MVPMatrix; // 一个表示组合model、view、projection矩阵的常量uniform mat4 u_MVMatrix; // 一个表示组合model、view矩阵的常量attribute vec4 a_Position; // 我们将要传入的每个顶点的位置信息attribute vec4 a_Color; // 我们将要传入的每个顶点的颜色信息attribute vec3 a_Normal; // 我们将要传入的每个顶点的法线信息attribute vec2 a_TexCoordinate; // 我们将要传入的每个顶点的纹理坐标信息varying vec3 v_Position;varying vec4 v_Color;varying vec3 v_Normal;varying vec2 v_TexCoordinate; // 这将会传入到片段着色器// 顶点着色器入口点void main(){ // 传入纹理坐标 v_TexCoordinate = a_TexCoordinate; // 将顶点位置转换成眼睛空间的位置 v_Position = vec3(u_MVMatrix * a_Position); // 传入颜色 v_Color = a_Color; // 将法线的方向转换在眼睛空间 v_Normal = vec3(u_MVMatrix * vec4(a_Normal, 0.0)); // gl_Position是一个特殊的变量用来存储最终的位置 // 将顶点乘以矩阵得到标准化屏幕坐标的最终点 gl_Position = u_MVPMatrix * a_Position;}复制代码
片段着色器 all
precision mediump float; //我们将默认精度设置为中等,我们不需要片段着色器中的高精度uniform sampler2D u_Texture; // 传入纹理uniform vec3 u_LightPos; // 光源在眼睛空间的位置varying vec3 v_Position; // 插入的位置varying vec4 v_Color; // 插入的位置颜色varying vec3 v_Normal; // 插入的位置法线varying vec2 v_TexCoordinate; // 插入的纹理坐标void main() // 片段着色器入口{ // 将用于哀减 float distance = length(u_LightPos - v_Position); // 获取从光源到顶点方向的光线向量 vec3 lightVector = normalize(u_LightPos - v_Position); // 计算光线矢量和顶点法线的点积,如果法线和光线矢量指向相同的方向,那么它将获得最大的照明 float diffuse = max(dot(v_Normal, lightVector), 0.1); // 根据距离哀减光线 diffuse = diffuse * (1.0 / (1.0 + (0.25 * distance * distance))); // 添加环境照明 diffuse = diffuse + 0.3; // 颜色乘以亮度哀减和纹理值得到最终的颜色 gl_FragColor = v_Color * diffuse * texture2D(u_Texture, v_TexCoordinate);}复制代码
怎样从raw资源目录中读取文本?
public class RawResourceReader { public static String readTextFileFromRawResource(final Context context, final int resurceId) { final InputStream inputStream = context.getResources().openRawResource(resurceId); final InputStreamReader inputStreamReader = new InputStreamReader(inputStream); final BufferedReader bufferedReader = new BufferedReader(inputStreamReader); String nextLine; final StringBuilder body = new StringBuilder(); try { while ((nextLine = bufferedReader.readLine()) != null) { body.append(nextLine).append('\n'); } } catch (IOException e) { return null; } finally { try { bufferedReader.close(); } catch (IOException e) { e.printStackTrace(); } } return body.toString(); }}复制代码
教程目录
打包教材
可以在Github下载本课程源代码:
本课的编译版本也可以再Android市场下: “我”也编译了个apk,方便大家下载: