C++下的OpenGL文字显示的完美解决方案

以前一直用Delphi+OpenGL搞图形开发。最近改用VC++了。比起 Delphi而言,VC++最大的不同就在于没有统一的封装库(在Delphi中一律是VCL),如果仅为一点东西就使用某个库会使整个程序看起来极不协调。这里的介绍的方法原理跟我以前在Delphi中使用的方法是一致的。只不过没有使用任何封装库而已。

我曾在网上看过许多文字的解决方案,它们大多不能让人满意。有一种方法采用wgl函数生成某个具体的文字的显示列表,并在渲染时调用显示列表。这种方法必须为每个文字创建显示列表,文字一多就显得不够灵活。因此我采用的方法是先用GDI把指定的文字绘制到内存中的Bitmap中去,在把Bitmap转换成纹理送给OpenGL。这里也顺便小结一下Windows GDI,如果你对Windows GDI已十分熟悉可以跳过此节。

一提到GDI,很多人肯定会认为这个方法很慢。其实不尽然。GDI的绘图函数比起OpenGL来确实慢了许多,但如果用的好,并不会影响程序的效率。因为大多数情况下,你并不需要在每一帧都要重复使用GDI来绘制文字。

在实际应用中,大多数文字是静态的,少数文字在某些帧会发生改变。因此我们需要这样的一种方法,它不仅能绘制出高质量的字体,而且在需要时可以不影响系统效率地灵活地改变。

单击这里下载本文的代码


概念介绍

 

首先要解决的问题是如何使用Windows GDI创建位图,然后在位图中绘制文字,并把绘制后的位图读取出来。这一部分跟OpenGL没有任何关系,并且这一操作也无需在每一帧都执行。

这一部分概括如下:

    1. 创建Windows GDI 设备环境

    2. 创建一个内存中的位图对象,并把它指定到设备环境中去

    3. 为设备环境指定绘图参数,如笔的颜色,背景颜色等等

    4. 调用Windows GDI绘图函数在设备环境中绘图

    5. 把位图对象中的信息抓取出来

先解释一下Windows GDI的一些概念。

设备环境(Device Content):设备环境是GDI绘图函数可以操作的对象。它包括一组Windows GDI子对象。这些子对象指定了绘图的图像,或者绘图的方式。常见的Windows GDI子对象包括:

    Bitmap:位图。这里存储了绘图的结果。

    Pen : 笔。指定笔的粗细,线条的样式,笔的颜色等等。

    Brush: 画刷。指定相关绘图区域的背景填充方式。

    Font: 字体对象。指定相关的文字绘制函数使用什么字体来绘制文字。

    ...

可以把设备环境比作一部绘图的机器,那么Bitmap就是机器里面的一张纸,机器画的图都显示在这张纸上。Pen和Brush对象都很好理解,Pen就是一只笔,它插在机器的孔里面,机器可以控制这只笔来回移动。Brush也类似。

在文章的后面我会进一步深化这些概念。

开始实现
 

现在要做的第一件事情就是创建设备环境。也就是创建一部用于绘图的机器。调用函数:

    HDC CreateCompatibleDC( CDC* pDC );  //如果pDC是NULL,就自动创建一个新的设备环境。

因此你只需调用 Handle = CreateCompatibleDC(NULL);就可以创建一个新的设备环境,它的句柄保存在Handle变量中。

接着你要创建一个Bitmap对象,并把它指定到设备环境中去。这就好像你买了一张纸,然后把它塞进你的绘图机里。

Bitmap的创建过程稍微复杂一些,因为要指定很多参数。而我们需要的很简单:一个不带调色板的RGB格式的位图。使用这个函数创建内存位图:

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
    );

根据我们的需要,第一个参数是没有用的,你可以指定为0,当然如果你愿意也可以指定为刚才创建的设备环境的句柄(Handle)。第二个参数指定了即将创建的位图的格式。这个数据结构后面再介绍。第三个参数是一个指向一个指针变量的指针。当函数执行完后,这个指针变量将指向位图的像素数据。我们需要使用这个指针来读取位图中的数据。hSection 和 dwOffset是用来从文件中读取位图的,我们均不需要,忽略。

因此你先要创建一个指定位图格式的数据结构 BITMAPINFO 。为了简单起见,这里直接给出我们需要的BITMAPINFO变量。这些项目的具体意义请参阅MSDN。

我们这样创建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;

随后我们创建位图:

    void * imgptr = NULL;//用来接受位图数据的指针变量。
        HBITMAP bitHandle = CreateDIBSection(0,&bitInfo,DIB_RGB_COLORS,&imgptr,NULL,0);
        HGDIOBJ OldBmp = SelectObject(Handle,bitHandle);
        DeleteObject(OldBmp);

这里,我们使用了CreateDIBSection函数创建了位图对象。随后我们又使用了SelectObject函数将创建的位图对象选择到设备环境中。注意最后一行的DeleteObject,这是什么意思呢?当我们创建设备环境时,新创建的设备环境并不完全是空的。它包含了一个1×1的位图对象。而一个设备环境只能包含一个位图对象。当我们为设备环境指定新的位图时,原来的那个位图就被置换了出来。置换出来的位图对象的句柄就是SelectObject函数的返回值。这个以前的位图是没有任何作用的,我们可以调用 DeleteObject来删除它。

另外,请记住imgptr,以后需要使用这个变量读取位图的内容。

准备工作还没有完成,我们还要相继为设备环境创建画刷和字体对象。画刷对象的创建很简单,因为我们只需要一个用白色填充背景的画刷。因此直接使用 hdlBrush = CreateSolidBrush(RGB(255,255,255));即可。创建完后,我们依然需要调用SelectObject将它选择到设备环境中去。这样今后绘图时相关函数就会使用这个画刷来填充背景。

字体对象创建就要复杂得多。因为描述字体的参数也非常多。先看一下创建字体对象的函数。

HFONT CreateFontIndirect(LOGFONT *font)

LOGFONT 是一个Struct,包含了许多内容。这里我们给出代码并介绍最有用的几项。

    LOGFONT font;
        font.lfHeight = -MulDiv(FontSize, GetDeviceCaps(Handle, LOGPIXELSY), 72);
        //lfHeight项指定了文字的高度。GDI函数随后会根据指定的高度确定使用的字体大小。这对我们并不是十分方便,  因此用上面的表达式来根据字体大小计算相应的字体高度。

        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;                     //删除线
        font.lfUnderline = FontUnderline;                     //下划线
        font.lfWeight = (FontBold ?FW_NORMAL:FW_BOLD);        //是否粗体
        font.lfWidth = 0;                                      //忽略

同样的,创建完字体后,也要用SelectObject将字体对象选择到设备环境中去。

不知你是否注意到,在创建位图对象时,需要指定位图的高度和宽度。而你怎么知道要多大的高度和宽度才能适合要创建的文字的大小呢?因此,我们应该在确定了文字大小之后再创建位图对象。确定文字大小可以使用函数:

    BOOL GetTextExtentPoint32(
          HDC hdc,           // handle to DC
          LPCTSTR lpString,  // text string
          int cbString,      // characters in string
          LPSIZE lpSize      // string size
        );

其中lpSize是一个指向SIZE类型的指针,SIZE类型的变量描述了文字的高度和宽度,单位是像素。你可以使用下面的代码计算文字的高度和宽度。

    STextSize sText;
        sText.cx =0; sText.cy =0;
        GetTextExtentPoint32(Handle,Text,(int)_tcslen(Text),&sText);

Handle是设备环境的句柄。

显然,GetTextExtentPoint32必须在字体对象被选入设备环境之后才会起作用。因此我们的流程如下:

1.创建设备环境,得到设备环境的句柄Handle

2.创建字体对象,把字体对象选入设备环境

3.创建画刷对象,并选入设备环境

4.用GetTextExtentPoint32得到要显示的字符串的大小tSize

5.创建位图。指定位图的大小为tSize。把位图选入设备环境

我们必须考虑另外一个问题,就是许多早期的显卡并不支持Non-Power-Of-Two-Textures扩展,这就意味着我们创建的位图大小不应该是任意的,而必须是2的整数次幂。为了支持这一点,我们在得到文字的大小之后应该用下面的函数计算位图的大小。

    int GetPO2Value(int value)
    {
        return Round(Power(2,ceil(log((float)value)/log(2.0f))));
    }

    //Round 和Power是自己写的数学函数。它们的定义如下:

    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;
    }

现在可以在设备环境中绘制文字了。
 

  TextOut(Handle,X,Y,Text,(int)_tcslen(Text)));
 

绘制文字之后,需要把位图的内容读取出来。下面的代码用于读取位图中的内容。注意我们之前创建位图时得到的imgptr指针。

    unsigned char ** ScanLine; //位图的扫描行
    ScanLine = new unsigned char *[Height]; // Height是位图的高度,Width是位图的宽度
    int rowWidth = Width*bitInfo.bmiHeader.biBitCount/8; //一个扫描行所占用的字节
    while (rowWidth %4) rowWidth++; //每个扫描行都是32位对齐的。
    for(int i=0;i     {
    ScanLine[i]=(unsigned char *)(imgptr)+rowWidth*i;//得到每个扫描行开始处的指针。
    }
    //经过上述代码后,ScanLine就可以看作一个二维的数组,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中的数据读到一个一维数组中,供OpenGL使用。

    unsigned char *pic;

    pic = new unsigned char[TexWidth*TexHeight*4];
    int LineWidth = TexWidth*4;
    for (int i=0;i     {
        for (int j=0;j         {
            if (ScanLine[i][j*3+2]!=255) //这样写的目的是为了兼容文字反锯齿
            {
                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中。

剩下的内容就十分简单了。把pic作为纹理传给OpenGL,然后绑定该纹理,设置 OpenGL绘图参数,根据文字位图的大小在屏幕上绘制一个Quad,注意关闭深度缓冲,关闭光照,启动混色,把投影矩阵设置为和屏幕视域一样大的平行投影,然后在想要的地方绘制就性了。下面给出准备和结束屏幕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);
    }

可供参考的渲染循环:

    void RenderScene()
    {
        Draw3D;
        BeginUIDrawing();
            DrawText(x,y);
        EndUIDrawing();
    }

 

归纳整合

 

把上述内容总结归纳成相应的数据结构,可以参考下面的封装方法。详细的代码可以单击这里下载。通过阅读和使用这些代码,可以让你更好地了解整个工作机制。

    class CCanvas //包括字体对象和画刷对象,并封装了绘图函数
    {
    private:
        HFONT hdFont;
        HBRUSH hdBrush;
    public:
        HDC Handle;//设备环境的句柄,由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 //包括创建设备环境,创建CCanvas对象和位图对象。

                   //提供的ScanLine指针指向了每一个扫描行的数据
    {
    private:
        void CreateBMP(int Width, int Height); //创建一个位图
    public:
        HDC Handle; //设备环境的句柄
        HBITMAP bitHandle; //位图的句柄
        CCanvas *Canvas;  //Canvas对象,包括字体对象和画刷对象以及相关绘图函数
        unsigned char** ScanLine;//指向位图数据
        CDIBImage();
        ~CDIBImage();
        void SetSize(int Width, int Height);     //设置位图的大小
    };

    class CGLText //把GDI中的位图读取出来并作为纹理对象
    {
    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
    };

你可能感兴趣的:(OpenGL)