perf(canvas): coalesce renderer redraws
This commit is contained in:
178
view/canvas/renderFrame.test.ts
Normal file
178
view/canvas/renderFrame.test.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
|
import type { AppState } from "@editor/state";
|
||||||
|
import { canvasRenderFramesEqual, selectCanvasRenderFrame } from "./renderFrame";
|
||||||
|
|
||||||
|
describe("canvas render frame selection", () => {
|
||||||
|
test("ignores non-visual tool and history state", () => {
|
||||||
|
const state = createInitialAppState("Test");
|
||||||
|
const next: AppState = {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
generate: {
|
||||||
|
...state.editor.tools.generate,
|
||||||
|
prompt: "new prompt",
|
||||||
|
steps: state.editor.tools.generate.steps + 1,
|
||||||
|
},
|
||||||
|
chromaKey: {
|
||||||
|
...state.editor.tools.chromaKey,
|
||||||
|
tolerance: state.editor.tools.chromaKey.tolerance + 1,
|
||||||
|
},
|
||||||
|
magicWand: {
|
||||||
|
...state.editor.tools.magicWand,
|
||||||
|
contiguous: !state.editor.tools.magicWand.contiguous,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
history: { past: [{ document: state.document, editor: state.editor }], future: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(next))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects document changes", () => {
|
||||||
|
const state = createInitialAppState("Test");
|
||||||
|
const next = {
|
||||||
|
...state,
|
||||||
|
document: {
|
||||||
|
...state.document,
|
||||||
|
name: "Changed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(next))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compares stable visual editor fields structurally", () => {
|
||||||
|
const state = createInitialAppState("Test");
|
||||||
|
const sameSelectionValues = {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
selection: {
|
||||||
|
artboardId: state.editor.selection.artboardId,
|
||||||
|
layerIds: [...state.editor.selection.layerIds],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const changedBrush = {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
brush: {
|
||||||
|
...state.editor.tools.brush,
|
||||||
|
size: state.editor.tools.brush.size + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const changedPreview = {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
brushPreview: { position: { x: 10, y: 20 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(sameSelectionValues))).toBe(true);
|
||||||
|
expectFrameChanged(state, changedBrush);
|
||||||
|
expectFrameChanged(state, changedPreview);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, change] of visualEditorChanges) {
|
||||||
|
test(`detects ${name} changes`, () => {
|
||||||
|
const state = createInitialAppState("Test");
|
||||||
|
expectFrameChanged(state, change(state));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
|
||||||
|
[
|
||||||
|
"viewport",
|
||||||
|
(state) => ({
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
viewport: {
|
||||||
|
...state.editor.viewport,
|
||||||
|
center: { x: state.editor.viewport.center.x + 1, y: state.editor.viewport.center.y },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"selection",
|
||||||
|
(state) => ({
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
selection: { artboardId: "artboard", layerIds: ["layer"] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"transform session",
|
||||||
|
(state) => ({
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
transformSession: {
|
||||||
|
target: { type: "artboard", id: "artboard" },
|
||||||
|
handle: "body",
|
||||||
|
startPoint: { x: 1, y: 2 },
|
||||||
|
initialBounds: { x: 0, y: 0, w: 100, h: 80 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"mask edit",
|
||||||
|
(state) => ({
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
maskEdit: { targetLayerId: "target", maskLayerId: "mask", viewMode: "overlay" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"brush stroke preview",
|
||||||
|
(state) => ({
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
brushStrokePreview: { layerId: "layer", assetId: "asset", source: "preview" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"active tool",
|
||||||
|
(state) => ({
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: { ...state.editor.tools, activeTool: "brush" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"interaction mode",
|
||||||
|
(state) => ({
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: { ...state.editor.tools, interactionMode: { type: "temporary-pan", previousTool: "select" } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
function expectFrameChanged(previous: AppState, next: AppState) {
|
||||||
|
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(previous), selectCanvasRenderFrame(next))).toBe(false);
|
||||||
|
}
|
||||||
87
view/canvas/renderFrame.ts
Normal file
87
view/canvas/renderFrame.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Rect, Vec2D } from "@core/geometry";
|
||||||
|
import type { RenderFrame } from "@renderer/index";
|
||||||
|
import type { AppState, BrushPreviewState, BrushStrokePreviewState, EditorState, MaskEditState, SelectionState, ViewportState } from "@editor/state";
|
||||||
|
import type { BrushSettings, InteractionMode } from "@editor/tools";
|
||||||
|
import type { TransformSession, TransformTarget } from "@editor/transform";
|
||||||
|
|
||||||
|
export function selectCanvasRenderFrame(state: AppState): RenderFrame {
|
||||||
|
return {
|
||||||
|
document: state.document,
|
||||||
|
editor: state.editor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasRenderFramesEqual(a: RenderFrame, b: RenderFrame): boolean {
|
||||||
|
return a.document === b.document && visualEditorStatesEqual(a.editor, b.editor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualEditorStatesEqual(a: EditorState, b: EditorState): boolean {
|
||||||
|
return (
|
||||||
|
viewportStatesEqual(a.viewport, b.viewport) &&
|
||||||
|
selectionStatesEqual(a.selection, b.selection) &&
|
||||||
|
transformSessionsEqual(a.transformSession, b.transformSession) &&
|
||||||
|
maskEditStatesEqual(a.maskEdit, b.maskEdit) &&
|
||||||
|
brushPreviewStatesEqual(a.brushPreview, b.brushPreview) &&
|
||||||
|
brushStrokePreviewStatesEqual(a.brushStrokePreview, b.brushStrokePreview) &&
|
||||||
|
visualToolStatesEqual(a.tools, b.tools)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewportStatesEqual(a: ViewportState, b: ViewportState): boolean {
|
||||||
|
return a.zoom === b.zoom && a.rotation === b.rotation && vec2Equal(a.center, b.center) && a.size.w === b.size.w && a.size.h === b.size.h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionStatesEqual(a: SelectionState, b: SelectionState): boolean {
|
||||||
|
if (a.artboardId !== b.artboardId || a.layerIds.length !== b.layerIds.length) return false;
|
||||||
|
return a.layerIds.every((layerId, index) => layerId === b.layerIds[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformSessionsEqual(a: TransformSession | undefined, b: TransformSession | undefined): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return transformTargetsEqual(a.target, b.target) && a.handle === b.handle && vec2Equal(a.startPoint, b.startPoint) && rectsEqual(a.initialBounds, b.initialBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformTargetsEqual(a: TransformTarget, b: TransformTarget): boolean {
|
||||||
|
return a.type === b.type && a.id === b.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brushPreviewStatesEqual(a: BrushPreviewState | undefined, b: BrushPreviewState | undefined): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return vec2Equal(a.position, b.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
function brushStrokePreviewStatesEqual(a: BrushStrokePreviewState | undefined, b: BrushStrokePreviewState | undefined): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.layerId === b.layerId && a.assetId === b.assetId && a.source === b.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualToolStatesEqual(a: EditorState["tools"], b: EditorState["tools"]): boolean {
|
||||||
|
return a.activeTool === b.activeTool && interactionModesEqual(a.interactionMode, b.interactionMode) && brushSettingsEqual(a.brush, b.brush);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean {
|
||||||
|
if (a.type !== b.type) return false;
|
||||||
|
if (a.type === "temporary-pan") return b.type === "temporary-pan" && a.previousTool === b.previousTool;
|
||||||
|
return b.type === "tool" && a.tool === b.tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brushSettingsEqual(a: BrushSettings, b: BrushSettings): boolean {
|
||||||
|
return a.color === b.color && a.size === b.size && a.hardness === b.hardness;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vec2Equal(a: Vec2D, b: Vec2D): boolean {
|
||||||
|
return a.x === b.x && a.y === b.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectsEqual(a: Rect, b: Rect): boolean {
|
||||||
|
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, type RefObject } from "react";
|
import { useEffect, type RefObject } from "react";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { createRenderer } from "@renderer/index";
|
import { createRenderer, type RenderFrame } from "@renderer/index";
|
||||||
|
import { canvasRenderFramesEqual, selectCanvasRenderFrame } from "./renderFrame";
|
||||||
|
|
||||||
export function useCanvasRenderer(canvasRef: RefObject<HTMLCanvasElement | null>, store: AppStore) {
|
export function useCanvasRenderer(canvasRef: RefObject<HTMLCanvasElement | null>, store: AppStore) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -8,14 +9,38 @@ export function useCanvasRenderer(canvasRef: RefObject<HTMLCanvasElement | null>
|
|||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const renderer = createRenderer(canvas);
|
const renderer = createRenderer(canvas);
|
||||||
renderer.render(store.getState());
|
let renderedFrame = selectCanvasRenderFrame(store.getState());
|
||||||
|
let pendingFrame: RenderFrame | undefined;
|
||||||
|
let animationFrame: number | undefined;
|
||||||
|
|
||||||
|
const flushRender = () => {
|
||||||
|
animationFrame = undefined;
|
||||||
|
const frame = pendingFrame;
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
pendingFrame = undefined;
|
||||||
|
renderedFrame = frame;
|
||||||
|
renderer.render(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRender = (frame: RenderFrame) => {
|
||||||
|
pendingFrame = frame;
|
||||||
|
if (animationFrame !== undefined) return;
|
||||||
|
animationFrame = requestAnimationFrame(flushRender);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleRender(renderedFrame);
|
||||||
|
|
||||||
const unsubscribe = store.subscribe((state) => {
|
const unsubscribe = store.subscribe((state) => {
|
||||||
renderer.render(state);
|
const nextFrame = selectCanvasRenderFrame(state);
|
||||||
|
const currentFrame = pendingFrame ?? renderedFrame;
|
||||||
|
if (canvasRenderFramesEqual(currentFrame, nextFrame)) return;
|
||||||
|
scheduleRender(nextFrame);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
};
|
};
|
||||||
}, [canvasRef, store]);
|
}, [canvasRef, store]);
|
||||||
|
|||||||
Reference in New Issue
Block a user