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

最後的紳士發表於2021-08-12

前言

前段時間曾經寫過一個視訊播放器:https://www.cnblogs.com/judgeou/p/14746051.html

然而這個播放器卻無法顯示出外掛或者內封的字幕,這裡要稍微解釋一下,字幕存在的三種形式:

  • 內嵌:字幕是畫面的一部分
  • 內封:把字幕檔案,例如ass檔案放入了視訊檔案。
  • 外掛:字幕單獨是一個檔案。

在內嵌的情況下不需要特殊處理,而內封和外掛的時候,就需要進行額外的工作才能看到字幕。

在 DX 11.1 之前,往 D3D 表面渲染文字的主要方式,是通過把字型檔案變成圖片的形式載入到紋理,然後再利用文字編碼對映到紋理UV的方式來渲染文字,當我們使用 Imgui 的時候他就是這麼處理文字的。

優點:可以高速切換文字,渲染效率高。缺點:如果遇上中文字型等字元數量龐大的字型,那麼開頭載入字型到紋理這個階段需要耗費大量CPU時間,之後也需要佔據大量視訊記憶體。如果需要顯示比較大而且又清晰的字型,那麼這種負面情況會加劇,如果大到一定程度,一張紋理都塞不下,情況會變得相當複雜。(除非你不介意糊的話可以用小紋理渲染大字型)。以及,一些字型排版、特效等難以實現。

DX 11.1 之後,Direct2D 可以和 Direct3D 互操作了,d2d 的文字渲染功能全都可以利用起來,渲染高質量字型不再是問題。

不過今天要說的 libass 這個庫(https://github.com/libass/libass)使得我們不需要關心上面說的問題,基本幫我們把所有字型特效全部搞定了。

編譯 libass

當一個庫宣稱自己跨平臺的時候,就說明在 Windows 上可能要折騰一番了。這個庫使用了典型的 GNU Build System,在 MinGW64 上可以非常流暢的編譯成功,如果你不介意使用DLL檔案進行動態連結,可以嘗試自己在 MinGW64 上編譯原始碼試試。但是如果你想靜態連結到自己的專案,那麼 MinGW64 上編譯出來的庫檔案在 Visual Studio 中多半會不能用。。。

通過一番折騰,我已經把 libass 程式碼以及其依賴項整理好了,弄成了 vs 的專案:https://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 https://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 這樣跨平臺的庫來實現,這就導致其幾乎沒有硬體加速能力,如果要完全硬體加速,恐怕得自己寫渲染程式碼了。

相關文章