From dfa2bffedaadde54de0bb957365ef19fa5eae471 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 4 Jul 2026 15:27:20 +0200 Subject: [PATCH] perf(canvas): coalesce renderer redraws --- view/canvas/renderFrame.test.ts | 178 +++++++++++++++++++++++++++++++ view/canvas/renderFrame.ts | 87 +++++++++++++++ view/canvas/useCanvasRenderer.ts | 31 +++++- 3 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 view/canvas/renderFrame.test.ts create mode 100644 view/canvas/renderFrame.ts diff --git a/view/canvas/renderFrame.test.ts b/view/canvas/renderFrame.test.ts new file mode 100644 index 0000000..1df93a5 --- /dev/null +++ b/view/canvas/renderFrame.test.ts @@ -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); +} diff --git a/view/canvas/renderFrame.ts b/view/canvas/renderFrame.ts new file mode 100644 index 0000000..d82f58e --- /dev/null +++ b/view/canvas/renderFrame.ts @@ -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; +} diff --git a/view/canvas/useCanvasRenderer.ts b/view/canvas/useCanvasRenderer.ts index ec3597c..6f4f8a4 100644 --- a/view/canvas/useCanvasRenderer.ts +++ b/view/canvas/useCanvasRenderer.ts @@ -1,6 +1,7 @@ import { useEffect, type RefObject } from "react"; 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, store: AppStore) { useEffect(() => { @@ -8,14 +9,38 @@ export function useCanvasRenderer(canvasRef: RefObject if (!canvas) return; 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) => { - renderer.render(state); + const nextFrame = selectCanvasRenderFrame(state); + const currentFrame = pendingFrame ?? renderedFrame; + if (canvasRenderFramesEqual(currentFrame, nextFrame)) return; + scheduleRender(nextFrame); }); return () => { unsubscribe(); + if (animationFrame !== undefined) cancelAnimationFrame(animationFrame); renderer.dispose(); }; }, [canvasRef, store]);