吸引人的画笔效果

小的时候,从来没有使用过 软头丙烯马克笔,有一次去文具店, 我被画笔鲜亮的颜色所吸引,售货员告诉我,这个笔写的字,不会在纸张上有任何的渗透,而且是软头的,我心想,软头的?那不是写起字来像毛笔的感觉吗?而且竟然不渗透纸张?这么神奇,于是买了回来,哇~ 写字的体验真的太棒了,写出来的字,圆圆的,很饱满,略带毛笔的渗透边缘的感觉,还会错以为自己写字很好而爱上写字,好像给字体加上了美颜的效果,真的太喜欢了。
所以我在想:软件上在进行白板签名的时候,能不能也显示类似的效果呢?于是我打算尝试一下, 看看能不能签名的时候模拟出类似的效果, 哪怕有一点点的相似呢?
寻找参考 – Ougishi
首先,我先进行各种搜罗, 看是否已经有类似 笔迹优化 软件,进行了各种搜索之后,找到了一个名字为 ougishi.exe 的软件,这个软件的表现真的很让我吃惊,写出来的字体真的有行云流水的流畅丝滑的感觉,但是它有两个缺点:字体的效果不是实时的,写完之后要点击一下 变换 显示,也有可能是版本的限制吧,除了这一点,其他的真的是看起来很完美:


寻找参考 – Win10 画图
另外,Win10系统自带的绘画软件也有一些效果,它的自由线条画笔有9种效果,但是看起来真的很不美丽:

贝塞尔曲线画笔 有6种:

寻找参考 – WPF InkCanvas
另外WPF自带的的 InkCanvas 画板效果,看起来非常的流畅, 它在绘制的 过程中 线条是有折线感的,在绘制完成之后,立刻自动进行了优化,所以看起来整体上也基本是实时的。它最大的缺点 就是所有点的位置都是均匀的,除此之外,其他都非常棒,极其流畅自然 :
功能代码:
XAML:
<ink:InkCanvas x:Name="signatureCanvas" DefaultDrawingAttributes="{StaticResource DefaultDrawingAttributes}"/>XAML.CS:
signatureCanvas.DefaultDrawingAttributes.Color = Colors.Red;
signatureCanvas.DefaultDrawingAttributes.Width = 30;
signatureCanvas.DefaultDrawingAttributes.Height = 30;
signatureCanvas.DefaultDrawingAttributes.FitToCurve = true;
signatureCanvas.EditingMode = InkCanvasEditingMode.Ink;

目前我能找到比较好的效果,就以上这些,接下来我打算一步步进行尝试 。
从零开始尝试
根据查阅的资料,我了解到,在手写签名相关的设备上,有一些硬件是支持感应笔的压力,转弯的角度 等这些 与真实笔 接近的物理特性,但是我目前手头只有一个普通的触摸屏,它除了收集触摸的点的信息之外,没有任何其他数据可参考,因此我打算研究的前提是:普通的鼠标绘制,或者是普通的触摸屏绘制,因此我能依赖的信息,只有绘制在屏幕上的点,由此可以关联得到的信息是:根据点的记录时间来得到点的绘制速度,因此我研究的效果中,影响签名笔迹效果的是,每个点处的速度,根据不同的点速度显示为不同的形态
尝试1:观察点规律
那么在屏幕上签名的时候,实际补捉到的点,到底是什么样的形态呢 ?
先来收集点:
private bool is_down = false;
private Dictionary<PointF, DateTime> pt_time_dict = new Dictionary<PointF, DateTime>();
private void panel1_MouseDown(object sender, MouseEventArgs e)
{
is_down = true;
pt_time_dict.Clear();
pt_time_dict[new PointF(e.Location.X, e.Location.Y)] = DateTime.Now;
}
private void panel1_MouseMove(object sender, MouseEventArgs e)
{
if (!is_down) return;
pt_time_dict[new PointF(e.Location.X, e.Location.Y)] = DateTime.Now;
panel1.Invalidate();
}
private void panel1_MouseUp(object sender, MouseEventArgs e)
{
is_down = false;
}
private void panel1_Paint(object sender, PaintEventArgs e)
{
var keys = pt_time_dict.Keys.ToList();
for (int i = 0; i < keys.Count ; i++)
{
var rt = new System.Drawing.RectangleF(keys[i],new SizeF());
rt.Inflate(5, 5);
e.Graphics.FillEllipse(Brushes.Red, rt);
}
}

我们发现,在每一个点的位置,都绘制一个圆圈的话,线条的所有的位置看起来粗细都是相同的,只有在笔画 有交叉重叠的位置会显得更加的粗一些,但是实际上,我用马克笔以比较慢的速度绘制的时候,所有位置看起来都是大致粗细均衡的,但是当我在某个位置快速滑过去的时候,在结尾处,它有一个非常漂亮的尾巴,线条趋于变细,并且有稍微分叉的感觉,而且由于我有一种把笔画提过去冲劲儿,所以在提笔的时候,一般会有一点蓄力的感觉,所以会停顿的稍微久一点点,看到的颜色会更深,笔墨更饱满,笔划更粗,在笔画结束的位置,笔会逐渐抬起并离开纸张,接触纸的面积越来越小,所以笔画会越来越细:

尝试2:观察点速度
这里描述的特性稍微有一点点多 ,我无法一下全部关注它们。首先,我希望先了解每个点的速度,速度 等于 距离 除以 时间 ,所以我计划把每一点的速度 = 它与下一个点的距离 除以 它与下一点的时间差 ,单位分别是 像素 和 秒 :
绘制代码:
private void panel1_Paint1(object sender, PaintEventArgs e)
{
var keys = pt_time_dict.Keys.ToList();
var value = pt_time_dict.Values.ToList();
var speeds = new List<double>();
for (int i = 0; i < keys.Count - 1; i++)
{
var rt = new System.Drawing.RectangleF(keys[i], new SizeF());
rt.Inflate(5, 5);
e.Graphics.FillEllipse(Brushes.Red, rt);
var dist = ptDist(keys[i], keys[i + 1]);
var time = (value[i + 1] - value[i]).TotalSeconds;
var speed = dist / time;
speeds.Add(speed);
}
Debug.WriteLine("速度:" + string.Join("、", speeds.Select(a=>(int)a)));
}

尝试3:让速度影响点大小
忽略过高或过低的异常值,我们观察到,绘制速度 快和慢的情况下,范围 大概在50 到 10000之间 ,假设我们设置最高速度为5000,最低速度为50 ,它们对应到的笔画的粗细,分别映射到 最细为1个像素, 最粗为30个像素:
绘制代码:
private void panel1_Paint (object sender, PaintEventArgs e)
{
var keys = pt_time_dict.Keys.ToList();
var value = pt_time_dict.Values.ToList();
var speedMax = 5000;
var speedMin = 50;
var speedRange = speedMax - speedMin;
var radusMin = 1;
var radusMax = 30;
var radusRange = radusMax - radusMin;
e.Graphics.Clear(Color.White);
List<float> lst_radus = new List<float>();
for (int i = 0; i < keys.Count; i++)
{
var speed = 0d;
if (i == keys.Count - 1)
{
var dist = ptDist(keys[i], keys[i - 1]);
var time = (value[i] - value[i - 1]).TotalSeconds;
speed = Math.Min(speedMax, dist / time);
}
else
{
var dist = ptDist(keys[i], keys[i + 1]);
var time = (value[i + 1] - value[i]).TotalSeconds;
speed = Math.Min(speedMax, dist / time);
}
var pct = 1 - (speed - speedMin) / (float)speedRange;
var r = (float)Math.Round(pct * radusRange + radusMin);
r = Math.Max(r, 1);
lst_radus.Add(r);
}
//绘制
e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
for (int i = 0; i < keys.Count - 1; i++)
{
var r = lst_radus[i];
var rt = new System.Drawing.RectangleF(keys[i], new SizeF());
rt.Inflate(r, r);
e.Graphics.FillEllipse(Brushes.Red, rt);
}
}

这个时候,我们看到:在慢速绘制的时候,圆圆的,有可爱的感觉了,在快速绘制的时候,也能够看到粗细的区别了。
尝试4:让线条更平滑
但是有一些问题,在快速绘制的时候,相邻的两个点大小差异太大,而且点的位置,有点乱,很不平滑,因此 因此需要添加对它们的平滑处理。
对于每个点的大小, 由与它相邻的前后两个点的大小做平均平均值,每个点的位置也由它前一点和后一点的x和y的均值来得到,这是1次平滑的计算,我们进行连续10次平滑计算,看一下效果:
平滑处理代码:
private void panel1_Paint (object sender, PaintEventArgs e)
{
var keys = pt_time_dict.Keys.ToList();
var value = pt_time_dict.Values.ToList();
var speedMax = 5000;
var speedMin = 50;
var speedRange = speedMax - speedMin;
var radusMin = 1;
var radusMax = 30;
var radusRange = radusMax - radusMin;
e.Graphics.Clear(Color.White);
List<float> lst_radus = new List<float>();
for (int i = 0; i < keys.Count; i++)
{
var speed = 0d;
if (i == keys.Count - 1)
{
var dist = ptDist(keys[i], keys[i - 1]);
var time = (value[i] - value[i - 1]).TotalSeconds;
speed = Math.Min(speedMax, dist / time);
}
else
{
var dist = ptDist(keys[i], keys[i + 1]);
var time = (value[i + 1] - value[i]).TotalSeconds;
speed = Math.Min(speedMax, dist / time);
}
var pct = 1 - (speed - speedMin) / (float)speedRange;
var r = (float)Math.Round(pct * radusRange + radusMin);
r = Math.Max(r, 1);
lst_radus.Add(r);
}
for (int t = 0; t < 10; t++)
{
//平滑位置
var lst_loc_rst = new List<PointF>();
for (int i = 0; i < keys.Count; i++)
{
if (i == 0 || i == keys.Count - 1)
{
lst_loc_rst.Add(keys[i]);
continue;
}
lst_loc_rst.Add(new PointF(
(keys[i - 1].X + keys[i ].X + keys[i + 1].X) / 3f,
(keys[i - 1].Y + keys[i ].Y + keys[i + 1].Y) / 3f
));
}
keys = lst_loc_rst;
//平滑大小
var lst_radus_rst = new List<float>();
for (int i = 0; i < lst_radus.Count; i++)
{
if (i == 0 || i == lst_radus.Count - 1)
{
lst_radus_rst.Add(lst_radus[i]);
continue;
}
lst_radus_rst.Add(
(lst_radus[i - 1] + lst_radus[i]+ lst_radus[i + 1]) / 3f
);
}
lst_radus = lst_radus_rst;
}
//绘制
e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
for (int i = 0; i < keys.Count - 1; i++)
{
//以点为中心绘制
//var r = lst_radus[i];
//var rt = new System.Drawing.RectangleF(keys[i], new SizeF());
//rt.Inflate(r, r);
//e.Graphics.FillEllipse(Brushes.Red, rt);
//以点为左上点绘制
var r = lst_radus[i];
var rt = new System.Drawing.RectangleF(keys[i], new SizeF(r,r));
e.Graphics.FillEllipse(Brushes.Red, rt); }
}

我们可以看到,经过平滑处理后,在慢速绘制的时候看起来非常漂亮,尤其是以点为 左上 绘制的时候,起笔的笔锋位置,非常有感觉;但是在快速绘制的时候,有效点断开了,接下来我们要把点进行补满。
尝试5:补齐线条中的缺失点
在补满点之前,正常绘制一笔(或者偏慢),一笔大概是30个点到50个点 ,但是现在 补充点之后,点数增加到了500个点左右,此时绘制的时候,明显速度变慢,因为圆圈数量过多导致ui卡顿 从而影响页面点采集,导致实际采集点数只有10点左右,甚至更少,补齐后的线条也呈现出明显的线性特征,显得非常僵硬,并且绘制的时候严重闪烁,

这个时候有 3 个方案可以考虑:
方案 1 :使用鼠标钩子,将点的采集 与点的补齐算法、和ui绘制进行分离,使原始点的采集完全不受任何卡顿的影响,我对这种方案进行了尝试,是基本可行的,但是要处理相对位置(鼠标钩子是相对屏幕全局的位置),而且还要使用钩子机制,我个人不太倾向于这个方案;
方案 2 :进行离屏绘制,仍然是通过ui采集点,但是采集完成之后,对点的补齐和绘制在另外一个线程处理,UI重绘的时候直接绘制这个缓存图, 这个方案我也进行了尝试,但是会闪烁严重,并没有明显的提速;
方案 3 :这是一个设置起来很简单的方案,其实它类似于批量提交UI更新, 就是开启双缓冲机制, 实验证明:这是一个既简单又靠谱的方案,效果非常的好:
开启双缓冲,同时将点的计算放在单独的线程处理(点的补齐算法让AI写的,调教多次后出来的),然后调整显示方式使它错位显示,便于比对:
private void panel1_Paint(object sender, PaintEventArgs e)
{//检测双缓冲if (!enbale_doublebufferd)panel1.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(panel1, true, null);//单独线程计算补齐点CheckPtsLoop();e.Graphics.Clear(Color.White);e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;stopwatch.Restart();foreach (var r in darw_rect_fill){e.Graphics.FillEllipse(Brushes.Red, r);}stopwatch.Stop();Debug.WriteLine("绘制补充点耗时毫秒:" + stopwatch.ElapsedMilliseconds);stopwatch.Restart();foreach (var r in darw_rect_init){e.Graphics.FillEllipse(Brushes.Green, r);}stopwatch.Stop();Debug.WriteLine("绘制原点耗时毫秒:" + stopwatch.ElapsedMilliseconds);if (true){//错位绘制,用于比对效果 - 原始 foreach (var r in darw_rect_init) e.Graphics.FillEllipse(Brushes.Red, r.X, r.Y - 150, r.Width, r.Height);//错位绘制,用于比对效果 - 合成{foreach (var r in darw_rect_fill) e.Graphics.FillEllipse(Brushes.Green, new RectangleF(r.X, r.Y + 150, r.Width, r.Height));foreach (var r in darw_rect_init) e.Graphics.FillEllipse(Brushes.Green, r.X, r.Y + 150, r.Width, r.Height);}}Text = "原点数:" + darw_rect_init.Count + ",补充后:" + darw_rect_fill.Count;
}
(每个图片中展示的3个分别为原始点,原始点+补充点分色显示、融合显示 )


开启了双缓冲之后的效果非常神奇,慢速绘制的效果非常好看,有粗有细,起笔有锋,这完全只根据了点的速度出来的效果,但是也明显地看到了一些问题:
问题 1:在非常快速绘制的时候,当前是按照 平均位置 来补齐的点,呈现出明显的线段形态,这里应该优化为贝塞尔曲线,
问题 2:目前只能看到最简单的笔锋效果,但实际上 软头丙烯马克笔 在进行绘制的时候,在不同笔画中的位置, 所呈现的形态是不一样的。比如我们在写笔画 捺 的时候,在拖尾的地方,笔的倾斜方向基本上是固定的,在绘制 捺 和 撇 的时候,尾巴的地方并不是像上面图片中简单的拖个细尾巴,这样其实不好看,它有一个 更浑厚饱满的尾巴,最后再加上一个小尖尾峰,稍微有一点毛笔感觉的样子。
因此接下来要做的工作是,在简单笔画的条件下,对不同的单笔画,加入一些特性的模拟。
等待后续更新。

C# - Winform , 模拟 软头丙烯马克笔 笔迹效果 的尝试。