ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

BMP位图在MDIClient窗口背景绘制:调色板与GDI编程实践

BMP位图在MDIClient窗口背景绘制:调色板与GDI编程实践 简介这是一份面向 Windows 平台 C 开发者的位图与调色板源代码示例包聚焦在 MDI 多文档界面中加载、显示与处理 BMP 位图适合学习 MFC 框架、GDI 绘图和图像文件格式的初中级程序员。压缩包共32个文件以 10 个 H 头文件和 9 个 CPP 源文件为核心配合 6 张 BMP 位图、2 个图标文件及工程配置文件整体约 54KB。通过阅读工程源码可以掌握 BITMAPFILEHEADER/BITMAPINFOHEADER 结构解析、8 位图像调色板创建与颜色索引映射、与设备上下文相关的 BitBlt 绘图调用以及 MDI 子窗口的创建与管理流程。同时也能看到文件 I/O 操作、内存分配和错误处理等在真实商业项目中的组织方式。包内还包含一个可编译的 MdiEx 示例工程适合直接运行观察位图显示效果并逐行学习实现思路。已有 105 人学习适合希望在桌面应用或图像处理方向积累底层图形经验的开发者。1. 位图与调色板MDIClient 窗口里贴图真正的门槛是系统调色板早年做 MDI 桌面工具比如图纸预览器、图像管理器都喜欢在空白客户区放一张位图当“门面”。这个需求听着简单实际做起来坑不少BMP 的调色板不能直接丢给 GDI 用8 位位图是索引色得先在内存里建立逻辑调色板再通过 SelectPalette 和 RealizePalette 映射到系统调色板顺序错了整张图就发灰、发绿或者花屏。bmp_in_mdiclient2.zip 这套题目把场景点得很明确bmp 表示位图加调色板mdiclient 表示它要显示在 MDI 框架的客户端宿主窗口里后缀 2 往往意味着这套代码至少迭代过一次。适合 C/C、Win32 SDK 或 MFC 方向的桌面开发者尤其是做遗留系统维护和影像工具的人。2. 先读透 bmp 头文件文件头、信息头与调色板表的边界关系2.1 14 字节文件头与 40 字节信息头BMP 的读取顺序不能乱。文件最前面的 BITMAPFILEHEADER 是 14 字节接着是 BITMAPINFOHEADER通常是 40 字节也有 12 字节的旧版 BITMAPCOREHEADER再往后才是调色板和像素数据。文件头里的 bfOffBits 字段记录的是像素数据的起始偏移这个值才是实际读像素时真正依赖的“路标”。#pragma pack(push, 1) typedef struct tagBITMAPFILEHEADER { WORD bfType; // BM0x4D42不是 BM 直接拒收 DWORD bfSize; // 整个文件大小字节数 WORD bfReserved1; // 必须为 0 WORD bfReserved2; // 必须为 0 DWORD bfOffBits; // 从文件头到像素数据的偏移 } BITMAPFILEHEADER; #pragma pack(pop) typedef struct tagBITMAPINFOHEADER { DWORD biSize; // 本结构大小固定 40 LONG biWidth; // 像素宽度 LONG biHeight; // 像素高度负数表示自上而下 WORD biPlanes; // 恒为 1 WORD biBitCount; // 1/4/8/16/24/32 DWORD biCompression; // 0不压缩1RLE82RLE4 DWORD biSizeImage; // 像素数据字节数BI_RGB 时可为 0 LONG biXPelsPerMeter; // 通常忽略 LONG biYPelsPerMeter; DWORD biClrUsed; // 使用的颜色数0 表示取 2^biBitCount DWORD biClrImportant; // 重要颜色数通常 0 } BITMAPINFOHEADER; #pragma pack(pop)这段代码里最值得强调的是 bfOffBits。很多自写的 BMP 生成工具会在这个字段上偷懒把像素数据紧挨着信息头放但如果调色板存在bfOffBits 必须等于 14 40 调色板字节数。读取时不要用固定公式算直接在内存映射文件里以 bfOffBits 为起点能少踩很多解析不对齐的坑。biHeight 为正时是自底向上的行序这是 BMP 最容易被新手弄反的地方。2.2 调色板表RGBQUAD 数组与 biClrUsed 的真实含义调色板区不是 GDI 的 HPALETTE它只是文件里的一个 RGBQUAD 数组。每个 RGBQUAD 是 4 字节最后那个字节是保留位通常填 0。颜色表项数在逻辑上等于 1 biBitCount但 biClrUsed 非零时以它为准。下表是常见位深对应的处理分支biBitCount颜色表项数调色板区字节数说明128单色位图4166416 色可带 RLE4 压缩82561024最典型的调色板位图1600无调色板像素是 RGB 555/5652400真彩色每像素三字节3200真彩色带 Alpha 或填充字节我一般会在解析时统一走 BITMAPINFO 结构把文件头的 14 字节跳过剩余部分当作 BITMAPINFO 的连续内存后面跟着的 RGBQUAD 就是 bmiColors。这样做的好处是后续调用 CreateDIBitmap、CreateDIBSection 时指针可以直接传给 Windows API不需要再拷贝一份结构体。解析调色板的常见做法是先把信息头读出来再根据上面的表格申请调色板缓存然后从文件偏移 14 biSize 处开始读入。需要注意旧程序的 BMP 可能是 12 字节的 BITMAPCOREHEADER这时调色板用的是 RGBTRIPLE3 字节处理这类文件时要做兼容分支否则颜色表整体偏移会差 256 字节。2.3 8 位位图与“通道图”制作 bmp 通道图时的调色板语义调色板位图在存储层面是索引值真正显示时由调色板把索引映射成 RGB。这个机制在做“bmp 通道图”时非常有用比如 UI 系统里把一张 8 位 BMP 当作灰度蒙版索引 0 表示全透明索引 255 表示不透明中间值作为半透明系数。这样做的代价是只能用 256 个灰度等级但换来的是极小的内存占用。常见做法是先把调色板统一填充成从黑到白的 256 级灰然后把像素数据按目的地加亮度的方式写入索引。读取端无需关心像素里的“颜色”只取索引值参与 alpha 混合。这类用法在一些老游戏、地图编辑器和工业 HMI 项目里仍很常见。拿到类似 bmp_in_mdiclient2 这样带调色板的源码时先看它创建逻辑调色板时的 PALETTEENTRY 填充逻辑就能判断它的位图到底是“显示用”还是“数据用”。3. MDIClient 子类化与调色板选择贴图前先选设备上下文3.1 MDIClient 是宿主窗口不是视图窗口MDI 框架里框架窗口的客户区被一个类名为 “MDIClient” 的系统窗口占据所有 MDI 子窗口都排列在这个宿主窗口内部。想在 MDI 客户区放背景位图不能简单往框架窗口的 WM_PAINT 里画——因为 MDIClient 窗口叠在框架客户区之上框架画得再漂亮也会被它挡住。常规方案是子类化 MDIClient 窗口。用 SetWindowLongPtr 把它的窗口过程替换成自己的原窗口过程保存下来在不需要特殊处理的消息上直接透传。这样背景绘制、子窗口重排、滚动条联动都还走系统默认逻辑只是把背景擦除这一步接管过来。3.2 子类化并接管 WM_ERASEBKGND子类化代码的核心是保存旧的窗口过程指针在 WM_ERASEBKGND 里做自己的绘制然后返回 TRUE表示背景已经由我们处理。注意这里不要调用 CallWindowProc 传给旧过程否则系统默认的灰色填充会先跑一遍造成一次明显闪烁。static WNDPROC s_pOldMDIClientProc NULL; LRESULT CALLBACK MDIClientSubclassProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_ERASEBKGND: // wParam 就是需要绘制的设备上下文 DrawMDIBackground((HDC)wParam, hwnd); return TRUE; // 背景已经画完不再让系统擦 case WM_SIZE: case WM_PAINT: // 尺寸变化时让背景跟着重绘 InvalidateRect(hwnd, NULL, FALSE); break; default: break; } return CallWindowProc(s_pOldMDIClientProc, hwnd, uMsg, wParam, lParam); } void SubclassMDIClient(HWND hwndMDIClient) { s_pOldMDIClientProc (WNDPROC)SetWindowLongPtr( hwndMDIClient, GWLP_WNDPROC, (LONG_PTR)MDIClientSubclassProc); }这段代码里有一个容易被忽略的细节WM_ERASEBKGND 的 wParam 是系统传入的 HDC直接在它上面做 BitBlt 即可。WM_SIZE 和 WM_PAINT 分支不是必须的但加上 InvalidateRect 后窗口大小调整时背景能立即刷新。如果子类化返回 TRUE 后还想要系统处理其他背景相关消息要把不需要的默认行为全部交给 CallWindowProc不要自己吞掉。绘制函数里还要处理 MDI 子窗口覆盖区域。背景绘制在子窗口下面系统会负责在子窗口重绘时盖住这些区域所以平铺逻辑不需要考虑子窗口位置直接整块绘制即可。注意这个全局窗口过程只适合单 MDI 框架窗口的演示真实工程里每个 MDI 框架各自持有一份窗口过程和位图句柄可以在创建框架窗口时用 SetProp 按窗口句柄存储。3.3 SelectPalette 与 RealizePalette 的分工调色板位图显示出来发灰、发绿十有八九是这两步没做对。SelectPalette 是把逻辑调色板选进设备上下文RealizePalette 是把逻辑调色板里的颜色映射到系统调色板。在真彩色系统上RealizePalette 几乎不做事但在 256 色兼容模式下它决定像素的索引色到底映射成哪个 RGB。void DrawMDIBackground(HDC hdc, HWND hwnd) { RECT rc; GetClientRect(hwnd, rc); // 只有在系统确实需要调色板的时候才选入 if (GetDeviceCaps(hdc, RASTERCAPS) RC_PALETTE) { HPALETTE hOld SelectPalette(hdc, g_hPalette, FALSE); RealizePalette(hdc); SelectPalette(hdc, hOld, FALSE); } HDC hMemDC CreateCompatibleDC(hdc); HBITMAP hOldBmp SelectBitmap(hMemDC, g_hBackgroundBmp); for (int y 0; y rc.bottom; y g_bmpHeight) { for (int x 0; x rc.right; x g_bmpWidth) { BitBlt(hdc, x, y, g_bmpWidth, g_bmpHeight, hMemDC, 0, 0, SRCCOPY); } } SelectBitmap(hMemDC, hOldBmp); DeleteDC(hMemDC); }参数说明SelectPalette 的第三个参数 bForceBackground传 FALSE 表示这是前台调色板窗口在前台时系统会把它的颜色映射优先传 TRUE 则始终当作后台调色板。RealizePalette 的返回值是实际被映射的调色板条目数调试时可以用这个值判断颜色是否全部映射成功。还有两个容易忽略的点。一是这里只需要目标 DC 选入调色板BitBlt 的色表映射按目标 DC 走如果后续换成 StretchBlt 或 AlphaBlend源 DC 最好也选入同一个调色板。二是在 256 色模式下每次 SelectPalette 后都要调 RealizePalette顺序不能反。很多程序只做了 SelectPalette 就开画结果系统用最近一次前台窗口的调色板做映射颜色整体偏移。把这个调用放在 BitBlt 之前可以保证绘制时用的是刚选入的调色板。4. 从 bmp 解析到 MDI 背景绘制一份可运行的 C/C 实现4.1 解析 BMP 并构造逻辑调色板把第 2 章的解析逻辑和第 3 章的窗口子类化串起来就是一个完整实现。加载阶段要做的三件事读文件、构造 BITMAPINFO、创建位图和逻辑调色板。这里的核心是第 2 章提到的 bfOffBits像素数据偏移必须用它来定位。bool LoadBMPBackground(LPCTSTR lpszFile) { HANDLE hFile CreateFile(lpszFile, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); if (hFile INVALID_HANDLE_VALUE) return false; BITMAPFILEHEADER bmf; DWORD dwRead 0; ReadFile(hFile, bmf, sizeof(bmf), dwRead, NULL); if (bmf.bfType ! 0x4D42) { CloseHandle(hFile); return false; } DWORD dwHeaderSize bmf.bfOffBits - sizeof(BITMAPFILEHEADER); PBYTE pHeader new BYTE[dwHeaderSize]; ReadFile(hFile, pHeader, dwHeaderSize, dwRead, NULL); BITMAPINFO *pbmi (BITMAPINFO*)pHeader; int nColors 0; if (pbmi-bmiHeader.biBitCount 8) { nColors (pbmi-bmiHeader.biClrUsed ! 0) ? pbmi-bmiHeader.biClrUsed : (1 pbmi-bmiHeader.biBitCount); } // 把像素数据读进内存 DWORD dwPixelsSize bmf.bfSize - bmf.bfOffBits; PBYTE pPixels new BYTE[dwPixelsSize]; SetFilePointer(hFile, bmf.bfOffBits, NULL, FILE_BEGIN); ReadFile(hFile, pPixels, dwPixelsSize, dwRead, NULL); CloseHandle(hFile); // 使用 DIB Section 避免额外的 DDB 转换 g_hBackgroundBmp CreateDIBSection(NULL, pbmi, DIB_RGB_COLORS, (VOID**)g_pPixelBits, NULL, 0); memcpy(g_pPixelBits, pPixels, dwPixelsSize); if (nColors 0) { g_hPalette BuildPaletteFromBMI(pbmi, nColors); } delete[] pHeader; delete[] pPixels; return true; }这段代码的要点有四个。一是用 bfOffBits 减去文件头大小得到信息头加调色板的总大小避免手动拼 14 字节的偏移。二是 8 位以下才需要创建调色板24/32 位直接构造 DIB Section 即可。三是用 CreateDIBSection 分配像素缓冲区后面 memcpy 直接写进系统管理的位图内存性能比 CreateDIBitmap 再 SelectObject 的方式高。四是不需要额外做 DDB 转换DIB Section 天然兼容 BitBlt。4.2 从 BITMAPINFO 构建逻辑调色板BuildPaletteFromBMI 是这套实现里最容易写错的地方。LOGPALETTE 结构体在声明时要额外分配 PALETTEENTRY 数组的内存不能直接栈上定长。调色板条目数量超过 256 时结构的标准写法是把可变长度数据放在最后一个字段后面。HPALETTE BuildPaletteFromBMI(BITMAPINFO *pbmi, int nColors) { if (nColors 0 || nColors 256) return NULL; LOGPALETTE *pLogPal (LOGPALETTE*)malloc( sizeof(LOGPALETTE) nColors * sizeof(PALETTEENTRY)); pLogPal-palVersion 0x300; // Windows 3.0 以上必须 pLogPal-palNumEntries (WORD)nColors; for (int i 0; i nColors; i) { pLogPal-palPalEntry[i].peRed pbmi-bmiColors[i].rgbRed; pLogPal-palPalEntry[i].peGreen pbmi-bmiColors[i].rgbGreen; pLogPal-palPalEntry[i].peBlue pbmi-bmiColors[i].rgbBlue; pLogPal-palPalEntry[i].peFlags 0; // 正常闪烁行为 // 需要防止抖动的图像可以设 PC_NOCOLLAPSE } HPALETTE hPal CreatePalette(pLogPal); free(pLogPal); return hPal; }参数说明palVersion 固定填 0x300在较新的 Windows 版本上填低了会导致 CreatePalette 失败。PALETTEENTRY 里的 peFlags 在动画调色板场景下可以填 PC_RESERVED告诉系统这个条目不允许被其他窗口的调色板映射走用于避免闪烁但代价是其他窗口无法使用该颜色槽桌面环境下一般保持 0。调色板创建失败时可以尝试用 GetNearestPaletteIndex 配合 GetDeviceCaps 判断当前显示模式是否低于 15 位色。注意8 位位图解析为 DIB Section 后如果后续还要保存回文件像素行必须按 4 字节对齐否则 biSizeImage 会和实际写入长度不一致读出来的文件边缘会出现斜条纹。4.3 位深分支与常见误用实际接入时位深决定了两条完全不同的路径很多“bmp 图像显示花屏”的问题都出在分支判断上场景应当走的路径常见误用8 位 BMP解析调色板创建逻辑调色板绘制前 SelectPalette RealizePalette直接用 CreateBitmap 加载颜色被系统当作 DDB 处理24 位 BMP跳过调色板直接 CreateDIBSection像素按 BGR 排列把像素当 RGB 顺序处理红蓝交换32 位 BMP同 24 位但需要确认 Alpha 通道是否有效直接拷给 24 位表面透明度丢失坐标系也是值得单独提的BMP 默认自底向上负高度才是自上而下。如果背景图是从网络或美术工具流转过来的十有八九是自上而下的 PNG 习惯转成 BMP 时不修正 biHeight 符号显示出来就是上下颠倒。标题里的后缀 2从迭代角度推测这种一版修不清的坑通常就包括调色板映射和 DDB/DIB 混用问题。5. 调色板闪烁与偏色的三个排错点以及 8 位位图的迁移技巧5.1 调色板闪烁的根因WM_ERASEBKGND 的擦除顺序背景闪烁的常见原因是系统先按默认行为把窗口擦成灰色再执行子类化里的绘制代码。处理办法是在子类化过程中直接返回 TRUE告诉系统“背景我已经画完了”不要走 DefWindowProc 的灰色填充。同时如果要在 WM_PAINT 和 WM_ERASEBKGND 两处都绘制必须保证两处逻辑一致否则每次重绘会交替出现两种色调。256 色模式下还有一类闪烁来自调色板映射竞争两个窗口各持有一个逻辑调色板前台窗口切换时会触发系统重新映射背景图颜色短暂跳变。给背景位图的 PALETTEENTRY 加上 PC_RESERVED 可以锁住颜色槽这是老游戏图像稳定显示的标准做法。5.2 用 GetSystemPaletteEntries 验证颜色映射调色板是否生效可以调用 GetSystemPaletteEntries 拿回系统物理调色板和 BMP 文件里的调色板逐项对比PALETTEENTRY sysPal[256]; UINT n GetSystemPaletteEntries(hdc, 0, 256, sysPal); // n 表示系统调色板实际条目数 // 256 色模式下通常能拿到 256 项真彩色下通常为 0验证时看三个点返回值为 0 说明当前显示模式不需要调色板代码走 RC_PALETTE 分支也不会执行返回值小于 256 说明系统保留了部分颜色常见的 20 色系统保留背景图某些颜色会被替换逐项比对颜色偏差较大的项说明调色板没有正确 Realize。5.3 旧 8 位调色板位图的迁移做法新代码里我一般直接走 32 位路线加载 8 位 BMP 后把像素里的索引值去查调色板输出为 BGRA32 表面alpha 通道根据需要填 0xFF 或按灰度索引映射成半透明值。这样就不再需要 SelectPalette、RealizePalette 这些环节也避免了 256 色模式下显示色偏。改动代价是内存占用从 256KB 涨到 1MB 左右对现在的影响可以忽略。迁移时的兼容技巧是保留一份离散的调色板表把 8 位索引直接作为查找表的键避免每像素做 RGB 差值运算。这样原有美术素材不用重存只是在加载时多一层转换和 bmp_in_mdiclient2 里那种“选入调色板、映射系统色、再 BitBlt”的经典路径相比省掉了运行时调色板状态管理。本文还有配套的精品资源点击获取
返回列表