perf(canvas): 画布渲染 - 降低大画布节点开销

This commit is contained in:
ddCat
2026-09-24 00:05:32 +08:00
parent 8822cb137c
commit 8a35a0ea98
17 changed files with 734 additions and 352 deletions

View File

@@ -9,6 +9,7 @@ import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-con
import { sortCanvasNodesByStackOrder, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import type { CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod";
import type { CanvasDisplayConnection, CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
@@ -28,6 +29,7 @@ type CanvasProjectWorldLayersProps = {
connectionApproach: CanvasConnectionApproach;
nodeById: Map<string, CanvasNodeData>;
visibleNodes: CanvasNodeData[];
nodeRenderLODById: Map<string, CanvasNodeRenderLOD>;
nodeStackOrder: CanvasNodeStackOrder;
frameChildrenById: Map<string, CanvasNodeData[]>;
linkedFolderPreviewNodesById: Map<string, CanvasNodeData[]>;
@@ -136,6 +138,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
<CanvasFrameNode
key={node.id}
data={node}
renderLOD={props.nodeRenderLODById.get(node.id) || "full"}
dragOffset={props.dragPreview?.nodeIds.has(node.id) ? props.dragPreview : undefined}
childNodes={framePreviewNodes(node)}
scale={viewportScale}
@@ -153,6 +156,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
<CanvasNode
key={node.id}
data={node}
renderLOD={props.nodeRenderLODById.get(node.id) || "full"}
dragOffset={props.dragPreview?.nodeIds.has(node.id) ? props.dragPreview : undefined}
scale={viewportScale}
isSelected={props.selectedNodeIds.has(node.id)}

View File

@@ -1409,6 +1409,7 @@ function InfiniteCanvasPage() {
imageEditNode,
mentionReferencesByNodeId,
nodeById,
nodeRenderLODById,
previewNode,
reduceMediaEffects,
relatedHighlight,
@@ -2692,6 +2693,7 @@ function InfiniteCanvasPage() {
connectionTargetNodeId={connectionTargetNodeId}
nodeById={nodeById}
visibleNodes={visibleNodes}
nodeRenderLODById={nodeRenderLODById}
nodeStackOrder={nodeStackOrder}
frameChildrenById={frameChildrenById}
linkedFolderPreviewNodesById={linkedFolderPreviewNodesById}

View File

@@ -1,20 +1,39 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { listGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { listGenerationTasks, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
const queryClient = useQueryClient();
const query = useQuery<GenerationTask[]>({
queryKey: ["canvas-active-tasks", projectId],
// Agent 的持久化执行仍复用任务队列/计费生命周期,但不应占据画布右上角的“生成任务”浮层。
// 多取一页再过滤,避免 Agent 排在前面时把真正的画布生成任务挤掉。
queryFn: () => listGenerationTasks(30, { projectId, activeOnly: true }).then((tasks) => tasks.filter((task) => !isInternalAgentTask(task)).slice(0, 5)),
queryFn: ({ signal }) => listGenerationTasks(30, { projectId, activeOnly: true }, undefined, signal).then((tasks) => tasks.filter((task) => !isInternalAgentTask(task)).slice(0, 5)),
enabled: enabled && Boolean(projectId),
refetchInterval: (current) => (current.state.data?.length ? 2_000 : 10_000),
// Shared task observers provide live updates. Lists only discover missed/new tasks.
refetchInterval: 10_000,
refetchIntervalInBackground: false,
refetchOnWindowFocus: true,
});
const taskIds = JSON.stringify((query.data || []).map((task) => task.id).sort());
useEffect(() => {
if (!enabled || !projectId) return;
return subscribeGenerationTasks(JSON.parse(taskIds) as string[], (task) => {
if (task.projectId !== projectId || isInternalAgentTask(task)) return;
queryClient.setQueryData<GenerationTask[]>(["canvas-active-tasks", projectId], (current) => {
if (!current) return current;
const previous = current.find((item) => item.id === task.id);
if (!previous || previous.updatedAt > task.updatedAt) return current;
if (task.status !== "queued" && task.status !== "running") return current.filter((item) => item.id !== task.id);
return current.map((item) => item.id === task.id ? task : item);
});
});
}, [enabled, projectId, queryClient, taskIds]);
useEffect(() => {
if (!enabled || !projectId) return;
const handleTaskChanged = (event: Event) => {
const task = (event as CustomEvent<{ task?: GenerationTask }>).detail?.task;
if (task?.projectId === projectId) void query.refetch();
@@ -25,7 +44,7 @@ export function useCanvasActiveTasks(projectId: string, enabled: boolean) {
window.removeEventListener("canvas:task-created", handleTaskChanged);
window.removeEventListener("canvas:task-cancelled", handleTaskChanged);
};
}, [projectId, query.refetch]);
}, [enabled, projectId, query.refetch]);
return {
tasks: query.data || [],

View File

@@ -254,12 +254,12 @@ export function useCanvasProjectLifecycle({
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
const snapshot = { nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
if (!observedContentRef.current || JSON.stringify(observedContentRef.current) === JSON.stringify(snapshot)) return;
if (!observedContentRef.current || sameCanvasHistorySnapshot(observedContentRef.current, snapshot)) return;
observedContentRef.current = snapshot;
const patch = { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo };
const stored = useCanvasStore.getState().projects.find((project) => project.id === projectId);
// 远端结果投影到编辑器不是一次本地编辑,避免改写时间戳并触发反向保存。
if (stored && Object.entries(patch).every(([key, value]) => JSON.stringify(stored[key as keyof CanvasProject]) === JSON.stringify(value))) return;
if (stored && sameCanvasPatch(stored, patch)) return;
updateProject(projectId, patch);
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
@@ -317,7 +317,7 @@ export function useCanvasProjectLifecycle({
const persistLocalEdits = useCallback(async () => {
const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
if (observedContentRef.current && JSON.stringify(observedContentRef.current) !== JSON.stringify(snapshot)) {
if (observedContentRef.current && !sameCanvasHistorySnapshot(observedContentRef.current, snapshot)) {
updateProject(projectId, {
nodes: nodesRef.current,
connections: connectionsRef.current,
@@ -407,6 +407,26 @@ export function useCanvasProjectLifecycle({
}
function sameCanvasHistorySnapshot(left: CanvasHistorySnapshot, right: CanvasHistorySnapshot) {
return left.nodes === right.nodes
&& left.connections === right.connections
&& left.chatSessions === right.chatSessions
&& left.activeChatId === right.activeChatId
&& left.canvasAppearance === right.canvasAppearance
&& left.backgroundMode === right.backgroundMode
&& left.showImageInfo === right.showImageInfo;
}
function sameCanvasPatch(project: CanvasProject, patch: Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo"> & { appearance: CanvasProject["appearance"] }) {
return project.nodes === patch.nodes
&& project.connections === patch.connections
&& project.chatSessions === patch.chatSessions
&& project.activeChatId === patch.activeChatId
&& project.appearance === patch.appearance
&& project.backgroundMode === patch.backgroundMode
&& project.showImageInfo === patch.showImageInfo;
}
function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) {
const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session]));
return currentSessions.map((session) => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
@@ -7,7 +7,8 @@ import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CO
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references";
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index";
import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
import { canvasOverviewMode, resolveCanvasNodeLOD, type CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod";
import { intersectsCanvasBounds, selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
import type { Skill } from "@/services/api/skills";
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
import type { DirectorScene } from "@/types/director";
@@ -176,7 +177,8 @@ export function useCanvasRenderModel({
return index;
}, [nodes]);
const renderedNodeIdsRef = useRef<Set<string>>(new Set());
const visibleNodes = useMemo(() => {
const forcedRenderNodeIds = useMemo(() => new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]), [dragPreview, selectedNodeIds]);
const candidateNodes = useMemo(() => {
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
selectCanvasVisibleNodes({
@@ -185,13 +187,74 @@ export function useCanvasRenderModel({
...renderBounds,
hiddenIds: renderHiddenNodeIds,
retainedIds: renderedNodeIdsRef.current,
forcedIds: new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])]),
forcedIds: forcedRenderNodeIds,
budget: canvasNodeRenderBudget(viewport.k),
}).forEach((node) => {
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds, viewport.k]);
}, [forcedRenderNodeIds, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, viewport.k]);
const immediateRenderNodeIds = useMemo(() => {
const ids = new Set(forcedRenderNodeIds);
candidateNodes.forEach((node) => {
if (ids.has(node.id) || intersectsCanvasBounds(canvasNodeBounds(node), renderBounds.view)) ids.add(node.id);
});
return ids;
}, [candidateNodes, forcedRenderNodeIds, renderBounds.view]);
const [mountedNodeIds, setMountedNodeIds] = useState<Set<string>>(() => new Set());
useEffect(() => {
const candidateIds = new Set(candidateNodes.map((node) => node.id));
setMountedNodeIds((current) => {
const next = new Set<string>();
current.forEach((id) => {
if (candidateIds.has(id)) next.add(id);
});
immediateRenderNodeIds.forEach((id) => next.add(id));
return next.size === current.size && [...next].every((id) => current.has(id)) ? current : next;
});
const queue = candidateNodes
.filter((node) => !immediateRenderNodeIds.has(node.id))
.sort((left, right) => {
const leftDistance = distanceToBounds(canvasNodeBounds(left), renderBounds.view);
const rightDistance = distanceToBounds(canvasNodeBounds(right), renderBounds.view);
return leftDistance - rightDistance;
});
let cursor = 0;
let frameId = 0;
const schedule = () => {
frameId = requestAnimationFrame(() => {
const startedAt = performance.now();
const additions: string[] = [];
while (cursor < queue.length && additions.length < 12 && performance.now() - startedAt < 4) additions.push(queue[cursor++].id);
if (additions.length) {
setMountedNodeIds((current) => {
if (additions.every((id) => current.has(id))) return current;
const next = new Set(current);
additions.forEach((id) => next.add(id));
return next;
});
}
if (cursor < queue.length) schedule();
});
};
if (queue.length && typeof window !== "undefined") schedule();
return () => cancelAnimationFrame(frameId);
}, [candidateNodes, immediateRenderNodeIds, renderBounds.view]);
const visibleNodes = useMemo(() => candidateNodes.filter((node) => immediateRenderNodeIds.has(node.id) || mountedNodeIds.has(node.id)), [candidateNodes, immediateRenderNodeIds, mountedNodeIds]);
const overviewRef = useRef(false);
const overview = canvasOverviewMode(viewport.k, overviewRef.current);
useEffect(() => { overviewRef.current = overview; }, [overview]);
const nodeRenderLODById = useMemo(() => {
const result = new Map<string, CanvasNodeRenderLOD>();
for (const node of visibleNodes) {
// Selection/drag keeps every node visible, not every editor mounted.
const editing = selectedNodeIds.size === 1 && selectedNodeIds.has(node.id);
const inView = intersectsCanvasBounds(canvasNodeBounds(node), renderBounds.view);
const inEntry = intersectsCanvasBounds(canvasNodeBounds(node), renderBounds.enter);
result.set(node.id, resolveCanvasNodeLOD(overview, inView, inEntry, editing));
}
return result;
}, [overview, selectedNodeIds, renderBounds.enter, renderBounds.view, visibleNodes]);
useEffect(() => {
renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id));
}, [visibleNodes]);
@@ -397,6 +460,7 @@ export function useCanvasRenderModel({
imageEditNode,
mentionReferencesByNodeId,
nodeById,
nodeRenderLODById,
previewNode,
reduceMediaEffects,
relatedHighlight,
@@ -413,3 +477,9 @@ export function useCanvasRenderModel({
visibleNodes,
};
}
function distanceToBounds(node: { left: number; top: number; right: number; bottom: number }, bounds: { left: number; top: number; right: number; bottom: number }) {
const dx = node.right < bounds.left ? bounds.left - node.right : node.left > bounds.right ? node.left - bounds.right : 0;
const dy = node.bottom < bounds.top ? bounds.top - node.bottom : node.top > bounds.bottom ? node.top - bounds.bottom : 0;
return Math.hypot(dx, dy);
}