以前一直用Delphi+OpenGL搞圖形開(kāi)發(fā)。最近改用VC++了。比起 Delphi而言,VC++最大的不同就在于沒(méi)有統一的封裝庫(在Delphi中一律是VCL),如果僅為一點(diǎn)東西就使用某個(gè)庫會(huì )使整個(gè)程序看起來(lái)極不協(xié)調。這里的介紹的方法原理跟我以前在Delphi中使用的方法是一致的。只不過(guò)沒(méi)有使用任何封裝庫而已。
我曾在網(wǎng)上看過(guò)許多文字的解決方案,它們大多不能讓人滿(mǎn)意。有一種方法采用wgl函數生成某個(gè)具體的文字的顯示列表,并在渲染時(shí)調用顯示列表。這種方法必須為每個(gè)文字創(chuàng )建顯示列表,文字一多就顯得不夠靈活。因此我采用的方法是先用GDI把指定的文字繪制到內存中的Bitmap中去,在把Bitmap轉換成紋理送給OpenGL。這里也順便小結一下Windows GDI,如果你對Windows GDI已十分熟悉可以跳過(guò)此節。
一提到GDI,很多人肯定會(huì )認為這個(gè)方法很慢。其實(shí)不盡然。GDI的繪圖函數比起OpenGL來(lái)確實(shí)慢了許多,但如果用的好,并不會(huì )影響程序的效率。因為大多數情況下,你并不需要在每一幀都要重復使用GDI來(lái)繪制文字。
在實(shí)際應用中,大多數文字是靜態(tài)的,少數文字在某些幀會(huì )發(fā)生改變。因此我們需要這樣的一種方法,它不僅能繪制出高質(zhì)量的字體,而且在需要時(shí)可以不影響系統效率地靈活地改變。
單擊這里下載本文的代碼
概念介紹
首先要解決的問(wèn)題是如何使用Windows GDI創(chuàng )建位圖,然后在位圖中繪制文字,并把繪制后的位圖讀取出來(lái)。這一部分跟OpenGL沒(méi)有任何關(guān)系,并且這一操作也無(wú)需在每一幀都執行。
這一部分概括如下:
1. 創(chuàng )建Windows GDI 設備環(huán)境
2. 創(chuàng )建一個(gè)內存中的位圖對象,并把它指定到設備環(huán)境中去
3. 為設備環(huán)境指定繪圖參數,如筆的顏色,背景顏色等等
4. 調用Windows GDI繪圖函數在設備環(huán)境中繪圖
5. 把位圖對象中的信息抓取出來(lái)
先解釋一下Windows GDI的一些概念。
設備環(huán)境(Device Content):設備環(huán)境是GDI繪圖函數可以操作的對象。它包括一組Windows GDI子對象。這些子對象指定了繪圖的圖像,或者繪圖的方式。常見(jiàn)的Windows GDI子對象包括:
Bitmap:位圖。這里存儲了繪圖的結果。
Pen : 筆。指定筆的粗細,線(xiàn)條的樣式,筆的顏色等等。
Brush: 畫(huà)刷。指定相關(guān)繪圖區域的背景填充方式。
Font: 字體對象。指定相關(guān)的文字繪制函數使用什么字體來(lái)繪制文字。
...
可以把設備環(huán)境比作一部繪圖的機器,那么Bitmap就是機器里面的一張紙,機器畫(huà)的圖都顯示在這張紙上。Pen和Brush對象都很好理解,Pen就是一只筆,它插在機器的孔里面,機器可以控制這只筆來(lái)回移動(dòng)。Brush也類(lèi)似。
在文章的后面我會(huì )進(jìn)一步深化這些概念。
開(kāi)始實(shí)現
現在要做的第一件事情就是創(chuàng )建設備環(huán)境。也就是創(chuàng )建一部用于繪圖的機器。調用函數:
HDC CreateCompatibleDC( CDC* pDC ); //如果pDC是NULL,就自動(dòng)創(chuàng )建一個(gè)新的設備環(huán)境。
因此你只需調用 Handle = CreateCompatibleDC(NULL);就可以創(chuàng )建一個(gè)新的設備環(huán)境,它的句柄保存在Handle變量中。
接著(zhù)你要創(chuàng )建一個(gè)Bitmap對象,并把它指定到設備環(huán)境中去。這就好像你買(mǎi)了一張紙,然后把它塞進(jìn)你的繪圖機里。
Bitmap的創(chuàng )建過(guò)程稍微復雜一些,因為要指定很多參數。而我們需要的很簡(jiǎn)單:一個(gè)不帶調色板的RGB格式的位圖。使用這個(gè)函數創(chuàng )建內存位圖:
HBITMAP CreateDIBSection(
HDC hdc, // handle to DC
CONST BITMAPINFO *pbmi, // bitmap data
UINT iUsage, // data type indicator
VOID **ppvBits, // bit values
HANDLE hSection, // handle to file mapping object
DWORD dwOffset // offset to bitmap bit values
);
根據我們的需要,第一個(gè)參數是沒(méi)有用的,你可以指定為0,當然如果你愿意也可以指定為剛才創(chuàng )建的設備環(huán)境的句柄(Handle)。第二個(gè)參數指定了即將創(chuàng )建的位圖的格式。這個(gè)數據結構后面再介紹。第三個(gè)參數是一個(gè)指向一個(gè)指針變量的指針。當函數執行完后,這個(gè)指針變量將指向位圖的像素數據。我們需要使用這個(gè)指針來(lái)讀取位圖中的數據。hSection 和 dwOffset是用來(lái)從文件中讀取位圖的,我們均不需要,忽略。
因此你先要創(chuàng )建一個(gè)指定位圖格式的數據結構 BITMAPINFO 。為了簡(jiǎn)單起見(jiàn),這里直接給出我們需要的BITMAPINFO變量。這些項目的具體意義請參閱MSDN。
我們這樣創(chuàng )建BITMAPINFO變量:
BITMAPINFO bitInfo;
bitInfo.bmiHeader.biSize=sizeof(BITMAPINFOHEADER);
bitInfo.bmiHeader.biWidth=Width;
bitInfo.bmiHeader.biHeight=-Height;
bitInfo.bmiHeader.biPlanes=1;
bitInfo.bmiHeader.biBitCount=24;
bitInfo.bmiHeader.biCompression=BI_RGB;
bitInfo.bmiHeader.biSizeImage=0;
bitInfo.bmiHeader.biXPelsPerMeter=0;
bitInfo.bmiHeader.biYPelsPerMeter=0;
bitInfo.bmiHeader.biClrUsed=0;
bitInfo.bmiHeader.biClrImportant=0;
bitInfo.bmiColors[0].rgbBlue=255;
bitInfo.bmiColors[0].rgbGreen=255;
bitInfo.bmiColors[0].rgbRed=255;
bitInfo.bmiColors[0].rgbReserved=255;
隨后我們創(chuàng )建位圖:
void * imgptr = NULL;//用來(lái)接受位圖數據的指針變量。
HBITMAP bitHandle = CreateDIBSection(0,&bitInfo,DIB_RGB_COLORS,&imgptr,NULL,0);
HGDIOBJ OldBmp = SelectObject(Handle,bitHandle);
DeleteObject(OldBmp);
這里,我們使用了CreateDIBSection函數創(chuàng )建了位圖對象。隨后我們又使用了SelectObject函數將創(chuàng )建的位圖對象選擇到設備環(huán)境中。注意最后一行的DeleteObject,這是什么意思呢?當我們創(chuàng )建設備環(huán)境時(shí),新創(chuàng )建的設備環(huán)境并不完全是空的。它包含了一個(gè)1×1的位圖對象。而一個(gè)設備環(huán)境只能包含一個(gè)位圖對象。當我們?yōu)樵O備環(huán)境指定新的位圖時(shí),原來(lái)的那個(gè)位圖就被置換了出來(lái)。置換出來(lái)的位圖對象的句柄就是SelectObject函數的返回值。這個(gè)以前的位圖是沒(méi)有任何作用的,我們可以調用 DeleteObject來(lái)刪除它。
另外,請記住imgptr,以后需要使用這個(gè)變量讀取位圖的內容。
準備工作還沒(méi)有完成,我們還要相繼為設備環(huán)境創(chuàng )建畫(huà)刷和字體對象。畫(huà)刷對象的創(chuàng )建很簡(jiǎn)單,因為我們只需要一個(gè)用白色填充背景的畫(huà)刷。因此直接使用 hdlBrush = CreateSolidBrush(RGB(255,255,255));即可。創(chuàng )建完后,我們依然需要調用SelectObject將它選擇到設備環(huán)境中去。這樣今后繪圖時(shí)相關(guān)函數就會(huì )使用這個(gè)畫(huà)刷來(lái)填充背景。
字體對象創(chuàng )建就要復雜得多。因為描述字體的參數也非常多。先看一下創(chuàng )建字體對象的函數。
HFONT CreateFontIndirect(LOGFONT *font)
LOGFONT 是一個(gè)Struct,包含了許多內容。這里我們給出代碼并介紹最有用的幾項。
LOGFONT font;
font.lfHeight = -MulDiv(FontSize, GetDeviceCaps(Handle, LOGPIXELSY), 72);
//lfHeight項指定了文字的高度。GDI函數隨后會(huì )根據指定的高度確定使用的字體大小。這對我們并不是十分方便, 因此用上面的表達式來(lái)根據字體大小計算相應的字體高度。
font.lfItalic = FontItalic; //是否斜體
font.lfOrientation = 0;
font.lfOutPrecision = OUT_TT_PRECIS; //選擇TrueType字體
font.lfPitchAndFamily = DEFAULT_PITCH || FF_DONTCARE;
font.lfQuality = ANTIALIASED_QUALITY; //啟用文字反鋸齒
font.lfStrikeOut = FontStrikeOut; //刪除線(xiàn)
font.lfUnderline = FontUnderline; //下劃線(xiàn)
font.lfWeight = (FontBold ?FW_NORMAL:FW_BOLD); //是否粗體
font.lfWidth = 0; //忽略
同樣的,創(chuàng )建完字體后,也要用SelectObject將字體對象選擇到設備環(huán)境中去。
不知你是否注意到,在創(chuàng )建位圖對象時(shí),需要指定位圖的高度和寬度。而你怎么知道要多大的高度和寬度才能適合要創(chuàng )建的文字的大小呢?因此,我們應該在確定了文字大小之后再創(chuàng )建位圖對象。確定文字大小可以使用函數:
BOOL GetTextExtentPoint32(
HDC hdc, // handle to DC
LPCTSTR lpString, // text string
int cbString, // characters in string
LPSIZE lpSize // string size
);
其中lpSize是一個(gè)指向SIZE類(lèi)型的指針,SIZE類(lèi)型的變量描述了文字的高度和寬度,單位是像素。你可以使用下面的代碼計算文字的高度和寬度。
STextSize sText;
sText.cx =0; sText.cy =0;
GetTextExtentPoint32(Handle,Text,(int)_tcslen(Text),&sText);
Handle是設備環(huán)境的句柄。
顯然,GetTextExtentPoint32必須在字體對象被選入設備環(huán)境之后才會(huì )起作用。因此我們的流程如下:
1.創(chuàng )建設備環(huán)境,得到設備環(huán)境的句柄Handle
2.創(chuàng )建字體對象,把字體對象選入設備環(huán)境
3.創(chuàng )建畫(huà)刷對象,并選入設備環(huán)境
4.用GetTextExtentPoint32得到要顯示的字符串的大小tSize
5.創(chuàng )建位圖。指定位圖的大小為tSize。把位圖選入設備環(huán)境
我們必須考慮另外一個(gè)問(wèn)題,就是許多早期的顯卡并不支持Non-Power-Of-Two-Textures擴展,這就意味著(zhù)我們創(chuàng )建的位圖大小不應該是任意的,而必須是2的整數次冪。為了支持這一點(diǎn),我們在得到文字的大小之后應該用下面的函數計算位圖的大小。
int GetPO2Value(int value)
{
return Round(Power(2,ceil(log((float)value)/log(2.0f))));
}
//Round 和Power是自己寫(xiě)的數學(xué)函數。它們的定義如下:
float Power(float base, float exponent) //計算Base的Exponent次方
{
return exp(log(base)*exponent);
}
int Round(float value) //四舍五入到整數
{
if (value>0.4f)
return ((int)(value+0.5f));
else if (value<-0.4f)
return (-(int)(-value-0.5f));
else
return 0;
}
現在可以在設備環(huán)境中繪制文字了。
TextOut(Handle,X,Y,Text,(int)_tcslen(Text)));
繪制文字之后,需要把位圖的內容讀取出來(lái)。下面的代碼用于讀取位圖中的內容。注意我們之前創(chuàng )建位圖時(shí)得到的imgptr指針。
unsigned char ** ScanLine; //位圖的掃描行
ScanLine = new unsigned char *[Height]; // Height是位圖的高度,Width是位圖的寬度
int rowWidth = Width*bitInfo.bmiHeader.biBitCount/8; //一個(gè)掃描行所占用的字節
while (rowWidth %4) rowWidth++; //每個(gè)掃描行都是32位對齊的。
for(int i=0;i<Height;i++)
{
ScanLine[i]=(unsigned char *)(imgptr)+rowWidth*i;//得到每個(gè)掃描行開(kāi)始處的指針。
}
//經(jīng)過(guò)上述代碼后,ScanLine就可以看作一個(gè)二維的數組,ScanLine[i]表示位圖第i行的所有像素數據。
//ScanLine[i][j*3] 表示第i行,第j列的像素的Blue分量。
//ScanLine[i][j*3+1] 表示第i行,第j列的像素的Green分量。
//ScanLine[i][j*3+2] 表示第i行,第j列的像素的Red分量。
//現在把ScanLine中的數據讀到一個(gè)一維數組中,供OpenGL使用。
unsigned char *pic;
pic = new unsigned char[TexWidth*TexHeight*4];
int LineWidth = TexWidth*4;
for (int i=0;i<TexHeight;i++)
{
for (int j=0;j<TexWidth;j++)
{
if (ScanLine[i][j*3+2]!=255) //這樣寫(xiě)的目的是為了兼容文字反鋸齒
{
pic[i*LineWidth+j*4] = 255;
pic[i*LineWidth+j*4+1] = 255;
pic[i*LineWidth+j*4+2] = 255;
}
else
{
pic[i*LineWidth+j*4] = 0;
pic[i*LineWidth+j*4+1] = 0;
pic[i*LineWidth+j*4+2] = 0;
}
pic[i*LineWidth+j*4+3] = 255- ScanLine[i][j*3+2];
}
}
至此,我們已完成了GDI繪圖的部分,并把繪制后的文字位圖存儲在了一維數組pic中。
剩下的內容就十分簡(jiǎn)單了。把pic作為紋理傳給OpenGL,然后綁定該紋理,設置 OpenGL繪圖參數,根據文字位圖的大小在屏幕上繪制一個(gè)Quad,注意關(guān)閉深度緩沖,關(guān)閉光照,啟動(dòng)混色,把投影矩陣設置為和屏幕視域一樣大的平行投影,然后在想要的地方繪制就性了。下面給出準備和結束屏幕2D繪圖的代碼,以供參考。
void BeginUIDrawing()
{
int viewport[4];
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA);
glGetIntegerv(GL_VIEWPORT,viewport);
glMatrixMode(GL_PROJECTION);
glPushMatrix();
glLoadIdentity();
glOrtho(0,viewport[2],viewport[3],0,1,-1);
glMatrixMode(GL_MODELVIEW);
glPushMatrix();
glLoadIdentity();
glDisable(GL_DEPTH_TEST);
}
void EndUIDrawing()
{
glPopMatrix();
glMatrixMode(GL_PROJECTION);
glPopMatrix();
glMatrixMode(GL_MODELVIEW);
glEnable(GL_DEPTH_TEST);
}
可供參考的渲染循環(huán):
void RenderScene()
{
Draw3D;
BeginUIDrawing();
DrawText(x,y);
EndUIDrawing();
}
歸納整合
把上述內容總結歸納成相應的數據結構,可以參考下面的封裝方法。詳細的代碼可以單擊這里下載。通過(guò)閱讀和使用這些代碼,可以讓你更好地了解整個(gè)工作機制。
class CCanvas //包括字體對象和畫(huà)刷對象,并封裝了繪圖函數
{
private:
HFONT hdFont;
HBRUSH hdBrush;
public:
HDC Handle;//設備環(huán)境的句柄,由CDIBImage賦值
CCanvas(HDC DC);
~CCanvas();
void ChangeFont(SFont newFont);//改變字體
void TextOut(char *Text, int X, int Y); //繪制文字
STextSize GetTextSize(char *Text); //得到文字的大小
void Clear(int w, int h); //清空位圖。
};
class CDIBImage //包括創(chuàng )建設備環(huán)境,創(chuàng )建CCanvas對象和位圖對象。
//提供的ScanLine指針指向了每一個(gè)掃描行的數據
{
private:
void CreateBMP(int Width, int Height); //創(chuàng )建一個(gè)位圖
public:
HDC Handle; //設備環(huán)境的句柄
HBITMAP bitHandle; //位圖的句柄
CCanvas *Canvas; //Canvas對象,包括字體對象和畫(huà)刷對象以及相關(guān)繪圖函數
unsigned char** ScanLine;//指向位圖數據
CDIBImage();
~CDIBImage();
void SetSize(int Width, int Height); //設置位圖的大小
};
class CGLText //把GDI中的位圖讀取出來(lái)并作為紋理對象
{
private:
int TexHeight,TexWidth,TextHeight,TextWidth;
GLuint TexID;
CDIBImage *Bit;
int GetPO2Value(int value); // Get a minimum power-of-two value
//that is larger than the specified value.
public:
CGLText();
~CGLText();
void SetFont(SFont sFont); // Set the font styled of this label.
void SetText(char *Text); // Set the text that is going to be displayed.
void Draw(int X, int Y); // Draw the text at specified position
};