【OpenGL-ES】二维纹理

前言

上一节介绍了OpenGL及EGL等方面的一些相关内容,在最后通过绘制一个简单的三角形来展示了在Android上使用OpenGL ES的大致步骤。大部分教程中接下来的章节会介绍向量、矩阵等,从而引入坐标和变换,但是因为这部分在现在我们的实际开发中应用并不大,所以该章节会先介绍OpenGL中的纹理,在下一节中就可以通过绘制纹理来更直观地介绍坐标与变换。

因为OpenGL的纹理映射本身也是比较大的主题,本节只限于讨论二维纹理的基本使用,对于纹理映射的其他方法,后面会继续学习。本章节主要参考 Textures objects and parameters 以及 OpenGL学习脚印: 二维纹理映射(2D textures) ,后者是目前阅读过的讲解纹理方面条理最清晰的国内博文。但是该博文是使用OpenGL进行示例,所以我将该文中的示例改为Android上的OpenGL ES,同时添加了一些额外的细节讲解和自己的理解,如纹理映射原理和纹理颠倒问题等,也在文末多加了灰度图的拓展从而展示OpenGL进行图像处理的优势,文中涉及到的代码也会更详尽。

通过本节可以了解到

  • 纹理映射的概念和原理
  • 渲染二维纹理
  • 将二维纹理转化为灰度图

什么是纹理贴图

一般说来,纹理是表示物体表面的一幅或几幅二维图形,也称纹理贴图(texture)。当把纹理按照特定的方式映射到物体表面上的时候,能使物体看上去更加真实。当前流行的图形系统中,纹理绘制已经成为一种必不可少的渲染方法。在理解纹理映射时,可以将纹理看做应用在物体表面的像素颜色。在真实世界中,纹理表示一个对象的颜色、图案以及触觉特征。纹理只表示对象表面的彩色图案,它不能改变对象的几何形式。更进一步的说,它只是一种高强度的计算行为。

为什么使用纹理

要使渲染的物体更加逼真,一方面我们可以使用更多的三角形来建模,通过复杂的模型来逼近物体,但是这种方法会增加绘制流水线的负荷,而且很多情况下不是很方便的。使用纹理,将物体表面的细节映射到建模好的物体表面,这样不仅能使渲染的模型表面细节更丰富,而且比较方便高效。纹理映射就是这样一种方法,在程序中通过为物体指定纹理坐标,通过纹理坐标获取纹理对象中的纹理,最终显示在屏幕区域上,已达到更加逼真的效果。

比如我们在利用OpenGL做游戏的时候,加载了一个人物模型进来了,这个人物模型上是没有色彩的。我们需要给它绘上需要的色彩才行。但是这些色彩从哪里来呢?我们不可能像之前处理三角形那样,根据顶点取生成需要的色彩,那样对于我们给这个人物模型绘色的工作量实在太大了。这个时候我们就需要用到纹理贴图的技术了——把一个纹理(对于2D贴图,可以简单的理解为图片),按照所期望的方式显示在诸多三角形组成的物体的表面。

这是我们上一节介绍图元时候看到的一张图,一只由非常多个三角形组成的兔子,想象一下如果我们要用上次那种顶点设置颜色的方式来绘制出它的皮毛眼睛等细节,几乎可以说是不可能完成的任务,所以我们需要纹理来帮我们实现这个操作。

纹素(texel)和纹理坐标

纹素

使用纹素这个术语,而不是像素来表示纹理对象中的显示元素,主要是为了强调纹理对象的应用方式。纹理对象通常是通过纹理图片读取到的,这个数据保存到一个二维数组中,这个数组中的元素称为纹素(texel),纹素包含颜色值和alpha值。纹理对象的大小的宽度和高度应该为2的整数幂,例如16, 32, 64, 128, 256。要想获取纹理对象中的纹素,需要使用纹理坐标(texture coordinate)指定。

纹理坐标

纹理坐标应该与纹理对象大小无关,这样指定的纹理坐标当纹理对象大小变更时,依然能够工作,比如从256x256大小的纹理,换到512x256时,纹理坐标依然能够工作。因此纹理坐标使用规范化的值,大小范围为[0,1],纹理坐标使用uv或st表示,如下图所示:

u轴从左至右,v轴从底向上指向。右上角为(1,1),左下角为(0,0)。
通过指定纹理坐标,可以映射到纹素。例如一个256x256大小的二维纹理,坐标(0.5,1.0)对应的纹素即是(128,256)。(256x0.5 = 128, 256x1.0 = 256)。
纹理映射时只需要为物体的顶点指定纹理坐标即可,其余部分由片元着色器插值完成,如下图所示:

模型变换和纹理坐标

所谓模型变换,就是对物体进行缩放、旋转、平移等操作,后面会着重介绍。当对物体进行这些操作时,顶点对应的纹理坐标不会进行改变,通过插值后,物体的纹理也像紧跟着物体发生了变化一样。如下图所示为变换前物体的纹理坐标:

经过旋转变换后,物体和对应的纹理坐标如下图所示,可以看出上面图中纹理部分的房子也跟着发生了旋转:

当然有一些技术可以使纹理坐标有控制地发生改变,但是这不在本节的讨论范围内,这里可以看到所渲染物体通过改变顶点坐标来实现旋转操作,但是其纹理坐标并没有变化。

创建纹理对象

创建纹理对象的过程同OpenGL中创建FBO、VBO等类似,都是创建,绑定,然后设置一些参数的过程:


    //创建一个存放纹理对象句柄的数组
    int[] textureId = new int[1];
    //创建一个纹理对象,存放于textureId数组内
    GLES20.glGenTextures(1, textureId, 0);
    //将该纹理绑定到GL_TEXTURE_2D目标,代表这是一个二维纹理
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId[0]);   

这里我们绑定到GL_TEXTURE_2D目标,表示二维纹理。

纹理参数

WRAP参数

上面提到纹理坐标(0.5, 1.0)到纹素的映射,恰好为(128,256)。如果纹理坐标超出[0,0]到[1,1]的范围(假设我们纹理坐标uv范围都设置为0.0-2.0)该怎么处理呢?这个就是WRAP参数由来,它使用以下方式来处理:

  • GL_REPEAT:坐标的整数部分被忽略,重复纹理,这是OpenGL纹理默认的处理方式
  • GL_MIRRORED_REPEAT: 纹理也会被重复,但是当纹理坐标的整数部分是奇数时会使用镜像重复。
  • GL_CLAMP_TO_EDGE: 坐标会被截断到[0,1]之间。结果是坐标值大的被截断到纹理的边缘部分,形成了一个拉伸的边缘(stretched edge pattern)。
  • GL_CLAMP_TO_BORDER: 不在[0,1]范围内的纹理坐标会使用用户指定的边缘颜色。
    *
    当纹理坐标超出[0,1]范围后,使用不同的选项,这些选项输出的效果如下图所示:

在OpenGL中设置wrap参数方式如下:


    //设置其WRAP方式
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);   

上面的几个选项对应的都是整数,因此使用glTexParameteri来设置,结尾的i代表整数。

Filter参数

当使用纹理坐标映射到纹素数组时,正好得到对应纹素的中心位置的情况,很少出现。例如上面的(0.5,1.0)对应纹素(128,256)的情况是比较少的。如果纹理坐标映射到纹素位置(152.34,745.14)该怎么办呢 ?

一种方式是对这个坐标进行取整,使用最佳逼近点来获取纹素,这种方式即点采样(point sampling),也就是最近邻滤波(nearest neighbor filtering)。这种方式容易导致走样误差,明显有像素块的感觉。最近邻滤波方法的示意图如下所示:

图中目标纹素位置,离红色这个纹素最近,因此选择红色作为最终输出纹素。

另外还存在其他滤波方法,例如线性滤波方法(linear filtering),它使用纹素位置(152.34,745.14)附近的一组纹素的加权平均值来确定最终的纹素值。例如使用 ( (152,745), (153,745), (152,744) and (153,744) )这四个纹素值的加权平均值。权系数通过与目标点(152.34,745.14)的距离远近反映,距离(152.34,745.14)越近,权系数越大,即对最终的纹素值影响越大。线性滤波的示意图如下图所示:

图中目标纹素位置周围的4个纹素通过加权平均计算出最终输出纹素。

还存在其他的滤波方式,如三线性滤波(Trilinear filtering)等。刚才介绍到的最近邻滤波和线性滤波的对比效果如下图所示:

可以看出最近邻方法获取的纹素看起来有明显的像素块,而线性滤波方法获取的纹素看起来比较平滑。两种方法各自有不同的应用场合,不能说线性滤波一定比最近邻滤波方法好,例如要制造8位图形效果(8 bit graphics,每个像素使用8位字节表示)需要使用最近邻滤波。例如下面这张8位图:

另外一种场景是,纹理应用到物体上,最终要绘制在显示设备上,这里存在一个纹素到像素的转换问题,有以下三种情形:

  • 一个纹素最终对应屏幕上的多个像素 这称之为放大(magnification)
  • 一个纹素对应屏幕上的一个像素 这种情况不需要滤波方法
  • 一个纹素对应少于一个像素,或者说多个纹素对应屏幕上的一个像素 这个称之为缩小(minification)

放大和缩小的示意图如下:

在OpenGL中通过使用下面的函数,为纹理的放大和缩小滤波设置相关的控制选项:


    //设置其放大缩小滤波方式
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);

其中GL_LINEAR对应线性滤波,GL_NEAREST对应最近邻滤波方式。

Mipmaps

考虑一个情景:当物体在场景中离观察者很远,最终只用一个屏幕像素来显示时,这个像素该如何通过纹素确定呢?如果使用最近邻滤波来获取这个纹素,那么显示效果并不理想。需要使用纹素的均值来反映物体在场景中离我们很远这个效果,对于一个 256×256的纹理,计算平均值是一个耗时工作,不能实时计算,因此可以通过提前计算一组这样的纹理用来满足这种需求。这组提前计算的按比例缩小的纹理就是Mipmaps。Mipmaps纹理大小每级是前一等级的一半,按大小递减顺序排列为:

  • 原始纹理 256×256
  • Mip 1 = 128×128
  • Mip 2 = 64×64
  • Mip 3 = 32×32
  • Mip 4 = 16×16
  • Mip 5 = 8×8
  • Mip 6 = 4×4
  • Mip 7 = 2×2
  • Mip 8 = 1×1

OpenGL会根据物体离观察者的距离选择使用合适大小的Mipmap纹理。Mipmap纹理示意图如下所示:

OpenGL中通过函数glGenerateMipmap(GL_TEXTURE_2D);来生成Mipmap,前提是已经指定了原始纹理。原始纹理必须自己通过读取纹理图片来加载,这个后面会介绍。
如果直接在不同等级的MipMap之间切换,会形成明显的边缘,因此对于Mipmap也可以同纹素一样使用滤波方法在不同等级的Mipmap之间滤波。要在不同等级的MipMap之间滤波,需要将之前设置的GL_TEXTURE_MIN_FILTER选项更改为以下选项之一:

  • GL_NEAREST_MIPMAP_NEAREST: 使用最接近像素大小的Mipmap,纹理内部使用最近邻滤波。
  • GL_LINEAR_MIPMAP_NEAREST: 使用最接近像素大小的Mipmap,纹理内部使用线性滤波。
  • GL_NEAREST_MIPMAP_LINEAR: 在两个最接近像素大小的Mipmap中做线性插值,纹理内部使用最近邻滤波。
  • GL_LINEAR_MIPMAP_LINEAR: 在两个最接近像素大小的Mipmap中做线性插值,纹理内部使用线性滤波。

使用glGenerateMipmap(GL_TEXTURE_2D)产生Mipmap的前提是你已经加载了原始的纹理对象。使用MipMap时设置GL_TEXTURE_MIN_FILTER选项才能起作用,设置GL_TEXTURE_MAG_FILTER的Mipmap选项将会导致无效操作,OpenGL错误码为GL_INVALID_ENUM。

设置Mipmap选项如下代码所示:


   glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);

如何使用纹理

纹理坐标

首先要指定纹理坐标,我们分别贴出该节demo中的顶点坐标,以及与顶点坐标顺序相同和垂直翻转的两份纹理坐标,代码如下所示:


    //默认顶点坐标
    public static final float[] VERTEX_COORD = new float[]{
            -1f, 1f,//左上
            -1f, -1f,//左下
            1f, -1f,//右下
            1f, 1f//右上
    };
    //与默认顶点坐标顺序一致的纹理坐标,会出现纹理垂直翻转的问题
    public static final float[] TEXTURE_COORD = {
            0.0f, 1.0f,//左上
            0.0f, 0.0f,//左下
            1.0f, 0.0f,//右下
            1.0f, 1.0f//右上
    };
    //垂直翻转后的纹理坐标
    public static final float[] TEXTURE_COORD_VERTICAL_FLIP = {
            0.0f, 0.0f,//左下
            0.0f, 1.0f,//左上
            1.0f, 1.0f,//右上
            1.0f, 0.0f,//右下
    };

这里需要两份纹理坐标,主要是为了解决使用纹理过程中几乎都会遇到的纹理垂直翻转的问题,这里先卖个关子,下面会有专门的小节讲解。

加载原始纹理

OpenGL支持直接以buffer作为原始纹理来加载,在android中也支持使用bitmap,针对Bitmap,Android SDK提供了一个工具类可以让我们方便地将Bitmap加载成纹理:


    //生成原始纹理图片
    Bitmap texture = getTextureBitmap();
    //将图片绑定到当前glBindTexture的纹理对象上
    GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, texture, 0);
## 纹理对象/参数 最后就是将创建纹理对象,设置OpenGL纹理参数,读取纹理图片,定义纹理图像格式等步骤结合起来,纹理数据最终传递到了显卡中存储:

    //生成原始纹理图片
    Bitmap texture = getTextureBitmap();
    int textureId = new int[1];
    //创建一个纹理对象,存放于textureId数组内
    GLES20.glGenTextures(1, textureId, 0);
    //将该纹理绑定到GL_TEXTURE_2D目标,代表这是一个二维纹理
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId[0]);
    //设置其放大缩小滤波方式
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
    //设置其WRAP方式
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
    GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
            GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);

    //将图片绑定到当前glBindTexture的纹理对象上
    GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, texture, 0);
    //绑定完纹理后可直接回收图片资源
    texture.recycle();

注意:图片资源在加载成纹理后就可以释放了。

着色器中使用纹理对象

在顶点着色器中我们传递了纹理坐标,有了纹理坐标后,获取最终的纹素则是在片元着色器中完成的。由于纹理对象通过使用uniform变量来像片元着色器传递,实际上这里传递的是对应纹理单元(texture unit)的索引号。纹理单元、纹理对象对应关系如下图所示:

着色器通过纹理单元的索引号索引纹理单元,每个纹理单元可以绑定多个纹理到不同的目标(1D,2D)。OpenGL可以支持的纹理单元数目是有限制的,纹理单元最大支持数目可以通过查询GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS常量获取。这些常量值是按照顺序定义的,因此可以采用 GL_TEXTURE0 + i 的形式书写常量,其中整数i在[0, GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS)范围内。

作为一个了解,纹理对象不仅包含纹理数据,还包含采样参数,这些采样参数称之为采样状态(sampling state)。而采样对象(sampler object)就是只包含采样参数的对象,将它绑定到纹理单元时,它会覆盖纹理对象中的采样状态,从而重新配置采样方式。这里不再继续讨论采样对象的使用了。

要使用纹理必须在使用之前激活对应的纹理单元,默认状态下0号纹理单元是激活的,因此即使没有显式地激活也能工作。激活并使用纹理的代码如下:


    //将纹理对象设置到0号单元上
    GLES20.glUniform1i(mTexture, 0);
    //激活GL_TEXTURE0,该纹理单元默认激活
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
    //绑定纹理数据到GL_TEXTURE0纹理单元上,并且指定其为GL_TEXTURE_2D目标
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId[0]);

上述glUniform1i将0号纹理单元作为整数传递给片元着色器中的mTexture变量,同时指定使用该纹理单元对应的二维纹理,片元着色器中使用uniform变量对应这个纹理采样器,使用变量类型为:


  uniform sampler2D inputImageTexture;

uniform变量与attribute变量
uniform变量与顶点着色器中使用的属性变量(attribute variables)不同,
属性变量首先进入顶点着色器,如果要传递给片元着色器,需要在顶点着色器中定义输出变量输出到片元着色器。而uniform变量则类似于全局变量,在整个着色器程序中都可见。

示例

这里就不重复介绍上一章里介绍过的代码了,直接贴出附带注释的顶点着色器和片元着色器,以及修改后的Renderer三个回调函数。

顶点着色器:


private static final String VERTEX_SHADER =
            "//设置的顶点坐标数据\n" +
            "attribute vec4 vPosition;" +
            "//设置的纹理坐标数据\n" +
            "attribute vec4 inputTextureCoordinate;\n" +
            "//输出到片元着色器的纹理坐标数据\n" +
            "varying vec2 textureCoordinate;\n" +
            "void main() {" +
            "//设置最终坐标\n" +
            "  gl_Position = vPosition;" +
            "  textureCoordinate = inputTextureCoordinate.xy;\n" +
            "}";

片段着色器:


    private static final String FRAGMENT_SHADER =
            "//设置float类型默认精度,顶点着色器默认highp,片元着色器需要用户声明\n" +
            "precision mediump float;" +
            "//从顶点着色器传入的纹理坐标数据\n" +
            "varying vec2 textureCoordinate;\n" +
            "//用户传入的纹理数据\n" +
            "uniform sampler2D inputImageTexture;\n" +
            "void main() {" +
            "//该片元最终颜色值\n" +
            "  gl_FragColor = texture2D(inputImageTexture, textureCoordinate);" +
            "}";
其中texture函数根据纹理坐标,获取纹理对象中的纹素。 回调函数:

    @Override
    public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
        //编译着色器并链接顶点与片元着色器生成OpenGL程序句柄
        mProgramId = OpenGLUtil.loadProgram(VERTEX_SHADER, FRAGMENT_SHADER);
        //通过OpenGL程序句柄查找获取顶点着色器中的顶点坐标句柄
        mPositionId = GLES20.glGetAttribLocation(mProgramId, "vPosition");
        //通过OpenGL程序句柄查找获取顶点着色器中的纹理坐标句柄
        mInputTextureCoordinate = GLES20.glGetAttribLocation(mProgramId, "inputTextureCoordinate");
        //通过OpenGL程序句柄查找获取片元着色器中的纹理句柄
        mTexture = GLES20.glGetUniformLocation(mProgramId, "inputImageTexture");
        //获取顶点数据
        mVertexBuffer = CoordinateUtil.getVertexCoord();
        //获取与顶点数据顺序相同的纹理坐标数据
        mTextureBuffer = CoordinateUtil.getTextureCoord();
        //获取与顶点数据顺序垂直翻转的纹理坐标数据
        //mTextureBuffer = CoordinateUtil.getTextureVerticalFlipCoord();
        //加载纹理
        loadTexture();
    }
    @Override
    public void onSurfaceChanged(GL10 gl10, int width, int height) {
        GLES20.glViewport(0, 0, width, height);
    }
    @Override
    public void onDrawFrame(GL10 gl10) {
        //这里网上很多博客说是设置背景色,其实更严格来说是通过所设置的颜色来清空颜色缓冲区,改变背景色只是其作用之一
        GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f);//白色不透明
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        //告知OpenGL所要使用的Program
        GLES20.glUseProgram(mProgramId);
        //启用指向渲染图形所处的顶点数据的句柄
        GLES20.glEnableVertexAttribArray(mPositionId);
        //绑定渲染图形所处的顶点数据
        GLES20.glVertexAttribPointer(mPositionId, CoordinateUtil.PER_COORDINATE_SIZE,
                GLES20.GL_FLOAT, false,
                CoordinateUtil.COORDINATE_STRIDE, mVertexBuffer);
        //启用指向纹理坐标数据的句柄
        GLES20.glEnableVertexAttribArray(mInputTextureCoordinate);
        //绑定纹理坐标数据
        GLES20.glVertexAttribPointer(mInputTextureCoordinate, CoordinateUtil.PER_COORDINATE_SIZE,
                GLES20.GL_FLOAT, false, CoordinateUtil.COORDINATE_STRIDE, mTextureBuffer);
        //将纹理对象设置到0号单元上
        GLES20.glUniform1i(mTexture, 0);
        //激活GL_TEXTURE0,该纹理单元默认激活
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        //绑定纹理数据到GL_TEXTURE0纹理单元上
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId[0]);
        //使用GL_TRIANGLE_FAN方式绘制纹理
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_FAN, 0, CoordinateUtil.VERTEX_COUNT);
        //禁用顶点数据对象
        GLES20.glDisableVertexAttribArray(mPositionId);
        //禁用纹理坐标数据对象
        GLES20.glDisableVertexAttribArray(mInputTextureCoordinate);
        //解绑纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
    }

在我们不需要渲染的时候,需要对纹理进行回收,因为OpenGL的纹理个数是有限制的,当超过限制时将发生如崩溃等异常现象:


    public void destroy() {
       if (mTextureId[0] != OpenGLUtil.NO_TEXTURE) {
           GLES20.glDeleteTextures(1, mTextureId,0);
       }
       GLES20.glDeleteProgram(mProgramId);
   }

运行程序,效果如下图所示:

观看结果我们会发现这张猫的图片被垂直镜像翻转了一下,这就是我们接下来要讲的OpenGL纹理实际应用过程中最容易遇到的问题,借这个问题我们也可以了解一下纹理映射的原理。现在我们先把结果矫正回来,只需要注释掉:


    mTextureBuffer = CoordinateUtil.getTextureCoord();

使用相比顶点坐标顺序垂直翻转过的纹理坐标即可:


    mTextureBuffer = CoordinateUtil.getTextureVerticalFlipCoord();

矫正后就能显示出正确的结果了:

这里还有一个问题,就是我们这张图片因为比例与屏幕显示区域比例不一致的原因导致了变形,这就是下一节要介绍的变换里一个应用场景:如何使纹理适配各种比例的显示区域。

纹理的颠倒问题

纹理颠倒问题非常常见,根本原因就是大部分图像坐标系统的原点和OpenGL的纹理坐标原点并不一致,因为官网和google上也没有解释为什么会导致最终镜像翻转,有些开发直接将左上角当作(0,0)来设置纹理坐标也是一种取巧的方式,但是我觉得去理解这个“问题”存在的原因会更有助于理解OpenGL的这个映射原理,当然既然网上没有解释,以下是我个人demo试验和理解的结论,如有误差还望指出。

启用纹理映射后,如果想把一幅纹理映射到相应的几何图元,就必须告诉GPU如何进行纹理映射,也就是为图元的顶点指定恰当的纹理坐标。纹理坐标用浮点数来表示,范围一般从0.0到1.0,左下角坐标为(0.0,0.0),左上角坐标为(0.0,1.0),右上角坐标为(1.0,1.0),右下角坐标为(1.0,0.0),如下图所示:

左图为纹理图和纹理坐标,右图为顶点图和顶点坐标。

将纹理拆解成三角形图元后映射成最终右边的矩形上,需要将纹理坐标指定到正确的顶点上,才能使纹理正确的显示,否则显示出来的纹理会无法显示,或者出现旋转、翻转、错位等情况。

现在我们的顶点坐标是按照右图V1V2V3V4的顺序来设置的,如果纹理坐标与顶点坐标的设置顺序相同也是V1V2V3V4设置,当纹理顺序与坐标顺序一致时,就相当于左图平移到了右图,但是这时候这个左图已经不是我们现在看到的这张图片,而是加载到OpenGL中的纹理了。而又因为大多数图像信息的坐标原点在左上角,而OpenGL纹理的坐标原点在左下角的原因,当图片加载成纹理时,图片左上角的坐标原点会被放置到左下角,那么左上角移动到左下角同时整张图的位置大小不变,就是垂直镜像翻转,也就是我们实际使用的纹理是一张被镜像翻转过的图片。我们之后的绘制操作都是基于这张旋转后的纹理进行,那么这时候我们再进行上述的“平移”(映射)操作时,就会得到一个翻转后的结果。

如果我们要使图片正常显示,那么可以将纹理坐标或者顶点坐标垂直镜像翻转一次,那么在绘制这张翻转后的纹理的时候,因为纹理坐标与顶点坐标也处于镜像翻转关系,那么就会负负得正了。所以纹理坐标的正确顺序应该是V2V1V4V3,这样也就是一张垂直镜像翻转后的猫咪图片在绘制出来的时候又被镜像翻转了一次。

当然按照这个特性,我们就可以通过改变坐标顺序来轻松实现翻转图片等功能。

灰度图

首先灰度图和黑白图是不一样的概念。黑白图是指每个像素的颜色用二进制的1位来表示,那么颜色只有10这两个值。这也就是说,要么是黑,要么是白。而灰度图是指像素由一个量化的灰度来描述,没有彩色信息,但是相比黑白图像,灰度图像在黑色与白色之间还有许多级的颜色深度。即黑白图像属于灰度图像,但是灰度图像不一定是黑白图像。

灰度图像通常是在如可见光等单个电磁波频谱内测量每个像素的亮度得到的,也可以理解为颜色的深浅程度。将彩色图像转化为灰度图像可以实现降为从而简化计算,而通过RGB获取图像灰度值的方法有多种,比如RGB求和取平均值,也可以使灰度值等于某一通道的值来取该通道下的灰度图像(一幅完整的图像,是由红色、绿色、蓝色三个通道组成的。红色、绿色、蓝色三个通道的缩览图都是以灰度显示的。用不同的灰度色阶来表示“ 红,绿,蓝”在图像中的比重。),使用何种算法视具体的应用场景而定。比较普遍使用的是一个心理学公式,通过人眼对RGB的敏感度不同而对RGB使用不同的权重最终计算得出灰度值:

Gray = R0.299 + G0.587 + B*0.114

使用OpenGL来进行图像处理就相比将Bitmap转化为字节数组再处理方便得多了,这里只需要修改片元着色器最终产生的gl_FragColor的值就可以了:


    private static final String GREY_FRAGMENT_SHADER =
        "//设置float类型默认精度,顶点着色器默认highp,片元着色器需要用户声明\n" +
        "precision mediump float;" +
        "//从顶点着色器传入的纹理坐标数据\n" +
        "varying vec2 textureCoordinate;\n" +
        "//用户传入的纹理数据\n" +
        "uniform sampler2D inputImageTexture;\n" +
        "void main() {" +
        "//该片元最终颜色值\n" +
        "  vec4 color = texture2D(inputImageTexture, textureCoordinate);" +
        "  float result = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;" +
        "  gl_FragColor = vec4(result, result, result, 1.0);" +
        "}";

运行程序,最终结果如下:

总结

本章节首先介绍了纹理相关的一些重要概念,然后通过代码与图片讲解了在Android使用纹理的步骤,最后通过纹理颠倒纹理进一步解释纹理映射的原理,顺便还拓展了一下灰度图的相关内容。很明显,如果仅仅是为了显示一张图片,那么使用OpenGL明显是大材小用了,但是如果是为了做一些图像处理那么使用OpenGL反倒比Android原生API简单得多,更不用说明显的性能差距了。

答疑

Q:在一次渲染中着色器会执行几次?
How many times shader executes?
假设使用顶点着色器、几何着色器(目前很少使用,可以忽略),片段着色器去绘制三条连续的线段,每条连续线段有五个顶点,那么以上三种着色器各自应该执行多少次:
假设这每五个顶点都是独立的,一共有15个不同的顶点,那么顶点着色器会执行15次。如果使用glDrawElements()并且这些顶点里面有一些是重复顶点,那么顶点着色器可能每个重复顶点只执行一次,也可能没重复一次就再执行一次,也可能在这两者之间。
如果是绘制线或者三角形,那么几何着色器每个点会执行一次。现在绘制的是以LINE_STRIP方式绘制,所以几何着色器会针对每个片段执行一次,5个顶点4个片段所以执行4次,绘制三条所以12次。
片段着色器至少每个片段执行一次。如果使用多重采样,那么可能每个片段执行一次也可能每次采样执行一次,视gl_SamplePosition而定。某些片段不需要渲染但是需要执行一些运算,这时候片段着色器也会执行。

参考

OpenGL学习脚印: 二维纹理映射(2D textures)

Textures objects and parameters


版权声明

![Creative Commons BY-NC-ND 4.0 International License](/images/cc.png)

Lam’s Blog by Binghe Lin is licensed under a Creative Commons BY-NC-ND 4.0 International License.
林炳河创作并维护的Lam’s Blog采用创作共用保留署名-非商业-禁止演绎4.0国际许可证

本文首发于Lam’s Blog - Knowledeg as Action,版权所有,侵权必究。

本文永久链接:http://linbinghe.com/2018/dfe1b405.html