Web特效025—用 Canvas 2D 做Web特效的定义与边界:这支画笔能做到哪一步

Web特效025---用 Canvas 2D 做Web特效的定义与边界:这支画笔能做到哪一步

Canvas 2D 常被说成网页上的一支画笔,可真正开始做特效时,问题很快就来了:它画完的圆为什么不能像 DOM 元素一样直接拖动?擦开一层雾面玻璃,为什么只画一张模糊图片还不够?画布尺寸、设备像素比和每帧重绘,又会把性能推到哪一步?本文以"擦开带有水珠的雾面玻璃,露出清晰产品画面"为贯穿案例,先界定 Canvas 2D 是什么、负责什么,再用路径、图片、离屏画布、合成模式和低分辨率遮罩拆解这个效果。我们还会解释即时模式带来的对象管理成本,为什么大面积模糊与逐像素更新不能随手塞进动画循环,以及何时该让 DOM、CSS 或 WebGL 接手。你可以从文章开头的资源链接体验完整案例,再对照文中的小段代码理解它如何工作。读完之后,你不仅能判断一支 Canvas 2D 画笔能否完成眼前的特效,也能为真实网页选择合适的渲染层次,而不是把所有视觉需求都硬塞进同一张画布。

本文资源 地址
配套案例:Web特效025 雾面玻璃擦拭效果 查看与获取案例

一、先给这支画笔划边界:Canvas 2D 到底是什么

1. getContext('2d') 给你的不是一个图形对象列表

页面里的 <canvas> 是一个绘图区域,getContext('2d') 返回的是向这块区域发出二维绘制命令的接口。它知道当前填充色、线宽、变换等绘制状态,却不会替你保存"这里有一颗可点击的水珠"这样的业务对象。

html 复制代码
<canvas id="effect" width="800" height="450"></canvas>
<script>
  const canvas = document.querySelector('#effect');
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#1b9b9c';
  ctx.fillRect(40, 40, 120, 80);
</script>

这段代码把一个矩形写入画布的像素。fillRect() 返回后,你不能通过 ctx.getRectangle(0) 找回它,因为并不存在这样的矩形节点。DOM 中的按钮可以作为节点保存、设置样式和监听点击;Canvas 中的矩形需要你自己保存坐标、尺寸和命中规则。

2. "画完就留下像素"意味着移动时要重新组织画面

如果矩形要向右移动,不能只修改"矩形节点的 left"。最直接的做法是保存它的位置,清理上一帧,再以新位置绘制。这里的 x 是以画布像素为单位的横坐标。

javascript 复制代码
const box = { x: 40, y: 40, width: 120, height: 80 };

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#1b9b9c';
  ctx.fillRect(box.x, box.y, box.width, box.height);
}

box.x += 20;
render();

这叫"即时模式":每条绘制命令执行时就影响目标位图,后续的对象状态和重绘顺序由应用自己管理。它很适合大量短命粒子、统一渲染的光斑和擦拭痕迹;如果画面里是十几个需要独立选中、输入文字和无障碍语义的控件,DOM 往往更省事。

3. CSS 大小与实际像素是两套尺寸

画布可能在 CSS 中显示为 800×450,但内部只有 800×450 个绘制像素。在设备像素比为 2 的屏幕上,细线可能被放大得发虚。通常要让绘制缓冲区按显示尺寸和设备像素比设置,并让代码继续用 CSS 像素思考布局。

javascript 复制代码
function resizeCanvas(canvas, ctx) {
  const rect = canvas.getBoundingClientRect();
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  canvas.width = Math.round(rect.width * dpr);
  canvas.height = Math.round(rect.height * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  return { width: rect.width, height: rect.height };
}

const size = resizeCanvas(canvas, ctx);
ctx.fillRect(20, 20, 100, 40); // 坐标仍按 CSS 像素理解

注意:给 canvas.width 或 canvas.height 赋值会清空画布并重置绘制状态,所以应在尺寸变化后重新设置变换并重新绘制。Canvas 2D 的第一条边界不是"能不能画",而是"你是否愿意自己管理像素、尺寸与重绘"。

二、它擅长画什么:路径、图片、像素和合成

1. 路径适合连续形状,不等于可编辑对象

beginPath()、moveTo()、quadraticCurveTo() 与 stroke() 能快速画出流线、光带和笔刷边缘。比如下面把三个点连成一条弯曲的擦拭轨迹;坐标单位仍是当前画布坐标系中的像素。

javascript 复制代码
ctx.beginPath();
ctx.moveTo(80, 220);
ctx.quadraticCurveTo(240, 120, 440, 210);
ctx.lineWidth = 36;
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,.7)';
ctx.stroke();

路径定义了这一笔如何画,绘制完成后并不会自动变成可拖拽的元素。你可以把路径保存在 Path2D 中并用 isPointInPath() 做几何命中,但对象列表、层级和交互状态仍要自己设计。

2. drawImage() 把位图接进同一套绘制流程

案例中一张清晰图片作为底图,一张模糊后的图片作为玻璃表面的雾层。drawImage() 既能画整张图,也能指定源区域和目标区域;绘制前要确保图片已经加载。

javascript 复制代码
const photo = new Image();
photo.src = '/scene.png';
photo.onload = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(photo, 0, 0, canvas.width, canvas.height);
};

这里 canvas.width 和 canvas.height 是绘制缓冲区像素;若代码已经用 ctx.setTransform(dpr, ...) 转换为 CSS 像素坐标,应改用上节 size.width、size.height 作目标尺寸。跨域图片若没有正确配置 CORS,画面可以显示,但随后读取像素或导出画布可能触发安全限制;"能画上去"不等于"能读取回来"。

3. 合成模式决定"新的一笔怎样作用于旧画面"

平常 source-over 会把新颜色覆盖到已有画面上。雾面玻璃案例需要相反的动作:在上层雾图中沿指针轨迹"挖洞",露出下层清晰图片。destination-out 会按新图形的不透明度擦除已有画布内容。

javascript 复制代码
ctx.drawImage(frostLayer, 0, 0);
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(pointerX, pointerY, 80, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';

假如雾层画布下方没有清晰底图,这一笔只会擦成透明,不会凭空生成清晰图像。合成模式是 Canvas 2D 很强的一面:遮罩、拖尾、局部揭晓和发光叠加都能由它完成;同时它也要求你明确"哪一层是源,哪一层是目标"。

三、用雾面玻璃案例看清:Canvas 2D 能做到哪一步

1. 先分层:清晰底图、雾层和交互不是同一件事

这个案例不是让 Canvas 每帧重新绘制整张产品图。底层放清晰图片,顶层 Canvas 保存雾气与水珠;顶层局部变透明时,底图自然透出来。下面是教学版结构,正式案例由 Vue 组件创建相同的两层。

html 复制代码
<div class="scene">
  <img class="scene__photo" src="/scene.png" alt="">
  <canvas class="scene__fog" width="800" height="450"></canvas>
</div>
<style>
  .scene { position: relative; width: 800px; height: 450px; overflow: hidden; }
  .scene__photo, .scene__fog { position: absolute; inset: 0; width: 100%; height: 100%; }
  .scene__photo { object-fit: cover; }
</style>

把图片、雾层和输入边界分开,才能单独替换产品图、调整雾的浓度,或把效果封装成网页组件。如果想直接体验"擦过---露出---回雾",可以打开文章开头资源表中的 025 配套案例。

2. 只生成一次雾层,别在每一帧反复模糊图片

雾气可以由"模糊且略降饱和的底图 + 半透明浅色遮罩 + 少量凝结水珠"组合得到。真正耗时的模糊与水珠绘制先放入离屏画布,只在图片或参数发生变化时重建;动画帧只复用这张缓存。

javascript 复制代码
const frostLayer = document.createElement('canvas');
frostLayer.width = 800;
frostLayer.height = 450;
const fog = frostLayer.getContext('2d');

fog.filter = 'blur(18px) saturate(.7)';
fog.drawImage(photo, -20, -20, 840, 490); // 外扩绘制,减少边缘缺色
fog.filter = 'none';
fog.fillStyle = 'rgba(225,236,232,.32)';
fog.fillRect(0, 0, 800, 450);

上面 photo 是已加载图片,800×450 是示例画布的内部像素尺寸。正式案例还在缓存层绘制细小折射水珠,水珠属于"雾层",所以擦开时它们会一起消失。它们并不是漂浮在 DOM 上的独立小组件。

3. 全屏擦拭为什么需要"每块区域自己的时间"

如果每一次鼠标移动都存一颗圆形笔触,再给笔触数组设置数量上限,擦得越快,旧笔触就越快被新笔触挤掉。我们在调试案例时遇到过这个问题。解决方式不是不断提高数组上限,而是把低分辨率遮罩分成网格,为每格保存最后一次擦拭时间;该格按固定时长保持清晰、再逐渐回雾。

javascript 复制代码
const maskWidth = 200, maskHeight = 113; // 教学版约为 800×450 的四分之一
const touchedAt = new Float64Array(maskWidth * maskHeight);
const holdMs = 5000, fadeMs = 2500;

function alphaAt(index, now) {
  const last = touchedAt[index];
  if (!last) return 0;
  const age = now - last;
  if (age < holdMs) return 255;
  return Math.round(255 * Math.max(0, 1 - (age - holdMs) / fadeMs));
}

每次笔刷经过,只更新覆盖网格的 touchedAt;渲染时把这些透明度写入一张小遮罩,再放大到雾层画布上执行 destination-out。这不是说小网格没有代价,而是把每帧处理规模从整张高清画布降低到较小的二维数组。擦得快慢只改变新覆盖的区域,不再改变旧区域的消失时长。

四、画笔的第一条边界:像素不是会自己行动的对象

1. 要点击某颗水珠,先保存它的逻辑数据

一张雾层画布里可以有几百颗水珠,但 Canvas 不知道"第 37 颗水珠"在哪里。若产品需求要求点击某颗水珠弹出信息,应用必须保存水珠中心、半径、编号等数据,并自己做命中计算。

javascript 复制代码
const drops = [{ id: 'a', x: 180, y: 90, radius: 12 }];

function hitDrop(x, y) {
  return drops.find(drop =>
    Math.hypot(x - drop.x, y - drop.y) <= drop.radius
  ) ?? null;
}

x、y 和 radius 必须在同一坐标系里。若指针事件给的是视口坐标,先减去画布的 getBoundingClientRect();如果内部像素尺寸与 CSS 尺寸不同,再按比例换算。坐标没有对齐,命中区就会偏移。

2. 已经画下的东西无法直接"改属性",需要选择重绘范围

让一颗水珠移动 10 像素,改的是你保存的 drop.x,而不是画布中的"水珠节点"。对简单动画,可以每帧清屏后按数组顺序重画;当背景很复杂时,也可以只重画受影响的区域,但必须处理对象重叠和边缘羽化。

javascript 复制代码
function drawDrops(ctx, drops) {
  for (const drop of drops) {
    ctx.beginPath();
    ctx.arc(drop.x, drop.y, drop.radius, 0, Math.PI * 2);
    ctx.fill();
  }
}

drops[0].x += 10;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawDrops(ctx, drops);

这段只演示对象状态与重绘关系;真实页面若还有背景和其他图层,清屏后也要按顺序补画。多个对象之间的遮挡与合成由绘制顺序决定,不像 DOM 那样天然有一棵可查询的元素树。

3. 复杂交互界面不要全部画进 Canvas

按钮、输入框、可复制的标题和屏幕阅读器需要的语义,如果一股脑画成像素,会额外付出命中、焦点、键盘操作和无障碍支持的成本。更实际的方案是让 Canvas 负责视觉特效,让 DOM 负责真正的界面。下面的监听示例保留原生按钮的点击语义,画布只响应指针:

javascript 复制代码
const button = document.querySelector('#show-details');
button.addEventListener('click', () => {
  document.querySelector('#details').hidden = false;
});

canvas.addEventListener('pointermove', event => {
  const rect = canvas.getBoundingClientRect();
  const x = (event.clientX - rect.left) * canvas.width / rect.width;
  const y = (event.clientY - rect.top) * canvas.height / rect.height;
  console.log({ x, y });
});

这正是"边界"的意思:Canvas 2D 能画出按钮的外观,却不自动拥有 HTML 按钮的行为、语义和输入机制。把它用在擅长的视觉层,通常比要求它包揽整页更可靠。

五、画笔的第二条边界:分辨率与重绘成本

1. 画布越大,每帧搬运的像素越多

1920×1080 约有 207 万个像素,单张 RGBA 缓冲区按 4 字节计算约占 8 MB;如果同时有清晰底图、雾层缓存、遮罩和显示画布,占用与读写量会继续叠加。移动端再按设备像素比放大,成本可能成倍增长。因而应给 DPR 设置合理上限,并在尺寸变化时统一更新画布。

javascript 复制代码
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

这里的上限 2 不是通用标准,而是性能与清晰度的一个起点。若案例需要大量粒子或模糊,低性能设备可以进一步降低内部渲染分辨率;若要精细绘制文字,则应重新评估清晰度损失。

2. 静态内容缓存,动态内容才进动画循环

如果雾层的模糊、水珠和底图在交互过程中都不变,没必要每帧重新执行滤镜和水珠路径绘制。把静态结果保存在离屏画布,交互帧只复制缓存并应用遮罩。下面用 dirty 标记控制什么时候需要一帧:

javascript 复制代码
let dirty = false;
function requestRender() {
  if (dirty) return;
  dirty = true;
  requestAnimationFrame(render);
}
function render() {
  dirty = false;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(frostLayer, 0, 0);
  // 此处再应用当前擦拭遮罩
}
requestRender();

这段适合静态或按需更新的图层。025 案例在"回雾"期间仍需连续动画帧,但完全恢复后就可以停下;下次指针进入再启动。成本取决于画布大小、模糊与像素处理次数,不取决于"用了 Canvas"这一个标签。

3. 先测量,再决定是否换渲染方案

假如画面里有几千条同时更新的粒子、全屏逐像素扭曲或多轮复杂滤镜,Canvas 2D 也许还能跑,但 CPU 端绘制与像素读写可能成为瓶颈。先测帧耗时,确认瓶颈在哪一步,再考虑 WebGL 或 WebGPU;不要只因"效果很酷"就提前换工具。

javascript 复制代码
let last = performance.now();
function sampleFrame(now) {
  const deltaMs = now - last;
  last = now;
  if (deltaMs > 32) console.warn('这一帧超过约 32ms:', deltaMs);
  requestAnimationFrame(sampleFrame);
}
requestAnimationFrame(sampleFrame);

这个数值只能提示卡顿,不能证明卡在 Canvas 绘制,也可能是脚本、布局、图片解码或其他任务。进一步用浏览器性能面板分别看脚本执行、绘制、合成与内存。Canvas 2D 的边界应由目标设备和实测结果确定,而不是凭 API 名字猜测。

六、什么时候继续画,什么时候把笔交给别的工具

1. 先按对象特性选层,不按"高级"与"低级"排序

如果对象数量少、每个都要单独点击和承载语义,先考虑 DOM/CSS;如果是大量二维短命笔触、统一绘制的遮罩、图片合成或水珠纹理,Canvas 2D 很合适;如果核心工作是海量几何、着色器计算或复杂三维场景,再考虑 WebGL。它们不是逐级替代关系,而是面向不同控制范围的接口。

javascript 复制代码
function chooseLayer({ semanticUI, many2DPixels, gpuShader }) {
  if (semanticUI) return 'DOM / CSS';
  if (gpuShader) return 'WebGL';
  if (many2DPixels) return 'Canvas 2D';
  return 'DOM / CSS';
}

console.log(chooseLayer({
  semanticUI: false,
  many2DPixels: true,
  gpuShader: false
})); // Canvas 2D

这是帮助讨论的简化判断,不是自动选型器。同一网页可以同时有 DOM 按钮、Canvas 2D 雾层和 WebGL 背景。关键是把每一层的输入、输出和生命周期说清楚。

2. 做成网页组件时,入口与清理必须成对

把 025 案例放进 Vue 或其他组件体系时,画布绘制逻辑可以独立成函数,组件负责挂载、指针事件和销毁。即使不用框架,下面这段原生 JavaScript 也展示了"绑定---解绑"要成对出现:

javascript 复制代码
function mountPointer(canvas, onPoint) {
  function move(event) {
    const rect = canvas.getBoundingClientRect();
    onPoint({
      x: (event.clientX - rect.left) * canvas.width / rect.width,
      y: (event.clientY - rect.top) * canvas.height / rect.height
    });
  }
  canvas.addEventListener('pointermove', move);
  return () => canvas.removeEventListener('pointermove', move);
}

const unmountPointer = mountPointer(canvas, point => console.log(point));
// 组件卸载时调用 unmountPointer()

正式案例还要取消未完成的动画帧,并在图片加载回调、窗口尺寸监听等位置清理资源。真正可复用的"画笔"不是一堆能画出画面的 API,而是可以明确接入、更新与退出的模块。

3. 回到案例:这支笔能画到哪一步

025 案例表明 Canvas 2D 足以完成雾层缓存、凝结水珠、局部擦除和按区域回雾。清晰产品图交给底层图片元素,圆形笔刷跟着 Pointer Events 走,复杂的界面语义不塞进效果画布。即便未来把水珠换成雨痕、把底图换成建筑模型或产品剖视图,分层与边界判断仍能复用。

javascript 复制代码
const effectConfig = {
  brushRadius: 100, // 画布逻辑像素
  holdMs: 5000,      // 最后一次擦过后保持清晰
  fadeMs: 2500       // 随后逐渐回雾
};

function revealAlpha(elapsedMs) {
  if (elapsedMs < effectConfig.holdMs) return 1;
  return Math.max(0, 1 -
    (elapsedMs - effectConfig.holdMs) / effectConfig.fadeMs);
}

这段把效果最关键的时间边界明确成参数:无论鼠标移动多快,同一位置都按自己的最后擦拭时间恢复。Canvas 2D 的能力边界也由此清晰起来:它能高效地把二维绘制命令变成像素,并提供路径、图像、合成与像素处理;但对象管理、语义交互和性能策略,需要我们在画布外设计。选择它,不是因为所有网页特效都必须用 Canvas,而是因为当前这个效果的视觉任务确实适合这支画笔。

七、关于八荒启

八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。

我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。

八荒启,专为交互动画而生

让复杂事物可探索、可操作、可反馈

如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。

  • 官方网站:https://bahuangqi.com
  • 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
  • 定制服务:可通过官网提交需求或联系人工客服进行评估

感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。

相关推荐
Ai-_Man1 小时前
您您这可以把Dola的多个会话比如说。左侧的多个会话一次性导出吗?不是单条会话里面的多次会对话。用AI导出鸭,答案是可以的
开发语言·前端·人工智能·小程序
excel2 小时前
Nuxt 中使用 useHead 优化 SEO 与 GEO
前端
IT_陈寒3 小时前
SpringBoot启动慢得像蜗牛?原来是这个配置在捣鬼
前端·人工智能·后端
计算机魔术师3 小时前
Muse Spark跑赢Gemini,但真正的底牌是这种设计
前端
PYB33 小时前
【Web·JS·基础】函数的使用和展运算符...objs
前端·javascript
指针向南3 小时前
Chrome读不了HEIC怎么办:原生解码和WASM两条路
前端·图像处理·人工智能·chrome·计算机视觉·wasm
默_笙4 小时前
🚥 给 RAG 装一台心电监护仪:LangSmith 全链路观测(上)
前端·javascript
前端snow5 小时前
ai agent --- mem0 外挂记忆系统
前端
特立独行的猫a6 小时前
用仓颉写一个 Tauri:IPC 的每次往返实现原理(web层到仓颉层的触发过程)
前端·ui·harmonyos·tauri·鸿蒙·仓颉