Skip to content

Commit b17de51

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 629e0f0 commit b17de51

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
@@ -159,6 +159,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
159159
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
160160
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
161161
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
162+
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
162163
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
163164
import { useNodeBadge } from '@/composables/node/useNodeBadge'
164165
import { useCanvasDrop } from '@/composables/useCanvasDrop'
@@ -179,6 +180,7 @@ import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/w
179180
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
180181
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
181182
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
183+
import { createNodeCullingIndex } from '@/renderer/core/spatial/nodeCullingIndex'
182184
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
183185
import type { StartupOutcome } from '@/platform/workflow/persistence/base/draftTypes'
184186
import { useFirstRunEntry } from '@/renderer/extensions/firstRunTour/gettingStarted/firstRunEntry'
@@ -293,9 +295,53 @@ watch(
293295
}
294296
)
295297
296-
const allNodes = computed((): VueNodeData[] =>
298+
const rawNodes = computed((): VueNodeData[] =>
297299
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
298300
)
301+
302+
// Bounds come from layoutStore rather than the litegraph nodes so they share a
303+
// source with layoutVersion; keying the cache on one and reading the other
304+
// would let it hold stale bounds indefinitely.
305+
const cullingIndex = createNodeCullingIndex({
306+
getVersion: () => layoutStore.layoutVersion,
307+
getEntries: () => {
308+
const layouts = layoutStore.getAllNodes().value
309+
return rawNodes.value.map((node) => {
310+
const layout = layouts.get(node.id)
311+
if (!layout) return { id: node.id, bounds: null }
312+
313+
// Vue nodes render their title above the stored position.
314+
return {
315+
id: node.id,
316+
bounds: {
317+
x: layout.position.x,
318+
y: layout.position.y - LiteGraph.NODE_TITLE_HEIGHT,
319+
width: layout.size.width,
320+
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
321+
}
322+
}
323+
})
324+
}
325+
})
326+
327+
watch(rawNodes, () => cullingIndex.invalidate())
328+
329+
const { mountedNodeIds } = useViewportCulling({
330+
nodes: rawNodes,
331+
queryNodesInBounds: (bounds) => cullingIndex.query(bounds),
332+
getViewportSize: () => {
333+
const element = comfyApp.canvas?.canvas
334+
return {
335+
width: element?.clientWidth ?? 0,
336+
height: element?.clientHeight ?? 0
337+
}
338+
},
339+
isPinned: (id) => canvasStore.selectedNodeIds.has(id)
340+
})
341+
342+
const allNodes = computed((): VueNodeData[] =>
343+
rawNodes.value.filter((node) => mountedNodeIds.value.has(node.id))
344+
)
299345
watch(
300346
() => linearMode.value,
301347
(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)