perf(glass): reduce long-list interaction overhead (#600)

This commit is contained in:
InfinityPacer
2026-07-30 18:58:49 +08:00
committed by GitHub
parent 55e3a8cba2
commit 097beb6f63
4 changed files with 335 additions and 40 deletions

View File

@@ -1858,6 +1858,108 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('keeps the active and nearby visible clips when a long list exceeds the shader budget', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const outerSurface = appendOpticalSurface('v-card', { height: 2200, width: 900, x: 80, y: -1200 })
outerSurface.dataset.glassOpticalSurface = ''
const cards = Array.from({ length: 12 }, (_, index) => {
const card = document.createElement('article')
card.className = 'app-hover-lift-card'
setOpticalSurfaceBounds(card, {
height: 120,
width: 360,
x: index % 2 === 0 ? 100 : 500,
y: -1100 + Math.floor(index / 2) * 180,
})
outerSurface.append(card)
return card
})
setOpticalSurfaceBounds(cards[10], { height: 120, width: 360, x: 100, y: 620 })
setOpticalSurfaceBounds(cards[11], { height: 120, width: 360, x: 500, y: 620 })
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 560, clientY: 680 }))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uInteractionRects: { value: Array<{ toArray: () => number[] }> }
}
}
}>
}
const rects = scene.children[0].material.uniforms.uInteractionRects.value
.slice(0, scene.children[0].material.uniforms.uInteractionRectCount.value)
.map(rect => rect.toArray())
expect(rects[0][0]).toBeCloseTo(500 / 1200)
expect(rects.some(rect => Math.abs(rect[0] - 100 / 1200) < 0.001 && Math.abs(rect[1] - 60 / 800) < 0.001)).toBe(
true,
)
expect(rects.every(rect => rect[1] >= -96 / 800)).toBe(true)
scope.stop()
})
it('hits cached interaction clips without rescanning nested cards on pointermove', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const outerSurface = appendOpticalSurface('v-card', { height: 500, width: 900, x: 80, y: 100 })
outerSurface.dataset.glassOpticalSurface = ''
const card = document.createElement('article')
card.className = 'app-hover-lift-card'
setOpticalSurfaceBounds(card, { height: 180, width: 360, x: 100, y: 140 })
outerSurface.append(card)
const querySelectorAll = vi.spyOn(outerSurface, 'querySelectorAll')
const measureCard = vi.spyOn(card, 'getBoundingClientRect')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
querySelectorAll.mockClear()
measureCard.mockClear()
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 180, clientY: 200 }))
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 200, clientY: 220 }))
expect(querySelectorAll).not.toHaveBeenCalled()
expect(measureCard).not.toHaveBeenCalled()
ResizeObserverMock.instances.forEach(observer => observer.trigger())
expect(querySelectorAll).not.toHaveBeenCalled()
scope.stop()
})
it('keeps the active texture and ready state when a replacement wallpaper fails', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
@@ -2350,7 +2452,7 @@ describe('glass optical surface discovery', () => {
}
expect(wallpaperSampling.at(-1)).toBe(1)
window.dispatchEvent(new WheelEvent('wheel'))
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 80 }))
expect(wallpaperSampling.at(-1)).toBe(0)
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
@@ -2370,6 +2472,84 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('ignores scroll intent and movement that cannot move a managed glass surface', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
const unrelatedScroller = document.createElement('div')
unrelatedScroller.style.overflowY = 'auto'
Object.defineProperties(unrelatedScroller, {
clientHeight: { configurable: true, value: 200 },
scrollHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0, writable: true },
})
document.body.append(unrelatedScroller)
const unrelatedContent = document.createElement('div')
unrelatedScroller.append(unrelatedContent)
const overlay = document.createElement('div')
overlay.className = 'v-overlay'
const overlayScroller = unrelatedScroller.cloneNode() as HTMLElement
overlay.append(overlayScroller)
document.body.append(overlay)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('high'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
unrelatedContent.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 80 }))
unrelatedContent.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'PageDown' }))
unrelatedScroller.dispatchEvent(new Event('scroll', { bubbles: false }))
overlayScroller.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 80 }))
overlayScroller.dispatchEvent(new Event('scroll', { bubbles: false }))
expect(document.documentElement).not.toHaveAttribute('data-glass-scroll-presentation')
scope.stop()
})
it('starts native scroll presentation for a nested scroller that moves managed surfaces', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const scroller = document.createElement('div')
scroller.style.overflowY = 'auto'
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 900 },
scrollTop: { configurable: true, value: 0, writable: true },
})
document.body.append(scroller)
const surface = appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
scroller.append(surface)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('high'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
surface.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 80 }))
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
scope.stop()
})
it('redraws a high-quality scroll layer without advancing its flow targets', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)

View File

@@ -359,6 +359,11 @@ type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> &
mode: GlassOpticalSurfaceMode
}
interface GlassInteractionClipDescriptor extends GlassOpticalSurfaceDescriptor {
/** 拥有该局部裁剪的顶层材质面。 */
owner: HTMLElement
}
interface PreparedWallpaperTexture {
/** 单次预滤后的低分辨率壁纸;中间 RenderTarget 不跨准备阶段保留。 */
frostedTarget: WebGLRenderTarget | null
@@ -400,6 +405,7 @@ const SURFACE_SELECTORS = [
] as const
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
const INTERACTION_CLIP_SELECTOR = '.app-hover-lift-card'
const INTERACTION_CLIP_OVERSCAN_PX = 96
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
function getSurfacePresentationSpace(
@@ -1377,6 +1383,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
let interactionClipRegistry: GlassInteractionClipDescriptor[] = []
let interactionClipMembershipDirty = true
let activeSurface: HTMLElement | null = null
let activeInteractionClip: HTMLElement | null = null
let outgoingSurface: HTMLElement | null = null
@@ -1430,7 +1438,47 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
renderFrame(performance.now(), false)
}
/** 找到本次输入实际可能推动的最近滚动容器。 */
function resolveScrollIntentTarget(event: Event): Element | null {
const documentScroller = document.scrollingElement ?? document.documentElement
const eventTarget = event.target
if (!(eventTarget instanceof Element)) return documentScroller
if (eventTarget.closest('.v-overlay')) return null
const wheel = event instanceof WheelEvent ? event : null
for (
let candidate: Element | null = eventTarget;
candidate && candidate !== document.body;
candidate = candidate.parentElement
) {
const style = getComputedStyle(candidate)
const canScrollY =
/(auto|overlay|scroll)/.test(style.overflowY) &&
candidate.scrollHeight > candidate.clientHeight &&
(!wheel ||
(wheel.deltaY < 0 && candidate.scrollTop > 0) ||
(wheel.deltaY > 0 && candidate.scrollTop + candidate.clientHeight < candidate.scrollHeight))
const canScrollX =
/(auto|overlay|scroll)/.test(style.overflowX) &&
candidate.scrollWidth > candidate.clientWidth &&
(!wheel ||
(wheel.deltaX < 0 && candidate.scrollLeft > 0) ||
(wheel.deltaX > 0 && candidate.scrollLeft + candidate.clientWidth < candidate.scrollWidth))
if (canScrollX || canScrollY) return candidate
}
return documentScroller
}
/** 只有会移动文档或已管理玻璃表面的滚动才需要切换 scroll 材质路径。 */
function isRelevantScrollTarget(target: EventTarget | null) {
if (!(target instanceof Element) || target === document.documentElement || target === document.body) return true
return observedSurfaces.some(surface => target.contains(surface))
}
function handleScrollIntent(event: Event) {
if (event instanceof WheelEvent && event.deltaX === 0 && event.deltaY === 0) return
if (event instanceof KeyboardEvent) {
const target = event.target
if (
@@ -1442,6 +1490,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (!['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' '].includes(event.key)) return
}
const scrollTarget = resolveScrollIntentTarget(event)
if (!scrollTarget || !isRelevantScrollTarget(scrollTarget)) return
beginNativeScrollPresentation()
}
@@ -1815,12 +1865,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}
const presentation = getCommittedPresentationSize()
const normalizedInteractionClips = interactionClips.map(clip => {
const rect =
clip.key === activeInteractionClip && clip.key.isConnected ? getElementPresentationRect(clip.key) : clip.rect
return normalizeGlassOpticalRect(rect, presentation.width, presentation.height)
})
const normalizedInteractionClips = interactionClips.map(clip =>
normalizeGlassOpticalRect(clip.rect, presentation.width, presentation.height),
)
const uniformInteractionRects = resources.uniforms.uInteractionRects.value
const uniformInteractionRadii = resources.uniforms.uInteractionRadii.value
for (let index = 0; index < 8; index += 1) {
@@ -1871,38 +1918,102 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
* 材质父表面可折叠多个交互卡片;共享动态场只在最终输出时按真实卡片边界裁剪。
* 活动卡优先占用固定预算,其余可见卡片继续消费同一时序场。
*/
function updateInteractionClips() {
function refreshInteractionClipRegistry() {
const seen = new Set<HTMLElement>()
const candidates: GlassOpticalSurfaceDescriptor[] = []
const append = (element: HTMLElement, mode: GlassOpticalSurfaceMode) => {
const candidates: GlassInteractionClipDescriptor[] = []
const append = (
element: HTMLElement,
owner: HTMLElement,
mode: GlassOpticalSurfaceMode,
committedRect?: GlassOpticalRect,
) => {
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
const rect = getElementPresentationRect(element)
const rect = committedRect ?? getElementPresentationRect(element)
if (rect.width < 24 || rect.height < 24) return
seen.add(element)
candidates.push({ key: element, mode, rect })
candidates.push({ key: element, mode, owner, rect })
}
for (const slot of surfaceSlots) {
const mode = slot.mode ?? 'dynamic'
for (const surface of surfaceRegistry) {
const mode = surface.mode ?? 'dynamic'
if (mode === 'static-material') continue
const nestedClips = [
...(slot.key.matches(INTERACTION_CLIP_SELECTOR) ? [slot.key] : []),
...slot.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR),
]
const surfaceIsClip = surface.key.matches(INTERACTION_CLIP_SELECTOR)
if (surfaceIsClip) append(surface.key, surface.key, mode, surface.rect)
const nestedClips = [...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR)]
if (nestedClips.length > 0) {
nestedClips.forEach(clip => append(clip, mode))
} else {
append(slot.key, mode)
nestedClips.forEach(clip => append(clip, surface.key, mode))
} else if (!surfaceIsClip) {
append(surface.key, surface.key, mode, surface.rect)
}
}
const activeIndex = activeInteractionClip
? candidates.findIndex(candidate => candidate.key === activeInteractionClip)
: -1
if (activeIndex > 0) candidates.unshift(...candidates.splice(activeIndex, 1))
interactionClipRegistry = candidates
interactionClipMembershipDirty = false
}
/** 稳定帧复用成员集合;顶层 clip 直接复用 surface collector 已提交的矩形。 */
function refreshInteractionClipGeometry() {
const committedSurfaces = new Map(surfaceRegistry.map(surface => [surface.key, surface]))
interactionClipRegistry = interactionClipRegistry.flatMap(clip => {
if (
!clip.key.isConnected ||
!clip.owner.isConnected ||
resolveGlassOpticalSurfaceMode(clip.key) !== clip.mode ||
!committedSurfaces.has(clip.owner)
) {
return []
}
const rect = committedSurfaces.get(clip.key)?.rect ?? getElementPresentationRect(clip.key)
if (rect.width < 24 || rect.height < 24) return []
return [{ ...clip, rect }]
})
}
function updateInteractionClips() {
const slotKeys = new Set(surfaceSlots.map(slot => slot.key))
const viewportX = presentationSpace === 'scroll' ? window.scrollX : 0
const viewportY = presentationSpace === 'scroll' ? window.scrollY : 0
const interactionPoint = getPresentationPoint(lastPointerX, lastPointerY)
const candidates = interactionClipRegistry
.filter(candidate => slotKeys.has(candidate.owner))
.map(candidate => {
const { rect } = candidate
const visibleWidth = Math.min(viewportX + window.innerWidth, rect.x + rect.width) - Math.max(viewportX, rect.x)
const visibleHeight =
Math.min(viewportY + window.innerHeight, rect.y + rect.height) - Math.max(viewportY, rect.y)
const inOverscan =
rect.x + rect.width >= viewportX - INTERACTION_CLIP_OVERSCAN_PX &&
rect.x <= viewportX + window.innerWidth + INTERACTION_CLIP_OVERSCAN_PX &&
rect.y + rect.height >= viewportY - INTERACTION_CLIP_OVERSCAN_PX &&
rect.y <= viewportY + window.innerHeight + INTERACTION_CLIP_OVERSCAN_PX
const distance = Math.hypot(
rect.x + rect.width * 0.5 - interactionPoint.x,
rect.y + rect.height * 0.5 - interactionPoint.y,
)
return {
candidate,
distance,
visible: visibleWidth >= 24 && visibleHeight >= 24,
inOverscan,
}
})
.filter(entry => entry.candidate.key === activeInteractionClip || entry.inOverscan)
.sort((left, right) => {
const leftActive = left.candidate.key === activeInteractionClip
const rightActive = right.candidate.key === activeInteractionClip
if (leftActive !== rightActive) return leftActive ? -1 : 1
if (left.visible !== right.visible) return left.visible ? -1 : 1
return left.distance - right.distance
})
.map(entry => entry.candidate)
const maxCount = window.innerWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
interactionClips = candidates.slice(0, maxCount)
}
@@ -1947,6 +2058,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
true,
nextObservedSurfaces,
)
if (interactionClipMembershipDirty) refreshInteractionClipRegistry()
else refreshInteractionClipGeometry()
updateVisibleSurfaceUniforms(timestamp)
const observedSurfacesChanged =
@@ -2331,17 +2444,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const surface = availableSurfaces.find(candidate => rectContainsPoint(candidate.rect, x, y))
if (!surface) return null
const interactionClips = [
...(surface.key.matches(INTERACTION_CLIP_SELECTOR) ? [surface.key] : []),
...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR),
]
.filter(element => resolveGlassOpticalSurfaceMode(element) === surface.mode)
.map(element => ({ element, rect: getElementPresentationRect(element) }))
.filter(candidate => rectContainsPoint(candidate.rect, x, y))
const matchingClips = interactionClipRegistry
.filter(candidate => candidate.owner === surface.key && rectContainsPoint(candidate.rect, x, y))
.sort((left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height)
return {
clip: interactionClips[0]?.element ?? surface.key,
clip: matchingClips[0]?.key ?? surface.key,
surface,
}
}
@@ -2352,8 +2460,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
*/
function activateInteractionSurface(surface: HTMLElement, timestamp: number, reducedMotion: boolean) {
if (activeSurface === surface) {
updateInteractionClips()
writeSurfaceUniforms(timestamp)
return false
}
@@ -2732,10 +2838,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function handleScroll(event: Event) {
if (presentationSpace !== 'scroll' || !resources) return
const target = event.target
if (!isRelevantScrollTarget(target)) return
beginNativeScrollPresentation()
scrollFrameCommitted = false
scrollLateGeometryCommitted = false
const target = event.target
const isDocumentScroll =
!(target instanceof Element) || target === document.documentElement || target === document.body
if (!isDocumentScroll) {
@@ -2909,6 +3017,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
if (!mutationTouchesOpticalSurface(mutations)) return
interactionClipMembershipDirty = true
commitActivePagePresentation()
scheduleSurfaceStabilityUpdate()
})
@@ -3006,6 +3115,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
availableSurfaces = []
surfaceSlots = []
interactionClips = []
interactionClipRegistry = []
interactionClipMembershipDirty = true
activeSurface = null
activeInteractionClip = null
outgoingSurface = null

View File

@@ -2,7 +2,7 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import { TransferDirectoryConf, StorageConf } from '@/api/types'
import type { ApiResponse, StorageConf, TransferDirectoryConf } from '@/api/types'
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
import StorageCard from '@/components/cards/StorageCard.vue'
import { useI18n } from 'vue-i18n'
@@ -272,9 +272,13 @@ function removeStorage(storage: StorageConf) {
// 保存设置
async function saveSystemSettings(value: any) {
try {
// 响应拦截器返回业务响应体,并行组合前需收窄 Axios 的静态返回类型。
const [envResult, mountedDiskResult] = await Promise.all([
api.post('system/env', value),
api.post(`system/setting/${mountedLocalDiskDeleteEmptyDirsKey}`, mountedLocalDiskDeleteEmptyDirs.value),
api.post('system/env', value) as unknown as Promise<ApiResponse>,
api.post(
`system/setting/${mountedLocalDiskDeleteEmptyDirsKey}`,
mountedLocalDiskDeleteEmptyDirs.value,
) as unknown as Promise<ApiResponse>,
])
if (envResult.success && mountedDiskResult.success) {
$toast.success(t('setting.directory.organizeSaveSuccess'))