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:
Rizumu Ayaka
2026-03-13 04:30:18 +08:00
committed by GitHub
parent ffda940e5a
commit 8c93567019
9 changed files with 127 additions and 104 deletions

View File

@@ -81,7 +81,6 @@
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
/>
</TransformPane>

View File

@@ -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, {

View File

@@ -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>

View File

@@ -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()

View File

@@ -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
)
}

View File

@@ -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'
})
})

View File

@@ -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'
}))