一、整体架构:MVC 式的 Delegate 模式
这套代码的核心思想是数据与逻辑分离:
| 角色 | 谁 | 职责 |
|---|---|---|
| 数据模型(Model) | CurveDelegate里的std::vector<ImVec2> points | 只存数据,不画、不处理鼠标 |
| 视图+控制器(View/Controller) | ImCurveEdit::Edit(...) | 读数据、画曲线、处理鼠标、回调 Delegate |
| 委托(Delegate) | CurveDelegate继承ImCurveEdit::Delegate | 把 ImCurveEdit 的请求翻译成对数据的操作 |
ImCurveEdit 本身不拥有数据,它每一帧都通过GetPointCount()、GetPoints()、GetMin()、GetMax()从 Delegate 现拉数据,画完就忘。这叫即时模式(Immediate Mode),和你 3D 版本用的"保留模式"(先建树、后遍历、改节点重绘)完全不同。
关键好处:改数据不用通知任何人,下一帧 ImCurveEdit 自动看到新数据。这就是为什么 Delete 逻辑能写在 main.cpp 里、完全不用和 ImCurveEdit 通信。
二、数学层:曲线是怎么画出来的
2.1 数据的两种表达
同一批点,在代码里有两个坐标系:
| 坐标系 | 存储 | 范围 |
|---|---|---|
| 逻辑坐标 | points[i]存的值 | [min, max]由delegate.min/max决定 |
| 屏幕坐标 | 每帧实时算出来的 | 像素单位,[0, editSize] |
我们在main.cpp里维护了"每逻辑单位 = 500 像素"(kPixelsPerUnit)的映射,所以:
max = (editSize.x / 500 - 1, editSize.y / 500)这样窗口缩放时,同一个逻辑点的屏幕位置按比例守恒,不会拉伸——这是你之前遇到的"窗口变大点会动"问题的解法。
2.2 逻辑坐标 → 屏幕坐标
ImCurveEdit 内部用的公式(main.cpp里复刻了一份,保证一致):
range.x = max.x - min.x + 1 // 注意 x 多了个 +1 range.y = max.y - min.y // 归一化到 [0, 1] nx = (point.x - min.x) / range.x ny = (point.y - min.y) / range.y // 映射到屏幕像素 offset = (origin.x, origin.y + editSize.y) // 编辑区左下角 viewSize = (editSize.x, -editSize.y) // y 是负的,因为屏幕 y 向下 screen.x = nx * viewSize.x + offset.x screen.y = ny * viewSize.y + offset.yviewSize.y为负是关键:逻辑 y 向上增长,屏幕 y 向下增长,负号把它们对齐。
2.3 Catmull-Rom 插值
对每相邻两点(p1, p2),看它们的前一个点p0和后一个点p3:
P(t) = 0.5 * [ (2*p1) + (-p0 + p2) * t + (2*p0 - 5*p1 + 4*p2 - p3) * t² + (-p0 + 3*p1 - 3*p2 + p3) * t³ ]为什么这样就很光滑:
- 在 t=0 处,P(0) = p1;在 t=1 处,P(1) = p2。曲线经过所有控制点。
- 在 t=0 处的切线正比于
(p2 - p0);在 t=1 处的切线也正比于(p2 - p0)。 - 由于相邻段共享端点,左边那段"在 p2 处的切线"和右边那段"在 p2 处的切线"用的是同一个
p3 - p1,自动连续。这就是 C1 连续。 - 相比贝塞尔曲线:不需要手柄,点摆哪里曲线就过哪里;代价是无法表达尖角和无法单独调某段的曲率。
首尾段的处理:第一段没有 p0,用p1自己代替;最后一段没有 p3,用p2自己代替。这叫"端点复制"(clamped end)。副作用是两点时曲线会轻微鼓出而不是直线——这是设计取舍,为了保证和 ImCurveEdit 原版视觉一致。
三、交互层:四种鼠标操作
3.1 拖动控制点(Move Point)
ImCurveEdit 内部维护一个selection集合,记录选中的(curveIndex, pointIndex)。鼠标在控制点附近按下 → 加入 selection → 拖动时对每个选中点调用:
delegate.EditPoint(curveIndex,pointIndex,newValue);EditPoint是 Delegate 的回调,你在里面做两件事:
- 函数模式:赋值 + 按 x 排序 + 返回新索引(排序后索引可能变)
- 自由模式:赋值 + 不排序 + 返回原索引
ImCurveEdit 拿到返回值,如果索引变了就更新 selection——这是 MVC 里典型的"Model 改变通知 View"机制,只不过这里是通过返回值实现的。
3.2 双击曲线加点(Add Point)
ImCurveEdit 检测到鼠标距曲线 8 像素内双击 → 调用:
delegate.AddPoint(curveIndex,logicalPos);你在里面做的不是简单追加,而是:
- 遍历所有相邻点对,对每对算"点到线段的距离平方"
- 找距离最小的段
points.insert(begin + bestSegment + 1, value)
为什么不用排序:排序只在函数模式有效(按 x 排恰好等于按段插入);自由模式下 x 不是单调的,排序会破坏点击顺序。用"找最近段 + 中间插入",两种模式行为统一,也不依赖 x 单调性。
3.3 框选(Quad Selection)
ImCurveEdit 内部维护一个selectingQuad状态:
- 鼠标在空白处按下 → 记下起点
- 拖动时画红框,把框内所有控制点加入 selection
- 松手 → 结束
这跟你的 3D 版本应该很像,区别只是从"3D 视锥剔除"退化成了"2D 矩形相交测试"。
3.4 Delete 删除
这块 ImCurveEdit 内部不做,全部在 main.cpp 里实现:
- 每帧检查
selectedPoints(由ImCurveEdit::Edit的第五个参数返回) - 如果检测到 Delete 键 + 有选中点 → 收集索引 → 从大到小排序去重
- 从后往前
points.erase(...)(从后往前是因为删前面的会导致后面索引失效) - 清空 selectedPoints
关键坑:ImCurveEdit 内部有一个static std::set<EditPoint> selection,它记录的是索引。你从外部删除点后,这个 set 里的旧索引会在下一帧越界访问。所以必须在ImCurveEdit.cpp的Edit()函数开头加一段越界清理:
foreach(curveIndex,pointIndex)in selection:ifpointIndex>=GetPointCount(curveIndex):remove from selection这叫防御式编程——不改数据结构,而是在入口处过滤脏数据。
四、渲染层:ImDrawList 的即时绘制
整个窗口的绘制都通过ImDrawList完成,每帧重建:
// 曲线(每两相邻点之间细分成 20 段小线段)foreachsegment(p1,p2):forsub in0..19:r1=CatmullRom(...)r2=CatmullRom(...)dl->AddLine(screen(r1),screen(r2),green,1.3f);// 控制点(菱形)foreach point:dl->AddConvexPolyFilled(diamond,4,black);dl->AddPolyline(diamond,4,blue,2.0f);为什么细分成 20 段:GPU 不能直接画三次曲线。用折线逼近,段数多了视觉上就光滑。20 段对 500 像素的段长来说已经足够。
为什么用 4 顶点菱形而不是AddCircleFilled:为了和 ImCurveEdit 原版完全一致。原版用的是 4 顶点凸多边形,看起来像小菱形,用圆形会不一致,切模式时会闪一下。
五、状态层:三种模式的状态机
整个程序有一个顶层状态机:
N键 双击 EDIT <--------> DRAW <--------> EDIT ^ | |___________ S 键切换 ___________| (正交于上面的切换)gMode只管 EDIT/DRAW,delegate.sortPointsByX只管函数/自由。两者正交,可以任意组合:
| gMode | sortPointsByX | 行为 |
|---|---|---|
| EDIT | true | 编辑模式 + 函数排序 |
| EDIT | false | 编辑模式 + 自由顺序 |
| DRAW | true | 绘制模式 + 每点按 x 排序 |
| DRAW | false | 绘制模式 + 保持点击顺序 |
为什么模式切换时不重排点:用户在函数模式下画了个 S 型(其实被排序成了单调曲线),切到自由模式如果重排,曲线会突然跳到"未排序的样子",视觉上很突兀。保持数组顺序不变,用户自己决定要不要拖动整理。
为什么绘制模式下的加点也遵循模式:用户在自由模式下按 S 形点击,点序就是 S 形;在函数模式下点击,点会自动按 x 排好——两种需求都满足。
六、输入层:ImGui 1.87+ 的事件队列
这块是最容易踩坑的地方,也是你的 3D 版本可能没遇到过的问题:
- ImGui 1.87 之前,
io.MousePos是直接赋值:io.MousePos.x = ... - 1.87 之后改为事件队列:
io.AddMousePosEvent(x, y),事件在下一帧NewFrame()时统一处理
ImApp.h 原始代码用的是旧 API,需要改。为什么:如果你直接赋值io.MousePos,ImGui 内部会在NewFrame()里用队列里的值覆盖你的赋值——结果就是鼠标坐标永远不动,一切交互失效。
另一个坑:IsAnyMouseButtonDown()在WM_LBUTTONUP里调用,读的是io.MouseDown[],但队列还没处理,读到的是上一帧的值,会误判为"还有键按下",导致ReleaseCapture()不被调用 → 窗口标题栏失效。
解法:用 Win32 的GetAsyncKeyState(VK_LBUTTON)直接查物理按键,绕过 ImGui 队列。
七、和你 3D 版本的核心差异
| 维度 | 你的 3D 版本 | 这个 2D Demo |
|---|---|---|
| 数据表示 | 3D 点 + 手柄 + 可能是 NURBS 或 Bezier | 2D 点 + Catmull-Rom,无手柄 |
| 渲染 | 顶点缓冲 + 投影矩阵 + 深度测试 | ImDrawList 2D 屏幕空间直接画 |
| 坐标变换 | 4x4 矩阵,含旋转、投影 | 2x2 缩放 + 平移,无旋转 |
| 交互命中 | 射线投射 + 屏幕空间距离 | 屏幕空间欧氏距离 |
| 模式 | 通常是"保留模式"UI | 即时模式(ImGui 哲学) |
| 选中/框选 | 视锥剔除 | 矩形重叠 |
| 数据存储 | 常驻内存对象树 | 每帧通过 Delegate 现拉 |
本质上数学不同(3D 复杂得多),但架构模式相似:都是"数据 + 交互 + 渲染"三层分离。你那边可能是 MFC/Qt 的 MVC,这边是 ImGui 的即时模式 Delegate。
八、一句话总结每个功能
| 功能 | 实现核心 |
|---|---|
| 曲线光滑 | Catmull-Rom 插值,段间 C1 连续,不需要手柄 |
| 经过控制点 | Catmull-Rom 数学性质:P(0)=p1,P(1)=p2 |
| 窗口缩放点不动 | 逻辑范围随编辑区尺寸动态调整,像素/逻辑单位恒定 |
| 双击加点 | 找最近线段 + 中间插入(两种模式统一) |
| 函数/自由模式 | Delegate 里加 bool,EditPoint/AddPoint 分支处理 |
| Delete 删除 | main.cpp 里监听按键 + ImCurveEdit.cpp 里防御式清理 selection |
| 绘制模式 | 独立状态机 + 自己处理鼠标 + 自己用 ImDrawList 画曲线 |
| 窗口缩放/关闭 | 用 Win32 原生样式,去掉冲突的 WS_POPUP |
| 鼠标输入正常 | 改用 ImGui 1.87+ 事件 API |