基于 Cocos 游戏引擎的音视频研发探索

作者 信马归风
2019.09.26 10:31 字数 2618 阅读 105评论 2

本文主要介绍了流利说团队基于 cocos 游戏引擎进行音视频相关需求开发过程中所遇到的问题和解决方案。文章中将依次阐述 cocos 引擎直接渲染视频的方案,继而引申出多线程环境下 OpenGL 环境的管理方法,最后说明音视频处理流水线模型需要解决的问题与我们的方案。

让 cocos 引擎直接渲染视频

为什么?

可能大家首先会疑惑,为什么要让 cocos 引擎来负责渲染视频呢?而不利用原生平台的渲染机制,如使用Android平台的SurfaceView或TextureView。

让我们来分析下利弊:

原生机制优势:1.常规播放器接口的直接支持。2.视频渲染性能稳定。3.代码简单。

原生机制劣势:1.无法更精确的调整视频与游戏元素的层级关系(只能置游戏其上或其下)2.游戏控制播放器动画有性能损耗(游戏→ native 损耗)。

因此,如果要在视频层上播放游戏显示游戏元素或游戏动画,利用原生机制无疑会转移大量的游戏业务在native端完成。这无论从项目管理和实现复杂度考虑都是不可取的。因此我们决定让 cocos 引擎使用外接纹理渲染视频。

实现方案

分析cocos引擎本身的渲染机制,我们发现cocos引擎中封装了cocos2d::Sprite对象用于渲染显示,Sprite对象需要我们提供 cocos2d::Texture2D 对象和尺寸信息。因此我们可以封装一个 cocos 引擎中的基础节点 cocos2d::ui::Widget 专门用于视频显示,cocos 中 widge 对象是类似于 native 中 View 功能的组件,负责管理组织绘制大小,绘制位置和绘制内容。我们可以在其回调的 Draw 方法中完成视频绘制。

在 native 层我们将视频输出数据转为纹理,再传递至Widget中转化为 Cocos 的Texture2D对象交由 Sprite 绘制。而 Sprite 渲染尺寸则由 widget 提供。由此我们可以得出下面这样一个简易的转化链:

多线程OpenGL

但是上面这条转化链并不能简单的实现。首先 cocos 引擎是在单独开启的一个线程中进行工作的,以下简称 cocos 线程。也就是说我们最终 OpenGL 的绘制都会在 cocos 线程中操作。我们用 cocos 线程的OpenGL context 去进行纹理转化,甚至增加贴图美颜等功能都是不合适的。音视频中有一些 OpenGL 操作,很有可能使 cocos 整个 OpenGL 状态机被破坏掉。所以需要将所有的音视频转化、处理操作都限制在子线程中。

假如我们需要在多线程下共享纹理数据,需要让 OpenGL Context 共享同一个ShareGroup。因此我们需要接管整个架构环境中所有 OpenGLContext的构建过程。如Android端我们需要在 Cocos 引擎Cocos2dxActivity的中将 Cocos2dxGLSurfaceView::setEGLContextFactory 修改为我们自己的提供的方法。除此之外,纹理转化和处理模块的 OpenGL 环境也需要统一构建共享 ShareGroup 的 context。

Android端有一点特殊之处,屏蔽了 ShareGroup 的概念。但是我们只要在 OpenGL Context 的构造函数传入一个 Context,即可让两个 Context共享 ShareGroup。

音视频处理流水线模型

建立模型

为了整合音视频处理的各个环节,构建统一的错误处理、线程管理、生命周期管理机制,我们对音视频处理流程进行了抽象,建立起一个以音视频源、线程分发器、消费者链组成的流水线模型。

抽象出的音视频源负责加载本地或网络视频资源,而后进行解码操作。亦或者为采集摄像头数据的采集器,最终输出视频帧数据。而消费者组成消费处理链,负责接收处理帧数据或纹理数据。如我们自定义的 cocos::Widget 可以作为消费链的最后一个消费者。

线程分发器即是负责连接源与消费者。线程分发器创建管理音视频各自的工作线程,把外部命令和音视频数据分发至目标线程再回调给消费者,保证消费者内部方法在同一线程执行,从而消除消费者模块的中的线程安全问题。

按照这样的方式建立的流水线模型具备较好的稳定性和扩展性,可以保证如 OpenGL 上下文管理,视频帧数据转化为纹理等诸多模块的复用。另外由于消费者和生产者的完全解耦,也能够实现诸如动态切换音视频源的功能。此外多线程流水线也能很好的发挥多核 CPU 的性能。

尽管模型已经建立,但在细节方面还存在不少问题等待我们去解决。下面我就简单说明几个问题以及我们的探索。

音视频帧数据的复用

为了避免内存频繁分配而造成的不必要的耗时。我们通常会对构建的之前生成过得音视频数据进行复用,因为存在多分辨率切换的问题,由此会生成诸多大小不一的内存块。因此复用的前提是被复用的内存>=需要分配的内存。在 Android 端即是指 ByteBuffer 的capacity需要满足上述条件。因此我们可以建立一个ByteBuffer对象池用于缓存已经被消费完成的ByteBuffer,在复用时遍历缓存池找寻符合大小条件的 ByteBuffer。在一般情况下视频数据需要 ByteBuffer 数组来存储,因此我们可以对对象池的每个对象增加标签属性,保证相同分辨率的视频数据可以快速找到可被复用的内存。

那么音视频数据被回收的时机是什么呢?单线程模型下是极为容易确定的,但是多线程环境下事情就变得复杂了,我们无法知道什么时候数据才被真正的消费完成。因此我们参考图片加载框架Fresco中对Bitmap回收问题的解决方案引入 Closeable References(可回收引用)概念。CloseableRef 对象包裹我们需要缓存的对象,内置的引用计数会在我们所有线程持有的引用都被 close 后才会回收。在回收的回调方法中我们将其加入缓存对象池中。

工作线程的阻塞监控

开发多线程复杂项目我们必须考虑到在低配机型下,工作线程积攒大量任务无法被消费处理的情况。如音频和视频的处理线程,如果视频处理过慢可能会导致严重的音画不同步。因此我们需要建立可以被管理的工作线程任务队列。我们在 Android 端的实践为:基于 HandlerThread ,另外增加一个可以被管理的Queue。每当产生任务,我们将任务入栈,并向 HandlerThread 发送一条出栈指令,HandlerThread 从 Queue 末尾出栈处理任务。

Queue 中可以记录多项重要参数用于决策处理。如综合任务预期执行时间、任务的处理时间和队列积攒数量进行进行策略性丢弃。或者根据一段时间的综合情况来决定是否降级生产音视频的分辨率等参数等。

总结

以上讲述的几个技术关键点是我们团队在项目开发过程中不断探讨与发现得出的。整套框架方案已经在项目中落地,获得了还不错的开发结果。希望能为大家带来些许帮助。另外敬请期待我们少儿流利说即将上线的直播课功能。

发表评论

说点什么吧!留下邮箱让我好回复你。 必填项已用*标注