刚开始学OpenGL ES那会儿,我一直想不通一件事:系统明明已经提供了很便捷的图像接口,为什么还要绕一大圈,写顶点着色器、片元着色器,再手动把一张图片铺到屏幕上?直到后来接手一个图片处理Demo,需要在纹理上做滤镜、做旋转、做局部亮度调整,我才意识到,系统级图片加载只是“读文件”,而OpenGL ES里的“加载”其实是把图片变成GPU可以采样的纹理,再交给GLSL去重新绘制。这个思路一旦通了,后面所有图形渲染的框架就都能串起来。
这篇是《初探OpenGLES》系列的第三篇,重点就是“使用GLSL加载图片”。我会从纹理数据如何进入GPU开始讲,中间穿插顶点着色器和片元着色器的写法,最后把常见黑屏、倒置、花屏问题一并拆开说清楚。适合那种已经跑通“画一个三角形”或是“画一个立方体”的新手,也适合刚接触着色器、但连纹理API参数都没吃透的朋友。
1. 为什么“GLSL加载图片”不是让着色器去读文件
很多第一次接触OpenGL ES的人听到“用GLSL加载图片”,下意识以为是要在着色器里写类似fopen的代码,直接把磁盘里的JPG读出来。实际上着色器程序运行在GPU上,它根本没有任何文件系统访问能力,也没有解码JPEG、PNG的能力。图片的字节数据必须在CPU侧完成解码,再上传到GPU显存里,着色器做的只是“采样”和“着色”而已。
这就牵扯出一个很关键的概念:纹理(Texture)。在OpenGL ES体系里,图片不是以“文件”形式存在的,而是以“纹理”的形式被绑定到一个纹理对象上。你可以把纹理想象成一块贴满马赛克的大布,每个马赛克叫做纹素(Texel)。着色器在绘制时,会把屏幕上的每个像素点映射到这块布的某个位置,然后取那个位置的纹素颜色作为输出。整个过程是并行计算,屏幕上几百万个像素同时取色、同时计算,这也是为什么图像处理在GPU上可以做到非常流畅。
1.1 从文件到纹理,中间隔着解码和上传
如果只是用系统自带API去显示一张图片,开发者通常不需要关心图片原始像素是什么格式,因为解码器已经把结果封装好了。但走上OpenGL ES这条路后,你至少要接触两个阶段:解码和上传。
解码阶段,一般是BitmapFactory这类API把PNG、JPEG变成一维的像素字节数组。这些字节是有顺序的,每一行像素从左到右排列,每一行之间紧密相连,形成一条很长的内存带。OpenGL ES要求纹理数据是连续的内存,所以解码出来的字节数组格式必须可控。我建议统一转换成Bitmap.Config.ARGB_8888,对应OpenGL里的GL_RGBA和GL_UNSIGNED_BYTE,这是最省心的组合。有些图片为了省内存会转成RGB_565,在纹理上传时就得用GL_RGB,虽然不是不行,但很多GLSL采样器还需要额外处理通道顺序,新手阶段没必要给自己加难度。
上传阶段,调用glTexImage2D把像素数组塞进当前绑定的纹理对象。这个函数一次上传的是整个纹理层级,不需要像视频解码那样流式提交。上传完成后,CPU里的原始字节数组就可以释放了,后续所有读取都发生在GPU端。
1.2 GPU管线的分工:顶点阶段与片元阶段各管什么
GLSL加载图片和普通的图片显示最大的不同,在于你拥有完全的控制权,而控制权就来自管线里两个着色器。
顶点着色器负责几何位置。你想把图片显示在屏幕的哪个区域,是用一个全屏四边形,还是只占用左上角一小块,全由顶点的坐标决定。它不参与取色,只负责告诉GPU“这些三角形的顶点在什么位置”,以及把对应的纹理坐标传递给下一阶段。
片元着色器则负责颜色输出。对于三角形覆盖的每个像素,GPU都会执行一次片元着色器。我们通常会在里面声明一个sampler2D类型的uniform采样器,再用内建的texture()函数从纹理对象里取出颜色,最后赋给gl_FragColor。
这里有个极容易踩的误区:很多人觉得图片显示出来是“系统渲染”的,实际上在OpenGL ES世界里,图片一进GPU就是一堆RGBA数字。你想把图片变暗、变灰、局部模糊,无非是在片元着色器里对这些数字做数学操作。当你看透这一点,就能明白为什么后来的滤镜都爱写自定义GLSL——因为着色器的输出就是像素的最终颜色,改一行代码等于改整个画面。
2. 把图片变成GPU认得的纹理数据:核心API逐个拆开
这一节是新手绕不开的第一个大坑。代码看似简单,其实每一个参数背后都有讲究,拼接起来之后整个纹理系统才正确运转。
2.1 纹理对象的创建与绑定时机
纹理对象可以理解成一个带编号的GPU资源句柄。CPU侧先要一个ID,再把这个ID绑定到某一个纹理单元上。我见过有人每帧都调用glGenTextures去重新生成纹理ID,这绝对是错误用法。纹理对象应该在初始化阶段只生成一次,后续帧内只需要重新绑定同一个ID即可。
典型的初始化代码如下:
int[] textureId = new int[1]; GLES20.glGenTextures(1, textureId, 0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId[0]);绑定在这里的意思是“让后续纹理操作作用于这个对象”。不绑定就直接上传纹理数据,通常不会报错,但数据可能并没有进入你预期的纹理对象,画面自然出不来。绑定完成后,设置纹理的缩放过滤和包裹方式,再进行数据上传。
2.2 glTexImage2D参数逐项讲解
GLES20.glTexImage2D( GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, bitmapPixels );第一个参数必须是GL_TEXTURE_2D,这是一张二维纹理。第二个参数是多级渐远纹理的层级,0表示只上传基础层级,现在大部分场景用不到多级纹理。第三个参数是纹理内部格式,会被GPU用来分配存储空间和处理颜色,务必用GL_RGBA。第四、第五参数是图片宽高,这里特别要提醒:宽高必须是2的幂次方吗?在OpenGL ES 2.0以上,非2的幂纹理也被广泛支持,但前提是纹理过滤方式不能使用需要多级纹理的GL_LINEAR_MIPMAP_LINEAR。如果你不确定,最稳妥的做法是用单级过滤GL_LINEAR或GL_NEAREST。
第六个参数是border,必须填0,这是OpenGL ES遗留的历史参数。第七、第八个参数分别指CPU侧像素数组的格式与数据类型。第九个参数就是解码出来的像素数组。
有一种很奇怪的表现:图片上传后显示出来颜色错乱,绿色变成紫色,很可能是因为CPU侧用的是Bitmap.Config.ARGB_8888,但你把上传格式写成了GL_RGB。一个像素四字节,硬件却只读三字节,数据错位后当然会出现颜色诡异的状态。
2.3 纹理过滤与包裹方式:影响画面质量的两组参数
glTexParameteri决定了两件事:纹理缩放时如何采样新像素,以及纹理坐标超出0到1范围后怎么处理。
放大过滤用GL_LINEAR会做双线性插值,边缘过渡自然,缺点是略微模糊。缩小过滤用GL_LINEAR也能获得柔和的降采样效果,但成本稍高。对于显示图片、做UI贴图的场景,我的建议是统一使用GL_LINEAR。GL_NEAREST适合像素风格游戏,它不做插值,放大后会出现明显的锯齿,效果反而更符合复古题材。
包裹方式上,GL_CLAMP_TO_EDGE比GL_REPEAT更适合普通图片显示,因为纹理坐标恰好落在边界上时,GL_REPEAT可能出现一条微弱的边线或整体偏移,而GL_CLAMP_TO_EDGE会把边界外的采样强制约束在纹理边缘像素。
3. 两个着色器里到底写了什么:GLSL代码逐行解读
图像要出现在屏幕上,至少要有一个绘制用的几何体。最常用的方案是画两个三角形拼成一个矩形,然后把整张图片作为纹理贴上去。这个矩形就是“图像显示区域”。
3.1 顶点着色器:传递位置,也必须传递纹理坐标
先看顶点着色器:
attribute vec4 aPosition; attribute vec2 aTexCoord; uniform mat4 uMatrix; varying vec2 vTexCoord; void main() { gl_Position = uMatrix * aPosition; vTexCoord = aTexCoord; }这里的aTexCoord是每个顶点对应的纹理坐标,取值范围通常是0到1。纹理坐标和顶点坐标不是同一个坐标系,顶点坐标表示几何体的位置,纹理坐标表示要采样的位置,所以我习惯让它们分开传递,互不干扰。
顶点着色器里有几件事是必须做的:把输入的顶点位置乘以矩阵,得到裁剪坐标系里的最终位置;把纹理坐标原样传到片元着色器。为什么不是直接在片元着色器里写死常量?因为一个四边形有四个顶点,每个顶点的纹理坐标不同,你可能希望图片翻转、镜像、只显示局部区域,这些操作本质都是调整纹理坐标,写死就没法灵活控制了。
3.2 片元着色器:像素输出的终审法官
片元着色器是这个流程里最重要的角色:
precision mediump float; uniform sampler2D uTexture; varying vec2 vTexCoord; void main() { gl_FragColor = texture2D(uTexture, vTexCoord); }sampler2D就是纹理采样器的类型。在OpenGL ES 2.0里,采样纹理用的是texture2D()函数,到了3.0以上则推荐texture()。函数第一个参数是纹理对象,第二个参数是纹理坐标,返回一个vec4颜色值,前三个分量是RGB,第四个是Alpha。
这是最简单的写法,但实际项目中很少直接用原始颜色。比如给图片增加灰度效果,可以在采样后做一次线性变换:
vec4 color = texture2D(uTexture, vTexCoord); float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); gl_FragColor = vec4(gray, gray, gray, color.a);理解了片元着色器“输出即像素”的原理,就可以自由解锁各种滤镜玩法。灰度的本质是用加权和替代原始RGB;反色是1.0 - color.rgb;颜色变暖是分别调整RGB分量的倍率。我建议新手把这些简单效果都实现一遍,比看十篇原理文章都有用。
3.3 编译GLSL最容易犯的三个错误
着色器是字符串,运行时才编译,所以错误提示不像普通Java代码那样直接标红。我刚开始做的时候,平均每次调试都要花十分钟去找错位的地方。常见的坑有三个。
第一个是精度声明缺失。片元着色器必须写precision mediump float;,这是OpenGL ES 2.0的硬性要求,如果漏掉,部分GPU直接编译失败,部分GPU则默认低精度,画面出现明显色块。第二个是把gl_FragColor误写成gl_FragColor = vec4(1.0),这个四维向量必须填四个分量。第三个是uniform变量声明了但从未激活,glGetUniformLocation返回-1,上传时没有报错,但着色器里根本没用到这个值,画面状态自然不对。
编译完之后,最好用glGetShaderiv和glGetShaderInfoLog打印日志。我习惯把编译成功的判断直接接入初始化流程,一旦失败就抛出异常并输出日志,避免留一个“黑屏但无报错”的诡异状态给后续排查。
4. 从纹理到屏幕:完整渲染一帧的正确顺序
代码写好之后,最关键的问题就是在一个渲染循环里把事情按顺序做对。很多人贴图失败,不是着色器写得不对,而是渲染顺序混乱,导致GPU执行时绑定的对象不是预期那一个。
4.1 渲染上下文与主线程的关系
OpenGL ES的API必须在拥有GL上下文的线程中调用。移动端最常见的做法是把GLSurfaceView与Renderer配合使用,系统会单独开一条渲染线程,并在onSurfaceCreated、onSurfaceChanged、onDrawFrame三个回调里给出确定的调用时机。
自己手动创建EGL上下文也行,但要小心:GL操作和UI线程的线程不一致时,纹理ID的可见性往往是问题根源。比如你在主线程创建了纹理,却在渲染线程绑定,很可能得到0号纹理的空引用。GLSurfaceView的优点是这些同步问题都帮你处理好了,代价是你必须在回调里完成所有GL操作。
渲染模式有两个选择。RENDERMODE_CONTINUOUSLY会持续不断重绘,适合需要动画的场景。如果只是显示静态图片,建议设置成RENDERMODE_WHEN_DIRTY,只在数据变化时请求渲染,能降低不少功耗。
4.2 onSurfaceCreated阶段:只做一次性初始化
纹理创建、着色器编译、缓冲对象分配都应该放在onSurfaceCreated里,因为此阶段相当于上下文的初始化时刻。这里有个很容易忽略的问题:纹理的加载和解码不能放在主线程里做,否则首次启动会卡顿。我通常把BitmapFactory的调用放到异步线程,解码完成后再切回渲染线程做glTexImage2D。
加载纹理的完整过程可以打包成一个方法,大致流程是:
- 生成纹理ID并绑定。
- 设置
GL_LINEAR过滤和GL_CLAMP_TO_EDGE包裹方式。 - 用
glTexImage2D上传像素数据。 - 解绑纹理,把纹理ID存下来。
这一步结束后,纹理已经存在于GPU显存中。后续绘制时只需要重新绑定这个纹理ID即可。
4.3 onDrawFrame阶段:绑定点就是这条帧的命门
每一帧通常要做的事按以下顺序排列:
- 清屏:
glClearColor和glClear把画布重置成指定颜色。 - 绑定着色器程序。
- 绑定纹理:
glActiveTexture(GL_TEXTURE0)和glBindTexture。 - 将纹理单元编号传给对应的uniform采样器。
- 绑定顶点数组缓冲区和顶点属性。
- 调用
glDrawArrays或glDrawElements触发绘制。 - 交换缓冲区,显示画面。
这里的关键是“顺序”。绑定点一旦错位,glDrawArrays可能用的是上一个纹理的ID,或者根本没有纹理绑定,black screen就来了。
4.4 一张图渲染成正方形:矩阵与视口比例的处理办法
如果直接把四边形顶点坐标写成(-1,-1)到(1,1),在竖屏设备上图片会被拉伸到整个GL坐标系范围,比例尽失。解决办法是用正交投影矩阵把坐标映射到宽高等比例的区域。
假设图片宽度为imgW,高度为imgH,屏幕可用区域为viewW和viewH,正交矩阵可以这样计算:
float aspectRatio = imgW / imgH; float viewAspect = viewW / viewH; float left, right, bottom, top; if (aspectRatio > viewAspect) { left = -1f; right = 1f; bottom = -1f / aspectRatio; top = 1f / aspectRatio; } else { left = -1f * aspectRatio; right = 1f * aspectRatio; bottom = -1f; top = 1f; } Matrix.orthoM(matrix, 0, left, right, bottom, top, -1f, 1f);这样图片无论是横屏还是竖屏,都能保持比例显示,不会变形。这段逻辑虽然简单,却是做图片查看器、贴纸功能的基础。真正要把图片完全铺满全屏,其实只要让顶点坐标覆盖整个NDC即可,去掉矩阵也可以,但灵活性就差很多。
5. 图片显示不出来:我总结过一张排查清单
几乎每一个OpenGL ES新手都会遇到图片显示不出来的问题。我印象最深的一次,图明明加载成功,但屏幕上只显示一片黑色,我检查了半天的数据、坐标,最后发现问题是着色器里采样器绑定的纹理单元和glActiveTexture不一致。
5.1 黑屏问题:优先级最高的五项检查
黑屏是最常见的现象,它的排查顺序可以固定下来:
先看GLSL编译日志,确认两个着色器都编译成功;再看glGetUniformLocation的返回值,是不是负一;然后确认glUseProgram已经调用;接着检查纹理ID是不是零,glTexImage2D是否真的上传了数据;最后检查纹理单元是否设置为TEXTURE0并且采样器的值写的是0。
我曾经为了检查方便,把这五项列成一个小表放在代码注释里,每排查一项就划掉一项。这个习惯特别管用,因为OpenGL ES的错误不是显式的,绝大多数坑都藏在这些静默失败里。
| 现象 | 可能原因 | 首选检查点 |
|---|---|---|
| 全屏一片黑 | 着色器未编译成功 | 读取着色器日志 |
| 黑屏但有顶点运动 | 纹理未绑定或采样器无效 | 检查纹理ID与uniform位置 |
| 黑屏但有UI元素 | 渲染循环未触发 | 检查GLSurfaceView渲染模式 |
| 画面是纯色块 | 纹理坐标被清零 | 检查顶点属性是否启用 |
5.2 图像倒置与图像变形:坐标系统的边界问题
图片倒置这个问题,几乎所有人都见过。原因在于OpenGL ES的传统坐标系里,纹理坐标原点在左下角,越往上Y值越大;而日常图片解码后的第一行像素往往是图片的顶部。如果不做处理,两者直接映射,图片就会上下颠倒。
最简单的解决办法是在上传纹理前把图片翻转一下。第二种办法是在顶点着色器里把纹理坐标的Y再翻转一次,比如vTexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);。两种方案效果相同,我习惯用后者,因为不用动像素数组,一行代码解决。
变形问题则大多出在glViewport不一致。GL窗口坐标宽度和高度如果不匹配,图片就会被拉伸。建议在onSurfaceChanged回调里用实际surface的宽高去调用glViewport(0, 0, width, height),千万别用某个逻辑分辨率去固定它。
5.3 性能上的三个小教训
纹理并不是越小越好,但也不是越大越清晰。移动GPU的纹理带宽有限,把一张4096的大图直接全屏显示,某些低端设备明显掉帧。最有效的做法是在解码阶段就压缩到显示尺寸,而不是上传后再靠GPU缩放。
纹理过滤选择上,静态图用GL_LINEAR,缩放动画如果用GL_NEAREST会有明显的锯齿闪烁。重复帧渲染时,不要每帧都重新调用glTexImage2D,这个操作的带宽消耗远高于一次glDrawArrays。只有画面内容变化时,才需要重新上传或更新纹理局部区域。
内存层面,Bitmap解码后如果不及时recycle(),在高分辨率图片场景下会瞬间占用几十兆内存。纹理上传完成后,原始Bitmap的内存就不需要保留了,尽量在确保没有复用需求时主动释放,避免把性能问题拖到后续帧里。
结尾
写到底,我觉得“使用GLSL加载图片”这件事,真正的门槛不在于几行API调用,而在于你能不能跳出“文件加载”的思维,接受GPU那套“纹理采样与并行着色”的世界观。我入门时也踩过不少坑,但现在回头看,那些黑屏和花屏反而帮我建立起了对渲染流程的敏感度:拿到一个画面问题,先想管线,再想资源,最后才想算法,这个顺序基本不会错。
如果你也在做类似功能,一个小建议是不要一上来就追求复杂滤镜。先把一张普通图片原样显示出来,确认纹理上传、着色器编译、坐标映射全部通了,再去折腾灰度、反色和矩阵变换。基础链路跑通之后,后续所有花活都只是片元着色器里几行数学公式的事。