简介:这份C#交互式绘图示例源码面向已掌握C#基础语法、希望入门GDI+图形编程的开发者,通过一个可运行的窗体项目演示如何用鼠标与键盘操作实时创建和修改图形。压缩包共383个文件,约1.17MB,以84个cs源文件为核心,配合22个resx与24个resources资源文件、11个csproj项目文件及5个sln解决方案,另有exe、pdb等编译产物和bmp、gif、jpg等示例图片素材,构成可直接打开调试的完整工程。内容围绕Graphics、Pen、Brush等核心类展开,覆盖Paint事件重绘、DrawLine与DrawRectangle等基本图形绘制、MouseClick与MouseMove输入捕获、Matrix图形变换、Save与Restore模拟图层、Bitmap双缓冲缓存以及线性与径向渐变填充,并强调Dispose释放图形资源。已有387人学习,适合作为图形界面课程的配套练习,帮助读者理解事件驱动绘图流程并积累可复用的代码片段。
1. 从一块画布说起:C# 交互式绘图能解决什么
去年帮一个做教学演示的团队改东西,他们要在课堂上实时画函数曲线、随手标注、拖拽调整控制点,原来用某个脚本方案拼了半天,鼠标事件和重绘总是打架,拖一下卡三帧。后来换成 C# 桌面端做交互式绘图,GDI+ 双缓冲一上,拖拽立刻跟手了。这就是 C# 交互式绘图最典型的落地场景:不是做美术级绘图软件,而是做「能点、能拖、能实时反馈」的工程画布。它适合两类人——需要给算法做可视化调试的后端开发者,以及要给客户做数据标注、图形编辑小工具的一线开发。这份代码包把画布、图元、命中检测、撤销重做这几块拆得比较清楚,拿下来改改就能变成自己的绘图模块。
2. 项目定位与技术选型:为什么是 WinForms + GDI+ 而不是 WPF
2.1 这套代码到底在画什么
先把边界说清楚。这份 C# 交互式绘图代码不是要替代 Visio 或 CAD,它解决的是「在一个可控的坐标系里,用鼠标完成图元的创建、选中、移动、缩放、删除」这条完整链路。核心图元常见做法是抽象成一个基类,派生矩形、椭圆、线段、自由曲线几类,每个图元自己负责绘制和命中检测。画布层只做三件事:维护图元集合、把鼠标坐标翻译成世界坐标、按顺序调每个图元的 Draw。
这种分层的好处是加新图元几乎不用动画布代码。我一般会定义一个Shape抽象类,把Draw(Graphics g)、HitTest(Point p)、Bounds三个成员作为契约,后面所有交互逻辑都围绕这三个成员转。下面这段是这套结构的骨架,参数含义我在注释里标了:
// 图元基类:所有可绘制对象的最小契约 public abstract class Shape { public Color StrokeColor { get; set; } = Color.Black; // 描边色 public float StrokeWidth { get; set; } = 2f; // 线宽,单位像素 public bool Selected { get; set; } // 是否被选中,决定是否画控制点 // 子类实现具体绘制,g 已由画布做好双缓冲与坐标变换 public abstract void Draw(Graphics g); // 命中检测:p 是已经转换到世界坐标系的点 public abstract bool HitTest(PointF p); // 包围盒,用于选中框和脏矩形重绘 public abstract RectangleF Bounds { get; } }HitTest用世界坐标而不是屏幕坐标,是为了让缩放后命中依然准确——这是很多人第一版会翻车的地方,屏幕坐标命中在缩放比例不为 1 时全错。Bounds除了画选中框,还服务于脏矩形重绘,只重绘变化区域,交互才不卡。
2.2 技术栈取舍:GDI+ 的性价比在哪
选型上,这套代码走的是 WinForms + System.Drawing(GDI+)。理由很实在:交互式绘图的核心开销在鼠标移动时的重绘频率,GDI+ 配合BufferedGraphics或控件的DoubleBuffered = true,在几千个图元以内完全够用,而且 API 直白,Graphics.DrawEllipse、FillPath这些调用没有学习成本。WPF 的矢量渲染和命中测试更优雅,但它的坐标变换、DrawingVisual体系对新手是道坎,改起来反而慢。
| 方案 | 上手成本 | 交互重绘表现 | 适合场景 |
|---|---|---|---|
| WinForms + GDI+ | 低 | 双缓冲后流畅 | 中小规模图元编辑、教学演示 |
| WPF + Canvas | 中 | 矢量渲染好 | 需要动画、复杂样式 |
| SkiaSharp | 中 | 跨平台、性能强 | 要上 Linux/macOS |
常见做法是把绘制逻辑和窗体解耦:窗体只负责把Paint事件的Graphics传进来,画布类负责遍历图元。这样以后想换 SkiaSharp,只改画布类的绘制入口,图元契约不动。我一般会在画布类里留一个Render(Graphics g)方法作为唯一绘制出口,窗体那边就一行canvas.Render(e.Graphics)。
2.3 交互状态机:鼠标事件不能散着写
交互式绘图最容易写乱的是鼠标事件。MouseDown里判断是新建还是选中,MouseMove里判断是拖拽还是画框,MouseUp里收尾——如果这些判断散落在三个事件里各写一遍,加一个「旋转」功能就得改三处。合格的做法是引入一个简单的状态机,用枚举表示当前模式:
// 画布当前交互模式,决定鼠标事件走哪条分支 public enum CanvasMode { Idle, // 空闲,可选中 Drawing, // 正在新建图元 Dragging, // 正在拖动选中图元 RubberBand // 框选 } // MouseDown 里根据模式切换,而不是堆 if-else private void OnMouseDown(object sender, MouseEventArgs e) { var world = ScreenToWorld(e.Location); // 屏幕坐标转世界坐标 if (mode == CanvasMode.Idle) { var hit = shapes.LastOrDefault(s => s.HitTest(world)); // 后画的优先命中 if (hit != null) { Select(hit); mode = CanvasMode.Dragging; } else { mode = CanvasMode.RubberBand; } } }ScreenToWorld里要带上缩放和平移:world = (screen - offset) / zoom。这个换算一旦写错,表现就是「鼠标和图形对不上」,尤其在缩放后特别明显。把模式枚举和坐标换算收在一处,后面加旋转、加吸附都只是多一个分支,不会牵一发动全身。
3. 目录结构与核心模块拆解:一个图元从创建到落屏的链路
3.1 工程目录怎么摆
这类绘图项目目录不宜深,按职责分四层就够。下面是我按这套代码整理出的典型结构,文件名是通用叫法,实际以包内为准:
DrawingDemo/ ├── Program.cs // 入口,Application.Run ├── Forms/ │ └── MainForm.cs // 窗体,挂载画布控件、工具栏 ├── Canvas/ │ ├── DrawingCanvas.cs // 画布:图元集合、坐标变换、事件分发 │ └── CanvasMode.cs // 交互状态枚举 ├── Shapes/ │ ├── Shape.cs // 图元基类 │ ├── RectangleShape.cs │ ├── EllipseShape.cs │ └── FreehandShape.cs // 自由曲线,存点列表 └── Commands/ └── ICommand.cs // 撤销重做命令接口Canvas和Shapes分离是关键,画布不关心图元长什么样,图元不关心鼠标怎么来。Commands单独一层是为了撤销重做,后面第 5 章会展开。新手常把所有逻辑塞进MainForm.cs,几百行后就没法维护了,血泪经验是第一天就把画布类拆出去。
3.2 坐标变换与双缓冲:不卡的前提
交互流畅的前提是两件事:坐标换算正确、重绘不闪。双缓冲在 WinForms 里最省事的做法是给画布控件开DoubleBuffered,或者自己用BufferedGraphicsContext。下面这段是画布OnPaint的典型写法:
protected override void OnPaint(PaintEventArgs e) { // 开启双缓冲,避免拖动时闪烁 using (var buffer = BufferedGraphicsManager.Current.Allocate( e.Graphics, ClientRectangle)) { var g = buffer.Graphics; g.Clear(Color.White); // 背景 g.SmoothingMode = SmoothingMode.AntiAlias; // 抗锯齿,曲线才不毛糙 g.TranslateTransform(offset.X, offset.Y); // 平移 g.ScaleTransform(zoom, zoom); // 缩放 foreach (var s in shapes) s.Draw(g); // 按 z 序绘制 buffer.Render(e.Graphics); // 一次性贴回屏幕 } }TranslateTransform和ScaleTransform的顺序不能反,先平移后缩放,世界坐标原点才落在预期位置。SmoothingMode.AntiAlias对自由曲线和椭圆效果明显,但图元特别多时可以关掉换性能。buffer.Render只调一次,这是不闪的核心——所有绘制在内存位图上完成,最后整块贴屏。
3.3 自由曲线的采样与简化
自由曲线是交互绘图里最考验手感的部分。MouseMove里如果每个点都存,一条线能存上千个点,重绘和命中都变慢。常见做法是设一个最小采样距离,移动超过阈值才记点:
// 自由曲线:按最小距离采样,避免点爆炸 public class FreehandShape : Shape { private readonly List<PointF> points = new(); private const float MinDistance = 2f; // 采样阈值,像素 public void AddPoint(PointF p) { if (points.Count == 0 || Distance(points[^1], p) >= MinDistance) points.Add(p); } public override void Draw(Graphics g) { if (points.Count < 2) return; using var pen = new Pen(StrokeColor, StrokeWidth); g.DrawLines(pen, points.ToArray()); // 折线近似曲线 } }MinDistance取 2 像素是经验值,太小点太多,太大线条发棱。DrawLines比逐段DrawLine快,因为它一次提交整条路径。如果对平滑度要求高,可以在绘制前对点列表做一次 Catmull-Rom 或贝塞尔拟合,但那是二次开发阶段的事,第一版折线足够。
3.4 命中检测:选中为什么点不准
命中检测的坑集中在两处:线宽和缩放。矩形、椭圆用包围盒判断没问题,但线段和自由曲线如果只判断包围盒,点在线旁边很远也会选中。合格做法是对线段算点到线段距离,阈值取StrokeWidth / 2 + 容差:
// 点到线段距离,用于线段/折线的精确命中 private static float DistanceToSegment(PointF p, PointF a, PointF b) { var dx = b.X - a.X; var dy = b.Y - a.Y; var lenSq = dx * dx + dy * dy; if (lenSq == 0) return Distance(p, a); // 退化成点 var t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq; t = Math.Clamp(t, 0f, 1f); // 投影限制在线段内 var proj = new PointF(a.X + t * dx, a.Y + t * dy); return Distance(p, proj); }t夹在 0 到 1 之间保证投影落在线段上,否则会算到延长线。容差要除以zoom,因为世界坐标下的线宽在屏幕上被放大了,命中阈值也得跟着换算,不然放大后点不中细线。这个细节不注意,用户放大画布后就会觉得「选中失灵」。
4. 跑起来与改起来:环境、启动与第一个自定义图元
4.1 环境准备与启动
这套代码是标准 .NET 桌面工程,跑起来没有额外依赖。需要的是 .NET SDK(6.0 及以上都行,WinForms 目标框架写net6.0-windows或更高)和 Visual Studio 2022 或命令行。命令行方式最直接:
# 进入工程目录后还原并运行 dotnet restore dotnet run --project DrawingDemo/DrawingDemo.csproj如果目标框架是net6.0-windows,必须在 Windows 上跑,因为 WinForms 依赖系统 GDI。dotnet run起不来时先看两处:一是.csproj里有没有<UseWindowsForms>true</UseWindowsForms>,二是Program.cs里有没有[STAThread]标记——少了它,剪贴板和部分控件行为会异常。这两点是最常见的启动失败原因。
4.2 加一个自定义图元:以三角形为例
二次开发最高频的需求就是加图元。按第 2 章的契约,新建一个类继承Shape,实现三个成员即可,画布代码一行不用改。下面是一个三角形图元的完整实现:
// 三角形图元:三个顶点,支持绘制与命中 public class TriangleShape : Shape { public PointF A { get; set; } public PointF B { get; set; } public PointF C { get; set; } public override void Draw(Graphics g) { using var pen = new Pen(StrokeColor, StrokeWidth); g.DrawPolygon(pen, new[] { A, B, C }); // 闭合三角形 if (Selected) DrawHandles(g); // 选中时画控制点 } public override bool HitTest(PointF p) { // 用重心坐标判断点是否在三角形内 float d1 = Sign(p, A, B), d2 = Sign(p, B, C), d3 = Sign(p, C, A); bool hasNeg = d1 < 0 || d2 < 0 || d3 < 0; bool hasPos = d1 > 0 || d2 > 0 || d3 > 0; return !(hasNeg && hasPos); // 同号即在内部 } public override RectangleF Bounds => RectangleF.FromLTRB( Math.Min(A.X, Math.Min(B.X, C.X)), Math.Min(A.Y, Math.Min(B.Y, C.Y)), Math.Max(A.X, Math.Max(B.X, C.X)), Math.Max(A.Y, Math.Max(B.Y, C.Y))); }Sign是叉积符号函数,三个符号同号说明点在三角形内,这是重心坐标法的简化版,比射线法好写。Bounds用三个顶点的极值拼出来,选中框和脏矩形都靠它。加完这个类,在工具栏的「新建图元」分支里new TriangleShape { ... }并shapes.Add就生效了,这就是契约设计带来的好处。
4.3 参数怎么调:缩放、线宽与采样
几个常被问到的参数,调法如下。缩放zoom建议限制在 0.1 到 10 之间,超出后 GDI+ 的浮点精度开始出问题,线条会抖。线宽StrokeWidth在世界坐标下是固定值,如果希望「缩放时线宽不变」,绘制前要除以zoom。自由曲线的MinDistance按屏幕像素算更合理,也就是采样前先把点转成屏幕坐标再比距离。
提示:改完参数后先拿一条自由曲线和一条线段做回归,重点看放大 4 倍后能不能点中细线、拖动是否跟手,这两项过了基本就没大问题。
5. 避坑与排查:交互绘图里最容易翻车的五件事
5.1 拖动时画面闪烁
现象是鼠标一拖,整个画布白一下再重画。原因多半是没开双缓冲,或者OnPaint里直接往e.Graphics上画。解决是给画布控件设DoubleBuffered = true,或者按 3.2 节用BufferedGraphics包一层。另外OnPaintBackground如果被重写去刷背景,也会闪,直接不重写或置空即可。
5.2 缩放后鼠标和图形对不上
现象是放大后点图形没反应,或者拖拽偏移。原因是ScreenToWorld和绘制时的变换顺序不一致,绘制用了「先平移后缩放」,换算却写成「先除 zoom 再减 offset」。解决是把换算和绘制统一成一套:world = (screen - offset) / zoom,并且只在画布类里维护这一份公式,别在事件里另写一遍。
5.3 自由曲线越画越卡
现象是画长线时帧率掉得厉害。原因是每个鼠标点都存、每帧都全量重绘。解决是加最小采样距离(3.3 节),并且用脏矩形只重绘变化区域。如果图元总数上千,考虑把已完成图元缓存成位图,只重绘正在画的那一个。
5.4 撤销后选中状态错乱
现象是撤销一步后,原来选中的图元还显示选中框,但对象已经没了。原因是撤销只回退了图元集合,没同步清空selected引用。解决是把「选中」也纳入命令体系,或者在每次撤销后统一执行一次ClearSelection(),保证引用不悬空。
5.5 高 DPI 下线条发虚
现象是在 125% 或 150% 缩放的屏幕上,线条粗细不均、文字模糊。原因是进程没声明 DPI 感知,系统做了位图拉伸。解决是在app.manifest里声明 PerMonitorV2,或者在Program.cs里调Application.SetHighDpiMode(HighDpiMode.PerMonitorV2),让 GDI+ 按真实像素绘制。
6. 二次开发:把绘图模块接进自己的工程
拿到这套代码后,真正有价值的是把它当模块用,而不是当成品改。我的做法是先抽一个IDrawingSurface接口,把「加图元、删图元、导出位图、导出图元数据」四个能力暴露出去,窗体只依赖接口。这样同一套画布既能嵌进 WinForms 主程序,也能被一个无界面的批处理调用去生成图。导出这块给个可直接抄的方法:
// 把当前画布导出为 PNG,scale 控制输出分辨率 public void ExportPng(string path, float scale = 2f) { var size = new Size( (int)(ClientSize.Width * scale), (int)(ClientSize.Height * scale)); using var bmp = new Bitmap(size.Width, size.Height); using var g = Graphics.FromImage(bmp); g.SmoothingMode = SmoothingMode.AntiAlias; g.ScaleTransform(scale, scale); // 高分辨率输出 Render(g); // 复用画布绘制入口 bmp.Save(path, ImageFormat.Png); }scale取 2 就是两倍图,适合贴进文档。关键是复用Render,保证屏幕和导出走同一套绘制逻辑,不会出现「屏幕对、导出错」的玄学问题。验证方法也简单:导出后拿尺子量一下图元相对位置,和屏幕上一致就说明坐标变换没跑偏。
再往深一层是撤销重做。命令模式是标准解法,每个操作实现Do和Undo,画布维护两个栈。这里有个容易忽略的点:拖动图元时不要每移动一像素就压一条命令,应该在MouseUp时把整次拖动合并成一条,否则撤销一次只回退一个像素,用户会疯。我一般会在MouseDown记下起始位置,MouseUp时算总位移生成一条MoveCommand。
最后说个习惯。这类交互绘图项目,我每次改完坐标变换或命中检测,都会强制走一遍「缩放 0.5 倍、1 倍、4 倍各点一次、拖一次、撤销一次」的回归清单,因为这两块是黑匣子,肉眼看不出来,只有实际点过才知道对不对。早期我图省事跳过这步,结果交付前一天发现放大后全线点不中,连夜改换算公式,那滋味不想再来一次。希望这套拆解能帮你少走点弯路,把画布这块稳稳接进自己的项目里。
本文还有配套的精品资源,点击获取