【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 處理,為此,需要做不少工作:
- 不使用之前的全螢幕覆蓋的紋理,改為每個圖層獨立創建小紋理
- 直接把 assimg->bitmap 原封不動複製到字幕紋理中
- 把 assimg->color 作為常量緩衝,和紋理歸為一組資源,放入 pipeline
- 通過 assimg->dst_x 和 assimg->dst_y 計算頂點坐標,和紋理歸為一組資源,放入pipeline,讓字幕渲染到正確的位置
- 通過 著色器 來對像素顏色進行處理,充分利用GPU。
- 把這些資源放到一個數組中保存起來,當字幕沒有變化時(isChange == 0),直接開始 D3D 的渲染流程,跳過寫入數據到紋理等資源的過程
- 因為我們沒法預測字幕點陣圖的大小,以及 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);
}
結果截圖:
即使是數量較多的彈幕,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 這樣跨平台的庫來實現,這就導致其幾乎沒有硬體加速能力,如果要完全硬體加速,恐怕得自己寫渲染程式碼了。