Fix/vue nodes viewport culling (#5510)

* debug: disable culling flag

* fix: vue nodes LOD

* fix: viewport culling and improve perf

* fix: PR feedback and more perf improvements

* refactor: forEach to for of

* fix: PR feedback

* fix: PR feedback

* fix: PR feedback

* fix: PR feedback

---------

Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
This commit is contained in:
Simula_r
2025-09-13 20:59:53 -07:00
committed by GitHub
parent bf054113f8
commit b1917c6469
2 changed files with 67 additions and 173 deletions

View File

@@ -40,7 +40,7 @@
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
v-for="nodeData in nodesToRender"
v-for="nodeData in allNodes"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
@@ -183,12 +183,12 @@ const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const nodesToRender = viewportCulling.nodesToRender
const allNodes = viewportCulling.allNodes
const handleTransformUpdate = () => {
viewportCulling.handleTransformUpdate(
vueNodeLifecycle.detectChangesInRAF.value
)
viewportCulling.handleTransformUpdate()
// TODO: Fix paste position sync in separate PR
vueNodeLifecycle.detectChangesInRAF.value()
}
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse

View File

@@ -1,16 +1,14 @@
/**
* Viewport Culling Composable
* Vue Nodes Viewport Culling
*
* Handles viewport culling optimization for Vue nodes including:
* - Transform state synchronization
* - Visible node calculation with screen space transforms
* - Adaptive margin computation based on zoom level
* - Performance optimizations for large graphs
* Principles:
* 1. Query DOM directly using data attributes (no cache to maintain)
* 2. Set display none on element to avoid cascade resolution overhead
* 3. Only run when transform changes (event driven)
*/
import { type Ref, computed, readonly, ref } from 'vue'
import { type Ref, computed } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useTransformState } from '@/renderer/core/layout/useTransformState'
import { app as comfyApp } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
@@ -25,188 +23,84 @@ export function useViewportCulling(
nodeManager: Ref<NodeManager | null>
) {
const canvasStore = useCanvasStore()
const { syncWithCanvas } = useTransformState()
// Transform tracking for performance optimization
const lastScale = ref(1)
const lastOffsetX = ref(0)
const lastOffsetY = ref(0)
// Current transform state
const currentTransformState = computed(() => ({
scale: lastScale.value,
offsetX: lastOffsetX.value,
offsetY: lastOffsetY.value
}))
/**
* Computed property that returns nodes visible in the current viewport
* Implements sophisticated culling algorithm with adaptive margins
*/
const nodesToRender = computed(() => {
if (!isVueNodesEnabled.value) {
return []
}
// Access trigger to force re-evaluation after nodeManager initialization
void nodeDataTrigger.value
if (!comfyApp.graph) {
return []
}
const allNodes = Array.from(vueNodeData.value.values())
// Apply viewport culling - check if node bounds intersect with viewport
// TODO: use quadtree
if (nodeManager.value && canvasStore.canvas && comfyApp.canvas) {
const canvas = canvasStore.canvas
const manager = nodeManager.value
// Ensure transform is synced before checking visibility
syncWithCanvas(comfyApp.canvas)
const ds = canvas.ds
// Work in screen space - viewport is simply the canvas element size
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
// Add margin that represents a constant distance in canvas space
// Convert canvas units to screen pixels by multiplying by scale
const canvasMarginDistance = 200 // Fixed margin in canvas units
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const filtered = allNodes.filter((nodeData) => {
const node = manager.getNode(nodeData.id)
if (!node) return false
// Transform node position to screen space (same as DOM widgets)
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
// Check if node bounds intersect with expanded viewport (in screen space)
const isVisible = !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
return isVisible
})
return filtered
}
return allNodes
const allNodes = computed(() => {
if (!isVueNodesEnabled.value) return []
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
return Array.from(vueNodeData.value.values())
})
/**
* Handle transform updates with performance optimization
* Only syncs when transform actually changes to avoid unnecessary reflows
* Update visibility of all nodes based on viewport
* Queries DOM directly - no cache maintenance needed
*/
const handleTransformUpdate = (detectChangesInRAF: () => void) => {
// Skip all work if Vue nodes are disabled
if (!isVueNodesEnabled.value) {
return
}
// Sync transform state only when it changes (avoids reflows)
if (comfyApp.canvas?.ds) {
const currentScale = comfyApp.canvas.ds.scale
const currentOffsetX = comfyApp.canvas.ds.offset[0]
const currentOffsetY = comfyApp.canvas.ds.offset[1]
if (
currentScale !== lastScale.value ||
currentOffsetX !== lastOffsetX.value ||
currentOffsetY !== lastOffsetY.value
) {
syncWithCanvas(comfyApp.canvas)
lastScale.value = currentScale
lastOffsetX.value = currentOffsetX
lastOffsetY.value = currentOffsetY
}
}
// Detect node changes during transform updates
detectChangesInRAF()
// Trigger reactivity for nodesToRender
void nodesToRender.value.length
}
/**
* Calculate if a specific node is visible in viewport
* Useful for individual node visibility checks
*/
const isNodeVisible = (nodeData: VueNodeData): boolean => {
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) {
return true // Default to visible if culling not available
}
const updateVisibility = () => {
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
const canvas = canvasStore.canvas
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return false
syncWithCanvas(comfyApp.canvas)
const manager = nodeManager.value
const ds = canvas.ds
// Viewport bounds
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
const canvasMarginDistance = 200
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const margin = 500 * ds.scale
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
// Get all node elements at once
const nodeElements = document.querySelectorAll('[data-node-id]')
return !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
// Update each element's visibility
for (const element of nodeElements) {
const nodeId = element.getAttribute('data-node-id')
if (!nodeId) continue
const node = manager.getNode(nodeId)
if (!node) continue
// Calculate if node is outside viewport
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
const isNodeOutsideViewport =
screen_x + screen_width < -margin ||
screen_x > viewport_width + margin ||
screen_y + screen_height < -margin ||
screen_y > viewport_height + margin
// Setting display none directly avoid potential cascade resolution
if (element instanceof HTMLElement) {
element.style.display = isNodeOutsideViewport ? 'none' : ''
}
}
}
// RAF throttling for smooth updates during continuous panning
let rafId: number | null = null
/**
* Get viewport bounds information for debugging
* Handle transform update - called by TransformPane event
* Uses RAF to batch updates for smooth performance
*/
const getViewportInfo = () => {
if (!canvasStore.canvas || !comfyApp.canvas) {
return null
const handleTransformUpdate = () => {
if (!isVueNodesEnabled.value) return
// Cancel previous RAF if still pending
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
const canvas = canvasStore.canvas
const ds = canvas.ds
return {
viewport_width: canvas.canvas.width,
viewport_height: canvas.canvas.height,
scale: ds.scale,
offset: [ds.offset[0], ds.offset[1]],
margin_distance: 200,
margin_x: 200 * ds.scale,
margin_y: 200 * ds.scale
}
// Schedule update in next animation frame
rafId = requestAnimationFrame(() => {
updateVisibility()
rafId = null
})
}
return {
nodesToRender,
allNodes,
handleTransformUpdate,
isNodeVisible,
getViewportInfo,
// Transform state
currentTransformState: readonly(currentTransformState),
lastScale: readonly(lastScale),
lastOffsetX: readonly(lastOffsetX),
lastOffsetY: readonly(lastOffsetY)
updateVisibility
}
}