WPF 筆跡演算法 從點集轉筆跡輪廓

lindexi發表於2023-10-12

本文將告訴大家一些筆跡演算法,從使用者輸入的點集,即滑鼠軌跡點或觸控軌跡點等,轉換為一個可在介面繪製顯示筆跡畫面的基礎數學演算法。儘管本文標記的是 WPF 的筆跡演算法,然而實際上本文更側重基礎數學計算,理論上可以適用於任何能夠支援幾何繪製的 UI 框架上,包括 UWP 或 WinUI 或 UNO 或 MAUI 或 Eto 等框架

我將從簡單到複雜的順序描述筆跡演算法,本文屬於比較偏演算法底層,閱讀之前請先確保初中的數學知識還沒忘了

本文適合於想要了解筆跡繪製更多細節的夥伴,以及期望自己設計出更好看的筆跡的夥伴,以及沒事幹摸魚看部落格的夥伴

最簡單的筆跡軌跡演算法

大家都知道,無論是滑鼠還是觸控還是筆,所產生的資料基本都是點資料。根據點集建立一條筆跡軌跡的一個實現方式是建立一條几何圖形,將幾何圖形繪製到介面上。在 UI 框架的底層裡,是不存在筆跡的概念的,只有畫圖、畫文字、畫幾何圖形等基礎繪製原語而已。從點集構建出一條几何軌跡最簡單的方法是構建一條折線,程式碼也非常簡單,只是將所有的輸入點當成折線即可

也就是建立一個 Polyline 物件,不斷將輸出的點集加入到折線裡面。以下是例子程式碼,先新建一個空 WPF 專案,在 MainWindow.xaml 裡新增事件監聽,如以下程式碼

<Window x:Class="YegeenurcairwheBeahealelbewe.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:YegeenurcairwheBeahealelbewe"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800" StylusDown="MainWindow_OnStylusDown" StylusMove="MainWindow_OnStylusMove" StylusUp="MainWindow_OnStylusUp">
    <Canvas x:Name="InkCanvas">

    </Canvas>
</Window>

在後臺程式碼裡面,實現事件,以下的程式碼很簡單,相信大家一看就明白

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private void MainWindow_OnStylusDown(object sender, StylusDownEventArgs e)
    {
        var polyline = new Polyline()
        {
            Stroke = Brushes.Black,
            StrokeThickness = 5
        };
        InkCanvas.Children.Add(polyline);

        _pointCache[e.StylusDevice.Id] = polyline;

        foreach (var stylusPoint in e.GetStylusPoints(this))
        {
            polyline.Points.Add(stylusPoint.ToPoint());
        }
    }

    private void MainWindow_OnStylusMove(object sender, StylusEventArgs e)
    {
        if (_pointCache.TryGetValue(e.StylusDevice.Id,out var polyline))
        {
            foreach (var stylusPoint in e.GetStylusPoints(this))
            {
                polyline.Points.Add(stylusPoint.ToPoint());
            }
        }
    }

    private void MainWindow_OnStylusUp(object sender, StylusEventArgs e)
    {
        if (_pointCache.Remove(e.StylusDevice.Id, out var polyline))
        {
            foreach (var stylusPoint in e.GetStylusPoints(this))
            {
                polyline.Points.Add(stylusPoint.ToPoint());
            }
        }
    }

    private readonly Dictionary<int/*StylusDeviceId*/, Polyline> _pointCache=new Dictionary<int, Polyline>();
}

以上的程式碼放在githubgitee 歡迎訪問

可以透過如下方式獲取本文的原始碼,先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin d76fffd214ed5b3aeb99f3593c441b7a12f10d55

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin d76fffd214ed5b3aeb99f3593c441b7a12f10d55

獲取程式碼之後,進入 HallgaiwhiyiwaLejucona\YegeenurcairwheBeahealelbewe 資料夾

儘管以上的程式碼很簡單,但是大家將會發現筆跡不夠順滑,至少比 WPF 最簡邏輯實現多指順滑的筆跡書寫 呼叫 WPF 自帶的筆跡繪製的方法不順滑好多,而且繪製速度也差好多

先忘掉 WPF 的上層呼叫,假如現在我們想要自己編寫演算法來畫一條比 WPF 不會差太多的筆跡軌跡,可以如何做呢。接下來我將繼續從簡單到複雜的順序告訴大家不同的演算法

用兩條折線繪製筆跡

上文使用折線的方式可以很簡單繪製出筆跡,但是無法實現一條粗細變化的筆跡軌跡。筆跡的粗細變更一般來說和觸控壓感相關,換句話說,想要實現跟隨觸控壓感變更而變更粗細的筆跡軌跡輪廓就需要用到至少比折線更加複雜的方式

接下來介紹的方式是用兩條線段繪製筆跡,可以將筆跡元素理解為一個由兩條折線構成的閉合 Path 幾何形狀。如下圖所示,筆跡軌跡就是一個 Path 幾何形狀的填充

這裡如果看完還沒理解的話,推薦先暫停下來,先想一想。因為這裡有點難描述哈

在這個的基礎上,我們的問題就轉換為根據輸入的點集轉換為 Path 幾何形狀

接下來我將介紹根據輸入的點集轉換為 Path 幾何形狀的最簡單方法之一,期望以下的方法能夠給大家帶來一些啟示。我將快速給出一些圖和文字描述給到大家,方便快速理解整體的思想。然後再給出具體的實現

下圖的藍色的點表示的是當前所輸入收到的點集

接下來求每個點與下一個點相連的射線向量,再算出射線向量的法線方向,在此法線方向上以觸控點的中心向法線兩端延伸線段,延伸的線段長度由筆跡粗細配置以及當前觸控點的壓感係數決定,如下圖,藍色的線就是射線向量,黃色的線是射線向量的法線方向延伸的線段

再獲取線段的兩個端點,如下圖,紅色的圓點就是延伸的線段的兩個端點

接著將各個線段的端點按照如下圖的方式連線起來,各個線段的兩個端點分別按照兩邊連線成兩條折線,再將這兩條折線和起始點和結束點連線到一起,構成閉合的 Path 幾何形狀,紅色的折線就可以被當成筆跡軌跡的 Path 幾何形狀

最後將紅色的折線組成的筆跡軌跡的 Path 幾何形狀填充,填充之後看起來的效果還行

相信大家看到這裡就理解了用兩條折線繪製筆跡的方法

接下來我將告訴大家如何使用具體的程式碼實現用兩條折線繪製筆跡

原本我是想繼續採用 WPF 專案完成此步驟的演示,但剛好我開啟了一個 UNO 框架的專案,於是我就使用 UNO 框架專案作為演示。這裡需要說明的是 UNO 和 WPF 之間的關係不是重複的存在,而是相互引用的關係,如下圖可以看到 UNO 可以處於 WPF 的上層,換句話說就是使用 UNO 框架時可以將 WPF 當成底層,從這個方面來說,最後構建輸出的也依然是一個 WPF 應用

新建一個 UNO 專案,在 MainPage.xaml 裡面監聽事件,製作一些準備輔助筆跡繪製的介面邏輯,簡單的程式碼如下

<Canvas x:Name="InkCanvas" Background="Transparent" PointerPressed="InkCanvas_OnPointerPressed" PointerMoved="InkCanvas_OnPointerMoved" PointerReleased="InkCanvas_OnPointerReleased" PointerCanceled="InkCanvas_OnPointerCanceled"/>

在 MainPage.xaml.cs 後臺程式碼裡面,根據輸入事件的監聽,獲取到當前的輸入點集。這部分程式碼預計大家一看就明白,我這裡就快速跳過

    private void InkCanvas_OnPointerPressed(object sender, PointerRoutedEventArgs e)
    {
        var pointerPoint = e.GetCurrentPoint(InkCanvas);
        Point position = pointerPoint.Position;

        var inkInfo = new InkInfo();
        _inkInfoCache[e.Pointer.PointerId] = inkInfo;
        inkInfo.PointList.Add(position);

        DrawStroke(inkInfo);
    }


    private void InkCanvas_OnPointerMoved(object sender, PointerRoutedEventArgs e)
    {
        if (_inkInfoCache.TryGetValue(e.Pointer.PointerId, out var inkInfo))
        {
            var pointerPoint = e.GetCurrentPoint(InkCanvas);
            Point position = pointerPoint.Position;

            inkInfo.PointList.Add(position);
            DrawStroke(inkInfo);
        }
    }

    private void InkCanvas_OnPointerReleased(object sender, PointerRoutedEventArgs e)
    {
        if (_inkInfoCache.Remove(e.Pointer.PointerId, out var inkInfo))
        {
            var pointerPoint = e.GetCurrentPoint(InkCanvas);
            Point position = pointerPoint.Position;
            inkInfo.PointList.Add(position);
            DrawStroke(inkInfo);
        }
    }

    private void InkCanvas_OnPointerCanceled(object sender, PointerRoutedEventArgs e)
    {
        if (_inkInfoCache.Remove(e.Pointer.PointerId, out var inkInfo))
        {
            RemoveInkElement(inkInfo.InkElement);
        }
    }

    private void RemoveInkElement(FrameworkElement? inkElement)
    {
        if (inkElement != null)
        {
            InkCanvas.Children.Remove(inkElement);
        }
    }

    private readonly Dictionary<uint /*PointerId*/, InkInfo> _inkInfoCache = new Dictionary<uint, InkInfo>();

public class InkInfo
{
    public FrameworkElement? InkElement { set; get; }
    public List<StrokePoint> PointList { get; } = new List<StrokePoint>();
}

public readonly record struct StrokePoint(Point Point, float Pressure = 0.5f)
{
    public static implicit operator StrokePoint(Point point) => new StrokePoint(point);
}

以上程式碼沒給出的 DrawStroke 則是核心演算法,在 InkInfo 裡面存放了 PointList 點集。在 DrawStroke 需要根據此點集資訊構建出一個 FrameworkElement 型別的物件,這個物件就是筆跡元素物件。按照本文以上的演算法原理描述,這個筆跡物件就是在數學上由兩段折線組合而成的閉合 Path 幾何形狀。這裡為了簡單使用,就使用了內建的 Microsoft.UI.Xaml.Shapes.Polygon 型別

使用 Polygon 型別時,最重要的就是獲取按照預期順序的筆跡輪廓點,也就是上文的各個線段的兩個端點,也就是如下圖裡黃色的點

為了計算筆跡輪廓點集,以下程式碼封裝了 GetOutlinePointList 方法,這個方法需要傳入 InkInfo 的 PointList 點集,也就是輸入的點集,以及筆跡的大小

    public static Point[] GetOutlinePointList(List<StrokePoint> pointList, int inkSize)
    {
        ... // 忽略程式碼
    }

由於我們需要計算射線向量方向,這就意味著至少需要兩個點才能計算,於是先加上如下判斷邏輯

    public static Point[] GetOutlinePointList(List<StrokePoint> pointList, int inkSize)
    {
        if (pointList.Count < 2)
        {
            throw new ArgumentException("小於兩個點的無法應用演算法");
        }

        ... // 忽略程式碼
    }

如上文的演算法,可以看到輸出的筆跡輪廓點集,也就是 GetOutlinePointList 的返回值,的元素個數將會是 pointList 點集的兩倍加二。為什麼會是 pointList 點集的兩倍加二的值?因為如上文的演算法,每個原始輸入點都可以算出兩個端點,再加上最後將首末兩個點一共就是兩倍加二的值

        var pointCount = pointList.Count * 2 /*兩邊的筆跡軌跡*/ + 1 /*首點重複*/ + 1 /*末重複*/;

        var outlinePointList = new Point[pointCount];

接著進行輸入的原始點集的迴圈,計算每個點的射線向量

        for (var i = 0; i < pointList.Count; i++)
        {
            var currentPoint = pointList[i];
            var nextPoint = pointList[i + 1]; // 先忽略最後一個點的錯誤計算

            var x = nextPoint.Point.X - currentPoint.Point.X;
            var y = nextPoint.Point.Y - currentPoint.Point.Y;

            // 拿著紙筆自己畫一下吧,這個是簡單的數學計算
            double angle = Math.Atan2(y, x) - Math.PI / 2;
        }

以上程式碼的 angle 就是向量角度,於是再計算端點距離輸入原始點的距離,即可算出端點座標

            // 筆跡粗細的一半,一邊用一半,合起來就是筆跡粗細了
            var halfThickness = inkSize / 2d;

            // 壓感這裡是直接乘法而已
            halfThickness *= currentPoint.Pressure;
            // 不能讓筆跡粗細太小
            halfThickness = Math.Max(0.01, halfThickness);

            var leftX = currentPoint.Point.X + (Math.Cos(angle) * halfThickness);
            var leftY = currentPoint.Point.Y + (Math.Sin(angle) * halfThickness);

            var rightX = currentPoint.Point.X - (Math.Cos(angle) * halfThickness);
            var rightY = currentPoint.Point.Y - (Math.Sin(angle) * halfThickness);

            outlinePointList[i + 1] = new Point(leftX, leftY);
            outlinePointList[pointCount - i - 1] = new Point(rightX, rightY);

以上程式碼只是簡單的初中函式計算,相信大家一看就知道

以上的程式碼實際上是不能執行的,因為最後一個點的計算還沒有加上。這裡就簡單將最後一個點的向量方向記錄為前一個點的方向,修改之後的程式碼如下

        double angle = 0.0;
        for (var i = 0; i < pointList.Count; i++)
        {
            var currentPoint = pointList[i];

            // 如果不是最後一點,那就可以和筆跡當前軌跡點的下一點進行計算向量角度
            if (i < pointList.Count - 1)
            {
                var nextPoint = pointList[i + 1];

                var x = nextPoint.Point.X - currentPoint.Point.X;
                var y = nextPoint.Point.Y - currentPoint.Point.Y;

                // 拿著紙筆自己畫一下吧,這個是簡單的數學計算
                angle = Math.Atan2(y, x) - Math.PI / 2;
            }

            // 筆跡粗細的一半,一邊用一半,合起來就是筆跡粗細了
            var halfThickness = inkSize / 2d;

            // 壓感這裡是直接乘法而已
            halfThickness *= currentPoint.Pressure;
            // 不能讓筆跡粗細太小
            halfThickness = Math.Max(0.01, halfThickness);

            var leftX = currentPoint.Point.X + (Math.Cos(angle) * halfThickness);
            var leftY = currentPoint.Point.Y + (Math.Sin(angle) * halfThickness);

            var rightX = currentPoint.Point.X - (Math.Cos(angle) * halfThickness);
            var rightY = currentPoint.Point.Y - (Math.Sin(angle) * halfThickness);

            outlinePointList[i + 1] = new Point(leftX, leftY);
            outlinePointList[pointCount - i - 1] = new Point(rightX, rightY);
        }

接著再加上首末兩個點就完成了方法

    public static Point[] GetOutlinePointList(List<StrokePoint> pointList, int inkSize)
    {
        if (pointList.Count < 2)
        {
            throw new ArgumentException("小於兩個點的無法應用演算法");
        }

        var pointCount = pointList.Count * 2 /*兩邊的筆跡軌跡*/ + 1 /*首點重複*/ + 1 /*末重複*/;

        var outlinePointList = new Point[pointCount];

        // 用來計算筆跡點的兩點之間的向量角度
        double angle = 0.0;
        for (var i = 0; i < pointList.Count; i++)
        {
            var currentPoint = pointList[i];

            // 如果不是最後一點,那就可以和筆跡當前軌跡點的下一點進行計算向量角度
            if (i < pointList.Count - 1)
            {
                var nextPoint = pointList[i + 1];

                var x = nextPoint.Point.X - currentPoint.Point.X;
                var y = nextPoint.Point.Y - currentPoint.Point.Y;

                // 拿著紙筆自己畫一下吧,這個是簡單的數學計算
                angle = Math.Atan2(y, x) - Math.PI / 2;
            }

            // 筆跡粗細的一半,一邊用一半,合起來就是筆跡粗細了
            var halfThickness = inkSize / 2d;

            // 壓感這裡是直接乘法而已
            halfThickness *= currentPoint.Pressure;
            // 不能讓筆跡粗細太小
            halfThickness = Math.Max(0.01, halfThickness);

            var leftX = currentPoint.Point.X + (Math.Cos(angle) * halfThickness);
            var leftY = currentPoint.Point.Y + (Math.Sin(angle) * halfThickness);

            var rightX = currentPoint.Point.X - (Math.Cos(angle) * halfThickness);
            var rightY = currentPoint.Point.Y - (Math.Sin(angle) * halfThickness);

            outlinePointList[i + 1] = new Point(leftX, leftY);
            outlinePointList[pointCount - i - 1] = new Point(rightX, rightY);
        }

        outlinePointList[0] = pointList[0].Point;
        outlinePointList[pointList.Count + 1] = pointList[^1].Point;
        return outlinePointList;
    }

在透過 GetOutlinePointList 拿到筆跡輪廓點之後,即可構建出 Polygon 物件,如以下程式碼

    public static Polygon CreatePath(InkInfo inkInfo, int inkSize)
    {
        List<StrokePoint> pointList = inkInfo.PointList;
        var outlinePointList = GetOutlinePointList(pointList, inkSize);

        var polygon = new Polygon();

        foreach (var point in outlinePointList)
        {
            polygon.Points.Add(point);
        }
        polygon.Fill = new SolidColorBrush(Colors.Red);
        return polygon;
    }

儘管以上程式碼是在 UNO 框架下編寫的,但可以直接複製程式碼在 UWP 應用上直接執行

拿到 Polygon 物件之後,將此物件加入到介面裡面,如以下程式碼,即可完成筆跡的繪製。在不斷落點輸入點資料過程中,將不斷執行 Polygon 的 Points 的清理和重新新增,於是就可以不斷跟隨落點更新筆跡內容,完成筆跡書寫的功能

    private void DrawStroke(InkInfo inkInfo)
    {
        var pointList = inkInfo.PointList;
        if (pointList.Count < 2)
        {
            // 小於兩個點的無法應用演算法
            return;
        }

        var inkElement = MyInkRender.CreatePath(inkInfo, inkSize);

        if (inkInfo.InkElement is null)
        {
            InkCanvas.Children.Add(inkElement);
        }

        inkInfo.InkElement = inkElement;
    }

完成到這裡,其實就算完成了一個簡單的在繪製的過程,可根據壓感引數變更筆跡粗細的演算法了

但是一般的輸入裝置,比如滑鼠或者渣觸控式螢幕都是沒有壓感的,或者是沒有正確的壓感的,那這個時候似乎體現不出以上演算法的優勢。這時候可以繼續和大家介紹另一個有趣的功能實現,模擬筆鋒

很多人都喜歡寫字的時候帶筆鋒,無論是寫中文還是寫英文的時候。模擬筆鋒也許可以讓使用者感謝寫出來的字更好看,透過壓感模擬筆鋒是一個非常簡單的實現。實現思路就是從筆尖到筆身的順序,讓輸入的點集的壓感從小到大,大概如下圖所示,如此即可做出類似筆鋒的效果

大概的實現程式碼如下

        // 模擬筆鋒

        // 用於當成筆鋒的點的數量
        var tipCount = 20;

        for (int i = 0; i < pointList.Count; i++)
        {
            if ((pointList.Count - i) < tipCount)
            {
                pointList[i] = pointList[i] with
                {
                    Pressure = (pointList.Count - i) * 1f / tipCount
                };
            }
            else
            {
                pointList[i] = pointList[i] with
                {
                    Pressure = 1.0f
                };
            }
        }

加上模擬筆鋒之後,即可使用以上的演算法畫出如下圖的筆跡效果

上圖是我開了除錯模式的效果,除錯模式就是在原筆跡元素的基礎上,繪製出藍色的原始輸入的點集,以及黃色的端點

以上的程式碼放在githubgitee 歡迎訪問

可以透過如下方式獲取本文的原始碼,先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 8d59a96e0d4e390ae78946ff556a759901961856

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 8d59a96e0d4e390ae78946ff556a759901961856

獲取程式碼之後,進入 HallgaiwhiyiwaLejucona 資料夾

歡迎大家將程式碼拉下來,執行看試試效果。以上程式碼是寫在 UNO 框架裡的,可以在 Windows 平臺上使用 WinUI 或 WPF 執行,也可以在 Linux 系統使用 GTK 執行

但大家也可以很輕鬆就看出來以上演算法存在的不足還是有很多的,比如是採用折線連線筆跡輪廓的點集,這就導致了在觸控取樣不夠密或滑鼠精度很低的情況下,畫出來的筆跡存在很明顯的折線效果,不夠順滑。另外,從以上的簡單數學計算上,也存在著輸入軌跡大角度的折彎時存在計算錯誤

接下來我將和大家介紹更加進階的演算法,解決以上簡單演算法所遇到的問題

順滑的筆跡演算法

以上的用兩條折線繪製筆跡的演算法被我稱為十字法筆跡演算法,這是一個簡單的演算法,無法作出順滑的筆跡效果。接下來將和大家介紹被我命名為米字法筆跡演算法的演算法

接下來介紹的米字法筆跡演算法是為觸控設計的筆跡書寫軌跡演算法,可以實現比較順滑的筆跡繪製效果,同時可以有多組引數可配置,配合高階擬合函式可以寫出特別多不同的筆跡效果,比如毛筆字、鋼筆字等

以下介紹的演算法被我申請了專利保護,現在專利已經公開授權,我就不放出來具體的程式碼了。原本專利裡面是有詳細公開資訊的,但是專利本身寫得難以閱讀,為了讓大家能夠更清晰知道具體的筆跡實現演算法,我就準備使用更白話的方式向大家介紹演算法內容

必須提醒大家的是,如果在商業軟體上使用,必須繞過本文接下來介紹的方法,本文接下來介紹的方法只能借鑑不能抄哦。不然等你大賺時,法務小姐姐會去找你麻煩的

當然,非商業用途等不怕專利的情況,那就隨意咯

接下來介紹的方法按照順序分別是 CN109284059ACN115373534A 兩篇專利裡面包含的內容,你可以認為本文只是記錄讀了以上兩篇專利之後的自己所學到的內容。我自己釋出部落格在我自己的非盈利非商業的部落格上是可以的,屬於非營利實施,但是如果有夥伴想要在商業用途上轉載本文,那就是侵犯專利的權利,違法的哦

本文以下介紹的演算法部分只介紹大概思路,不會包含具體實現細節以及程式碼,更詳細的實施方法還請自行參閱專利的內容

本文以下的演算法將預設是為觸控設計的筆跡書寫軌跡演算法,輸入的原始點被稱為原始觸控點。觸控點資料將包含 X Y 資訊,以及可選的壓感和寬度高度資訊,還有一個隱含的速度資訊。如下圖,藍色的點就是觸控過來的觸控點資訊,觸控點是一些離散的點。我這裡產品裡主打的觸控框都是紅外觸控框,紅外觸控框從原理上也只能獲取到離散的觸控點,但如果點足夠密,那將離散的點視為連續的線段也是沒有問題的

在進入實際演算法之前,還需要進行一步點的過濾。也就是將一些奇怪的點給過濾掉,比如在一些渣觸控框上,可能存在報點存在離群點的情況,或者是出現在 0 0 點的情況,需要自己根據具體的硬體裝置進行丟點處理。這一步不是必須的,基本只有在大螢幕觸控框下才需要進行

骨架計算

完成點集的處理之後,即可開始計算筆跡的骨架。可以將筆跡骨架認為是一個最簡單展示一段順滑筆跡的軌跡,也就是當筆跡各處的粗細都一致時,即沒有稜角和筆鋒時的一段幾何軌跡。實際上的演算法後續的稜角和筆鋒、跟隨壓感變更等等都是在筆跡的骨架的基礎上,修改筆跡某一段的粗細變化。骨架的計算十分簡單,可以採用貝塞爾等演算法將收到的觸控點進行平滑計算,此過程如果需要補點,即在觸控點不夠密集時進行補點,則可以自己再疊加一些魔改的貝塞爾演算法,比如 一種簡單的貝塞爾擬合演算法_貝塞爾曲線擬合-CSDN部落格 介紹的方法

一般是將收集到的觸控點每兩個點的中心做定點,使用收集到的觸控點做控制點,如下圖

對於許多業務情況來說,只需要到這一步就可以算畫出一段平滑的筆跡了

接下來的步驟將和大家介紹如何畫出更好看的筆跡效果

稜角最佳化

稜角最佳化步驟是一個專門為中文書寫筆跡軌跡最佳化的方法。用途是讓寫出來的漢字比較有稜角,適合使用者手寫類似黑體或楷體,不適合用在草書的情況。大概的演算法思路如下,假定有類似如下的輸入觸控點

這時需要把這些點分為兩個線段,分為兩個線段的大概效果如下圖

對於漢字而言,我認為如果以上兩個線段構成的內角在 90 度以下時,有稜的好看,超過 90 度時,使用圓角的好看

透過輸入可以拿到觸控點,按照兩個觸控點連線為線,求相鄰線段的夾角,判斷角度可以知道使用者是否希望畫出稜還是畫出圓。加上這個最佳化之後就可以在寫漢字時,比微軟預設的 WPF 或 UWP 的筆跡演算法在稜角方面處理更好

如圖的 α 就是兩個線段的角度,判卷角度如果大於 90° 就是使用者希望畫圓的角,使用貝塞爾演算法。如果小於 90° 那就可以判斷使用者希望畫有稜的,直接把點分開為兩個線段

當然了,上文提到的 90° 是我自己測試發現的數值,大家可以根據自己的實際需要修改引數。在不需要讓筆跡有筆鋒以及跟隨壓感時,以上的稜角最佳化步驟可以用在骨架計算的步驟上,直接作用到使用骨架繪製出的筆跡上。也可以在帶壓感時的在下文繼續介紹的更復雜的米字法筆跡演算法的最後呈現時使用

筆跡軌跡寬度最佳化

無論是否有壓感,都可以應用上筆跡軌跡寬度的最佳化,筆跡軌跡的寬度可以認為是在骨架的基礎上,進行填充,讓原本只有骨架的很細的筆跡變粗。可以認為在骨架計算步驟拿到的是一條沒有寬度的線條,進行筆跡軌跡寬度最佳化計算就可以畫出更好看的筆跡效果。比如說寫一個漢字的“一”字,就可以寫出兩端寬度比較大,中間寬度比較小的筆效果

簡單的筆跡軌跡寬度最佳化演算法大概如下,下面將會用到一點點公式,相信大家一看就明白,以下使用到的公式

使用者可以設定筆跡軌跡線條的寬度,這個設定的寬度為初始寬度,將使用者設定的筆跡粗細寬度記為 T 引數。速度引數 v 的計算有些取巧,因為收集到的點的時間間隔是隻有很小的誤差,為了最佳化計算,就把兩個點直接的距離作為使用者的畫線速度

上圖公式裡面的 u(v) 函式計算方法就是取使用者正常最慢速度,記為 w 值,這裡的 w 為常量 1 的值。為了防止在靜止距離獲得最小的點為負數,這裡使用 u(v)=Max(v-w,x) 限制最小值為 x 的值,按照經驗,這裡取 x 為常量 2 的值。為了防止使用者的畫線速度太快,所以按照經驗取最高的速度只能是 5 的值。以上的效果就是在使用者書寫速度超過最高速度 5 單位長度 1 毫秒的時候取 80% 的使用者設定粗細。在使用者使用很慢速度畫線的時候採用120%的使用者設定粗細

最後的常量 a 我按照經驗取的是 T/0.12 的值

以上的常量部分指的不是 C# 裡面的常量,而是參與數學計算公式裡面的常量,即和自變數對應的常量。這些常量大家都可以根據自己的經驗進行修改,或者寫一個修改引數的工具讓美工或設計師去最佳化

經過這一個步驟之後,就可以實現在使用者使用快速畫線,畫出來的線就會變細,在使用者畫線的速度變慢,就會畫出寬度比較大的線

米字法

這部分屬於寫出順滑的筆跡的核心演算法。在經過了筆跡軌跡寬度最佳化之後,儘管看起來已經有些順滑了,其實依然無法寫出毛筆字效果,比如刀鋒等效果,最多隻能寫出有粗細變更的筆跡。接下來的演算法部分將使用到稜角最佳化步驟處理的骨架軌跡算出的骨架點,以及筆跡軌跡寬度最佳化步驟輸出的每個點的筆跡粗細大小資訊,進行更高階的最佳化

透過上文的描述,大家也知道筆跡元素可以由筆跡輪廓兩邊的曲線組合而成,因此求筆跡的幾何圖形本質就是求筆跡的輪廓線,由筆跡的輪廓線填充即可獲取筆跡。如下圖,只需要將如下兩條曲線相連線,那麼將獲得一條筆跡的幾何圖形

在經過骨架計算步驟之後,即可拿到骨架軌跡,透過骨架軌跡即可拿到相應的骨架點。拿到相應的骨架點的演算法不固定,可以是求均勻的距離下的骨架軌跡上的點,也可以求對原始觸控點的骨架校正點。如果難以理解如何透過骨架軌跡拿到相應的骨架點,那可以將骨架點當成原始的觸控點來看,因為缺少骨架點這一步不用影響對接下來的演算法的理解

如下圖,假定以下拿到的藍色的點就是骨架點

根據觸控點的每個點的狀態可以決定骨架點的每個點的狀態,對應的就是每個點的上下左右邊距,如下圖。決定每個點的上下左右邊距演算法叫做慣性邊距演算法,這個慣性邊距演算法將放在下文再描述

經過了慣性邊距演算法,可以獲取骨架點的上下左右邊距,取邊距的端點,作為筆廓點。如下圖,筆廓點就是藍色的圓圈

如下圖,連線筆跡的筆廓點就可以獲得筆跡的輪廓線,也就是獲得筆跡的幾何圖形。但僅僅採用如上述演算法,可以看到筆跡的輪廓相對粗糙,雖然比上文給的演算法好了一點,但也沒好多少。想要實現更好的效果,還需要繼續新增更多邏輯

在開始介紹演算法之前,需要引入不對稱橢圓的概念,預設的橢圓都是對稱的,如上下對稱或左右對稱。而不對稱橢圓是上下左右都不對稱的橢圓。如下圖,從不對稱橢圓的圓心的上下左右四個方向有著不同的長度

不對稱橢圓的演算法相當於繪製出四個對稱的橢圓,分別取其中的四分之一拼接起來的橢圓

如下圖,是將繪製出來的四個對稱的橢圓各取四分之一部分拼接起來,其中填充部分就是非對稱橢圓

這裡的非對稱橢圓是用在將筆跡的骨架點按照慣性邊距演算法上下左右分別採用不同的長度,建立出來的橢圓

沿著橢圓的切線方向連線的線段就可以作出平滑的筆跡輪廓線,如下圖。下圖繪製僅僅只是參考,部分線段連線不是採用橢圓的切線

特別的,為了效能最佳化部分,因為筆跡的粗細一般都很小,在筆跡粗細很小的時候,可以使用多邊形近似代替橢圓。因為對多邊形的求值計算的效能要遠遠高於橢圓,同時求橢圓切線的程式碼也不好寫。如下圖,採用如 米 字的方式代替橢圓

只需要連線橢圓的外接輪廓點即可作出筆跡效果,如下圖

當骨架點足夠密集的時候,這時候連線橢圓的外接輪廓點使用線段連線,再將這個線段組成閉合的折線即可寫出十分順滑的筆跡效果了。經過我的實際測試,透過骨架軌跡算出比較密集的骨架點,從而讓外接輪廓點連線畫出的筆跡效果,既順滑且渲染效能高。在骨架點不夠密集時,如直接將觸控點當骨架點時,可以使用貝賽爾曲線形式連線外接輪廓點,從而畫出順滑的筆跡效果,但經過實際測試我發現此方法無論是筆跡的順滑還是渲染效能都不如讓骨架點足夠密集的方法

此演算法除了能夠讓筆跡效果十分順滑之外,還能實現筆跡刀鋒效果。核心實現是根據慣性邊距演算法可以決定邊距,透過邊距的不同,可以實現出如毛筆的刀鋒效果,如下圖所示。在運筆繪製刀鋒效果時,如圖情況將會更改左邊距距離,讓筆跡的一邊貼近直線而另一邊是曲線的效果。採用此演算法可以做到更好的寫出毛筆字效果

慣性邊距演算法就是透過一系列的程式碼處理,決定每個骨架點的上下左右邊距的值,比如運動軌跡方向,比如運動速度,比如預測字形等等。這部分更多的是靠設計師或美工進行最佳化

以下是我給出的一個認為簡單的演算法例子,大家也可以自行發揮

在筆跡軌跡寬度最佳化的基礎上,將筆跡軌跡寬度最佳化的輸出結果作為筆跡粗細參考值。將每個骨架點的上下左右邊距先採用筆跡粗細的一半作為基準值,然後分別附加各自的縮放係數。根據筆跡的運動軌跡方向,可以將方向分為上下左右四個方向,再按照運動的速度以及多個筆跡點的偏移累計值決定縮放係數的值。如上圖,按照筆跡軌跡是向左下方向,將會取筆跡的多個觸控點,計算累計的偏移值,如取筆跡的距離當前的前n個觸控點,如上圖是取5個觸控點的座標,求出距離當前座標的偏移值也就是相當於求當前點和前第5個點的距離。如果在這前5個觸控點中,有方向不一致的觸控點存在,如第三個觸控點的方向和其他點的觸控方向不同,那麼將偏移值減去方向不一致的觸控點的相對於其下一個觸控點的距離。再根據觸控偏移值決定對應方向的縮放係數,決定縮放係數的方法就是取n個觸控點的對應方向的最大距離數,如發現是存在左右方向的偏移那麼取水平方向距離值,將距離值減去偏移值除的值處以距離值乘以給特定觸控框最佳化的常數,即可獲取方向上的觸控偏移縮放係數。根據不同的上下左右邊距的不同縮放係數就可以實現如上圖的效果。同樣的,採用此方法進行不同縮放係數最終還是需要乘以筆跡觸控點壓感變化的縮放係數,才是最終的各個方向的縮放係數

透過以上的演算法即可實現比較好看的筆跡效果

本文只討論了筆跡的演算法,而不包含如何最佳化筆跡繪製的效能以及更多的觸控相關內容。如果大家對這部分感興趣,請參閱 WPF 觸控相關

相關文章