刚开始学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那套“纹理采样与并行着色”的世界观。我入门时也踩过不少坑但现在回头看那些黑屏和花屏反而帮我建立起了对渲染流程的敏感度拿到一个画面问题先想管线再想资源最后才想算法这个顺序基本不会错。如果你也在做类似功能一个小建议是不要一上来就追求复杂滤镜。先把一张普通图片原样显示出来确认纹理上传、着色器编译、坐标映射全部通了再去折腾灰度、反色和矩阵变换。基础链路跑通之后后续所有花活都只是片元着色器里几行数学公式的事。
阅读完成 · 觉得有帮助?