Web 端 H265 wasm 软解入门

1,399 阅读2分钟

相比硬解视频,虽然软解的性能上会相差挺多,但是软解兼容性好,针对一些不支持硬解的设备,还是需要软解来兜底。

H265软解架构图

image.png

简易流程是基于 c 调用 FFmpeg 的解码能力,封装打包成 wasm 供 js 调用;通过 js 将 H265 裸流传入给 c 进行消费解码,回调出的 yuv420p 数据使用 canvas 进行绘制;

源码编译 FFmpeg

安装 emcc

用来将 FFmpeg 以及我们自己写的 c 代码编译成 wasm

  • 直接参考官方文档安装即可(我用的是截止目前的最新版本 3.1.57)
  • 需要注意的是,官方文档中安装有一些前置条件,检查好按需安装即可

获取 FFmpeg 并编译

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 单元

image.png

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

image.png

  • YUV 图像有两种存储格式
    • packed: 每个像素点的Y,U,V是连续交叉存储的
    • planar: 先连续存储所有像素点的Y,然后存储所有像素点的U,最后存储所有像素点的V
  • yuv420P 使用的是 planar 格式
  • 结合下图,假设现在已知图像的 width 及 height,那么 yLength 为 width * height; uvLength 为 (width / 2) * (height / 2) image.png

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 值随后被用于填充像素的颜色。

单线程解码效果

image.png

  • 平均解码一帧的时间在 36 毫秒左右
  • 具体时间取决于很多方面, 设备性能, 画面复杂程度等, 这里只是一个大致的统计时间, 方便跟后续多线程以及simd优化后进行数据上的对比

开启 FFmpeg 多线程解码优化

开启步骤

  • FFmpeg 编译脚本中添加 --enable-pthreads
  • decode_hevc.c 代码中添加 codec_context->thread_count = ${线程数}
  • wasm 编译脚本中添加 -pthread -s USE_PTHREADS=1

开启2个线程的效果

image.png

  • 对比上面单线程的效果,快了将近 40% 左右

开启4个线程的效果

image.png

  • 可以看到, 解码时间进一步提升, 相比单线程快了将近 60% 左右, 相比开启2个线程, 速度并没有翻倍
  • 因此线程并不是越多越好,每个线程都存在内存开销和系统开销, wasm 包比较大的时候会更明显; 单 H265 软解的 wasm 包并不算大, 压缩完200kb左右
  • 线程创建虽然相对快, 但是浏览器创建 Worker 比较慢,需要根据实际场景找到平衡

开启 FFmpeg simd 优化

开启步骤

  • FFmpeg 脚本添加 --extra-cflags="-c -Oz -fPIC -msse -msse2 -msse3 -msimd128"
  • wasm 脚本添加 -msimd128

开启效果

image.png

  • 效果跟开启2个线程差不多, 提升 40% 左右

小结

  • 简单分享了 H265 软解的实现方式, H264 可以复用上述流程
  • 软解源码地址: github.com/ponkans/hev…
  • 由于软解的性能瓶颈, 一般配合硬解效果更好, 软解作为兜底
    • 硬解主要有 mse 方案, 之前分享过WebRTC 支持 H265 硬解
    • 以及 webcodecs 方案(webcodecs 解码成 videoFrame, webgl 直接绘制), 后续有时间了继续分享