:平滑曲線、筆鋒與輪廓生成全解析)
簡介面向圖形處理與數(shù)字藝術開發(fā)者的C#毛筆字模擬源碼包聚焦筆鋒算法與平滑算法的工程實現(xiàn)可直接編譯運行。資源內(nèi)包含完整的Visual Studio解決方案共47個文件以C#源碼文件.cs為核心輔以圖片素材png/jpg、配置文件config/resx及運行示例exe整體僅809KB便于快速閱讀與調試。已有715人學習下載適合對數(shù)字書法、渲染效果或交互式繪畫感興趣的中高級開發(fā)者。通過源碼可深入理解筆觸壓力與角度模擬、筆尖形態(tài)計算及圖像平滑濾波等關鍵邏輯為游戲美術、UI特效或創(chuàng)意工具開發(fā)提供可直接借鑒的算法參考。1. 毛筆字算法在 C# 里落地平滑、筆鋒與可編譯工程的關系把鼠標采集到的一串 PointF 直接連成線寫出來的字永遠不會像毛筆字問題不在畫線而在筆鋒。真正的毛筆字算法核心是筆鋒算法而不是筆刷貼圖起筆和收筆的錐形輪廓、運筆過程中由速度調制出的寬窄變化、以及最后交給 C# 渲染層的那條平滑軌跡三件事疊在一起才產(chǎn)生“筆意”。這套可編譯的 C# 源碼常見形態(tài)就是把這三塊拆成能單獨調參的模塊底層用 WinForms 的 GraphicsPath 和 FillPath 完成渲染不依賴外部圖像引擎。適合正在做手寫批注、電子簽名、美術字編輯器的人也適合想在 Windows 桌面上快速驗證書寫效果的上位機程序員。下文按平滑、筆鋒、輪廓生成、調參避坑的順序展開最后給出一套驗證筆跡的自檢方法。2. 平滑算法選型把鼠標軌跡變成連貫曲線的三條路徑2.1 Catmull-Rom 插值最少依賴的平滑方案凡是和手寫軌跡打交道的 C# 工程我第一選擇基本是 Catmull-Rom 樣條而不是貝塞爾。原因很直接Catmull-Rom 穿過所有原始采樣點而貝塞爾曲線不經(jīng)過控制點。毛筆字的骨架是用戶的真實運筆軌跡任何偏離采樣點的擬合后續(xù)在做筆鋒寬度調制時都會出現(xiàn)“寫的是 A渲染成 B”的錯位感。public static ListPointF CatmullRomSmooth(ListPointF input, int samplesPerSegment) { var result new ListPointF(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 形式張力參數(shù)固定為 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 時曲線已經(jīng)比較順但快速轉角處會出現(xiàn)輕微多邊形感取到 16 以上視覺差距不明顯還會讓后續(xù)法線寬度計算的循環(huán)量成倍上漲。對于實時書寫8 是性價比最好的起點值。2.2 先抽稀再平滑給手抖軌跡做 Douglas-Peucker 預處理鼠標 Move 事件在慢速書寫時會產(chǎn)生大量噪聲點這些點不全是用戶主動運筆更多是傳感器抖動。直接對原始點做 Catmull-Rom插值結果會忠實地穿過每一個噪聲點筆畫邊緣會出現(xiàn)細小的毛刺而且這些毛刺在筆鋒錐化階段會被方法線偏移進一步放大。我一般會在平滑之前先做一次抽稀去掉那些偏離主體方向過遠的冗余點。Douglas-Peucker 是這里最穩(wěn)的方案它不改變剩余點的原始坐標只刪點保留下來的點仍然在真實軌跡上。public static ListPointF Simplify(ListPointF 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 ListPointF { 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 開始調數(shù)位板輸入源抖動小可以壓到 0.3 到 0.5。注意抽稀和插值的順序不能反過來先插值再抽稀噪聲已經(jīng)被插值“洗”成了平滑曲線上的起伏Douglas-Peucker 會因為距離偏差太小而認為處處合格等于白做。先抽稀后插值剩下的關鍵點仍然在軌跡上插值只是加密而不是修正。2.3 為什么不用均值濾波和高階貝塞爾有人會問直接用滑動平均值不行嗎均值濾波確實能壓噪聲但它會系統(tǒng)性地縮小軌跡曲率半徑寫出來的字整體“瘦”一圈而且筆鋒尖角位置會被平均成圓角這對書法效果是致命的。貝塞爾擬合更適合離線生成美術字在實時手寫場景里控制點不落在筆跡上后續(xù)計算筆鋒方向時你必須額外維護一套控制點映射關系復雜度不成比例。平滑層做到 Catmull-Rom 加 Douglas-Peucker 已經(jīng)夠用。這組代碼不依賴具體窗體框架把它放進一個 GraphicUtils 靜態(tài)類里就是一份可以獨立編譯的 C# 類庫WinForms、WPF 甚至控制臺輸出位圖都能復用。3. 筆鋒算法實現(xiàn)從等寬描邊到毛筆感的兩個關鍵步驟3.1 動態(tài)寬度用速度和壓力調制當前筆劃粗細等寬線條是記號筆寬度隨運筆速度變化才是毛筆的第一特征。慢的地方吸墨多、筆畫粗快的地方筆畫細。鼠標沒有壓感事件常見的替代方案是用瞬時速度反推壓力值再把壓力映射到寬度上。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; // 速度越快壓力越小用冪函數(shù)讓中間段變化更自然 float normalized Math.Clamp(speed / maxSpeed, 0f, 1f); return MathF.Pow(1f - normalized, 1.5f); }maxSpeed 的取值需要看輸入設備。鼠標在屏幕上快速劃過速度能到 3000 像素每秒我一般在 2500 到 3500 之間調。指數(shù)取 1.5 而不是 1是為了讓中速書寫時的粗細變化明顯一點否則大部分筆畫會落在“中等寬度”附近缺少棱角。得到 estimatedPressure 之后當前點寬度就是float currentWidth baseWidth * (0.35f 0.65f * pressure);0.35 這個下限是防止寬度歸零導致筆跡斷裂。如果后續(xù)加了筆鋒錐化這里不需要再額外收窄錐化邏輯會在輪廓層統(tǒng)一處理。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); // 取兩者中較小的系數(shù)表示“兩端都要錐化” float taper MathF.Min(startT, endT); // 正弦曲線讓端部更圓潤避免線性漸變產(chǎn)生折角 taper MathF.Sin(taper * 0.5f * MathF.PI); return width * taper; }這里最關鍵的細節(jié)是必須用弧長而不是點索引。如果直接拿當前點的下標做錐化當鼠標快速掃過時端點附近密集的插值點會讓錐化區(qū)突然變短寫出來的字每一筆的筆鋒長度都不一樣。用弧長計算后無論采樣點密還是稀筆鋒都固定在 taperLen 這段幾何長度內(nèi)。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。這一小步能讓“撇”“捺”這類收筆產(chǎn)生明顯的筆勢。注意 tailOffset 不要超過 taperLen 的一半否則出鋒會脫離主體筆畫看起來像筆尖斷掉了。我踩過的坑是把 tailOffset 設成和 taperLen 等長結果寫“一”字時收筆拖出一條細長尾巴放大后非常突兀。4. 渲染管線與工程結構把三段代碼拼成可編譯的 C# 程序4.1 數(shù)據(jù)結構StrokePoint、StrokeData 和值元組的使用為了讓源碼可編譯且可維護我一般會把數(shù)據(jù)結構和渲染邏輯拆開。單個采樣點用 readonly record struct 保存StrokeData 存放一段完整筆畫的原始點和平滑結果。public readonly record struct StrokePoint(PointF Position, float Pressure, float Timestamp); public class StrokeData { public ListStrokePoint RawPoints { get; } new(); public ListPointF SmoothedPoints { get; set; } new(); public float BaseWidth { get; set; } 10f; public float TaperLen { get; set; } 18f; }record struct 是 C# 10 之后的語法用來做一筆畫里的點集合很適合復制成本低撤銷重繪時不會誤改歷史數(shù)據(jù)。在計算邏輯里我偶爾會用值元組把 Position 和 Pressure 一起解構出來比如var (pos, pressure, _) (p.Position, p.Pressure, p.Timestamp);這樣寫的好處是代碼意圖清晰后面做寬度計算時不需要反復訪問結構體字段。如果你在舊版 .NET Framework 上編譯把 record struct 換成普通 struct 即可算法部分不受影響。4.2 GraphicsPath 輪廓生成左右輪廓合并成閉合多邊形平滑點和寬度都算好后剩下就是把軌跡“撐”成一塊可以填充的封閉區(qū)域。做法是沿著軌跡逐點計算法線方向向左右兩側各偏移半個當前寬度生成左輪廓和右輪廓再把右輪廓反轉后拼成一個閉合多邊形。public static GraphicsPath BuildOutline(StrokeData stroke) { var pts stroke.SmoothedPoints; int n pts.Count; var left new ListPointF(n); var right new ListPointF(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; }這段代碼里最值得注意的坑是方向向量直接取相鄰點差。如果平滑層做得好方向變化是連續(xù)的如果平滑密度不夠方向會跳躍輪廓就會出現(xiàn)擰巴的交叉。所以輪廓生成對平滑質量極其敏感寧可多插值也不要在這一步做額外修正。GetWidthAt 內(nèi)部做的事就是先按速度算出基礎寬度再用 TaperWidth 按弧長錐化。你可以在這一步把兩種寬度策略合起來最后效果等于“動態(tài)寬度 × 端點錐化”兩個系數(shù)相乘即可不要做疊加。4.3 實時書寫的狀態(tài)管理與雙緩沖刷新實時書寫時我會把交互抽象成一個小狀態(tài)機Down 表示落筆Move 表示運筆Up 表示收筆。MouseDown 時 new 一個 StrokeDataMouseMove 時追加采樣點并即時重繪MouseUp 時把這段筆畫放進已完成列表。private readonly ListStrokeData _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 出棧后全量重繪。這樣撤銷后參數(shù)仍然保留后續(xù)如果想“還原”或者調整粗細數(shù)據(jù)還在不會變成黑匣子。5. 毛筆字算法調參避坑五個最容易翻車的參數(shù)與排查5.1 必調參數(shù)對照表參數(shù)建議起始值作用調大后效果調小后效果samplesPerSegment8平滑插值密度曲線更圓潤計算量增大轉角變生硬simplifyTolerance1.0抽稀強度筆跡更簡練細節(jié)丟失噪聲保留多taperLen18筆鋒長度筆鋒更舒展筆鋒變短促baseWidth10基準筆畫寬度整體變粗整體變細maxSpeed3000速度壓力映射上限粗細變化遲鈍粗細變化敏感5.2 筆鋒像被切了一刀現(xiàn)象是每個筆畫的起筆和收筆處有一條明顯的直線切口放大到 200% 看就是一個平頭沒有弧度。原因是錐化系數(shù)直接用了“剩余點索引 / 總點數(shù)”而不是弧長比例。當插值密度調整后同一個筆鋒長度在慢速段和快速段對應的點數(shù)量不一樣錐化區(qū)長度跟著采樣率飄視覺上有時鈍、有時尖。解決方法是把 TaperWidth 里的參數(shù)改成 arcLengthFromStart并且在 BuildOutline 之前先算好每段弧長不要在每個點臨時累加。5.3 快速書寫時筆跡斷斷續(xù)續(xù)現(xiàn)象是快速畫一條長線筆畫中間出現(xiàn)細小的空白缺口像墨水斷了。原因是鼠標 Move 事件間隔大前后兩個采樣點之間距離超過筆畫寬度輪廓左右偏移后中間出現(xiàn)空洞。常見做法是增加插值段數(shù)同時把抽稀 tolerance 調大一點點讓軌跡更干凈。注意不要把 baseWidth 隨便調大來蓋缺口那會讓慢速段變得過粗整篇字失去粗細對比。5.4 大回環(huán)或轉彎急處出現(xiàn)自交和填充空洞現(xiàn)象是寫“乙”或“乃”這類回筆字時FillPath 填充區(qū)域內(nèi)出現(xiàn)三角形白斑。原因是輪廓點沿著法線向外偏移寬度 w如果當前曲率半徑小于 w左右輪廓就會交叉填充算法無法判斷哪邊是里、哪邊是外。我在處理這種場景時會對寬度的上限加一個曲率約束三點外接圓半徑小于兩倍寬度時把寬度壓到半徑的 0.8 倍。這樣會輕微損失粗度但不會破洞。5.5 撤銷后筆跡整體變形或粗細錯位現(xiàn)象是寫完幾筆調整畫筆粗細后再撤銷之前的筆畫也跟著變粗。原因是撤銷棧里只存了 Points 點列表沒有把 StrokeData 整個對象入棧。Width、TaperLen 這些參數(shù)屬于繪制狀態(tài)不隨點數(shù)據(jù)保存重繪時用了當前畫筆的新參數(shù)歷史筆畫就被“污染”了。解決方法是 Stack 里直接壓 StrokeData 引用或者至少把 BaseWidth、TaperLen、SmoothedPoints 一起打包成快照再入棧。6. 進階壓感模擬、頓筆與驗證筆跡的自檢方法如果后續(xù)想做得更像毛筆可以引入頓筆檢測。頓筆在軌跡上的特征是短時間內(nèi)速度急速下降但位移很小此時寬度會短暫放大。我通常用速度的一階差分來判斷當前速度低于前一段速度的 40%且位移小于 3 像素就認為用戶在頓筆把當前寬度乘以 1.3。這個技巧在小字場景里效果很明顯寫“橫折”時轉折點會自然鼓起來。驗證一套筆鋒算法是否合格我一般用三步自檢。第一步是把渲染結果放大到 400%檢查所有端部是否形成光滑的錐形有沒有平頭和折角。第二步是分別畫一條直線、一條慢速曲線、一條快速弧線比較三者的寬度變化是否連續(xù)如果出現(xiàn)忽粗忽細說明速度壓力映射沒有調平。第三步是顯示原始采樣點和最終輪廓的疊加視圖確認輪廓與采樣點之間沒有整體偏移這一步能篩掉方向向量計算錯誤和法線方向反了的問題。最后提醒一個我踩過的坑早期做手寫批注控件時我為了省事直接用最后一小段輪廓做線性收窄結果每個字的收尾都像被刀削過后來老老實實改回弧長錐化才解決問題。這不是玄學而是筆鋒這件事必須用幾何參數(shù)去算不能靠渲染層碰運氣。希望你照著這套思路搭的時候能少走這一步彎路。本文還有配套的精品資源點擊獲取