音视频开发 三:渲染图片纹理

  实现了GLSurfaceView绘制纯色背景图时,我们可以尝试下实现如何渲染出一张图片。
  这里需要简单介绍一个OpenGL的绘制原理。

  基本概念

  在OpenGL中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组,这导致OpenGL的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素。3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线(Graphics Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的。图形渲染管线可以被划分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。这个教程里,我们会简单地讨论一下图形渲染管线,以及如何利用它创建一些漂亮的像素。
  图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。

  有些着色器允许开发者自己配置,这就允许我们用自己写的着色器来替换默认的。这样我们就可以更细致地控制图形渲染管线中的特定部分了,而且因为它们运行在GPU上,所以它们可以给我们节约宝贵的CPU时间。OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的。

  我们绘制一个图形最基本的需要俩个着色器,一个顶点着色器(Vertex Shader),一个片段着色器(Fragment Shader),OpenGL可以绘制点,线,三角形,所以我们想要绘制任何形状的图像都可以由这三种形状拼接起来,例如我们想要一片方形的区域绘制图像,那么我们就可以用俩个三角形拼接出这个方形使用。

  顶点着色器

  先看下顶点着色器,首先看下面一张图


顶点坐标系示意图

  顶点着色器的作用是绘制顶点,,比如上面三角形的三个顶点,我们想要绘制出这个三角形,那么我们只要知道三个顶点的坐标就可以了,需要注意的是,绘制顶点的时候,顶点坐标系是上右为正,左下为负,交点为(0,0),并且范围是(-1,-1)到(1,1)之间,不可逾越,我们看下如何在代码中创建顶点坐标。

//声明坐标
float vertices[] = {
    // 第一个三角形
    1f, 1f,   // 右上角
    1f, -1f,   // 右下角
    -1f, 1f, // 左上角
    // 第二个三角形
    1f, -1f,  // 右下角
    -1f, -1f, // 左下角
    -1f, 1f,  // 左上角
    };
    private FloatBuffer vertexBuffer;
    
    //分配空间
    vertexBuffer = ByteBuffer.allocateDirect(vertexData.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(vertexData);
    vertexBuffer.position(0);

  使用数组来限定绘制范围,下面来进行内存分配,上面写了6个坐标,每个三角形三个坐标,但是我们发现左上和右下的坐标重复了,导致了额外的开销,是否可以进行简单化,OpenGL提供了索引缓冲对象(EBO),我们可以避免写这种重复坐标的情况,这里借下万里大哥的图一用,如下。


索引缓冲对象示意图

  我们可以使用四个坐标来表示俩个三角形,但是需要保证绘制俩个三角形的坐标环绕方向一致,同时顺时针或者同时逆时针,那么上面我们写的六个坐标我们就可以表示成如下:

    private float[] vertexData = {
            -1f, -1f, //A
            1f, -1f,  //B
            -1f, 1f, //C
            1f, 1f   //D
    };

  其中A,B,C表示第一个三角形,C,B,D表示第二个三角形,按照这种规则,可以避免写重复的坐标地址。

  片段着色器

  片段着色器的作用是绘制纹理,比如上面的三角形,我们绘制出了三角形,但是却是空白一片的,那些砖块一样的纹理贴图,就需要我们使用片段着色器给附着到这个空白三角形上,我们再看下下图


片段坐标系示意图

  片段着色器和顶点着色器是不同的,片段着色器范围是(0,0)到(1,1),我们在使用的时候需要进行区分。
  我们想展示一张图片,我们就需要用顶点着色器用俩个三角形拼接一个方形区域,并且将我们需要展示的图片渲染在这片区域上就可以了。
  看下如何在代码中声明片段着色器:

    //声明坐标
    private float[] fragmentData = {
            0f, 1f,
            1f, 1f,
            0f, 0f,
            1f, 0f
    };
    private FloatBuffer fragmentBuffer;

    //分配空间
    fragmentBuffer = ByteBuffer.allocateDirect(fragmentData.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(fragmentData);
    fragmentBuffer.position(0);

  和顶点着色器基本上没有区别,也可以使用EBO的写法。

  着色器程序

  写好了俩个着色器后我们需要编写着色器程序,作用就是如何使用坐标并一些列操作,着色器程序我们也可以分成顶点着色器和片段着色器程序俩种,先看顶点着色器程序,看下图:

attribute vec4 v_Position;
attribute vec2 f_Position;
varying vec2 ft_Position;
void main() {
    ft_Position = f_Position;
    gl_Position = v_Position;
}

  以上代码使用GLSL(OpenGL Shader Language)编写的,声明了三个变量,一个四维向量和2俩个二维向量,包含了attribute,vec4,varying等关键字,不知道GLSL语法的,可以先移步GLSL语法文档,上面的v_Position我们用来表示顶点坐标,f_Position表示片段坐标,ft_Position用于顶点和片段之间的传值临时变量,gl_Position是GLSL的内置变量,用于确定后的顶点坐标,再看下片段着色器程序,如下图:

precision mediump float;
varying vec2 ft_Position;
uniform sampler2D sTexture;
void main() {
    gl_FragColor=texture2D(sTexture, ft_Position);
}

  这里定义的ft_Position是由上面定义的顶点着色器程序中传值过来的,所以定义类型需要用varying,gl_FragColor是GLSL的内置变量,用于设置片段着色器的颜色。

  纹理

  当我们想要把图片等绘制到视图上,就要借助纹理来实现,比如我们现在有一个空白相框,我们想要把我们和狗的合照放上去的时候,我们可以直接放上去,那么就在空间上产生了一个位置,然后我们想放第二张照片的时候,可以放在第一张照片的上面,那么此时对于相框来说,就需要用了俩个空间位置,我们这里可以理解为俩个纹理,在使用纹理的时候,我们需要获得纹理的标识,称之为纹理id(textureId),我们想要把一张图片放上去就需要获取一个纹理id,我们看下如下代码:

        int[] textureIds = new int[1];
        //创建纹理空间
        GLES20.glGenTextures(1, textureIds, 0);
        textureid = textureIds[0];

        //将纹理和视图进行绑定
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureid);
        //激活纹理,默认不激活
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        //设置采样器对应具体的纹理单元,从0开始,依次递增
        GLES20.glUniform1i(sampler, 0);

        //设置环绕方式
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT);

        //设置过滤方式
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

        //获取图片并展示成二级纹理
        Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.androids);
        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

        //回收图片
        bitmap.recycle();
        //解绑纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

  利用大小为1的一维数组来表示一个纹理id,并且绑定到我们的视图上,并且需要设置采样器与我们使用的纹理单元一一对应,这里我们只用来一个纹理,片段着色器能访问纹理对象,但是我们怎样能把纹理对象传给片段着色器呢?GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如sampler1D、sampler3D,这里用了一个采样器sampler,sampler的声明在GLSL代码中,在上面的代码中sTexture即是,当我们使用完了纹理后,我们不使用时,仍然调用glBindTexture方法进行解绑,只是传值改成了0,中间一部分代码,我们看下:

        //设置环绕方式
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT);

  纹理环绕方式

  纹理坐标的范围通常是从(0, 0)到(1, 1),那如果我们把纹理坐标设置在范围之外会发生什么?OpenGL默认的行为是重复这个纹理图像(我们基本上忽略浮点纹理坐标的整数部分),但OpenGL提供了更多的选择


环绕方式

  当纹理坐标超出默认范围时,每个选项都有不同的视觉效果输出。我们来看看这些纹理图像的例子:


环绕示意图

还有一部分代码,如下:

        //设置过滤方式
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

  纹理过滤

  纹理坐标不依赖于分辨率(Resolution),它可以是任意浮点值,所以OpenGL需要知道怎样将纹理像素(Texture Pixel,也叫Texel,译注1)映射到纹理坐标。当你有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。你可能已经猜到了,OpenGL也有对于纹理过滤(Texture Filtering)的选项。纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:GL_NEAREST和GL_LINEAR。
  GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:


临近过滤

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


线性过滤

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

  设置好这些,我们最后执行

        //获取图片并展示成二级纹理
        Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.androids);
        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

  就可以把图片渲染在指定的二级纹理上了。
  当然,最终如果需要真正的展示出我们的图片,我们需要把我们的顶点着色器和片段着色器全部给绘制出来才可以。
  查看以下代码:



        String vertexSource = ShaderUtil.getRawResource(context, R.raw.vertex_shader);
        String fragmentSource = ShaderUtil.getRawResource(context, R.raw.fragment_shader);

        program = ShaderUtil.createProgram(vertexSource, fragmentSource);

        vPosition = GLES20.glGetAttribLocation(program, "v_Position");
        fPosition = GLES20.glGetAttribLocation(program, "f_Position");
        sampler = GLES20.glGetUniformLocation(program, "sTexture");

        ...
        ...

        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        GLES20.glClearColor(1f, 0f, 0f, 1f);

        //使用编译成功的程序链
        GLES20.glUseProgram(program);
   
        //绑定二级纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureid);
        //激活顶点坐标
        GLES20.glEnableVertexAttribArray(vPosition);
        //告知OpenGL如何采样纹理
        GLES20.glVertexAttribPointer(vPosition, 2, GLES20.GL_FLOAT, false, 8,
                vertexBuffer);

        GLES20.glEnableVertexAttribArray(fPosition);
        GLES20.glVertexAttribPointer(fPosition, 2, GLES20.GL_FLOAT, false, 8,
                fragmentBuffer);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

  编译完我们的顶点着色器代码和片段着色器代码,设置了画板的清除颜色并且使用了俩个着色器生成的程序链,然后绑定二级纹理并且激活坐标设置采样规则,顶点和片段着色器流程类似,最后调用glDrawArrays方法,从0偏移开始,绘制四个坐标,也就是俩个三角形合起来的方形,同学们可以可以尝试更改这俩个数字查看效果。

  到此我们的渲染图片纹理部分就完成了,给出这节课涉及到的代码:

public class TextureRender implements WLEGLSurfaceView.WlGLRender {

    private Context context;


    private float[] vertexData = {
            -1f, -1f,
            1f, -1f,
            -1f, 1f,
            1f, 1f
    };
    private FloatBuffer vertexBuffer;

    private float[] fragmentData = {
            0f, 1f,
            1f, 1f,
            0f, 0f,
            1f, 0f
    };
    private FloatBuffer fragmentBuffer;

    private int program;
    private int vPosition;
    private int fPosition;
    private int textureid;
    private int sampler;


    public TextureRender(Context context) {
        this.context = context;
        vertexBuffer = ByteBuffer.allocateDirect(vertexData.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(vertexData);
        vertexBuffer.position(0);

        fragmentBuffer = ByteBuffer.allocateDirect(fragmentData.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(fragmentData);
        fragmentBuffer.position(0);

    }

    @Override
    public void onSurfaceCreated() {

        String vertexSource = ShaderUtil.getRawResource(context, R.raw.vertex_shader);
        String fragmentSource = ShaderUtil.getRawResource(context, R.raw.fragment_shader);

        program = ShaderUtil.createProgram(vertexSource, fragmentSource);

        vPosition = GLES20.glGetAttribLocation(program, "v_Position");
        fPosition = GLES20.glGetAttribLocation(program, "f_Position");
        sampler = GLES20.glGetUniformLocation(program, "sTexture");


        int[] textureIds = new int[1];
        //创建纹理空间
        GLES20.glGenTextures(1, textureIds, 0);
        textureid = textureIds[0];

        //将纹理和视图进行绑定
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureid);
        //激活纹理,默认不激活
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        //设置采样器对应具体的纹理单元,从0开始,依次递增
        GLES20.glUniform1i(sampler, 0);

        //设置环绕方式
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT);

        //设置过滤方式
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

        //获取图片并展示成二级纹理
        Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.androids);
        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

        //回收图片
        bitmap.recycle();
        //解绑纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
    }

    @Override
    public void onSurfaceChanged(int width, int height) {
        GLES20.glViewport(0, 0, width, height);
    }

    @Override
    public void onDrawFrame() {

        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        GLES20.glClearColor(1f, 0f, 0f, 1f);

        GLES20.glUseProgram(program);

        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureid);
        GLES20.glEnableVertexAttribArray(vPosition);
        GLES20.glVertexAttribPointer(vPosition, 2, GLES20.GL_FLOAT, false, 8,
                vertexBuffer);

        GLES20.glEnableVertexAttribArray(fPosition);
        GLES20.glVertexAttribPointer(fPosition, 2, GLES20.GL_FLOAT, false, 8,
                fragmentBuffer);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

    }
}

  说的很多,代码其实并不复杂,看下如何使用:

public class GLTextureView extends EGLSurfaceView{

    public GLTextureView(Context context) {
        this(context, null);
    }

    public GLTextureView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public GLTextureView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setRender(new TextureRender(context));
    }
}

  GLTextureView在上一小节已经介绍过,这节课结合上节内容一起使用,即可完成图片的渲染。