上次我们使用FFMPEG做了编码:

https://srcblog.ffeng123.win:23443/archives/b6c5c8d0-273f-4ca2-896c-c48b08e9a4a9

当时几乎所有代码都是借鉴(抄袭)的,这次我们要在虚幻中播放视频,并没有什么能找到(抄袭)的资料。

所以呢——

创造问题,解决问题,开始!

基本架构

在主线程上解码视频是不可取的,我们要设计一个合理的架构。

其中,解码器线程是要我们自己管理的。

视频解码器

首先是最基础的部分,解码视频。

我希望将视频解码输出成一张纹理,然后每调用一次更新就刷新一次纹理。

基础知识

一个视频文件里面有多个,一般包含一个视频流和一个音频流。

我们实现的程序假设存在音频视频流各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仓库

我能想到的,最大的成功就是无愧于自己的心。