Skip to content

Commit 5988bed

Browse files
committed
perf: virtualize vue nodes outside the viewport
GraphCanvas rendered every node in the graph, so a large workflow paid full DOM cost for nodes nobody could see. Filter the v-for down to nodes intersecting an expanded viewport rect. Measured on a graph built by stamping the default workflow across empty space, panning at a working zoom: 8000 nodes: 1790ms -> 133ms median frame, 606k -> 5.3k DOM elements 3000 nodes: 272ms -> 49ms median frame Design notes: - The mounted set is derived from a throttled sample of the camera, not a reactive dependency on it. TransformPane deliberately writes its transform via direct DOM mutation to avoid re-diffing every node each frame; binding the node list to the camera would reintroduce exactly that cost. - Nodes mount as soon as they enter and unmount after a delay, so nodes oscillating on the viewport edge do not thrash. Because that delay only elapses once panning stops, a long continuous pan would otherwise accumulate everything swept over; an eager prune bounds the set once it outgrows what is visible. - Selected nodes are never culled, so unmounting cannot interrupt a drag or resize in progress. - Culls by unmounting, never by `display: none`. The shared ResizeObserver has no zero-size guard, so a hidden node would write 0x0 bounds back through layoutStore into `liteNode.size` and corrupt the saved workflow. Note this does not help when the whole graph is on screen. `min_scale` is 0.1, but loadGraphData's fit-view bypasses that clamp, so a freshly loaded large workflow starts fully visible with nothing to cull.
1 parent e8bacc0 commit 5988bed

3 files changed

Lines changed: 393 additions & 1 deletion

File tree

src/components/graph/GraphCanvas.vue

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
158158
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
159159
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
160160
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
161+
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
161162
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
162163
import { useNodeBadge } from '@/composables/node/useNodeBadge'
163164
import { useCanvasDrop } from '@/composables/useCanvasDrop'
@@ -178,6 +179,7 @@ import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/w
178179
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
179180
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
180181
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
182+
import { createNodeCullingIndex } from '@/renderer/core/spatial/nodeCullingIndex'
181183
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
182184
import type { StartupOutcome } from '@/platform/workflow/persistence/base/draftTypes'
183185
import { useFirstRunEntry } from '@/renderer/extensions/firstRunTour/gettingStarted/firstRunEntry'
@@ -292,9 +294,53 @@ watch(
292294
}
293295
)
294296
295-
const allNodes = computed((): VueNodeData[] =>
297+
const rawNodes = computed((): VueNodeData[] =>
296298
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
297299
)
300+
301+
// Bounds come from layoutStore rather than the litegraph nodes so they share a
302+
// source with layoutVersion; keying the cache on one and reading the other
303+
// would let it hold stale bounds indefinitely.
304+
const cullingIndex = createNodeCullingIndex({
305+
getVersion: () => layoutStore.layoutVersion,
306+
getEntries: () => {
307+
const layouts = layoutStore.getAllNodes().value
308+
return rawNodes.value.map((node) => {
309+
const layout = layouts.get(node.id)
310+
if (!layout) return { id: node.id, bounds: null }
311+
312+
// Vue nodes render their title above the stored position.
313+
return {
314+
id: node.id,
315+
bounds: {
316+
x: layout.position.x,
317+
y: layout.position.y - LiteGraph.NODE_TITLE_HEIGHT,
318+
width: layout.size.width,
319+
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
320+
}
321+
}
322+
})
323+
}
324+
})
325+
326+
watch(rawNodes, () => cullingIndex.invalidate())
327+
328+
const { mountedNodeIds } = useViewportCulling({
329+
nodes: rawNodes,
330+
queryNodesInBounds: (bounds) => cullingIndex.query(bounds),
331+
getViewportSize: () => {
332+
const element = comfyApp.canvas?.canvas
333+
return {
334+
width: element?.clientWidth ?? 0,
335+
height: element?.clientHeight ?? 0
336+
}
337+
},
338+
isPinned: (id) => canvasStore.selectedNodeIds.has(id)
339+
})
340+
341+
const allNodes = computed((): VueNodeData[] =>
342+
rawNodes.value.filter((node) => mountedNodeIds.value.has(node.id))
343+
)
298344
watch(
299345
() => linearMode.value,
300346
(isLinearMode) => {
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { computed, effectScope, nextTick, reactive, ref } from 'vue'
3+
4+
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
5+
import {
6+
getCullingBounds,
7+
useViewportCulling
8+
} from '@/composables/graph/useViewportCulling'
9+
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
10+
import { boundsIntersect } from '@/renderer/core/layout/utils/layoutMath'
11+
12+
const camera = reactive({ x: 0, y: 0, z: 1 })
13+
14+
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
15+
useTransformState: () => ({ camera })
16+
}))
17+
18+
function nodeData(id: string): VueNodeData {
19+
return {
20+
id: id as NodeId,
21+
title: id,
22+
type: 'test',
23+
mode: 0,
24+
selected: false,
25+
executing: false
26+
}
27+
}
28+
29+
const VIEWPORT = { width: 1000, height: 1000 }
30+
31+
/** Runs the culling composable inside a scope and exposes its reactive result. */
32+
function setup(
33+
bounds: Record<string, Bounds | null>,
34+
pinned: Set<string> = new Set()
35+
) {
36+
const nodes = ref(Object.keys(bounds).map(nodeData))
37+
const scope = effectScope()
38+
39+
// Plain scan stands in for the spatial index; the index has its own tests.
40+
const queryNodesInBounds = (rect: Bounds) =>
41+
nodes.value
42+
.map((node) => node.id)
43+
.filter((id) => {
44+
const nodeBounds = bounds[id]
45+
return !nodeBounds || boundsIntersect(nodeBounds, rect)
46+
})
47+
48+
const mountedNodeIds = scope.run(
49+
() =>
50+
useViewportCulling({
51+
nodes: computed(() => nodes.value),
52+
queryNodesInBounds,
53+
getViewportSize: () => VIEWPORT,
54+
isPinned: (id) => pinned.has(id)
55+
}).mountedNodeIds
56+
)!
57+
58+
return { nodes, mountedNodeIds, scope }
59+
}
60+
61+
beforeEach(() => {
62+
vi.useFakeTimers()
63+
Object.assign(camera, { x: 0, y: 0, z: 1 })
64+
})
65+
66+
describe('getCullingBounds', () => {
67+
it('expands the viewport by the margin ratio in graph space', () => {
68+
expect(getCullingBounds({ x: 0, y: 0, z: 1 }, VIEWPORT, 0.5)).toEqual({
69+
x: -500,
70+
y: -500,
71+
width: 2000,
72+
height: 2000
73+
})
74+
})
75+
76+
it('accounts for pan offset and zoom', () => {
77+
// At z=2 the viewport covers half as much graph space.
78+
expect(getCullingBounds({ x: -100, y: -50, z: 2 }, VIEWPORT, 0)).toEqual({
79+
x: 100,
80+
y: 50,
81+
width: 500,
82+
height: 500
83+
})
84+
})
85+
})
86+
87+
describe('useViewportCulling', () => {
88+
it('mounts only nodes intersecting the expanded viewport', () => {
89+
const { mountedNodeIds } = setup({
90+
onscreen: { x: 0, y: 0, width: 100, height: 100 },
91+
// Inside the 0.5 margin band, so still mounted.
92+
margin: { x: 1200, y: 0, width: 100, height: 100 },
93+
offscreen: { x: 50_000, y: 0, width: 100, height: 100 }
94+
})
95+
96+
expect(mountedNodeIds.value).toEqual(
97+
new Set(['onscreen', 'margin'] as NodeId[])
98+
)
99+
})
100+
101+
it('keeps pinned nodes mounted even when far off screen', () => {
102+
const { mountedNodeIds } = setup(
103+
{ dragged: { x: 50_000, y: 0, width: 100, height: 100 } },
104+
new Set(['dragged'])
105+
)
106+
107+
expect(mountedNodeIds.value.has('dragged' as NodeId)).toBe(true)
108+
})
109+
110+
it('mounts nodes that have no layout yet so they can measure themselves', () => {
111+
const { mountedNodeIds } = setup({ unmeasured: null })
112+
113+
expect(mountedNodeIds.value.has('unmeasured' as NodeId)).toBe(true)
114+
})
115+
116+
it('mounts entering nodes immediately but delays unmounting departing ones', async () => {
117+
const { mountedNodeIds } = setup({
118+
left: { x: -3000, y: 0, width: 100, height: 100 },
119+
right: { x: 1000, y: 0, width: 100, height: 100 }
120+
})
121+
122+
expect(mountedNodeIds.value).toEqual(new Set(['right'] as NodeId[]))
123+
124+
// Pan left so `left` enters and `right` leaves.
125+
camera.x = 3000
126+
await nextTick()
127+
128+
// Entering node is mounted right away; departing node lingers.
129+
expect(mountedNodeIds.value).toEqual(new Set(['left', 'right'] as NodeId[]))
130+
131+
await vi.advanceTimersByTimeAsync(300)
132+
expect(mountedNodeIds.value).toEqual(new Set(['left'] as NodeId[]))
133+
})
134+
135+
it('does not accumulate nodes during a long continuous pan', async () => {
136+
// A row of nodes the viewport sweeps across without ever pausing.
137+
const NODE_COUNT = 60
138+
const bounds = Object.fromEntries(
139+
Array.from({ length: NODE_COUNT }, (_, i) => [
140+
`n${i}`,
141+
{ x: i * 4000, y: 0, width: 100, height: 100 }
142+
])
143+
)
144+
const { mountedNodeIds } = setup(bounds)
145+
146+
for (let step = 0; step < NODE_COUNT; step++) {
147+
camera.x = -step * 4000
148+
await nextTick()
149+
// Stay under the unmount delay so the debounced prune never runs.
150+
await vi.advanceTimersByTimeAsync(120)
151+
}
152+
153+
// Without eager pruning the mounted set would cover every node the
154+
// viewport swept over.
155+
expect(mountedNodeIds.value.size).toBeLessThan(NODE_COUNT / 3)
156+
})
157+
158+
it('drops nodes removed from the graph', async () => {
159+
const { nodes, mountedNodeIds } = setup({
160+
a: { x: 0, y: 0, width: 100, height: 100 },
161+
b: { x: 0, y: 0, width: 100, height: 100 }
162+
})
163+
164+
expect(mountedNodeIds.value.size).toBe(2)
165+
166+
nodes.value = [nodeData('a')]
167+
await nextTick()
168+
await vi.advanceTimersByTimeAsync(300)
169+
170+
expect(mountedNodeIds.value).toEqual(new Set(['a'] as NodeId[]))
171+
})
172+
})

0 commit comments

Comments
 (0)