进阶交互与图形
总结一些比较复杂的图形和交互实现思路,这是自研 Canvas 渲染引擎或考察图形学基础时的常见考点。
1. 拾取判定 (Hit Testing)
面试题:Canvas 只是一个单一的 DOM 节点,如果我在上面画了 100 个不同的图形,怎么知道用户点击了哪一个?
由于 Canvas 是“即时模式 (Immediate Mode)”,画完即像素,浏览器并不知道图形的边界。业界核心的拾取方案如下:
几何计算与射线/包含检测 (Point in Path): 在内存中维护一个所有图形的数据对象树(类似虚拟 DOM)。当监听到 Canvas 的
click事件时,拿到鼠标的(x, y)坐标。然后遍历图形树,利用数学公式计算或者使用 Canvas 原生的ctx.isPointInPath(path, x, y)方法,判断鼠标是否落在了某个图形的轮廓内。isPointInPath(x, y)/isPointInStroke(x, y): Canvas 原生提供的 API。缺点是它只针对“当前最后绘制的路径”,如果画布上有几十个图形,点击时需要遍历重绘所有路径去检测,性能较差。ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.fill();
canvas.addEventListener("click", (e) => {
// 获取相对 Canvas 的坐标
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (ctx.isPointInPath(x, y)) {
console.log("精准点中圆形!");
}
});几何数学计算法: 自己根据图形的数学方程计算。比如判断是否点击了圆形,只需计算鼠标坐标与圆心的距离是否小于等于半径。速度极快,但缺点是如果图形是极度复杂的曲线或不规则多边形,纯数学计算非常困难且耗时。
颜色拾取法 (Color Picking) - 大厂高级技巧:
- 原理:建立一个与主画布完全相同大小的离屏 Canvas。当我们在主画布上画一个图形时,在这个离屏 Canvas 的对应位置画一个形状完全一样、但颜色是唯一随机 RGB 码的影子图形。
- 拾取过程:把每个图形和它的专属 RGB 码存入一个 Hash 表(如
Map)。点击时,通过ctx.getImageData(x, y, 1, 1)读取离屏 Canvas 上对应坐标的像素颜色。拿到 RGB 值后,去 Hash 表里一查,就能瞬间知道点击了哪个图形。 - 优势:无视图形复杂度,时间复杂度为 $O(1)$,像 ECharts、ZRender 等大型可视化库在处理复杂图形拾取时常采用此方案。
// 伪代码:颜色拾取法核心逻辑
const shapeMap = new Map();
const idColor = "rgb(0, 0, 1)"; // 独一无二的颜色ID
shapeMap.set("0,0,1", { name: "复杂多边形" });
// 1. 在离屏 Canvas 画同样的形状,填充这个颜色
offscreenCtx.fillStyle = idColor;
offscreenCtx.fill(complexPath);
// 2. 点击主画布时,去离屏画布取色
canvas.addEventListener("click", (e) => {
// 读取点击位置的 1x1 像素数据
const pixel = offscreenCtx.getImageData(e.offsetX, e.offsetY, 1, 1).data;
const colorKey = `${pixel[0]},${pixel[1]},${pixel[2]}`; // "0,0,1"
const clickedShape = shapeMap.get(colorKey);
if (clickedShape) console.log("点中了:", clickedShape.name);
});
2. 像素操作 (ImageData)
利用 ctx.getImageData(x, y, w, h) 可以获取画布区域内的像素信息(返回一个包含 RGBA 值的巨大一维数组 Uint8ClampedArray)。
经典面试题:如何用 Canvas 实现图片的灰度滤镜(黑白照片)?
function applyGrayScale(ctx, width, height) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
// 遍历所有像素,每次跳 4 个值 (R, G, B, A)
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 心理学灰度公式(人眼对绿色最敏感)
const gray = r * 0.299 + g * 0.587 + b * 0.114;
data[i] = gray; // R
data[i + 1] = gray; // G
data[i + 2] = gray; // B
// data[i + 3] 是 Alpha,保持不变
}
// 把修改后的像素塞回画布
ctx.putImageData(imageData, 0, 0);
}注意:由于需要遍历百万级的像素点,直接在主线程做像素操作极其耗时。真实业务中(如视频绿幕抠图),通常交由 Web Worker 计算,或者直接用 WebGL 的 Fragment Shader 在 GPU 端并发处理。
3. 矩阵变换 (Transform Matrix)
对于复杂的图形缩放、旋转和位移,直接使用 translate、scale、rotate 容易导致全局坐标系状态错乱、难以维护。
高级做法是直接操作变换矩阵 ctx.transform(a, b, c, d, e, f) 或 setTransform。
参数对应 2D 仿射变换矩阵:
a(m11): 水平缩放 |b(m12): 水平倾斜c(m21): 垂直倾斜 |d(m22): 垂直缩放e(dx): 水平位移 |f(dy): 垂直位移
// 需求:将坐标系平移(100, 50)并放大2倍
// 等价于 ctx.translate(100, 50); ctx.scale(2, 2);
// a=2, d=2 (缩放),e=100, f=50 (位移),倾斜为0
ctx.setTransform(2, 0, 0, 2, 100, 50);
ctx.fillRect(0, 0, 50, 50); // 实际画在 (100, 50),大小为 100x100在制作类似 Figma、Fabric.js 这样的画板工具时,图形树的无级缩放与拖拽底层全是依靠矩阵运算来重置全局坐标系。
4. 图像合成 (globalCompositeOperation)
Canvas 提供了多种图形叠加模式(类似 Photoshop 的图层混合模式)。
source-over:默认,新图形画在老图形上面。destination-out:新图形和老图形重叠的部分会变透明。实战场景:实现“刮刮乐”效果
// 1. 底层放一张中奖结果的 DOM 图片 (通过 CSS background 放在 canvas 下面)
// 2. Canvas 铺满银灰色涂层
ctx.fillStyle = "silver";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 3. 关键:后续画的图形,会让已有图像变透明
ctx.globalCompositeOperation = "destination-out";
// 4. 鼠标滑动时画粗线条(擦除涂层)
canvas.addEventListener("mousemove", (e) => {
if (!isDrawing) return;
ctx.beginPath();
ctx.arc(e.offsetX, e.offsetY, 20, 0, Math.PI * 2);
ctx.fill(); // 这块区域的银灰色会被“掏空”,露出底下的图片
});
5. 多行文本与文字测量 (Text Wrapping)
Canvas 原生的 fillText 不支持换行,文字超长会被截断。在海报生成、富文本编辑器等场景中,需要自己利用 ctx.measureText() 来计算文字宽度并手动换行。
function fillTextMultiline(ctx, text, x, y, maxWidth, lineHeight) {
let line = "";
let currentY = y;
for (let n = 0; n < text.length; n++) {
const testLine = line + text[n];
const metrics = ctx.measureText(testLine);
const testWidth = metrics.width;
// 如果当前行宽度超出 maxWidth,且不是第一个字符,则换行
if (testWidth > maxWidth && n > 0) {
ctx.fillText(line, x, currentY);
line = text[n];
currentY += lineHeight;
} else {
line = testLine;
}
}
// 绘制最后一行
ctx.fillText(line, x, currentY);
}
// 使用
ctx.font = "16px Arial";
ctx.fillStyle = "black";
fillTextMultiline(
ctx,
"这是一段非常长的测试文本,需要根据最大宽度自动进行换行处理,以确保它能在 Canvas 中完美展示。",
10,
30,
200,
24,
);
6. 场景树与渲染引擎抽象 (Scene Graph)
现代 Canvas 库(如 Konva、Fabric.js)都屏蔽了原生的绘制细节,抽象出了面向对象的“场景树”(Scene Graph)概念,将即时模式(Immediate Mode)封装成了保留模式(Retained Mode)。
核心思想是构建一棵对象树,并在 requestAnimationFrame 中遍历这棵树去调用每个对象的 draw 方法。
// 1. 基类定义
class Shape {
constructor(x, y) {
this.x = x;
this.y = y;
}
render(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
this.draw(ctx);
ctx.restore();
}
draw(ctx) {
/* 由子类实现 */
}
}
class Rect extends Shape {
constructor(x, y, w, h, color) {
super(x, y);
this.w = w;
this.h = h;
this.color = color;
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.fillRect(0, 0, this.w, this.h);
}
}
// 2. 场景与渲染循环
class Stage {
constructor(canvas) {
this.ctx = canvas.getContext("2d");
this.children = [];
}
add(shape) {
this.children.push(shape);
}
render() {
this.ctx.clearRect(0, 0, 800, 600);
this.children.forEach((child) => child.render(this.ctx));
requestAnimationFrame(() => this.render());
}
}
// 3. 业务代码只需操作对象,无需关心上下文状态
const stage = new Stage(canvas);
const rect1 = new Rect(10, 10, 50, 50, "red");
stage.add(rect1);
stage.render();
// 想要移动矩形?直接改属性,下一帧自动生效
rect1.x = 100;
7. 碰撞检测基础 (Collision Detection)
除了前面提到的“鼠标是否点中图形”(拾取),有时还需要判断“图形与图形是否发生碰撞”,这是物理引擎和游戏开发的基础。
矩形碰撞 (AABB - Axis-Aligned Bounding Box): 最常用且性能最好的碰撞检测算法,要求图形是未旋转的矩形(或者将其包裹在未旋转的包围盒中)。
function isRectCollision(rect1, rect2) {
return (
rect1.x < rect2.x + rect2.width &&
rect1.x + rect1.width > rect2.x &&
rect1.y < rect2.y + rect2.height &&
rect1.y + rect1.height > rect2.y
);
}圆形碰撞: 计算两圆心之间的距离,如果距离小于两圆半径之和,则发生碰撞。
function isCircleCollision(circle1, circle2) {
const dx = circle1.x - circle2.x;
const dy = circle1.y - circle2.y;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < circle1.radius + circle2.radius;
}