对话框里画一个立体模型,这事听起来像是上世纪的做法,可我在几个工控上位机项目里至今还在用它顶着。工程是十几年前建的 MFC 对话框程序,没有三维引擎,没有显卡加速,甚至连一套像样的渲染管线都没有,就靠 CDC 一根线一根面地往上画。但恰恰是这种"土办法",把三维图形的核心链条——顶点、旋转矩阵、透视投影、背面剔除、光照着色——完完整整摊在你面前,一个环节都藏不住。你把这套东西手写一遍,再去看任何三维框架的文档,基本就是认亲戚。这篇内容适合三类人:一是手上维护着老 MFC 工程、需要在界面上加个三维示意图的;二是想搞明白三维图形底层到底怎么算出那一帧画面的;三是做设备上位机、测试工具,需要一个不依赖额外运行库的轻量三维展示。下面我按自己实际落地的顺序,从数据结构讲到双缓冲、光照和鼠标交互,把能直接抄的部分都写出来。
1. 需求拆解与整体设计思路
1.1 一个"能转的立方体"背后到底要解决什么
需求单上往往只有一句话:对话框里放一个立体模型,操作员一眼能看出当前配置的是哪个方向。听起来简单,但把它翻译成技术任务,其实是四件事叠在一起。第一是要有一份几何描述,模型由哪些点、哪些面组成,这部分是静态数据。第二是要有一次空间变换,让模型能绕三个轴转动,这就是旋转矩阵要干的活。第三是要把三维坐标压到二维画布上,否则屏幕只有宽高两个方向,装不下第三个维度,这是透视投影。第四是要让面看起来有立体感,纯线框是"透明"的,六个面重叠在一起会让人分不清前后,必须靠填充色和明暗来制造遮挡感。
这四件事对应的代码量其实不大,一个两三百行的对话框类就能装下,难点不在写,而在于每一步的坐标约定要统一。我见过不少半途而废的实现,问题都出在"这台机器上转得挺好,换台机器模型翻了个面",本质是某个环节的 Y 轴方向或者旋转正方向定反了。所以在一开始就把约定钉死,比后面调试省太多时间。
我的约定是这样的:模型坐标系里 X 向右、Y 向上、Z 指向观察者;屏幕坐标系里 X 向右、Y 向下。也就是说,投影的时候 Y 要取反,这是第一个必须记住的反向点。旋转采用右手法则,绕某轴正方向旋转时,从该轴正端往原点看是逆时针。这套约定跟大多数图形学教材一致,后面查资料不容易对不上。
1.2 技术选型:为什么最后没上三维引擎
摆在面前的选择其实不少。引入一个成熟的三维渲染库是最省事的,模型加载、光照、相机全都现成;退一步用系统自带的图形接口做硬件加速,性能也好得多。但我最后选了最原始的 GDI 手绘,理由有三条,都是实际踩出来的。
第一条是部署成本。这类上位机经常跑在产线机器上,装软件要走审批,多一个动态库就多一份扯皮。纯 GDI 是系统自带的,编译出来一个可执行文件拷过去就能跑,运维那边零负担。第二条是可控性。需求里要的"立体感"其实很有限,一个带明暗的立方体或者长方体就够,用引擎反而要花时间跟它的材质系统、相机参数较劲,调出来的效果还未必符合工业界面那种"干净、不花哨"的审美。第三条是排查方便。GDI 画出来的每一个像素都是我算出来的,出了问题断点打在自己代码里,变量一目了然;引擎黑盒出问题,日志翻半天。
当然代价也很清楚:面数一上去就撑不住。我实测过,纯 GDI 填充多边形,在普通办公机上跑到两千个面左右开始掉帧,五千个面就明显卡顿了。所以这套方案的定位要摆正——它是"几十到几百个面"级别的轻量展示方案,不是通用三维渲染器。想清楚这一点,后面所有的取舍都好做了。
1.3 一条手工搭起来的渲染小流水线
整体流程我拆成五步,按顺序走一遍就是完整的一帧。
- 模型空间:从静态数组里取出顶点坐标,此时模型静止在原点,坐标范围大概在正负 1 之间。
- 旋转:对每个顶点依次施加绕 X、绕 Y、绕 Z 的旋转,得到世界空间坐标。
- 投影:把世界空间的三维点按透视关系映射成画布上的二维整数坐标。
- 可见性判断:算出每个面的法向量,判断它是否朝向观察者,把背对观察者的面丢掉。
- 着色与绘制:根据法向量和光源方向算明暗系数,调制基色,按深度从远到近填充多边形。
这五步每帧都要完整跑一遍,任何一步的顺序错了都会出怪现象。比如先投影再判可见性,法向量就算不出来了;比如不排序直接画,远处的面会盖住近处的面,模型看起来像被啃了一口。这套流程跟真正的渲染管线是同一个骨架,只是把顶点着色器、光栅化这些环节换成了几次乘法和一个Polygon调用。
还有一个容易被忽略的设计点:模型数据和渲染代码要分离。我把顶点表、面表定义成static const的全局数组,渲染函数只接收"指针 + 数量"这样的参数。这么做的直接好处是,后面要把立方体换成别的模型,只改数据表,渲染代码一行不动。我后来加过一个八棱柱和一个简化版的机械臂关节,都是纯加数据,十分钟搞定。
2. 数学准备:顶点、旋转与投影三件套
2.1 顶点与面的数据结构怎么定
数据结构这块,我建议一开始就分成三层:点、面、模型。点用三个 double 表示坐标,别用 float,虽然这个场景下精度绰绰有余,但 double 在做累加旋转的时候不会出现那种"转了几百圈之后模型慢慢变形"的漂移。虽然实际上每帧都是从原始顶点重新算的,不存在累积误差,但用 double 心里踏实,而且现代机器上这点开销完全可以忽略。
面用顶点索引加一个基色来表示。这里有个关键约定:顶点索引的顺序决定了面的朝向。我是按"从模型外部看过去逆时针"来排的,这样用相邻两边做叉积算出来的法向量就指向外部。如果这个顺序搞反了,背面剔除会整个反过来,你会看到模型的内壁。
具体到立方体,八个顶点和六个面我这样定义:
| 顶点编号 | X | Y | Z |
|---|---|---|---|
| 0 | -1 | -1 | -1 |
| 1 | +1 | -1 | -1 |
| 2 | +1 | +1 | -1 |
| 3 | -1 | +1 | -1 |
| 4 | -1 | -1 | +1 |
| 5 | +1 | -1 | +1 |
| 6 | +1 | +1 | +1 |
| 7 | -1 | +1 | +1 |
六个面按从前到后的顺序排,每行四个索引,从外部看是逆时针:
| 面 | 顶点序列 | 外法向 | 基色(示例) |
|---|---|---|---|
| 前 z=+1 | 4, 5, 6, 7 | (0, 0, 1) | 砖红 |
| 后 z=-1 | 0, 3, 2, 1 | (0, 0, -1) | 暗红 |
| 底 y=-1 | 0, 1, 5, 4 | (0, -1, 0) | 深蓝 |
| 顶 y=+1 | 3, 7, 6, 2 | (0, 1, 0) | 浅蓝 |
| 左 x=-1 | 0, 4, 7, 3 | (-1, 0, 0) | 深绿 |
| 右 x=+1 | 1, 2, 6, 5 | (1, 0, 0) | 浅绿 |
这个表建议直接照着打,六个面的索引顺序我都验算过法向,打错一个字符后面审核半天找不出来。至于颜色,给每个面不同基色是为了让相邻面即使明暗接近也能靠色相区分,工业界面上很吃这一套,比纯灰度好看得多,也更容易看懂朝向。
2.2 三个旋转矩阵的直观理解
旋转矩阵的公式网上一搜一大把,但很多人写了之后不知道怎么验证对不对。我提供一个自己常用的土办法:拿一个顶点手动算一遍,看它转到哪去了。
绕 X 轴转角度 a 的时候,X 坐标不变,Y 和 Z 在 YZ 平面里旋转。公式是:
y' = y * cos(a) - z * sin(a) z' = y * sin(a) + z * cos(a)验证:取点 (0, 1, 0),即 Y 轴正方向上方一点,绕 X 轴转 90 度,代进去得到 y' = 0、z' = 1,也就是说它转到了 Z 轴正方向,朝观察者过来了。按右手法则,右手大拇指指向 X 轴正方向,四指从 Y 卷向 Z,确实是这样。对上了,公式没问题。
绕 Y 轴要注意符号,因为它涉及的是"侧面"旋转:
x' = x * cos(b) + z * sin(b) z' = -x * sin(b) + z * cos(b)取点 (0, 0, 1),也就是朝向观察者的那一点,绕 Y 轴转 90 度,得到 x' = 1、z' = 0,它转到了右侧。右手法则大拇指指 Y,四指从 Z 卷向 X,同样吻合。
绕 Z 轴的公式最"标准":
x' = x * cos(c) - y * sin(c) y' = x * sin(c) + y * cos(c)这三组旋转的应用顺序会影响最终姿态,这是欧拉角的固有特性,同一个终态可以用多组角度表示。对立方体这种近似对称的模型,顺序影响不大;但对后面要换成非对称模型的情况,我建议固定成"先绕 Y(偏航)、再绕 X(俯仰)、最后绕 Z(翻滚)",这样鼠标拖拽出来的手感最符合直觉——左右拖是绕模型竖轴转,上下拖是绕水平轴翻。
实际写的时候,我会把三次旋转写成一个函数,内部依次算,返回旋转后的点。这样调用方只需要传三个角度,不用关心内部顺序。
2.3 透视投影与视口映射
投影这一步是把三维点变成二维像素坐标,我的做法是把相机放在 Z 轴正方向某处,朝原点看,屏幕平面在 z = 0。
透视的核心是"近大远小",用一个缩放因子来表达:离相机越近的点,横向偏移被放得越大。设相机距离为D,点的 Z 坐标为z,那么缩放因子是:
t = D / (D - z)这里D - z必须大于零,否则就是点在相机后面或者正好在相机平面上,会出现除以零或者符号翻转,模型会瞬间炸成一片乱线。立方体顶点坐标绝对值最大约 1.73(对角线一半),所以 D 取 5 到 8 之间都比较安全,我一般用 6。
拿到 t 之后,屏幕坐标就是:
sx = cx + x * t * f sy = cy - y * t * fcx、cy是画布中心,f是焦距系数,控制模型的整体大小。注意sy那里是减号,这就是前面说的 Y 轴反向,屏幕坐标往下是正方向,而模型坐标往上是正方向,不取反的话模型会上下颠倒。这个问题我第一次写的时候被坑了半天,因为立方体基本对称,倒过来看着好像也没错,直到加了个带上下结构的模型才发现不对劲。
f的取值跟画布尺寸挂钩比较合理。我一般让f = zoom * min(width, height) * 0.35,其中zoom是用户可调的缩放系数,默认 1.0。这个 0.35 是试出来的:立方体外接球半径约 1.73,乘上焦距再乘 0.35,在最小边对应的方向上大概占了屏幕的三分之一多一点,四周留白合适。如果取 0.5,边角位置的面会被切掉一块,很难看。
2.4 参数取值与边界校验清单
这些参数看着随意,实际上每个都有安全范围,我在调试阶段整理过一张表,照着填基本不会出问题:
| 参数 | 推荐值 | 安全范围 | 越界后果 |
|---|---|---|---|
| 相机距离 D | 6.0 | 3.0 ~ 20.0 | 太小导致模型极度畸变,太大会退化成正交投影 |
| 焦距系数 0.35 | 0.35 | 0.2 ~ 0.5 | 太大画面被裁切,太小模型挤在中间 |
| 缩放 zoom | 1.0 | 0.2 ~ 3.0 | 太小看不见,太大只剩一个面颜色 |
| 俯仰角 pitch | 0 | -1.5 ~ 1.5 弧度 | 超出范围模型上下颠倒 |
| 偏航角 yaw | 0 | 不限,取模 2π | 角度累加到很大时 cos/sin 精度略降 |
| 翻滚角 roll | 0 | 不限 | 同上 |
角度这个我补一句话:虽然理论上不限,但我会在每次累加后做一次fmod(angle, 2π)。这不是性能优化,而是防止长时间运行后角度值大到几万,虽然 double 的精度撑得住,但一旦有人把它转成 float 传给别的地方就会出问题,属于防御性编程。
3. 对话框工程搭建与画布处理
3.1 工程与控件准备
新建工程时选基于对话框的 MFC 应用,向导里把"使用 MFC"设为静态链接或者共享 DLL 都行,看部署需求。我倾向于共享 DLL,因为产线上一般都有运行库,静态链接会让可执行文件大出一截。
对话框资源上,我通常会放一个 Static 控件当作画布占位,拉成矩形,ID 改成一个有意义的名字,比如IDC_CANVAS。这么做的好处是布局跟着对话框走,改窗口大小时不用自己算位置。但这里有个必须提前知道的坑:Static 控件会覆盖住父窗口的绘制区域,你在对话框的OnPaint里往那个矩形画,画不上,被控件自己的白底挡得严严实实。
解决办法有两条路。一条是子类化这个 Static 控件,把绘制搬到它自己的OnPaint里去,正统但代码多,要写一个派生类和一个DDX_Control成员。另一条是把 Static 控件的 Visible 属性设为 false,让它只当占位器,绘制仍在对话框里做。我选的是第二条,理由是代码少、好维护,而且鼠标消息统一在对话框里处理,不用来回转发。
需要提醒的是,控件隐藏之后那个位置就属于对话框客户区了,鼠标事件会正常落到对话框的OnLButtonDown、OnMouseMove上,所以拖拽交互反而更顺。但你也得记得判断鼠标坐标是否落在画布矩形内,否则在对话框其它地方拖拽也会触发旋转,体验很怪。
3.2 画布矩形的获取与背景擦除的取舍
画布矩形的获取方式,我写成一个小函数,每次需要的时候调一次,而不是缓存起来。原因是对话框可能被拖动、缩放、更换显示器(不同 DPI),缓存容易过期。代码如下:
CRect CModelViewDlg::GetCanvasRect() const { CRect rc(0, 0, 0, 0); CWnd* pWnd = GetDlgItem(IDC_CANVAS); if (pWnd != nullptr) { pWnd->GetWindowRect(&rc); ScreenToClient(&rc); } return rc; }用GetWindowRect配合ScreenToClient而不是GetClientRect,是因为GetClientRect拿到的坐标原点是控件自己的,还得再转换一次,容易出错。这个写法虽然多一次坐标转换,但返回的矩形直接就是对父窗口客户区的坐标,后面就不用再操心。
关于背景擦除,这块的说法很多,我按自己的实测给结论。对话框默认会在WM_ERASEBKGND里刷一遍背景,如果你在这个消息里无脑返回 TRUE,确实能消除一部分闪烁,但会导致画布之外、对话框之内的区域没人清理,改过窗口大小之后会留下一片脏污。所以我不对OnEraseBkgnd做全局干预,而是把防闪烁的责任全部交给画布内的双缓冲。只要双缓冲的那张位图完整覆盖了画布矩形,画布区域就不会有任何闪烁,画布之外由系统正常擦除,两边都不冲突。
唯一的例外是:如果你在画布边缘看到一圈细细的闪动,那是因为画布矩形和双缓冲位图的尺寸差了一个像素。这种情况把矩形稍微内缩一个像素,或者把位图按rc.Width() + 1建,就干净了。
3.3 双缓冲:消灭闪烁的完整实现
双缓冲的道理大家都知道,但写的时候顺序容易错。我把完整的框架写出来,重点是资源创建和释放的顺序。
void CModelViewDlg::OnPaint() { CPaintDC dc(this); CRect rcCanvas = GetCanvasRect(); if (rcCanvas.Width() <= 0 || rcCanvas.Height() <= 0) return; // 1. 建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcCanvas.Width(), rcCanvas.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 2. 在内存 DC 上绘制,坐标原点就是画布左上角 DrawScene(&memDC, CRect(0, 0, rcCanvas.Width(), rcCanvas.Height())); // 3. 一次性贴到屏幕上 dc.BitBlt(rcCanvas.left, rcCanvas.top, rcCanvas.Width(), rcCanvas.Height(), &memDC, 0, 0, SRCCOPY); // 4. 按逆序恢复并释放 memDC.SelectObject(pOldBmp); bmp.DeleteObject(); memDC.DeleteDC(); }四个步骤的顺序不能乱。SelectObject必须在DeleteObject之前恢复,否则位图还被 DC 选中就删不掉,会造成资源泄漏——这种泄漏很隐蔽,任务管理器里 GDI 对象数一直涨,跑几个小时之后程序直接崩掉,报的错还跟绘制毫无关系。我建议在调试阶段用任务管理器看一眼 GDI 对象计数,稳定运行时应该在一个固定值上下浮动,如果一直单调增,就是这里出问题了。
DrawScene内部接收的矩形原点是 (0,0),这是因为内存 DC 的坐标是相对于位图的,跟屏幕无关。这个小细节不注意的话,画出来的东西会偏一个画布偏移量,很多人以为是自己投影算错了,其实只是坐标系没换。
如果画布尺寸比较大(比如 1200×800),每帧新建位图的开销其实不小。我做过一个优化版本:在OnSize里判断尺寸变化,变化了才重建缓存的位图,平时复用。实测下来帧率能再提一截,尤其是自动旋转的时候。不过这会引入额外的状态管理,代码复杂度上去了,如果只是画个立方体,每帧重建完全够用,先跑通再优化。
4. 渲染核心:剔除、光照与绘制顺序
4.1 背面剔除:为什么必须先做
立方体有六个面,但任何时刻你最多只能看到三个面。剩下三个背对观察者的面,如果不剔除,它们会被先画上去,然后被前面的面盖住,虽然最终看起来结果一样,但白白浪费了三次多边形填充和三次光照计算。更重要的是,不剔除就没有"实体感":因为 GDI 填充多边形时如果面的顶点顺序是顺时针和逆时针混着,在某些填充模式下的结果会很难看,而剔除之后剩下的面顺序是一致的,画出来干净。
判断方法很直接:算出面的外法向量,再看从面中心指向相机的向量,两者点积大于零说明面朝向相机,保留;小于零说明背对相机,丢弃。
Vec3 FaceNormal(const Vec3& a, const Vec3& b, const Vec3& c) { Vec3 u = Sub(b, a); Vec3 v = Sub(c, a); return Cross(u, v); // 只要方向,长度无所谓 } bool IsFrontFacing(const Vec3& normal, const Vec3& faceCenter, double camDist) { Vec3 viewDir = Sub(Vec3{ 0.0, 0.0, camDist }, faceCenter); return Dot(normal, viewDir) > 0.0; }叉积的减法顺序很关键。我用的是(b - a) × (c - a),配合前面"从外部看逆时针"的顶点顺序,得到的就是外法向。如果换成(c - a) × (b - a),法向整个反过来,剔除逻辑就反了。
这里有个经验值可以分享:点积结果的绝对值很小时要小心。当某个面正好侧对着相机,点积接近零,浮点误差会让它一会儿被判为正面一会儿被判为背面,表现在画面上就是边缘有一条面在闪烁。我的处理是加一个很小的阈值,比如把判断条件改成Dot(...) > 1e-9,虽然理论上会让极侧面的面被丢掉,但视觉上根本看不出来,反而更稳定。
4.2 面法向量与朗伯光照的计算
有了法向量,光照就好办了。我用的是最经典的朗伯漫反射模型,一句话概括:面越正对光源,就越亮;越侧对光源,就越暗;背对光源就只有环境光。
// 指向光源的单位向量,自己按喜好调 Vec3 lightDir = Normalize(Vec3{ -0.4, 0.7, 0.85 }); double diff = Dot(Normalize(normal), lightDir); if (diff < 0.0) diff = 0.0; const double ambient = 0.28; // 环境光比例 const double k = ambient + (1.0 - ambient) * diff; // 0.28 ~ 1.0这个k就是明暗系数,最后拿它去乘基色的 RGB 三个分量:
BYTE Modulate(BYTE base, double k) { int v = (int)(base * k + 0.5); return (BYTE)(v > 255 ? 255 : (v < 0 ? 0 : v)); }光源方向为什么取(-0.4, 0.7, 0.85)?这是我反复调出来的。Z 分量为正意味着光从观察者这一侧照过去,这样看到的面都不会是纯黑;Y 分量为正意味着光从上方打下来,符合人对"光从天上来"的直觉;X 分量为负则让左侧的面稍亮、右侧稍暗,产生一点方向感。如果你把光源放在正后方,模型就会变成剪影,全是暗的,看着很闷。
环境光比例 0.28 这个值也有讲究。设得太小,背光面接近纯黑,跟深色背景糊在一起,看不出边界;设得太大,明暗对比不够,六个面颜色差不多,反而没了立体感。0.25 到 0.35 之间我觉得都合适,具体看你的界面配色。
4.3 深度排序:凸模型也不能完全省
对立方体这种凸多面体,做完背面剔除之后剩下的面互相之间其实不会遮挡,所以理论上不排序也能画对。但我不建议省这一步,原因有两个:一是换成凹模型(比如一个 L 形的夹具)之后会立刻出问题,而这时候你已经忘了排序这回事了;二是排序代码就那么几行,几乎没成本。
我用的是画家算法,把可见面按面中心的 Z 坐标从小到大排序,先画远的,后画近的,后面的自然盖住前面的。Z 越大越靠近相机,所以升序排。
struct FaceDraw { int idx[4]; double depth; // 面中心 z COLORREF fill; }; // ... 收集可见面 ... std::sort(vec.begin(), vec.end(), [](const FaceDraw& a, const FaceDraw& b) { return a.depth < b.depth; });面中心的 Z 就用四个顶点 Z 的平均值,对这个场景足够用。画家算法在互相穿插的面之间会失效,但那是"模型自交"的情况,正经建模不会出现,不用管。
4.4 完整绘制函数与关键细节
把上面几块拼起来就是核心绘制函数:
void CModelViewDlg::DrawScene(CDC* pDC, const CRect& rc) { pDC->FillSolidRect(rc, RGB(30, 32, 38)); const double cx = rc.Width() / 2.0; const double cy = rc.Height() / 2.0; const double camDist = 6.0; const double focal = m_zoom * min(rc.Width(), rc.Height()) * 0.35; // 顶点旋转 + 投影 Vec3 world[8]; CPoint screen[8]; for (int i = 0; i < 8; ++i) { world[i] = Rotate(kCubeVerts[i], m_pitch, m_yaw, m_roll); double t = focal / (camDist - world[i].z); screen[i] = CPoint((int)lround(cx + world[i].x * t), (int)lround(cy - world[i].y * t)); } // 收集可见面 std::vector<FaceDraw> visible; Vec3 lightDir = Normalize(Vec3{ -0.4, 0.7, 0.85 }); for (int f = 0; f < 6; ++f) { const FaceDef& fd = kCubeFaces[f]; Vec3 a = world[fd.v[0]]; Vec3 b = world[fd.v[1]]; Vec3 c = world[fd.v[2]]; Vec3 n = FaceNormal(a, b, c); Vec3 center{ (a.x + world[fd.v[1]].x + world[fd.v[2]].x + world[fd.v[3]].x) / 4.0, (a.y + world[fd.v[1]].y + world[fd.v[2]].y + world[fd.v[3]].y) / 4.0, (a.z + world[fd.v[1]].z + world[fd.v[2]].z + world[fd.v[3]].z) / 4.0 }; if (!IsFrontFacing(n, center, camDist)) continue; double diff = Dot(Normalize(n), lightDir); if (diff < 0.0) diff = 0.0; double k = 0.28 + 0.72 * diff; FaceDraw item; item.idx[0] = fd.v[0]; item.idx[1] = fd.v[1]; item.idx[2] = fd.v[2]; item.idx[3] = fd.v[3]; item.depth = center.z; item.fill = RGB(Modulate(GetRValue(fd.color), k), Modulate(GetGValue(fd.color), k), Modulate(GetBValue(fd.color), k)); visible.push_back(item); } std::sort(visible.begin(), visible.end(), [](const FaceDraw& x, const FaceDraw& y) { return x.depth < y.depth; }); // 逐面填充 for (const FaceDraw& item : visible) { CPoint quad[4]; for (int i = 0; i < 4; ++i) quad[i] = screen[item.idx[i]]; CBrush brush(item.fill); CPen pen(PS_SOLID, 1, item.fill); CBrush* pOldBrush = pDC->SelectObject(&brush); CPen* pOldPen = pDC->SelectObject(&pen); pDC->Polygon(quad, 4); pDC->SelectObject(pOldBrush); pDC->SelectObject(pOldPen); brush.DeleteObject(); pen.DeleteObject(); } }几个必须提的细节。第一,Polygon会自动闭合多边形,并且用当前画笔描边、当前画刷填充,边框色我特意选了和填充色完全一样,这样相邻面之间不会出现难看的黑缝。第二,lround四舍五入比直接强制转换好,后者是截断,会让模型在旋转时出现一两像素的抖动。第三,每面创建一次画刷和画笔,六个面还好,如果面数上去了,建议改成按明暗分档预建几支笔刷复用,能省不少 GDI 对象创建开销。
还有一点,FillSolidRect(rc, ...)里我用的是深灰蓝而不是纯黑。纯黑背景配合明暗面,暗的面会直接融进背景里,边界完全消失;稍微提亮一点的深色背景,既保持了"深色界面"的观感,又能让最暗的面还有一点点边界可辨。
5. 交互实现:拖拽、滚轮与自动旋转
5.1 鼠标拖拽的增量累积方式
拖拽旋转有两种实现思路:一种是记录按下点和当前点的差值,每次移动都相对初始状态重新算角度;另一种是记录上一次的点,每次只处理增量。我用第二种,因为它天然支持连续拖拽,鼠标回到起点也不会让角度跳回去,手感更像"抓住模型转"。
void CModelViewDlg::OnLButtonDown(UINT nFlags, CPoint point) { CRect rc = GetCanvasRect(); if (rc.PtInRect(point)) { m_bDragging = true; m_ptLast = point; SetCapture(); // 关键:鼠标移出窗口也要能收到消息 } CDialogEx::OnLButtonDown(nFlags, point); } void CModelViewDlg::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDragging) { int dx = point.x - m_ptLast.x; int dy = point.y - m_ptLast.y; m_yaw += dx * 0.012; m_pitch += dy * 0.012; m_pitch = max(-1.5, min(1.5, m_pitch)); m_ptLast = point; InvalidateRect(GetCanvasRect(), FALSE); } CDialogEx::OnMouseMove(nFlags, point); } void CModelViewDlg::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDragging) { m_bDragging = false; ReleaseCapture(); } CDialogEx::OnLButtonUp(nFlags, point); }SetCapture这一句很多人会漏,结果是鼠标拖得快一点、移出画布区域,旋转就中断了,得重新按下。加上之后,整个拖拽过程消息都发给这个窗口,直到松开为止。但要注意ReleaseCapture一定要在OnLButtonUp里调,而且考虑加一个OnCaptureChanged的兜底,防止别有程序抢走捕获之后状态卡在"正在拖拽"。
系数 0.012 是手感调出来的。太大了轻轻一动模型就转半圈,根本停不准;太小了拖半天纹丝不动。0.01 到 0.015 之间都可以,具体看你画布多大。俯仰角我做了 ±1.5 弧度的钳制,大概是 ±86 度,防止模型翻过头出现上下颠倒,很多人觉得能翻到底更有"自由感",但实际用起来一直翻过界很烦。
5.2 定时器驱动与帧率控制的取舍
自动旋转用SetTimer最简单。我在OnInitDialog里调SetTimer(1, 30, nullptr),30 毫秒一帧,理论上是 33 帧每秒,实际因为 GDI 绘制和消息队列的延迟,跑起来大概在 25 到 30 之间,肉眼看足够顺滑。
void CModelViewDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && m_bAutoRotate) { m_yaw += 0.02; if (m_yaw > 6.283185307) m_yaw -= 6.283185307; InvalidateRect(GetCanvasRect(), FALSE); } CDialogEx::OnTimer(nIDEvent); }这里的InvalidateRect第二个参数传 FALSE,意思是"不要擦除背景",只标记重绘。配合双缓冲,这一句是消除闪烁的第二道保险。如果传 TRUE,系统会在重绘前先刷一遍背景,即使有双缓冲,画布边缘也可能闪一条。
每帧转 0.02 弧度,一圈大概 314 帧,按 30 帧每秒算下来 10 秒转一圈,节奏比较从容。转太快让人眼花,转太慢又像卡住了。这个值我调过好几轮,最后还是 0.02 最舒服。
如果你希望拖动的时候暂停自动旋转,可以在OnLButtonDown里把m_bAutoRotate置 false,在OnLButtonUp里再恢复,或者干脆加一个复选框让用户自己控制。我倾向于后者,因为有些场景操作员就是想让模型定住看清楚某个面。
5.3 滚轮缩放与参数钳制
滚轮消息在对话框里处理,注意nFlags的高位字才是滚轮增量,而且它以 120 为一个单位。
BOOL CModelViewDlg::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { CRect rc = GetCanvasRect(); ScreenToClient(&rc); // pt 是屏幕坐标,先转成客户区坐标 if (rc.PtInRect(pt)) { m_zoom *= (zDelta > 0 ? 1.12 : 1.0 / 1.12); m_zoom = max(0.2, min(3.0, m_zoom)); InvalidateRect(GetCanvasRect(), FALSE); return TRUE; } return CDialogEx::OnMouseWheel(nFlags, zDelta, pt); }两个坑。第一,OnMouseWheel传进来的pt是屏幕坐标,不是客户区坐标,直接拿去PtInRect判断永远是 false,得先转换。我第一次写的时候怎么滚都没反应,查了半天才想起来这茬。第二,zDelta可能不是正负 120,有些高精度鼠标或者触摸板会给出更小的值,所以判断用> 0而不是== 120,按比例缩放也不要用固定步长。
缩放系数用乘 1.12 而不是加固定值,是因为视觉上的"放大"是乘性的,加固定值在放大倍数大的时候感觉不明显,在倍数小的时候又跳得太猛。
6. 常见问题与排查实录
6.1 画面闪烁、残影与更新区域
闪烁是这套方案里出现频率最高的问题,我按现象分了三类,排查路径完全不一样。
第一类是"整个画布疯狂闪",几乎肯定是没做双缓冲,或者双缓冲但每次都在OnEraseBkgnd里又刷了一遍背景。检查方法很简单:在OnPaint里数一下FillSolidRect被调用了几次,正常应该只有一次,在内存 DC 上。
第二类是"只有边缘一圈细线闪",这是画布矩形和位图尺寸不匹配,前面提过,把位图按矩形尺寸精确创建就行。
第三类是"拖动窗口时出现拖影",那通常是InvalidateRect传了 TRUE,或者没有正确处理WM_SIZE。我的做法是在OnSize里调一次Invalidate,让窗口尺寸变化后强制全量重绘,别指望系统帮你补。
还有一种"残影"值得单独说:如果你把绘制写在OnDraw里(有些老代码会这么做),又在别处直接调Invalidate,可能出现旧内容没清干净。MFC 对话框里请老老实实在OnPaint里用CPaintDC,不要自己GetDC,GetDC拿到的 DC 不会自动处理更新区域,容易留下脏点。
6.2 模型看不见、跑飞和形状错乱
模型消失最吓人的一次,是我把相机距离camDist改成了 1.0,想着"离近点看得大"。结果camDist - z在模型的某些顶点上变成了负数,透视除法直接翻符号,顶点飞到屏幕外面,画出来的是一堆莫名其妙的线条穿过整个窗口。记住:相机距离必须大于模型所有顶点 Z 坐标的最大值,这是硬约束,加个断言都不为过。
还有一种"看不见"是颜色问题。深色背景配一个环境光系数 0.1 的暗色模型,面是画上去了,但跟背景一模一样,你以为没画,其实是画了看不出来。排查的时候临时把背景换成洋红或者亮绿,一眼就能分辨。
形状错乱的情况,八成是旋转顺序和法向约定不一致。具体表现是:模型能转,但某个角度下会突然"翻面",看到的是内壁。这时候把背面剔除临时关掉,让所有面都画出来,如果内壁的颜色顺序确实反了,那就是顶点顺序定义错了,回去按"从外看逆时针"重新排一遍。
6.3 面颜色发黑、明暗跳变与资源泄漏
颜色发黑有两种原因。一种是法向量没归一化就去点乘光源方向,长度不为 1 的法向量会让点积结果超出 [-1, 1],钳制到 0 之后就全是纯黑。这个错误很隐蔽,因为模型转起来的时候有时候又正常,那是因为恰好在某些角度点积落回了合理区间。规矩就是规矩:点乘之前必须归一化。
另一种发黑是环境光设太小,前面说过,0.28 是个稳妥值。
明暗跳变是指相邻两帧之间某个面的亮度突然大跳一下,看起来像在闪。这通常是背面剔除的阈值太严,面在临界角度反复进出可见集合。把点积判断从> 0改成> 1e-9,或者干脆给剔除加一个极短的时间滞后(这一帧保留、下一帧再判),都能缓解。
至于资源泄漏,我在调试阶段养成一个习惯:开着任务管理器,看 GDI 对象那一列。正常运行时它应该在一个固定值附近小幅波动,比如 60