ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#毛笔字算法实现:平滑曲线、笔锋与轮廓生成全解析

C#毛笔字算法实现:平滑曲线、笔锋与轮廓生成全解析

简介:面向图形处理与数字艺术开发者的C#毛笔字模拟源码包,聚焦笔锋算法与平滑算法的工程实现,可直接编译运行。资源内包含完整的Visual Studio解决方案,共47个文件,以C#源码文件(.cs)为核心,辅以图片素材(png/jpg)、配置文件(config/resx)及运行示例(exe),整体仅809KB,便于快速阅读与调试。已有715人学习下载,适合对数字书法、渲染效果或交互式绘画感兴趣的中高级开发者。通过源码可深入理解笔触压力与角度模拟、笔尖形态计算及图像平滑滤波等关键逻辑,为游戏美术、UI特效或创意工具开发提供可直接借鉴的算法参考。

1. 毛笔字算法在 C# 里落地:平滑、笔锋与可编译工程的关系

把鼠标采集到的一串 PointF 直接连成线,写出来的字永远不会像毛笔字,问题不在画线,而在笔锋。真正的毛笔字算法,核心是笔锋算法,而不是笔刷贴图:起笔和收笔的锥形轮廓、运笔过程中由速度调制出的宽窄变化、以及最后交给 C# 渲染层的那条平滑轨迹,三件事叠在一起才产生“笔意”。这套可编译的 C# 源码,常见形态就是把这三块拆成能单独调参的模块,底层用 WinForms 的 GraphicsPath 和 FillPath 完成渲染,不依赖外部图像引擎。适合正在做手写批注、电子签名、美术字编辑器的人,也适合想在 Windows 桌面上快速验证书写效果的上位机程序员。下文按平滑、笔锋、轮廓生成、调参避坑的顺序展开,最后给出一套验证笔迹的自检方法。

2. 平滑算法选型:把鼠标轨迹变成连贯曲线的三条路径

2.1 Catmull-Rom 插值:最少依赖的平滑方案

凡是和手写轨迹打交道的 C# 工程,我第一选择基本是 Catmull-Rom 样条,而不是贝塞尔。原因很直接:Catmull-Rom 穿过所有原始采样点,而贝塞尔曲线不经过控制点。毛笔字的骨架是用户的真实运笔轨迹,任何偏离采样点的拟合,后续在做笔锋宽度调制时都会出现“写的是 A,渲染成 B”的错位感。

public static List<PointF> CatmullRomSmooth(List<PointF> input, int samplesPerSegment) { var result = new List<PointF>(input.Count * samplesPerSegment); for (int i = 0; i < input.Count - 1; i++) { var p0 = input[Math.Max(i - 1, 0)]; var p1 = input[i]; var p2 = input[i + 1]; var p3 = input[Math.Min(i + 2, input.Count - 1)]; for (int s = 1; s <= samplesPerSegment; s++) { float t = s / (float)samplesPerSegment; float t2 = t * t; float t3 = t2 * t; // Catmull-Rom 的 Cardinal 形式,张力参数固定为 0.5 float x = 0.5f * (2f * p1.X + (-p0.X + p2.X) * t + (2f * p0.X - 5f * p1.X + 4f * p2.X - p3.X) * t2 + (-p0.X + 3f * p1.X - 3f * p2.X + p3.X) * t3); float y = 0.5f * (2f * p1.Y + (-p0.Y + p2.Y) * t + (2f * p0.Y - 5f * p1.Y + 4f * p2.Y - p3.Y) * t2 + (-p0.Y + 3f * p1.Y - 3f * p2.Y + p3.Y) * t3); result.Add(new PointF(x, y)); } } result.Add(input[input.Count - 1]); return result; }

这段代码里最关键的是相邻四个点 p0 到 p3 的选取方式:在首尾段用 clamp 复制端点,避免索引越界。samplesPerSegment 表示每两个原始采样点之间插入多少个新点,我一般在 6 到 10 之间取值。取 4 时曲线已经比较顺,但快速转角处会出现轻微多边形感;取到 16 以上,视觉差距不明显,还会让后续法线宽度计算的循环量成倍上涨。对于实时书写,8 是性价比最好的起点值。

2.2 先抽稀再平滑:给手抖轨迹做 Douglas-Peucker 预处理

鼠标 Move 事件在慢速书写时会产生大量噪声点,这些点不全是用户主动运笔,更多是传感器抖动。直接对原始点做 Catmull-Rom,插值结果会忠实地穿过每一个噪声点,笔画边缘会出现细小的毛刺,而且这些毛刺在笔锋锥化阶段会被方法线偏移进一步放大。

我一般会在平滑之前先做一次抽稀,去掉那些偏离主体方向过远的冗余点。Douglas-Peucker 是这里最稳的方案,它不改变剩余点的原始坐标,只删点,保留下来的点仍然在真实轨迹上。

public static List<PointF> Simplify(List<PointF> pts, float tolerance) { if (pts.Count < 3) return pts; float maxDist = 0f; int index = -1; var start = pts[0]; var end = pts[pts.Count - 1]; for (int i = 1; i < pts.Count - 1; i++) { float dist = PointLineDistance(pts[i], start, end); if (dist > maxDist) { maxDist = dist; index = i; } } if (maxDist > tolerance && index != -1) { var left = Simplify(pts.GetRange(0, index + 1), tolerance); var right = Simplify(pts.GetRange(index, pts.Count - index), tolerance); left.RemoveAt(left.Count - 1); left.AddRange(right); return left; } return new List<PointF> { start, end }; } private static float PointLineDistance(PointF p, PointF a, PointF b) { float dx = b.X - a.X; float dy = b.Y - a.Y; if (dx == 0 && dy == 0) return Vector2.Distance(p, a); float t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / (dx * dx + dy * dy); t = Math.Clamp(t, 0f, 1f); float projX = a.X + t * dx; float projY = a.Y + t * dy; return Vector2.Distance(p, new PointF(projX, projY)); }

tolerance 是抽稀的强度,单位是像素。普通鼠标手写,我建议从 1.0 开始调;数位板输入源抖动小,可以压到 0.3 到 0.5。注意抽稀和插值的顺序不能反过来:先插值再抽稀,噪声已经被插值“洗”成了平滑曲线上的起伏,Douglas-Peucker 会因为距离偏差太小而认为处处合格,等于白做。先抽稀后插值,剩下的关键点仍然在轨迹上,插值只是加密而不是修正。

2.3 为什么不用均值滤波和高阶贝塞尔

有人会问,直接用滑动平均值不行吗?均值滤波确实能压噪声,但它会系统性地缩小轨迹曲率半径,写出来的字整体“瘦”一圈,而且笔锋尖角位置会被平均成圆角,这对书法效果是致命的。贝塞尔拟合更适合离线生成美术字,在实时手写场景里,控制点不落在笔迹上,后续计算笔锋方向时你必须额外维护一套控制点映射关系,复杂度不成比例。

平滑层做到 Catmull-Rom 加 Douglas-Peucker 已经够用。这组代码不依赖具体窗体框架,把它放进一个 GraphicUtils 静态类里,就是一份可以独立编译的 C# 类库,WinForms、WPF 甚至控制台输出位图都能复用。

3. 笔锋算法实现:从等宽描边到毛笔感的两个关键步骤

3.1 动态宽度:用速度和压力调制当前笔划粗细

等宽线条是记号笔,宽度随运笔速度变化才是毛笔的第一特征。慢的地方吸墨多、笔画粗,快的地方笔画细。鼠标没有压感事件,常见的替代方案是用瞬时速度反推压力值,再把压力映射到宽度上。

float EstimatePressure(StrokePoint current, StrokePoint previous, float maxSpeed) { float dt = Math.Max(current.Timestamp - previous.Timestamp, 0.001f); float speed = Vector2.Distance(current.Position, previous.Position) / dt; // 速度越快,压力越小;用幂函数让中间段变化更自然 float normalized = Math.Clamp(speed / maxSpeed, 0f, 1f); return MathF.Pow(1f - normalized, 1.5f); }

maxSpeed 的取值需要看输入设备。鼠标在屏幕上快速划过,速度能到 3000 像素每秒,我一般在 2500 到 3500 之间调。指数取 1.5 而不是 1,是为了让中速书写时的粗细变化明显一点,否则大部分笔画会落在“中等宽度”附近,缺少棱角。

得到 estimatedPressure 之后,当前点宽度就是:

float currentWidth = baseWidth * (0.35f + 0.65f * pressure);

0.35 这个下限是防止宽度归零导致笔迹断裂。如果后续加了笔锋锥化,这里不需要再额外收窄,锥化逻辑会在轮廓层统一处理。

3.2 端点锥化:让起笔和收笔变成尖角而不是半圆

毛笔字最难的其实是两端。直接把 lastWidth 设为 0,轮廓会在终点处形成一个突兀的折角,视觉上像被菜刀切了一刀。正确的做法是按“弧长”做宽度衰减:从笔画起点到当前点走过的路径长度越短,宽度越接近 0;距离终点越近,宽度同样越小。

public static float TaperWidth(float arcLengthFromStart, float totalLength, float taperLen, float width) { // 起笔段:0 -> 1 float startT = MathF.Min(arcLengthFromStart / taperLen, 1f); // 收笔段:1 -> 0 float endT = MathF.Min((totalLength - arcLengthFromStart) / taperLen, 1f); // 取两者中较小的系数,表示“两端都要锥化” float taper = MathF.Min(startT, endT); // 正弦曲线让端部更圆润,避免线性渐变产生折角 taper = MathF.Sin(taper * 0.5f * MathF.PI); return width * taper; }

这里最关键的细节是必须用弧长而不是点索引。如果直接拿当前点的下标做锥化,当鼠标快速扫过时,端点附近密集的插值点会让锥化区突然变短,写出来的字每一笔的笔锋长度都不一样。用弧长计算后,无论采样点密还是稀,笔锋都固定在 taperLen 这段几何长度内。taperLen 我通常取 16 到 24 像素,写小字时降到 10 左右,写大字可以到 30。

3.3 收笔处的定向出锋:让笔势有一个明确方向

毛笔收笔时不光是宽度变窄,还会顺着运笔方向带出一小段“锋”。这个效果可以在锥化基础上再加一段方向延展:取最后两个轨迹点的方向向量,把轮廓终点向该方向再推出几像素。

Vector2 lastDir = lastPoint - previousPoint; lastDir.Normalize(); // 出锋长度一般取笔锋长度的 30%~40% float tailOffset = taperLen * 0.35f; PointF tailEnd = lastPoint + lastDir * tailOffset;

然后把锥化轮廓的最后一个顶点从 lastPoint 替换成 tailEnd。这一小步能让“撇”“捺”这类收笔产生明显的笔势。注意 tailOffset 不要超过 taperLen 的一半,否则出锋会脱离主体笔画,看起来像笔尖断掉了。我踩过的坑是把 tailOffset 设成和 taperLen 等长,结果写“一”字时收笔拖出一条细长尾巴,放大后非常突兀。

4. 渲染管线与工程结构:把三段代码拼成可编译的 C# 程序

4.1 数据结构:StrokePoint、StrokeData 和值元组的使用

为了让源码可编译且可维护,我一般会把数据结构和渲染逻辑拆开。单个采样点用 readonly record struct 保存,StrokeData 存放一段完整笔画的原始点和平滑结果。

public readonly record struct StrokePoint(PointF Position, float Pressure, float Timestamp); public class StrokeData { public List<StrokePoint> RawPoints { get; } = new(); public List<PointF> SmoothedPoints { get; set; } = new(); public float BaseWidth { get; set; } = 10f; public float TaperLen { get; set; } = 18f; }

record struct 是 C# 10 之后的语法,用来做一笔画里的点集合很适合,复制成本低,撤销重绘时不会误改历史数据。在计算逻辑里我偶尔会用值元组把 Position 和 Pressure 一起解构出来,比如:

var (pos, pressure, _) = (p.Position, p.Pressure, p.Timestamp);

这样写的好处是代码意图清晰,后面做宽度计算时不需要反复访问结构体字段。如果你在旧版 .NET Framework 上编译,把 record struct 换成普通 struct 即可,算法部分不受影响。

4.2 GraphicsPath 轮廓生成:左右轮廓合并成闭合多边形

平滑点和宽度都算好后,剩下就是把轨迹“撑”成一块可以填充的封闭区域。做法是沿着轨迹逐点计算法线方向,向左右两侧各偏移半个当前宽度,生成左轮廓和右轮廓,再把右轮廓反转后拼成一个闭合多边形。

public static GraphicsPath BuildOutline(StrokeData stroke) { var pts = stroke.SmoothedPoints; int n = pts.Count; var left = new List<PointF>(n); var right = new List<PointF>(n); for (int i = 1; i < n - 1; i++) { Vector2 dir = pts[i] - pts[i - 1]; dir.Normalize(); Vector2 normal = new Vector2(-dir.Y, dir.X); // 宽度由压力和端点锥化共同决定 float width = GetWidthAt(stroke, i); left.Add(new PointF( pts[i].X + normal.X * width, pts[i].Y + normal.Y * width)); right.Add(new PointF( pts[i].X - normal.X * width, pts[i].Y - normal.Y * width)); } right.Reverse(); var path = new GraphicsPath(); path.AddPolygon(left.Concat(right).ToArray()); return path; }

这段代码里最值得注意的坑是方向向量直接取相邻点差。如果平滑层做得好,方向变化是连续的;如果平滑密度不够,方向会跳跃,轮廓就会出现拧巴的交叉。所以轮廓生成对平滑质量极其敏感,宁可多插值也不要在这一步做额外修正。

GetWidthAt 内部做的事就是先按速度算出基础宽度,再用 TaperWidth 按弧长锥化。你可以在这一步把两种宽度策略合起来:最后效果等于“动态宽度 × 端点锥化”,两个系数相乘即可,不要做叠加。

4.3 实时书写的状态管理与双缓冲刷新

实时书写时,我会把交互抽象成一个小状态机:Down 表示落笔,Move 表示运笔,Up 表示收笔。MouseDown 时 new 一个 StrokeData,MouseMove 时追加采样点并即时重绘,MouseUp 时把这段笔画放进已完成列表。

private readonly List<StrokeData> _strokes = new(); private StrokeData _current; private bool _drawing; protected override void OnMouseDown(MouseEventArgs e) { _drawing = true; _current = new StrokeData(); _current.RawPoints.Add(new StrokePoint(e.Location, 1f, Environment.TickCount64)); } protected override void OnMouseMove(MouseEventArgs e) { if (!_drawing) return; float now = Environment.TickCount64; var last = _current.RawPoints[^1]; float pressure = EstimatePressure( new StrokePoint(e.Location, 0f, now), last, 3000f); _current.RawPoints.Add(new StrokePoint(e.Location, pressure, now)); Invalidate(); } protected override void OnMouseUp(MouseEventArgs e) { if (!_drawing) return; _drawing = false; _current.RawPoints = Simplify(_current.RawPoints, 1f) .Select(p => new StrokePoint(p, 1f, 0f)) .ToList(); _current.SmoothedPoints = CatmullRomSmooth( _current.RawPoints.Select(p => p.Position).ToList(), 8); _strokes.Add(_current); _current = null; Invalidate(); }

鼠标没有压感,所以 Move 里的 pressure 全靠速度估算。Environment.TickCount64 返回毫秒,计算速度时记得除以 1000。重绘时我建议用 BufferedGraphics 做双缓冲,避免笔迹多时闪烁:

protected override void OnPaint(PaintEventArgs e) { var buffer = BufferedGraphicsManager.Current.Allocate(e.Graphics, ClientRectangle); var g = buffer.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.Clear(Color.White); foreach (var stroke in _strokes) { using var path = BuildOutline(stroke); using var brush = new SolidBrush(Color.Black); g.FillPath(brush, path); } buffer.Render(e.Graphics); buffer.Dispose(); }

撤销功能不要在 Canvas 级别的位图上做擦除,而是维护一个 Stack ,出栈后全量重绘。这样撤销后参数仍然保留,后续如果想“还原”或者调整粗细,数据还在,不会变成黑匣子。

5. 毛笔字算法调参避坑:五个最容易翻车的参数与排查

5.1 必调参数对照表

参数建议起始值作用调大后效果调小后效果
samplesPerSegment8平滑插值密度曲线更圆润,计算量增大转角变生硬
simplifyTolerance1.0抽稀强度笔迹更简练,细节丢失噪声保留多
taperLen18笔锋长度笔锋更舒展笔锋变短促
baseWidth10基准笔画宽度整体变粗整体变细
maxSpeed3000速度压力映射上限粗细变化迟钝粗细变化敏感

5.2 笔锋像被切了一刀

现象是每个笔画的起笔和收笔处有一条明显的直线切口,放大到 200% 看就是一个平头,没有弧度。

原因是锥化系数直接用了“剩余点索引 / 总点数”,而不是弧长比例。当插值密度调整后,同一个笔锋长度在慢速段和快速段对应的点数量不一样,锥化区长度跟着采样率飘,视觉上有时钝、有时尖。解决方法是把 TaperWidth 里的参数改成 arcLengthFromStart,并且在 BuildOutline 之前先算好每段弧长,不要在每个点临时累加。

5.3 快速书写时笔迹断断续续

现象是快速画一条长线,笔画中间出现细小的空白缺口,像墨水断了。

原因是鼠标 Move 事件间隔大,前后两个采样点之间距离超过笔画宽度,轮廓左右偏移后中间出现空洞。常见做法是增加插值段数,同时把抽稀 tolerance 调大一点点,让轨迹更干净。注意不要把 baseWidth 随便调大来盖缺口,那会让慢速段变得过粗,整篇字失去粗细对比。

5.4 大回环或转弯急处出现自交和填充空洞

现象是写“乙”或“乃”这类回笔字时,FillPath 填充区域内出现三角形白斑。

原因是轮廓点沿着法线向外偏移宽度 w,如果当前曲率半径小于 w,左右轮廓就会交叉,填充算法无法判断哪边是里、哪边是外。我在处理这种场景时会对宽度的上限加一个曲率约束,三点外接圆半径小于两倍宽度时,把宽度压到半径的 0.8 倍。这样会轻微损失粗度,但不会破洞。

5.5 撤销后笔迹整体变形或粗细错位

现象是写完几笔,调整画笔粗细后再撤销,之前的笔画也跟着变粗。

原因是撤销栈里只存了 Points 点列表,没有把 StrokeData 整个对象入栈。Width、TaperLen 这些参数属于绘制状态,不随点数据保存,重绘时用了当前画笔的新参数,历史笔画就被“污染”了。解决方法是 Stack 里直接压 StrokeData 引用,或者至少把 BaseWidth、TaperLen、SmoothedPoints 一起打包成快照再入栈。

6. 进阶:压感模拟、顿笔与验证笔迹的自检方法

如果后续想做得更像毛笔,可以引入顿笔检测。顿笔在轨迹上的特征是短时间内速度急速下降但位移很小,此时宽度会短暂放大。我通常用速度的一阶差分来判断:当前速度低于前一段速度的 40%,且位移小于 3 像素,就认为用户在顿笔,把当前宽度乘以 1.3。这个技巧在小字场景里效果很明显,写“横折”时转折点会自然鼓起来。

验证一套笔锋算法是否合格,我一般用三步自检。第一步是把渲染结果放大到 400%,检查所有端部是否形成光滑的锥形,有没有平头和折角。第二步是分别画一条直线、一条慢速曲线、一条快速弧线,比较三者的宽度变化是否连续,如果出现忽粗忽细,说明速度压力映射没有调平。第三步是显示原始采样点和最终轮廓的叠加视图,确认轮廓与采样点之间没有整体偏移,这一步能筛掉方向向量计算错误和法线方向反了的问题。

最后提醒一个我踩过的坑:早期做手写批注控件时,我为了省事直接用最后一小段轮廓做线性收窄,结果每个字的收尾都像被刀削过,后来老老实实改回弧长锥化才解决问题。这不是玄学,而是笔锋这件事必须用几何参数去算,不能靠渲染层碰运气。希望你照着这套思路搭的时候,能少走这一步弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进