mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-23 07:50:15 +00:00
Compare commits
7 Commits
drjkl/chor
...
perf/fix-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3517e14124 | ||
|
|
84fad7f507 | ||
|
|
ca0e41a786 | ||
|
|
08b357ae18 | ||
|
|
aaa5faf87c | ||
|
|
c5dd55e219 | ||
|
|
ae51e7b320 |
@@ -154,6 +154,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('large graph viewport pan sweep', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
// Pan aggressively across the full graph so many nodes cross the
|
||||
// viewport boundary, triggering mount/unmount cycles and GC churn.
|
||||
const centerX = box.x + box.width / 2
|
||||
const centerY = box.y + box.height / 2
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// Sweep right (nodes exit left edge, new nodes enter right edge)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.page.mouse.move(centerX + i * 8, centerY + i * 3)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
// Sweep back left
|
||||
for (let i = 120; i > 0; i--) {
|
||||
await comfyPage.page.mouse.move(centerX + i * 8, centerY + i * 3)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('viewport-pan-sweep')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Viewport pan sweep: ${m.styleRecalcs} recalcs, ${m.layouts} layouts, ` +
|
||||
`${m.taskDurationMs.toFixed(1)}ms task, ` +
|
||||
`heap Δ${(m.heapDeltaBytes / 1024).toFixed(0)}KB, ` +
|
||||
`${m.domNodes} DOM nodes`
|
||||
)
|
||||
})
|
||||
|
||||
test('subgraph DOM widget clipping during node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -147,6 +147,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
@@ -167,6 +168,7 @@ import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/w
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
@@ -282,7 +284,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const allNodes = computed((): VueNodeData[] =>
|
||||
const rawNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
watch(
|
||||
@@ -302,6 +304,28 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const canvasElement = computed(() => canvasStore.canvas?.canvas)
|
||||
const { isTransforming } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 256
|
||||
})
|
||||
|
||||
const nodeLayouts = layoutStore.getAllNodes()
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes,
|
||||
nodeLayouts,
|
||||
getViewportSize: () => {
|
||||
const rect = canvasStore.canvas?.canvas?.getBoundingClientRect()
|
||||
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
||||
},
|
||||
isTransforming,
|
||||
canvasElement
|
||||
})
|
||||
|
||||
const allNodes = computed(() =>
|
||||
rawNodes.value.filter((node) => mountedNodeIds.value.has(node.id))
|
||||
)
|
||||
|
||||
function onLinkOverlayReady(el: HTMLCanvasElement) {
|
||||
if (!canvasStore.canvas) return
|
||||
canvasStore.canvas.overlayCanvas = el
|
||||
|
||||
240
src/composables/graph/useViewportCulling.test.ts
Normal file
240
src/composables/graph/useViewportCulling.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
const mockIsNodeInViewport = vi.fn()
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
|
||||
useTransformState: () => ({
|
||||
isNodeInViewport: mockIsNodeInViewport,
|
||||
camera: { x: 0, y: 0, z: 1 }
|
||||
})
|
||||
}))
|
||||
|
||||
// Must import after mock setup
|
||||
const { useViewportCulling } =
|
||||
await import('@/composables/graph/useViewportCulling')
|
||||
|
||||
function makeNode(id: string): VueNodeData {
|
||||
return {
|
||||
id: id as NodeId,
|
||||
title: `Node ${id}`,
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
executing: false,
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
let layoutCounter = 0
|
||||
|
||||
function makeLayout(x: number, y: number, w = 200, h = 100): NodeLayout {
|
||||
return {
|
||||
id: `layout-${layoutCounter++}`,
|
||||
position: { x, y },
|
||||
size: { width: w, height: h },
|
||||
bounds: { x, y, width: w, height: h },
|
||||
zIndex: 0,
|
||||
visible: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('useViewportCulling', () => {
|
||||
const isTransforming = ref(false)
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
isTransforming.value = false
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('mounts all nodes when all are visible', () => {
|
||||
const nodes = [makeNode('1'), makeNode('2'), makeNode('3')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(300, 100)],
|
||||
['3', makeLayout(500, 100)]
|
||||
])
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes),
|
||||
nodeLayouts: computed(() => layouts),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.size).toBe(3)
|
||||
expect(mountedNodeIds.value.has('1')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('3')).toBe(true)
|
||||
})
|
||||
|
||||
it('culls nodes outside the viewport after debounce', async () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(5000, 5000)]
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockImplementation(
|
||||
(pos: [number, number]) => pos[0] < 2000 && pos[1] < 2000
|
||||
)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes),
|
||||
nodeLayouts: computed(() => layouts),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
// Node 2 is outside viewport, but hasn't been pruned yet on
|
||||
// initial mount since it was never in the set to begin with
|
||||
// and computeVisibleNodeIds runs immediately
|
||||
expect(mountedNodeIds.value.has('1')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(false)
|
||||
})
|
||||
|
||||
it('mounts nodes without layout data', () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)]
|
||||
// Node '2' has no layout
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes),
|
||||
nodeLayouts: computed(() => layouts),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.has('1')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
})
|
||||
|
||||
it('mounts all nodes when viewport size is zero', () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(5000, 5000)]
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockReturnValue(false)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes),
|
||||
nodeLayouts: computed(() => layouts),
|
||||
getViewportSize: () => ({ width: 0, height: 0 }),
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.size).toBe(2)
|
||||
})
|
||||
|
||||
it('delays unmounting nodes that leave the viewport', async () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(300, 100)]
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
|
||||
const rawNodes = ref(nodes)
|
||||
const nodeLayouts = ref(layouts)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => rawNodes.value),
|
||||
nodeLayouts: computed(() => nodeLayouts.value),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.size).toBe(2)
|
||||
|
||||
// Node 2 leaves viewport
|
||||
mockIsNodeInViewport.mockImplementation(
|
||||
(pos: [number, number]) => pos[0] < 200
|
||||
)
|
||||
|
||||
// Trigger a refresh by updating layouts
|
||||
nodeLayouts.value = new Map(layouts)
|
||||
await nextTick()
|
||||
|
||||
// Node 2 should still be mounted (debounce hasn't fired)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
|
||||
// After debounce delay, node 2 should be unmounted
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(mountedNodeIds.value.has('1')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(false)
|
||||
})
|
||||
|
||||
it('immediately mounts nodes entering the viewport', async () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(5000, 5000)]
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockImplementation(
|
||||
(pos: [number, number]) => pos[0] < 2000
|
||||
)
|
||||
|
||||
const rawNodes = ref(nodes)
|
||||
const nodeLayouts = ref(layouts)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => rawNodes.value),
|
||||
nodeLayouts: computed(() => nodeLayouts.value),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.has('2')).toBe(false)
|
||||
|
||||
// Node 2 enters viewport
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
nodeLayouts.value = new Map(layouts)
|
||||
await nextTick()
|
||||
|
||||
// Should be immediately mounted without waiting for debounce
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles new nodes being added to the graph', async () => {
|
||||
const nodes = ref([makeNode('1')])
|
||||
const layouts = ref(
|
||||
new Map<NodeId, NodeLayout>([['1', makeLayout(100, 100)]])
|
||||
)
|
||||
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes.value),
|
||||
nodeLayouts: computed(() => layouts.value),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.size).toBe(1)
|
||||
|
||||
// Add a new node
|
||||
nodes.value = [...nodes.value, makeNode('2')]
|
||||
layouts.value = new Map([...layouts.value, ['2', makeLayout(200, 200)]])
|
||||
await nextTick()
|
||||
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
})
|
||||
})
|
||||
150
src/composables/graph/useViewportCulling.ts
Normal file
150
src/composables/graph/useViewportCulling.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Viewport Culling for Vue Node Components
|
||||
*
|
||||
* Controls which nodes are mounted as Vue components based on viewport
|
||||
* visibility. Nodes entering the viewport are mounted immediately; nodes
|
||||
* leaving are unmounted after a debounce delay to avoid mount/unmount
|
||||
* churn when nodes oscillate at the viewport edge during panning.
|
||||
*
|
||||
* Visibility checks are throttled during active pan/zoom interactions
|
||||
* to avoid turning culling into a per-frame reactive hotspot.
|
||||
*/
|
||||
import {
|
||||
useDebounceFn,
|
||||
useEventListener,
|
||||
useResizeObserver,
|
||||
useThrottleFn
|
||||
} from '@vueuse/core'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
/** Viewport margin as a fraction of viewport dimensions (0.75 = 75% extra) */
|
||||
const VIEWPORT_MARGIN = 0.75
|
||||
|
||||
/** Delay before unmounting nodes that left the viewport */
|
||||
const HIDE_DELAY_MS = 250
|
||||
|
||||
/** Throttle interval for visibility recomputation during pan/zoom */
|
||||
const CULL_THROTTLE_MS = 96
|
||||
|
||||
interface UseViewportCullingOptions {
|
||||
rawNodes: ComputedRef<VueNodeData[]>
|
||||
nodeLayouts: ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
|
||||
getViewportSize: () => { width: number; height: number }
|
||||
isTransforming: Ref<boolean>
|
||||
canvasElement?: Ref<HTMLElement | undefined | null>
|
||||
}
|
||||
|
||||
export function useViewportCulling({
|
||||
rawNodes,
|
||||
nodeLayouts,
|
||||
getViewportSize,
|
||||
isTransforming,
|
||||
canvasElement
|
||||
}: UseViewportCullingOptions) {
|
||||
const { isNodeInViewport } = useTransformState()
|
||||
const mountedNodeIds = shallowRef(new Set<string>())
|
||||
|
||||
function computeVisibleNodeIds(): Set<string> {
|
||||
const viewport = getViewportSize()
|
||||
const layouts = nodeLayouts.value
|
||||
const visible = new Set<string>()
|
||||
|
||||
if (!viewport.width || !viewport.height) {
|
||||
for (const node of rawNodes.value) visible.add(node.id)
|
||||
return visible
|
||||
}
|
||||
|
||||
for (const node of rawNodes.value) {
|
||||
const layout = layouts.get(node.id)
|
||||
|
||||
if (!layout) {
|
||||
visible.add(node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
isNodeInViewport(
|
||||
[layout.position.x, layout.position.y],
|
||||
[layout.size.width, layout.size.height],
|
||||
viewport,
|
||||
VIEWPORT_MARGIN
|
||||
)
|
||||
) {
|
||||
visible.add(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
const pruneMountedNodes = useDebounceFn(() => {
|
||||
mountedNodeIds.value = computeVisibleNodeIds()
|
||||
}, HIDE_DELAY_MS)
|
||||
|
||||
function refreshMountedNodes() {
|
||||
const visibleNow = computeVisibleNodeIds()
|
||||
const current = mountedNodeIds.value
|
||||
|
||||
let hasNewNodes = false
|
||||
let needsPrune = false
|
||||
let next = current
|
||||
|
||||
for (const id of visibleNow) {
|
||||
if (!current.has(id)) {
|
||||
if (next === current) next = new Set(current)
|
||||
next.add(id)
|
||||
hasNewNodes = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of current) {
|
||||
if (!visibleNow.has(id)) {
|
||||
needsPrune = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewNodes) {
|
||||
mountedNodeIds.value = next
|
||||
}
|
||||
|
||||
if (needsPrune) {
|
||||
void pruneMountedNodes()
|
||||
}
|
||||
}
|
||||
|
||||
const refreshThrottled = useThrottleFn(refreshMountedNodes, CULL_THROTTLE_MS)
|
||||
|
||||
watch([rawNodes, nodeLayouts], refreshMountedNodes, { immediate: true })
|
||||
|
||||
const { camera } = useTransformState()
|
||||
watch(
|
||||
() => [camera.x, camera.y, camera.z] as const,
|
||||
() => {
|
||||
if (isTransforming.value) {
|
||||
void refreshThrottled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(isTransforming, (moving) => {
|
||||
if (!moving) {
|
||||
refreshMountedNodes()
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(window, 'resize', refreshMountedNodes)
|
||||
|
||||
if (canvasElement) {
|
||||
useResizeObserver(canvasElement, refreshMountedNodes)
|
||||
}
|
||||
|
||||
return {
|
||||
mountedNodeIds
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user