OpenGL ES 2.0 —— 纹理绘制

 

前言

通过前面 2D 图像绘制的学习, 让我们对 GL 渲染图像有了一定的了解

作为非游戏开发者, 我们很少会自己绘制图形, 更多的是去绘制一张 2D 的图片, 通过纹理便可以实现这一功能

纹理的定义如下

纹理可以理解为 2D 的贴纸, 我们可以通过这个贴纸, 将纹理图像映射到 OpenGL 的图形上

接下来探索一下纹理相关的知识点, 最终提供一个 fitCenter 和 centerCrop 的实现

一. 纹理坐标系统

纹理坐标系统, 即针对于纹理画布建立的坐标系统, 其样式如下

纹理坐标系统

纹理的坐标系为 TSR 坐标系, 2D 纹理只用到 TS, 其中 1 描述加载的纹理的初始大小, 与 GL 坐标系一样, 是一个归一化的值

二. 纹理的环绕

纹理环绕触发场景

纹理原始的范围通常是[0, 1], 当我们选取纹理的区域超过了原始范围, 则会触发纹理的环绕效果

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

一) 视觉效果

纹理环绕视觉效果

二) 操作 API

// 设置 S 为 GL_REPEAT
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT);
// 设置 T 为 GLREPEAT
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT);

三. 纹理的过滤

纹理的过滤

将纹理映射到 GL 图形上时, 可能会对纹理进行缩放, 这个过程称之为纹理过滤(类似于图片的采样操作), GL 提供了纹理过滤的选项, 主要有 GL_LINEAR 和 GL_NEAREST

一) GL_LINEAR(线性过滤)

GL_LINEAR(也叫线性过滤,(Bi)linear Filtering) 它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。

一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。

下图中你可以看到返回的颜色是邻近像素的混合色:

GL_LINEAR

二) GL_NEAREST(邻近过滤)

GL_NEAREST 是 OpenGL 默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。

下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:

GL_NEAREST

三) 操作 API

// 设置缩小过滤
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);

四. 纹理绘制实战

这里以绘制 Bitamp 为例

一) 生成纹理

public class TextureRenderer implements GLSurfaceView.Renderer {

    /**
     * 创建纹理
     */
    private int createTextureFromRes(int resId) {
        // 生成绑定纹理
        int[] textures = new int[1];
        GLES20.glGenTextures(1, textures, 0);
        int textureId = textures[0];
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
        // 设置环绕方向
        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 生成 2D 纹理
        Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(), resId);
        GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
        // 解绑
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
        return textureId;
    }  
    
}

二) 定义坐标

public class TextureRenderer implements GLSurfaceView.Renderer {

    /**
     * 矩形顶点坐标(定义在 GL 世界坐标系中)
     */
    private float[] mRectCoords = new float[]{
            // 矩形顶点坐标
            -1f, 1f,     // 左上
            -1f, -1f,    // 左下
            1f, 1f,      // 右上
            1f, -1f,     // 右下
    };
    private FloatBuffer mRectCoordsBuffer;

    /**
     * 取纹理区域的坐标(原始纹理的范围为 [0, 1], 我们可以指定取纹理的哪些部分)
     * <p>
     * 因为要贴到上面定义的矩形上, 因此其位置要与矩形的顶点一一对应
     * <p>
     * 图片的 Y 轴通常是向下的, 我在取我们所需部分时, 需要上下颠倒一下映射, 这样可以保证纹理映射到矩形上时正常的
     */
    private float[] mTextureCoords = new float[]{
            0f, 0f,          // 纹理左下
            0f, 1f,          // 纹理左上
            1f, 0f,          // 纹理右下
            1f, 1f           // 纹理右上
    };
    private FloatBuffer mTextureCoordsBuffer;
    
    public TextureRenderer(Context context) {
        this.mContext = context;
        // 初始化顶点坐标
        mRectCoordsBuffer = ByteBuffer.allocateDirect(mRectCoords.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(mRectCoords);
        mRectCoordsBuffer.position(0);
        // 初始化纹理顶点坐标
        mTextureCoordsBuffer = ByteBuffer.allocateDirect(mTextureCoords.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(mTextureCoords);
        mTextureCoordsBuffer.position(0);
    }
    
}

这里选取的纹理坐标有两个点需要注意

  • 纹理坐标一般情况下需要保证与矩形的顶点坐标的顺序一致, 否则会导致映射偏差
  • 我们这里纹理与 GL 图形映射时, 对纹理坐标进行了上下颠倒的处理
    • 这是因为一般图片的坐标 Y 轴是向下的, 因此在其读入内存生成纹理时, 在纹理坐标系上图形就已经上下颠倒了, 我们在选取的纹理顶点与 GL 图形进行映射时再颠倒一次就能够保证显示效果的正常了

图片映射到纹理

三) 定义着色器

1. 顶点着色器

// 定义一个属性,图形顶点坐标
attribute vec4 aShapeCoords;
// 定义一个属性,纹理顶点坐标
attribute vec2 aTextureCoords;
// 顶点裁剪矩阵
uniform mat4 uMatrix;
// varying 可用于相互传值
varying vec2 vTextureCoords;
void main() {
    // 暂存纹理的坐标, 到片元着色器中使用
    vTextureCoords = aTextureCoords;
    // gl_Position 为内置变量, 根据 u_Matrix 计算出裁剪坐标系的位置
    gl_Position = aShapeCoords * uMatrix;
}

2. 片元着色器

// 着色器纹理扩展类型
#extension GL_OES_EGL_image_external : require
// 设置精度,中等精度
precision mediump float;
// varying 可用于相互传值
varying vec2 vTextureCoords;
// 2D 纹理 ,uniform 用于 application 向 gl 传值
uniform sampler2D uTexture;
void main() {
    gl_FragColor = texture2D(uTexture, vTextureCoords);//进行纹理采样,拿到当前颜色
}

三) 定义变换矩阵

通过上面的 GL 矩形是坐标可知, 它是填充整个 View 的, 若是纹理直接映射到 GL 矩形上, 可能会出现比例失调的问题, 也就是 ImageView 中的 fitXY 的 ScaleType

因此我们需要使用投影矩阵, 来矫正视觉观感, 下面给出 fitCenter 和 centerCrop 的投影实现

1. fitCenter 投影

fitCenter 投影

可以看到 fitCenter 的投影面是大于 GL 矩形的, 以保证 GL 矩形能够以正常的比例全部显示在投影平面内, 因为矩形的大小与纹理是对应的, 因此

public class TextureRenderer implements GLSurfaceView.Renderer {
    
    /**
     * 定义投影矩阵
     * <p>
     * 这里省略了视图矩阵, 我们初始坐标系即观察者坐标系
     */
    private final float[] mProjectionMatrix = new float[16];
    
    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        ......
        // 2. 实现 fitCenter
        // 获取比例
        BitmapFactory.Options opts = new BitmapFactory.Options();
        opts.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(mContext.getResources(), mResId, opts);
        // 计算 Bitmap 的宽高比
        float aspectBitmap = opts.outWidth / (float) opts.outHeight;
        // 计算当前画布 Surface 的宽高比
        float aspectSurface = width / (float) height;
        // fitCenter
        fitCenter(aspectSurface, aspectBitmap);
    }
    
    private void fitCenter(float aspectPlane, float aspectTexture) {
        float left, top, right, bottom;
        // 1. 纹理比例 > 投影平面比例
        if (aspectTexture > aspectPlane) {
            left = -1;
            right = 1;
            top = 1 / aspectPlane * aspectTexture;
            bottom = -top;
        }
        // 2. 纹理比例 < 投影平面比例
        else {
            left = -aspectPlane / aspectTexture;
            right = -left;
            top = 1;
            bottom = -1;
        }
        Matrix.orthoM(
                mProjectionMatrix, 0,
                left, right, bottom, top,
                1, -1
        );
    }
    
}

2. centerCrop

centerCrop 投影图解

centerCrop 与 fitCenter 不同, 它的投影平面小于纹理, 以保证 GL 矩形铺满整个平面

public class TextureRenderer implements GLSurfaceView.Renderer {
    
    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        ......
        // 获取比例
        BitmapFactory.Options opts = new BitmapFactory.Options();
        opts.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(mContext.getResources(), mResId, opts);
        // 计算 Bitmap 的宽高比
        float aspectBitmap = opts.outWidth / (float) opts.outHeight;
        // 计算当前画布 Surface 的宽高比
        float aspectSurface = width / (float) height;
        // centerCrop
        centerCrop(aspectSurface, aspectBitmap);
    }
    
    private void centerCrop(float aspectPlane, float aspectTexture) {
        float left, top, right, bottom;
        // 1. 纹理比例 > 投影平面比例
        if (aspectTexture > aspectPlane) {
            left = -aspectPlane / aspectTexture;
            right = -left;
            top = 1;
            bottom = -1;
        }
        // 2. 纹理比例 < 投影平面比例
        else {
            left = -1;
            right = 1;
            top = 1 / aspectPlane * aspectTexture;
            bottom = -top;
        }
        Matrix.orthoM(
                mProjectionMatrix, 0,
                left, right, bottom, top,
                1, -1
        );
    }
    
}

四) 纹理的绘制

纹理的绘制流程就比较简单了

public class TextureRenderer implements GLSurfaceView.Renderer {
    
     @Override
    public void onDrawFrame(GL10 gl) {
        // 清屏并绘制白色
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        GLES20.glClearColor(1f, 1f, 1f, 1f);
        // 绘制纹理
        // 激活 Program
        GLES20.glUseProgram(mProgram);
        // 写入顶点坐标数据
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, mVboId);
        GLES20.glEnableVertexAttribArray(aShapeCoords);
        GLES20.glVertexAttribPointer(aShapeCoords, 2, GLES20.GL_FLOAT, false, 8, 0);

        // 写入纹理坐标数据
        GLES20.glEnableVertexAttribArray(aTextureCoords);
        GLES20.glVertexAttribPointer(aTextureCoords, 2, GLES20.GL_FLOAT, false, 8,
                mRectCoords.length * 4);
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);

        // 写入裁剪矩阵数据
        GLES20.glUniformMatrix4fv(uMatrix, 1, false, mProjectionMatrix, 0);

        // 激活纹理
        GLES20.glUniform1i(uTexture, 0);
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId);

        // 执行绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

        // 解绑纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
    }
    
}

绘制展示

fitCenter

fitCenter 效果展示

centerCrop

centerCrop 效果展示

总结

从纹理的实战中可以看到, 纹理的绘制比起图形的绘制更有趣, 其中需要掌握

  • 纹理的坐标
    • TSR
  • 环绕方式
    • GL_REPEAT: 对纹理的默认行为。重复纹理图像。
    • GL_MIRRORED_REPEAT: 和GL_REPEAT一样,但每次重复图片是镜像放置的。
    • GL_CLAMP_TO_EDGE: 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。
    • GL_CLAMP_TO_BORDER: 超出的坐标为用户指定的边缘颜色。
  • 过滤方式
    • GL_LINEAR(线性过滤)
    • GL_NEAREST(邻近过滤)
  • 纹理的绘制
    • 图像读入纹理坐标
    • 从纹理坐标选取所需的区域
    • 将选取的区域映射到 GL 图形

通过手写了两个非常常用的 fitCenter 和 centerCrop 效果的实现, 对坐标系统有了更加深刻的理解, 这对后面自定义相机的实战有着非常大的帮助

参考文献