上次我们使用FFMPEG做了编码:
当时几乎所有代码都是借鉴(抄袭)的,这次我们要在虚幻中播放视频,并没有什么能找到(抄袭)的资料。
所以呢——
创造问题,解决问题,开始!
基本架构
在主线程上解码视频是不可取的,我们要设计一个合理的架构。

其中,解码器线程是要我们自己管理的。
视频解码器
首先是最基础的部分,解码视频。
我希望将视频解码输出成一张纹理,然后每调用一次更新就刷新一次纹理。
基础知识
一个视频文件里面有多个流,一般包含一个视频流和一个音频流。
我们实现的程序假设存在音频视频流各0~1个,多了就不要了。
我们先初始化需要的东西:
基础解码
打开视频文件、创建解码器的代码如下
av_register_all();
if (avformat_open_input(&pFormatCtx, TCHAR_TO_UTF8(*url), NULL, NULL)) {
return false;
}
if (avformat_find_stream_info(pFormatCtx, NULL) < 0) {
return false;
}
// 找视频流
video_stream_index = -1;
for (uint32 i = 0; i < pFormatCtx->nb_streams; i++)
{
if (pFormatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO)
{
video_stream_index = i;
break;
}
}
if (video_stream_index == -1)
{
return false;
}
// 创建解码器
video_codec_ctx = pFormatCtx->streams[video_stream_index]->codec;
video_codec = avcodec_find_decoder(video_codec_ctx->codec_id);
if (video_codec == NULL)
{
return false;
}
if (avcodec_open2(video_codec_ctx, video_codec, NULL) < 0) {
return false;
}
video_width = video_codec_ctx->width;
video_height = video_codec_ctx->height;转换颜色格式
FFmpeg解码后得到的原始数据是YUV格式,而虚幻纹理用的是BGRA格式,需要转换。
做转换有多种方法:
一个循环,CPU逐像素转换
交给FFMPEG转换
不转换,等用的时候给着色器转换(GPU转换)
其中,不转换的话这个纹理就不那么灵活了,所以,我们要转换。
FFMPEG的转换函数似乎是有用一些单指令多数据的CPU指令集优化的,所以,我们选择用FFMPEG的函数转换。
使用如下方式创建一个sws上下文,(这个东西除了可以做颜色转换,还可以做缩放)。
sws_ctx = sws_getContext(
video_width, video_height, video_codec_ctx->pix_fmt,
video_width, video_height, AV_PIX_FMT_BGRA, SWS_FAST_BILINEAR | SWS_ACCURATE_RND | SWS_BITEXACT, NULL, NULL, NULL);音频解码器
然后是音频解码器,明确指定了想要的输出数据格式、采样率和通道数:
audio_stream_index = -1;
for (uint32 i = 0; i < pFormatCtx->nb_streams; i++)
{
if (pFormatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO)
{
audio_stream_index = i;
break;
}
}
if (audio_stream_index == -1)
{
return false;
}
// 创建解码器
audio_codec_ctx = pFormatCtx->streams[audio_stream_index]->codec;
audio_codec_ctx->request_channel_layout = AV_CH_LAYOUT_STEREO;
audio_codec_ctx->request_sample_fmt = AV_SAMPLE_FMT_FLTP;
audio_codec_ctx->sample_rate = FFeng_FFMPEG_SAMPLE_RATE;
audio_codec_ctx->channels = 2;
audio_codec = avcodec_find_decoder(audio_codec_ctx->codec_id);
if (audio_codec == NULL)
{
return false;
}
if (avcodec_open2(audio_codec_ctx, audio_codec, NULL) < 0)
{
return false;
}解码循环
线程的管理我们不再赘述,当解码线程启动时,应当进入解码循环,解码循环里面做这些事情:
判断是不是需要解码(因为我们希望视频的解码速率能够和现实时间相同)
判断是不是需要退出
从文件读取一个包,给视频解码器、音频解码器或丢弃
从视频解码器中取出解码好的帧(如果有)
从音频解码器中取出解码好的帧(如果有)
时间同步
判断是不是需要解码这一步,需要获得正在解码的视频的时间相关的信息。
每个包和每个帧都是有时间码的,但时间码不一定是以秒为单位,为了方便我们处理,我们在拿到各种时间码时先乘以av_q2d(stream->time_base) 。
这样一来,我们定义一个变量residue_time ,现实时间变化加上去,视频帧输出就减去帧之间的时间码差值,在每次解码循环中,如果发现这个值小于零了,就Sleep,等等现实时间,从而实现与现实时间的同步。
音画同步我们就不做啦,我相信视频编码时会保证它俩不会差很多。
av_read_frame(pFormatCtx, &packet) 读取一个包,它的返回值小于0时失败,可能是读到了文件末尾。
avcodec_send_packet(codec_ctx, &packet) 将包放入解码器,这个操作是同步的,会同时进行解码。
所以,在包放入解码器后,就可以尝试从解码器中取帧了。
视频解码
解码时我们同时在解码线程上把颜色格式转换了。主线程资源,能省则省
AVFrame* frame = av_frame_alloc();
while (avcodec_receive_frame(video_codec_ctx, frame) >= 0) {
auto new_pts = frame->pts * video_basetime;
residue_time -= new_pts - video_current_pts;
video_current_pts = frame->pts * video_basetime;
void* frame_buffer;
if (video_frame_queue_free.Dequeue(frame_buffer)) {
// 转换图像格式
uint8_t* dstData[1] = { (uint8_t*)frame_buffer };
int dstLinesize[1] = { frame->width * 4 };
sws_scale(sws_ctx, frame->data, frame->linesize, 0, video_height, dstData, dstLinesize);
// 将帧送入渲染队列
video_frame_queue.Enqueue(frame_buffer);
}
av_frame_free(&frame);
frame = av_frame_alloc();
}
av_frame_free(&frame);音频解码
音频解码出来的数据是AV_SAMPLE_FMT_FLTP ,我们将数据处理成虚幻要的格式再放入队列。
(此处的队列应当是有优化空间的,先暂时这样,对自己温柔点~)
AVFrame* frame = av_frame_alloc();
while (avcodec_receive_frame(audio_codec_ctx, frame) >= 0) {
auto new_pts = frame->pts * audio_basetime;
if (!video_enable) {
residue_time -= new_pts - audio_current_pts;
}
audio_current_pts = new_pts;
const int NumSamples = frame->nb_samples * frame->channels;
float* Left = (float*)frame->data[0];
float* Right = (float*)frame->data[1];
for (int i = 0; i < frame->nb_samples; ++i)
{
audio_frame_queue.Enqueue(Left[i]);
audio_frame_queue.Enqueue(Right[i]);
}
av_frame_free(&frame);
frame = av_frame_alloc();
}
av_frame_free(&frame);这样,我们就将需要的数据放入队列了。
复制给纹理
创建一个纹理:
texture = UTexture2D::CreateTransient(video_width, video_height, PF_B8G8R8A8);
texture->SRGB = 1;
texture->UpdateResource();(此处有一个坑,如果UpdateResource这里报未定义错误,可能是之前的代码中不知道为什么引入了Windows的头文件,它有一个叫UpdateResource的宏,需要#undef UpdateResource )
视频帧在CPU中,是虚幻要的格式,接下来只需要复制过去:
FTexture2DRHIRef RHITexture = texture->GetResource()->GetTexture2DRHI();
ENQUEUE_RENDER_COMMAND(UpdateTexture)([RHITexture, this, framebuffer](FRHICommandListImmediate& RHICmdList) {
uint32 DestStride = 0;
void* TextureData = RHICmdList.LockTexture2D(
RHITexture, 0, RLM_WriteOnly, DestStride, false
);
// 内存直接拷贝(如果Stride匹配)
if (DestStride == (uint32)(this->video_width * 4)) {
FMemory::Memcpy(TextureData, framebuffer, this->video_width * this->video_height * 4);
}
else {
// 处理行对齐不一致的情况
const uint8* SrcRow = (uint8_t*)framebuffer;
uint8* DestRow = (uint8*)TextureData;
for (int y = 0; y < this->video_height; ++y) {
FMemory::Memcpy(DestRow, SrcRow, this->video_width * 4);
SrcRow += this->video_width * 4;
DestRow += DestStride;
}
}
RHICmdList.UnlockTexture2D(RHITexture, 0, false);
video_frame_queue_free.Enqueue(framebuffer);
});至于内存拷贝这里的判断,AI写的,应该能提高性能吧~
播放音频
这里我能想到有两种方法:
实现一个自定义波形类,丢给音频播放器组件播放。
实现一个自定义音频播放器类,播放音频。
我实现了后者。
继承USynthComponent ,实现其中的OnGenerateAudio 方法,从队列中取音频。
组件创建并附加后需要调用一次Activate 才会开始播放。这个坑我踩啦
循环播放
我希望解码器能够循环解码一个视频,这种需求应该是很常见的。
这里也不需要太复杂,我们有两个队列作缓冲区,末尾从头开始时并不会产生什么问题。
我们只需要简单清除一下状态就可以啦。
重置时间码
刷新解码器缓冲
将文件游标挪到开头
if (av_seek_frame(pFormatCtx, -1, 0, AVSEEK_FLAG_BACKWARD) < 0) {
return false;
}
if (video_codec_ctx != nullptr)
{
avcodec_flush_buffers(video_codec_ctx);
video_starttime = 0;
}
if (audio_codec_ctx != nullptr)
{
avcodec_flush_buffers(audio_codec_ctx);
video_starttime = 0;
}
audio_current_pts = audio_starttime;
video_current_pts = video_starttime;
return true;于是,我们实现了FFMPEG的解码。(≧∇≦)/
详细代码见Github仓库