Android OpenGL ES黑屏检测方案

2,699 阅读6分钟

背景

在某个游戏项目中我们遇到了部分机型录制画面黑屏的问题,游戏基于OpenGL ES做渲染,画面录制需要从游戏的渲染线程中Copy默认的FrameBuffer对象,将当前游戏帧数据Copy到另一块OpenGL Texture中,之后可以将TextureId共享到编码线程中,利用OpenGL重新绘制到MediaCodec的Input Surface中。而录制黑屏如果不是游戏渲染有问题导致,那么就是我们Copy游戏帧数据时出现了问题,一般游戏上线时会有兼容性测试,不太可能出现游戏渲染问题,因此我们需要为我们的功能添加黑屏检测,并上报结果到监控平台。

黑屏检测

黑屏检测的主要思路就是检测位于MediaCodec输入端的游戏帧数据,其像素值是否为0。常见的做法是通过glReadPixels从Texture读取像素Buffer检测若干个像素点位,这种方式的通用性比较好,不过需要在主存中多创建一块存放像素的Buffer,以及使用CPU来做检测,会造成额外的内存和CPU性能消耗,因此我们考虑在重渲染Texture的Shader中进行黑屏检测,利用GPU强大的并行计算能力可以更高效地完成,同时避免数据拷贝。

原子计数器

原子计数器(Atomic Counter)是一种拥有存储对象的特殊GLSL数据类型,在将一个原子计数器绑定到一个存储对象后,可以被多个Shadar共享,并且提供了一些原子操作对其进行读写,当有多个Shadar对其进行操作时,OpenGL可以保证其原子性,但不保证顺序正确。

因此我们可以使用原子计数器来统计Fragment Shadar执行时,像素值为了0的执行次数和总的执行次数,通过对比两者的数值就可以判断当前的游戏帧的所有像素是否都为0,都为0则为「黑帧」。

实现步骤

1. 在Fragment Shadar中声明两个Atomic Counter,需要使用数据类型atomic_uint

layout (binding = 0, offset=0) uniform atomic_uint zero_counter;
layout (binding = 0, offset=4) uniform atomic_uint exec_counter;

binding = 0,offset = 0 分别指定绑定到GL_ATOMIC_COUNTER_BUFFER上的BufferObjects的索引和在BufferObject中的偏移,其中offset是可选的,atomic_uint 需要占用4个byte,在不指定offset时,会使用在上一个Atomic Counter的offset上加4,启始位置为0。

  • zero_counter: 统计像素值为0的Shadar执行次数
  • exec_counter: 统计Shadar总的执行次数

2. 申请Buffer Object并绑定到GL_ATOMIC_COUNTER_BUFFER

Android 上,Atomic Counter需要OpenGL ES 3.1 及以上才支持

int[] buffer = new int[1];
//申请Buffer Object
GLES31.glGenBuffers(1, buffer, 0);
int glAtomicCounterIdx = buffer[0];
//初始化并指定Buffer Object占用8bytes的存储
GLES31.glBindBuffer(GLES31.GL_ATOMIC_COUNTER_BUFFER, glAtomicCounterIdx);
GLES31.glBufferData(GLES31.GL_ATOMIC_COUNTER_BUFFER, 8, null, GLES31.GL_DYNAMIC_COPY);
//将BufferObject绑定GL_ATOMIC_COUNTER_BUFFER
GLES31.glBindBufferBase(GLES31.GL_ATOMIC_COUNTER_BUFFER, 0, glAtomicCounterIdx);

需要申请8bytes的存储,因为我们会用到两个Atomic_counter, 也就是前面的zero_counterexec_counter.

3. 在Fragment Shadar中使用原子操作,统计zero_counterexec_counter

OpenGL ES 3.1 主要提供了以下三个相关的操作:

  • atomicCounter: 读取一个atomic counter当前的值

  • atomicCounterIncrement:给一个atomic counter加1并返回先前的值

  • atomicCounterDecrement:给一个atomic counter减1并返回先前的值

在判断像素的RGBA都为0时,zero_counter 加1;exec_counter则每次执行都会加1; 得到完整的Fragment Shader:

#version 310 es
precision mediump float;
uniform sampler2D u_Texture;
layout (binding = 0, offset=0) uniform atomic_uint zero_counter;
layout (binding = 0, offset=4) uniform atomic_uint exec_counter;
in vec2 v_texCoords;
out vec4 FragColor;
void main() {
    FragColor = texture(u_Texture, v_texCoords);
    if(all(equal(FragColor, vec4(0)))) {
        atomicCounterIncrement(zero_counter);
    }
    atomicCounterIncrement(exec_counter);
}

4. 在每次绘制前重置Atomic Counter的Buffer为0, 绘制完成后读取zero_counterexec_counter

在调用glDraw*函数之前,调用glBufferSubDataGL_ATOMIC_COUNTER_BUFFER的8bytes置为0

//class field
 private IntBuffer glBuffer = ByteBuffer.allocateDirect(8).order(ByteOrder.nativeOrder()).asIntBuffer();
 
 GLES31.glBindBuffer(GLES31.GL_ATOMIC_COUNTER_BUFFER, glAtomicCounterIdx);
 glBuffer.put(0).put(0).position(0);
 GLES31.glBufferSubData(GLES31.GL_ATOMIC_COUNTER_BUFFER, 0, 8, glBuffer);

在调用glDraw*函数之后,需要使用glMapBufferRangeglUnmapBuffer 来映射Atomic counter buffer区域为ByteBuffer, 需要注意的一点是必须order(ByteOrder.nativeOrder()) 处理大小端的问题。

GLES31.glBindBuffer(GLES31.GL_ATOMIC_COUNTER_BUFFER, glAtomicCounterIdx);
ByteBuffer buffer = (ByteBuffer) GLES31.glMapBufferRange(GLES31.GL_ATOMIC_COUNTER_BUFFER, 0, 8, GLES31.GL_MAP_READ_BIT);
IntBuffer intBuffer = buffer.order(ByteOrder.nativeOrder()).asIntBuffer();
int zeroCounter = intBuffer.get(0);
int execCounter = intBuffer.get(1);
GLES31.glUnmapBuffer(GLES31.GL_ATOMIC_COUNTER_BUFFER);
Log.d(TAG, "after: zero counter: "+zeroCounter+", exec counter:"+execCounter);

之后可以通过判断zeroCounter==execCounter 为true, 来判读当前帧是否为「黑帧」

性能测试

性能测试使用的是一款Unity FPS Demo以及GamePerf(类似PerfDog),主要对比使用Atomic Counter时CPU、Memory、GPU的消耗情况。

测试机型: Honor 10

测试结果

结论: 从测试的结果中可以看出,Atomic Counter的检测方案对CPU和Memory的消耗没有明显影响;但是GPU在处理Fragment Shader时的消耗(Fragment Active)有比较明显的增加, 导致整体的GPU消耗(GPU Active)比原来要多。

cpuProcUsgePSS(MB)GPU ActiveFragment ActivceNonFragment ActivceTiler Active
Enable Atomic Counternormal5.73167.4819.8118.151.960.97
recording6.09181.0684.6182.962.461.07
Disable Atomic Counternormal3.46167.3119.8318.161.970.98
recording5.87181.7924.8523.192.221.01

这里GPU消耗多可能是if-else导致的,但是OpenGL ES 3.1的Shadar中,不能使用atomicAdd(inout int mem, int data), 无法避免使用分支语句。

兼容测试

测试内容

  • 不同GPU上Shader 编译是否正常

  • 渲染一张图片是否正常, 图片左侧区域像素值为0, 右侧区域像素非0

  • 两个Atomic_counter读数是否符合预期

    image

    image

测试结果

结论: 大部分部分设备都支持OpenGL ES 3.2,支持OpenGL ES 3.1 及以上的设备都支持Atomic_Counter, 不支持OpenGL ES 3.1的都是比较老的低端GPU型号

GPUOpenGL ES机型编译渲染Atomic_count异常
PowerVRRogueGM94463.2OPPO Reno3YYY异常1
PowerVRRogueGE81003.2天珑移动 U304AAYYY
PowerVRRogueGE83203.2Honor Play 9A、OPPO A5sYYY
PowerVRRogueGE83003.2传音INFINIX Smart 5YYY
Mali-G763.2华为 Mate 20YYY异常2
Mali-G723.2Honor 10、华为 Mate 10YYY
Mali-G713.2三星 Galaxy A10、华为 P10YYY
Mail-G57
Mali-G52
Mali-G513.2华为 畅享 10YYY
Mali-T8803.1华为 Mate 8YYY
Mali-T860VIVO Y67A、金立 S9YYY
Mali-T8303.2华为 麦芒 6YYY
Mali-T7203.1VIVO Y35、金立 F100SNNN异常3
Adreno(TM)6503.2Realme X50 Pro 5GYYY
Adreno(TM)6303.2谷歌 Pixel 3YYY
Adreno(TM)6203.2VIVO iQOO Z3YYY
Adreno(TM)6163.2小米 MI CC 9YYY
Adreno(TM)6103.2Redmi Note 9 4GYYY
Adreno(TM)5403.2谷歌 Pixel 2YYY
Adreno(TM)5123.2VIVO X20AYYY
Adreno(TM)5063.2Vivo X9YYY
Adreno(TM)5053.2摩托罗拉 moto e6YYY
Adreno(TM)4053.1华为 麦芒 4YYY
Adreno(TM)3303.0LG Nexus 5NNN
Adreno(TM)3083.0海信 F26NNN
Adreno(TM)3063.0OPPO A31NNN

异常

  1. PowerVRRogueGE8320: Link Error: Vertex and Fragment shaders were not compiled with the same language version.

统一Vertex shader 和 Fragment shader 版本为310 es可以避免.

  1. Mali-G72: L0001 Shader languages do not match.

统一Vertex shader 和 Fragment shader 版本为310 es可以避免.

  1. Mali-T720: L0005 The number of fragment atomic counter buffers (1) is greater than the maximum number allowed (0)

Fragment shader 中的atomic counter buffer 数量大于 GL_MAX_COMPUTE_ATOMIC_COUNTER_BUFFERS的数量限制,在这个case中maximun number为0, 也就是不支持

结尾

根据以上的测试结果,需要注意的是虽然目前大部分Android设备支持OpenGL ES 3.1,但是在低端机上容易会出现Shader编译不过情况,所以我们仍然需要写一段兜底逻辑,在Shader编写出现问题时,自动降级到OpenGL ES 3.0的Shader,停用Atomic Counter,另外做好线上的功能开关,在必要时开启检测,也十分重要。

参考资料