要实现软光栅,首先肯定是要实现绘制像素,这个软光栅渲染器我打算使用C++在Windows平台上开发,这篇文章一起来探究如何使用win32绘制像素。
Win32API下,要直接绘制一个像素,我们可以在窗口过程中处理WM_PAINT
消息,使用Windows GDI模块的SetPixel
函数绘制某个像素点:
//窗口过程的部分代码
LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
case WM_PAINT:
hdc = BeginPaint(hwnd, &ps); //获取设备环境句柄
SetPixel(hdc, 50, 50, RGB(255, 0, 0)); //在坐标(50, 50)绘制RGB颜色为(255, 0, 0)的点
EndPaint(hwnd, &ps); //释放设备环境句柄
}
这个函数虽然方便,但是只能标记一个像素点,如果我们在一个1920x1080的屏幕上绘制,那就需要调用这个函数两百多万次,总的绘制所需时间就会非常离谱(如我上一篇文章所说,第一次不明白在python tkinter上用这种一个个像素点画的方法,一张640x480的图画了将近10秒,可见效率如此得低下)。
因此,我们要尽量少调用绘制函数,将所有的绘制数据定义为一个缓冲区包装起来,各种计算完之后最终就调用一次绘图函数把缓冲区的内容全画出来,这样就能大大提升绘图效率了。
那么如何实现这个“包装数据并一次性全画出来呢”,我们就需要使用到位图。
首先先来大致了解一下位图的概念(这里的位图是指BMP格式的图像,并非位图数据结构):位图是Windows操作系统中的标准图像文件格式,又称点阵图或者光栅图,它使用像素来描述图像。
图片中的像素,是用RGB(红绿蓝三原色)混合构成,我们常见的8位色,24位色等指的就是用多少位来表示一个颜色,单色的位图每个像素只需要一位(如非黑即白,占用一个bit),灰度或者彩色位图每个像素需要若干位,现在计算机常用的是24位色,即RGB每一种颜色占8位,一共可以表示一千六百多万种颜色,或者有些时候再增加8位用于存放透明度,这个通常叫32位色。
位图(BMP格式)在日常使用计算机中远不如JPG、PNG等格式常见,因为位图的数据并没有经过压缩,或者只使用行程长度编码进行轻度的无损压缩,导致位图文件通常较大,一张1024x768,24位色的图像需要花费2MB多的空间。
但是位图的颜色数据直接以二进制的形式展现,分析并不是一件难事,也适合我们学习,所以位图在图像领域仍然发挥着重要的角色。这里我们从位图的格式入手,一步步推导到绘制。
位图主要分为两类:
第一类是DDB(device-dependent bitmap),即设备相关位图,有的地方也叫GDI位图。DDB高度依赖输出设备,颜色模式必须与输出设备相一致,显示时也需要以系统的调色板为基础进行每一位颜色的映射,局限性很大。
还有一类是DIB(device-independent bitmap),设备无关位图。DIB不依赖具体设备,可以在不同的机器或系统中显示位图所固有的颜色。Windows3.0之后,DIB图像信息一般都存于BMP后缀的文件中(有时候也会以.DIB或者.RLE作为拓展名)。
我们这里侧重学习绘制,不过多阐述概念原理那些,下面我们主要讨论的位图默认也是现在绝大多数使用的DIB(设备无关位图)。
接下来我们先简单分析一下位图的格式,主要分为以下几个部分:
BITMAPFILEHEADER
结构体,存储文件类型、大小等信息BITMAPINFOHEADER
结构体,存储位图的尺寸、颜色格式等信息RGBQUAD
结构体,结构描述由红色、绿色和蓝色的相对强度组成的颜色,这个在文件中可有可无位图文件头:
//BITMAPFILEHEADER结构体,来自Microsoft文档
typedef struct tagBITMAPFILEHEADER {
WORD bfType;
DWORD bfSize;
WORD bfReserved1;
WORD bfReserved2;
DWORD bfOffBits;
} BITMAPFILEHEADER, *LPBITMAPFILEHEADER, *PBITMAPFILEHEADER;
bfType
,尺寸 2 Byte,表示文件类型,必须是BM字符串,对应十进制为19778,十六进制为0x4d42bfSize
,尺寸 4 Byte,表示位图文件的大小,以字节(Byte)为单位bfReserved1
,尺寸 2 Byte,保留,必须为0bfReserved2
,尺寸 2 Byte,保留,必须为0bfOffBits
,尺寸 4 Byte,表示从位图文件头结构的开头到DIB像素位的偏移量,以字节为单位整个位图文件头占用大小是14 Byte,也可以看一下这张来自维基百科相关词条的图:
位图信息头:
//BITMAPINFOHEADER结构体,来自Microsoft文档
typedef struct tagBITMAPINFOHEADER {
DWORD biSize;
LONG biWidth;
LONG biHeight;
WORD biPlanes;
WORD biBitCount;
DWORD biCompression;
DWORD biSizeImage;
LONG biXPelsPerMeter;
LONG biYPelsPerMeter;
DWORD biClrUsed;
DWORD biClrImportant;
} BITMAPINFOHEADER, *LPBITMAPINFOHEADER, *PBITMAPINFOHEADER;
biSize
,尺寸 4 Byte,指定此结构所需的字节数,此值不包括颜色表的大小或颜色掩码的大小,一般直接为sizeof(BITMAPINFOHEADER)
即可biWidth
,尺寸 4 Byte,指定位图的宽度,以像素为单位biHeight
,尺寸 4 Byte,指定位图的高度,以像素为单位,这里要注意对于未压缩的 RGB 位图,如果 biHeight 为正值,则位图为自下而上的 DIB,原点位于左下角。 如果 biHeight 为负数,则位图为自上而下 DIB,原点位于左上角biPlanes
,尺寸 2 Byte,指定目标设备的平面数。 此值必须设置为 1biBitCount
,尺寸 2 Byte,指定每像素位数biCompression
,尺寸 4 Byte,采用的压缩格式,通常为BI_RGB宏,对应值为0biSizeImage
,尺寸 4 Byte,指定图像的大小,以字节为单位,对于未压缩的 RGB 位图,可以将其设置为 0biXPelsPerMeter
,尺寸 4 Byte,指定位图的目标设备的水平分辨率,以像素/米为单位biYPelsPerMeter
,尺寸 4 Byte,指定位图的目标设备的垂直分辨率,以像素/米为单位biClrUsed
,尺寸 4 Byte, 指定位图实际使用的颜色表中的颜色索引数,为0则表示默认值biClrImportant
,尺寸 4 Byte,指定被认为对显示位图很重要的颜色索引数, 如果此值为零,则所有颜色都很重要虽然这个结构体看过去内容很多有些复杂,但如果我们自己填写的话,从第六个开始所有的值都填上0就可以了。
颜色表:
//RGBQUAD结构体,来自Microsoft文档
typedef struct tagRGBQUAD {
BYTE rgbBlue; //该颜色的蓝色分量
BYTE rgbGreen; //该颜色的绿色分量
BYTE rgbRed; //该颜色的红色分量
BYTE rgbReserved; //保留值
} RGBQUAD;
16、24、32位色的DIB中,一般不使用调色板,因此不会这个结构体打交道,这里我们就不讨论了。
GDI中还定义了下面的结构:
//BITMAPINFO结构体,来自Microsoft文档
typedef struct tagBITMAPINFO {
BITMAPINFOHEADER bmiHeader;
RGBQUAD bmiColors[1];
} BITMAPINFO, *LPBITMAPINFO, *PBITMAPINFO;
这个结构存放了位图信息头和颜色表,如上文所说,颜色表我们使用不到。
DIB像素位:
DIB像素位没有特定的结构体表示,指向的就是一块代表颜色的二进制内存,例如24位颜色,每个像素需要消耗24位,即红绿蓝每个颜色需要消耗8位,或者说1字节存储。
这里需要注意,正常情况下我们是用RGB或者RGBA的顺序存储,但是在DIB像素位这里使用了big endian(先出现的数字在高位),多数情况RGB会按B,G,R的顺序表示,RGBA会按B,G,R,A的顺序表示。
整个BMP文件中只有这里是big endian,其他地方均是采用little endian(先出现的数字在低位)。
还有一个需要注意的地方就是为了提升读取效率,位图每一行的数据都需要是4字节(32位)的倍数,所以有时候每行的数据末尾会出现补零的情况。因此,位图每一行的数据大小有可能会因为补零而增加。
说了那么多理论,刚刚有些粗体标记的概念没有完全理解没关系,接下来我们就用一张图片来实际计算一下,就能很快理解啦。
位图可以手工创建,例如通过Windows自带画图程序,VS,Photoshop等软件创建(PS创建位图会因为存储方式不同多出2字节),也可以通过代码生成。这里我们先采用Windows自带画分别制作一张3x3大小和20x20大小,颜色为24位色的位图。
先来计算一下理论上3x3大小位图的文件大小,位图文件头和位图信息头加上一共54Byte,每个颜色24位也就是3Byte,9个颜色一共27Byte,整个文件总共是54 + 27 = 81Byte
但是我们右击图片,会发现实际大小是90Byte,和我们计算的81Byte不同,多出来了一部分:
这就是我们刚刚提到的“补零”,每一行数据大小要和4Byte整除,因此一行数据就从9字节补到了12字节,所以位图的颜色数据大小是12x3=36Byte,加上位图头的54Byte就刚好是90Byte,与文件大小一致。
补零之后每一行所用的字节数也可以用下面的公式计算出来:
R o w L e n g t h = ⌊ b i W i d t h ⋅ b i B i t C o u n t + 31 32 ⌋ ⋅ 4 RowLength = \lfloor \frac{ biWidth \cdot biBitCount + 31 } { 32 } \rfloor \cdot 4 RowLength=⌊32biWidth⋅biBitCount+31⌋⋅4
即每一行所用的字节数 = 位图的宽度 乘 每像素所用的位数之后 加上 31 除 32,然后向下取整最后再 乘 4
代码表示为:
RowLength = 4 * ((biWidth * biBitCount + 31) / 32);
或者:
RowLength = ((biWidth * biBitCount + 31) & ~31) >> 3;
理解了公式,我们再来计算一下20x20大小的那张位图大小
每一行数据大小是20 * 3 = 60Byte,是4的倍数,所以不用补零。也可以套用刚刚的公式计算:(20 * 24 + 31) / 32向下取整为15,乘上4就是60,然后60乘上图片的高度为1200,加上位图头总共是1254Byte
可以看到图片实际大小和我们计算的相同。
在VS中创建一个Win32工程,我们把刚刚创建的那两张位图移动到工程的文件夹下,可以在VS里把那两张图片作为现有项导入方便查看。
然后我们编辑代码,使用fstream二进制读取bmp的文件内容,这里我们先读取3x3位图:
BITMAPFILEHEADER bitmapFile; //位图文件头
BITMAPINFO bitmapInfo; //位图信息头加上颜色表
BYTE* bitmap; //位图颜色数据
LONG64 bitmapSize; //位图颜色数据大小
ifstream fin;
fin.open("3x3位图.bmp", ios::in, ios::binary);
fin.read((char*)&bitmapFile, sizeof(BITMAPFILEHEADER));
fin.read((char*)&bitmapInfo.bmiHeader, sizeof(BITMAPINFOHEADER));
// 有些位图文件的biSizeImage设置为0
// 所以这里我们使用位图文件大小减去开头到DIB像素位的偏移量计算位图颜色数据大小
bitmapSize = bitmapFile.bfSize - bitmapFile.bfOffBits;
bitmap = new BYTE[bitmapSize]; //开辟位图颜色数据大小(字节)的空间
fin.read((char*)bitmap1, bitmapInfo.bmiHeader.biSizeImage);
fin.close();
然后我们打断点,使用调试查看这几个数据:
位图信息头:
大家可以将这些数据分别与之前介绍的结构体内容进行对照,比如位图宽高是3x3,信息头的biWidth
和biHeight
也都是3,说明读取的内容也都是正确的。
要查看DIB数据位部分,需要先在VS菜单栏依次点开“调试-窗口-内存-内存1”,找到bitmap指针的值,然后将值填进内存查看的地址里,就可以查看颜色数据了。
内存查看器以16进制存储,每一个数代表4位,以两个数(1字节)为一组。
前面提到了如果biHeight
,也就是图像高度为正值,那图像的原点位于左下角,而且DIB数据位使用big endian方式存储,所以从左往右分别对应的颜色是BGR。
确认了这些约定,我们来读一下数据,我们的图像使用的是24位色,所以这里以两个数为一组的话,三组则代表一个颜色数据。3x3位图左下角的第一个像素是纯蓝色,RGB表示为(0x00, 0x00, 0xff),然后我们读前三组数,是ff 00 00,因为是BGR顺序,所以也代表纯蓝色。
再往下读两个颜色都是纯蓝色,和我们的图片一致。这里还会发现三个颜色读完多出来了三个00,那是因为这里每一行的数据进行了补零。
这里大家也可以尝试用内存查看器去读一读位图头的内容,方法都是一样的,在这里就不写了。
分析完位图的格式以及各种注意点,终于要进入位图绘制的介绍了。
要绘制DIB位图,有两种方法:
SetDIBitsToDevice
或者StretchDIBits
函数直接绘制Bitblt
或者StretchBlt
函数绘制使用第二种方法性能更好,所以这里我们就介绍第二种方法绘制。
这部分其实难度并不大,先贴出Bitblt
和StretchBlt
这两个函数原型:
BOOL BitBlt(
[in] HDC hdc,
[in] int x,
[in] int y,
[in] int cx,
[in] int cy,
[in] HDC hdcSrc,
[in] int x1,
[in] int y1,
[in] DWORD rop
);
hdc
为设备上下文的句柄x
, y
分别是目标矩形左上角的x,y坐标,也就是以左上角为原点,将图像从这个坐标开始绘制cx
,cy
分别是源矩形和目标矩形的宽度和高度,例如一张20x20的位图,如果这两个值都为20,那就是全部都绘制出来,如果都是10,那就只画整张图的四分之一,注意这个值并不是拉伸图片而是裁剪hdcSrc
为源设备上下文的句柄x1
,y1
分别是源矩形左上角的x,y坐标,也就是从图像的(x1, y1)坐标开始绘制rop
,指定光栅操作代码。 这些代码定义源矩形的颜色数据如何与目标矩形的颜色数据组合,以实现最终颜色。BOOL StretchBlt(
[in] HDC hdcDest,
[in] int xDest,
[in] int yDest,
[in] int wDest,
[in] int hDest,
[in] HDC hdcSrc,
[in] int xSrc,
[in] int ySrc,
[in] int wSrc,
[in] int hSrc,
[in] DWORD rop
);
这个函数xDest
,yDest
就对应了前一个函数的x
,y
,xSrc
和ySrc
对应x1
和y1
。
它加了两个参数wSrc
,hSrc
分别可以表示源矩形的宽度和高度,然后wDest
和hDest
即为目标矩形的宽度和高度,通过调整这两个参数以实现图像的拉伸效果
我们这里就介绍Bitblt绘制函数,第二种感兴趣可以自己尝试。
x
和y
我们就都填上0,代表位图从窗口原点开始画。cx
和cy
分别就填写窗口客户区的宽高,代表绘制整张位图:RECT rect;
GetClientRect(hwnd, &rect);
cx = rect.right - rect.left;
cy = rect.bottom - rect.top;
也可以在处理WM_SIZE
中获取客户区大小:
LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_SIZE:
cx = LOWORD(lParam);
cy = HIWORD(lParam);
break;
}
}
x1
和y1
我们也都填写0,代表从位图的原点开始绘制还有几个参数稍微复杂一点点,也逐个分析下:
hdc
是设备上下文的句柄,在WM_PAINT
消息里可以直接使用BeginPaint
和EndPaint
函数对来获取hdc,在非WM_PAINT
消息里的地方可以使用GetDC
和ReleaseDC
获取(我们做软光栅,经常需要在非WM_PAINT
消息里获取hdc,所以我们后面使用第二种):HDC hdc = BeginPaint(hwnd, &ps);
EndPaint(hwnd, &ps);
HDC hdc = GetDC(hwnd);
ReleaseDC(hwnd, hdc);
hdcSrc
为源设备上下文的句柄,需要先用CreateDIBSection
函数生成用DIB位图句柄,然后将此句柄选入此hdc即可:CreateDIBSection
函数原型:
HBITMAP CreateDIBSection(
[in] HDC hdc,
[in] const BITMAPINFO *pbmi,
[in] UINT usage,
[out] VOID **ppvBits,
[in] HANDLE hSection,
[in] DWORD offset
);
hdc
,设备上下文的句柄pbmi
, 指向BITMAPINFO
结构的指针,该结构指定 DIB 的各种属性,包括位图尺寸和颜色usage
,我们不使用调色板,填写DIB_RGB_COLORS
宏就行,对应值为0ppvBits
,指向DIB数据位地址的指针hSection
和offset
都填写NULL或者0就可以,表示系统会为DIB分配内存那么以下是获取hdcSrc的代码:
HDC hdcMem = CreateCompatibleDC(hdc); //创建位图使用的内存设备环境
HBITMAP hBitmap = CreateDIBSection(hdcMem, &bitmapInfo, DIB_RGB_COLORS, (void**)&bitmap, NULL, NULL); //生成DIB位图句柄
SelectObject(hdcMem, hBitmap); //将位图选入内存设备环境
Bitblt
函数最后一个参数是rop
,指光栅操作代码,这些代码定义源矩形的颜色数据如何与目标矩形的颜色数据组合,以实现最终颜色。这里我们填写SRCCOPY
宏,将源矩形直接复制到目标矩形,也就是图像直接复制到窗口上。现在我们已经知道了所有参数的含义并且也有办法获取了,接下来只要调用绘制就可以啦:
BitBlt(hdc, 0, 0, cxClient, cyClient, hdcMem, 0, 0, SRCCOPY);
绘制位图的全部代码:
LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
BITMAPFILEHEADER bitmapFile;
BITMAPINFO bitmapInfo;
BYTE* bitmap;
LONG64 bitmapSize;
HBITMAP hBitmap;
static int cxClient, cyClient;
ifstream fin;
HDC hdc, hdcMem;
PAINTSTRUCT ps;
switch (message)
{
case WM_SIZE:
cxClient = LOWORD(lParam);
cyClient = HIWORD(lParam);
break;
case WM_PAINT:
fin.open("20x20位图.bmp", ios::in, ios::binary);
fin.read((char*)&bitmapFile, sizeof(BITMAPFILEHEADER));
fin.read((char*)&bitmapInfo.bmiHeader, sizeof(BITMAPINFOHEADER));
bitmapSize = bitmapFile.bfSize - bitmapFile.bfOffBits;
cxDib = bitmapInfo.bmiHeader.biWidth;
cyDib = bitmapInfo.bmiHeader.biHeight;
hdc = GetDC(hwnd);
hdcMem = CreateCompatibleDC(hdc);
// 注意CreateDIBSection函数会自动给bitmap分配空间,我们不需要手动new
hBitmap = CreateDIBSection(hdcMem, &bitmapInfo, DIB_RGB_COLORS, (void**)&bitmap, NULL, NULL);
fin.read((char*)bitmap, bitmapSize);
fin.close();
BitBlt(hdc, 0, 0, cxClient, cyClient, hdcMem, 0, 0, SRCCOPY);
ReleaseDC(hwnd, hdcMem);
ReleaseDC(hwnd, hdc);
break;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, message, wParam, lParam);
}
通过上面的学习,位图文件其实也就是将各种信息和颜色数据以二进制的方式存储起来了,然后我们用代码将数据读取进来,记录在专门的结构体或者变量里。
那么要用代码生成位图的话,我们只要自己计算并填写好位图头的类,然后颜色数据也是同样的,使用Byte大小的变量表示,当然也可以做结构体或者类封装起来方便修改颜色,最后还是通过那几个函数将位图绘制出来。
这就是代码生成位图的大体思路,软光栅的部分在像素着色器当中需要对具体的像素进行操作,到时候我们就按照上面的思路做好方便修改某个像素颜色的类就行了。
那么win32api绘制位图差不多就介绍到这里,软光栅我还正在慢慢完善,这篇文章作为制作过程中的第一篇实践记录。最近也差不多期末周了,可能文章更新得比较慢,等暑假再多花点时间肝,同样,文章有什么不对或者理解不到位的地方欢迎指出,我们一起进步!