Skip to content

Commit 99a02a8

Browse files
committed
perf: draw nodes as boxes on a renderer-owned canvas when zoomed out
The previous approach mounted a div per node while zoomed out past legibility, which still cost an element per node - 3,003 of them on a large graph - and profiling showed most of a frame going to layout, paint and compositing for what amounts to filled rectangles. Draw them onto a canvas instead. The drawing lives in nodeBoxRenderer, which takes a 2D context and a set of bounds and has no graph or canvas renderer dependencies, mirroring how CanvasPathRenderer already owns link drawing. A zoomed-out graph therefore does not depend on the legacy canvas renderer being present, which an earlier attempt at this did. Node bounds come from the culling index, which already keeps them cached against layoutVersion, so redrawing each frame rebuilds nothing. 3000-node graph at its opening zoom: 227,787 -> 418 DOM elements, the remainder being application chrome rather than nodes.
1 parent b1e52e2 commit 99a02a8

6 files changed

Lines changed: 267 additions & 48 deletions

File tree

src/components/graph/GraphCanvas.vue

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,22 +71,29 @@
7171
@pointerup.capture="forwardPointerUpPanEvent"
7272
@pointermove.capture="forwardPointerMovePanEvent"
7373
>
74-
<!-- Vue nodes rendered based on graph nodes -->
75-
<template v-for="nodeData in allNodes" :key="nodeData.id">
76-
<LGraphNodeLOD v-if="isLowQuality" :node-data="nodeData" />
77-
<LGraphNode
78-
v-else
79-
:node-data="nodeData"
80-
:error="
81-
executionErrorStore.lastExecutionErrorNodeId === nodeData.id
82-
? 'Execution error'
83-
: null
84-
"
85-
:data-node-id="nodeData.id"
86-
/>
87-
</template>
74+
<!--
75+
Vue nodes rendered based on graph nodes. While zoomed out past
76+
legibility they are replaced wholesale by NodeBoxOverlay, which draws the
77+
same rectangles onto a canvas for a fraction of the cost.
78+
-->
79+
<LGraphNode
80+
v-for="nodeData in isLowQuality ? [] : allNodes"
81+
:key="nodeData.id"
82+
:node-data="nodeData"
83+
:error="
84+
executionErrorStore.lastExecutionErrorNodeId === nodeData.id
85+
? 'Execution error'
86+
: null
87+
"
88+
:data-node-id="nodeData.id"
89+
/>
8890
</TransformPane>
8991

92+
<NodeBoxOverlay
93+
v-if="shouldRenderVueNodes && comfyAppReady && isLowQuality"
94+
:get-boxes="getNodeBoxes"
95+
/>
96+
9097
<LinkOverlayCanvas
9198
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
9299
:canvas="comfyApp.canvas"
@@ -161,6 +168,7 @@ import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
161168
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
162169
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
163170
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
171+
import type { NodeId } from '@/renderer/core/layout/types'
164172
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
165173
import { useNodeBadge } from '@/composables/node/useNodeBadge'
166174
import { useCanvasDrop } from '@/composables/useCanvasDrop'
@@ -187,7 +195,7 @@ import type { StartupOutcome } from '@/platform/workflow/persistence/base/draftT
187195
import { useFirstRunEntry } from '@/renderer/extensions/firstRunTour/gettingStarted/firstRunEntry'
188196
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
189197
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
190-
import LGraphNodeLOD from '@/renderer/extensions/vueNodes/components/LGraphNodeLOD.vue'
198+
import NodeBoxOverlay from '@/renderer/extensions/vueNodes/components/NodeBoxOverlay.vue'
191199
import { useLowQualityRendering } from '@/renderer/extensions/vueNodes/composables/useLowQualityRendering'
192200
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
193201
import { UnauthorizedError } from '@/scripts/api'
@@ -298,6 +306,21 @@ watch(
298306
}
299307
)
300308
309+
// Colour per node, rebuilt only when the node list changes rather than per
310+
// frame; the overlay redraws every frame.
311+
const nodeColors = computed(() => {
312+
const colors = new Map<NodeId, string | undefined>()
313+
for (const node of rawNodes.value) colors.set(node.id, node.bgcolor)
314+
return colors
315+
})
316+
317+
function getNodeBoxes() {
318+
const colors = nodeColors.value
319+
return cullingIndex
320+
.entries()
321+
.map((entry) => ({ bounds: entry.bounds!, color: colors.get(entry.id) }))
322+
}
323+
301324
const { isLowQuality } = useLowQualityRendering(
302325
computed(() => canvasStore.canvas ?? undefined)
303326
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import { drawNodeBoxes } from '@/renderer/core/canvas/nodeBoxRenderer'
4+
import type { NodeBox } from '@/renderer/core/canvas/nodeBoxRenderer'
5+
6+
function fakeContext() {
7+
const fills: Array<{ rect: number[]; color: string }> = []
8+
let fillStyle = ''
9+
const ctx = {
10+
save: vi.fn(),
11+
restore: vi.fn(),
12+
scale: vi.fn(),
13+
translate: vi.fn(),
14+
fillRect: (...rect: number[]) => fills.push({ rect, color: fillStyle }),
15+
get fillStyle() {
16+
return fillStyle
17+
},
18+
set fillStyle(value: string) {
19+
fillStyle = value
20+
}
21+
}
22+
return { ctx: ctx as unknown as CanvasRenderingContext2D, fills }
23+
}
24+
25+
const box = (x: number, color?: string): NodeBox => ({
26+
bounds: { x, y: 0, width: 100, height: 50 },
27+
color
28+
})
29+
30+
const VIEWPORT = { x: 0, y: 0, width: 1000, height: 1000 }
31+
const CAMERA = { x: 0, y: 0, z: 1 }
32+
const STYLE = { defaultColor: '#333' }
33+
34+
describe('drawNodeBoxes', () => {
35+
it('draws only boxes intersecting the viewport', () => {
36+
const { ctx, fills } = fakeContext()
37+
38+
const drawn = drawNodeBoxes(
39+
ctx,
40+
[box(0), box(500), box(50_000)],
41+
CAMERA,
42+
VIEWPORT,
43+
STYLE
44+
)
45+
46+
expect(drawn).toBe(2)
47+
expect(fills.map((f) => f.rect[0])).toEqual([0, 500])
48+
})
49+
50+
it('uses each node colour, falling back to the default', () => {
51+
const { ctx, fills } = fakeContext()
52+
53+
drawNodeBoxes(ctx, [box(0, '#abcdef'), box(200)], CAMERA, VIEWPORT, STYLE)
54+
55+
expect(fills.map((f) => f.color)).toEqual(['#abcdef', '#333'])
56+
})
57+
58+
it('applies the camera to the context rather than to each box', () => {
59+
const { ctx, fills } = fakeContext()
60+
61+
drawNodeBoxes(ctx, [box(10)], { x: -5, y: -7, z: 2 }, VIEWPORT, STYLE)
62+
63+
expect(ctx.scale).toHaveBeenCalledWith(2, 2)
64+
expect(ctx.translate).toHaveBeenCalledWith(-5, -7)
65+
// Bounds stay in graph space; the context carries the transform.
66+
expect(fills[0].rect).toEqual([10, 0, 100, 50])
67+
})
68+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Draws nodes as plain filled rectangles.
3+
*
4+
* Used while the view is zoomed out far enough that node contents would be
5+
* illegible, where a node resolves to little more than a rectangle on screen
6+
* anyway. Pure canvas drawing with no graph or renderer dependencies: it needs
7+
* only a 2D context and a set of bounds.
8+
*/
9+
import type { Bounds } from '@/renderer/core/layout/types'
10+
11+
export interface NodeBox {
12+
bounds: Bounds
13+
/** Node's own colour; falls back to the shared default when absent. */
14+
color?: string
15+
}
16+
17+
export interface NodeBoxStyle {
18+
defaultColor: string
19+
}
20+
21+
export interface NodeBoxCamera {
22+
x: number
23+
y: number
24+
z: number
25+
}
26+
27+
/**
28+
* Rectangles are drawn in graph space with the camera applied to the context,
29+
* matching how the other canvas layers position themselves.
30+
*
31+
* @returns how many boxes were actually drawn
32+
*/
33+
export function drawNodeBoxes(
34+
ctx: CanvasRenderingContext2D,
35+
boxes: Iterable<NodeBox>,
36+
camera: NodeBoxCamera,
37+
viewport: Bounds,
38+
style: NodeBoxStyle
39+
): number {
40+
const scale = camera.z || 1
41+
42+
ctx.save()
43+
ctx.scale(scale, scale)
44+
ctx.translate(camera.x, camera.y)
45+
46+
let drawn = 0
47+
let currentColor = ''
48+
49+
for (const { bounds, color } of boxes) {
50+
if (!intersects(bounds, viewport)) continue
51+
52+
// Batching by colour avoids a state change per node; most nodes in a graph
53+
// share the default.
54+
const fill = color || style.defaultColor
55+
if (fill !== currentColor) {
56+
ctx.fillStyle = fill
57+
currentColor = fill
58+
}
59+
60+
ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height)
61+
drawn++
62+
}
63+
64+
ctx.restore()
65+
return drawn
66+
}
67+
68+
function intersects(a: Bounds, b: Bounds): boolean {
69+
return !(
70+
a.x + a.width < b.x ||
71+
b.x + b.width < a.x ||
72+
a.y + a.height < b.y ||
73+
b.y + b.height < a.y
74+
)
75+
}

src/renderer/core/spatial/nodeCullingIndex.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export function createNodeCullingIndex({
4444
/** Nodes with no bounds yet; they cannot be indexed, so always report them. */
4545
let unpositioned: NodeId[] = []
4646

47+
/** Retained so callers can iterate bounds without rebuilding them. */
48+
let positionedEntries: CullingIndexEntry[] = []
49+
4750
function rebuild(): void {
4851
const positioned: CullingIndexEntry[] = []
4952
const pending: NodeId[] = []
@@ -67,6 +70,7 @@ export function createNodeCullingIndex({
6770
}
6871

6972
unpositioned = pending
73+
positionedEntries = positioned
7074

7175
if (positioned.length === 0) {
7276
tree = null
@@ -104,6 +108,16 @@ export function createNodeCullingIndex({
104108
return unpositioned.length ? matches.concat(unpositioned) : matches
105109
},
106110

111+
/**
112+
* Every node that has bounds, for callers that iterate the whole set
113+
* rather than query a region. Shares the version-keyed cache, so reading
114+
* it each frame does not rebuild anything.
115+
*/
116+
entries(): readonly CullingIndexEntry[] {
117+
ensureFresh()
118+
return positionedEntries
119+
},
120+
107121
/** Forces a rebuild on next query, for changes the version does not cover. */
108122
invalidate(): void {
109123
built = false

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

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script setup lang="ts">
2+
import { useRafFn } from '@vueuse/core'
3+
import { useTemplateRef } from 'vue'
4+
5+
import { drawNodeBoxes } from '@/renderer/core/canvas/nodeBoxRenderer'
6+
import type { NodeBox } from '@/renderer/core/canvas/nodeBoxRenderer'
7+
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
8+
import type { Bounds } from '@/renderer/core/layout/types'
9+
10+
const { getBoxes } = defineProps<{
11+
/** Boxes to draw, in graph space. Called once per frame. */
12+
getBoxes: () => Iterable<NodeBox>
13+
}>()
14+
15+
const { camera } = useTransformState()
16+
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
17+
18+
/** Visible region in graph space, from the inverse of the camera transform. */
19+
function viewportBounds(width: number, height: number): Bounds {
20+
const scale = camera.z || 1
21+
return {
22+
x: -camera.x,
23+
y: -camera.y,
24+
width: width / scale,
25+
height: height / scale
26+
}
27+
}
28+
29+
useRafFn(() => {
30+
const el = canvasRef.value
31+
if (!el) return
32+
33+
const dpr = window.devicePixelRatio || 1
34+
const width = el.clientWidth
35+
const height = el.clientHeight
36+
if (!width || !height) return
37+
38+
const pixelWidth = Math.round(width * dpr)
39+
const pixelHeight = Math.round(height * dpr)
40+
if (el.width !== pixelWidth || el.height !== pixelHeight) {
41+
el.width = pixelWidth
42+
el.height = pixelHeight
43+
}
44+
45+
const ctx = el.getContext('2d')
46+
if (!ctx) return
47+
48+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
49+
ctx.clearRect(0, 0, width, height)
50+
51+
drawNodeBoxes(ctx, getBoxes(), camera, viewportBounds(width, height), {
52+
// Matches the node surface colour so a zoomed-out graph reads the same as
53+
// a zoomed-in one.
54+
defaultColor:
55+
getComputedStyle(el).getPropertyValue('--node-box-color').trim() ||
56+
'#353535'
57+
})
58+
})
59+
</script>
60+
61+
<template>
62+
<!--
63+
Stands in for the node layer while zoomed out past legibility. Owned and
64+
drawn by the renderer, so a zoomed-out graph does not depend on the canvas
65+
renderer being present.
66+
-->
67+
<canvas
68+
ref="canvasRef"
69+
data-testid="node-box-overlay"
70+
class="pointer-events-none absolute inset-0 size-full [--node-box-color:var(--color-zinc-700)]"
71+
/>
72+
</template>

0 commit comments

Comments
 (0)