给一个矩形加上随机抖动,很容易得到“手绘感“。难的是让它看起来像被画出来,而不是被晃出来。@violetjs/inkline 是 violet 仓库里的一个小型库,约 10 KB gzip,只有一个依赖,却把这件事拆得相当清楚。本文顺着一次 draw() 调用,读一遍它的源码。

先看它能画成什么样。下图是库自带的示例:输入只是中心线和色块这样的矢量数据,用 G 笔勾线、淡彩上色。

用 G 笔勾线并淡彩上色的咖啡少女插画

想亲手调参数,可以打开站内的手绘绘图库对照页:它把 Rough.js 与 inkline 用同几何、同 seed 并排渲染,可以切换笔、手法、填充和随机种子。

整体管线

一次绘制只做一件事:把场景里的每个图元,依次变成“笔画“,再交给“笔“变成墨迹。

flowchart LR
    P["Path 路径"] --> F["flatten 展平为骨架"]
    F --> H["Hand 手法: 规划并运动笔画"]
    H --> N["Pen 笔: 笔画变墨迹"]
    N --> B["InkBatch 中间表示"]
    B --> S["SVG 渲染"]
    B --> C["Canvas 渲染"]

几个职责边界值得留意:

  • Hand(手法) 决定“怎么走“:哪里断笔、哪里越界、速度与压力如何变化。
  • Pen(笔) 决定“留下什么“:定宽线、渐细的墨线、半透明的马克笔。
  • InkBatch 是两者与渲染器之间的中间表示,颜色只记录角色(role),渲染时才查调色板。因此换色不需要重新生成。

入口 draw() 在 src/draw.ts 中只有一百多行,逻辑是:展平路径,让手法生成描边笔画,让填充生成排线或色块,再让笔把所有笔画变成批次,最后把每个批次的时间归一化到 [0, 1]。

确定性:随机不依赖调用顺序

手绘风格库常见的一个麻烦:改了一个图形,整张图所有线条都变了,因为它们共用一个随机数序列。inkline 的做法是不用序列,而用哈希。

src/core/rng.ts 里,随机源由 seed 与图元 id 的哈希派生,取值时再混入“通道“与“序号“。同一个 (通道, 序号) 永远得到同一个值,与谁先被调用无关。下面是同一思路的独立实现(PCG 哈希,Jarzynski 与 Olano,2020):

node

预期输出三行:三个随机数、true、以及接近 0.500 的均值。

落到库里是这样的效果。我用真实的 draw() 做了一次检验:场景里有矩形 a 和圆 b,把 a 的宽度从 100 改成 120。

操作结果
相同 seed 绘制两次两个图元的输出逐点一致
修改 a 的尺寸a 变化,b 完全不变
换 seeda 变化

这是“每个图元以 id 为作用域“的直接结果:createRandom(seed, hashString(item.id))。填充还会再 scope() 一层,描边与排线互不干扰。

笔画规划:先想好怎么落笔

src/hand/plan.ts 的 planStrokes 是整个库最像“人“的地方。它不抖动任何点,只决定笔画的边界:

  • 转角按概率断笔,断开的两笔在转角处越界交叉一点点,像真的抬笔再落笔。越界长度有上限,并随笔段变短而按比例收缩,免得短笔段拖出毛刺。
  • 闭合曲线首尾错开:起点由随机相位决定,终点越过起点一小段(overlap,默认占周长 4% 到 10%),正是人画圆时那个“没对上“的接头。
  • 超长路径拆成多笔,接缝两侧各重叠半个线宽,避免缝隙。
  • 复画(passes):draft 手法每笔画两遍,第二遍标记为 pass = 1,之后由笔以更细更淡的方式绘制。

内置手法其实只是参数不同:

手法特点
neat低抖动,少断笔,适合图表与界面
natural默认值
draft大幅抖动,每笔复画一遍
inking漫画勾线:长线由 45 到 160 像素的短笔接成,笔间时而留白时而交叠

下图是同样的矩形分别用 neat、natural、draft、inking 画出的结果(从左到右)。注意 draft 的双线,以及 inking 在转角处的交叉越界:

同一组矩形用四种手法绘制的对比

我用同一个矩形加圆的场景跑过,笔画数和顶点数确实不同:natural 为 3 笔,draft 为 6 笔、顶点数翻了一倍,inking 同样 6 笔但顶点更少。

运动:最小 jerk 轨迹

规划只给出“从哪到哪“,src/hand/gesture.ts 负责“怎么走“。核心是一个五次多项式:

这是神经科学中描述人手到达运动的最小 jerk 模型(Flash 与 Hogan,1985):起点与终点的速度、加速度都为 0,中间速度呈钟形。源码里一行写完:

export function minimumJerk(t: number): number {
	return t * t * t * (10 + t * (6 * t - 15));
}

库用等间隔的 取控制点,再映射到弧长,于是控制点在笔画两端密、中段疏。这既让笔画自然地“起笔慢、行笔快、收笔慢“,也让后面的动画回放自带速度感。下面验证钟形速度:

node

输出中速度先增后减,在 附近达到最大值 ,两端接近 0。

在这条轨迹上,库再叠加三层偏移,都沿法向施加:

  1. 低频噪声:幅度随笔长缓慢变化,波长约在 60 到 260 之间。
  2. 整笔弯曲:按“段“取正弦形,两端归零。注释里解释了原因:转角是轨迹的途经点,整笔弯曲或整体平移会把转角磨圆。
  3. 起落笔误差:在端部一段距离内平滑衰减,只加在端点上会因控制点密集而形成钩子。

另一个细节:控制点细分时会跳过含角点的区间,否则切线夹角永远超过阈值,会一直二分到线宽量级。角点前后只各补一个近邻控制点,转角依然锐利。

笔:把压力变成宽度

笔画的每个点都带着压力 :两端从 tip 经 smoothstep 升到 1,中间叠加一层低频起伏。压力如何变成线宽,由笔决定。

最简单的 fineliner 恒宽,直接用原生描边。其余三支笔共用 createRibbonPen,它调用 strokeRibbon(src/pen/ribbon.ts)求椭圆笔尖沿笔画的扫掠轮廓,输出一个带圆帽的多边形。宽度倍率是:

而“压感强度“在恒宽与这条曲线之间做线性插值,因此 createGPen({ pressure: 0.5 }) 就是“半压感“,取 0 退化为恒宽。四支内置笔的效果如下,从左到右依次是 fineliner(恒宽)、inkpen(两端渐细)、marker(半透明斜口笔尖)和 gpen(从发丝到粗线):

同一组矩形用四种笔绘制的对比

这里有几个很扎实的实现细节:

  • 笔尖轮廓取的是法向上的支撑点,对凸笔尖是精确的包络。
  • 大转角处在外侧补圆弧,内侧的自交交给 nonzero 填充规则吸收,不做额外的布尔运算。
  • 轮廓精简使用 Douglas-Peucker,但左右两侧同去同留(simplifyIndices 接受多条折线,取各条偏差的较大者),这样条带的左右点数永远相等。这一点后面做动画时才显出价值。

Douglas-Peucker 本身很短,下面是 Python 版本,用 101 个点的半圆演示容差的影响:

python

容差 0.05、0.25、1.0 分别保留 37、21、13 个点。库中的默认 precision 为 0.25。

填充:线与面

src/fill/ 提供了七种图案填充(排线、交叉排线、折线、点阵等)和一种淡彩填充 createWashFill。图案填充生成“引导线“,交给填充手法和笔去画,所以排线同样有手感。

多种图案填充的对比

淡彩填充更有意思,它模仿水彩上色:

  • 色块整体朝背光侧平移一小段,朝光一侧于是露出一圈纸白,这就是“高光“。
  • 色块边缘沿法向随噪声起伏,再裁剪回原形状,所以留白时宽时窄。
  • 背光侧加成簇的短排线。成簇靠噪声过滤扫描行实现:ctx.noise(PATCH, row * 0.3) < 0.12 的行被跳过。
  • 留白与排线都按图形厚度(2 × 面积 / 周长)换算,而不是包围盒,斜向的细长色块不会被留白吃光。

线稿加淡彩的组合,由 lineWashStyle 一步配好(勾线手法、G 笔、淡彩填充):

线稿加淡彩的效果

时间:同一份数据,既能画也能播

最后一个设计决定了动画能力。每个笔画点是四元组 (x, y, 压力, 时间),时间单位是弧长,不是秒。draw() 在末尾把每个批次的 spans 按图元总时长归一化到 [0, 1]。

于是回放变成纯函数:createSchedule 只根据图元的自然时长分配窗口,itemProgress 返回某时刻的归一化进度,渲染器按进度显示笔画的前缀。

模式行为
sequential图元依次落笔(默认)
parallel全部同时开始、同时结束
stagger按顺序错开起笔、互相重叠

我对前面那个两图元场景计算了总时长:顺序模式 1.25 秒,并行模式 0.74 秒。笔画的时间轴不随编排改变,窗口只是在外面套了一层。这也解释了为什么条带多边形要保持左右点数一致:按前缀显现一条变宽的笔画,只需要截取左右各前 个点即可。

这套时间轴还能用在真实图表上:@violetjs/inkline/dom 的 inkSvg 可以原地重画页面上已有的 SVG,保留它的布局、文字和颜色。下图是对 Mermaid 流程图重画后的结果:

被 inkSvg 重画成手绘风格的 Mermaid 流程图

此外,生成时有预算保护:maxVertices 默认 200 万,maxStrokes 默认 20 万。超出时抛出 BudgetExceeded 而不是静默降低细节,错误信息里直接写明“detail is never reduced automatically“。

值得借鉴的几点

回头看,这个库的设计有几处很克制:

  1. 随机用哈希,不用序列。 以图元 id 为作用域,局部改动不会扩散。
  2. 规划与运动分开。 先决定笔画边界,再决定轨迹,两者各自可调,也各自可测。
  3. 颜色只存角色。 同一份结果换调色板即可复用,SVG 与 Canvas 共用一个中间表示。
  4. 时间是数据,不是动画。 把落笔时间写进每个点,回放、暂停、倒放才不需要重画。
  5. 先防坏结果,再谈好看。 比如弯曲两端归零、端部抖动限幅、控制点细分跳过角点,每一条在源码注释里都写明了它防的是哪种瑕疵。

如果你想读源码,建议从 src/draw.ts 起步,然后依次读 hand/plan.ts、hand/gesture.ts 和 pen/ribbon.ts。核心不过千行,却已经能把“手绘“这件事讲得很清楚。

参考

  • Flash, T. & Hogan, N. (1985). The coordination of arm movements: an experimentally confirmed mathematical model. Journal of Neuroscience.
  • Jarzynski, M. & Olano, M. (2020). Hash Functions for GPU Rendering. JCGT.
  • Douglas, D. & Peucker, T. (1973). 折线精简算法。