如何模拟手绘形状-RoughJS背后的算法

2020-05-24 05:16:47

RoughJS是一个小型(<;9kB)JavaScript图形库,允许您以粗略的手绘风格进行绘制。它允许您在<;Canvas>;和SVG上绘图。这篇博文是为了解决RoughJS最常见的问题:它是如何工作的?

我被手绘的图表、图表和草图的图像迷住了;和我一样,我也是一个真正的书呆子,我想知道如果有一种方法可以通过代码画出这样的数字会怎么样。尽可能地模仿手绘,但又要清晰和可编程。我决定专注于基本体-直线、多边形、椭圆和曲线,创建一个完整的2D图形库。图表/图形库和应用程序可以构建在它的基础上。

经过快速研究,我看到了这篇由Jo Wood和其他人撰写的论文,标题为“用于信息可视化的粗略渲染”(Sketchy Render For Information Visualization)。这里描述的技术构成了该库的基础,尤其是用于绘制线条和椭圆的技术。

我在2017年写了第一个版本,只在画布上工作。一旦问题解决了,我就失去了兴趣。一年后,我开始大量使用SVG,并决定调整RoughJS以同样使用SVG。我还重新设计了API,使其更加基本,并将重点放在简单的矢量图形基元上。我分享了2.0版的黑客新闻,令人惊讶的是,它爆炸了。这是2018年ShowHN第二受欢迎的帖子。

从那以后,人们用RoughJS-Excalidraw,为什么猫和狗…,roughViz制图库,等等创造了一些令人惊叹的东西。

模仿手绘形状背后的基本概念是随机性。当你手绘任何东西时,没有两个形状是完全相同的。没有什么是确切的。因此,RoughJS中的每个空间点都通过随机偏移进行调整。随机性的大小由一个称为粗糙度的数值参数来描述。

想象一个点A,并在它周围画一个圆。现在,A被该圆内的一个随机点替换。此随机圈的区域由粗糙度值控制。

手绘的线条从来不是直的,通常会形成弯曲度(这里描述)。我们根据粗糙度随机化直线的两个端点。然后我们还沿着这条线在50%和75%的标记周围挑选另外两个随机点。通过曲线连接这些点可以产生弯曲效果。

手绘时,人们有时会在线上快速来回走动。这可以是为了突出显示线条,也可以仅仅是为了调整线条的直线度。它看起来像这样:

为了赋予这种额外的粗略效果,RoughJS绘制了两次线条,使其感觉更粗略。我确实计划在未来使其更具可配置性。

手绘时,较长的线条往往不那么笔直,而更弯曲。因此,产生弯曲效果的偏移量的随机性是直线长度和随机值的函数。然而,这并不适用于排得很长的队伍。例如,在下图中,同心正方形是用相同的随机种子绘制的-也就是说,它们本质上是相同的随机形状,但缩放比例不同。

您会注意到,外部方块的边缘往往比内部方块的边缘看起来更粗糙一些。因此,还会根据线路的长度添加阻尼系数。阻尼因子以阶跃函数的形式应用于不同的长度。

拿起一张纸,用一个连续的动作,以最快的速度画出一串圆圈。这是我做这件事时的样子:

请注意,除非您非常小心,否则循环的起点和终点实际上不会相交。RoughJS试图在使其看起来更完整的同时实现这一点(改编自giCenter论文)。

该算法在椭圆上找到n个点,其中n由椭圆的大小决定。然后根据每个点的粗糙度随机化每个点。然后通过这些点拟合一条曲线。为了达到目的不相交的效果,倒数第二个点不与第一个点相遇。相反,它会连接第二个和第三个点。

还绘制了第二个椭圆,以使其具有更闭合的环路和额外的粗略效果。

与线条的情况一样,如果将相同的形状缩放到不同的大小,其中一些人工产物会变得更加突出。在椭圆中,这一点更为明显,因为这种关系本质上是二次关系。在下图中,请注意圆圈的形状相同,但外部圆圈看起来更粗糙。

该算法现在通过估计圆上的更多点(N)来根据形状的大小自动调整自身。以下是使用自动调整生成的同一组圆。

填充手绘形状的一种常见方法是使用悬挂线。与手绘草图一样,线条不会停留在形状的轮廓内。他们也是随机的。线条的密度、角度、宽度都是可配置的。

像上面的例子一样填充正方形是很容易的,但是在填充各种形状时会遇到一些麻烦。例如,凹多边形(角度可以大于180°)通常会导致以下问题:

事实上,上面的图片来自早期版本的RoughJS中的错误报告。从那时起,我已经将hachure填充算法更新为扫描线填充技术的改编版本。

扫描线填充算法可用于填充任何多边形。其想法是使用水平线(扫描线)扫描多边形。扫描线从多边形的顶部到底部。对于每条扫描线,我们确定扫描线与多边形相交的点。我们从左到右排列这些交叉点。

当我们从一个点转到另一个点时,我们从填充模式切换到非填充模式;当我们遇到扫描线上的每个交叉点时,在状态之间切换。这里还有更多需要考虑的内容,特别是边缘情况和如何优化扫描;有关这方面的更多信息可以在这里找到:栅格化多边形,或展开伪代码的以下部分。

👉🏼首先,按Y最小值排序的所有边的全局边缘表(ET)。如果边具有相同的Y最小值,则它们将按其X最小坐标值进行排序。

👉🏼第二,活动边表(AET),其中仅保留与当前扫描线相交的边。

interface EdgeTableEntry{ymin:number;ymax:number;x:number;//初始化为Xmin iSlope:number;//直线斜率的反转:1/m}interface ActiveEdgeTableEntry{scanlineY:number;//扫描线边缘的y值:EdgeTableEntry;}。

1.将y设置为ET中最小的y。这表示当前扫描线。

(B)从y=y max的AET条目中删除,然后对x上的AET进行排序。

(C)使用来自AET的成对x坐标填充扫描线y上的像素。

(D)增量y,增量为由孔洞密度定义的适当值,即下一条扫描线。

(E)对于AET中剩余的每条非垂直边,更新新y的x(edge.x=edge.x+edge.iSlope)。

对于Hachure填充,基于指定的Hachure线密度逐步递增扫描线,并且使用上述线条算法绘制每条线。

然而,该算法是为水平扫描线设计的。为了实现各种缝隙角度,该算法首先将形状本身旋转所需的缝隙角度。计算旋转形状的扫描线。然后,计算出的直线以反方向的斜度旋转回来。

RoughJS还支持其他填充样式,但它们都派生自相同的hachure算法。交叉线是以一定角度绘制曲线,然后以+90°的角度再次绘制。一条之字形线路试图将一条架空线路与前一条线路连接起来。沿着衣架线画小圆圈,画一个虚线图案。

RoughJS中的所有内容都被规格化为曲线。直线、多边形、椭圆等,所以创建一条粗略的曲线是很自然的延伸。在RoughJS中,您在曲线上提供一组点,曲线拟合用于将这些点转换为一组三次Bezier曲线。

每条贝塞尔曲线都有两个端点和两个控制点。通过根据粗糙度随机化这些曲线,可以用同样的方式创建粗略的曲线。

然而,填充曲线需要相反的要求。不是将所有内容规格化为曲线,而是将曲线规格化为多边形。一旦有了多边形,就可以使用扫描线算法填充曲线形状。

人们可以使用三次Bezier曲线方程以所需的速率对曲线上的点进行采样。

使用基于孔洞密度的采样率可以提供足够的点来填充形状。但是它的效率不是很高。如果曲线的部分很尖,你会想要更多的点。如果曲线的部分几乎是一条直线,你就会想要更少的点。一种技术是计算曲线的弯曲/平坦程度。如果曲线太弯曲,可以把曲线分成两条较小的曲线。如果它是平的,那么就把它当作一条线。

曲线的平坦度是使用这篇博客文章中描述的方法计算的。将平坦度值与公差值进行比较,以决定是否分割曲线。

仅基于公差,该算法就很好地提供了足够的点来表示一条曲线。然而,它并不能有效地去除不需要的分数。第二个参数,距离对此有帮助。该技术使用Ramer-Douglas-Peucker算法来减少点数。

根据形状的粗糙度,可以设置适当的距离值。一旦拥有了多边形的所有顶点,曲线形状就可以很好地填充:

SVG路径非常强大,可以用来创建各种令人惊叹的图画,这也使得使用它们有点棘手。

RoughJS解析路径并将路径规格化为三个操作:移动、直线和三次曲线。(路径-数据-解析器)。一旦标准化,就可以使用上面描述的绘制直线和曲线的技术来绘制形状。

路径上的点软件包将路径规格化和曲线点采样相结合,以估计路径上的相应点。

我经常分享的SVG示例是一张美国的粗略地图:

访问网站或Github回购或API文档。在Twitter@RoughLib上关注该项目。