相比硬解视频,虽然软解的性能上会相差挺多,但是软解兼容性好,针对一些不支持硬解的设备,还是需要软解来兜底。
H265软解架构图
简易流程是基于 c 调用 FFmpeg 的解码能力,封装打包成 wasm 供 js 调用;通过 js 将 H265 裸流传入给 c 进行消费解码,回调出的 yuv420p 数据使用 canvas 进行绘制;
源码编译 FFmpeg
安装 emcc
用来将 FFmpeg 以及我们自己写的 c 代码编译成 wasm
- 直接参考官方文档安装即可(我用的是截止目前的最新版本 3.1.57)
- 需要注意的是,官方文档中安装有一些前置条件,检查好按需安装即可
获取 FFmpeg 并编译
- git clone git.ffmpeg.org/ffmpeg.git
- git checkout -b 4.1 origin/release/4.1(我用的是4.1的版本)
echo "Beginning Build:"
rm -r ffmpeg
mkdir -p ffmpeg
cd ../ffmpeg
rm -rf ffbuild/.config ffbuild/config.fate ffbuild/config.log ffbuild/config.mak ffbuild/config.sh
make clean
emconfigure ./configure --cc="emcc" --cxx="em++" --ar="emar" --ranlib="emranlib" --prefix=$(pwd)/../hevc-decoder-wasm/ffmpeg --enable-cross-compile --target-os=none --arch=x86_64 --cpu=generic \
--disable-avdevice \
--disable-avformat \
--disable-swresample \
--disable-postproc \
--disable-avfilter \
--disable-programs \
--disable-logging \
--disable-everything \
--disable-ffplay \
--disable-ffprobe \
--disable-asm \
--disable-doc \
--disable-devices \
--disable-network \
--disable-hwaccels \
--disable-parsers \
--disable-bsfs \
--disable-debug \
--disable-protocols \
--disable-indevs \
--disable-outdevs \
--disable-decoders \
--disable-encoders \
--disable-demuxers \
--disable-muxers \
--disable-filters \
--disable-swscale \
--enable-gpl \
--enable-version3 \
--enable-decoder=hevc \
--enable-parser=hevc \
make
make install
- 因为主要是用来软解 H265, 因此将一些不必要的模块禁用掉了(比如 disable-demuxers 解封装)
- rm -rf ffbuild/.config ffbuild/config.fate ffbuild/config.log ffbuild/config.mak ffbuild/config.sh 这一行是用来修改脚本,重新执行的时候,移除掉上一次生成的 FFmpeg config 配置,不然会命中上一次的配置
decode_hevc.c 源码解析
文末会给出可运行的 dmeo, 这里主要分析下各个方法是干什么的
init_decoder
用来初始化解码器,以及为相应的数据分配内存
- 比如 frame->format = AV_PIX_FMT_YUV420P; 指定 frame 格式为 YUV420P
decode_AnnexB_buffer
ErrorCode decode_AnnexB_buffer(const uint8_t* buffer, size_t buffer_size) {
ErrorCode ret = kErrorCode_Success;
while (buffer_size > 0) {
int size = av_parser_parse2(parser_context, codec_context, &pkt->data,
&pkt->size, buffer, buffer_size, AV_NOPTS_VALUE,
AV_NOPTS_VALUE, 0);
if (size < 0) {
ret = kErrorCode_FFmpeg_Error;
break;
}
buffer += size;
buffer_size -= size;
if (pkt->size) {
ret = decode_AVPacket(codec_context, frame, pkt);
if (ret != kErrorCode_Success) {
break;
}
}
}
return ret;
}
使用 libavcodec 提供的 av_parser_parse2 方法对 AnnexB 数据进行解析,生成 AVPacket
- 其中 av_parser_parse2 方法就是从 js 接收 AnnexB 的数据, 注意这里不要求一定是严格的一帧数据(av_parser_parse2 会一直解析 avpacket 直到没有数据为止), 所以可能是多帧数据
- 一般裸流使用 AnnexB 的格式,AVCC 常用于 MP4 等封装中
- 其中 AVPacket 就是下图中一个一个的 nalu 单元
decode_AVPacket
- 使用 libavcodec 提供的 avcodec_send_packet 以及 avcodec_receive_frame 方法对 AVPacket 进行解析, 生成 avframe 数据
- 通过 av_image_copy_to_buffer 将 avframe 中的 YUV 数据转换为一个连续的缓冲区, 这个连续的缓冲区是一个一维的
uint8_t
数组,可以被JavaScript 以线性方式访问 - 将转换后的
uint8t
yuv_buffer 回调给 js 进行绘制
decode_hevc.c 源码编译成 wasm
rm -rf dist/libffmpeg_265.wasm dist/libffmpeg_265.js
export TOTAL_MEMORY=67108864
export EXPORTED_FUNCTIONS="[ \
'_init_decoder', \
'_flush_decoder', \
'_close_decoder', \
'_decode_AnnexB_buffer', \
'_main', \
'_malloc', \
'_free'
]"
echo "Running Emscripten..."
# 依赖的 FFmpeg 库文件
emcc decode_hevc.c ffmpeg/lib/libavcodec.a ffmpeg/lib/libavutil.a \
-O3 \
-I "ffmpeg/include" \
-s WASM=1 \
-s TOTAL_MEMORY=${TOTAL_MEMORY} \
-s EXPORTED_FUNCTIONS="${EXPORTED_FUNCTIONS}" \
-s EXTRA_EXPORTED_RUNTIME_METHODS="['addFunction']" \
-s RESERVED_FUNCTION_POINTERS=14 \
-s FORCE_FILESYSTEM=1 \
-o dist/libffmpeg_265.js \
echo "Finished Build"
yuv420P 介绍及 WebGL 绘制
yuv420P
- YUV 图像有两种存储格式
- packed: 每个像素点的
Y,U,V
是连续交叉存储的 - planar: 先连续存储所有像素点的
Y
,然后存储所有像素点的U
,最后存储所有像素点的V
- packed: 每个像素点的
- yuv420P 使用的是 planar 格式
- 结合下图,假设现在已知图像的 width 及 height,那么 yLength 为 width * height; uvLength 为 (width / 2) * (height / 2)
WebGL 绘制
具体代码参考文末 demo, 这里主要说一下比较重要的3部分
- initGL 方法中需要对纹理图像进行y轴反转: gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
- renderFrame 方法中, 在对 y、u、v 分量进行数据填充时, 注意 yuv420P 的特性即可
gl.y.fill(width, height, videoFrame.subarray(0, yLength));
// `width >> 1` 和 `height >> 1` 表示将宽度和高度除以 2,这是因为 U 和 V 数据通常是 Y 数据的四分之一
gl.u.fill(width >> 1, height >> 1, videoFrame.subarray(yLength, yLength + uvLength));
gl.v.fill(width >> 1, height >> 1, videoFrame.subarray(yLength + uvLength, videoFrame.length));
- 在
initGL
方法中定义的YUV2RGB
矩阵用于将 YUV 颜色空间的数据转换为 RGB 颜色空间
const mat4 YUV2RGB = mat4 ( 1.0, 0.0, 1.402, -0.701, 1.0, -0.34414, -0.71414, 0.529, 1.0, 1.772, 0.0, -0.886, 0.0, 0.0, 0.0, 1.0 );
这个矩阵是一个 4x4 的变换矩阵,用于将 YUV 颜色空间的点映射到 RGB 颜色空间。矩阵的每一行对应于 RGB 颜色空间中的一个颜色通道(红、绿、蓝),而每一列对应于 YUV 颜色空间中的一个分量(Y、U、V)以及一个常数项。
矩阵的工作原理如下:
第一列:表示如何从 YUV 转换到 RGB 的红色通道
第二列:表示如何从 YUV 转换到 RGB 的绿色通道。
第三列:表示如何从 YUV 转换到 RGB 的蓝色通道。
第四列:是一个位移或偏移量,通常用于调整颜色的亮度。在这个例子中,对于 R、G、B 通道,常数项都是 0,表示不进行额外的亮度调整。最后一个值是 1.0,表示矩阵乘法后的归一化因子。
在 WebGL 的片段着色器中,这个矩阵被用于将纹理采样的 YUV 值转换为 RGB 值。转换后的 RGB 值随后被用于填充像素的颜色。
单线程解码效果
- 平均解码一帧的时间在 36 毫秒左右
- 具体时间取决于很多方面, 设备性能, 画面复杂程度等, 这里只是一个大致的统计时间, 方便跟后续多线程以及simd优化后进行数据上的对比
开启 FFmpeg 多线程解码优化
开启步骤
- FFmpeg 编译脚本中添加 --enable-pthreads
- decode_hevc.c 代码中添加 codec_context->thread_count = ${线程数}
- wasm 编译脚本中添加 -pthread -s USE_PTHREADS=1
开启2个线程的效果
- 对比上面单线程的效果,快了将近 40% 左右
开启4个线程的效果
- 可以看到, 解码时间进一步提升, 相比单线程快了将近 60% 左右, 相比开启2个线程, 速度并没有翻倍
- 因此线程并不是越多越好,每个线程都存在内存开销和系统开销, wasm 包比较大的时候会更明显; 单 H265 软解的 wasm 包并不算大, 压缩完200kb左右
- 线程创建虽然相对快, 但是浏览器创建 Worker 比较慢,需要根据实际场景找到平衡
开启 FFmpeg simd 优化
开启步骤
- FFmpeg 脚本添加 --extra-cflags="-c -Oz -fPIC -msse -msse2 -msse3 -msimd128"
- wasm 脚本添加 -msimd128
开启效果
- 效果跟开启2个线程差不多, 提升 40% 左右
小结
- 简单分享了 H265 软解的实现方式, H264 可以复用上述流程
- 软解源码地址: github.com/ponkans/hev…
- 由于软解的性能瓶颈, 一般配合硬解效果更好, 软解作为兜底
- 硬解主要有 mse 方案, 之前分享过WebRTC 支持 H265 硬解
- 以及 webcodecs 方案(webcodecs 解码成 videoFrame, webgl 直接绘制), 后续有时间了继续分享