
简介面向 Android 图形与性能优化开发者的 OpenGL ES 3.0 通用计算学习包完整覆盖 Compute Shader 从创建、编译链接到 Work Group 分配、glDispatchCompute 调用、Buffer/Texture 数据交换及 Memory Barrier 同步的移动端应用链路并附带 Java 层示例代码与性能优化建议适合需要将物理模拟、图像处理等计算密集任务迁移至 GPU 的开发者参考。资源共 1091 个文件压缩包仅 5.58MB以 Java 工程源码、gradle/xml 配置和 GLES30 调用代码为主同时包含编译生成的 class/dex/jar、txt 说明、properties 资源及 apk 安装包便于对照代码理解构建与运行过程。目前已有 1355 人学习。除基础 API 用法外资料还重点关注实际部署中的内存对齐、缓存策略和同步细节能让读者在 Android 端快速搭建 Compute Shader 调试环境并评估性能收益。Android端用Compute Shader加速计算从选型到落地的完整实操移动端App一旦碰到大批量浮点运算、图像处理、物理模拟这类任务CPU很多时候是真的扛不动。我最早在项目里遇到这个瓶颈是做实时滤镜特效和粒子系统主线程算一帧要卡掉好几毫秒iOS那边用Metal的compute pipeline跑得飞起Android这边却一直在纠结选哪个方案。后来切换到了OpenGL ES 3.1引入的compute shader才算真正踏实下来。这东西说白了就是让GPU去干通用计算而不是只干渲染的活儿。Android从API 21开始支持GLES 3.1也就是Android 5.0往上覆盖了绝大多数在用的设备。这篇文章适合遇到过性能瓶颈、想在Android上把手动优化到极致的人阅读。如果你是刚接触GPU计算的也能从零开始看懂compute shader怎么写、怎么调试、怎么接入现有工程。我会把一个完整可运行的最简示例拆开讲把GLES31里那些绕来绕去的API、内存屏障、数据对齐规则都说清楚再补上我在真机和模拟器上踩过的各种坑。1. 为什么是compute shader移动端并行计算的选型逻辑1.1 三套主流方案的取舍Android上能用的并行计算方案现在主流就三个RenderScript、OpenCL、compute shader。RenderScript已经被官方标记为废弃新项目基本不该碰老项目也建议迁移。OpenCL在部分移动GPU上有驱动支持但它不是Android官方推荐的公共API不同芯片厂商的OpenCL驱动版本参差不齐做产品级兼容要耗费大量精力。compute shader是OpenGL ES 3.1标准的一部分虽然不是官方推荐但是Android系统层面会做统一转发只要设备支持GLES 3.1就一定能用compute shader。它和图形管线的资源模型是同一套能和渲染程序共享缓冲区、纹理省去了跨API拷贝数据的开销。这一点在实际项目里价值非常大因为绝大多数需要GPU计算的场景最终结果要么显示在屏幕上要么参与后续渲染。1.2 Compute Shader的适用场景和边界不是所有任务都适合丢给GPU。compute shader擅长的是“同一条指令处理大量数据”的并行任务典型的比如图像像素处理、滤镜卷积、粒子位置更新、物理碰撞检测、矩阵运算。而逻辑强依赖、分支复杂、或者数据量太小的任务扔到GPU上反而更慢。举个简单的判断标准如果单次要处理的数据量不到几千个元素CPU和GPU的启动开销、同步开销就足够吃掉加速收益了。我在项目里实测过处理一张分辨率为1080p的图像做灰度化GPU优势非常明显但如果只是处理几百个粒子的位置更新CPU和GPU的差异反而不大甚至CPU更快。2. 环境准备与最小可运行示例2.1 开发环境与硬件要求Android工程层面只需要确保两点SDK版本在21以上以及项目里用的是GLES31类而不是GLES20。如果用的是C/OpenGL需要链接GLES3的库在CMake里链接GLESv3确保头文件包含的是GLES3/gl31.h。硬件门槛也不高Adreno 400系列以上的高通芯片、Mali T760以上的ARM芯片、PowerVR 6系列以上都能跑。唯一要注意的是模拟器Android Studio默认的模拟器用的是SwiftShader软件渲染器它对compute shader的支持很差经常是shader编译过了但运行结果不对或者直接报错。这已经是老问题了跑compute shader建议直接上真机。2.2 第一个Compute Shader示例数组平方计算这里我写一个最小但完整的示例目标是给一个float数组的每个元素做平方运算。代码用KotlinGLES31不需要GLSurfaceView直接创建离屏EGL上下文就能跑这样更容易被内嵌到普通业务代码里。#version 310 es layout(local_size_x 64, local_size_y 1, local_size_z 1) in; layout(std430, binding 0) buffer InputBuf { float inputData[]; }; layout(std430, binding 1) buffer OutputBuf { float outputData[]; }; void main() { uint index gl_GlobalInvocationID.x; outputData[index] inputData[index] * inputData[index]; }这段shader做了几件事声明每个工作组有64个线程绑定两个SSBO到binding 0和1每个线程处理一个元素。gl_GlobalInvocationID.x是全局线程索引它等于workGroupID * workGroupSize localInvocationID这个索引会贯穿整个dispatch过程。Kotlin侧的核心代码如下private fun buildComputeProgram(): Int { val src #version 310 es layout(local_size_x 64) in; layout(std430, binding 0) buffer InputBuf { float inputData[]; }; layout(std430, binding 1) buffer OutputBuf { float outputData[]; }; void main() { uint index gl_GlobalInvocationID.x; outputData[index] inputData[index] * inputData[index]; } val shader GLES31.glCreateShader(GLES31.GL_COMPUTE_SHADER) GLES31.glShaderSource(shader, src) GLES31.glCompileShader(shader) val program GLES31.glCreateProgram() GLES31.glAttachShader(program, shader) GLES31.glLinkProgram(program) GLES31.glDeleteShader(shader) return program }创建程序时用的是GL_COMPUTE_SHADER类型这和普通顶点/片段着色器不太一样。链接完成后shader对象可以立刻删除不占资源。接下来创建两个SSBO并在dispatch前绑定val dataSize 1024 * 1024 // 1M个float共4MB val input FloatArray(dataSize) { it.toFloat() } val ssboInput IntArray(1) val ssboOutput IntArray(1) GLES31.glGenBuffers(1, ssboInput, 0) GLES31.glGenBuffers(1, ssboOutput, 0) GLES31.glBindBuffer(GLES31.GL_SHADER_STORAGE_BUFFER, ssboInput[0]) GLES31.glBufferData(GLES31.GL_SHADER_STORAGE_BUFFER, input.size * 4, java.nio.FloatBuffer.wrap(input), GLES31.GL_DYNAMIC_DRAW) GLES31.glBindBuffer(GLES31.GL_SHADER_STORAGE_BUFFER, ssboOutput[0]) GLES31.glBufferData(GLES31.GL_SHADER_STORAGE_BUFFER, input.size * 4, null, GLES31.GL_DYNAMIC_COPY) GLES31.glBindBuffer(GLES31.GL_SHADER_STORAGE_BUFFER, 0)这里有个细节输出buffer申请空间时可以直接传null相当于只分配显存但不初始化省掉了一次无意义的CPU到GPU的数据拷贝。输入数据用FloatBuffer包装是为了满足JNI的缓冲区对齐要求。然后是绑定和派发计算GLES31.glUseProgram(programId) GLES31.glBindBufferRange(GLES31.GL_SHADER_STORAGE_BUFFER, 0, ssboInput[0], 0, input.size * 4) GLES31.glBindBufferRange(GLES31.GL_SHADER_STORAGE_BUFFER, 1, ssboOutput[0], 0, input.size * 4) val workGroupSize 64 val numGroups (input.size workGroupSize - 1) / workGroupSize GLES31.glDispatchCompute(numGroups, 1, 1)glDispatchCompute(x, y, z)里的x、y、z分别是三个维度的工作组数量不是线程数量。因为shader里local_size_x为64所以1M个数据需要16384个工作组这样总线程数刚好覆盖全部数据。计算完成后不能直接回读结果必须先加一个内存屏障GLES31.glMemoryBarrier(GLES31.GL_SHADER_STORAGE_BARRIER_BIT)然后才能把结果从GPU侧拉回CPU。回读方式在后面章节细讲。3. 核心API与内存模型详解3.1 记得调用GLES31而不是其他GL类很多刚从Android 2D/3D渲染转过来的人第一步就容易栽在类名上。如果项目里用的是android.opengl.GLES20这个类就算设备支持GLES 3.1也调用不了任何compute相关的方法。必须换成android.opengl.GLES31。同样C里的头文件要包含GLES3/gl31.h链接时加上-lGLESv3。创建compute program的流程和普通渲染program不同之处只有一个shader类型必须是GL_COMPUTE_SHADER。其余api比如glShaderSource、glCompileShader、glLinkProgram全部复用。3.2 数据布局与内存对齐规则这部分是compute shader最大的坑没有之一。SSBO的内存布局有两种常用规则std140和std430。std430是std140在SSBO场景下的改进版规则更紧凑能省不少显存带宽但也更容易踩坑。关键的差异在于vec3的对齐。std430下vec3的对齐值是4字节也就是说一个float紧跟一个vec3是连续的但是在std140下vec3的对齐值是16字节float后面会跟12个字节的padding。如果你的CPU侧数据结构是按照C的float[3]排列的那么shader里如果用std140声明vec3数据对不上是必然的。我在项目里遇到过类似问题最初没注意shader和CPU端结构体布局差异结果结构体里又一个float再加一个vec3从GPU回读的数据总是不对排查了很久才发现是对齐规则的锅。解决方案也简单shader里声明缓冲区数据时要么全部用float数组手工摆列要么保证CPU端结构体也按照std430的16字节对齐规则手动padding。经验之谈常见的float数据直接声明成float数组最不容易出问题。还有一个大坑是shader里声明的buffer大小必须和CPU端分配的实际字节数一致GPU不会帮你做越界检查一旦shader里声明的数组长度小于实际写入的元素索引它依然会去写那段显存结果就是踩了别的buffer的数据debug的时候非常隐蔽。所以dispatch之前务必自己算清楚总数据量和shader索引范围。4. 数据回读与性能优化实践4.1 CPU与GPU的同步时机不能靠感觉compute shader派发之后GPU是异步执行。如果在没有任何同步手段的情况下CPU立刻去读结果buffer拿到的要么是旧数据要么是半计算状态。我见过不少第一次写compute shader的同行问“为什么我的结果老是错”八成都是这个原因。glMemoryBarrier解决的是GPU内部数据可见性问题比如一个阶段写、另一个阶段读它确保写入在读取前对后一个阶段可见。但回读数据到CPU还需要额外的同步。最粗暴的方式是glFinish()它会阻塞CPU直到所有GL命令执行完毕实现简单但性能很差每一帧都调用会让GPU流水线彻底停摆是个不小的性能浪费。更好的做法是使用GLsync对象。下面这段代码展示了如何用fence优雅地等异步读取val sync GLES31.glFenceSync(GLES31.GL_SYNC_GPU_COMMANDS_COMPLETE, 0) // 在稍后的某个时间点执行 if (sync ! 0L) { while (true) { val res GLES31.glClientWaitSync(sync, 0, 10000000) // 10ms超时 if (res GLES31.GL_ALREADY_SIGNALED || res GLES31.GL_CONDITION_SATISFIED) { break } // 超时可以做一些其他逻辑而不是死等 } GLES31.glDeleteSync(sync) }这种方式的好处是你可以在dispatch完之后继续做别的CPU工作真正需要结果时才去等待。回读数据本身也有两种方式glGetBufferSubData和glMapBufferRange。前者适合一次性把整个buffer拷回来后者适合需要多次读写的场景可以减少一次CPU内存拷贝。实测下来glMapBufferRange配合driver的write-combine优化在大buffer场景下比glGetBufferSubData快10%~20%。4.2 避免卡顿的优化实践第一次调通compute shader后很多人会立刻遇到新问题GPU计算本身很快但整体体验还不如CPU版本卡顿发生在每一帧的同步等待上。我自己的优化经验有三个按收益排序第一尽量把多个计算任务合并到同一个shader里或者一次dispatch里。比如滤镜链要依次做亮度调整、锐化、色彩映射最好合并成一个kernel而不是分三次dispatch每dispatch一次就意味着一次额外的同步和调度开销。第二回读频率能降则降。很多计算中间结果是不需要回CPU的比如粒子系统的下一帧位置、滤镜的中间图像这些数据可以直接留在GPU侧下一帧继续读。只有最终需要展示或者需要CPU做逻辑判断的结果才回读。把1M个float从GPU回读到CPU的耗时大约在0.2~0.5毫秒看着不多但如果每帧都来回操作累积起来非常可观。第三试着用双缓冲或者环形缓冲。当一个buffer正在被GPU读取时CPU可以写另一个buffer交替使用能隐藏掉大部分同步等待。这个技巧在实时渲染项目里是标配compute shader场景同样适用。5. 常见问题与排查技巧实录5.1 Shader编译报错或链接失败最常见的报错是shader版本不匹配。compute shader必须写#version 310 es少一个es后缀或者版本号写成300直接编译失败。另一个高频问题是忘记声明local_size或者声明非法local_size_x/y/z的乘积在很多移动GPU上不能超过256Adreno部分芯片要求更严格超过128就会出现编译错误。稳妥起见我用64或128作为工作组大小。编译失败后通过glGetShaderInfoLog拿具体报错信息val log GLES31.glGetShaderInfoLog(shaderId) Log.e(ComputeShader, compile error: $log)有些错误日志比较抽象比如“0:5(10): error: syntax error”这种这时候按行号对着shader源码查大概率能找到。C开发的话也可以用adreno和mali厂商提供的离线调试工具做本地模拟比真机反复推包快得多。5.2 运行结果完全错误或部分错误结果错乱的原因很多按概率排内存布局不对、dispatch数量算错、同步缺失、buffer绑错binding点。调试时我一般先把数据量缩小到一个工作组能处理完的大小比如64个元素配合CPU端的参考实现逐项比对。如果小数据量正确、大数据量出错优先怀疑是索引越界或者工作组数量算错这种问题往往是整除不干净导致的需要把最后一个不满的工作组处理掉。还有一个在部分设备上很隐蔽的现象shader里声明了layout(std430)部分老驱动可能只支持std140编译时不报错但结果错误。这种情况只能在真机上换上std430试跑一遍对比遇到就把所有buffer改成std140声明代价是更耗带宽但兼容性上最稳妥。5.3 设备兼容问题的处理compute shader不同芯片的驱动表现差异很大。Adreno在compute上的计算带宽比较均衡Mali的早期型号在局部内存使用上有很多限制比如local array大小超过一定阈值就直接编译失败。PowerVR在高精度浮点上表现不错但回读data cache的延迟偏高。项目要做产品级兼容时我用了个一站式检测方案启动时跑一个小规模的compute shader基准读取设备GPU型号和硬件支持信息如果检测到不支持的设备就走CPU fallback路径。看不出性能差异的甚至可以直接都走GPU有明显兼容问题的就走CPU至少保证功能可用。几个实际的设备表现我整理成了下表方便参考芯片型号compute shader兼容度实测1M float平方耗时含回读备注Snapdragon 888 (Adreno 660)好约1.8ms支持子组扩展可进一步优化Snapdragon 865 (Adreno 650)好约2.2ms稳定Kirin 990 (Mali-G76)一般约2.8ms动态分支性能较差MediaTek Dimensity 1200 (Mali-G77)一般约2.5ms高温降频影响明显模拟器SwiftShader差极慢或崩溃不可用跑同类性能基准时要注意数字会受屏幕分辨率、负载状态影响不要当作绝对的测试标准主要看相对关系。调试时还有一个很实用的工具链用Android Studio的GPU Debugger或者在渲染代码里插桩计数。前者能直观看到每个buffer的内容后者适合做全流程自动化回归测试。真机上跑的时候开Settings - Developer options - GPU rendering profile也能粗略看到GPU负担曲线。6. 从最小Demo到真实项目的一点心得如果你只是拿上面的例子跑通了那只是个起点。真实项目里往往需要把compute shader和渲染管线打通比如计算完的结果要作为纹理参与后续绘制那就需要在compute shader输出的SSBO或图像上执行一次glMemoryBarrier屏障bit用GL_SHADER_IMAGE_ACCESS_BARRIER_BIT或者GL_TEXTURE_FETCH_BARRIER_BIT这步漏了的话画面上会花屏或者出现时序错乱。我自己的体会是compute shader的入门曲线其实比很多人想的要陡困难主要不在shader语言本身而是在于“搞清楚数据在哪里、什么时候可见”这个内存模型思维转换。CPU编程里所有内存都是你的随便读写GPU编程里一个线程能看到的数据范围极其有限跨线程通信要走共享内存跨阶段通信要过内存屏障每一个不同步的角落都可能变成bug的温床。最后再分享一个小技巧第一次做某个compute shader任务时先用CPU算法实现一遍输出一份同样的结果作为“标准答案”GPU版本跑完直接逐字节对比。这套验证流程能在前期就把90%的数据问题拦截住等确认正确了再去做性能优化会省下大量调试时间。本文还有配套的精品资源点击获取