前言背景
在数字绘图应用中,如何模拟真实的手绘效果一直是一个有趣的技术挑战。传统的线条绘制方法(如 Canvas 的 stroke() 或 SVG 的 <path stroke="...">)虽然简单,但无法自然地表现笔触的粗细变化和压感效果。
Perfect Freehand 是一个优雅的解决方案,它采用了一个反直觉但极其有效的思路:不是绘制线条,而是生成闭合的多边形轮廓并填充它。这种方法让我们能够:
- 🎨 模拟真实的笔触压感变化
- ✏️ 实现自然的起笔和收笔效果
- 🖌️ 支持不同的笔刷风格(钢笔、毛笔等)
- 🎯 获得平滑流畅的绘制体验
核心原理
为什么不直接描边?
传统的描边方式存在以下局限:
// 传统方式:描边中心线
ctx.lineWidth = 10; // 固定粗细
ctx.stroke(path); // 无法表现压感变化这种方式的问题是 lineWidth 是固定的,即使我们根据压感动态调整宽度,也很难实现自然的过渡效果。
Perfect Freehand 的思路
Perfect Freehand 将问题转化为:如何根据中心线生成一个可变宽度的轮廓?
核心步骤:
- 采集原始输入点
- 平滑处理点序列
- 模拟压感(如果设备不支持)
- 计算轮廓边界点
- 填充闭合路径
算法步骤详解
Perfect Freehand 采用两阶段处理的设计,将复杂的笔触生成过程分解为清晰的步骤:
阶段一:getStrokePoints - 处理输入点
第一阶段将原始输入点转换为包含丰富信息的 StrokePoint 对象:
// StrokePoint 结构
{
point: [x, y], // 坐标
pressure: 0.5, // 压感值 (0-1)
vector: [vx, vy], // 归一化方向向量
distance: 10, // 与前一点的距离
runningLength: 100 // 累积路径长度
}1.1 向量工具库
算法使用向量数学进行精确计算:
const vec = {
sub: (a, b) => [a[0] - b[0], a[1] - b[1]], // 减法
add: (a, b) => [a[0] + b[0], a[1] + b[1]], // 加法
len: (a) => Math.sqrt(a[0] * a[0] + a[1] * a[1]), // 长度
uni: (a) => { // 归一化
const len = vec.len(a);
return len > 0 ? [a[0] / len, a[1] / len] : [0, 0];
},
per: (a) => [-a[1], a[0]], // 垂直向量
mul: (a, n) => [a[0] * n, a[1] * n] // 缩放
};1.2 点的处理流程
function getStrokePoints(inputPoints, options = {}) {
const { streamline = 0.5, simulatePressure = true } = options;
const points = [];
let runningLength = 0;
let prev = null;
for (let i = 0; i < inputPoints.length; i++) {
const curr = inputPoints[i];
const point = [curr.x, curr.y];
const pressure = curr.pressure || 0.5;
if (i === 0) {
points.push({
point,
pressure,
vector: [1, 1],
distance: 0,
runningLength: 0
});
prev = point;
continue;
}
// 计算距离和累积长度
const distance = dist(prev, point);
runningLength += distance;
// Streamline: 平滑处理
let actualPoint = point;
if (streamline > 0 && i < inputPoints.length - 1) {
actualPoint = [
lerp(prev[0], point[0], 1 - streamline),
lerp(prev[1], point[1], 1 - streamline)
];
}
// 计算方向向量
const vector = vec.uni(vec.sub(actualPoint, prev));
points.push({
point: actualPoint,
pressure,
vector,
distance,
runningLength
});
prev = actualPoint;
}
// 模拟压感(基于速度)
if (simulatePressure) {
for (let i = 0; i < points.length; i++) {
const sp = points[i];
if (i === 0) {
sp.pressure = 0.25;
} else {
const prev = points[i - 1];
// 移动越快,压感越小
const simulatedPressure = clamp(0.5 - sp.distance / 50, 0, 1);
sp.pressure = lerp(prev.pressure, simulatedPressure, 0.5);
}
}
}
return points;
}关键点:
- Streamline 平滑:使用线性插值
lerp(prev, curr, 1 - streamline),值越大越平滑 - 方向向量:归一化后用于计算垂直方向
- 累积长度:用于起笔/收笔渐变的精确控制
- 压感模拟:基于点间距离(速度)计算,快速移动时压感降低
阶段二:getOutlinePoints - 生成轮廓
第二阶段根据 StrokePoints 计算闭合轮廓:
function getOutlinePoints(strokePoints, options = {}) {
const {
size = 16,
thinning = 0.5,
taperStart = 0,
taperEnd = 0
} = options;
if (strokePoints.length === 0) return [];
// 特殊处理:单点绘制成圆
if (strokePoints.length === 1) {
const { point, pressure } = strokePoints[0];
const radius = (size / 2) * pressure;
const circle = [];
for (let i = 0; i < 8; i++) {
const angle = (Math.PI * 2 * i) / 8;
circle.push([
point[0] + Math.cos(angle) * radius,
point[1] + Math.sin(angle) * radius
]);
}
return circle;
}
const totalLength = strokePoints[strokePoints.length - 1].runningLength;
const leftPoints = [];
const rightPoints = [];
// 计算每个点的轮廓
for (let i = 0; i < strokePoints.length; i++) {
const { point, pressure, vector, runningLength } = strokePoints[i];
// 1. 基础半径
let radius = size / 2;
// 2. 应用压感(thinning)
if (thinning !== 0) {
if (thinning < 0) {
// 负值:压力越大,线越细(钢笔效果)
radius *= 1 - pressure * Math.abs(thinning);
} else {
// 正值:压力越大,线越粗(毛笔效果)
radius *= pressure + (1 - pressure) * (1 - thinning);
}
}
// 3. 起笔渐变(基于累积长度)
if (taperStart > 0 && runningLength < taperStart) {
const t = runningLength / taperStart;
radius *= t;
}
// 4. 收笔渐变(基于累积长度)
if (taperEnd > 0 && runningLength > totalLength - taperEnd) {
const t = (totalLength - runningLength) / taperEnd;
radius *= t;
}
// 5. 计算垂直向量和偏移
const perpendicular = vec.per(vector);
const offset = vec.mul(perpendicular, radius);
// 6. 生成左右轮廓点
leftPoints.push(vec.add(point, offset));
rightPoints.push(vec.sub(point, offset));
}
// 7. 组合成闭合轮廓
return [...leftPoints, ...rightPoints.reverse()];
}核心概念详解:
2.1 垂直向量(Perpendicular Vector)
垂直向量用于确定笔触的宽度方向:
运动方向 →
↑ 垂直向量
中心点 ●
↓ 垂直向量
通过 vec.per([dx, dy]) 得到 [-dy, dx],即逆时针旋转 90°。
2.2 Thinning 参数的数学原理
-
thinning < 0(钢笔效果):
radius *= 1 - pressure * |thinning|压力增大时,半径减小,模拟钢笔的特性
-
thinning > 0(毛笔效果):
radius *= pressure + (1 - pressure) * (1 - thinning)压力增大时,半径增大,模拟毛笔的特性
2.3 渐变的精确控制
使用 runningLength(累积长度)而非点索引,确保渐变效果不受采样率影响:
// 起笔:前 taperStart 像素逐渐变粗
if (runningLength < taperStart) {
radius *= runningLength / taperStart; // 0 → 1
}
// 收笔:后 taperEnd 像素逐渐变细
if (runningLength > totalLength - taperEnd) {
radius *= (totalLength - runningLength) / taperEnd; // 1 → 0
}完整的 getStroke 函数
将两个阶段组合:
function getStroke(inputPoints, options = {}) {
const strokePoints = getStrokePoints(inputPoints, options);
const outlinePoints = getOutlinePoints(strokePoints, options);
return outlinePoints;
}SVG 路径生成
将轮廓点数组转换为 SVG 路径字符串:
function arrayToPath(points) {
if (points.length === 0) return '';
let path = `M ${points[0][0]},${points[0][1]}`;
for (let i = 1; i < points.length; i++) {
path += ` L ${points[i][0]},${points[i][1]}`;
}
return path + ' Z'; // Z 闭合路径
}可视化理解
轮廓生成过程
压感与粗细的关系
渐变效果
完整流程图
参数调优指南
1. 笔刷大小(size)
- 范围:4-64 像素
- 效果:控制线条的基础粗细
- 数学:
radius = size / 2 - 建议:
- 小画布(< 500px):8-16
- 中等画布(500-1000px):12-24
- 大画布(> 1000px):20-32
2. 细化(thinning)
- 范围:-1 到 1
- 效果:控制压感对线条粗细的影响程度
- 数学原理:
thinning < 0:radius *= 1 - pressure * |thinning|- 压力 ↑ → 半径 ↓(钢笔效果)
thinning > 0:radius *= pressure + (1 - pressure) * (1 - thinning)- 压力 ↑ → 半径 ↑(毛笔效果)
- 应用场景:
- 书法、签名:0.5 - 0.8(明显的粗细变化)
- 技术绘图:-0.5 - 0(均匀线条)
- 艺术绘画:0.3 - 0.6(自然变化)
- 标注、批注:0.2 - 0.4(轻微变化)
3. 流线性(streamline)
- 范围:0 - 0.95
- 效果:平滑抖动,但会引入延迟
- 数学:
actualPoint = lerp(prevPoint, currPoint, 1 - streamline) - 权衡:
0:无平滑,完全跟随输入(适合精确控制)0.3-0.5:轻度平滑(推荐日常使用)0.6-0.8:重度平滑(适合手抖用户)> 0.8:延迟明显(不推荐)
- 建议:
- 鼠标绘制:0.5 - 0.7
- 触摸屏:0.4 - 0.6
- 手写笔(支持压感):0.3 - 0.5
4. 起笔/收笔渐变(taperStart/taperEnd)
- 范围:0 - 50 像素
- 效果:控制笔触两端的渐变长度
- 数学:基于
runningLength(累积路径长度)计算渐变比例// 起笔:前 N 像素从 0 渐变到完整半径 if (runningLength < taperStart) { radius *= runningLength / taperStart; } // 收笔:后 N 像素从完整半径渐变到 0 if (runningLength > totalLength - taperEnd) { radius *= (totalLength - runningLength) / taperEnd; } - 应用场景:
- 自然笔触:10 - 20(模拟真实落笔/提笔)
- 无渐变:0(直接开始/结束)
- 强调效果:30 - 50(艺术化效果)
- 书法:15 - 30(起笔重,收笔轻)
5. 模拟压感(simulatePressure)
- 类型:布尔值
- 效果:为不支持压感的设备模拟压感
- 原理:根据点间距离(速度)计算压感
simulatedPressure = clamp(0.5 - distance / 50, 0, 1)- 快速移动 → 距离大 → 压感小 → 线条细
- 慢速移动 → 距离小 → 压感大 → 线条粗
- 建议:
- 鼠标/触摸屏:开启
- 支持压感的手写笔:关闭
参数组合推荐
场景 1:数字签名
{
size: 12,
thinning: 0.6,
streamline: 0.5,
taperStart: 15,
taperEnd: 20,
simulatePressure: true
}场景 2:技术绘图
{
size: 16,
thinning: -0.3,
streamline: 0.4,
taperStart: 0,
taperEnd: 0,
simulatePressure: false
}场景 3:艺术绘画
{
size: 20,
thinning: 0.5,
streamline: 0.6,
taperStart: 10,
taperEnd: 10,
simulatePressure: true
}场景 4:白板标注
{
size: 14,
thinning: 0.3,
streamline: 0.5,
taperStart: 5,
taperEnd: 5,
simulatePressure: true
}性能优化
1. 点的采样优化
不需要记录每一帧的点,根据距离阈值采样可以显著减少计算量:
let lastRecordedPoint = null;
const MIN_DISTANCE = 5; // 最小采样距离
function handlePointerMove(e) {
const point = getPointerPosition(e);
// 只有移动距离超过阈值才记录
if (!lastRecordedPoint || dist(lastRecordedPoint, point) >= MIN_DISTANCE) {
currentStroke.push(point);
lastRecordedPoint = point;
render();
}
}效果:
- 减少 50-70% 的点数
- 降低计算复杂度
- 不影响视觉效果
2. 增量渲染优化
对于多条笔画,使用离屏 Canvas 缓存已完成的笔画:
// 初始化离屏 Canvas
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d');
offscreenCanvas.width = mainCanvas.width;
offscreenCanvas.height = mainCanvas.height;
let completedStrokes = []; // 已完成的笔画
function handlePointerUp() {
if (currentStroke.length > 0) {
// 将当前笔画绘制到离屏 Canvas
const outline = getStroke(currentStroke, settings);
const path = new Path2D(arrayToPath(outline));
offscreenCtx.fill(path);
completedStrokes.push(currentStroke);
currentStroke = [];
}
}
function render() {
// 清空主画布
mainCtx.clearRect(0, 0, mainCanvas.width, mainCanvas.height);
// 绘制缓存的已完成笔画(一次性)
mainCtx.drawImage(offscreenCanvas, 0, 0);
// 只计算和绘制当前笔画
if (currentStroke.length > 0) {
const outline = getStroke(currentStroke, settings);
const path = new Path2D(arrayToPath(outline));
mainCtx.fill(path);
}
}效果:
- 已完成笔画只计算一次
- 渲染时间从 O(n) 降至 O(1)
- 支持数百条笔画流畅绘制
3. 路径简化
使用 Douglas-Peucker 算法简化轮廓,减少 SVG 路径复杂度:
function simplifyPath(points, tolerance = 1) {
if (points.length < 3) return points;
// 找到距离起点-终点连线最远的点
let maxDistance = 0;
let maxIndex = 0;
const start = points[0];
const end = points[points.length - 1];
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(points[i], start, end);
if (d > maxDistance) {
maxDistance = d;
maxIndex = i;
}
}
// 如果最大距离大于阈值,递归简化
if (maxDistance > tolerance) {
const left = simplifyPath(points.slice(0, maxIndex + 1), tolerance);
const right = simplifyPath(points.slice(maxIndex), tolerance);
return [...left.slice(0, -1), ...right];
}
// 否则只保留起点和终点
return [start, end];
}
function perpendicularDistance(point, lineStart, lineEnd) {
const dx = lineEnd[0] - lineStart[0];
const dy = lineEnd[1] - lineStart[1];
const norm = Math.sqrt(dx * dx + dy * dy);
if (norm === 0) return dist(point, lineStart);
return Math.abs(
(dy * point[0] - dx * point[1] + lineEnd[0] * lineStart[1] - lineEnd[1] * lineStart[0]) / norm
);
}使用:
const outline = getStroke(currentStroke, settings);
const simplified = simplifyPath(outline, 1.5); // 容差 1.5 像素
const path = arrayToPath(simplified);效果:
- 减少 30-50% 的轮廓点
- 降低 SVG 路径复杂度
- 提升渲染性能
4. 使用 requestAnimationFrame
避免过度渲染,使用 RAF 节流:
let rafId = null;
function handlePointerMove(e) {
const point = getPointerPosition(e);
currentStroke.push(point);
// 取消之前的渲染请求
if (rafId) cancelAnimationFrame(rafId);
// 在下一帧渲染
rafId = requestAnimationFrame(() => {
render();
rafId = null;
});
}5. Web Worker 异步计算
对于复杂场景,将轮廓计算移至 Web Worker:
// worker.js
self.onmessage = (e) => {
const { points, settings } = e.data;
const strokePoints = getStrokePoints(points, settings);
const outline = getOutlinePoints(strokePoints, settings);
self.postMessage({ outline });
};
// main.js
const worker = new Worker('worker.js');
worker.onmessage = (e) => {
const { outline } = e.data;
renderOutline(outline);
};
function handlePointerUp() {
worker.postMessage({
points: currentStroke,
settings: settings
});
}性能对比
| 优化方案 | 点数 | 计算时间 | 渲染时间 |
|---|---|---|---|
| 无优化 | 1000 | 15ms | 8ms |
| + 采样优化 | 300 | 5ms | 3ms |
| + 离屏缓存 | 300 | 5ms | 1ms |
| + 路径简化 | 200 | 4ms | 0.5ms |
| + RAF 节流 | 200 | 4ms | 0.5ms |
推荐组合:采样优化 + 离屏缓存 + RAF 节流
实际应用场景
- 数字签名:自然的笔迹效果
- 白板应用:流畅的书写体验
- 绘图工具:专业的绘画软件
- 笔记应用:手写笔记功能
- 教育软件:批注和标注
实现说明
本文的演示实现基于 Perfect Freehand 官方库,为了教学目的做了适当简化:
保留的核心特性 ✅
- 两阶段处理:
getStrokePoints→getOutlinePoints - 向量数学:完整的向量工具库和几何计算
- Streamline 平滑:基于线性插值的平滑算法
- 压感模拟:基于速度的压感计算
- Thinning 效果:正负值对应毛笔/钢笔效果
- Taper 渐变:基于累积长度的精确渐变
简化的部分 📝
- Easing 函数:使用线性插值,官方库支持自定义缓动函数
- Cap 处理:未实现起笔/收笔的圆角封口
- Last 参数:简化了最后一点的特殊处理
- 边界情况:简化了一些极端情况的处理逻辑
为什么简化?
- 教学清晰:聚焦核心算法,避免细节干扰
- 代码可读:更容易理解和学习
- 功能完整:90% 的使用场景都能满足
如果你需要生产级别的实现,建议直接使用官方库:
npm install perfect-freehandimport getStroke from 'perfect-freehand'
const stroke = getStroke(points, {
size: 16,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => t,
start: {
taper: 0,
easing: (t) => t,
cap: true
},
end: {
taper: 0,
easing: (t) => t,
cap: true
},
simulatePressure: true,
last: true
})与其他方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Canvas stroke | 简单快速,浏览器原生支持 | 无法表现压感变化 | 简单绘图、图表 |
| 变宽度 stroke | 支持压感,API 简单 | 连接处理复杂,效果不自然 | 技术绘图 |
| Perfect Freehand | 自然流畅,完全控制 | 计算量稍大 | 手写、签名、艺术绘画 |
| 纹理笔刷 | 艺术效果好,真实感强 | 性能开销大,实现复杂 | 专业绘画软件 |
| Bezier 曲线 | 平滑优雅,数学精确 | 不适合实时绘制 | 矢量设计工具 |
效果演示
下面是一个完整的交互式演示。
当前实现的局限性
本文的演示实现为了教学目的进行了简化,与官方 Perfect Freehand 库相比存在以下差距:
与官方实现的差距
1. Easing 函数缺失
官方库支持自定义缓动函数,可以更精细地控制效果:
// 官方库支持
{
easing: (t) => t * (2 - t), // 全局压感缓动
start: {
taper: 20,
easing: (t) => t * t, // 起笔缓动
},
end: {
taper: 20,
easing: (t) => --t * t * t + 1, // 收笔缓动
}
}当前实现:使用线性插值 lerp,无自定义缓动
影响:无法实现如"ease-in"、"ease-out"等自然的加速/减速效果
2. Cap 端点处理
官方库在起笔和收笔处会绘制圆角封口:
// 官方库
{
start: { cap: true }, // 起笔圆角
end: { cap: true } // 收笔圆角
}当前实现:未实现 cap 处理
影响:笔触两端可能出现尖锐的三角形,不够自然
3. Last 参数的完整处理
官方库的 last 参数会影响最后几个点的处理:
// 绘制中:last = false,末端略微滞后
getStroke(points, { last: false })
// 绘制完成:last = true,末端精确到最后一点
getStroke(points, { last: true })当前实现:简化了 last 参数的逻辑
影响:实时绘制时末端可能不够平滑
4. 边界情况处理
官方库对极端情况有更完善的处理:
- 单点绘制:生成完整的圆形
- 两点绘制:特殊的胶囊形状
- 极小距离点:自动合并
- 极大压感变化:平滑过渡
当前实现:只处理了单点绘制为圆形
影响:某些边界情况可能出现异常形状
5. 性能优化
官方库包含多项性能优化:
- 点的采样优化
- 向量计算缓存
- 增量更新机制
- WebAssembly 可选支持
当前实现:未做特殊优化
影响:大量点或高频绘制时性能较差
Perfect Freehand 算法本身的局限性
即使是官方完整实现,Perfect Freehand 算法也存在一些固有限制:
1. 计算复杂度
- 时间复杂度:O(n),n 为输入点数
- 空间复杂度:O(n),需要存储轮廓点
- 对比:传统
stroke()只需 O(1) 的线宽设置
影响:
- 实时绘制时,点数过多(>1000)会有延迟
- 移动设备上性能压力更大
- 不适合需要极高帧率的场景(如游戏)
2. 无法表现笔刷纹理
Perfect Freehand 生成的是光滑的填充路径,无法模拟:
- 毛笔的纤维纹理
- 铅笔的颗粒感
- 水彩的晕染效果
- 油画的笔触质感
解决方案:需要结合纹理贴图或粒子系统
3. 连接处可能出现瑕疵
当方向突变或压感剧变时,轮廓连接处可能出现:
正常情况: 问题情况:
━━━━ ━━╱╲━━
━━ ╲ ╱
━━ ╲╱
原因:
- 垂直向量在急转弯处的突变
- 压感剧变导致半径差异过大
- 点采样率不足
缓解方法:
- 增加
streamline参数平滑路径 - 限制压感变化速率
- 提高点采样率
4. 不支持笔刷形状自定义
算法假设笔刷是圆形的,无法实现:
- 扁平笔刷(书法效果)
- 方形笔刷(像素风格)
- 星形笔刷(装饰效果)
- 自定义形状笔刷
原因:轮廓计算基于圆形半径和垂直向量
5. 内存占用
每条笔画需要存储:
{
inputPoints: [...], // 原始点
strokePoints: [...], // 处理后的点
outlinePoints: [...], // 轮廓点(2x)
svgPath: "M ... L ... Z" // SVG 字符串
}影响:
- 复杂绘图(数百条笔画)内存占用可观
- 需要实现撤销/重做时内存压力更大
优化方案:
- 只保存原始点和参数
- 按需重新计算轮廓
- 使用离屏 Canvas 缓存已完成笔画
6. 跨平台一致性挑战
不同平台的差异:
- 浏览器:SVG 渲染引擎差异
- 设备:压感支持程度不同
- 输入:鼠标/触摸/手写笔特性各异
表现:
- 相同代码在不同设备上效果可能不同
- 需要针对性调整参数
适用场景建议
根据以上局限性,Perfect Freehand 最适合:
✅ 适合:
- 数字签名(点数少,要求自然)
- 手写笔记(实时性要求不高)
- 白板标注(简单线条)
- 矢量绘图工具(可后期优化)
❌ 不适合:
- 专业绘画软件(需要纹理和高级笔刷)
- 高性能游戏(计算开销大)
- 像素级精确绘图(算法基于平滑)
- 极大规模绘图(内存和性能限制)
改进方向
如果需要在生产环境使用,可以考虑:
- 混合方案:简单线条用 Perfect Freehand,复杂笔刷用纹理
- 分级渲染:实时用简化算法,完成后用完整算法重绘
- Web Worker:将计算移至后台线程
- WebAssembly:用 Rust/C++ 重写核心算法提升性能
- 增量更新:只计算新增点的轮廓,而非全部重算
算法的数学美学
Perfect Freehand 展现了计算机图形学中的几个优雅思想:
1. 维度转换
将一维的"线"问题转换为二维的"面"问题,突破传统 API 限制。
2. 向量几何的应用
通过简单的向量运算,实现了复杂的笔触效果。
3. 物理直觉的数学化
将物理世界的直觉转化为可计算的数学公式。
4. 分层抽象设计
每一层都有清晰的职责和数据结构。
总结
Perfect Freehand 算法的精妙之处在于:
- 思路转换:从"画线"转变为"填充形状",突破传统 API 限制
- 两阶段设计:
getStrokePoints+getOutlinePoints,清晰的职责分离 - 向量数学:使用垂直向量、归一化等基础工具实现复杂效果
- 物理模拟:通过速度模拟压感,贴近真实书写体验
- 参数化设计:丰富的参数让不同场景都能获得最佳效果
- 数学优雅:简洁的公式,强大的表现力
核心价值
这个算法不仅适用于手绘应用,其核心思想可以推广到:
- 可变宽度路径:地图路线、流程图连线
- 数据可视化:河流图、桑基图
- 游戏开发:轨迹特效、魔法线条
- 艺术创作:生成艺术、算法绘画
参考资源
评论 (0)
登录后即可发表评论