#frontend#data-visualization#developer-tools

Inkline:给 SVG 和 Canvas 一支「像人在画」的笔

Mizuki_Miru
Mizuki_Miru
2026年10月12日8 次阅读

@violetjs/inkline 是一个手绘风格的矢量绘图库:你给它图形或 SVG path,它规划笔画、运笔、施压、填色,输出可以是 SVG,也可以是 Canvas。

这篇文章回答四件事:它和「给线条加点抖动」有什么不同,核心做法出自哪里,和 Rough.js 比各有什么取舍,以及怎么上手、什么时候不该用。

手绘风格通常怎么做,哪里不对劲

想让图表有手绘感,最常见的做法是给每个顶点加随机偏移、把线画两遍。Rough.js 是这条路线最流行的实现。下面是同一个场景(圆角矩形、圆、三角形,斜线填充)分别用 Inkline 的 natural 手法与 Rough.js 画出来的结果:

Inkline natural 手法 + 签字笔

Rough.js,roughness 1.5

两者风格不同,这里不比谁好看,只看三个差异,它们是下面各节的出发点:

  1. 笔画的组织方式。 Rough.js 的每条边是一条被扰动的曲线,且默认重复一遍;Inkline 先把图形拆成「笔画计划」:长线分几笔,转角处可以断笔并越界,闭合图形在起笔处首尾相叠。
  2. 线的粗细。 Rough.js 输出的是等宽的描边;Inkline 的笔有压感,gpen 从发丝线渐变到粗线,线条两端可以收细(见下图 G 笔 + 草稿手法)。
  3. 随机从哪来。 它决定改一个图元时其他图元会不会跟着变,后面单独讲。

同一组图形,G 笔 + 草稿手法(每笔画两遍)

注:Rough.js 的 rectangle 没有圆角参数,上图它的矩形是直角;要圆角需要自己给出 path。这是两个库 API 的差别,不是质量差别。

特性一览

笔:四支内置,压感可调

同一组矩形换用四支笔

fineliner(签字笔,默认,恒宽)、inkpen(钢笔,起收笔收细)、marker(半透明扁头,重叠处加深)、gpen(漫画勾线用的软尖,压力从发丝到粗线)。参数用工厂函数调,如 createGPen({ pressure: 0.5 }),压感强度 0(恒宽)到 1。也可以用 createRibbonPen 自己写变宽笔。

手法:把几何变成笔画

同一组图形换用四种手法

neat、natural 适合图表和界面;draft 每笔画两遍;inking 在转角处抬笔、线条越界,像漫画线稿。createHand 可以逐项调参,例如 breakChance(转角断笔概率)、overshoot(越界长度)、maxStroke(单笔最长,超过就分几笔)。

填充与线稿淡彩

线稿淡彩风格的房子与太阳

填充有 hachure、cross-hatch、zigzag、dashed、dots、solid 等图案,线条由描边用的同一手法和笔画出。lineWashStyle 组合了勾线手法、G 笔与淡彩:每个图形只给一种基础色,淡彩朝背光侧错开,朝光一侧留出纸白,较大的图形在背光侧加短排线。

直接重画现成的图表

inkSvg 把页面上已经渲染好的 SVG(Mermaid、Graphviz、D3 的输出)原地换成手绘笔触,布局、文字、箭头和颜色保留,对同一张图重复调用不会叠出两层。它要读计算样式和尺寸,所以只能在浏览器 DOM 里用。

ts
import { gpen, styles } from "@violetjs/inkline";
import { inkSvg } from "@violetjs/inkline/dom";

// svg 必须已经在文档里
inkSvg(document.querySelector("#diagram svg")!, { style: { ...styles.draft, pen: gpen } });

绘制动画

createSchedule + createPlayer 让整幅图按笔画依次、同时或交错重放,可跳转、暂停、倒放。动画只改变「哪些部分可见」,不重新生成。

它怎么做到的

先规划笔画,再沿轨迹运笔

每一笔的位置曲线用最小 jerk 轨迹(minimum-jerk):τ∈[0,1] 时 10τ³ − 15τ⁴ + 6τ⁵,两端速度与加速度为 0。这是 Flash 与 Hogan 1985 年为人手点到点伸展运动提出的模型,速度呈钟形:起笔慢、中段快、收笔慢。它不是书写模型,Inkline 把它借来描述一笔的快慢,属于工程上的近似;源码里 minimumJerk 就是这个多项式。

转角的处理借用了「途经点」的思路(源码注释引用同一篇论文,但这里是工程做法,并非论文原模型):弯曲与复线偏离按段计算,各段两端归零,所以转角不会被整体弯曲磨圆。沿笔画再叠加低频与高频两层梯度噪声作横向抖动,起落笔的位置误差在端部一段距离内平滑衰减(以上是实现细节,见 src/hand/gesture.ts)。控制点之间用向心 Catmull–Rom(α=0.5)样条转成三次贝塞尔,切线夹角过大处细分,避免把原有的曲线抹圆。

随机数:按 id 派生,不按调用顺序

draw 的随机源由三样东西决定:seed、图元 id、内部通道名。id 与通道名先过 FNV-1a 变成整数,再用 PCG 哈希混合;噪声是带五次平滑插值的一维梯度噪声。因为随机值是「通道 + 序号」定位的,而不是从一个全局随机数流里依次取,所以和调用顺序无关。

下面是实际运行的对比。三个矩形 a、b、c 位置不变,在 a 和 b 之间再加一个 n:

id 作标识:插入前

id 作标识:插入后,a、b、c 笔画与上图完全相同

把三个图元的输出批次序列化后逐个比较:用 id 作标识时,a、b、c 三个图元的结果逐字节相同。如果改用数组下标当 id(插入前是 "0"、"1"、"2",插入后是 "0"、"1"、"2"、"3"),即 a=0、b=1、c=2 变成 a=0、n=1、b=2、c=3,位置没动的 b、c 的 id 变了,笔画就全变了(只有排在前面的 "0" 不变):

下标作 id:插入前

下标作 id:插入后,位置没动的两个矩形笔画已不同

结论很实际:id 要取业务上稳定的标识,不要用下标。对比 Rough.js:它也支持按形状设 seed,但我没有对它做「插入元素」的同类实验,所以这里不下结论。Inkline 的做法让配图可以进版本库:改一个节点,diff 里只会出现这个节点。

其余用到的算法

  • 曲线展平后用 Douglas–Peucker 精简轮廓。
  • 填充用扫描线求交,nonzero 与 evenodd 两种填充规则下孔洞都会留空。
  • 变宽笔画由「条带轮廓」生成多边形,不依赖 stroke-width;只有默认的 fineliner 以原生描边输出。

和 Rough.js 比

我在同一台机器上用 Node 对比了「1000 个带斜线填充的 120×64 矩形」的生成耗时,Inkline 生成并保留可渲染批次,Rough.js 用 generator() 生成绘图描述(均不含 DOM 与序列化):

Inkline 0.2.0Rough.js 4.6.6
1000 个 hachure 填充矩形生成耗时87 ms49 ms
最小用法打进 esbuild 包(minify + gzip)约 10.4 KB约 9.2 KB

单机、单次、没有预热,只代表这一组输入(Inkline 用 styles.natural + fills.hachure,Rough.js 用 roughness 1.5、fillStyle: hachure;打包体积是各自只导入最小用法、esbuild --bundle --minify 后 gzip 的结果);它说明的是量级,不是排名。可以确定的是:在「生成速度」这一项 Rough.js 并不落后,Inkline 的卖点不在这里。

InklineRough.js
随机性按 seed + id 派生,局部稳定(已实测)可按形状设 seed(据文档,未实测)
线条粗细压感笔,可变宽等宽描边
笔画模型规划笔画,转角断笔、越界、首尾相叠扰动曲线并重复绘制
现成 SVGinkSvg 原地重画据文档,没有对应功能,需自己重建图形
动画内置 schedule/player据文档,没有内置
输出SVG 与 CanvasSVG 与 Canvas

另外,仓库里的 bench/compare-rough.ts 用一组更完整的场景(直线、圆角路径、128 段三次曲线等)与 Rough.js 对照,想看更细的数字可以直接运行它。

上手

sh
npm install @violetjs/inkline
ts
import { draw, rect, styles, fills } from "@violetjs/inkline";
import { renderSvg } from "@violetjs/inkline/svg";

const drawing = draw(
  {
    width: 240,
    height: 150,
    items: [{ id: "card", path: rect(20, 20, 200, 110, 12), fillRole: "surface" }],
  },
  { style: { ...styles.natural, fill: fills.hachure }, seed: 35 },
);

const svg = renderSvg(drawing, { paper: "#fffdf7", ink: "#252332", surface: "#8f7ad1" });

要点:

  • 场景由图元组成,每个图元有稳定的 id、一条 path,以及可选的颜色角色 fillRole、strokeRole(默认 ink)。颜色角色在渲染时按调色板解析,所以换主题不用重新生成;mountSvg + updateSvgPalette 就是为此准备的。
  • 子入口按需导入:@violetjs/inkline/svg、/canvas、/dom。默认笔加 SVG 渲染器打包约 10 KB(gzip),只有一个依赖。
  • renderSvg 需要 Node 18+;mountSvg、drawCanvas、inkSvg 需要浏览器 DOM。

边界

  • 不是通用的草图风格库。 没有文字手写体:label 只在给定点居中绘制文字,不做排版和换行。
  • 体积有代价。 一个图元要规划笔画、做样条与扫描线,不适合每帧对成千上万个对象重新生成;先 draw 一次,再用 schedule 或调色板去变化。输出规模有上限(默认 200 万顶点、20 万笔画),超出时抛 BudgetExceeded,不会悄悄降级。
  • inkSvg 依赖浏览器。 它读取计算样式和尺寸,Node 里不能用。
  • 版本是 0.x。 0.2.0 是首个公开版本,1.0 之前小版本可能有破坏性变更。

参考

评论将在接近时加载。