本文首发于微信公众号——世界上有意思的事,搬运转载请注明出处,否则将追究版权责任。交流qq群:859640274
大家好久不见,又有两个多月没有发文章了。最近新型肺炎闹得挺凶,希望大家身体健康。本篇博客是视频编辑 SDK 解析文章中的第二篇,文章中我会介绍将上一篇文章中解码出来的视频帧通过 OpenGL 绘制出来的方式。WsVideoEditor 中的代码也已经更新了。大家在看文章的时候一定要结合项目中的代码来看。
本文分为以下章节,读者可按需阅读:
1.OpenGL之我的理解
2.Android层的框架搭建
3.C/C++渲染视频帧
4.尾巴
一、OpenGL之我的理解
讲解 OpenGL 的教程目前有很多,所以这一章笔者不会去教大家如何入门或者使用 OpenGL。本章笔者只会从抽象的角度来和大家讨论一下笔者对于 OpenGL 的理解。至于如何入门 OpenGL 则会推荐几个有用的网站。
1.OpenGL是什么?可以干什么?
如图1,我们知道 OpenGL/OpenGL ES 是一个图形图像渲染框架 ,它的规范由Khronos组织 制定,各个显卡厂商在驱动中实现规范,再由各个系统厂商集成到系统中,最终提供各种语言的 API 给开发者使用。
当然图形图像渲染框架 不仅仅只有 OpenGL 这一种。Apple 的 Metal(不跨平台)、Google 的 vulkan(跨平台)、微软的 DirectX(不跨平台) 都是 OpenGL 的竞品。
那么什么是图形图像渲染框架 呢?做 Android、iOS、前端、Flutter 的同学一定都用过 Canvas,在各自的平台中 Canvas 就是一个比较上层的图形图像渲染框架 。
如图2,我们在使用 Canvas 绘制一个三角形的时候一般有以下步骤,在 OpenGL 中也是类似:
1.确定坐标系
2.根据坐标系定义三角形的三个点
3.调用绘制函数/触发的渲染函数
除了像 Canvas 一样绘制 2D 图像,OpenGL 最主要的功能还是进行 3D 绘制,这就是 Canvas 们无法企及的地方了。
2.OpenGL是如何工作的?
要了解 OpenGL 是如何工作的,首先我们得知道:OpenGL 运行在哪里? 没错有些读者已经知道了:OpenGL 运行在 GPU 上面 ,至于在 GPU 上运行的好坏我就不赘述了。
我们在平时的开发当中,绝大部分时间都在与内存和 CPU 打交道。突然让我们写运行在 GPU 上面的程序,我想大部分人都会水土不服,毕竟这是一个思维上的转变 。大多数教程一上来就是告诉大家如何调用 OpenGL 的 api,然后拼凑出一个程序来,大家也照猫画虎的敲出代码,但最终很多人并没有理解 OpenGL 是如何运行的,这也是它难学的地方。那么下面我会通过一张图来粗略的讲讲 OpenGL 是如何运行的。
图3中有1、2、3、4、5 个步骤,这几个步骤组合起来的代码就表示绘制一个三角形到屏幕上。可运行的代码可以在
learning-opengl这里找到,图中的代码只是关键步骤。我这里也只是讲解 OpenGL 的运行方式,更具体的代码使用还需要读者去前面的网站中学习。
1.首先我们可以在 Java/c/c++ 等等语言中使用 OpenGL 的 api,所以这里我使用 c 来讲解。
2.如图我们可以看见:GPU 内部会包括显存 和GPU核心 。我们平时开发 CPU 程序基本可以总结为:获取数据到内存中-->通过各种语言定义函数让 CPU 改变数据-->将改变后的数据输出 。
3.那么开发 GPU 程序就可以类比成:将内存的数据交给 GPU 的显存-->通过 GLSL 语言定义函数让 GPU 改变数据-->将改变后的数据通过一定的方式绘制到屏幕上。
4.图中代码片段1 就是通过 CPU 将 GLSL 的代码编译成 GPU 指令
5.图中代码片段2 是在内存中定义好数据,然后将数据拷贝到 GPU 显存中,在显存中数据是以对象的形式存在的。
6.图中代码片段3 是告诉 GPU 我需要运行代码片段1 中编译好的 GPU 指令了。
7.图中代码片段4 是用 GPU 运行我们 GLSL 产生的指令以刷新屏幕
8.图中代码片段5 是和 c/c++ 一样手动进行内存回收
9.以上5个代码片段连起来,一个三角形就绘制完成了。
这里我推荐两个教程,让让大家能够学习 OpenGL 的具体用法,毕竟仰望星空的同时脚踏实地也非常重要:
1.可以运行在 Mac、Windows 上的 c/c++ 语言下的 OpenGL 教程:learning-OpenGL
2.可以运行在 Android 上的 Java 语言下的 OpenGL 教程: OpenGLES3.0 接入视频实现特效
二、Android层的框架搭建
我的老本行是 Android 开发,所以这一章我会讲解视频编辑SDK在 Android 层的代码。代码已经更新 WsVideoEditor,本章需结合代码食用。另外本章节强依赖 从零开始仿写一个抖音App——视频编辑SDK开发(一) 的第三章SDK架构以及运行机制介绍 ,大家一定要先读一下。本章会省略很多已知知识。
1.WsMediaPlayer
图4是编辑 SDK 的架构图,从中我们可以看见 WsMediaPlayer 代理了 Native 的 NativeWSMediaPlayer 的 Java 类。该类具有一个播放器应该有的各种 API,例如 play、pause、seek 等等。其实很多 Android 中的系统类都是以这种形式存在的,例如 Bitmap、Surface、Canvas 等等。说到底 Java 只是 Android 系统方便开发者开发 App 的上层语言,系统中大部分的功能最终都会走到 Native 中去,所以读者需要习惯这种代码逻辑 。那么我们就来看看这个类的运行方式吧。
1.看代码我们可以知道,WsMediaPlayer 的所有 API,最终都走到了 NativeWSMediaPlayer 中。
2.我们可以看 VideoActivity 的代码里面有创建和使用 WsMediaPlayer 的流程。
3.mPlayer = new WsMediaPlayer()
:会创建一个 NativeWSMediaPlayer 对象,初始化的时候会创建两个对象
1.VideoDecodeService:这个对象用于解码视频帧,具体代码解析在从零开始仿写一个抖音App——视频编辑SDK开发(一) 的第四章VideoDecodeService解析
2.FrameRenderer:这个对象用于将 VideoDecodeService 对象解码出来的视频帧,通过 OpenGL 绘制到屏幕上。
4.mPlayer.setProject(videoEditorProjectBuilder.build())
:最终走到了 NativeWSMediaPlayer::SetProject
中,这里只是将 EditorProject 设置给 VideoDecodeService 和 FrameRenderer。
5.mPlayer.loadProject()
:最终走到了 wsvideoeditor::LoadProject 中,这里的主要逻辑是对每一段视频使用 FFmpeg 进行解封装 ,获取到各个视频的时长、长宽、等等信息,然后存入 EditorProject 中以便之后使用。至于 FFmpeg 的使用可以参见这几篇文章:从零开始仿写一个抖音App——音视频开篇、零开始仿写一个抖音App——基于FFmpeg的极简视频播放器
6.至此我们的 WsMediaPlayer 就创建完了,其他 Api 例如 play、pause、seek 等等就交给读者去了解吧。
2.WsMediaPlayerView
如果把播放视频比作:一个绘画者 每隔 30ms 就向画布 上绘制一幅连环画 的话。那么绘画者 就是 WsMediaPlayer,连环画 就是视频,画布 就是 WsMediaPlayerView。
1.WsMediaPlayerView 是基础于 TextureView 的。所以其生命周期会被系统自动调用,我们也需要在这些回调中做一些事情
1.init()
:创建 WsMediaPlayerView 是调用,初始化一些参数,注册回调。
2.setPreviewPlayer
:将 WsMediaPlayer 交给 PlayerGLThread,以绘制。
3.onResume()/onPause()
:需要手动在 Activity 中调用,用于启动/暂停绘制。
4.onSurfaceTextureAvailable
:在 TextureView.draw
的时候被系统调用,表示我们可以开始进行绘制了。我们在这里就创建了一个 PlayerGLThread,用于在非主线程进行 30ms 的定时循环绘制。同时还获取了绘制窗口的大小。
5.onSurfaceTextureSizeChanged
:当绘制窗口改变的时候,更新窗口大小,最终会作用在 OpenGL 的绘制窗口上。
6.onSurfaceTextureDestroyed
:资源销毁。
2.再来看看 PlayerGLThread,它是一个无限循环的线程,也是 OpenGL 环境的创建者,还是 WsMediaPlayer 的主要调用者。
1.根据对 WsMediaPlayerView 的描述我们知道:PlayerGLThread 会在 TextureView.draw
调用与 WsMediaPlayer 被设置,这两个条件同时满足时启动线程。
2.PlayerGLThread 有 mFinished 以控制线程是否结束。
3.PlayerGLThread 有 mRenderPaused 以控制是否调用 WsMediaPlayer.draw 进行绘制。
4.PlayerGLThread 有 mWidth 和 mHeight 以记录绘制窗口的大小,也即 OpenGL 的绘制区域。
5.线程循环的开始,runInternal 会首先检查 OpenGL 的环境是否可用,然后根据 WsMediaPlayer 选择是否创建新的 OpenGL 环境。
6.OpenGL 环境创建好之后,会调用 mPlayer.onAttachedView(mWidth, mHeight)
来向 Native 同步绘制区域的大小。
7.如果所有环境准备就绪,!mFinished && !mRenderPaused
为 true,那么调用 mPlayer.drawFrame()
进行绘制。
8.runInternal 中每次循环为 33ms,在 finally 中通过 sleep 保证。
3.另外需要注意的是,OpenGL 在每个线程中有一个 OpenGL Context,这相当于一个线程单例。所以即使我们在 Java 层创建了 OpenGL 的环境,只要 C/C++ 层中运行的代码也处于同一个线程,绘制还是可以正常进行的,OpenGL Context 也是共用的。
三、C/C++渲染视频帧
我在从零开始仿写一个抖音App——视频编辑SDK开发(一) 的第四章VideoDecodeService解析 中讲解了如何解码出视频帧,在上一章中讲解了如何在 Android 层准备好 OpenGL 的渲染环境。这些都为本章打下了基础,没有看过的同学一定要仔细阅读啊。同样本章的代码已经上传至WsVideoEditor,请结合代码食用本章。
1.FrameRender绘制流程解析
图5是视频编辑 SDK 的运行机制,本次我们解析的功能是在 FrameRender 中渲染 VideoDecodeService 提供的视频帧,也就是视频播放功能。下面我们就从第二章中提到的 WsMediaPlayer.draw 方法入手。
1.通过第二章大家都知道在视频播放的情况下,WsMediaPlayer.draw 会以 33ms 为间隔不断的进行循环调用。
2.就像大家想的那样,WsMediaPlayer.draw 最终会调用到 Native 的 NativeWSMediaPlayer::DrawFrame 方法中。这个方法目前还不完善里面只有测试代码,因为我们目前只能播放图像,还没有播放声音,所以目前 current_time_ = current_time = GetRenderPos()
获取到的时间戳,是我构造的测试代码。
3.current_time_ 有了,我们就可以用 decoded_frames_unit = video_decode_service_->GetRenderFrameAtPtsOrNull(current_time)
来从 VideoDecodeService 中获取到视频帧,因为 VideoDecodeService 有一个单独的线程自己对视频进行解码(代码解析前面提到过)。所以这里可能出现获取不到视频帧的情况,这也是后续需要完善的地方。
4.获取到了视频帧时候会用 frame_renderer_.Render(current_time, std::move(decoded_frames_unit))
来渲染。
5.这里我们先回忆一下,frame_renderer_ 是怎么来的。通过第二章的讲解我们知道,frame_renderer_ 是在 NativeWSMediaPlayer 被创建的时候同时创建的。
6.我们进入 FrameRenderer 类中,会发现几个参数,我这里先简单解释一下,后面一些会分析其代码:
1.ShaderProgramPool:提供各种 "ShaderProgram",例如将 Yuv420 转化为 Argb 的 Yuv420ToRgbShaderProgram、拷贝 Argb 的 CopyArgbShaderProgram、将 Argb 图像绘制到屏幕上的 WsFinalDrawProgram。同时它还提供纹理数据对象的封装类 WsTexture。
2.AVFrameRgbaTextureConverter:整合了 Yuv420ToRgbShaderProgram 和 WsFinalDrawProgram,可以将 AVFrame 转化成 WsTexture。
7.简单了解了 FrameRenderer,我们回到 FrameRenderer::Render,然后进入 FrameRenderer::RenderInner。
1.代码中先更新了一些数据 render_width/height 这个表示我们在第二章中提到的渲染区域的宽高。project_width/height 则表示视频的宽/高。showing_media_asset_rotation_ 表示视频旋转的角度,showing_media_asset_index_ 表示正在播放的是第几个视频(我们的 EditorProject 支持按顺序添加多个视频)。
2.然后如果传入的 current_frame_unit_ 是一个新视频帧的话,那么就通过 current_original_frame_texture_ = avframe_rgba_texture_converter_.Convert
来将AVFrame 转化成 WsTexture。此时视频帧已经从内存中被拷贝到了显存中了,WsTexture.gl_texture_ 可以理解为显存中纹理(视频帧)数据对象的指针。
3.再继续给 WsFinalDrawProgram 设置 render_width/height 和 project_width/height 以保证视频帧能够正确的绘制到渲染区域中。
4.最终通过 GetWsFinalDrawProgram()->DrawGlTexture
将视频帧真正的绘制到屏幕上。
2.OpenGL缓存和绘制解析
通过上一小结的介绍,我们知道了绘制视频帧的大致流程,但是我们只是粗略的介绍了整个渲染流程。所以这一节作为上一节的补充,会简单介绍一下我们的 OpenGL 缓存逻辑和绘制逻辑。
1.我们在第一章介绍 OpenGL 的运行机制的时候提到:OpenGL 需要用到的数据全部都是从内存中发送到显存中的。如果是普通的坐标数据还好数据量比较小,但如果是像我们提到的视频帧数据 的话,每次绘制都进行申请和释放的话,那样会造成很大的浪费。所以我们首先要讲到的就是视频帧数据对象 的复用(后面以纹理对象 来代替)。
1.还记得我们上一节中提到的 WsTexture 吗?这个对象就是我对纹理对象的封装。它里面有几个参数:width_/height_ 分别像素数量、gl_texture_ 就是纹理对象的地址、is_deleted_ 表示纹理对象是否已经被回收。
2.既然要复用对象,那么 pool 就少不了。所以 WsTexturePool 就是为了复用 WsTexture 而定义的。我们可以看见其内部有一个 texture_map_,用于存储 WsTexture,key 就是纹理对象的长宽。每次调用 WsTexturePool::GetWsTexturePtr 获取 WsTexture 的时候,都会先从 texture_map_ 中寻找是否有合适的。如果有就直接返回,如果没有则创建一个然后添加到 texture_map_ 中。
3.再继续看 WsTextureFbo,这个对象是对 WsTexture + fbo 的封装。fbo 是什么?如果把纹理对象比作 Bitmap 的话,那么 fbo 可以被认为是 Canvas。
2.我们前面提到了 shader program 是由 cpu 编译而成,编译又是一个需要耗费时间的过程。那么我们是否可以缓存 shader program 呢,毕竟某一个操作的 shader program 是固定的,例如我们在上一节提到的:将 Yuv420 转化为 Argb 的操作。shader program 当然也是可以缓存的, 所以我们就使用了 Yuv420ToRgbShaderProgram、CopyArgbShaderProgram 等等类来封装某一个 shader program。
3.介绍完了 shader program 和 纹理对象的缓存,上一节提到的 ShaderProgramPool 的用处就水落石出了。
4.剩下的 OpenGL 的绘制逻辑就交给读者们自己去分析啦!
四、尾巴
又是一篇大几千字的干货出炉,希望这篇文章能让你满意,废话不多说,我们下篇文章见。
连载文章
1.从零开始仿写一个抖音app——开始
4.从零开始仿写一个抖音App——日志和埋点以及后端初步架构
5.从零开始仿写一个抖音App——app架构更新与网络层定制
6.从零开始仿写一个抖音App——音视频开篇
7.从零开始仿写一个抖音App——基于FFmpeg的极简视频播放器
8.从零开始仿写一个抖音App——跨平台视频编辑SDK项目搭建
9.从零开始仿写一个抖音App——Android绘制机制以及Surface家族源码全解析)
10.从零开始仿写一个抖音App——视频编辑SDK开发(一)
不贩卖焦虑,也不标题党。分享一些这个世界上有意思的事情。题材包括且不限于:科幻、科学、科技、互联网、程序员、计算机编程。下面是我的微信公众号:世界上有意思的事 ,干货多多等你来看。