Chaikin 角切割算法:用 Canvas 绘制丝滑曲线
引言:为什么你画的线总是"毛毛躁躁"?
如果你曾经尝试过在网页上实现一个手写板或者绘图应用,大概率会经历这样一个崩溃的瞬间:当你满怀期待地用鼠标或触控笔在 Canvas 上画下一条优美的弧线时,屏幕上出现的却是一条充满锯齿、边缘坑坑洼洼的"毛毛虫"。
这背后的罪魁祸首,是用户绘制时的速度不均。当你的鼠标快速移动时,系统采集到的点非常稀疏;而当你在某个位置停顿或缓慢移动时,采集到的点又会密集地挤在一起。如果直接用这些原始点去连线,线条的曲率就会发生剧烈的突变。此外,人手本身的生理性微颤,也会让原本应该平滑的曲线布满高频的毛刺。
为了解决这个问题,业界有各种方案,比如贝塞尔曲线拟合、高斯模糊等。但在实时交互的画笔场景下,我们需要一种既能抹平毛刺、又能保持轨迹跟随感,且计算开销极低的算法。今天,我们将深入探讨一套在 Canvas 画笔项目中经过实战检验的"三步走"平滑管线。这套管线的核心,是著名的 Chaikin's Corner Cutting(Chaikin 角切割)算法。
算法总览:从"粗糙折线"到"丝滑曲线"的蜕变
要让一条线变得丝滑,我们不能指望一步到位。在我们的实现中,一条原始轨迹在到达屏幕之前,必须经过三道严格的"工序"。这三道工序构成了一个完整的数据处理管线:
- 等距重采样(Arc-Length Resampling):这是基础。无论用户画得快还是慢,我们都按照固定的物理间距(例如 4 像素)在轨迹上重新打点。这一步消除了时间维度上的不均匀,将"时间序列"转化为了纯粹的"空间序列"。
- 三角窗加权平均平滑(Triangular Window Smoothing):这是去噪。针对重采样后依然存在的生理手抖毛刺,我们使用一个包含 7 个点的三角窗进行加权平均。因为点距已经被固定,这个平滑操作的物理半径是恒定的,不会因为用户画得快就变弱,也不会因为画得慢就变强。
- Chaikin 角切割细分(Corner Cutting Subdivision):这是升华。对经过平滑的折线进行两轮 Chaikin 细分。每一次细分,都会将原本的尖锐折角"切"掉,替换为更平缓的新线段。两轮过后,折线会奇迹般地收敛于一条极其平滑的二次 B 样条曲线。
这三步环环相扣:重采样为平滑提供了均匀的基底,平滑为 Chaikin 细分提供了干净的输入,而 Chaikin 细分则赋予了线条最终的几何美感。
数据流概览:
输入: 5 个手绘点(疏密不均)
↓ 等距重采样 (spacing=4)
12 个等距点
↓ 三角窗加权平均 (窗口±3, 24px)
12 个平滑点(端点不变)
↓ Chaikin 细分 ×2 (开放路径)
48 个点的平滑开放曲线
第一步:等距重采样(Arc-Length Resampling)
原理与图解说明
等距重采样的核心思想是"按距离而非按时间"来采样。想象你在一条蜿蜒的乡间小路上行走,手里拿着一个卷尺。不管你是跑还是走,你只在卷尺刻度刚好走过 4 米时,才在地上插一面小旗。
在代码实现中,我们维护一个 remaining(剩余配额)变量,初始值为设定的间距 spacing = 4。当我们遍历输入的原始点时,会计算当前点到上一个采样点的距离:
- 如果距离小于
remaining,说明还没走够 4 像素,我们只需将remaining减去这段距离,然后继续处理下一个点。 - 如果距离大于等于
remaining,说明这 4 像素的配额已经用完。此时,我们需要在这两个原始点之间,利用线性插值公式ratio = remaining / distance精确地"切"出一个新的采样点。插入新点后,我们将remaining重置为 4,并用新点作为起点,继续检查剩下的距离是否还能再切出一个点(处理极长线段的情况)。
最后,还有一个终点保护机制:由于浮点数精度的原因,最后一个采样点可能距离原始轨迹的终点还有极其微小的差距(比如 0.005 像素)。为了保证轨迹的完整性,如果这个差距大于 0.01 像素,我们会强制将原始终点追加到采样点数组的末尾。
核心代码片段
javascript
// 等距重采样核心逻辑
const spacing = 4;
const sampled = [{ ...input[0] }]; // 保留起点
let remaining = spacing;
for (let i = 1; i < input.length; i++) {
let start = input[i - 1];
const end = input[i];
let distance = Math.hypot(end.x - start.x, end.y - start.y);
// 只要当前线段长度足够,就不断插值
while (distance >= remaining) {
const ratio = remaining / distance;
// 线性插值计算新点坐标
start = {
x: start.x + (end.x - start.x) * ratio,
y: start.y + (end.y - start.y) * ratio
};
sampled.push(start);
// 更新起点和剩余距离,继续检查当前线段
distance = Math.hypot(end.x - start.x, end.y - start.y);
remaining = spacing;
}
// 线段不够长,扣除已走过的距离,结转配额
remaining -= distance;
}
// 终点保护:确保轨迹完美连接到最后一个原始点
const last = input[input.length - 1];
const tail = sampled[sampled.length - 1];
if (Math.hypot(last.x - tail.x, last.y - tail.y) > 0.01) {
sampled.push({ ...last });
}
第二步:三角窗加权平均平滑
为什么需要三角窗?
经过等距重采样后,点的分布变得均匀了,但人手抖动带来的高频噪声依然存在。如果简单地用相邻三个点求平均,虽然能抹平毛刺,但会让曲线变得过于"迟钝",丢失原本的锐利转折。为了在"平滑"与"保真"之间找到完美的平衡,我们引入了三角窗(Triangular Window)。
权重分布与 24 像素的秘密
对于轨迹中的非端点,我们取它左右各 3 个邻居,加上它自己,总共 7 个点。这 7 个点的权重分布呈现一个完美的等腰三角形:1, 2, 3, 4, 3, 2, 1。权重总和为 16。中心点权重最高,越远的邻居权重越低,这种渐变权重能有效防止曲线产生不自然的"台阶感"。
权重分布表:
| offset | -3 | -2 | -1 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|---|---|---|
| weight | 1 | 2 | 3 | 4 | 3 | 2 | 1 |
这里有一个极其精妙的工程细节:为什么是左右各 3 个点?
因为我们第一步设定的等距间距 spacing = 4 像素。左右各 3 个邻居,意味着这 7 个点在物理空间上跨越的距离是 6 × 4 = 24 像素。
24 像素是一个非常理想的物理半径:它足以抹平人手正常的生理微颤,但又不至于大到把用户刻意画出的锐角或急转弯给"糊"掉。更重要的是,因为点距被固定了,这个平滑滤波器的物理作用范围是绝对恒定的。无论用户是用 1 秒画完这条线,还是用 10 秒画完,平滑的强度都一模一样,彻底消除了绘制速度对平滑效果的干扰。
第三步:Chaikin 角切割细分
算法原理:切掉棱角,留下丝滑
Chaikin 算法是一种经典的曲线细分算法,由 George Chaikin 在 1974 年提出。它的思想极其直观:对于折线上的每一条边,我们不直接连接两个顶点,而是在这条边的 1/4 处和 3/4 处各取一个新点,然后用这两个新点来替代原来的顶点。
用数学公式表达,假设当前线段由点 P_i 和 P_{i+1} 组成,那么生成的两个新点 Q 和 R 分别为:
- Q = 0.75 × P_i + 0.25 × P_{i+1} (边的 1/4 处)
- R = 0.25 × P_i + 0.75 × P_{i+1} (边的 3/4 处)
为什么是两轮?凸组合的数学魔法
Chaikin 算法有一个极其优美的数学性质:凸组合(Convex Combination)。
观察上面的公式,0.75 + 0.25 = 1,且两个系数都大于 0。这意味着,新生成的点 Q 和 R,永远会严格落在 P_i 和 P_{i+1} 连成的线段内部,绝对不会跑到线段外面去。
将这个性质推广到整条曲线,就意味着:无论细分多少次,新生成的曲线永远被包裹在原始控制点的**凸包(Convex Hull)**内部。曲线绝对不会发生"过冲(Overshoot)"现象------即不会像某些样条曲线那样,为了追求平滑而猛地超出原始轨迹的包络范围。这对于画笔应用来说是至关重要的,因为用户期望"我画在哪里,线就在哪里"。
至于为什么是两轮 ?Chaikin 算法每执行一轮,点数大约翻倍,曲线阶数提升。一轮细分后,折线已经变得相对圆润;执行两轮后,点数变为原来的约 4 倍,曲线会极其完美地收敛于一条二次 B 样条曲线。两轮是在"视觉平滑度"和"计算性能"之间权衡出的最佳甜点。
细分轮次与点数关系:
| 轮次 | 每条边产生的点数 | 总点数倍数 |
|---|---|---|
| 0(原始) | 1 | 1× |
| 1 | 2 | 2× |
| 2 | 4 | 4× |
开放路径 vs 闭合路径
在我们的画笔场景中,处理的是开放路径(Open Path) 。因此,在细分时,我们不做闭合取模。首尾两个端点保持原样不动,只对中间的线段进行 1/4 和 3/4 的切割。这保证了画笔的起点和终点不会因为算法的循环处理而发生位置偏移或扭曲。
如果是绘制闭合图形(如多边形选区),则需要将最后一个点与第一个点相连进行取模计算:points[(index + 1) % points.length]。但画笔是开放路径,取模会导致首尾被连起来,形成一条不该存在的回连线。
Canvas 画笔的工程实现细节
有了强大的算法,还需要优秀的工程架构来支撑实时交互。
1. 事件采集与触发时机
我们使用 Pointer Events API(pointerdown、pointermove、pointerup)来统一处理鼠标和触控。每收到一个点,就推入 rawPoints 数组。为了节省性能,我们设定攒够 3 个点才触发一次平滑计算。因为少于 3 个点无法构成有效的三角窗平滑。
2. 实时预览与抬笔固化
在 pointermove 期间,我们实时计算并绘制平滑后的曲线,给用户"所见即所得"的跟手感。当 pointerup 触发时,我们将当前的平滑结果作为最终笔触,推入历史栈 strokes 中固化,并清空当前的预览数据。
3. 离屏缓存与高清屏适配
为了避免每一帧都重绘所有历史笔触,实际项目中通常会使用离屏 Canvas(Offscreen Canvas)来缓存已固化的笔触。主 Canvas 每帧只需清空、绘制离屏缓存、再绘制当前正在进行的平滑预览即可。此外,必须通过 window.devicePixelRatio 对 Canvas 的宽高进行缩放,否则在 Retina 等高清屏上,线条会显得模糊发虚。
4. 撤销与清空
维护一个固定长度的历史栈(例如最多保留 30 笔),配合 Ctrl+Z 快捷键,即可轻松实现撤销功能。清空操作直接重置历史栈并清空画布。
完整代码
下面是一个完整 HTML 文件,包含了上述所有的算法逻辑与工程细节。可以直接保存为 .html 文件并在浏览器中打开:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
<title>平滑画笔 · Canvas</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
overflow: hidden;
background: #f0f2f5;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
#app {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
}
#toolbar {
height: 56px;
flex-shrink: 0;
background: #fff;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
}
#toolbar .group {
display: flex;
align-items: center;
gap: 8px;
}
#toolbar label {
font-size: 13px;
color: #374151;
}
#toolbar input[type="range"] {
width: 120px;
}
#toolbar input[type="color"] {
width: 32px;
height: 32px;
border: none;
padding: 0;
background: none;
cursor: pointer;
}
#toolbar button {
height: 32px;
padding: 0 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
color: #111827;
font-size: 13px;
cursor: pointer;
}
#toolbar button:hover {
background: #f3f4f6;
}
#toolbar button:disabled {
opacity: .4;
cursor: not-allowed;
}
#toolbar .sep {
width: 1px;
height: 24px;
background: #e5e7eb;
}
#sizeVal {
font-size: 12px;
color: #6b7280;
min-width: 36px;
}
#canvas {
flex: 1;
display: block;
width: 100%;
height: 100%;
background: #fff;
touch-action: none;
cursor: crosshair;
}
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<div class="group">
<label>粗细</label>
<input id="size" type="range" min="1" max="60" value="6" />
<span id="sizeVal">6px</span>
</div>
<div class="group">
<label>颜色</label>
<input id="color" type="color" value="#222222" />
</div>
<div class="sep"></div>
<button id="undoBtn" disabled>撤销</button>
<button id="clearBtn">清空</button>
</div>
<canvas id="canvas"></canvas>
</div>
<script>
/* =========================================================
1. 平滑函数:等距重采样 → 加权平均 → Chaikin 细分 ×2
========================================================= */
function smoothCutPoints(input) {
if (input.length < 3) return input.map(p => ({ ...p }));
const spacing = 4;
const sampled = [{ ...input[0] }];
let remaining = spacing;
for (let i = 1; i < input.length; i++) {
let start = input[i - 1];
const end = input[i];
let distance = Math.hypot(end.x - start.x, end.y - start.y);
while (distance >= remaining) {
const ratio = remaining / distance;
start = {
x: start.x + (end.x - start.x) * ratio,
y: start.y + (end.y - start.y) * ratio,
};
sampled.push(start);
distance = Math.hypot(end.x - start.x, end.y - start.y);
remaining = spacing;
}
remaining -= distance;
}
const last = input[input.length - 1];
const tail = sampled[sampled.length - 1];
if (Math.hypot(last.x - tail.x, last.y - tail.y) > 0.01) {
sampled.push({ ...last });
}
let points = sampled.map((point, index) => {
if (index === 0 || index === sampled.length - 1) return { ...point };
let x = 0, y = 0, total = 0;
for (let offset = -3; offset <= 3; offset++) {
const neighbor = sampled[index + offset];
if (!neighbor) continue;
const weight = 4 - Math.abs(offset);
x += neighbor.x * weight;
y += neighbor.y * weight;
total += weight;
}
return { x: x / total, y: y / total };
});
for (let pass = 0; pass < 2; pass++) {
if (points.length < 3) break;
const refined = [points[0]];
for (let index = 0; index < points.length - 1; index++) {
const point = points[index];
const next = points[index + 1];
refined.push(
{ x: point.x * 0.75 + next.x * 0.25, y: point.y * 0.75 + next.y * 0.25 },
{ x: point.x * 0.25 + next.x * 0.75, y: point.y * 0.25 + next.y * 0.75 }
);
}
refined.push(points[points.length - 1]);
points = refined;
}
return points;
}
/* =========================================================
2. 画笔应用
========================================================= */
(() => {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const sizeInput = document.getElementById('size');
const sizeVal = document.getElementById('sizeVal');
const colorInput = document.getElementById('color');
const undoBtn = document.getElementById('undoBtn');
const clearBtn = document.getElementById('clearBtn');
let dpr = window.devicePixelRatio || 1;
let strokes = []; // 已固化的笔触 {points, size, color}
let isDrawing = false;
let rawPoints = []; // 当前笔触的原始采集点
let smoothedPoints = []; // 当前笔触的平滑点(实时预览用)
// 自适应高清屏:canvas 内部分辨率 = css 尺寸 × dpr,坐标按 dpr 缩放
function resize() {
dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
redraw();
}
window.addEventListener('resize', resize);
// 把页面坐标转成 canvas 内部坐标
function getPoint(e) {
const rect = canvas.getBoundingClientRect();
return { x: (e.clientX - rect.left) * dpr, y: (e.clientY - rect.top) * dpr };
}
/* ---------- 绘制原语 ---------- */
function drawStroke(points, size, color) {
if (!points || points.length < 2) return;
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (points.length === 2) {
// 只有两个点时画短线段,避免 lineTo 不显示
ctx.beginPath();
ctx.arc(points[0].x, points[0].y, size / 2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return;
}
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.stroke();
ctx.restore();
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const s of strokes) drawStroke(s.points, s.size, s.color);
// 正在画的这一笔:预览层
if (smoothedPoints.length >= 2) {
drawStroke(smoothedPoints, Number(sizeInput.value), colorInput.value);
}
}
/* ---------- 指针事件 ---------- */
function onDown(e) {
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
isDrawing = true;
rawPoints = [getPoint(e)];
smoothedPoints = [];
}
function onMove(e) {
if (!isDrawing) return;
e.preventDefault();
rawPoints.push(getPoint(e));
// 至少 3 个点才触发平滑;点数过少时预览会抖动,所以攒一小段再算
if (rawPoints.length >= 3) {
smoothedPoints = smoothCutPoints(rawPoints);
}
redraw();
}
function onUp(e) {
if (!isDrawing) return;
isDrawing = false;
try { canvas.releasePointerCapture(e.pointerId); } catch (_) { }
// 抬笔:用最后一次平滑结果固化;点数太少就直接用原始点
const finalPoints = smoothedPoints.length >= 3 ? smoothedPoints : rawPoints.map(p => ({ ...p }));
if (finalPoints.length >= 2) {
strokes.push({ points: finalPoints, size: Number(sizeInput.value), color: colorInput.value });
if (strokes.length > 30) strokes.shift(); // 限制历史长度,防止内存膨胀
}
smoothedPoints = [];
redraw();
updateButtons();
}
canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('pointercancel', onUp);
canvas.addEventListener('pointerleave', onUp);
/* ---------- 工具栏 ---------- */
sizeInput.addEventListener('input', () => { sizeVal.textContent = sizeInput.value + 'px'; });
clearBtn.addEventListener('click', () => {
strokes = [];
redraw();
updateButtons();
});
undoBtn.addEventListener('click', () => {
strokes.pop();
redraw();
updateButtons();
});
function updateButtons() { undoBtn.disabled = strokes.length === 0; }
// 键盘快捷键:Ctrl/Cmd+Z 撤销
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (strokes.length) { strokes.pop(); redraw(); updateButtons(); }
}
});
resize();
})();
</script>
</body>
</html>
总结
在 Canvas 画笔开发中,一条丝滑曲线的诞生,绝非单纯依赖某个高深的数学公式,而是多个基础算法巧妙组合的结果。下表总结了这三步管线中各自扮演的关键角色:
| 算法步骤 | 核心作用 | 关键参数/特性 |
|---|---|---|
| 等距重采样 | 消除绘制速度差异导致的点疏密不均 | spacing=4,线性插值,终点保护 |
| 三角窗加权平均 | 抹掉手抖毛刺,保持转折锐度 | 7点窗口,权重 1/2/3/4/3/2/1,物理半径 24px |
| Chaikin 细分×2 | 增加点数、柔化拐角,收敛于二次 B 样条 | 1/4 和 3/4 取点,凸组合不过冲,开放路径首尾不动 |
如果你正在开发一个在线绘图工具、手势识别系统,或者只是对计算几何感兴趣,这套"重采样→加权平均→Chaikin 细分"的三步管线是一个非常值得收藏的实战方案。它的精妙之处在于:每一步都只解决一个明确的问题,组合在一起却又产生了 1+1+1 > 3 的效果。