Skip to content

Commit cec1de0

Browse files
simula-rgithub-actions
andauthored
feat: vue nodes LOD system (#5631)
## Summary Replaced reactive (Vue-based) widget LOD with CSS visibility control. Performance doesn't dramatically improve, but we avoid the mount/unmount overhead during zoom/pan operations. This PR implements the visual component of LOD—complex widgets that need lifecycle management will be addressed separately. ### Problem & Solution Problem: we want LOD to improve rendering performance and visual feedback but discovered using reactivity in the current setup for it meant mounting/unmounting caused worse lag than the performance it aimed to fix. Switching to render all the details all the time but using css visibility proved to be the best solution. However, it doesn't improve rendering performance by much because the GPU texture size is the bottleneck (from TransformPane.vue CSS transforms) and not rasterization. Solution: Keep all nodes/widgets mounted, use CSS visibility: hidden for LOD. Trade memory for performance stability during zoom/pan/drag operations. ### Technical Decision We chose Performance > Memory: - CSS transforms create a single GPU texture whose size depends on node count, not widget complexity - Mounting/unmounting hundreds of widgets during zoom = noticeable lag from Vue VDOM diffing (since all components are mounted all the time because of viewport culling challenge/trade off see #5510.) - CSS visibility changes = no reactivity overhead, smooth interactions - Result: Similar performance, but without interaction stutters This is the visual layer only. If we want a hook into the LOD state per node / widget that would be the next follow up system to implement. ### Next Steps (maybe) - Chunked (split up single Transform Pane transform layer) when rendering 1000+ nodes (maybe) - ~~Selective unmounting API for widgets that register as "expensive"~~ - ~~Client bound hydration system~~ ## Screenshots (if applicable) <!-- Add screenshots or video recording to help explain your changes --> <img width="1355" height="960" alt="image" src="https://github.com/user-attachments/assets/41474d1b-9dbe-4240-a8cf-f4c9ff51d8e0" /> <img width="1354" height="963" alt="image" src="https://github.com/user-attachments/assets/9f55edaa-5858-41b9-b6a8-c2d37e1649bd" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5631-feat-vue-nodes-LOD-system-2726d73d365081c6a6c4e14aa634f19c) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <[email protected]>
1 parent b4976c1 commit cec1de0

File tree

23 files changed

+437
-930
lines changed

23 files changed

+437
-930
lines changed
924 Bytes
Loading
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expect } from '@playwright/test'
2+
3+
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
4+
5+
test.describe('Vue Nodes - LOD', () => {
6+
test.beforeEach(async ({ comfyPage }) => {
7+
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
8+
await comfyPage.setup()
9+
await comfyPage.loadWorkflow('default')
10+
})
11+
12+
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
13+
await comfyPage.vueNodes.waitForNodes()
14+
15+
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
16+
expect(initialNodeCount).toBeGreaterThan(0)
17+
18+
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
19+
20+
const vueNodesContainer = comfyPage.vueNodes.nodes
21+
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
22+
const buttonsInNodes = vueNodesContainer.getByRole('button')
23+
24+
await expect(textboxesInNodes.first()).toBeVisible()
25+
await expect(buttonsInNodes.first()).toBeVisible()
26+
27+
await comfyPage.zoom(120, 10)
28+
await comfyPage.nextFrame()
29+
30+
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
31+
32+
await expect(textboxesInNodes.first()).toBeHidden()
33+
await expect(buttonsInNodes.first()).toBeHidden()
34+
35+
await comfyPage.zoom(-120, 10)
36+
await comfyPage.nextFrame()
37+
38+
await expect(comfyPage.canvas).toHaveScreenshot(
39+
'vue-nodes-lod-inactive.png'
40+
)
41+
await expect(textboxesInNodes.first()).toBeVisible()
42+
await expect(buttonsInNodes.first()).toBeVisible()
43+
})
44+
})
106 KB
Loading
39.8 KB
Loading
106 KB
Loading

src/assets/css/style.css

Lines changed: 46 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -929,48 +929,6 @@ audio.comfy-audio.empty-audio-widget {
929929
}
930930
/* End of [Desktop] Electron window specific styles */
931931

932-
/* Vue Node LOD (Level of Detail) System */
933-
/* These classes control rendering detail based on zoom level */
934-
935-
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
936-
.lg-node--lod-minimal {
937-
min-height: 32px;
938-
transition: min-height 0.2s ease;
939-
/* Performance optimizations */
940-
text-shadow: none;
941-
backdrop-filter: none;
942-
}
943-
944-
.lg-node--lod-minimal .lg-node-body {
945-
display: none !important;
946-
}
947-
948-
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
949-
.lg-node--lod-reduced {
950-
transition: opacity 0.1s ease;
951-
/* Performance optimizations */
952-
text-shadow: none;
953-
}
954-
955-
.lg-node--lod-reduced .lg-widget-label,
956-
.lg-node--lod-reduced .lg-slot-label {
957-
display: none;
958-
}
959-
960-
.lg-node--lod-reduced .lg-slot {
961-
opacity: 0.6;
962-
font-size: 0.75rem;
963-
}
964-
965-
.lg-node--lod-reduced .lg-widget {
966-
margin: 2px 0;
967-
font-size: 0.875rem;
968-
}
969-
970-
/* Full LOD (zoom > 0.8) - Complete detail rendering */
971-
.lg-node--lod-full {
972-
/* Uses default styling - no overrides needed */
973-
}
974932

975933
.lg-node {
976934
/* Disable text selection on all nodes */
@@ -996,23 +954,52 @@ audio.comfy-audio.empty-audio-widget {
996954
will-change: transform;
997955
}
998956

999-
/* Global performance optimizations for LOD */
1000-
.lg-node--lod-minimal,
1001-
.lg-node--lod-reduced {
1002-
/* Remove ALL expensive paint effects */
1003-
box-shadow: none !important;
1004-
filter: none !important;
1005-
backdrop-filter: none !important;
1006-
text-shadow: none !important;
1007-
-webkit-mask-image: none !important;
1008-
mask-image: none !important;
1009-
clip-path: none !important;
957+
/* START LOD specific styles */
958+
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
959+
960+
.isLOD .lg-node {
961+
box-shadow: none;
962+
filter: none;
963+
backdrop-filter: none;
964+
text-shadow: none;
965+
-webkit-mask-image: none;
966+
mask-image: none;
967+
clip-path: none;
968+
background-image: none;
969+
text-rendering: optimizeSpeed;
970+
border-radius: 0;
971+
contain: layout style;
972+
transition: none;
973+
974+
}
975+
976+
.isLOD .lg-node > * {
977+
pointer-events: none;
978+
}
979+
980+
.lod-toggle {
981+
visibility: visible;
982+
}
983+
984+
.isLOD .lod-toggle {
985+
visibility: hidden;
986+
}
987+
988+
989+
.lod-fallback {
990+
display: none;
1010991
}
1011992

1012-
/* Reduce paint complexity for minimal LOD */
1013-
.lg-node--lod-minimal {
1014-
/* Skip complex borders */
1015-
border-radius: 0 !important;
1016-
/* Use solid colors only */
1017-
background-image: none !important;
993+
.isLOD .lod-fallback {
994+
display: block;
995+
}
996+
997+
.isLOD .image-preview img {
998+
image-rendering: pixelated;
999+
}
1000+
1001+
1002+
.isLOD .slot-dot {
1003+
border-radius: 0;
10181004
}
1005+
/* END LOD specific styles */

src/renderer/core/layout/transform/TransformPane.vue

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
<template>
22
<div
3-
class="transform-pane"
4-
:class="{ 'transform-pane--interacting': isInteracting }"
3+
class="absolute inset-0 w-full h-full pointer-events-none"
4+
:class="
5+
cn(
6+
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
7+
isLOD ? 'isLOD' : ''
8+
)
9+
"
510
:style="transformStyle"
611
@pointerdown="handlePointerDown"
712
>
@@ -18,6 +23,8 @@ import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
1823
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
1924
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
2025
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
26+
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
27+
import { cn } from '@/utils/tailwindUtil'
2128
2229
interface TransformPaneProps {
2330
canvas?: LGraphCanvas
@@ -34,6 +41,8 @@ const {
3441
isNodeInViewport
3542
} = useTransformState()
3643
44+
const { isLOD } = useLOD(camera)
45+
3746
const canvasElement = computed(() => props.canvas?.canvas)
3847
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
3948
settleDelay: 200,

src/renderer/extensions/vueNodes/components/ImagePreview.vue

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,20 @@
9494
</div>
9595
</div>
9696

97-
<!-- Image Dimensions -->
98-
<div class="text-white text-xs text-center mt-2">
99-
<span v-if="imageError" class="text-red-400">
100-
{{ $t('g.errorLoadingImage') }}
101-
</span>
102-
<span v-else-if="isLoading" class="text-gray-400">
103-
{{ $t('g.loading') }}...
104-
</span>
105-
<span v-else>
106-
{{ actualDimensions || $t('g.calculatingDimensions') }}
107-
</span>
97+
<div class="relative">
98+
<!-- Image Dimensions -->
99+
<div class="text-white text-xs text-center mt-2">
100+
<span v-if="imageError" class="text-red-400">
101+
{{ $t('g.errorLoadingImage') }}
102+
</span>
103+
<span v-else-if="isLoading" class="text-gray-400">
104+
{{ $t('g.loading') }}...
105+
</span>
106+
<span v-else>
107+
{{ actualDimensions || $t('g.calculatingDimensions') }}
108+
</span>
109+
</div>
110+
<LODFallback />
108111
</div>
109112
</div>
110113
</template>
@@ -119,6 +122,8 @@ import { downloadFile } from '@/base/common/downloadUtil'
119122
import { useCommandStore } from '@/stores/commandStore'
120123
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
121124
125+
import LODFallback from './LODFallback.vue'
126+
122127
interface ImagePreviewProps {
123128
/** Array of image URLs to display */
124129
readonly imageUrls: readonly string[]

src/renderer/extensions/vueNodes/components/InputSlot.vue

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010
/>
1111

1212
<!-- Slot Name -->
13-
<span
14-
v-if="!dotOnly"
15-
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
16-
>
17-
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
18-
</span>
13+
<div class="relative">
14+
<span
15+
v-if="!dotOnly"
16+
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
17+
>
18+
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
19+
</span>
20+
<LODFallback />
21+
</div>
1922
</div>
2023
</template>
2124

@@ -38,6 +41,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
3841
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
3942
import { cn } from '@/utils/tailwindUtil'
4043
44+
import LODFallback from './LODFallback.vue'
4145
import SlotConnectionDot from './SlotConnectionDot.vue'
4246
4347
interface InputSlotProps {

0 commit comments

Comments
 (0)