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

本文將告訴大家一些筆跡算法,從用戶輸入的點集,即鼠標軌跡點或觸摸軌跡點等,轉換爲一個可在界面繪製顯示筆跡畫面的基礎數學算法。儘管本文標記的是 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 觸摸相關

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章