上一篇我们我们要对GLSL语言有了基础的了解,我们现在应该对于GLSL Shader脚本起码能理解其中的语法含义,不过这显然是不够的。对于前面几个章节都可以说是对Android OpenGL ES的入门,本篇呢就说一下在我们的实际工作中遇到的场景下(诸如相机滤镜是怎么实现的、视频如何使用OpenGL 渲染等等实际性问题)来介绍纹理

纹理

通过前面的内容我们已经了解到,我们可以为每个顶点添加颜色来增加图形的细节,从而创建出有趣的图像。但是,如果想让图形看起来更真实,我们就必须有足够多的顶点,从而指定足够多的颜色。这将会产生很多额外开销,因为每个模型都会需求更多的顶点,每个顶点又需求一个颜色属性。
这个时候我们就可以使用纹理(Texture)

纹理基本概念

纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节;你可以想象纹理是一张绘有砖块的纸,无缝折叠贴合到你的3D的房子上,这样你的房子看起来就像有砖墙外表了。因为我们可以在一张图片上插入非常多的细节,这样就可以让物体非常精细而不用指定额外的顶点。

下面你会看到之前教程的那个三角形贴上了一张砖墙图片。

纹理坐标

为了能够把纹理映射(Map)到三角形上,我们需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标(Texture Coordinate),用来标明该从纹理图像的哪个部分采样(译注:采集片段颜色)。之后在图形的其它片段上进行片段插值(Fragment Interpolation)。

纹理坐标在x和y轴上,范围为0到1之间(注意我们使用的是2D纹理图像)。使用纹理坐标获取纹理颜色叫做采样(Sampling)。纹理坐标起始于(0, 0),也就是纹理图片的左下角,终始于(1, 1),即纹理图片的右上角。下面的图片展示了我们是如何把纹理坐标映射到三角形上的。

我们为三角形指定了3个纹理坐标点。如上图所示,我们希望三角形的左下角对应纹理的左下角,因此我们把三角形左下角顶点的纹理坐标设置为(0, 0);三角形的上顶点对应于图片的上中位置所以我们把它的纹理坐标设置为(0.5, 1.0);同理右下方的顶点设置为(1, 0)。我们只要给顶点着色器传递这三个纹理坐标就行了,接下来它们会被传片段着色器中,它会为每个片段进行纹理坐标的插值。

纹理坐标看起来就像这样:
//纹理坐标

const GLfloat m_texture_coors[6] = {
     0.0f, 0.0f, // 左下角
     1.0f, 0.0f, // 右下角
     0.5f, 1.0f//上
};

2D纹理的坐标系是从左下角为原点向上为t轴,向右为s轴的坐标系这个坐标系的y方向和GL坐标系相反,所以默认按顶点坐标系方向输入的图像是倒置的

如果想要让纹理正向显示,我们需要做的一步工作便是将输入的纹理坐标或者顶点坐标进行y轴方向的倒置。
这些我们会在下面的示例中看到

加载图片

我们上面了解了纹理的坐标系之后,那么要做的第一件事自然是把它们加载到我们的应用中。
纹理图像可能被储存为各种各样的格式,每种都有自己的数据结构和排列,所以我们如何才能把这些图像加载到应用中呢?

我们这里采用Android上的API,

BitmapFactory.decodeResource(getResources(), R.mipmap.wall)

然后把得到的Bitmap对象传入Native进行处理

AndroidBitmapInfo info; // create a AndroidBitmapInfo
int result;
// 获取图片信息
result = AndroidBitmap_getInfo(env, bitmap, &info);
if (result != ANDROID_BITMAP_RESULT_SUCCESS) {
    LOGE("Player", "AndroidBitmap_getInfo failed, result: %d", result);
    return 0;
}
LOGD("Player", "bitmap width: %d, height: %d, format: %d, stride: %d", info.width, info.height,
 info.format, info.stride);
// 获取像素信息
unsigned char *data;
result = AndroidBitmap_lockPixels(env, bitmap, reinterpret_cast<void **>(&data));
if (result != ANDROID_BITMAP_RESULT_SUCCESS) {
    LOGE("Player", "AndroidBitmap_lockPixels failed, result: %d", result);
    return 0;
}
size_t count = info.stride * info.height;
LOGE("Player", "count: %d", count);
unsigned char *resultData = (unsigned char *) malloc(count * sizeof(unsigned char));;
memcpy(resultData, data, count);
// 像素信息不再使用后需要解除锁定
result = AndroidBitmap_unlockPixels(env, bitmap);
if (result != ANDROID_BITMAP_RESULT_SUCCESS) {
    LOGE("Player", "AndroidBitmap_unlockPixels failed, result: %d", result);
}

最后得到unsigned char *resultData指向图片的内存地址

我们可以使用stb_image.h库。这是另一个更好的解决方案,stb_image.h库是一个支持多种流行格式的图像加载库。关于stb_image.h库的更多使用方法详见StbImage

创建纹理

和之前生成的OpenGL对象一样,纹理也是使用ID引用的。让我们来创建一个:

GLuint m_texture_id = 0;
glGenTextures(1, &m_texture_id); 

glGenTextures函数首先需要输入生成纹理的数量,然后把它们储存在第二个参数的GLuint数组中(我们的例子中只是单独的一个GLuint

生成纹理之后还需要激活纹理,绑定纹理,让之后任何的纹理指令都可以配置当前绑定的纹理:

//激活指定纹理单元
//glActiveTexture(GL_TEXTURE0);
//绑定纹理ID到纹理单元
glBindTexture(GL_TEXTURE_2D, texture);

现在纹理已经绑定了,我们可以使用前面载入的图片数据生成一个纹理了。纹理可以通过glTexImage2D来生成:

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);

函数很长,参数也不少,所以我们一个一个地讲解:

  • 第一个参数指定了纹理目标(Target)。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
  • 第二个参数为纹理指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。
  • 第三个参数告诉OpenGL我们希望把纹理储存为何种格式。我们的图像只有RGB值,因此我们也把纹理储存为RGB值。
  • 第四个和第五个参数设置最终的纹理的宽度和高度。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
  • 下个参数应该总是被设为0(历史遗留的问题)。
  • 第七第八个参数定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存为char(byte)数组,我们将会传入对应值。
  • 最后一个参数是真正的图像数据。

当调用glTexImage2D时,当前绑定的纹理对象就会被附加上纹理图像。

PS
目前只有基本级别(Base-level)的纹理图像被加载了,如果要使用多级渐远纹理,我们必须手动设置所有不同的图像(不断递增第二个参数)。或者,直接在生成纹理之后调用glGenerateMipmap。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。关于多级渐远纹理我们后文会再介绍

使用纹理

上面我们已经加载了纹理,那么我们来看看怎么使用

首先一些初始化坐标

//顶点坐标
const GLfloat m_vertex_coors[9] = {
        -0.5f, -0.5f, 0.0f,//左下
        0.5f, -0.5f, 0.0f,//右下
        0.0f, 0.5f, 0.0f//左上
};
//纹理坐标
const GLfloat m_texture_coors[6] = {
        0.0f, 0.0f, // 左下角
        1.0f, 0.0f, // 右下角
        0.5f, 1.0f//上
};

接着是Vertex Shader

const char *BitmapDrawer::GetVertexShader() {
    return   "attribute vec4 aPosition; \n"//顶点坐标
             "attribute vec2 aCoordinate; \n"//纹理坐标
             "varying vec2 vCoordinate; \n"//输出纹理坐标
             "void main() \n"
             "{ \n"
             "   gl_Position = aPosition; \n"
             "   vCoordinate = aCoordinate; \n"
             "} \n";
}

对比前文的绘制三角形的Vertex Shader,我们这里多了纹理坐标

然后Fragment Shader

const char *BitmapDrawer::GetFragmentShader() {
    return "precision mediump float; \n"//配置精度
             "uniform sampler2D uTexture; \n"
             "varying vec2 vCoordinate; \n"
             "void main() \n"
             "{ \n"
             "   gl_FragColor = texture2D ( uTexture, vCoordinate); \n"
             "} \n";
}

最后是我们前文讲过的OpenGL的GLSL使用流程了,

PS
我们这里我只贴上不同于绘制三角形的代码
详细代码可查看位于文末的Github地址
void BitmapDrawer::InitVarHandler() {
    //获取变量句柄
    m_vertex_pos_handler = glGetAttribLocation(m_program_id, "aPosition");
     m_texture_pos_handler = glGetAttribLocation(m_program_id, "aCoordinate");
     m_texture_handler = glGetUniformLocation(m_program_id, "uTexture");
     //生成纹理
     if (m_texture_id == 0) {
            glGenTextures(1, &m_texture_id);
     LOGI(TAG, "Create texture id : %d, %x", m_texture_id, glGetError())
        }
        ActivateTexture();
     //绑定纹理数据
     if (cst_data != NULL) {
            glTexImage2D(GL_TEXTURE_2D, 0, // level一般为0
                         GL_RGBA, //纹理内部格式
                         m_origin_width, m_origin_height, // 画面宽高
                         0, // 必须为0
                         GL_RGBA, // 数据格式,必须和上面的纹理格式保持一直
                         GL_UNSIGNED_BYTE, // RGBA每位数据的字节数,这里是BYTE​: 1 byte
                         cst_data);// 画面数据
         //释放资源
         free(cst_data);
         cst_data = NULL;
     }
}

void BitmapDrawer::ActivateTexture() {
    GLenum type = GL_TEXTURE_2D;
     //激活指定纹理单元
    //    glActiveTexture(GL_TEXTURE0 + index);
     //绑定纹理ID到纹理单元
     glBindTexture(type, m_texture_id);
     //将活动的纹理单元传递到着色器里面
    //    glUniform1i(texture_handler, index);
     //配置边缘过渡参数
     glTexParameterf(type, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
     glTexParameterf(type, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
     glTexParameteri(type, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
     glTexParameteri(type, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}

//绘制

void BitmapDrawer::DoDraw() {
 //启用顶点的句柄
 glEnableVertexAttribArray(m_vertex_pos_handler);
 glEnableVertexAttribArray(m_texture_pos_handler);
 //设置着色器参数
//    glUniformMatrix4fv(m_vertex_matrix_handler, 1, false, m_matrix, 0);
 glVertexAttribPointer(m_vertex_pos_handler, 3, GL_FLOAT, GL_FALSE, 0, m_vertex_coors);
 glVertexAttribPointer(m_texture_pos_handler, 2, GL_FLOAT, GL_FALSE, 0, m_texture_coors);
 //开始绘制
 glDrawArrays(GL_TRIANGLE_STRIP, 0, 3);
}

最终结果如下图

image.png

上面的代码中有几处我们之前没有接触过的地方,还有存有疑惑的地方,下面我们一一解释

    //配置边缘过渡参数
     glTexParameterf(type, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
     glTexParameterf(type, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
     glTexParameteri(type, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
     glTexParameteri(type, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

纹理过滤与环绕

纹理过滤

上面我们说了纹理坐标相关的内容,但是纹理坐标不依赖于分辨率(Resolution),它可以是任意浮点值,所以OpenGL需要知道怎样将纹理像素(Texture Pixel,也叫Texel,译注1)映射到纹理坐标。当你有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。你可能已经猜到了,OpenGL也有对于纹理过滤(Texture Filtering)的选项。纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:GL_NEAREST和GL_LINEAR。

PS
Texture Pixel也叫Texel,你可以想象你打开一张.jpg格式图片,不断放大你会发现它是由无数像素点组成的,这个点就是纹理像素;注意不要和纹理坐标搞混,纹理坐标是你给模型顶点设置的那个数组,OpenGL以这个顶点的纹理坐标数据去查找纹理图像上的像素,然后进行采样提取纹理像素的颜色。

GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:

GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:

那么这两种纹理过滤方式有怎样的视觉效果呢?让我们看看在一个很大的物体上应用一张低分辨率的纹理会发生什么吧(纹理被放大了,每个纹理像素都能看到):

GL_NEAREST产生了颗粒状的图案,我们能够清晰看到组成纹理的像素,而GL_LINEAR能够产生更平滑的图案,很难看出单个的纹理像素。GL_LINEAR可以产生更真实的输出,但有些开发者更喜欢8-bit风格,所以他们会用GL_NEAREST选项。

当进行放大(Magnify)和缩小(Minify)操作的时候可以设置纹理过滤的选项,比如你可以在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。我们需要使用glTexParameter*函数为放大和缩小指定过滤方式。

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

纹理环绕

纹理坐标的范围通常是从[0, 0]到[1, 1],超过[0.0, 1.0]的范围是允许的,而对与超出范围的内容要如何显示,这就取决于纹理的环绕方式(Wrapping mode)。在OpenGL默认的行为是重复这个纹理图像(GL_REPEAT)。

下图从左到右依次是这三种效果:

环绕方式(Wrapping)描述
GL_REPEAT对纹理的默认行为。重复纹理图像
GL_MIRRORED_REPEAT但每次重复图片是镜像放置的
GL_CLAMP_TO_EDGE纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。

我们通过glTexParameteri指定Texture的环绕模式,可以看见它是可以在S,T两个方向上独立设置的。

// 设置Texutre的环绕模式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

GL_CLAMP_TO_EDGE
image.png

GL_REPEAT
image.png

GL_MIRRORED_REPEAT
image.png

纹理单元

下面我们来看这段代码

//激活指定纹理单元
//    glActiveTexture(GL_TEXTURE0 + index);
//绑定纹理ID到纹理单元
glBindTexture(type, m_texture_id);
//将活动的纹理单元传递到着色器里面
//    glUniform1i(texture_handler, index);

你可能会奇怪为什么// glUniform1i(texture_handler, index);这句代码被注释了,而且在我们的 Fragment Shader中明显有个sampler2D变量是个uniform,我们却不用glUniform给它赋值。使用glUniform1i,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。一个纹理的位置值通常称为一个纹理单元(Texture Unit)。一个纹理的默认纹理单元是0,它是默认的激活纹理单元,所以教程前面部分我们没有分配一个位置值。

纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,我们可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。就像glBindTexture一样,我们可以使用glActiveTexture激活纹理单元,传入我们需要使用的纹理单元:

glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture); 

激活纹理单元之后,接下来的glBindTexture函数调用会绑定这个纹理到当前激活的纹理单元,纹理单元GL_TEXTURE0默认总是被激活,所以我们在前面的例子里当我们使用glBindTexture的时候,无需激活任何纹理单元。

PS
OpenGL至少保证有16个纹理单元供你使用,也就是说你可以激活从GL_TEXTURE0到GL_TEXTRUE15。它们都是按顺序定义的,所以我们也可以通过GL_TEXTURE0 + 8的方式获得GL_TEXTURE8,这在当我们需要循环一些纹理单元的时候会很有用。

多级渐远纹理

上面我们队一个三角形或者矩形物体使用了纹理,但是想象一下当我们拥有很多个这样的物体,,每个物体上都有纹理。有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。由于远处的物体可能只产生很少的片段,OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了。

OpenGL使用一种叫做多级渐远纹理(Mipmap)的概念来解决这个问题,它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。让我们看一下多级渐远纹理是什么样子的:

手工为每个纹理图像创建一系列多级渐远纹理很麻烦,幸好OpenGL有一个glGenerateMipmaps函数,在创建完一个纹理后调用它OpenGL就会承担接下来的所有工作了。后面的教程中你会看到该如何使用它。

在渲染中切换多级渐远纹理级别(Level)时,OpenGL在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。就像普通的纹理过滤一样,切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用NEAREST和LINEAR过滤。为了指定不同多级渐远纹理级别之间的过滤方式,你可以使用下面四个选项中的一个代替原有的过滤方式:

过滤方式描述
GL_NEAREST_MIPMAP_NEAREST使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样
GL_LINEAR_MIPMAP_NEAREST使用最邻近的多级渐远纹理级别,并使用线性插值进行采样
GL_NEAREST_MIPMAP_LINEAR在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样
GL_LINEAR_MIPMAP_LINEAR在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样

就像纹理过滤一样,我们可以使用glTexParameteri将过滤方式设置为前面四种提到的方法之一:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 
PS
一个常见的错误是,将放大过滤的选项设置为多级渐远纹理过滤选项之一。这样没有任何效果,因为多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个GL_INVALID_ENUM错误代码。

片段(Frament Shader)纹理取样

我们再回顾一下上面的代码,

//顶点坐标
const GLfloat m_vertex_coors[9] = {
        -0.5f, -0.5f, 0.0f,//左下
        0.5f, -0.5f, 0.0f,//右下
        0.0f, 0.5f, 0.0f//左上
};
//纹理坐标
const GLfloat m_texture_coors[6] = {
        0.0f, 0.0f, // 左下角
        1.0f, 0.0f, // 右下角
        0.5f, 1.0f//上
};

在使用glTexImage2D把图片数据绑定纹理数据之后,我们到了Fragment Shader代码中用GLSL的内置函数texture2D对顶点生成的光栅化几何图形进行逐片段涂色操作

 gl_FragColor = texture2D ( uTexture, vCoordinate);

至于texture2D函数我开始也理解了很久,但是我们可以先用PS打开我们提供的超越的美图,然后放大到最大,我们就看到了下面的图片(太大了放不下,这是局部图),我们看到超越的美图是由一个个带有颜色的方格组成的,这些方格(不带有颜色的)就对应着我们前面强调的光栅化

image.png

image.png

我们可以想象一下,纹理是一张绘有图片的纸,无缝折叠贴合到你的几何图形上,这样你的几何图形看起来就像有图片外表了。因为我们可以在一张图片上插入非常多的细节,这样就可以让物体非常精细而不用指定额外的顶点。

我们只需要指定每个顶点坐标对应的纹理坐标,对纹理进行取样操作,我们就能以很少的顶点用纹理来添加物体的细节,这也是我们之后进行图片处理的基础。

小结

Github代码


YFan
272 声望923 粉丝

引用和评论

0 条评论