【C++】使用 libass,完成 Direct3D 11 下的字幕渲染

前言

前段时间曾经写过一个视频播放器://www.cnblogs.com/judgeou/p/14746051.html

然而这个播放器却无法显示出外挂或者内封的字幕,这里要稍微解释一下,字幕存在的三种形式:

  • 内嵌:字幕是画面的一部分
  • 内封:把字幕文件,例如ass文件放入了视频文件。
  • 外挂:字幕单独是一个文件。

在内嵌的情况下不需要特殊处理,而内封和外挂的时候,就需要进行额外的工作才能看到字幕。

在 DX 11.1 之前,往 D3D 表面渲染文字的主要方式,是通过把字体文件变成图片的形式载入到纹理,然后再利用文字编码映射到纹理UV的方式来渲染文字,当我们使用 Imgui 的时候他就是这么处理文字的。

优点:可以高速切换文字,渲染效率高。缺点:如果遇上中文字体等字符数量庞大的字体,那么开头载入字体到纹理这个阶段需要耗费大量CPU时间,之后也需要占据大量显存。如果需要显示比较大而且又清晰的字体,那么这种负面情况会加剧,如果大到一定程度,一张纹理都塞不下,情况会变得相当复杂。(除非你不介意糊的话可以用小纹理渲染大字体)。以及,一些字体排版、特效等难以实现。

DX 11.1 之后,Direct2D 可以和 Direct3D 互操作了,d2d 的文字渲染功能全都可以利用起来,渲染高质量字体不再是问题。

不过今天要说的 libass 这个库(//github.com/libass/libass)使得我们不需要关心上面说的问题,基本帮我们把所有字体特效全部搞定了。

编译 libass

当一个库宣称自己跨平台的时候,就说明在 Windows 上可能要折腾一番了。这个库使用了典型的 GNU Build System,在 MinGW64 上可以非常流畅的编译成功,如果你不介意使用DLL文件进行动态链接,可以尝试自己在 MinGW64 上编译源代码试试。但是如果你想静态链接到自己的项目,那么 MinGW64 上编译出来的库文件在 Visual Studio 中多半会不能用。。。

通过一番折腾,我已经把 libass 代码以及其依赖项整理好了,弄成了 vs 的项目://gitee.com/judgeou/libass-msvc,拿来直接编译就行。

初始化 libass

libass 的函数全都是以 ass_ 开头,很好辨认。

初始化操作没有太多要注意的:

ASS_Library* libass = ass_library_init();
ASS_Renderer* ass_renderer = ass_renderer_init(libass);
ASS_Track* ass_track = ass_read_file(libass, (char*)"subtitle.ass", (char*)"UTF-8");

ass_set_fonts(ass_renderer, NULL, "Arial", ASS_FONTPROVIDER_AUTODETECT, NULL, 0);
ass_set_frame_size(ass_renderer, bgWidth, bgHeight);

subtitle.ass 就是你字幕文件的路径,ass_set_fonts 第二、三个参数可以选择你想要的默认字体(当找不到对应字体的时候会使用)。

ass_set_frame_size 设置视频画面的分辨率,注意,这里是视频渲染时的分辨率,而不是原画分辨率,这样 libass 才会返回正确大小的位图。

渲染字幕

libass 渲染字幕的函数只有一个:ass_render_frame:

long long currentms = 6000; // 生成哪一时刻的字幕,单位是毫秒
int isChange = 0; // 与上一次生成比较,0:没有变化,1:位置变了,2: 内容变了
ASS_Image* assimg = ass_render_frame(ass_renderer, ass_track, currentms, &isChange);

ASS_Image 结构体是我们重点关注的东西:

/*
 * A linked list of images produced by an ass renderer.
 *
 * These images have to be rendered in-order for the correct screen
 * composition.  The libass renderer clips these bitmaps to the frame size.
 * w/h can be zero, in this case the bitmap should not be rendered at all.
 * The last bitmap row is not guaranteed to be padded up to stride size,
 * e.g. in the worst case a bitmap has the size stride * (h - 1) + w.
 */
typedef struct ass_image {
    int w, h;                   // Bitmap width/height
    int stride;                 // Bitmap stride 位图每一行有多少字节
    unsigned char *bitmap;      // 1bpp stride*h alpha buffer 仅含 alpha 通道的位图,大小是 stride * h
                                // Note: the last row may not be padded to 
                                // bitmap stride! 注意,最后一行可能不会填充满,意思是读取最后一行的时候,读够 w 字节就行了
    uint32_t color;             // Bitmap color and alpha, RGBA 位图使用的 RGBA 颜色
    int dst_x, dst_y;           // Bitmap placement inside the video frame 该位图应该显示在视频画面中的哪个位置

    struct ass_image *next;   // Next image, or NULL 下一个 image

    enum {
        IMAGE_TYPE_CHARACTER,
        IMAGE_TYPE_OUTLINE,
        IMAGE_TYPE_SHADOW
    } type;

} ASS_Image;

很明显这是一个链表,libass 实际会生成多层的图像,我们需要一层一层的逐一渲染才能看到正确的字幕。

按照一般思维,我们猜测 libass 应该要返回一个 RGBA 位图,这样我们只要使用常规的手段可以简单的把位图显示在画面的某处,但 libass 返回的位图竟然只有 alpha 通道,外加一个单一的颜色值,这就有点棘手了。

首先我们单独创建一个和画面大小相同的 RGBA 格式纹理 subTexture,把字幕写入到这个纹理,然后先渲染视频画面,再渲染字幕纹理,这样字幕就居于视频之上了。这里必须要注意渲染字幕的纹理前一定要调用 OMSetBlendState 告诉 DIrect3D 接下来要进行 alpha 混合,否则透明的像素会渲染为黑色而不是它后面的视频纹理像素。

实现关键代码:

int isChange = 0;
auto assimg = ass_render_frame(ass_renderer, ass_track, currentms, &isChange);

if (isChange != 0) {
	int count = 0;
	D3D11_MAPPED_SUBRESOURCE mapped;
	d3dctx->Map(subTexture.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
	memset(mapped.pData, 0, mapped.DepthPitch);

	if (assimg) {
		while (assimg) {
			auto src = assimg->bitmap;
			auto dst = (UCHAR*)mapped.pData;
			// 正确计算字幕的起始位置
			dst = dst + assimg->dst_y * mapped.RowPitch + assimg->dst_x * 4;

			for (int y = 0; y < assimg->h; ++y) {
				for (int x = 0; x < assimg->w; ++x) {
					auto i = assimg;
					auto pixel = dst + x * 4;

					auto srcA = (src[x] * (0xff - (assimg->color & 0x000000ff))) >> 8;
					auto compA = 0xff - srcA;

					double alpha = (255 - src[x]) / 255.0;
					UCHAR rb = (assimg->color & 0xff000000) >> 24;
					UCHAR gb = (assimg->color & 0x00ff0000) >> 16;
					UCHAR bb = (assimg->color & 0x0000ff00) >> 8;

					UCHAR ra = pixel[0];
					UCHAR ga = pixel[1];
					UCHAR ba = pixel[2];
					UCHAR aa = pixel[3];

					pixel[0] = (1 - alpha) * rb + alpha * ra;
					pixel[1] = (1 - alpha) * gb + alpha * ga;
					pixel[2] = (1 - alpha) * bb + alpha * ba;
					pixel[3] = (1 - alpha) * src[x] + alpha * aa;
				}
				// 指针移动到下一行
				src += assimg->stride;
				dst += mapped.RowPitch;
			}
			assimg = assimg->next;
		}

		d3dctx->Unmap(subTexture.Get(), 0);
	}
}

ctx->DrawIndexed(indicesSize, 0, 0);

主要就是循环每个像素写入正确的颜色值,渲染每一层的时候,注意要和上一层的像素手动进行alpha混合。所有层写入完毕后再 调用 DrawIndexed 渲染到 D3D 表面。

这种方法的一个问题在于,效率太低了,中间的混合过程运算并不轻松,而且迭代次数过多,经常动不动就一个图层接近一万次的迭代,如果用来渲染数量庞大的弹幕比PPT还卡。

想要提升效率,一是要减少迭代,二是要尽可能把运算交给 GPU 处理,为此,需要做不少工作:

  1. 不使用之前的全屏覆盖的纹理,改为每个图层独立创建小纹理
  2. 直接把 assimg->bitmap 原封不动复制到字幕纹理中
  3. 把 assimg->color 作为常量缓冲,和纹理归为一组资源,放入 pipeline
  4. 通过 assimg->dst_x 和 assimg->dst_y 计算顶点坐标,和纹理归为一组资源,放入pipeline,让字幕渲染到正确的位置
  5. 通过 着色器 来对像素颜色进行处理,充分利用GPU。
  6. 把这些资源放到一个数组中保存起来,当字幕没有变化时(isChange == 0),直接开始 D3D 的渲染流程,跳过写入数据到纹理等资源的过程
  7. 因为我们没法预测字幕位图的大小,以及 D3D 纹理大小是固定的,所以每次字幕变化时,都需要重新创建纹理,之前的纹理无法重复使用,必须要销毁。
// 创建字幕图层的只读的纹理
void CreateOneTimeTexture(ID3D11Device* d3ddevice, int width, int height, ID3D11Texture2D** subTexture, ID3D11ShaderResourceView** srv, const UCHAR* data, int pitch) {
	D3D11_TEXTURE2D_DESC subDesc = {};
	subDesc.Format = DXGI_FORMAT_R8_UNORM;
	subDesc.ArraySize = 1;
	subDesc.MipLevels = 1;
	subDesc.SampleDesc = { 1, 0 };
	subDesc.Width = width;
	subDesc.Height = height;
	subDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
	subDesc.Usage = D3D11_USAGE_IMMUTABLE;

	D3D11_SUBRESOURCE_DATA sd = {};
	sd.pSysMem = &data[0];
	sd.SysMemPitch = pitch;

	ComPtr<ID3D11Texture2D> tempTexture;
	if (subTexture == NULL) {
		subTexture = &tempTexture;
	}

	d3ddevice->CreateTexture2D(&subDesc, &sd, subTexture);

	if (srv) {
		// 创建着色器资源
		D3D11_SHADER_RESOURCE_VIEW_DESC const srvDesc = CD3D11_SHADER_RESOURCE_VIEW_DESC(
			*subTexture,
			D3D11_SRV_DIMENSION_TEXTURE2D,
			subDesc.Format
		);

		d3ddevice->CreateShaderResourceView(
			*subTexture,
			&srvDesc,
			srv
		);
	}

}

// 每一个字幕图层需要的 D3D 资源
struct SubtitleD3DResource {
	ComPtr<ID3D11Texture2D> tex;
	ComPtr<ID3D11ShaderResourceView> srv;
	ComPtr<ID3D11Buffer> cb_color;
	ComPtr<ID3D11Buffer> vertex;

	SubtitleD3DResource(ID3D11Device* device, int w, int h, const UCHAR* texdata, int pitch, uint32_t color, const vector<Vertex>& vertices) {
		// ... 在这里创建好这个结构体的 D3D 资源。
		CreateOneTimeTexture(device, w, h, &tex, &srv, texdata, pitch);

		D3D11_BUFFER_DESC bd = {};
		bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
		bd.Usage = D3D11_USAGE_IMMUTABLE;
		bd.ByteWidth = vertices.size() * sizeof(Vertex);
		bd.StructureByteStride = sizeof(Vertex);
		D3D11_SUBRESOURCE_DATA sd = {};
		sd.pSysMem = &vertices[0];

		device->CreateBuffer(&bd, &sd, &vertex);

		D3D11_BUFFER_DESC cbd = {};
		cbd.Usage = D3D11_USAGE_IMMUTABLE;
		cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
		cbd.ByteWidth = 16;
		cbd.StructureByteStride = sizeof(uint32_t);
		D3D11_SUBRESOURCE_DATA csd = {};
		csd.pSysMem = &color;

		device->CreateBuffer(&cbd, &csd, &cb_color);
	}
};

void Draw () {
// ...

	int isChange = 0;
	auto assimg = ass_render_frame(ass_renderer, ass_track, currentms + 6300, &isChange);

	if (isChange) {
		subsD3DResource.clear(); // SubtitleD3DResource的数组,重新写入字幕位图时需要清空,回收资源

		if (assimg) {
			while (assimg) {
				// 计算UV,这里要进行归一化,转换为 [0.0, 1.0]
				float u1 = (float)assimg->dst_x / sub_frame_width;
				float v1 = (float)assimg->dst_y / sub_frame_height;
				float u2 = ((float)assimg->dst_x + assimg->w) / sub_frame_width;
				float v2 = ((float)assimg->dst_y + assimg->h) / sub_frame_height;
				// 计算顶点坐标,这里把上面的结果变为 [-1.0, +1.0]
				float x1 = u1 * 2 - 1;
				float y1 = 1 - v1 * 2;
				float x2 = u2 * 2 - 1;
				float y2 = 1 - v2 * 2;

				vector<Vertex> vertices = {
					{x1,	y1,	0,	0,	0},
					{x2,	y1,	0,	1,	0},
					{x2,	y2,	0,	1,	1},
					{x1,	y2,	0,	0,	1},
				};

				// 直接把 bitmap 复制到纹理,没有任何多余的循环
				SubtitleD3DResource subRes(d3ddevice.Get(), assimg->w, assimg->h, assimg->bitmap, assimg->stride, assimg->color, vertices);
				subsD3DResource.push_back(subRes);

				assimg = assimg->next;
			}
		}
	}
	
	// 按顺序渲染每一个图层
	for (auto& subRes : subsD3DResource) {
		ID3D11Buffer* vertexBuffers2[] = { subRes.vertex.Get() };
		ctx->IASetVertexBuffers(0, 1, vertexBuffers2, &stride, &offset);

		ID3D11Buffer* cbs2[] = { subRes.cb_color.Get() };
		ctx->PSSetConstantBuffers(0, 1, cbs2);

		ID3D11ShaderResourceView* srvs2[] = { subRes.srv.Get() };
		ctx->PSSetShaderResources(0, 1, srvs2);

		ctx->DrawIndexed(indicesSize, 0, 0);
	}

// ...
}

渲染字幕纹理时,使用下面这个着色器:

Texture2D<float> tex : register(t0);

SamplerState splr;

cbuffer CBuf
{
    uint color;
};

float4 main_PS_ass(float2 tc : TEXCOORD) : SV_TARGET
{
    float alpha = tex.Sample(splr, tc); // 从纹理中取得 alpha 值

    // 从常量缓冲取得 rgb 值
    float r = ((color & 0xff000000) >> 24) / 255.0;
    float g = ((color & 0x00ff0000) >> 16) / 255.0;
    float b = ((color & 0x0000ff00) >> 8) / 255.0;

    return float4(r, g, b, alpha);
}

结果截图:

image

即使是数量较多的弹幕,CPU占用也不算太高了。但是如果再多一些还是会卡顿,如果还要优化,就需要抛弃 libass 的渲染代码,自己用 Direct2D 进行文字渲染,避免我这样每次都创建新的纹理,事实上大多数CPU都花费在了创建新纹理上。又或者想出一种办法可以用一张纹理通过着色器程序一次搞定,反正我是想不出来了。如果大家有什么好办法,请务必在评论区告诉我。

内封字幕

对于内封字幕,其实处理方法大同小异。ASS_Track 的获得方式不再是 ass_read_file,而是使用 ass_new_track 创建一个空的 track,在打开视频字幕流的时候(AVCodec.type == AVMEDIA_TYPE_SUBTITLE),从 AVCodecContext.extradata 可以取得 ASS 的文件头,类似这样的内容(注意它是UTF-8编码的):

[Script Info]
Title: 侦探已死:1下_番剧_bilibili_哔哩哔哩
Original Script: Generated by tiansh/ass-danmaku (embedded in liqi0816/bilitwin) based on //www.bilibili.com/bangumi/play/ep409795?spm_id_from=333.851.b_62696c695f7265706f72745f616e696d65.53
ScriptType: v4.00+
Collisions: Normal
PlayResX: 560
PlayResY: 420
Timer: 100.0000

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Fix,SimHei,25,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,20,20,2,0
Style: Rtl,SimHei,25,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,20,20,2,0

随后调用 ass_process_codec_private

ass_process_codec_private(ass_track, (char*)subcodecCtx->extradata, subcodecCtx->extradata_size);

在循环解码的阶段,判断 AVPacket 的解码器类型是 AVMEDIA_TYPE_SUBTITLE 时,则可从 packet 中的 pts 和 duration 得到该字幕应当出现的时刻与时长(注意要根据 timebase 转换成毫秒)。调用 avcodec_decode_subtitle2 可取得 AVSubtitle 对象,从 AVSubtitle.rects 取得 AVSubtitleRect**,因为同一时刻可能存在多个字幕。AVSubtitleRect.ass 就是我们要的东西,它通常是一行 ass event,总之调用 ass_process_chunk 把这行字符串交给 libass 即可。

AVPacket* packet;
// ... 读取 packet

// 这里得到的是秒
double duration = packet->duration * subtitleTimeBase;
double pts = packet->pts * subtitleTimeBase;

AVSubtitle sub = {};
int got_sub_ptr = 0;
avcodec_decode_subtitle2(codecCtx, &sub, &got_sub_ptr, packet);

if (got_sub_ptr) {
	int num = sub.num_rects;
	for (int i = 0; i < num; i++) {
		auto rect = sub.rects[i];
		ass_process_chunk(ass_track, rect->ass, strlen(rect->ass), pts * 1000, duration * 1000); // 乘以 1000 转换成毫秒
	}
}

之后的处理就和上面一样了,依然是调用 ass_render_frame 来获得图层。

结尾

libass 的 bitmap 通常 stride 会大于 w,多出来的部分是用来填充的空白数据,为什么要多此一举呢,简单的来说就是要对齐字节,加速 CPU 处理。比如现在 64 位 CPU 少说一次读取也能读取 64bit,也就是一个 long long 或者 两个 int,通过恰当的安排可以减少 CPU 读取次数。

为了兼容各平台,libass 没有使用和平台密切相关的技术,例如 Direct2D(最多也是用来获取字体),字体的绘制都是使用例如 freetype 这样跨平台的库来实现,这就导致其几乎没有硬件加速能力,如果要完全硬件加速,恐怕得自己写渲染代码了。