mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 02:32:18 +00:00
perf: detect pointer drag in useTransformSettling for pan optimization (#9649)
## Summary When many nodes are rendered in the transform container, both zoom and pan can cause FPS drops because the browser re-rasterizes all visible content at the new transform. `will-change: transform` tells the browser to keep the layer as a GPU texture and skip re-rasterization during active interaction, restoring visual quality only after settling. - Add pointer drag detection so `will-change: transform` covers pan in addition to zoom. Without this, dragging with 256+ nodes causes jank as the browser re-rasterizes the entire layer on every frame of the pan. - Fix settleDelay from 16ms to 256ms. At 16ms the debounce fires between consecutive wheel events (~50ms apart on a physical mouse), causing `will-change` to toggle on/off rapidly. Each toggle forces the browser to promote/demote the compositor layer, which is more expensive than not having the optimization at all. - Replace scoped CSS with Tailwind `will-change-transform`. - Remove per-node `will-change: transform` on `.lg-node`. Promoting each node to its own compositor layer (256 nodes = 256 GPU textures) increases memory pressure and compositing overhead, making performance worse than a single promoted container. - Previously, the virtual DOM of Nodes was updated during zooming and dragging, but now this update is avoided through some techniques. - Using the 3D versions of scale and translate can provide a smoother experience when dealing with a large number of nodes. ## Test plan - [x] Unit tests updated and passing - [x] Manual: verify during both zoom and pan - [x] Manual: compare pan FPS with 256 nodes before/after ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9649-perf-detect-pointer-drag-in-useTransformSettling-for-pan-optimization-31e6d73d3650818bb2c3ccd01a465140) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -81,7 +81,6 @@
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
@@ -133,21 +133,6 @@ describe('TransformPane', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove event listeners on unmount', async () => {
|
||||
@@ -166,43 +151,10 @@ describe('TransformPane', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction state management', () => {
|
||||
it('should apply interacting class during interactions', async () => {
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate interaction start by checking internal state
|
||||
// Note: This tests the CSS class application logic
|
||||
const transformPane = wrapper.find('[data-testid="transform-pane"]')
|
||||
|
||||
// Initially should not have interacting class
|
||||
expect(transformPane.classes()).not.toContain(
|
||||
'transform-pane--interacting'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle pointer events for node delegation', async () => {
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
ref="transformPaneRef"
|
||||
data-testid="transform-pane"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 size-full',
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
class="pointer-events-none absolute inset-0 size-full will-change-auto"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
@@ -16,12 +11,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
@@ -33,7 +27,32 @@ const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 16
|
||||
settleDelay: 256
|
||||
})
|
||||
|
||||
const transformPaneRef = useTemplateRef('transformPaneRef')
|
||||
|
||||
/**
|
||||
* Apply transform style and will-change class via direct DOM mutation
|
||||
* instead of reactive template bindings (:style / :class).
|
||||
*
|
||||
* These values change every animation frame during zoom or pan.
|
||||
* If they were bound in the template, Vue would diff the entire
|
||||
* TransformPane vnode—including all child node slots—on every frame,
|
||||
* causing expensive vdom patch work across the full node list.
|
||||
* Mutating the DOM directly limits the update to a single element.
|
||||
*/
|
||||
|
||||
watch([transformStyle, transformPaneRef], ([newStyle, el]) => {
|
||||
if (el) {
|
||||
Object.assign(el.style, newStyle)
|
||||
}
|
||||
})
|
||||
watch([isInteracting, transformPaneRef], ([interacting, el]) => {
|
||||
if (el) {
|
||||
el.classList.toggle('will-change-transform', interacting)
|
||||
el.classList.toggle('will-change-auto', !interacting)
|
||||
}
|
||||
})
|
||||
|
||||
useRafFn(
|
||||
@@ -46,9 +65,3 @@ useRafFn(
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transform-pane--interacting {
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,11 +69,40 @@ describe('useTransformSettling', () => {
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not track pan events', async () => {
|
||||
it('should track pointer drag as pan interaction', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not treat right-click as pan', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
element.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 2 })
|
||||
)
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not track pointermove without pointerdown', async () => {
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
|
||||
// Pointer events should not trigger transform
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
interface TransformSettlingOptions {
|
||||
/**
|
||||
* Delay in ms before transform is considered "settled" after last interaction
|
||||
* @default 200
|
||||
* @default 256
|
||||
*/
|
||||
settleDelay?: number
|
||||
/**
|
||||
@@ -16,10 +16,10 @@ interface TransformSettlingOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks when canvas zoom transforms are actively changing vs settled.
|
||||
* Tracks when canvas transforms (zoom or pan) are actively changing vs settled.
|
||||
*
|
||||
* This composable helps optimize rendering quality during zoom transformations.
|
||||
* When the user is actively zooming, we can reduce rendering quality
|
||||
* This composable helps optimize rendering quality during transform interactions.
|
||||
* When the user is actively zooming or panning, we can reduce rendering quality
|
||||
* for better performance. Once the transform "settles" (stops changing), we can
|
||||
* trigger high-quality re-rasterization.
|
||||
*
|
||||
@@ -50,35 +50,72 @@ export function useTransformSettling(
|
||||
|
||||
const isTransforming = ref(false)
|
||||
|
||||
/**
|
||||
* Mark transform as active
|
||||
*/
|
||||
const markTransformActive = () => {
|
||||
isTransforming.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transform as settled (debounced)
|
||||
*/
|
||||
const markTransformSettled = useDebounceFn(() => {
|
||||
isTransforming.value = false
|
||||
}, settleDelay)
|
||||
|
||||
/**
|
||||
* Handle zoom transform event - mark active then queue settle
|
||||
*/
|
||||
const handleWheel = () => {
|
||||
markTransformActive()
|
||||
function markInteracting() {
|
||||
isTransforming.value = true
|
||||
void markTransformSettled()
|
||||
}
|
||||
|
||||
// Register wheel event listener with auto-cleanup
|
||||
useEventListener(target, 'wheel', handleWheel, {
|
||||
capture: true,
|
||||
passive
|
||||
})
|
||||
const eventOptions = { capture: true, passive }
|
||||
|
||||
useEventListener(target, 'wheel', markInteracting, eventOptions)
|
||||
usePointerDrag(target, markInteracting, eventOptions)
|
||||
|
||||
return {
|
||||
isTransforming
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onDrag` on each pointermove while a pointer is held down.
|
||||
*/
|
||||
function usePointerDrag(
|
||||
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
|
||||
onDrag: () => void,
|
||||
eventOptions: AddEventListenerOptions
|
||||
) {
|
||||
/** Number of active pointers (supports multi-touch correctly). */
|
||||
const pointerCount = ref(0)
|
||||
|
||||
useEventListener(
|
||||
target,
|
||||
'pointerdown',
|
||||
(e: PointerEvent) => {
|
||||
// Only primary (0) and middle (1) buttons trigger canvas pan.
|
||||
if (e.button === 0 || e.button === 1) pointerCount.value++
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
target,
|
||||
'pointermove',
|
||||
() => {
|
||||
if (pointerCount.value > 0) onDrag()
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
// Listen on window so the release is caught even if the pointer
|
||||
// leaves the canvas before the button is released.
|
||||
useEventListener(
|
||||
window,
|
||||
'pointerup',
|
||||
() => {
|
||||
if (pointerCount.value > 0) pointerCount.value--
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
window,
|
||||
'pointercancel',
|
||||
() => {
|
||||
if (pointerCount.value > 0) pointerCount.value--
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('useTransformState', () => {
|
||||
it('should generate correct initial transform style', () => {
|
||||
const { transformStyle } = transformState
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transform: 'scale3d(1, 1, 1) translate3d(0px, 0px, 0)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
@@ -102,7 +102,7 @@ describe('useTransformState', () => {
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(0.5) translate(150px, 75px)',
|
||||
transform: 'scale3d(0.5, 0.5, 0.5) translate3d(150px, 75px, 0)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,7 +79,9 @@ function useTransformStateIndividual() {
|
||||
// ctx.scale(scale); ctx.translate(offset)
|
||||
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
|
||||
// Effective mapping: screen = (canvas + offset) * scale
|
||||
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
|
||||
// Using the 3D versions of scale and translate can provide a smoother experience
|
||||
// when dealing with a large number of nodes.
|
||||
transform: `scale3d(${camera.z}, ${camera.z}, ${camera.z}) translate3d(${camera.x}px, ${camera.y}px, 0)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user