Fabric.js Document Engine

搜索文档

查找页面或章节

EN

渲染大量对象

查看 Markdown

只重绘变化的部分,几千个对象时拖动也很流畅。

每次 renderAll,Fabric 都会清空整个画布,再把每个对象画一遍。在 5,000 个图形里拖动一个,每次鼠标移动都要画 5,000 个图形,这就是 fabricjs 卡顿 的主要原因(fabric.js #9847)。Fabric 自带的开关只能解决一部分:对象缓存减少每个对象的绘制时间,skipOffscreen 跳过视野外的对象,但屏幕上的一切仍然每帧都要重画。

试一试

正在加载在线示例,代码在下方。

dirty-regions-demo.tsx
"use client";
 
import { Rect } from "fabric";
import {
  createPerformanceMonitor,
  type DirtyRegionRenderer,
  enableDirtyRegionRendering,
} from "fabricjs-document-engine/performance";
import { GaugeIcon, MoveIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
 
import {
  COLORS,
  DemoCanvas,
  DemoFrame,
  Status,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";
 
const COUNT = 3000;
 
export default function DirtyRegionsDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const moverRef = useRef<Rect | null>(null);
  const [dirty, setDirty] = useState(true);
  const [moving, setMoving] = useState(false);
  const [stats, setStats] = useState({ ms: 0, drawn: 0 });
 
  useEffect(() => {
    if (!canvas) return;
    canvas.renderOnAddRemove = false;
    const shapes = Array.from(
      { length: COUNT },
      (_, index) =>
        new Rect({
          left: (index * 97) % (PAGE.width - 10),
          top: (index * 61) % (PAGE.height - 8),
          width: 9,
          height: 7,
          fill: COLORS[index % COLORS.length],
          selectable: false,
          evented: false,
          objectCaching: false,
        })
    );
    const mover = new Rect({
      left: 40,
      top: 120,
      width: 70,
      height: 50,
      rx: 8,
      ry: 8,
      fill: "#0d0d0d",
    });
    moverRef.current = mover;
    canvas.add(...shapes, mover);
    canvas.requestRenderAll();
    return () => {
      canvas.remove(...shapes, mover);
    };
  }, [canvas]);
 
  useEffect(() => {
    if (!canvas) return;
    const renderer: DirtyRegionRenderer | null = dirty
      ? enableDirtyRegionRendering(canvas)
      : null;
    const monitor = createPerformanceMonitor(canvas, { samples: 60 });
    const timer = setInterval(() => {
      const frame = monitor.stats();
      setStats({
        ms: Math.round(frame.p50Ms * 10) / 10,
        drawn: renderer
          ? renderer.stats().lastObjectsDrawn
          : frame.lastObjectsDrawn,
      });
    }, 400);
    canvas.requestRenderAll();
    return () => {
      clearInterval(timer);
      monitor.stop();
      renderer?.disable();
    };
  }, [canvas, dirty]);
 
  function move() {
    const mover = moverRef.current;
    if (!canvas || !mover || moving) return;
    setMoving(true);
    const start = performance.now();
    const step = (now: number) => {
      const progress = (now - start) / 2500;
      mover.set({
        left: 40 + Math.sin(progress * Math.PI * 2) * 160 + 160,
        top: 120 + Math.cos(progress * Math.PI * 4) * 60,
      });
      mover.setCoords();
      canvas.renderAll();
      if (progress < 1) requestAnimationFrame(step);
      else setMoving(false);
    };
    requestAnimationFrame(step);
  }
 
  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />
 
      <Toolbar>
        <ToolButton
          icon={MoveIcon}
          label={t("dirty.move")}
          primary
          disabled={!canvas || moving}
          onClick={move}
        />
        <ToolButton
          icon={GaugeIcon}
          label={dirty ? t("dirty.on") : t("dirty.off")}
          disabled={moving}
          onClick={() => setDirty((value) => !value)}
        />
      </Toolbar>
 
      <Status tone={stats.drawn > 0 ? "ok" : "idle"}>
        {t("dirty.status", {
          ms: stats.ms,
          drawn: stats.drawn,
          count: COUNT + 1,
        })}
      </Status>
    </DemoFrame>
  );
}

分别在脏区域渲染开启和关闭时移动深色图形,比较每帧耗时和绘制的对象数量。

开启脏区域渲染

import { enableDirtyRegionRendering } from "fabricjs-document-engine/performance";
 
const renderer = enableDirtyRegionRendering(canvas);
 
renderer.disable();

代码的其他部分不需要改变:renderAll、requestRenderAll、事件和导出都和以前一样。这就是 canvas 局部渲染 的做法。

它怎样决定画什么

  1. 每一帧,用一次遍历把每个对象的指纹和上一帧比较:变换、大小、层级位置、阴影、描边、dirty 标记以及子对象的这些信息。用 set() 修改而没有触发事件的变化也能发现。
  2. 变化的对象标记它的旧边界和新边界,并为阴影、描边拐角、文字溢出和抗锯齿留出余量。选中对象的控制点也会标记自己的区域。
  3. 标记的区域最多合并成四个矩形,也就是 canvas 脏矩形。
  4. 网格索引找出碰到这些矩形的对象,再由 Fabric 自己的渲染器按层级顺序只画这些对象,并裁剪到矩形内。before:render 和 after:render 仍然只触发一次。

视图或尺寸改变、背景或叠加层改变,或者变化的区域超过画布的 40% 时,这一帧会完整绘制。没有任何变化的帧什么也不画。

实测

在 1,200 × 800 的画布上拖动一个图形时,每帧耗时的中位数(无头浏览器):

对象数Chromium:Fabric / 脏区域Firefox:Fabric / 脏区域
1,000178 ms / 0.4 ms151 ms / 1 ms
5,000987 ms / 1.7 ms814 ms / 2 ms

其他辅助函数

  • createPerformanceMonitor(canvas) 记录每帧耗时(p50Ms、p95Ms、worstMs)和绘制的对象数,只保存最近若干帧。
  • batchCanvasUpdates(canvas, work) 在添加或修改大量对象时关闭 renderOnAddRemove,最后只渲染一次,减少 fabricjs 卡顿。
  • createSpatialIndex() 就是那个网格索引,可以用来做你自己的命中检测。

自定义对象

如果自定义对象在没有改变任何属性的情况下改变了绘制内容,在改变之后调用 renderer.invalidate(),下一帧就会完整绘制。

选项列在 性能 API 中。加载大量对象见 大文档,图片滤镜慢的问题见 图片滤镜。