OpenGL Android课程四:介绍纹理基础

翻译文

原文标题:Android Lesson Four: Introducing Basic Texturing
原文链接:http://www.learnopengles.com/android-lesson-four-introducing-basic-texturing/


介绍纹理基础

这是我们Android系列的第四个课程。
在本课中,我们将添加我们在第三课
中学到的内容,并学习如何添加纹理。
我们来看看如何从应用资源中获取一张
图片加载到OpenGLES中,并展示到
屏幕上。

跟着我一起来,你将马上明白纹理的
基本使用方式。
screenshot
screenshot

前提条件

本系列每个课程构建都是以前一个课程为基础,这节课是第三课的扩展,因此请务必在继续之前复习该课程。

已下是本系列课程的前几课:

纹理基础

纹理映射的艺术(以及照明)是构建逼真的3D世界最重要的部分。没有纹理映射,一切都是平滑的阴影,看起来很人工,就像是90年代的老式控制台游戏。

第一个开始大量使用纹理的游戏,如Doom和Duke Nukem 3D,通过增加视觉冲击力,大大提升了游戏的真实感——如果在晚上玩可能会真的吓唬到我们。

这里我们来看有纹理和没有纹理的场景

pre-fragment lighting
pre-fragment lighting

每片段照明;
正方形四个顶点中心位置
added texture
added texture

添加了纹理;
正方形四个顶点中心位置
看左边的图片,这个场景通过每像
素照明和着色点亮。这个场景看起
来非常平滑,现实生活中我们走进
一个房间有充满了光滑阴影的东西
就像是这个立方体。

在看右边的图片,同样的场景现在
纹理化了。环境光也增加了,因为
纹理的使用使整个场景变暗,也可
以看到纹理对侧面立方体的影响。
立方体具有和以前相同数量的多边
形,但它们有新纹理看起来更加详
细。

满足于那些好奇的人,这个纹理的
资源来自于公共领域的资源

纹理坐标

在OpengGL中,纹理坐标时常使用坐标(s,t)代替(x,y)。(s,t)表示纹理上的一个纹理元素,然后映射到多边形。另外需要注意这些纹理坐标和其他OpengGL坐标相似:t(或y)轴指向上方,所以值越高您走的越远。

大多数计算机图形,y轴指向下方。这意味着左上角是图片的原点(0,0),并且y值向下递增。换句话说,OpenGL的坐标系和大多数计算机图形相反,这是您需要考虑到的。

OpenGL的纹理坐标系
coordiante
coordiante

纹理映射基础

在本课中,我们将来看看常规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()方法中加载纹理

@Override
public 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)方法中添加一些代码。

@Override
public 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属性。

简而言之:

  1. 设置纹理单元
  2. 绑定纹理到这个单元
  3. 将此单元指定给片段着色器中的纹理标准

根据需要重复多个纹理。

进一步练习

一旦您做到这儿,您就完成的差不多了!当然这这并没有您预期的那么糟糕...或者确实糟糕?😉作为下一个练习,尝试通过加载另一个纹理,将其绑定到另一个单元,并在着色器中使用它。

回顾

现在我们回顾一下所有的着色器代码,以及我们添加了一个新的帮助功能用来从资源目录读取着色器代码,而不是存储在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市场下:google play 下载apk
“我”也编译了个apk,方便大家下载:github download

推荐阅读更多精彩内容