最近对 canvas 绘图产生了点兴趣,工作中偶尔会用到一点 canvas,但是不多。空闲时间折腾了点东西,顺便写个笔记记录一下,在这之前并没有了解过 canvas 引擎相关的技术原理,只是工作中接触过 pixi.js 的部分 API。

所以这里纯粹是个人的折腾笔记。

一、Canvas 知识要点

canvas 是 HTML5 的一个特性,提供了一系列 Javascript 接口来进行图形的绘制,它除了支持 js 2d 的绘制,还支持 WebGL 3D 渲染。想要 canvas 用的溜,基础知识得搞定,有几个点我前前后后折腾过几次,总是记不住,或许写下来,就变得通透了。

模糊问题

如果是第一次开发 canvas ,基本上都会遇到图像模糊的问题,比如下图中的圆边缘看起来有些模糊

Image

这个模糊问题通常是由于设备的 DPR 大于 1 导致的,在 DPR = 1 的设备中,1个逻辑像素由一个物理像素绘制,但是 DPR = 2 的设备中,1个逻辑像素将绘制到2个物理像素中,相当于把图形放大了一倍,所以看起来会模糊。

要解决这个问题,了解了 DPR 后,还需要了解两个知识点:画布宽高和样式宽高。

画布宽高为 canvas 标签中设置的宽高,如 <canvas width=“600” height=“300></canvas> ,样式宽高则是 css 样式中指定的宽高。

画布宽高定义了画布的真实尺寸,而样式宽高则是画布最终展现出来的尺寸,两者如果比例不一致,则会出现图像拉伸变形的情况。

前面说的模糊问题可以理解为图形被放大了 DPR 倍,那缩小回来即可,先将画布尺寸设置为样式尺寸的 DPR 倍

Image

此时可以看到图形已经变得清晰了,但是位置和大小似乎和预期的不一致,解决这个问题,还需要设置一个缩放比例 ctx.scale(DPR, DPR)

Image

模糊的问题到这里大概可以告一段落了,至少知道了该如何解决。

动画

canvas 的动画实现为帧动画,在每一帧中都需要去重绘整个画布,当然也可以设计局部重绘的方案,不过局部重绘在进行 diff 的阶段的开销很可能会大于全量重绘,复杂度也会提升几个量级。

既然是动画,那肯定离不开 requestAnimationFrame() ,后面简称 raf,写了个个简单的例子来看如何使用 raf 绘制动画。

function update() {
  // draw frame ...

  // animation end
  if (!ended) {
    requestAnimationFrame(update)
  }
}
requestAnimationFrame(update)

这是一个绘制动画的基本框架,raf 会将 update 注册为异步事件,在浏览器下一次重绘前会清空该队列,以保证动画的流畅,而动画是连续的一帧接一帧,因此调用上体现为递归的调用方式。

填充上一段简单的代码

let x = 100;
function update() {
  // clear canvas
  ctx.clearRect(0, 0, $canvas.width, $canvas.height);
  ctx.beginPath();
  ctx.fillStyle = "red";
  ctx.arc(x, 150, 20, 0, Math.PI * 2);
  ctx.fill();
  ctx.closePath();
  x++;
  // animation end
  if (x < 200) {
    requestAnimationFrame(update);
  }
}
requestAnimationFrame(update);

2022-06-17 09.42.38.gif

上面的代码是将小球水平方向移动移动100像素,那这个动画执行的总时间是无法确定的。如果想要指定动画执行时间,可以用时间来计算每一帧的位移,在理想情况下,每一帧的间隔为 16ms,假设需要在 2 秒的时间执行完这个动画,则 x 每一帧需要位移 100 / (2000 / 16) = 0.8 像素,将代码中 x++ 改为 x += 0.8 即可。

仔细想一下,上述的方案实际上是有问题的,不能保证 FPS 保持在 60 ,当 FPS 发生波动,动画时长可能会产生很大的误差。既然这样,换个思路想,可以在每一帧的时候,使用时间戳来计算百分比,从而得到当前的增量值。

let dx = 100,
    duration = 2000,
    start = 0;
  function update(t: number) {
    // 记录开始时间
    if (start === 0) start = t;
    // 计算当前百分比
    const percent = (t - start) / duration;
    if (percent < 1) {
      const x = 100 + percent * dx;
      // clear canvas
      ctx.clearRect(0, 0, $canvas.width, $canvas.height);
      ctx.beginPath();
      ctx.fillStyle = "red";
      ctx.arc(x, 150, 20, 0, Math.PI * 2);
      ctx.fill();
      ctx.closePath();
      requestAnimationFrame(update);
    }
  }
  requestAnimationFrame(update);

使用这种方案,不仅可以解决上述问题,还能实现非匀速运动动画,比如贝塞尔曲线等,并且可以基于此封装绘制关键帧动画的 API。

离屏渲染

离屏渲染是 canvas 性能优化的一个关键方案。

有些时候需要不止一个 canvas 来处理一些图像,或者辅助实现某些功能,这些场景下通常不需要在页面中渲染 canvas ,可以创建一个 canvas 标签来进行这一系列的操作,而不把它渲染到屏幕中。

const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
ctx.beginPath()
// ...

关于 canvas 的其它优化,移步 MDN 文档:https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas

二、基本框架

接下来到正题了,构建一个简单的 Canvas 渲染引擎,就叫“Cola”吧,因为可乐好喝(但是我不能多喝)。

Cola 类

经过实践发现,canvas 的开发使用面向对象编程范式会让代码更容易设计和维护。第一步先创建一个简单的类,初始化一个画布

export interface ColaOptions {
  /**
   * 画布宽度
   */
  width: number;
  /**
   * 画布高度
   */
  height: number;
  /**
   * 画布宽度与画布样式宽度的比例,建议不小于 DPR
   * 假设 resolution = 2, width = 600
   * 则创建的 canvas 为
   * ```html
   * <canvas width="1200" style="width: 600px"></canvas>
   * ```
   */
  resolution: number;
}

export class Cola {
  view: HTMLCanvasElement;
  width: number;
  height: number;
  resolution: number;
  private ctx: CanvasRenderingContext2D;

  constructor(options: ColaOptions) {
    this.view = document.createElement("canvas");
    this.width = options.width;
    this.height = options.height;
    this.resolution = options.resolution;
    this.view.width = this.width * this.resolution;
    this.view.height = this.height * this.resolution;
    this.view.style.width = `${this.width}px`;
    this.view.style.height = `${this.height}px`;

    const ctx = this.view.getContext("2d");
    if (!ctx) {
      throw new Error("无法获取 canvas 上下文");
    }
    this.ctx = ctx;
    this.ctx.scale(this.resolution, this.resolution);
  }
}

Cola 类接收画布的宽高和比例系数,托管了整个画布,使用的时候无需手动创建。在使用的时候只需要 new 一个 Cola 实例,并将 view 属性挂在到 dom 上即可。

const app = new Cola({
  width: 600,
  height: 300,
  resolution: devicePixelRatio,
});

document.body.appendChild(app.view);

此时画布还是空的,应用也没有相应的方法来绘制内容。那接下来给 Cola 添加一个绘制圆形的方法。

drawCircle(x: number, y: number, radius: number) {
  this.ctx.save();
  this.ctx.strokeStyle = "red";
  this.ctx.lineWidth = 4;
  this.ctx.beginPath();
  this.ctx.arc(x, y, radius, 0, Math.PI * 2);
  this.ctx.stroke();
  this.ctx.closePath();
  this.ctx.restore;
}

// app

app.drawCircle(300, 150, 100);

然后思考一下,可能在画布中绘制若干个元素,每个元素都有自己的坐标、样式、纹理等属性,仅提供一个方法是无法满足大部分场景的,应该将每种元素都抽象为一个类,并在 Cola 内部去管理这些实例,当需要往画布中添加一个元素的时候,只需要将元素的实例丢给 Cola ,它就能自动绘制出来。

ColaObject

基于上述的分析,可以抽象出一个接口 ColaObject 用来描述可以被 Cola 管理并绘制到画布上的类,这个接口应该有一些基础的属性:

  • x:x坐标
  • y:y坐标
  • render():渲染逻辑

简单定义一下接口

export interface ColaObject {
  x: number;
  y: number;
  width: number;
  height: number;
  render(canvas: HTMLCanvasElement): void;
}

然后基于该接口实现一个 Circle 类,用来表示一个圆形

export interface CircleOptions {
  x: number;
  y: number;
  radius: number;
  fill?: boolean;
  style?: string;
  lineWidth?: number;
}

export class Circle implements ColaObject {
  x: number;
  y: number;
  width: number;
  height: number;
  radius: number;
  fill: boolean;
  style: string;
  lineWidth: number;

  constructor(options: CircleOptions) {
    const {
      x,
      y,
      radius,
      fill = false,
      style = "black",
      lineWidth = 1,
    } = options;
    this.x = x;
    this.y = y;
    this.radius = radius;
    this.width = this.height = radius * 2;
    this.fill = fill;
    this.style = style;
    this.lineWidth = lineWidth;
  }

  render(canvas: HTMLCanvasElement): void {
    const ctx = canvas.getContext("2d")!;
    ctx.save();
    ctx.fillStyle = this.style;
    ctx.strokeStyle = this.style;
    ctx.lineWidth = this.lineWidth;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    if (this.fill) {
      ctx.fill();
    } else {
      ctx.stroke();
    }
    ctx.closePath();
    ctx.restore();
  }
}

render 方法中接收了 canvas 元素作为参数,可以在内部自由的编写改类型元素的绘制逻辑。写好 Circle 类后,给 Cola 类的 drawImage 方法删除,并添加一个 children 数组来存放里面绘制的 ColaObject 实例。

//Cola.ts
// ...
private children: ColaObject[];
// ...

costructor() {
//  ...
  this.children = [];
//  ...
}

// ...

addChild(child: ColaObject) {
  this.children.push(child);
  this.render();
}

private render() {
  this.ctx.clearRect(0, 0, this.width, this.height);
  this.children.forEach(child => {
    child.render(this.view);
  });
}

// ...

创建 children 数组的同时,为其添加了一个 addChild() 方法用来将实例添加进去。每添加一个实例,都需要将它绘制到画布上,因此还需要实现一个 render 方法,并在 addChild() 之后去调用它。

修改好上面的代码后,尝试使用它来绘制两个圆

const app = new Cola({
  width: 600,
  height: 300,
  resolution: devicePixelRatio,
});
document.body.appendChild(app.view);

const circle1 = new Circle({ x: 200, y: 150, radius: 100, style: "red" });
const circle2 = new Circle({ x: 400, y: 150, radius: 100, style: "red" });
app.addChild(circle1);
app.addChild(circle2);

Image

现在的实现已经能够在 canvas 中轻松绘制多个对象,但是还没有考虑到一个很重要的并且很常见的场景:重叠(层级)。

当多个对象发生重叠的时候,层级关系如何确定,需要更具层级来确定重叠部分展现的是哪个对象。这一部分留到后面来思考,接下来先为对象添加鼠标事件。

Event

在大多数 canvas 的应用场景中,都会包含用户的交互,所以还需要给图形添加鼠标事件。

但是 canvas 中并没有 DOM 结构,也没有提供给图形绑定事件的 API,只能通过监听 canvas 的鼠标事件,根据鼠标的坐标来查找命中的对象,再将事件派发给对象。事件的处理流程如下

Image

到这里,又会设计到层级问题。在发生重叠的情况下,只需要将事件派发给层级最高的对象即可,当然这里暂时不考虑重叠情况,先实现简单的事件处理。

首先设计了一个 EventEmitter 类,可以提供给后续需要事件的地方,下面是该类的抽象接口,具体实现可以查看源码。API 的设计和常见的事件中心是一致的,只不过为了更好的支持类型提示,我增加了一个泛型来声明事件名称对应的数据类型。

type EventHandler<T = any> = (data?: T) => void;
type OffEvent = () => void;

interface EventEmitter<EventDataMap extends Record<string, any>> {

  on<T extends keyof EventDataMap, D extends EventDataMap[T]>(
    name: T,
    handler: EventHandler<D>
  ): OffEvent;

  once<T extends keyof EventDataMap, D extends EventDataMap[T]>(
    name: T,
    handler: EventHandler<D>
  ): void;

  off<T extends keyof EventDataMap>(name: T, handler: EventHandler): void;

  emit<T extends keyof EventDataMap, D extends EventDataMap[T]>(
    name: T,
    data?: D
  ): void;
}

设计好 EventEmmiter 后,为 Circle 类添加一个 EventEmitter 的实例。这里如果是直接在 Circle 里面添加一个如 event 之类的属性,来存放 EventEmitter 实例的话,使用起来是这个样子的

circle.event.on('click', (data) => {
  // ...
})

使用上并不够简洁,如果能把 event 属性省略掉,直接在 Circle 中把 EventEmitter 的方法添加上去会更好,要实现这个想法,最简单的方式就是继承,Circle 类直接或间接的继承 EventEmitter ,就能够拥有 EventEmitter 的能力。

interface CircleEvents {
  click: [number, number];
}

export class Circle extends EventEmitter<CircleEvents> implements ColaObject {}

接下来就是处理 canvas 的鼠标事件,在 Cola 类中绑定 canvas 的鼠标事件,并且对事件进行处理,找到相应的对象。这里为了更好的组织代码,让 Cola 类也继承 EventEmitter,先来写一下鼠标事件的绑定

private initPointerEvents() {
  this.initEventForward();
  const clickHandler = (event: MouseEvent) => {
    event.preventDefault();
    const point = this.getPointerCoordinate(event);
    this.emit("pointerevent", { name: "click", coord: point });
  };
  this.view.addEventListener("click", clickHandler);
  return () => {
    this.view.removeEventListener("click", clickHandler);
  };
}

这里将三种鼠标事件,都通过 pointerevent 弹射出去,在另一个地方接收事件并进行转发,当然这里也可以只用一个私有方法来实现,只不过后续 Cola 可能还会使用到事件能力。

然后就是获取坐标,并查找对象

private initEventForward() {
  this.on('pointerevent', (data: { name: string; coord: [number, number] }) => {
    for (let i = this.children.length - 1; i >= 0; i--) {
      const child = this.children[i];
      if (
        child.isPointerOver(...data.coord)
      ) {
        child.emit(data.name, data.coord); // child 上不存在 emit 方法
        return;
      }
    }
  });
}

当写完上面的代码会发现有一行报错,提示 child 上找不到 emit 方法。这是因为 children 的类型是 ColaObject 数组,在 ColaObject 上并没有定义 EventEmitter 相关的方法,所以需要在让它也拥有 EventEmitter 的接口。

实现这个有两种方式,一种是让 ColaObject 也继承 EventEmitter

interface ColaObject extends EventEmitter {}

另一种则是将 ColaObject 改写为抽象类,并继承 EventEmitter

abstract class ColaObject<EventDataMap = Record<string, any>> extends EventEmitter<EventDataMap> {
  abstract x: number;
  abstract y: number;
  abstract width: number;
  abstract height: number;

  abstract render(canvas: HTMLCanvasElement): void;

  isPointerOver(x: number, y: number): boolean {
    return (
      x >= this.x &&
      x <= this.x + this.width &&
      this.y >= this.y &&
      this.y < this.y + this.height
    );
  }
}

这里我选择使用抽象类的方案,因为使用抽象类,还可以在 ColaObject 中定义一些公共的属性和逻辑,比如上面的代码就定义了一个通用的方法用来检测坐标是否在这个对象内。将 ColaObject 改写为抽象类后,Circle 类中实现 ColaObject 接口需要改为继承,并且可以删掉 EventEmitter 的继承,并重写 isPointerOver 方法。

class Circle extends ColaObject<CircleEvents> {

 isPointerOver(x: number, y: number): boolean {
    return (
      x >= this.x &&
      x <= this.x + this.width &&
      this.y >= this.y &&
      this.y < this.y + this.height
    );
  }

}

因为圆的展示区域并不是整个包围它的矩形,使用前面默认实现的方法检测,会存在四个角空白区域也会触发的情况。

最后,看一下效果

const c = new Circle({ x: 300, y: 150, radius: 100, style: "red" });
app.addChild(c);

c.on("click", (data) => {
  console.log("click a", data);
});

2022-06-18 21.18.34.gif

到这里就实现了简单的事件系统,到这里可以把所有的鼠标事件补充进去,不过面临一个问题:是否需要将事件委托到 Cola 类中。因为当鼠标移动的时候,事件的对象可能会发生改变,导致原本期望执行的事件没有被执行到。比如在做拖动功能的时候,鼠标快速的移动会让下一个触发 mousemove 的点不在对象范围内,导致 mousemove 事件发生中断。

如果使用事件委托的话,只需要在 Cola 实例中监听事件并接收当前事件的 target 。但是缺点就是所有对象的事件都需要在 Cola 中去处理或者分流,开发的灵活性大打折扣。这个问题可以参考 DOM 的事件冒泡机制,虽然现在还没有设计层级,但是可以把所有事件都冒泡到 canvas ,便解决了这个问题。

设计好事件系统后,把剩余的鼠标事件都添加上去。

private initEventForward() {
  this.on(
    "pointerevent",
    ({
      data,
    }: {
      data: { name: string; coord: { x: number; y: number } };
    }) => {
      for (let i = this.children.length - 1; i >= 0; i--) {
        const child = this.children[i];
        if (child.isPointerOver(data.coord.x, data.coord.y)) {
          child.emit(data.name, { data: data.coord });
          break;
        }
      }

      this.emit(data.name, { data: data.coord });
    }
  );
}

private initPointerEvents() {
  this.initEventForward();
  const createMouseEventHandler = (name: string) => {
    return (event: MouseEvent) => {
      event.preventDefault();
      const point = this.getPointerCoordinate(event);
      this.emit("pointerevent", { data: { name, coord: point } });
    };
  };
  const mouseEventList = [
    "click",
    "mousedown",
    "mouseup",
    "mouseenter",
    "mousemove",
    "mouseover",
    "mouseleave",
    "mouseout",
  ];
  const teardownEvents = mouseEventList.map((item) => {
    const handler = createMouseEventHandler(item);
    this.view.addEventListener(item as any, handler);
    return () => this.view.removeEventListener(item as any, handler);
  });
  return () => {
    teardownEvents.forEach((off) => {
      off?.();
    });
  };
}

更新渲染

现在有了简单的事件系统,可以接收到对象被点击的事件。很多场景下,接收到事件之后会对该对象进行一些操作,比如样式、位置等状态的改变,当这些东西改变之后,需要做的事情是能够更新画面,在 canvas 中展现最新的样式。

实现这个功能,需要设计一个机制,当对象的状态发生变更的时候,通知 Cola ,让 Cola 执行渲染逻辑。基于前面添加的事件系统,这个机制很容易实现,只需要为 ColaObject 定义一个 change 事件,并在 addChild 的时候去见听该事件

先在 ColaObject 类中添加事件类型

interface ColaObjectEvents {
  change: void;
}

export abstract class ColaObject<
  EventDataMap = Record<string, any>
> extends EventEmitter<EventDataMap & ColaObjectEvents> {}

然后更新 Cola 类中 addChild() 方法

addChild(child: ColaObject) {
  child.on('change', () => {
    this.render();
  });
  this.children.push(child);
  this.render();
}

最后在 Circle 类中添加修改属性/状态的方法,并在里面发射 change 事件

setFill(fill: boolean) {
  this.fill = fill;
  this.emit("change");
}

整个流程就串通了,先来试一下效果

const c = new Circle({ x: 300, y: 150, radius: 100, style: "red" });
app.addChild(c);

c.on("click", () => {
  c.setFill(!c.fill);
});

2022-06-18 22.00.00.gif

这里想到了一个优化点,每次 change 的时候都去调用 render() ,可能会存在很大的性能问题。当画面中的对象变多,并且可能会包含一些复杂的动画,可能会造成 render() 过度频繁的调用。

因为更新渲染是全量的,并不需要进行复杂的 diff 算法,所以只要有一个对象发射了 change 事件,就可以将状态标记为待更新,等待下一次重绘的时候再执行更新渲染。可以使用 raf 来实现这个优化,更新一下 Cola 类中的代码

addChild(child: ColaObject) {
  child.on("change", () => {
    this.render();
    this.dirty = true;
    this.checkUpdate();
  });
  this.children.push(child);
  this.checkUpdate();
}

private checkUpdate() {
  if (this.dirty) {
    requestAnimationFrame(this.render.pngd(this))
  }
}

private render() {
  this.ctx.clearRect(0, 0, this.width, this.height);
  this.fillBackground("white");
  this.children.forEach((child) => {
    child.render(this.view);
  });
  this.dirty = false;
}

更新渲染和事件都 OK 了,来实现一个拖拽效果

const app = new Cola({
  width: 600,
  height: 300,
  resolution: devicePixelRatio,
});
document.body.appendChild(app.view);

const c = new Circle({
  x: 300,
  y: 150,
  radius: 50,
  style: "red",
  fill: true,
});
app.addChild(c);

let obj: ColaObject | null;

app.on("mousemove", ({ data }) => {
  if (obj) {
    obj.x = data.x;
    obj.y = data.y;
  }
});
app.on("mouseup", () => {
  obj = null;
});

c.on("mousedown", (data) => {
  obj = c;
});

之前在 setFill() 方法中进行了变更通知,但是现在修改坐标属性,如果每个属性都要使用 setter 方法来设置的话,对于开发和维护都不是很友好,可以通过 setter/getter 特性把所有的属性都变成响应式的,对于通用的属性,可以直接在 ColaObject 抽象类中统一设置

export abstract class ColaObject<
  EventDataMap = Record<string, EventData>
> extends EventEmitter<EventDataMap & ColaObjectEvents> {
  private _x: number;
  private _y: number;
  private _width: number;
  private _height: number;

  set x(value: number) {
    this._x = value;
    this.emit("change");
  }
  get x() {
    return this._x;
  }
  set y(value: number) {
    this._y = value;
    this.emit("change");
  }
  get y() {
    return this._y;
  }
  set width(value: number) {
    this._width = value;
    this.emit("change");
  }
  get width() {
    return this._width;
  }
  set height(value: number) {
    this._height = value;
    this.emit("change");
  }
  get height() {
    return this._height;
  }

  constructor() {
    super();
    this._x = 0;
    this._y = 0;
    this._width = 0;
    this._height = 0;
  }

  isPointerOver(x: number, y: number): boolean {
    return (
      x >= this.x &&
      x <= this.x + this.width &&
      this.y >= this.y &&
      this.y < this.y + this.height
    );
  }

  abstract render(canvas: HTMLCanvasElement): void;
}

然后可以看一下效果

屏幕录制2022-06-19 23.05.33.gif

到这里,画布的更新渲染就搞定了。

接下来,搞个图片渲染。

未完待续。。。