13、OpenGL ES基础:渲染管线、着色器语言、纹理映射、EGL环境

好,咱们今天聊点硬核的——OpenGL ES。做Android音视频开发,这玩意儿是绕不过去的坎。说白了,它就是一套在嵌入式设备上跑3D图形的API。你手机里那些酷炫的相机滤镜、视频特效,底层基本都是它在干活。

我个人习惯把OpenGL ES比作一个「图形工厂」。你给它原材料(顶点坐标、纹理图片),它按照一套固定的流水线(渲染管线)加工,最后产出屏幕上的像素。这套流水线,就是咱们今天要啃的第一块骨头。

13.1 渲染管线:图形工厂的流水线

渲染管线,听起来玄乎,其实就是一套固定的处理步骤。我刚开始学的时候,总想跳过它直接写代码,结果代码跑出来全是黑屏。后来才明白,不理解管线,你连bug都不知道怎么调。

OpenGL ES 2.0以后的管线,是可编程的。这意味着中间有几个关键环节,你可以自己写代码(着色器)来控制。整个流程大致如下:

核心流程: 顶点数据 → 顶点着色器 → 图元装配 → 光栅化 → 片段着色器 → 逐片段测试 → 帧缓冲区

OpenGL ES 渲染管线流程图 顶点数据 顶点着色器 图元装配 光栅化 片段着色器 逐片段测试 帧缓冲区 可编程阶段 固定功能阶段

我来拆解一下这几个关键步骤:

  • 顶点着色器:处理每个顶点的位置、颜色。你想想看,一个三角形有三个顶点,这个阶段就是决定这三个点最终在屏幕上的位置。我在项目中遇到过,如果顶点坐标没处理好,整个画面会扭曲变形。
  • 图元装配:把顶点组装成点、线、三角形。说白了就是告诉GPU,这些点要连成什么形状。
  • 光栅化:把几何图形转换成像素片段。嗯,这里要注意,光栅化之后的数据是离散的像素点,不再是连续的图形了。
  • 片段着色器:决定每个像素最终的颜色。这是最灵活的阶段,滤镜、混合、纹理采样都在这里做。
  • 逐片段测试:深度测试、模板测试、裁剪测试。我曾经在这里踩过一个坑——深度测试没开,结果后面的物体把前面的挡住了,画面看起来像鬼片。

避坑指南: 我曾经在项目里遇到一个诡异的问题——画面闪烁。查了两天才发现,是顶点着色器里坐标变换矩阵算错了。记住,管线里任何一个环节出问题,最终画面都会崩。调试时建议从后往前查,先确认片段着色器没问题,再往前推。

13.2 着色器语言:GLSL 入门

着色器是用GLSL(OpenGL Shading Language)写的。这语言语法跟C语言很像,但有几个关键区别。我刚开始写的时候,总习惯用printf调试,结果发现GLSL里根本没有这玩意儿。

一个典型的顶点着色器长这样:

// 顶点着色器
attribute vec4 aPosition;    // 顶点位置
attribute vec2 aTexCoord;    // 纹理坐标
uniform mat4 uMVPMatrix;     // 变换矩阵
varying vec2 vTexCoord;      // 传递给片段着色器的纹理坐标

void main() {
    gl_Position = uMVPMatrix * aPosition;
    vTexCoord = aTexCoord;
}

片段着色器长这样:

// 片段着色器
precision mediump float;     // 精度限定符
uniform sampler2D uTexture;  // 纹理采样器
varying vec2 vTexCoord;      // 从顶点着色器传来的纹理坐标

void main() {
    gl_FragColor = texture2D(uTexture, vTexCoord);
}

这里有几个关键点:

  • attribute:每个顶点都不同的数据,比如位置、颜色。我习惯把attribute看作「每个顶点的身份证」。
  • uniform:对所有顶点都相同的数据,比如变换矩阵、时间。说白了就是全局变量。
  • varying:从顶点着色器传到片段着色器的数据。光栅化阶段会自动插值,让像素之间的过渡更平滑。
  • precision:精度限定符。移动端GPU性能有限,精度太高会拖慢速度,太低又会有画面瑕疵。我个人习惯用mediump,大部分场景够用。

注意: GLSL里没有指针,没有动态内存分配,没有递归。如果你在着色器里写了个while(true),GPU会直接卡死。我曾经见过同事把CPU上的算法直接搬进着色器,结果手机烫得能煎鸡蛋。

13.3 纹理映射:给图形穿上衣服

纹理映射,说白了就是把一张图片贴到3D模型上。你想想看,一个纯白色的三角形有什么好看的?贴上纹理之后,它就能变成照片、视频、或者任何你想要的图案。

纹理映射的核心是纹理坐标。每个顶点对应一个纹理坐标(u, v),范围是0.0到1.0。左上角是(0,0),右下角是(1,1)。

(0,0) (1,0) (0,1) (1,1) 纹理图片 u v

加载纹理的代码大致如下:

// 生成纹理对象
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_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);

// 上传纹理数据
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

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

避坑指南: 我曾经在项目里遇到纹理显示为纯色的问题。查了半天,发现是bitmap的格式不对——OpenGL ES要求纹理图片的宽高必须是2的幂次方(比如256x256、512x512)。虽然现在大部分设备支持非2的幂次方,但为了兼容性,我建议还是按这个规矩来。

13.4 EGL环境:OpenGL ES与窗口系统的桥梁

EGL,全称是Embedded Graphics Library。它负责管理OpenGL ES的上下文(Context)和显示表面(Surface)。说白了,没有EGL,你的OpenGL ES代码就不知道往哪里画。

在Android上,EGL的典型使用流程是这样的:

  1. 获取默认显示设备EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
  2. 初始化EGLEGL14.eglInitialize(display, majorVersion, minorVersion)
  3. 配置属性:选择颜色格式、深度缓冲位数等。我习惯用RGBA8888,颜色精度高一些。
  4. 创建EGL上下文EGL14.eglCreateContext(display, config, sharedContext, attribList)
  5. 创建EGL表面EGL14.eglCreateWindowSurface(display, config, surface, attribList)
  6. 绑定上下文EGL14.eglMakeCurrent(display, drawSurface, readSurface, context)
  7. 开始渲染:调用OpenGL ES API
  8. 交换缓冲区EGL14.eglSwapBuffers(display, surface)

这里有个关键点——共享上下文。如果你要在多个线程里操作OpenGL ES(比如一个线程渲染,一个线程加载纹理),就需要共享上下文。我在做视频播放器的时候,就是靠这个机制让解码线程和渲染线程协同工作的。

注意: EGL上下文是线程相关的。你不能在A线程创建上下文,然后在B线程直接使用。我曾经犯过这个错——在子线程创建了EGL上下文,然后在主线程调用OpenGL ES API,结果程序直接崩溃。记住,eglMakeCurrent必须在同一个线程调用。

嗯,到这里,OpenGL ES的基础框架就搭起来了。渲染管线是骨架,着色器是灵魂,纹理是血肉,EGL是连接外界的桥梁。这四个东西搞明白了,后面那些滤镜、特效、视频渲染,都是在此基础上添砖加瓦。


公众号:蓝海资料掘金营,微信deep3321