记最近遇到的坑-WebRTC on Android

最近做了版 OpenGL 相关的大需求, 将一个新鲜出炉的动态贴图SDK合入至我们的产品中。这个 SDK 的原理是先做人脸定位,然后再在对应的位置上通过 OpenGL 绘制图片资源。其所考虑到的特殊情况并不多,所以在合入 SDK 时踩了无数的坑。(毕竟我们产品是合入 SDK 的第一只小白鼠。。吱!)

看似比较常规的需求,但实际上暗藏了很多坑。
虽然 WebRTC 给我们提供了一个还算不错的视频与通信的方案,但还是给我留了个不少来源于 WebRTC 的巨坑。

本文所说的坑,是 WebRTC 对于 OpenGL 中位置(gl_Position)和坐标系(inputTextureCoordinate)的控制并不科学。WebRTC 所提供的 Android 的 API 中只要涉及到设置位置及坐标系的地方,目前来看都有问题存在的。

WebRTC 提供的几个工具类的介绍

GlRectDrawer

GlRectDrawer 是一个 WebRTC 在 Android 上实现的最基本的 Drawer。它提供了三种
格式的 GLSL 分别是 Rgb, Oes, Yuv。通过它我们可以做很多事情,例如将相机拿到的数据绘制到纹理上,并利用 OpenGL 做一些简单的变换等等。

YuvConverter

YuvConverter 用于将 Oes 纹理的 textureId 转换成 YUV 格式的 ByteBuffer。而它在整个 WebRTC 项目中的作用是编码传输视频数据。手机不支持硬件编码时,WebRTC 的 native 层的策略就会将编码这一块的任务 Fallback 到 Java 层的 YuvConverter 来完成。

YuvConverter 为啥要被当做 Fallback 方案呢?前方一大波高能代码即将来临:


  public void convert(ByteBuffer buf, int width, int height, int stride, int srcTextureId,
      float[] transformMatrix) {
    threadChecker.checkIsOnValidThread();
    if (released) {
      throw new IllegalStateException("YuvConverter.convert called on released object");
    }

    // We draw into a buffer laid out like
    //
    //    +---------+
    //    |         |
    //    |  Y      |
    //    |         |
    //    |         |
    //    +----+----+
    //    | U  | V  |
    //    |    |    |
    //    +----+----+
    //
    // In memory, we use the same stride for all of Y, U and V. The
    // U data starts at offset |height| * |stride| from the Y data,
    // and the V data starts at at offset |stride/2| from the U
    // data, with rows of U and V data alternating.
    //
    // Now, it would have made sense to allocate a pixel buffer with
    // a single byte per pixel (EGL10.EGL_COLOR_BUFFER_TYPE,
    // EGL10.EGL_LUMINANCE_BUFFER,), but that seems to be
    // unsupported by devices. So do the following hack: Allocate an
    // RGBA buffer, of width |stride|/4. To render each of these
    // large pixels, sample the texture at 4 different x coordinates
    // and store the results in the four components.
    //
    // Since the V data needs to start on a boundary of such a
    // larger pixel, it is not sufficient that |stride| is even, it
    // has to be a multiple of 8 pixels.

    if (stride % 8 != 0) {
      throw new IllegalArgumentException("Invalid stride, must be a multiple of 8");
    }
    if (stride < width) {
      throw new IllegalArgumentException("Invalid stride, must >= width");
    }

    int y_width = (width + 3) / 4;
    int uv_width = (width + 7) / 8;
    int uv_height = (height + 1) / 2;
    int total_height = height + uv_height;
    int size = stride * total_height;

    if (buf.capacity() < size) {
      throw new IllegalArgumentException("YuvConverter.convert called with too small buffer");
    }
    // Produce a frame buffer starting at top-left corner, not
    // bottom-left.
    transformMatrix =
        RendererCommon.multiplyMatrices(transformMatrix, RendererCommon.verticalFlipMatrix());

    // Bind our framebuffer.
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, frameBufferId);
    GlUtil.checkNoGLES2Error("glBindFramebuffer");

    if (frameBufferWidth != stride / 4 || frameBufferHeight != total_height) {
      frameBufferWidth = stride / 4;
      frameBufferHeight = total_height;
      // (Re)-Allocate texture.
      GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
      GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, frameTextureId);
      GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, frameBufferWidth,
          frameBufferHeight, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null);

      // Check that the framebuffer is in a good state.
      final int status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER);
      if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) {
        throw new IllegalStateException("Framebuffer not complete, status: " + status);
      }
    }

    GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, srcTextureId);
    GLES20.glUniformMatrix4fv(texMatrixLoc, 1, false, transformMatrix, 0);

    // Draw Y
    GLES20.glViewport(0, 0, y_width, height);
    // Matrix * (1;0;0;0) / width. Note that opengl uses column major order.
    GLES20.glUniform2f(xUnitLoc, transformMatrix[0] / width, transformMatrix[1] / width);
    // Y'UV444 to RGB888, see
    // https://en.wikipedia.org/wiki/YUV#Y.27UV444_to_RGB888_conversion.
    // We use the ITU-R coefficients for U and V */
    GLES20.glUniform4f(coeffsLoc, 0.299f, 0.587f, 0.114f, 0.0f);
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

    // Draw U
    GLES20.glViewport(0, height, uv_width, uv_height);
    // Matrix * (1;0;0;0) / (width / 2). Note that opengl uses column major order.
    GLES20.glUniform2f(
        xUnitLoc, 2.0f * transformMatrix[0] / width, 2.0f * transformMatrix[1] / width);
    GLES20.glUniform4f(coeffsLoc, -0.169f, -0.331f, 0.499f, 0.5f);
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

    // Draw V
    GLES20.glViewport(stride / 8, height, uv_width, uv_height);
    GLES20.glUniform4f(coeffsLoc, 0.499f, -0.418f, -0.0813f, 0.5f);
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);

    GLES20.glReadPixels(
        0, 0, frameBufferWidth, frameBufferHeight, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buf);

    GlUtil.checkNoGLES2Error("YuvConverter.convert");

    // Restore normal framebuffer.
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

    // Unbind texture. Reportedly needed on some devices to get
    // the texture updated from the camera.
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0);
  }

想看的同学可以仔细看看,不想看也没关系。上面的代码是 YuvConverter 的 convert 方法,观察仔细的同学肯定可以看到,在软编码时使用了 GLES20.glReadPixels() 这种方法,它有一个比较坑的地方在于需要同步等待所有操作执行完毕后, 再进行读取,所以它相当耗时。(有一种"猥琐发育, 别浪"的感觉...

你们苦苦等待的坑来了

在合入 SDK 之后,发现在 Preview 的时候没毛病,但在视频传输过程之后出现了问题。在视频通话的对面一方,看见己方发送的视频存在绿屏、花屏等情况,完全不能正常使用。图像的特征是花的、绿的,也就意味着问题要么出在解码一方上,要么出在编码一方上(废话- -)。

转机

这 Bug 我一直没想通,直到坑了我 N 天之后,同事告诉我 YuvConverter 在 convert 之前图像是正常的,而 convert 之后图像乱了。

与此同时,我在一行一行注释代码定位问题所在时,发现只要把 SDK 中的 GLES20.glDisableVertexAttribArray() 注释掉,就不会有绿屏和花屏的情况存在。虽然图像的坐标系以及缩放大小等等都不对,但的确是有正常数据过来了。

分析

这一定是坐标系和采样点的 Buffer 出了问题,一旦 disable 了 Buffer 之后,后面的数据就挂了。说明在后面的数据绘制的工程中,没有开启或者传递正确的 Buffer,或者说就没有给正确的 Buffer。再加上 YuvConverter 也出了问题,我们基本上就可以定位是 YuvConverter 在处理坐标系和采样纹理的时候有问题。

仔细一看,原来是 YuvConverter 中,只有在该类创建时会 enable Buffer。代码如下:

  public YuvConverter() {
    threadChecker.checkIsOnValidThread();
    frameTextureId = GlUtil.generateTexture(GLES20.GL_TEXTURE_2D);
    this.frameBufferWidth = 0;
    this.frameBufferHeight = 0;

    // Create framebuffer object and bind it.
    final int frameBuffers[] = new int[1];
    GLES20.glGenFramebuffers(1, frameBuffers, 0);
    frameBufferId = frameBuffers[0];
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, frameBufferId);
    GlUtil.checkNoGLES2Error("Generate framebuffer");

    // Attach the texture to the framebuffer as color attachment.
    GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
        GLES20.GL_TEXTURE_2D, frameTextureId, 0);
    GlUtil.checkNoGLES2Error("Attach texture to framebuffer");

    // Restore normal framebuffer.
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);

    shader = new GlShader(VERTEX_SHADER, FRAGMENT_SHADER);
    shader.useProgram();
    texMatrixLoc = shader.getUniformLocation("texMatrix");
    xUnitLoc = shader.getUniformLocation("xUnit");
    coeffsLoc = shader.getUniformLocation("coeffs");
    GLES20.glUniform1i(shader.getUniformLocation("oesTex"), 0);
    GlUtil.checkNoGLES2Error("Initialize fragment shader uniform values.");
    // Initialize vertex shader attributes.
    shader.setVertexAttribArray("in_pos", 2, DEVICE_RECTANGLE);
    // If the width is not a multiple of 4 pixels, the texture
    // will be scaled up slightly and clipped at the right border.
    shader.setVertexAttribArray("in_tc", 2, TEXTURE_RECTANGLE);
  }

可以看到,操作位置及坐标系的在构造方法之中,这种做法很容易被其他地方的代码给 disable 掉,从而导致各种奇奇怪怪的错误。

如何修复

其实很简单,把构造方法中的setVertexAttribArray移动到 convert 方法中。这样做,每次在转码的时候都会重新设置一下位置以及坐标系,而不会收到其他上下文中的影响了。

正确的做法

无论是用 shader 做什么,只要用到了 glPosition 以及坐标系,建议都在绘制前设置,并且在绘制后 disable 掉。可以帮助快速排除 Bug 的同时,也能减少奇奇怪怪的 Bug 的发生。
这也是我前面提到 GlRectDrawer 的目的。包括 GlRectDrawer,WebRTC 在整个与 OpenGL 相关的代码中,都是采用的"创建时设置,使用时不管"的策略。不知道为什么 WebRTC 会这样做,但这样做的确会造成我上面所说的问题。

后记

在阅读了 WebRTC 提供的 GlShader 的代码后发现,他们显然从来没有考虑过 disable 的问题,因为这里只封装了 setVertexAttribPointer 方法,而并没有封装 diableVertexAttribPointer 方法。

本人在 OpenGL 这一块了解不多,如有哪里写的不对,还请不吝赐教!