Fabric.js Document Engine

Search documentation

Find a page or section

中文

Rendering many objects

View as Markdown

Redraw only what changed, so dragging stays smooth with thousands of objects.

Every Fabric.js renderAll clears the whole canvas and draws every object again. Dragging one shape among 5,000 draws 5,000 shapes on every mouse move, and the editor stutters (fabric.js #9847). Fabric's own switches help only part of the way: object caching saves drawing time per object, and skipOffscreen skips objects outside the view, but everything on screen is still drawn every frame.

Try it

Loading the live demo. Its code is below.

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>
  );
}

Move the dark shape with dirty regions on and off, and compare the frame time and the number of objects drawn.

Turn on dirty regions

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

Nothing else changes in your code: Fabric.js renderAll, requestRenderAll, events and exports work as before.

How it decides what to draw

  1. Each frame, one pass compares a fingerprint of every object with the last frame: its transform, size, stack position, shadow, stroke, dirty flag and those of its children. Changes made with set() and no event are found too.
  2. A changed object marks its old and new bounds, grown for its shadow, stroke joins, text overhang and anti-aliasing. The selection's controls mark their area.
  3. The marked areas are merged into at most four rectangles.
  4. A grid index finds the objects that touch them, and Fabric's own renderer draws only those, in stacking order, clipped to the rectangles. before:render and after:render still fire once.

The frame is drawn in full when the view or size changes, when the background or overlay changes, or when the changed area is over 40% of the canvas. A frame where nothing changed draws nothing.

Measured

Median frame while dragging one shape on a 1,200 × 800 canvas, in headless browsers:

ObjectsChromium: Fabric / dirty regionsFirefox: Fabric / dirty regions
1,000178 ms / 0.4 ms151 ms / 1 ms
5,000987 ms / 1.7 ms814 ms / 2 ms

Other helpers

  • createPerformanceMonitor(canvas) records frame times (p50Ms, p95Ms, worstMs) and objects drawn, in a fixed ring of recent frames.
  • batchCanvasUpdates(canvas, work) turns off renderOnAddRemove while many objects are added or changed, then renders once.
  • createSpatialIndex() is the grid index itself, for your own hit testing.

Custom objects

If a custom object changes what it draws without changing any property, call renderer.invalidate() after the change, and the next frame is drawn in full.

The options are listed in the Performance API. Loading many objects is covered in large documents, and slow image filters in image filters.