手绘不只是随机抖动:Inkline 如何生成可重放的笔墨
给流程图加一点手绘质感,最直接的办法是把坐标随机挪一挪。但矩形的四个角歪了,不等于它看起来像一笔一笔画出来的:线条可能始终一样粗,转角没有抬笔痕迹,第二遍描线只是第一遍的等距副本。随机改变了形状,却没有解释落笔的过程。
如果还想播放绘制动画,问题会更明显。每帧重新扰动坐标,得到的是不断抖动的图,而不是逐渐完成的画。手绘效果需要同时回答两个问题:这条线是怎样画出来的,以及同一次绘制如何被稳定地重放。
@violetjs/inkline 把这两件事放在同一条生成流程里:输入几何先成为带压力和时间的笔画,再由笔生成可渲染的笔墨。SVG 和 Canvas 消费的是生成后的结果,而不是各自再随机画一遍。
从轮廓到笔画,中间缺的是什么
几何描述的是“应该画在哪里”。一个圆角矩形可以是一条闭合路径,但画它时不一定从固定角落起笔,也不一定一口气绕完。手法需要决定哪些转角抬笔、长线分成几段、接缝交叠多少,以及是否再描一遍。
Inkline 的 planStrokes 就负责这一层。它接收展平后的轮廓,依据手法参数安排断笔、越界、闭合轮廓的起笔位置和长线分段。草稿手法的复线也在这里获得独立的笔次,而不是让渲染器把同一条路径平移后重复显示。
规划之后才是实际运笔。实现会沿轮廓采样,叠加连续噪声、分段弯曲与端点偏差;转角附近另补控制点,避免平滑插值把角磨圆。压力则沿每一笔变化,起收笔的渐变与中段起伏被写进同一份笔画数据。
因此,“粗糙度”只是其中一个参数。调大它主要改变偏移幅度,不能代替分笔、复画和收笔。反过来,勾线手法也不需要处处大幅抖动:较稳的线条配合断笔与粗细变化,仍然可以留下清楚的绘制痕迹。这是生成策略,不是对真实手部动作的完整物理模拟。
对图表而言,还有一些位置不该随意变化。连线如果离开节点边缘,手绘感就破坏了图的关系表达。图元的 pinEnds: true 会让开放路径首尾保持精确,端部扰动在邻近区间内衰减,而不是把整条线都变回直尺效果。
手法决定动作,笔决定动作留下什么
手法输出的每个采样点包含 x、y、pressure、t。压力已经存在,但还不是最终线宽:同样的手部动作,用不同笔尖,留下的墨迹应该不同。
Inkline 把这一步交给 Pen.ink。默认签字笔以原生恒宽描边输出;钢笔和 G 笔把压力映射为变化的宽度,生成填充式的笔画轮廓;马克笔还带有扁笔尖方向和透明度。G 笔与钢笔的差别也不是给路径换个名字,而在于压力响应曲线不同。
上图来自仓库的笔示例。它适合用来理解变量的归属:想让线条多描一遍,应换手法;想保留运笔方式、调整起收笔的粗细对比,应换笔或调笔的压感强度。
这也说明压感不是一种与钢笔、马克笔并列的“笔”。它是笔画携带的输入,具体的笔可以响应,也可以保持恒宽。自定义笔的边界因此很明确:读取笔画和上下文,产出渲染批次,不必重新实现几何解析和分笔规划。
填充不一定是一块颜色,也不必总经过笔
手绘图中的排线,本质上仍然是一组线。如果轮廓由一套手法生成,填充却只是贴上规则纹理,两者容易显得来自不同画法。
Inkline 的填充有两条路径。图案填充可以先返回线条骨架,再经过填充手法和填充笔,最后裁剪到原区域;实色填充则直接返回上色区域,不强行绕一遍描边流程。风格可以指定独立的 fillHand、fillPen,没有指定时才沿用轮廓的手法与笔。
这里有个容易忽略的细节:styles.natural 已经配好了较克制的填充手法和签字笔。因此展开这个预设后只覆盖 pen,改变的是轮廓用笔,不会顺带替换已有的 fillPen。需要统一换笔时,应明确修改两者。
下面摘取仓库快速示例的主要调用。它画一个带斜线填充的圆角卡片,不涉及照片处理或外部图表布局:
import { draw, fills, rect, styles } 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",
});draw 的结果没有把紫色写死在几何里,而是保留 surface 这样的颜色角色。渲染时才由调色板解析,所以换主题不需要重新安排笔画。renderSvg 返回 SVG 字符串,不依赖 DOM;浏览器中需要元素时可以使用 mountSvg,Canvas 则通过独立的 /canvas 入口读取同一个 Drawing。
稳定随机不是“每次从同一个随机种子开始”这么简单
假设整幅图共享一个顺序随机数流:前面的卡片多生成一笔,就会多消耗几个随机数。后面没有改动的节点,也可能换了一套扰动。seed 虽然相同,局部编辑却扩散成整图变化。
Inkline 在 draw 中为每个图元用 seed 和 id 的哈希建立随机源;随机值再由通道和索引定位,而不是依赖“这是第几次调用”。填充还有自己的随机作用域。
这让随机变化有了边界。在几何、风格和生成参数不变,且扩展遵守确定性随机契约的前提下,一个稳定 id 的图元可以得到稳定的笔墨。增加别的图元,不会仅仅因为调用顺序改变就扰乱它。若把数组下标当作 id,插入元素后又重新编号,这项保证便失去了前提。
这里稳定的是绘制数据,不是整个返回对象或 SVG 文本必然逐字节相等。Drawing 还记录生成耗时,SVG 渲染器也会给内部裁剪标识追加实例序号。缓存或比较结果时,需要先明确自己关注的是几何、视觉还是序列化文本,不能把三者混为一谈。
重放已经画好的笔墨,而不是重新画一遍
有了稳定结果,动画就不必反复调用 draw。生成批次保存了子路径的时间区间;createSchedule 再为图元分配顺序、并行或交错的时间窗口。播放到某个时刻时,渲染器只计算哪些内容已经显现。
普通描边可以逐段露出;变宽笔画保留条带结构,按进度显示前缀;色面则调整透明度。SVG 的 seekSvg 和 Canvas 的带时间绘制都基于既有批次。这解释了为什么跳转和倒放不需要重新抽取随机数,也不会因换一帧就换一条线。
绘制顺序与叠放顺序也不是同一件事。包内单个图元的时间安排先描边、后填充,但批次叠放仍让颜色位于轮廓下方。它并不等于“整幅插画的所有轮廓先画完,最后统一上色”:跨多个图元的分阶段编排属于另一层需求,不能把应用自己的安排当成默认行为。
先有可画的几何,才有手绘渲染
图表通常已经有节点和连线几何,适合直接构造场景。已有 SVG 时,浏览器适配器 inkSvg 也能重画它支持的基本图形;它读取实际样式和尺寸,因此 SVG 必须已经挂在文档中。这个入口不是布局引擎,也不是任意 SVG 效果的通用转换器,文字、图片和箭头标记并不会一起被重新手绘。
插画则要区分中心线、色面与实墨区域。开放的中心线可以交给手法和笔;闭合色面负责底色;眼瞳一类实墨细节可以直接填充。仓库 README 中的 inkArtwork 是把这几类数据组装成场景的示例函数,不是包额外导出的照片导入 API。
一张照片还没有这些结构。如何分色、提取中心线、保留五官、简化轮廓,属于输入画稿的准备过程。Inkline 接收准备好的路径,再决定怎样落笔;它不能用一个 seed 把像素自动变成语义正确的插画。
因此,选择它之前最值得问的不是“能不能再随机一点”,而是“手里是否已有合适的几何,以及希望改变的是运笔、笔尖还是填充”。把这些问题分开,手绘质感才成为可调节、可复现的绘制过程,而不只是覆盖在图形上的一层噪声。