4. Canvas 2D进阶:路径绘制(贝塞尔曲线)、渐变与阴影、图像绘制与像素操作

好,咱们继续往下走。上一章我们把 Canvas 2D 的基础 API 过了一遍,能画线、画圆、画矩形了。但说实话,那些都是「基本功」。真正让 Canvas 变得强大、能画出复杂图形的,是这一章要讲的内容。

我个人习惯把这一章叫做「Canvas 的魔法时刻」。为什么?因为路径绘制里的贝塞尔曲线,能让你画出任何形状;渐变和阴影能让画面瞬间有质感;而图像绘制和像素操作,则是 Canvas 真正「玩出花」的地方。我在做鸿蒙系统的图形引擎优化时,这几个功能几乎天天打交道。

4.1 路径绘制:从直线到贝塞尔曲线

路径绘制,说白了就是「用点连成线,再用线围成面」。基础路径我们之前讲过 moveTolineTo,但那些只能画直线。现实世界哪有那么多直线?弧线、曲线才是常态。

4.1.1 二次贝塞尔曲线

二次贝塞尔曲线,只需要三个点:起点、控制点、终点。控制点决定了曲线的弯曲方向和程度。

// 二次贝塞尔曲线
canvasContext.beginPath();
canvasContext.moveTo(50, 200);  // 起点
canvasContext.quadraticCurveTo(150, 50, 250, 200);  // 控制点(150,50),终点(250,200)
canvasContext.stroke();

嗯,这里要注意:控制点并不在曲线上,它像是一根「磁铁」,把直线往自己的方向拉。控制点离起点越远,曲线弯曲得越厉害。我在项目中遇到过一个问题——画一个圆角聊天气泡,用二次贝塞尔曲线做圆角过渡,效果比用 arc 画圆弧更自然。

小技巧: 二次贝塞尔曲线适合做简单的弧线过渡。如果你需要更复杂的 S 形曲线,就得用三次贝塞尔曲线了。

4.1.2 三次贝塞尔曲线

三次贝塞尔曲线有两个控制点。这意味着你能控制曲线「怎么进来、怎么出去」,自由度更高。

// 三次贝塞尔曲线
canvasContext.beginPath();
canvasContext.moveTo(50, 200);
canvasContext.bezierCurveTo(100, 50, 200, 50, 250, 200);
// 两个控制点:(100,50) 和 (200,50),终点:(250,200)
canvasContext.stroke();

你想想看,两个控制点分别控制曲线在起点和终点处的切线方向。第一个控制点决定了曲线离开起点的角度,第二个控制点决定了曲线进入终点的角度。这就是为什么三次贝塞尔曲线能画出 S 形、波浪形这些复杂形状。

我曾经用三次贝塞尔曲线在鸿蒙平板上画过一个手写签名效果。用户手指滑动时,我每隔几个像素采样一个点,然后用这些点生成平滑的贝塞尔曲线。效果比直接连直线好太多了,完全没有锯齿感。

4.1.3 路径的闭合与裁剪

路径画完了,可以用 closePath() 闭合。但更高级的用法是裁剪——把路径变成一个「蒙版」,只有路径内部的区域才能被绘制。

// 路径裁剪
canvasContext.beginPath();
canvasContext.arc(200, 200, 100, 0, Math.PI * 2);
canvasContext.clip();  // 之后的所有绘制,只显示在圆形区域内

// 画一个矩形,但只会显示圆形内的部分
canvasContext.fillStyle = 'red';
canvasContext.fillRect(0, 0, 400, 400);
注意: clip() 会影响后续所有绘制操作。如果你只想临时裁剪,记得先用 save() 保存状态,裁剪完用 restore() 恢复。

4.2 渐变与阴影:让画面有「质感」

纯色填充太单调了。真实世界的光影是渐变的,物体是有阴影的。Canvas 提供了两种渐变和阴影支持。

4.2.1 线性渐变

线性渐变就是颜色沿着一条直线方向变化。你需要指定渐变的起点和终点,然后添加颜色断点。

// 线性渐变
let gradient = canvasContext.createLinearGradient(0, 0, 400, 0);
gradient.addColorStop(0, 'red');
gradient.addColorStop(0.5, 'yellow');
gradient.addColorStop(1, 'blue');

canvasContext.fillStyle = gradient;
canvasContext.fillRect(0, 0, 400, 200);

颜色断点的位置用 0 到 1 之间的数字表示。0 是起点,1 是终点。你可以加任意多个断点,颜色会平滑过渡。我在做鸿蒙系统的天气应用时,用线性渐变模拟天空从日出到日落的颜色变化,效果非常棒。

4.2.2 径向渐变

径向渐变是从一个点向外扩散的渐变。它需要两个圆:内圆和外圆。颜色从内圆边缘渐变到外圆边缘。

// 径向渐变
let gradient = canvasContext.createRadialGradient(200, 200, 20, 200, 200, 100);
gradient.addColorStop(0, 'white');
gradient.addColorStop(0.5, 'yellow');
gradient.addColorStop(1, 'orange');

canvasContext.fillStyle = gradient;
canvasContext.arc(200, 200, 100, 0, Math.PI * 2);
canvasContext.fill();

这个效果用来画太阳、灯泡、光晕非常合适。内圆半径越小,高光越集中。我曾经用径向渐变在鸿蒙平板上画过一个手电筒效果,用户拖动时,光晕跟着手指移动,配合阴影效果,立体感很强。

4.2.3 阴影

Canvas 的阴影设置很简单,四个属性搞定:颜色、水平偏移、垂直偏移、模糊半径。

// 阴影设置
canvasContext.shadowColor = 'rgba(0, 0, 0, 0.5)';
canvasContext.shadowOffsetX = 5;
canvasContext.shadowOffsetY = 5;
canvasContext.shadowBlur = 10;

canvasContext.fillStyle = 'blue';
canvasContext.fillRect(50, 50, 200, 100);
性能提示: 阴影绘制非常消耗性能。如果你需要大量绘制带阴影的元素,建议用离屏 Canvas 先把带阴影的图形渲染好,然后直接绘制离屏 Canvas 的结果。我在做鸿蒙系统的图表库时,就是靠这个技巧把帧率从 20fps 提到了 60fps。

4.3 图像绘制:把图片搬上 Canvas

Canvas 不仅能画图形,还能画图片。而且可以对图片进行缩放、裁剪、甚至逐像素处理。

4.3.1 基本图像绘制

drawImage 有三种调用方式,我按常用程度排个序:

调用方式 说明 适用场景
drawImage(img, x, y) 在 (x, y) 位置绘制原图 简单显示
drawImage(img, x, y, w, h) 缩放到 w×h 再绘制 图片缩放
drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) 从原图裁剪区域,再缩放到目标区域 图片裁剪、精灵图
// 最常用的裁剪绘制
// 从原图 (50, 50) 位置裁剪 100x100 区域
// 缩放到目标 (0, 0) 位置 200x200 区域
canvasContext.drawImage(img, 50, 50, 100, 100, 0, 0, 200, 200);

我在鸿蒙系统的相册应用里,用这个 API 实现了图片的「九宫格裁剪」功能。用户拖动裁剪框时,实时计算裁剪区域并绘制预览,体验非常流畅。

4.3.2 图像像素操作

这才是真正的「黑科技」。你可以读取 Canvas 上每个像素的 RGBA 值,修改它们,再写回去。这意味着你能实现滤镜、颜色替换、边缘检测等效果。

// 获取像素数据
let imageData = canvasContext.getImageData(0, 0, canvas.width, canvas.height);
let data = imageData.data;  // Uint8ClampedArray,每四个元素代表一个像素的 RGBA

// 遍历所有像素,做灰度处理
for (let i = 0; i < data.length; i += 4) {
    let r = data[i];
    let g = data[i + 1];
    let b = data[i + 2];
    let gray = 0.299 * r + 0.587 * g + 0.114 * b;  // 灰度公式
    
    data[i] = gray;      // R
    data[i + 1] = gray;  // G
    data[i + 2] = gray;  // B
    // data[i + 3] 是 Alpha,保持不变
}

// 写回 Canvas
canvasContext.putImageData(imageData, 0, 0);
核心要点: getImageData 返回的像素数据是按行排列的。第 i 个像素的 R 通道在 data[i*4],G 在 data[i*4+1],B 在 data[i*4+2],A 在 data[i*4+3]。坐标 (x, y) 对应的像素索引是 (y * width + x) * 4

我曾经在鸿蒙系统上做过一个实时滤镜引擎。用户拍照后,我读取像素数据,用不同的算法做颜色变换——复古滤镜、冷色调、暖色调、黑白。最让我头疼的是性能问题。直接操作像素数据在 CPU 上跑,大图会卡顿。后来我用了 WebGL 做并行计算,速度提升了 10 倍以上。

跨域问题: 如果你从其他域名加载图片并绘制到 Canvas 上,再调用 getImageData 会抛出安全错误。解决办法是让图片服务器设置 Access-Control-Allow-Origin 头,或者使用同源图片。

4.4 实战:做一个简单的图片滤镜工具

说了这么多,咱们来个小实战。把上面学的知识串起来:加载图片、绘制到 Canvas、用像素操作做滤镜、再显示结果。

// 1. 加载图片
let img = new Image();
img.src = 'photo.jpg';  // 实际使用时请用同源图片
img.onload = function() {
    // 2. 绘制原图
    canvasContext.drawImage(img, 0, 0);
    
    // 3. 获取像素数据
    let imageData = canvasContext.getImageData(0, 0, canvas.width, canvas.height);
    let data = imageData.data;
    
    // 4. 应用滤镜:反色效果
    for (let i = 0; i < data.length; i += 4) {
        data[i] = 255 - data[i];      // R
        data[i + 1] = 255 - data[i + 1];  // G
        data[i + 2] = 255 - data[i + 2];  // B
    }
    
    // 5. 写回 Canvas
    canvasContext.putImageData(imageData, 0, 0);
};

这个例子虽然简单,但思路是一样的。你可以把反色换成其他算法——比如增加对比度、调整亮度、做模糊效果。像素操作的本质就是数学运算,你懂了这个,就能做出各种酷炫的滤镜。

好了,这一章的内容就到这。路径绘制让你能画出任意形状,渐变和阴影让画面有立体感,图像绘制和像素操作则打开了「图像处理」的大门。下一章我们会讲动画和交互,到时候这些知识都会用上。