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.
"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
- Each frame, one pass compares a fingerprint of every object with the last frame: its transform, size, stack position, shadow, stroke,
dirtyflag and those of its children. Changes made withset()and no event are found too. - 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.
- The marked areas are merged into at most four rectangles.
- 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:renderandafter:renderstill 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:
| Objects | Chromium: Fabric / dirty regions | Firefox: Fabric / dirty regions |
|---|---|---|
| 1,000 | 178 ms / 0.4 ms | 151 ms / 1 ms |
| 5,000 | 987 ms / 1.7 ms | 814 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 offrenderOnAddRemovewhile 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.
Related
The options are listed in the Performance API. Loading many objects is covered in large documents, and slow image filters in image filters.