普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月5日首页

大屏天气展示太普通?视觉升级!用 Canvas 做动态天气遮罩,雷阵雨效果直接封神

作者 李剑一
2026年3月5日 10:37

之前做天气那个模块的时候,突发奇想想做一个大屏实时展示天气状况的蒙版。# Vue实现大屏获取当前所处城市及当地天气(纯免费)

需求

现在大屏上展示天气一般都是在左上/右上做天气的图标/纯文字的展示,虽然看起来非常直观,但是对于大屏这种需要炫酷效果的产品显得不合适。

目前市面上对于天气这一块也并不是非常重视,我接触的大屏项目/产品对这部分基本都没啥要求。

但是能够展示天气效果对于大屏本身有相当不错的加成效果。

屏幕录制 2026-03-05 102909.gif

所以我开发了这个大屏天气展示蒙版组件,能够根据当前天气状况以蒙版的形式展示出来,目前支持多种天气的展示效果。

方案

视频方案

一开始考虑的是纯视频解决方案,首先说这个方案非常的简单,将视频以背景图的形式放在蒙版上,通过 pointer-events: none; 鼠标穿透就能算是完成了。

但是实际操作过程中发现问题比较多,首先是透明背景需要特定格式的视频才能够支持。

必须使用支持 Alpha 通道(透明通道)的视频格式‌。

常见支持透明背景的格式包括:

  • ‌WebM(VP8 或 VP9 编码 + Alpha 通道)‌:Chrome、Firefox 和 WebView2 等 Chromium 内核环境支持良好 。
  • ‌MOV(Apple ProRes 4444 编码)‌:支持 Alpha 通道,但主要在 macOS 和专业软件(如 Final Cut Pro、After Effects)中使用 ‌。
  • ‌MP4(H.265/HEVC 编码)‌:部分平台(如 WebView2)支持含 Alpha 通道的 H.265 视频。

但是我在网上并没有找到相应格式的视频,自己录也弄得不好,所以放弃了。

另外以视频作为背景图在弱网环境下比较难加载,毕竟视频一般都要超过5M以上了。

但是如果有相应的视频,效果做出来绝对是最顶尖的。

GIF动图方案

和视频方案基本一致,唯一的区别是使用 GIF 动图作为背景图使用,效果也非常好。

问题点在于需要UI做一系列的动图效果,GIF 动图在加载上速度也不算太快,毕竟比较好的动图也不会太小。

还有一个问题在于如果屏幕大小发生变化,或者不是标准屏,可能存在图片拉伸/裁切等问题。

如果有UI协助,采用这个方案也非常不错。

Canvas渲染

采用 Canvas 渲染的方案实现这个是我最后的选择,原因有三:

  • Canvas性能开销不算太大,对低端设备相对比较友好
  • Canvas不依赖静态资源,弱网环境下不影响加载效果
  • Canvas能够根据屏幕大小达到自适应效果,避免特殊屏幕尺寸显示异常

采用 Canvas 粒子效果和渐变效果模拟阳光和雨滴、雪花等等状态,实现天气状态。

代码

初始化遮罩层

目前使用的是 Vue3 框架,因为是遮罩层所以采用 pointer-events: none; 鼠标穿透,避免影响大屏正常的操作。

<template>
    <canvas
        ref="weatherCanvas"
        class="weather-mask"
        :style="{ opacity: maskOpacity }"
    ></canvas>
</template>

<style scoped>
.weather-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none; /* 鼠标事件穿透 */
    z-index: 10; /* 确保在内容上方,可根据项目调整 */
}
</style>

这里需要注意,初始化画布的时候要记得设置一下width、height,让画布充满整个屏幕。

晴天效果

晴天效果采用光照渐变效果,在 Canvas 中绘制了一个从左上角到右下角线性渐变的效果,来模拟阳光照射的感觉。

同时增加部分光斑效果,模仿阳光投射在玻璃上的感觉。

// 创建从左上角到右下角的线性渐变(模拟阳光照射)
lightGradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);

// 绘制光斑效果
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 200, ${Math.random() * 0.1 + 0.05})`;
ctx.fill();

雨天效果

雨天采用粒子效果,实现细长的雨丝效果,这里没有做明显的区分,对于小雨、中雨、大雨。

其实想要区分也很简单,只要控制粒子的数量和速度即可。

ctx.beginPath();
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(particle.x, particle.y + particle.height);
ctx.strokeStyle = particle.color.replace('OPACITY', particle.opacity);
ctx.lineWidth = particle.width;
ctx.stroke();

这里我进行了简单的封装,因为雨天、雪天、雾天等等大部分都用到了粒子效果,所以针对粒子的绘制部分进行了封装。

因为下雨是一个连续的绘制过程,所以动画部分做了简单的循环。

const animate = () => {
    updateParticles(props.weatherType);
    animationId = requestAnimationFrame(animate);
};

下雪效果

下雪本质上和下雨区别不大,唯一的区别是粒子的状态、运动速度和运动方向。

这里没有采用雪花造型的粒子,确实做出来了,但是效果并不好,不如这种圆形的效果看起来好一些。

雪花的绘制和雨滴的绘制区别在于,雨滴的宽度是1,而雪花的大小是一定范围内随机的。

ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fillStyle = particle.color.replace('OPACITY', particle.opacity);
ctx.fill();

雷阵雨效果

雷阵雨效果是这里面个人觉得做的最好的一个,通过对下雨效果增加随机雷电闪烁屏幕的效果,达到雷阵雨天气的遮罩。

下雨仍然是复用的。

// 绘制主闪电路径
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(255, 255, 255, ${thunderAlpha})`;
ctx.lineWidth = Math.random() * 8 + 4;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(startX, startY);

// 绘制闪电分支
ctx.lineWidth = Math.random() * 4 + 1;
ctx.beginPath();
ctx.moveTo(branch.startX, branch.startY);
let bx = branch.startX;
let by = branch.startY;
const dx = Math.random() * 30 - 15;
const dy = Math.random() * 20 + 5;
ctx.lineTo(bx + dx, by + dy);
bx += dx;
by += dy;
ctx.stroke();

// 闪烁效果
ctx.fillStyle = `rgba(255, 255, 255, ${thunderAlpha * 0.1})`;

总结

这个遮罩我做了好几天,Canvas部分我也不是特别的熟悉,所以很多地方仍然有非常大的优化空间,有感兴趣的朋友可以移步下面的文章获取源代码。

# 动态天气实时渲染动态生成组件,附源代码及详细注释

至于为啥收费,我也是想尝试一下代码还能不能搞到钱,毕竟现在的软件行业白嫖是大家的常态。

如果您能支持1元钱那我不胜感激,如果确实认为不值,自己能够写出更好的,那我也祝福。

一共做个几个效果:晴、雨、雪、雾、雷阵雨、多云、沙尘、阴天。有兴趣的朋友可以运行起来自行查看一下。

昨天以前首页

【Three.js多相机渲染】如何在同一场景里实现“画中画”效果

作者 叶智辽
2026年3月2日 08:59

前言

你以为一个场景只能有一个相机?太天真了

上个月,产品经理拿着一个监控大屏的设计图来找我:

“小叶,你看这个效果——主画面看整个车间,右下角有个小窗口专门盯着那台最关键的设备,实时放大,能不能做?”

我一看,这不就是“画中画”吗?电视里看球赛的时候,主画面全场,小窗口给特写镜头,一模一样。

“能做是能做……”我脑子飞速转了一圈,“但你要知道哦,这是3D场景,不是2D视频,得渲染两次。”

产品经理眨眨眼:“那又怎样?你就说能不能做吧。”

“能。”

为了这个“能”,我研究了一下午多相机渲染。今天就把研究成果分享出来,让大伙儿少走弯路。


一、多相机渲染是啥?

平时我们写Three.js,都是一个场景、一个相机、一个渲染器:

renderer.render(scene, camera);

这叫单视角。

但Three.js允许你在同一帧里用多个相机渲染同一个场景。每个相机可以看到不同的角度、不同的位置,然后通过设置视口(viewport),把它们渲染到屏幕的不同区域。

比如:左边一个相机看整体,右边一个相机看局部;或者主画面占满,右下角一个小窗口显示另一个视角。

这就是多相机渲染。


二、最简单的画中画实现

先来个最基础的版本:主相机看整个场景,副相机放在一个设备旁边,把画面渲染到右下角一个小方框里。

1. 创建两个相机

// 主相机:看整体
const mainCamera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
mainCamera.position.set(10, 10, 20);
mainCamera.lookAt(0, 0, 0);

// 副相机:特写某个设备
const subCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); // 宽高比暂时设1,后面按视口算
subCamera.position.set(2, 2, 5); // 假设设备在原点附近
subCamera.lookAt(0, 0, 0);

2. 在渲染循环里分别设置视口

function animate() {
  requestAnimationFrame(animate);

  // 1. 渲染主相机(全屏)
  renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
  renderer.render(scene, mainCamera);

  // 2. 渲染副相机(右下角 300x200 的区域)
  const subWidth = 300;
  const subHeight = 200;
  renderer.setViewport(
    window.innerWidth - subWidth,  // x 起点
    0,                              // y 起点(从底部开始算的话,这里是0)
    subWidth,
    subHeight
  );
  renderer.render(scene, subCamera);
}

注意:setViewport 的坐标系是左下角为原点(0,0)。所以右下角的位置是 (window.innerWidth - subWidth, 0)

3. 别忘了清除深度

两个相机渲染同一个场景,如果不做处理,第二个相机的渲染可能会被第一个相机的深度信息干扰。

解决方案:每次渲染前清除深度缓冲区,但保留颜色缓冲区(或者直接重新清除全部)。

// 渲染主相机前,正常清除
renderer.clear();

// 渲染主相机
renderer.render(scene, mainCamera);

// 渲染副相机前,只清除深度(不清除颜色,否则主画面被清掉)
renderer.clearDepth();
renderer.render(scene, subCamera);

这样副相机的画面就能正确覆盖在主画面之上。


三、让副相机可交互

光有画面还不够,产品经理说:“小窗口能不能也支持旋转、缩放?我想仔细看看那台设备的细节。”

也就是说,副相机得绑定一套控制器。

1. 两套控制器?

直接用两个 OrbitControls 绑到同一个 canvas 上会有冲突。因为鼠标事件只有一个,你不知道用户是想操作主相机还是副相机。

解决方案:通过点击切换激活状态。点击哪个窗口,哪个相机就响应控制器。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// 主相机控制器
const mainControls = new OrbitControls(mainCamera, renderer.domElement);
mainControls.enableDamping = true;

// 副相机控制器(先禁用它)
const subControls = new OrbitControls(subCamera, renderer.domElement);
subControls.enabled = false;

// 点击事件判断
renderer.domElement.addEventListener('click', (event) => {
  const mouseX = event.clientX;
  const mouseY = event.clientY;

  // 判断是否点击在副相机区域
  const subWidth = 300;
  const subHeight = 200;
  const subLeft = window.innerWidth - subWidth;
  const subBottom = 0;
  const subRight = window.innerWidth;
  const subTop = subHeight;

  if (mouseX >= subLeft && mouseX <= subRight && mouseY >= subBottom && mouseY <= subTop) {
    // 点击在副窗口,激活副相机控制器,禁用主相机控制器
    subControls.enabled = true;
    mainControls.enabled = false;
  } else {
    // 点击在主窗口,激活主相机控制器,禁用副相机控制器
    subControls.enabled = false;
    mainControls.enabled = true;
  }
});

2. 控制器的更新

在动画循环里,同时更新两个控制器:

function animate() {
  requestAnimationFrame(animate);

  mainControls.update(); // 即使 enabled=false 也可以调用,只是没效果
  subControls.update();

  // 渲染代码同上
  // ...
}

四、进阶:小地图(俯视图)

画中画还有一个常见用法:小地图。主画面是自由视角,右下角显示一个从上往下的固定俯视图,帮助用户定位。

实现起来超级简单:

// 创建俯视相机
const mapCamera = new THREE.OrthographicCamera(-20, 20, 20, -20, 0.1, 100);
mapCamera.position.set(0, 30, 0);
mapCamera.lookAt(0, 0, 0);
mapCamera.up.set(0, 0, 1); // 让Z轴朝上,这样俯视图更符合直觉(如果场景是Z轴朝上的话)

// 在动画循环里
renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
renderer.render(scene, mainCamera);

renderer.clearDepth();
renderer.setViewport(window.innerWidth - 200, window.innerHeight - 200, 180, 180);
renderer.render(scene, mapCamera);

注意正交相机 OrthographicCamera 的参数:left/right/bottom/top 决定了视景范围,数值越小,放大倍数越大。

为了让小地图更清晰,可以关闭一些后期效果,或者用简单的 MeshBasicMaterial 渲染一个副本,但为了简单,直接复用原场景也行。


五、坑点汇总

1. 宽高比

副相机的宽高比要跟视口的宽高比保持一致,否则画面会拉伸:

subCamera.aspect = subWidth / subHeight;
subCamera.updateProjectionMatrix();

每次窗口大小变化时,也要重新计算。

2. 深度冲突

如果不调用 clearDepth(),第二个相机的渲染可能会因为深度测试失败而显示不全。上面已经给出解法。

3. 控制器冲突

上面用了点击切换的方法,但用户可能想同时操作?不太现实,因为鼠标只有一个。如果非要同时操作,可以考虑把副相机绑定到键盘控制,或者用不同的鼠标按钮。

4. 性能

多渲染一次,性能开销肯定翻倍。如果副相机视口很小,可以降低它的渲染分辨率或关闭阴影、后期等:

// 在渲染副相机前,临时关掉阴影
renderer.shadowMap.enabled = false;
renderer.render(scene, subCamera);
renderer.shadowMap.enabled = true; // 恢复

或者更狠一点:副相机用更低精度的几何体(LOD),但实现起来复杂,这里不展开。

5. 清理

副相机用完记得 dispose,尤其是如果动态创建和销毁:

subCamera = null;
// 如果有控制器,也调用 dispose
subControls.dispose();

六、实战:设备特写画中画

最后分享一下我在那个监控项目里的实际代码片段:

// 初始化
const mainCamera = new THREE.PerspectiveCamera(45, width/height, 0.1, 1000);
const subCamera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
subCamera.position.copy(device.position.clone().add(new THREE.Vector3(1, 1, 2)));
subCamera.lookAt(device.position);

// 动画循环
function render() {
  requestAnimationFrame(render);

  // 更新主控制器
  mainControls.update();

  // 主渲染
  renderer.setViewport(0, 0, width, height);
  renderer.clear();
  renderer.render(scene, mainCamera);

  // 副渲染(右下角 300x200)
  renderer.clearDepth();
  renderer.setViewport(width - 310, 10, 300, 200);
  renderer.render(scene, subCamera);

  // 画一个边框,突出小窗口
  // 可以用CSS,或者用另一个Sprite/Canvas画上去,这里省略
}

产品经理看了很满意,说:“就是这个效果!”

我心想:为了这个效果,我研究了半天多相机,但值了。


七、总结

多相机渲染并不复杂,核心就三步:

  1. 创建多个相机
  2. 在渲染循环里分别设置视口
  3. 处理好深度清除和宽高比

有了它,你可以实现:

  • 画中画特写
  • 小地图导航
  • 分屏对比
  • 多角度监控

下次产品经理再提类似需求,你就可以自信地说:“能,而且我能给你三个方案。”


互动

你用过Three.js的多相机渲染吗?实现了什么好玩的效果?评论区晒出来,让我抄抄作业 😏

下篇预告:【Three.js后期处理】如何让你的场景拥有电影级调色

手写无限画布4 —— 从视觉图元到元数据对象

作者 光头老石
2026年2月27日 17:13

画布上的每一个像素都是稍纵即逝的,真正永恒的,是背后那套被精心设计的元数据(Metadata)规范。

尽管在前面的篇章中,我们一路披荆斩棘,搞定了坐标系、渲染层和基本交互,让演示工程初具雏形。但 Canvas 本质上只是一块没有记忆的像素面板。

要想从理论走向工程落地,实现支持持久化与多人协同的业务,最核心的架构法则在于:必须将画布上的任意元素,都抽象并定义为可传输、可持久化的元数据对象(Metadata Object)。

元数据定义 (Metadata Definition)

我们要彻底抛弃"直接在画布里 new Konva.Rect()" 的思维惯性。

在一个成熟的白板应用架构中,画布引擎只是一个"读报机器",它读的报纸,就是我们定义的元数据规范(Metadata Schema)

为了达到我们最终建立一个类似 Excalidraw 的既定目标,我们在规范数据结构时,绝不能只停留在纯粹的"几何图形"定义上。我们必须在其之上,附加明确的预制业务概念。我们不仅要描述它是一个 Rect(矩形),更要描述它在业务里是一张 StickyNote(便利贴),还是一根 Connector(连接线)。

如下代码,这就是我们实际落地的元数据规范:

// src/schema/types.ts

// 所有图元共享的基因——它们必须遵守的基础契约
export interface BaseElementData {
  id: string; // 唯一宇宙编号,协同与更新的基石
  type: ElementType; // 业务大类
  x: number;
  y: number;
  width: number;
  height: number;
  hitColor: string; // 上一篇的命中测试色值,也要元数据化
  strokeColor: string;
  backgroundColor: string;
  opacity: number;
  zIndex: number; // 层级控制,决定覆盖关系
  isLocked: boolean; // 业务属性:用户是否锁定了该元素
  // ...
}

// 业务派生:形状、文字、线条各有自己的专属字段
export interface ShapeElementData extends BaseElementData {
  type: "rectangle" | "ellipse" | "diamond";
}

export interface LinearElementData extends BaseElementData {
  type: "arrow" | "line";
  points: number[][]; // 途经的折点
  startArrowhead: "arrow" | "triangle" | "none";
  endArrowhead: "arrow" | "triangle" | "none";
  startBindingId: string | null; // 线头绑定的元素 ID
  endBindingId: string | null;
}

// 终极联合类型:无限画布的唯一真理对象
export type CanvasElementData =
  | ShapeElementData
  | TextElementData
  | LinearElementData;

注意一个关键细节:上一篇讲到的命中测试色值 hitColor,也被我们收编进了元数据定义。从此刻起,一个图形的一切——它在哪、它多大、它长什么样、它怎么被点中——全部由这颗 JSON 树的一个节点来描述。再也没有游离在数据结构之外的"野状态"了。

纯元数据驱动带来的红利

当你把屏幕上所有花里胡哨的图形,都严格浓缩成上述哪怕只有几百 KB 大小的纯 JSON 文本时,奇迹发生了:

  1. 绝对纯净的持久化与协同:现在保存用户作品,不过就是做一次 JSON.stringify。而做多人协同,也不过是当某个 Node 的 x 发生改变时,通过 WebSocket 向房间里的其他人广播一个极小的 Diff 补丁 {"id": "node_1", "x": 250}
  2. 极其廉价的时间机器:撤销(Undo)与重做(Redo)再也不是什么黑科技。因为数据被极度抽象了,你只需要使用类似 Immer.js 等不可变数据结构工具,把每一步操作的 JSON 快照(或者 Delta 片段)保存在数组里,指针前后移动,就是时间倒流。
  3. 彻底的跨端解耦:这套 Metadata 甚至都不知道 Canvas 的存在。你可以把同一团 JSON 丢给 Web 端用 Konva 渲染,扔给 iOS 用 CoreGraphics 渲染,或者丢给后端 Node 帮你无头渲染出一张 PDF。

接入状态管理:Zustand

有了元数据定义,接下来的问题是:这颗 JSON 树放在哪?谁来读它、写它、通知别人它变了?

绝不能让 Konva 本身(View 层)既当爹又当妈地去存储这些业务数据,这会导致视图状态和业务逻辑严重耦合。我们引入现代轻量级状态管理库 zustand 作为单一事实来源(Single Source of Truth),对整个工程做一次严格的分层。

打开 src/store.ts,这是整个工程的心脏:

// src/store.ts

export const canvasStore = createStore<CanvasState>((set) => ({
  // 全部元素的 Record 字典,key 为 id
  elements: initialElements,
  // 应用运行时状态(当前工具、缩放、视口偏移、选中态...)
  appState: defaultAppState,

  // ——— 以下全是纯函数式的 Actions ———
  updateElementProps: (id, props) =>
    set((state) => ({
      elements: {
        ...state.elements,
        [id]: { ...state.elements[id], ...props },
      },
    })),

  addElement: (el) =>
    set((state) => ({
      elements: { ...state.elements, [el.id]: el },
    })),

  selectElement: (id) =>
    set((state) => ({
      appState: { ...state.appState, selectedElementIds: id ? [id] : [] },
    })),
  // ...
}));

值得反复品味的是:无论是创建元素、更新坐标、还是切换选中态,Store 里执行的全部都是浅拷贝替换{ ...state.elements, [id]: ... })。没有任何副作用,没有任何直接 DOM 操作。这意味着前面说的 Undo/Redo "时间机器",你只需要把这些 Immutable 快照存进一个栈里就好了——就是这么廉价。


引擎订阅:一个极致的"哑巴渲染器"

Store 管数据,那谁管画面?答案是 src/engine/index.ts——我们的引擎总控 EngineFacade。它做的事情极其克制:只读数据,只画画面

// src/engine/index.ts — 订阅逻辑

this.unsubscribe = canvasStore.subscribe((state) => {
  // 图元变更 → 重新渲染
  if (state.elements !== prevState.elements) {
    this.shapeRenderer.render(state.elements);
  }
  // 选中态变更 → 同步 Transformer 控制框
  if (state.appState.selectedElementIds !== prevState.appState.selectedElementIds) {
    this.selectionManager.syncSelection(state.appState.selectedElementIds);
  }
  // 视口变更 → 同步 Stage 缩放/平移
  if (state.appState.zoom !== prevState.appState.zoom || ...) {
    this.viewportManager.syncViewport(zoom, scrollX, scrollY);
  }
});

请注意这里的引用相等性比较(!==)。Zustand 的不可变数据范式保证了:只有当数据真正改变时,引用才会不同。所以引擎的每一次重绘都是精确触发的——不多画一帧,不少画一帧。

整个数据流形成了一个干净的单向环路

用户操作 → Store 更新元数据 → Engine 监听到变更 → Konva 重绘画面
                ↑                                      │
                └──────── 用户拖拽,Engine 回写坐标 ────┘

Konva 永远不私自修改任何数据。当用户拖拽一个图形时,Engine 层拦截 Konva 的 dragmove 事件,取得新坐标,然后调用 store.updateElementProps(id, { x, y }) 把新位置"汇报"回 Store。Store 更新后触发订阅回调,Engine 再根据新数据重绘——一切都是单向、可追溯的。

而浮在画布之上的 React UI(工具栏、属性面板)也是同一个 Store 的消费者:

// src/App.tsx — 属性面板(精简)
const PropertiesPanel = () => {
  const selectedIds = useCanvasStore(
    (state) => state.appState.selectedElementIds,
  );
  const elements = useCanvasStore((state) => state.elements);
  const updateElementProp = useCanvasStore((state) => state.updateElementProp);

  const el = elements[selectedIds[0]];
  // 从 store 读数据,渲染颜色选择器、描边样式按钮...
  // 用户点击后,直接调用 updateElementProp() 回写 store
};

我们常说,前端框架 React 的核心公式是 UI = f(State)。 而无限白板的架构真谛就是:Canvas = Konva(Metadata)


回望:四层地基已就位

至此,我们用四篇文章,自底向上地垒完了无限画布系统的四层地基:

层级 解决的核心问题 关键技术
坐标系 "无限"与"缩放"的数学本质 世界坐标 ↔ 屏幕坐标变换
渲染层 高性能绘制大量图形 Konva Scene Graph, 局部重绘
交互层 重建事件感知 离屏 Color Picking, Hit Testing
对象层 让画布拥有序列化的组织 元数据 Schema, Zustand 单向数据流

历经四篇文章的打磨,我们从最底层的数学坐标系起步,最终构筑起这套‘可协同、可撤销、可跨端’的数据驱动画布架构。这段工程演进之路的破局关键,其实就是两个字:克制。清晰划定架构的分层边界,想透每一层该做什么,并坚决杜绝越界。

本系列 实例项目已上传GitHub github.com/Seanshi2025… 项目上有完整的架构组织文档。

手写一个无限画布 #3:如何在Canvas 层上建立事件体系

作者 光头老石
2026年2月27日 10:29

你以为你点中了一个圆,其实你只是点中了一堆毫无意义的像素点。在画布里,所谓的“选中”,不过是一场精密的数学与色彩幻术。

上一篇我们终于搞定了渲染层,并明确选择了 Konva (Canvas 2D) 作为我们的底层渲染基石。现在,我们的屏幕上终于可以丝滑地渲染出极具表现力的图形了。

但是,当你试图把鼠标悬停在其中一个图形上,或者想拖拽一条连线时,你会遭遇一个巨大的反直觉打击:浏览器完全不知道你点的是什么。

在传统的前端开发中,原生 DOM 是一棵界限分明的树。鼠标移入一个 <div>,浏览器引擎会在底层自动做碰撞检测,并把 mouseenterclick 事件准确无误地派发给这个节点。如果你给 <div> 加了圆角(border-radius)甚至复杂的 clip-path,浏览器依然能完美识别出精确的边缘。这种体验太理所当然,以至于我们从未思考过背后的代价。

但在 Canvas 的世界里,这套秩序完全失效了。

对于浏览器来说,不管你在 Canvas 里画了多少个圆圈、多复杂的文字,它看到的永远只有一个扁平的 <canvas> 标签。 当用户点击屏幕时,浏览器的原生 Event 对象只能递给你一个冷冰冰的坐标:{ clientX: 500, clientY: 400 }。至于这个坐标下是空气、是红色正方形,还是三个交叠在一起的半透明多边形,对不起,只能你自己算。

要在毫无知觉的像素油盆上,重新赋予图形被“感知”的能力,这就是 命中测试(Hit Testing) 的核心命题。

直觉陷阱:纯算几何碰撞

面对这个问题,多数人脑海里冒出的第一个念头一定是算数学题。

“既然我知道画布上每个方块的长宽、每个圆的半径,那鼠标点下去的时候,去遍历所有图形做个碰撞测试不就好了?”

比如点矩形,就看鼠标坐标是不是在它的上下左右边界内;点圆,就算勾股定理看距离是不是小于半径;如果是多边形,大不了掏出大学计算机图形学里教的“射线法(Ray-Casting)”,看看射线和多边形交点是奇数还是偶数。

在很多游戏开发新手教程里,这确实是讲解命中测试的第一课。

但只要你真的在业务里动手写过,就会立刻体会到这种朴素算法带来的“工程绝望”:

如果是最基础的方块和圆还好,可你在白板工具(如 Excalidraw / Figma)里,最常面对的是用户鼠标画出的一条粗细不均、极度扭曲的自由手绘墨迹(Freehand Draw)。成百上千个点连出来的畸形曲线,你拿什么算交点?

即使你咬着牙把每根线段都算了,还有图形的中空与穿透问题。当用户点在一个空心圆环的正中间,或者字母 "O" 的空白处时,根据最粗糙的外围包围盒(Bounding Box),它是被命中的;但这根本违反了用户“我明明点在透明的地方,我想点它背后元素”的心理预期。哪怕你真算出了鼠标确实落在图形线条上,你又怎么确保,这层图形的正上方,没有被另一个半透明的阴影盖住呢?

别忘了最绝杀的性能噩梦。不仅是点击,鼠标每在屏幕上划过一个像素,就会高频触发 mousemove。如果同屏有几千个杂乱的图形交叠,每移动一毫米就要把所有多边形的射线方程重新算一遍,你的 CPU 风扇会直接起飞,页面帧率瞬间崩盘。

想靠纯写 if-else 的几何穷举来搞定一个不仅带各种圆角、线宽、自交错,还带层级遮挡的生产级别白板交互,可以说是直接在给 CPU 判死刑。


优雅的黑魔法:离屏 Canvas 与 Color Picking

针对纯正向几何数学算不通的情况,业界的顶级绘图引擎往往会使用一招极度聪明且优雅的逆向黑魔法:利用颜色查表法(Color Picking)。这也是 Konva 最为核心的看家本领机制。

hit-test-color-picking.png

它的核心逻辑堪称“暗度陈仓”,分为以下几个精妙的步骤:

1. 建立影分身(Hidden Canvas)

在内存中,创建一个跟主屏幕尺寸完全一致的隐藏 Canvas(用户看不见它)。主屏幕负责渲染展现给用户看的漂亮图形,而这个“影分身”只专门用来做苦力——命中测试。

2. 分配身份色(Color Hash)

当我们要往主屏幕画一个崭新的图形(比如一个带有高斯模糊阴影的蓝色虚线圈)时,引擎会在内存里给这个图形分配一个全局唯一、随机生成的 RGB 颜色值(比如 #000001)。 然后在内存的隐藏 Canvas 的同样坐标处,用这个唯一颜色 #000001 画一个同样轮廓的圆。无论主画布上的圆有多花哨,隐藏画布上的圆统统画成没有阴影、没有抗锯齿的纯色实心/实线

与此同时,维护一个字典(Hash Map),记录:#000001 映射到 蓝色虚线图对象引用

3. O(1) 的降维打击:只读一个像素

见证奇迹的时刻到了。 当前的场景是:主画布上画了成千上万个复杂的图形。隐藏画布上也用同样的布局画了成千上万个纯粹色块。

当用户在主屏幕上点击 (x: 500, y: 400) 时,引擎不去做任何数学几何碰撞计算,除了获取坐标外只做极其底层的一步:

  1. 走到隐藏 Canvas 面前。
  2. 精确地读取它 (500, 400) 这个坐标点上的 1 个像素的 RGB 颜色值getImageData)。
  3. 如果读出来的颜色是黑色(完全透明),说明没点中任何东西。
  4. 如果读出来的颜色是 #000001,引擎立刻去 Hash Map 里查表——破案了!对应的是那个蓝色的虚线圈对象。

为什么这个方案是统治级的?

  1. 彻底无视几何形状的难度。不管你画的是自由手绘还是残缺的文字轮廓,只要它被渲染引擎画在屏幕上,那对应的颜色像素就实打实地落在了隐藏画布上。它巧妙地利用底层的 GPU 渲染规则来替你完成极度复杂的轮廓光栅化判定。
  2. 天然解决重叠遮挡。主画布怎么叠加层级的,隐藏画布也是按同样顺序绘制的。你在隐藏画布上读出来的那个带颜色像素,必然是最顶层、没被别人遮挡的那个对象的颜色。完全不需要自己遍历判断层级。
  3. 极端的性能空间换时间。把原本复杂的 O(N×几何顶点数)O(N \times 几何顶点数) 的每帧遍历计算,直接降维成了读取内存图像一个单像素点的 O(1)O(1) 常数级查表时间。即使屏幕上有十万个对象,鼠标在上面疯狂移动也是绝对丝滑的。

站在巨人的肩膀:这就是 Konva

要在原生 Canvas 上实现一个可用于生产环境的稳健命中测试系统基建,工作量是极其庞大的。你要自己去维护那个巨大的离屏画布上下文同步、自己分配十六进制颜色、自己实现局部重绘优化、还要自己派发所有的模拟 DOM 冒泡事件。

这正是我们放弃从零手写引擎底层,转而选型采用 Konva 的终极原因。

Konva 在底层极其克制且优雅地封装了这套“离屏颜色拾取算法”。在开发者眼里,你完全感受不到那个诡异的“彩色隐藏画布”的存在。

它直接把这套脏活累活,包装成了我们最熟悉的、一如在写原生 DOM 一样的前端语法范式。这就让我们能够完全剥离繁复的数学几何泥潭,将精力投入在画布“事件分发与交互流控制”上:

// 这种久违的、确定的秩序感,对于开发无穷交互的白板来说是极其珍贵的。
import Konva from "konva";

const rect = new Konva.Rect({
  x: 50,
  y: 50,
  width: 100,
  height: 50,
  fill: "blue",
  draggable: true, // 开启拖拽!底层所有复杂的变换全自动运算并重绘画布。
});

// 你仿佛重新拥有了原生的 DOM 事件绑定系统
rect.on("mouseenter", () => {
  document.body.style.cursor = "pointer";
  rect.fill("red"); // 悬浮触发变色响应
});

rect.on("mouseleave", () => {
  document.body.style.cursor = "default";
  rect.fill("blue");
});

// 即使有成百上千个图形交叠,它也能极速计算,精准捕捉顶层响应
rect.on("click", (e) => {
  console.log("极速且精准地点中了我:", e.target);
});

有了 Konva 兜底解决“感知盲区”,我们终于补齐了跨越无限画布最重要、也是最难缠的一块技术栈拼图。

我们不再是在冷冰冰的像素点数组上作画,而是真正在操控和编排一个个有边界、能响应手势、知晓自身存在的“实体对象”

经历三篇的文章,我们已经打通了从“坐标系”、“底层渲染引擎选型博弈”到“重建事件分发秩序”的全部技术基建。

接下来,我们将长驱直入应用数据的深水区:在这块充满感知能力的画布上,我们该如何用正确的数据结构来对这些可被协同、可被导出、可被反序列化的对象进行定义?

❌
❌