一. GDI程式設計基礎
字幕疊加,應當是屬于圖形、圖像處理的範疇。在Windows平台上,圖形、圖像處理的方法當然首選GDI(Graphics Device Interface,圖形裝置接口)。GDI是什麼?GDI其實是一套API函數;它們功能豐富,使用起來簡單、靈活。下面,我們首先來介紹一些GDI程式設計的基礎知識。
GDI函數有很多,我們大緻可以把它們分成如下幾類:
· 裝置上下文(Device Context,簡稱DC)函數,如GetDC、CreateDC、DeleteDC等;
· 畫線函數,如LineTo、Polyline、Arc等;
· 填充畫圖函數,如Ellipse、FillRect、Pie等;
· 畫圖屬性函數,如SetBkColor、SetBkMode、SetTextColor等;
· 文本、字型函數,如TextOut、GetTextExtentPoint32、GetFontData等;
· 位圖函數,如SetPixel、BitBlt、StretchBlt等;
· 坐标函數,如DPtoLP、LPtoDP、ScreenToClient、ClientToScreen等;
· 映射函數,如SetMapMode、SetWindowExtEx、SetViewportExtEx等;
· 元檔案(MetaFile)函數,如PlayMetaFile、SetWinMetaFileBits等;
· 區域(Region)函數,如FillRgn、FrameRgn、InvertRgn等;
· 路徑(Path)函數,如BeginPath、EndPath、StrokeAndFillPath等;
· 裁剪(Clipping)函數,如SelectClipRgn、SelectClipPath等。
上述這些函數可以完成繪制使用者界面中的各個部分,包括我們在Windows平台上司空見慣的視窗、菜單、工具條、按鈕等。除了完成顯示操作功能外,GDI還提供了一些繪圖對象,用以渲染顯示。這些GDI對象包括:
裝置上下文(DC)——具有如顯示器或列印機等輸出裝置的繪圖屬性資訊的資料結構;
畫筆(Pen)——用于繪制線條;
畫刷(Brush)——用于圖案的填充;
字型(Font)——用于确定文本字元的樣式;
位圖(Bitmap)——用于存儲圖像;
調色闆(Palette)——螢幕上畫圖時可以使用的一些顔色的集合。
DC在GDI中是一個非常重要的概念。在MSDN上檢視各個GDI函數的使用說明,我們會發現大部分GDI函數都有一個HDC類型的參數;HDC就是DC句柄。Windows應用程式進行圖形、圖像處理的一般操作步驟如下:
1. 取得指定視窗的DC;
2. 确定使用的坐标系及映射方式;
3. 進行圖形、圖像或文字處理;
4. 釋放所使用的DC。
為了進一步簡化GDI函數的使用,或者說為了适應面向對象的程式設計風格,微軟的MFC類庫提供了幾個DC的封裝類。這些類的繼承關系如下:

圖1 關于DC的幾個MFC類的繼承關系
我們知道,絕大部分MFC類都是從CObject類派生的,CDC類也不例外。我們看到,CDC類是最基本的DC封裝類;它幾乎對應封裝了所有的GDI函數。另外,CDC類的各個派生類各有專門的用途:
CClientDC——在視窗的客戶區畫圖的DC;
CMetaFileDC——用于操作Windows元檔案的DC;
CPaintDC——響應WM_PAINT消息時畫圖使用的DC,多見于MFC程式的OnDraw函數中;
CWindowDC——在整個視窗範圍(包括架構、工具條等)中畫圖的DC。
MFC除了對DC進行類封裝外,對其它GDI對象也進行了類封裝。這些類的繼承關系如下:
圖2 GDI對象的MFC封裝類的繼承關系
CGdiObject——GDI對象的父類,定義了GDI對象封裝類的一些公有函數接口;
CBitmap——位圖相關操作的封裝類,包括位圖的裝入或建立等;
CBrush——畫刷對象的封裝類;
CFont——字型屬性及相關操作的封裝類;
CPalette——調色闆的封裝類;
CPen——畫筆對象的封裝類;
CRgn——區域對象以及區域相關操作的封裝類。
通過上述介紹,相信讀者對GDI程式設計有了一定的了解。接下去,我們就來讨論卡拉OK字幕疊加的實作原理。
二. 實作原理
字幕疊加,最基本的一種是在靜态圖像上進行的,一般就是直接在圖像上輸出标準的字元串,以合成新的圖像幀;而視訊上的字幕疊加,則是在連續的圖像幀序列上進行的,單幀上的疊加與靜态圖像上的疊加類似。本文所要講述的卡拉OK字幕疊加,就是一種在視訊上進行的字幕疊加。
在視訊上進行疊加的字幕,一般可以呈現出多種動态效果,比如滾動、旋轉等;卡拉OK字幕需要表達更多的内容,它至少包括:
1.根據進度,顯示不同的字幕内容(即歌詞);
2.字幕上應該表達出卡拉OK的音樂節奏;
3.對字幕進行勾邊或其他效果處理,以突出顯示。
以下是卡拉OK字幕效果的示範圖:
(圖檔較大,請拉動滾動條觀看)
(圖檔較大,請拉動滾動條觀看)
圖3 卡拉OK字幕效果圖
簡單的字幕疊加我們就可以通過GDI函數來實作。我們知道,字元的輸出可以使用TextOut函數;但是,如何輸出空心字,如何填充空心字呢?我們這裡要用到路徑。字元路徑的繪制過程參考如下:
<code>CClientDC * pClientDC = new CClientDC(mTargetWnd); // ...... pClientDC->BeginPath(); pClientDC->TextOut(x, y, szSubtitleLine); pClientDC->EndPath(); // pClientDC->StrokePath(); pClientDC->StrokeAndFillPath();</code>
我們看到,在TextOut函數調用前後分别調用了BeginPath函數和EndPath函數,以記錄字元輸出的路徑(實際上就是字元的輪廓);然後調用StrokePath函數将路徑勾勒出來,或者調用 StrokeAndFillPath函數在勾勒路徑的同時進行填充。需要注意的是,路徑勾勒的顔色由DC中目前選入的畫筆決定,填充的顔色由DC中目前選入的畫刷決定。
那麼,我們如何在字幕上表示演唱進度呢?根據音樂的節奏,我們需要為每個字元确定開始填充的時刻,并且指定該字元完成填充需要的時間。比如上述“真的好想你”一句歌詞,我們從時刻0開始填充,讓“真”顯示1500毫秒,“的”顯示300毫秒,“好”顯示1600毫秒, “想”顯示500毫秒,“你”顯示1000毫秒。于是,我們可以從開始播放時進行計時,并且以一定的頻率重新整理目前播放到的時間點;表現在卡拉OK字幕上,就是不斷地更新已經唱過的字幕和尚未唱過的字幕之間的分界線。從視覺效果上,我們看到的是填充色随着音樂從左到右地行進;并且單個字元的行進速度,也因該字元上配置設定的總的填充時間不同而不同,進而展現出應有的節奏感。
另外,我們從上述卡拉OK字幕效果圖中不難看出,已經唱過的字幕和尚未唱過的字幕的畫法是不一樣的:前半部分是藍色填充、白色勾邊,後半部分是黑色勾邊的空心字。而且,這兩部分之間的分界線有可能位于某個字元中(不會總是剛好在相鄰字元的間隙中)。那麼,如何準确地畫出這兩部分字幕呢?我們這裡可以使用GDI的區域、路徑裁剪操作。首先,根據目前進度,将視窗分成左右兩個矩形區域:
<code>// xStart, yStart為字幕行第一個字元顯示的(x, y)坐标 // pregress為目前進度坐标(已經唱過的寬度) // sz為SIZE類型的變量,記錄整行字幕的寬、高 CRgn region1, region2; region1.CreateRectRgn(xStart, yStart, xStart + pregress, yStart + sz.cy); region2.CreateRectRgn(xStart + pregress, yStart, xStart + sz.cx, yStart + sz.cy);</code>
在畫兩部分字幕的路徑之前,分别調用SelectClipRgn函數選入各自的區域;等到字幕路徑畫完之後,再調用SelectClipPath函數跟先前選入的區域進行“與”操作,即提取兩者的公共部分。整個過程參考如下:
<code>pClientDC->SelectClipRgn(&region1, RGN_COPY); // 1.選入用于畫已經唱過字幕的畫筆、畫刷 // 2.畫字幕路徑 // ...... pClientDC->SelectClipPath(RGN_AND); pClientDC->SelectClipRgn(&region2, RGN_COPY); // 1.選入用于畫尚未唱過字幕的畫筆、畫刷 // 2.畫字幕路徑 // ...... pClientDC->SelectClipPath(RGN_AND);</code>
三. 關鍵實作
我們使用VC生成一個基于對話框的程式來示範卡拉OK字幕疊加的實作。程式界面如下:
(圖檔較大,請拉動滾動條觀看)
圖4 示範程式界面
為了使字幕疊加的過程更加清晰,我們設計了一個邏輯控制類 CSubtitleController。在進行真正的字幕疊加之前,我們必須首先調用CSubtitleController類的 SetTargetWindow函數設定字幕的顯示視窗,随後調用SetSubtitleLine函數設定字幕行的内容、填充時間等屬性。具體實作中,我們在主對話框類CKaraokeDemoDlg中定義一個CSubtitleController類的執行個體mController,并且在對話框的初始化函數OnInitDialog中進行了如下的調用:
<code>BOOL CKaraokeDemoDlg::OnInitDialog() { CDialog::OnInitDialog(); // TODO: Add extra initialization here mController.SetTargetWindow(&mKaraokeWnd); mController.SetSubtitleLine(mSubtitleArray, mDurationArray, 0, 5); // ...... return TRUE; } 其中,mKaraokeWnd表示字幕顯示視窗,是一個CStatic類的對象執行個體;mSubtitleArray是CString類型的數組,用于存儲字幕内容(注意,應将字幕行中的各個字元單獨存儲);mDurationArray是int類型的數組,用于存儲字幕行中各個字元填充需要的時間。 mSubtitleArray和mDurationArray可以在CKaraokeDemoDlg類的構造函數中做如下的初始化: mSubtitleArray = new CString[5]; mDurationArray = new int[5]; mSubtitleArray[0] = "真"; mSubtitleArray[1] = "的"; mSubtitleArray[2] = "好"; mSubtitleArray[3] = "想"; mSubtitleArray[4] = "你"; mDurationArray[0] = 1500; // 以毫秒為機關 mDurationArray[1] = 300; mDurationArray[2] = 1600; mDurationArray[3] = 500; mDurationArray[4] = 1000; 主對話框類中還使用了一個定時器,定時間隔是40毫秒,即以每秒25幀的頻率重新整理字幕疊加的進度。我們在開始播放(即當使用者按下“Play”按鈕)時記下系統時間(存儲到DWORD類型的變量mStartTime中),然後在每次定時到達的時候再次讀取系統時間,與mStartTime做內插補點運算,得到目前播放到的時間點(我們暫且稱之為流時間)。在定時器消息響應函數CKaraokeDemoDlg::OnTimer中,我們會調用 CSubtitleController類的DrawSubtitle函數來完成實際的卡拉OK字幕輸出,這個函數的參數就是這個流時間。 在CSubtitleController類中,我們看到DrawSubtitle函數的具體實作如下: BOOL CSubtitleController::DrawSubtitle(DWORD inStreamTime) { ASSERT(mClientDC); DWORD timeInChar = 0; // 相對于目前字元填充的開始時間的時間 LONG sungLength = 0; // 已經唱過的字幕寬度 // LocateChar為CSubtitleController類的一個私有函數 // 根據目前播放到的時間點,定位到目前進度中的字元, // 并且得到播放時間點在目前字元中的相對時間 int currentChar = LocateChar(inStreamTime, timeInChar); if (currentChar != -1) // 定位成功 { // 計算已經唱過的字幕寬度 // mFromToArray數組記錄各個字元的屬性,包括開始、結束時間、尺寸等 sungLength = mFromToArray[currentChar].size.cx * timeInChar; sungLength = sungLength / mFromToArray[currentChar].duration; for (int i = 0; i < currentChar; i++) { // 累加上目前進度中的字元以前的所有字元的寬度 sungLength += mFromToArray[i].size.cx; } } else { // 如果無法定位到任何一個字元,則畫出整行 sungLength = mTotalWidth; } // 将字幕字型選入目标視窗的DC中 CFont * pOldFont = (CFont *) mClientDC->SelectObject(&mTextFont); mClientDC->SetBkMode(TRANSPARENT); // 設定輸出時背景透明 // 生成已經唱過的和尚未唱過的兩塊視窗區域 // mSungRegion和mSingingRegion均是CRgn類對象執行個體 mSungRegion.CreateRectRgn(mStartPoint.x, mStartPoint.y, mStartPoint.x + sungLength, mStartPoint.y + mFromToArray[0].size.cy); mSingingRegion.CreateRectRgn(mStartPoint.x + sungLength, mStartPoint.y, mStartPoint.x + mTotalWidth, mStartPoint.y + mFromToArray[0].size.cy); // 畫出第一部分:已經唱過的字幕(藍色填充,白色勾邊) int ret = mClientDC->SelectClipRgn(&mSungRegion, RGN_COPY); mClientDC->SetPolyFillMode(WINDING); HPEN pOldPen = (HPEN) mClientDC->SelectObject(mSungBoundaryPen); HBRUSH pOldBrush = (HBRUSH) mClientDC->SelectObject(mSungTextBrush); mClientDC->BeginPath(); mClientDC->TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine); mClientDC->EndPath(); mClientDC->StrokeAndFillPath(); // 畫出字元路徑并填充 mClientDC->SelectClipPath(RGN_AND); // 恢複以前的畫筆和畫刷 mClientDC->SelectObject(pOldPen); mClientDC->SelectObject(pOldBrush); // 畫出第二部分:尚未唱過的字幕(黑色勾邊空心字) pOldPen = (HPEN) mClientDC->SelectObject(mSingingBoundaryPen); pOldBrush = (HBRUSH) mClientDC->SelectObject(mSingingTextBrush); mClientDC->SelectClipRgn(&mSingingRegion, RGN_COPY); mClientDC->BeginPath(); mClientDC->TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine); mClientDC->EndPath(); mClientDC->StrokePath(); // 畫出字元路徑(不填充) mClientDC->SelectClipPath(RGN_AND); // 恢複以前的畫筆和畫刷 mClientDC->SelectObject(pOldBrush); mClientDC->SelectObject(pOldPen); mSungRegion.DeleteObject(); mSingingRegion.DeleteObject(); // 恢複目标視窗為“全區域” RECT bounds; mTargetWnd->GetClientRect(&bounds); CRgn rgn; rgn.CreateRectRgn(bounds.left, bounds.top, bounds.right, bounds.bottom); ret = mClientDC->SelectClipRgn(&rgn, RGN_COPY); // 恢複以前的字型 mClientDC->SelectObject(pOldFont); // 如果無法定位到任何一個字元,則傳回一個錯誤值 return (currentChar != -1); } // 根據目前播放到的時間點,定位到目前進度中的字元 int CSubtitleController::LocateChar(DWORD inStreamTime, DWORD & outTimeInChar) { // mCharCount為整個字幕行的字元個數 for (int i = 0; i < mCharCount; i++) { if (inStreamTime >= mFromToArray[i].from && inStreamTime < mFromToArray[i].to) { outTimeInChar = inStreamTime - mFromToArray[i].from; return i; } } return -1; }</code>
四. 性能優化
我們在示範中發現,頻繁地直接在視窗DC中畫圖會帶來一定的閃爍感。對此,我們可以進行一下優化,即首先建立一個與目标視窗DC相容的記憶體DC,在這個記憶體DC中畫好字幕後,再将字幕位圖從記憶體DC拷貝到目标視窗DC中去。
我們可以參考CSubtitleController類的DrawSubtitle2函數的實作:
<code>BOOL CSubtitleController::DrawSubtitle2(DWORD inStreamTime) { ASSERT(mClientDC); RECT bounds; mTargetWnd->GetClientRect(&bounds); int wndWidth = bounds.right - bounds.left; int wndHeight = bounds.bottom - bounds.top; CDC memDC; // 建立與目标視窗DC相容的記憶體DC memDC.CreateCompatibleDC(mClientDC); // 建立與目标視窗DC相容的位圖 HBITMAP membmp = CreateCompatibleBitmap(mClientDC->GetSafeHdc(),wndWidth,wndHeight); // 将位圖選入記憶體DC HBITMAP oldbmp = (HBITMAP) memDC.SelectObject(membmp); FillRect(memDC.GetSafeHdc(), &bounds, (HBRUSH)GetStockObject(LTGRAY_BRUSH)); /*----------------- 以下字幕操作都在記憶體DC中進行 ----------------*/ DWORD timeInChar = 0; LONG sungLength = 0; int currentChar = LocateChar(inStreamTime, timeInChar); if (currentChar != -1) { sungLength = mFromToArray[currentChar].size.cx * timeInChar; sungLength = sungLength / mFromToArray[currentChar].duration; for (int i = 0; i < currentChar; i++) { sungLength += mFromToArray[i].size.cx; } } else { sungLength = mTotalWidth; } CFont * pOldFont = (CFont *) memDC.SelectObject(&mTextFont); memDC.SetBkMode(TRANSPARENT); mSungRegion.CreateRectRgn(mStartPoint.x, mStartPoint.y, mStartPoint.x + sungLength, mStartPoint.y + mFromToArray[0].size.cy); mSingingRegion.CreateRectRgn(mStartPoint.x + sungLength, mStartPoint.y, mStartPoint.x + mTotalWidth, mStartPoint.y + mFromToArray[0].size.cy); // Draw the first part which has been sung int ret = memDC.SelectClipRgn(&mSungRegion, RGN_COPY); memDC.SetPolyFillMode(WINDING); HPEN pOldPen = (HPEN) memDC.SelectObject(mSungBoundaryPen); HBRUSH pOldBrush = (HBRUSH) memDC.SelectObject(mSungTextBrush); memDC.BeginPath(); memDC.TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine); memDC.EndPath(); memDC.StrokeAndFillPath(); memDC.SelectClipPath(RGN_AND); memDC.SelectObject(pOldPen); memDC.SelectObject(pOldBrush); // Draw the second part which is waiting for being sung pOldPen = (HPEN) memDC.SelectObject(mSingingBoundaryPen); pOldBrush = (HBRUSH) memDC.SelectObject(mSingingTextBrush); memDC.SelectClipRgn(&mSingingRegion, RGN_COPY); memDC.BeginPath(); memDC.TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine); memDC.EndPath(); memDC.StrokePath(); memDC.SelectClipPath(RGN_AND); memDC.SelectObject(pOldBrush); memDC.SelectObject(pOldPen); mSungRegion.DeleteObject(); mSingingRegion.DeleteObject(); memDC.SelectObject(pOldFont); // 将記憶體DC中的位圖拷貝到目标視窗DC中 mClientDC->BitBlt(0, 0, wndWidth, wndHeight, &memDC, 0, 0, SRCCOPY); // 删除記憶體DC及使用的資源 memDC.SelectObject(oldbmp); DeleteObject(membmp); memDC.DeleteDC(); return (currentChar != -1); }</code>
五. 結束語
本文介紹了卡拉OK字幕疊加的一般原理以及VC上使用GDI的一種簡單實作,并且提供了完整的示例源代碼,希望能夠對讀者朋友們有所啟示。